Compare commits

..

45 Commits

Author SHA1 Message Date
Matthieu
3df0b15fe7 docs : update CLAUDE.md with BookStackConfiguration and TaskBookStackLink entities
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m15s
Ticket: T-019

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
8040245e45 feat(ui) : make kanban column headers sticky with scrollable content
Give kanban containers a fixed viewport height. Column headers stay fixed
while task cards scroll independently within each column.

Ticket: LST-28

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
5d378c1f75 refactor(frontend) : replace any types with concrete TypeScript types
Replace 9 occurrences of 'any' with proper types: HydraCollection, Task,
ClientTicketWrite, TimeEntryWrite across 7 components.

Ticket: T-023

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
8544babf8c refactor(i18n) : replace hardcoded French strings with i18n keys
Replace 30+ hardcoded strings across 15 components with $t() calls.
Added keys for common actions, drawers titles, empty states, and modals.

Ticket: T-020

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
455121132d feat(frontend) : admin middleware, fix avatar upload, centralize IRI extraction, remove Nitro proxy
- Add admin middleware protecting /admin page (ROLE_ADMIN check)
- Fix useAvatarService to use useApi() with FormData detection
- Create extractIdFromIri() utility, replace manual IRI parsing
- Remove redundant Nitro devProxy (Vite proxy handles dev)

Tickets: T-014, T-015, T-017, T-021

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
fd3097cc26 chore(backend) : rate limiting, cache-control, remove twig, clean deps
- Add login_throttling on /login_check (5 attempts/min) with symfony/rate-limiter
- Add Cache-Control: public, max-age=86400 on avatar responses
- Remove symfony/twig-bundle (unused in API-only project)
- Remove unused dev deps: symfony/browser-kit, symfony/css-selector
- Rename API Platform title to "Lesstime API"

Tickets: T-010, T-016, T-022, T-024, T-025

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
ff7cff1d39 fix(backend) : add validation constraints and fix concurrent numbering
- Add Assert\Choice on ClientTicket type and status with typed constants
- Add Assert\Url on GiteaConfiguration, BookStackConfiguration, TaskBookStackLink, ClientTicket
- Fix concurrent task/ticket numbering: use pg_advisory_xact_lock instead of FOR UPDATE with MAX()
- Wrap CreateTaskTool numbering in transaction
- Harmonize repository contracts: both return max number, caller adds +1

Tickets: T-004, T-008, T-011, T-012

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
ed58a402b0 fix(auth) : use dedicated plainPassword field for password hashing
- Add non-persisted plainPassword field to User entity (write-only via API)
- Remove direct write access to password field
- Update UserPasswordHasherProcessor to hash from plainPassword
- Update frontend DTO and UserDrawer component

Ticket: T-009

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
2ac815d074 fix(security) : block SVG upload, enforce ROLE_CLIENT restrictions on documents
- Block SVG MIME type in TaskDocumentProcessor upload validation
- Serve existing SVG files as attachment (defense-in-depth) in download controller
- Block ROLE_CLIENT from uploading documents to tasks (only allowed via portal tickets)
- Add Doctrine extension to filter projects by allowedProjects for ROLE_CLIENT

Tickets: T-003, T-005, T-006

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
e0dfcbdbf8 fix(security) : add role checks on Gitea API resources and all MCP tools
- GiteaBranch, GiteaBranchName, GiteaPullRequest: require ROLE_USER
- All 22 MCP tools: require ROLE_USER (ROLE_ADMIN for users/clients listing)

Tickets: T-002, T-007

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
Matthieu
5db6b1e2b0 fix(security) : replace real secrets in .env with placeholders and create .env.example
Secrets moved to .env.local (gitignored). Added .env.example for new developers.
Also added .idea/ and docker/.env.docker.local to .gitignore and removed them from tracking.

Tickets: T-001, T-013, T-018

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:27:16 +01:00
gitea-actions
6e29aeb30f chore: bump version to v0.2.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m22s
2026-03-17 09:38:00 +00:00
Matthieu
cca548dfbc chore : bump version to 0.2.5 and fix MCP session directory
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Move MCP session storage from cache dir to var/mcp-sessions
so it survives cache:clear operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:36:04 +01:00
Matthieu
3d4b7fad12 fix(mcp) : allow unauthenticated GET on /_mcp for SSE streaming
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Build Release Artefact / build (push) Failing after 1m16s
Claude Code MCP HTTP client sends GET SSE requests without the
Authorization header, breaking the streamable HTTP transport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 09:15:29 +01:00
Matthieu
5ffb4bbedc chore : bump version to 0.2.3 and add Monolog logging
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m22s
Add symfony/monolog-bundle with rotating file logs in dev (7 days)
and fingers_crossed + rotating file in prod (30 days).
Deploy script now ensures var/log/ permissions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:52:06 +01:00
Matthieu
d2e9f9ed65 chore : bump version to 0.2.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:35:08 +01:00
Matthieu
c5898fbf74 feat(ui) : add create task button on my-tasks and responsive kanban columns
- Add "Créer une tâche" button on my-tasks page with mandatory project selector
- TaskModal now accepts optional projects prop for project selection in create mode
- Replace fixed-width kanban columns (w-72 shrink-0) with flexible layout (min-w-36 flex-1)
- Add min-w-0 and overflow-x-hidden on default layout to properly contain content
- Kanban now adapts to screen size from 1024px to 1920px+

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:35:02 +01:00
Matthieu
0180dd3715 chore : bump version to 0.2.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:41:38 +01:00
Matthieu
0f99098291 chore : bump version to 0.2.0 and update deploy doc
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m25s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:29:41 +01:00
Matthieu
1c6f473dff feat(mcp) : add clientTicket relation to time entries
Add ManyToOne relation from TimeEntry to ClientTicket entity.
MCP tools create-time-entry, update-time-entry, and list-time-entries
now support clientTicketId parameter for linking tickets to time entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:28:31 +01:00
Matthieu
c95fff530c docs(deploy) : add deployment guide and MCP connection tutorial
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:28:31 +01:00
gitea-actions
fb0e6c1ea4 chore: bump version to v0.1.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m19s
2026-03-16 08:52:02 +00:00
Matthieu
6d3ecc1322 Merge branch 'feature/client-portal' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 09:51:48 +01:00
Matthieu
f5986090c0 feat(deploy) : add deploy script and nginx config for bare Ubuntu server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:51:29 +01:00
Matthieu
d6399c20e1 fix : fix MCP create-task tool crashing on task creation
CreateTaskTool called nonexistent findMaxNumberByProject instead of
findMaxNumberByProjectForUpdate. Also removed FOR UPDATE clause from the
query as PostgreSQL does not support it with aggregate functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:26:36 +01:00
Matthieu
a972d243f5 style : center and resize view toggle buttons on my-tasks page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:26:34 +01:00
Matthieu
56bf88f293 fix : prevent document delete button from submitting the TaskModal form
The delete button in TaskDocumentList lacked type="button", causing it to
act as a submit button inside the form, which triggered handleSubmit and
closed the modal before the confirmation dialog could appear. Also added
guards to prevent closing TaskModal while a sub-modal is open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:26:32 +01:00
9d80e017c2 docs : complete architecture tree in README with all directories
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:27:52 +01:00
4e91507158 docs : rewrite README with full project documentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:25:42 +01:00
318f14ea88 docs : update CLAUDE.md with avatar feature context and gotchas
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:21:12 +01:00
202b516dc3 fix(avatar) : install symfony/mime for server-side MIME type detection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:13:18 +01:00
98782a9849 fix(avatar) : add explicit import for useAvatarService in profile page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:12:38 +01:00
b978adf9ae fix(avatar) : move avatar service to composables for Nuxt auto-import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:11:03 +01:00
e4fc34b90f refactor : simplify codebase and fix critical issues
Backend:
- Add MCP Serializer to centralize entity-to-array conversion (~300 lines deduped)
- Fix race condition in task/ticket number generation (SELECT FOR UPDATE + transaction)
- Add unique constraint on task (project_id, number) with migration
- Fix MIME type validation: use server-detected finfo instead of client-supplied type
- Add allowlist of permitted MIME types for uploads
- Fix TaskDocumentDownloadController: allow ROLE_CLIENT access, add priority:1
- Fix notification sent even when ticket status unchanged
- Remove redundant exception constructors
- Simplify services (BookStackApi double fetch, TokenEncryptor, GiteaApi)
- Consolidate duplicate checks in processors

Frontend:
- Fix useApi isHandlingUnauthorized scope (module-level to prevent double 401 redirect)
- Fix client-tickets toast key copy-paste bug
- Merge duplicated tasks service methods (getByProject + getByProjectArchived)
- Extract shared uploadWithRelation helper in task-documents service
- Extract formatFileSize utility from duplicated component code
- Extract status transition logic into useClientTicketHelpers composable
- Remove dead code (unused router, handleLogout, empty script blocks)
- Merge duplicate watchers and onMounted calls
- Normalize arrow functions to function declarations per convention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:09:16 +01:00
a5144443a4 fix(avatar) : address review findings — security and UX fixes
- Use getMimeType() instead of getClientMimeType() to prevent MIME spoofing
- Change IsGranted to IS_AUTHENTICATED_FULLY so ROLE_CLIENT can access avatars
- Remove Groups from avatarFileName (only avatarUrl needed by frontend)
- Disable aggressive caching to prevent stale avatar images
- Add error handling to avatar upload in profile page
- Use i18n for "Mon profil" button text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:02:27 +01:00
afd4baed92 feat(avatar) : replace initials with UserAvatar component everywhere
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:58:46 +01:00
e8f0202b15 feat(avatar) : add profile page with avatar upload and crop
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:57:55 +01:00
962b3d935c feat(avatar) : add AvatarCropper modal with vue-advanced-cropper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:56:11 +01:00
cea22f977b feat(avatar) : add UserAvatar component with image/initials fallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:55:52 +01:00
5613a7c92b feat(avatar) : add avatar service, DTO update, and cropper dependency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:55:39 +01:00
4d0aa65920 feat(avatar) : add avatar upload/serve/delete controller
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:54:23 +01:00
63315c0a15 feat(avatar) : add avatarFileName field to User entity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:53:43 +01:00
cff16611f4 docs : add user avatar implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:50:07 +01:00
96f5c7c91c docs : add user avatar feature design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:47:38 +01:00
gitea-actions
4216f1b5a1 chore: bump version to v0.1.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-15 18:07:23 +00:00
144 changed files with 4124 additions and 1457 deletions

6
.env
View File

@@ -1,5 +1,5 @@
APP_ENV=dev
APP_SECRET="a64f5614357bf56aecb1d7470e431535"
APP_SECRET="change_me_in_env_local"
APP_DEBUG=1
DEFAULT_URI=http://localhost/
@@ -11,7 +11,7 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=c2dbeec8fa8255bdab24e88b9fc1e57927740c429ae3b930d03e51b92e13a85f
JWT_PASSPHRASE=change_me_in_env_local
JWT_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
@@ -20,4 +20,4 @@ JWT_COOKIE_TTL=86400
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
ENCRYPTION_KEY=aaaaaaaaa
ENCRYPTION_KEY=change_me_in_env_local

99
.env.example Normal file
View File

@@ -0,0 +1,99 @@
###############################################################################
# Lesstime — Fichier d'environnement de reference
#
# Copiez ce fichier en .env.local et remplissez les valeurs sensibles.
# Les valeurs par defaut dans .env suffisent pour le developpement ;
# seuls les secrets (APP_SECRET, JWT_PASSPHRASE, ENCRYPTION_KEY) doivent
# etre definis dans .env.local.
#
# Ne commitez JAMAIS de vrais secrets dans .env ou .env.example.
###############################################################################
# ===========================================================================
# App
# ===========================================================================
# Environnement Symfony : dev, test, prod
APP_ENV=dev
# Secret applicatif Symfony (32 chars hex) — a generer pour chaque installation
# Generer avec : php -r "echo bin2hex(random_bytes(16));"
APP_SECRET="change_me_in_env_local"
# Active/desactive le mode debug (1 = oui, 0 = non)
APP_DEBUG=1
# URI par defaut de l'application (utilise pour les liens absolus)
DEFAULT_URI=http://localhost/
# ===========================================================================
# CORS (nelmio/cors-bundle)
# ===========================================================================
# Origines autorisees pour les requetes cross-origin (regex)
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
# ===========================================================================
# JWT (lexik/jwt-authentication-bundle)
# ===========================================================================
# Chemin vers la cle privee RSA pour signer les tokens JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
# Chemin vers la cle publique RSA pour verifier les tokens JWT
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
# Passphrase de la cle privee JWT — a generer pour chaque installation
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
JWT_PASSPHRASE=change_me_in_env_local
# Cookie securise (1 = HTTPS uniquement, 0 = HTTP autorise — dev seulement)
JWT_COOKIE_SECURE=0
# Duree de vie du token JWT en secondes (86400 = 24h)
JWT_TOKEN_TTL=86400
# Duree de vie du cookie JWT en secondes (86400 = 24h)
JWT_COOKIE_TTL=86400
# ===========================================================================
# Base de donnees (Doctrine / PostgreSQL)
# ===========================================================================
# Les variables POSTGRES_* sont definies dans docker/.env.docker
# et injectees automatiquement par Docker Compose.
# DATABASE_URL est construite a partir de ces variables.
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
# ===========================================================================
# Chiffrement
# ===========================================================================
# Cle de chiffrement pour les donnees sensibles (64 chars hex = 256 bits)
# Generer avec : php -r "echo bin2hex(random_bytes(32));"
ENCRYPTION_KEY=change_me_in_env_local
# ===========================================================================
# Docker (docker/.env.docker)
#
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
# surcharger localement.
# ===========================================================================
# DOCKER_APP_NAME=lesstime
# DOCKER_PHP_VERSION=8.4.6
# DOCKER_NODE_VERSION=24.12.0
# APP_USER=www-data
# POSTGRES_DB=lesstime
# POSTGRES_USER=root
# POSTGRES_PASSWORD=root
# POSTGRES_PORT=5435
# XDEBUG_CLIENT_HOST=host.docker.internal
# ===========================================================================
# Frontend (frontend/.env)
# ===========================================================================
# Base URL de l'API pour le client Nuxt (relative, proxifiee par Nginx)
# NUXT_PUBLIC_API_BASE=/api

View File

@@ -45,6 +45,7 @@ jobs:
set -euo pipefail
mkdir -p release
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
.env \
bin \
config \
migrations \

8
.gitignore vendored
View File

@@ -22,3 +22,11 @@
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
###> ide ###
.idea/
###< ide ###
###> docker local ###
docker/.env.docker.local
###< docker local ###

10
.idea/.gitignore generated vendored
View File

@@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

8
.idea/Lesstime.iml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:9cad43df-2147-4989-b7a4-443067034884&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:f407a514-c6b4-4b26-9555-445a85892502&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>
</project>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="386cba74:19cc24e9181:-799b" />
</MTProjectMetadataState>
</option>
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Lesstime.iml" filepath="$PROJECT_DIR$/.idea/Lesstime.iml" />
</modules>
</component>
</project>

20
.idea/php.xml generated
View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -12,11 +12,11 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
## Structure
```
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument)
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink)
src/ApiResource/ # Ressources API Platform (si découplées des entités)
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor)
src/Service/ # Services métier (NotificationService)
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController)
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
src/Command/ # Commandes console (GenerateApiTokenCommand)
@@ -28,10 +28,10 @@ migrations/ # Migrations Doctrine
docs/plans/ # Plans d'implémentation
docs/superpowers/ # Plans et specs superpowers
frontend/ # App Nuxt 4
frontend/pages/ # Pages (index, login, my-tasks, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
frontend/layouts/ # Layouts (default, portal)
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/)
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers)
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
frontend/stores/ # Stores Pinia (auth, ui, timer)
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents)
frontend/services/dto/ # Types TypeScript
@@ -80,6 +80,8 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}`
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime`
- Auth endpoints mixtes (ROLE_USER + ROLE_CLIENT) : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
### Frontend

190
README.md
View File

@@ -1,10 +1,173 @@
# Lesstime
Application de gestion de projet. Symfony 8 + API Platform 4 + Nuxt 4.
Application de gestion de projet avec suivi du temps et portail client.
## MCP Server
## Stack
Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistants IA (Claude Code, ChatGPT, Codex) d'interagir avec les projets, tâches et le suivi du temps.
| Couche | Technologies |
|--------|-------------|
| **Backend** | PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM |
| **Frontend** | Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS |
| **Base de données** | PostgreSQL 16 |
| **Auth** | JWT HTTP-only cookie (lexik/jwt-authentication-bundle) |
| **Infrastructure** | Docker (PHP-FPM, Nginx, PostgreSQL) |
## Fonctionnalités
- Gestion de projets et tâches (kanban, groupes, priorités, tags, efforts)
- Suivi du temps (timer, calendrier, vue liste)
- Portail client avec tickets (bug, amélioration, autre)
- Gestion de documents (upload, prévisualisation, téléchargement)
- Profil utilisateur avec avatar (crop circulaire)
- Notifications temps réel
- Intégration Gitea (issues, repos)
- Serveur MCP pour assistants IA
- Multi-langue (i18n)
## Prérequis
- Docker & Docker Compose
- Git
## Installation
```bash
# 1. Cloner le repo
git clone <url> && cd lesstime
# 2. Démarrer les containers
make start
# 3. Installation complète (composer, migrations, fixtures, build Nuxt)
make install
```
L'application est accessible sur **http://localhost:8082**.
### Comptes de test (fixtures)
| Utilisateur | Mot de passe | Rôle | Détails |
|-------------|-------------|------|---------|
| `admin` | `admin` | ROLE_ADMIN | Administrateur |
| `alice` | `alice` | ROLE_USER | Utilisateur interne |
| `bob` | `bob` | ROLE_USER | Utilisateur interne |
| `charlie` | `charlie` | ROLE_USER | Utilisateur interne |
| `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) |
| `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) |
## Commandes
### Docker
```bash
make start # Démarrer les containers
make stop # Arrêter les containers
make restart # Redémarrer les containers
make shell # Shell dans le container PHP
make shell-root # Shell root dans le container PHP
```
### Développement
```bash
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
make cache-clear # Vider le cache Symfony
make logs-dev # Tail logs Symfony
```
### Base de données
```bash
make migration-migrate # Lancer les migrations
make fixtures # Charger les fixtures
make db-reset # Reset BDD + migrations + fixtures (⚠️ supprime les données)
```
### Tests & Qualité
```bash
make test # PHPUnit
make php-cs-fixer-allow-risky # Fix code style PHP (Symfony + PSR-12)
```
### Installation complète
```bash
make install # Composer + migrations + fixtures + build Nuxt
make reset # Tout supprimer et réinstaller (⚠️ supprime la BDD)
```
## Architecture
```
src/
├── Entity/ # Entités Doctrine
├── ApiResource/ # Ressources API Platform (découplées)
├── State/ # Providers et Processors API Platform
├── Controller/ # Controllers custom Symfony
├── Service/ # Services métier
├── EventListener/ # Listeners Doctrine
├── Exception/ # Exceptions custom
├── Security/ # Authenticators custom
├── Repository/ # Repositories Doctrine
├── Command/ # Commandes console
├── DataFixtures/ # Fixtures
└── Mcp/Tool/ # MCP tools par domaine
├── Project/
├── Task/
├── TaskMeta/
├── TimeEntry/
└── Reference/
frontend/
├── pages/ # Pages Nuxt (routing auto)
│ ├── portal/ # Pages portail client
│ └── projects/ # Pages projets
├── layouts/ # Layouts (default, portal)
├── components/ # Composants Vue
│ ├── ui/ # Composants génériques
│ ├── task/ # Tâches
│ ├── user/ # Utilisateur (avatar, etc.)
│ ├── project/ # Projets
│ ├── client/ # Clients
│ ├── client-ticket/ # Tickets client
│ ├── admin/ # Administration
│ ├── notification/ # Notifications
│ └── time-tracking/ # Suivi du temps
├── composables/ # Composables (useApi, useNotifications, etc.)
├── stores/ # Stores Pinia (auth, ui, timer)
├── services/ # Services API
│ └── dto/ # Types TypeScript
├── plugins/ # Plugins Nuxt
├── utils/ # Utilitaires
├── i18n/locales/ # Traductions
└── middleware/ # Middleware auth
config/ # Config Symfony
migrations/ # Migrations Doctrine
docker/ # Dockerfiles et config Nginx
```
## Docker
| Container | Port | Description |
|-----------|------|-------------|
| `php-lesstime-fpm` | 3002 (dev Nuxt) | PHP-FPM + Node 24 |
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
| PostgreSQL | 5435 | Base de données |
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`)
## API
Toutes les routes API sont préfixées `/api` (API Platform).
- Documentation auto-générée : **http://localhost:8082/api**
- Auth : `POST /login_check` avec `{ username, password }` → cookie JWT `BEARER`
## Serveur MCP
Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistants IA d'interagir avec les données.
### Tools disponibles (22)
@@ -16,13 +179,6 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
| TaskMeta | `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group` |
| TimeEntry | `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` |
### Transports
| Transport | Usage | Auth |
|-----------|-------|------|
| **STDIO** | Claude Code sur la machine locale | Aucune |
| **HTTP** (`/_mcp`) | Clients MCP sur le réseau local | API token (`Authorization: Bearer <token>`) |
### Configuration locale (STDIO)
```json
@@ -55,17 +211,19 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
### Gestion des tokens API
```bash
# Générer un token pour un utilisateur
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
```
### Mise en production (réseau local)
## Déploiement
1. Déployer le code sur le serveur
2. `composer install --no-dev --optimize-autoloader`
3. `php bin/console doctrine:migrations:migrate --no-interaction`
4. `php bin/console cache:clear --env=prod`
5. `docker restart nginx-lesstime`
6. `php bin/console app:generate-api-token admin` — noter le token
7. Ouvrir le port 8082 sur le firewall du serveur (LAN uniquement)
8. Configurer les clients MCP avec l'URL `http://<ip-serveur>:8082/_mcp` + le token
5. `cd frontend && npm install && npm run build:dist`
6. `docker restart nginx-lesstime`
7. Ouvrir le port 8082 sur le firewall (LAN uniquement)
## Licence
Propriétaire — Tous droits réservés.

View File

@@ -25,12 +25,14 @@
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/mcp-bundle": "^0.6.0",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*",
"symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*"
},
@@ -89,8 +91,6 @@
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*",
"symfony/css-selector": "8.0.*"
"phpunit/phpunit": "^13.0"
}
}

1131
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,12 +10,11 @@ use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
return [
FrameworkBundle::class => ['all' => true],
TwigBundle::class => ['all' => true],
SecurityBundle::class => ['all' => true],
DoctrineBundle::class => ['all' => true],
DoctrineMigrationsBundle::class => ['all' => true],
@@ -24,4 +23,5 @@ return [
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
McpBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
];

View File

@@ -1,5 +1,5 @@
api_platform:
title: Hello API Platform
title: Lesstime API
version: 1.0.0
formats:
jsonld: ['application/ld+json']

View File

@@ -19,5 +19,5 @@ mcp:
path: /_mcp
session:
store: file
directory: '%kernel.cache_dir%/mcp-sessions'
directory: '%kernel.project_dir%/var/mcp-sessions'
ttl: 3600

View File

@@ -0,0 +1,56 @@
monolog:
channels:
- deprecation
when@dev:
monolog:
handlers:
main:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 7
channels: ["!event"]
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!deprecation"]
buffer_size: 50
nested:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 30
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: rotating_file
channels: [deprecation]
path: "%kernel.logs_dir%/deprecations.log"
max_files: 7

View File

@@ -22,6 +22,9 @@ security:
pattern: ^/login_check
stateless: true
provider: app_user_provider
login_throttling:
max_attempts: 5
interval: '1 minute'
json_login:
check_path: /login_check
username_path: username
@@ -59,6 +62,7 @@ security:
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Version de l'application en public
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }

View File

@@ -1,6 +0,0 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@@ -8,6 +8,7 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
imports:
- { resource: version.yaml }
@@ -39,3 +40,7 @@ services:
App\Controller\TaskDocumentDownloadController:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Controller\UserAvatarController:
arguments:
$avatarUploadDir: '%avatar_upload_dir%'

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.0'
app.version: '0.2.6'

View File

@@ -0,0 +1,50 @@
server {
listen 80;
listen [::]:80;
server_name project.malio-dev.fr;
root /var/www/lesstime/frontend/.output/public;
index index.html;
client_max_body_size 55m;
location ^~ /api/ {
root /var/www/lesstime/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/lesstime/public;
try_files $uri =404;
}
location = /api/login_check {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO /login_check;
fastcgi_param REQUEST_URI /login_check;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
location ^~ /_mcp {
root /var/www/lesstime/public;
try_files $uri /index.php?$query_string;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,9 +0,0 @@
DOCKER_APP_NAME=lesstime
DOCKER_PHP_VERSION=8.4.6
DOCKER_NODE_VERSION=24.12.0
APP_USER=www-data
POSTGRES_DB=lesstime
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5435
XDEBUG_CLIENT_HOST=192.168.0.124

213
docs/deploy.md Normal file
View File

@@ -0,0 +1,213 @@
# Deploiement sur serveur Ubuntu (sans Docker)
## Prerequis
- Ubuntu 22.04+ avec PHP 8.4, Node 24, PostgreSQL 16, Nginx
- Acces root ou sudo sur le serveur
## 1. Preparer la BDD
```bash
sudo -u postgres createuser lesstime
sudo -u postgres createdb -O lesstime lesstime
sudo -u postgres psql -c "ALTER USER lesstime WITH PASSWORD 'ton-mdp';"
```
## 2. Creer les dossiers
```bash
sudo mkdir -p /var/www/lesstime/var/log /var/www/lesstime/var/cache /var/www/lesstime/config/jwt
sudo chown -R www-data:www-data /var/www/lesstime
```
## 3. Configurer l'environnement
```bash
sudo nano /var/www/lesstime/.env
```
Contenu minimal :
```ini
APP_ENV=prod
```
```bash
sudo nano /var/www/lesstime/.env.local
```
Contenu :
```ini
APP_ENV=prod
APP_SECRET=<random-hex-32>
APP_DEBUG=0
DEFAULT_URI=http://project.malio-dev.fr/
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
DATABASE_URL="postgresql://lesstime:<mdp>@localhost:5432/lesstime?serverVersion=16&charset=utf8"
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<passphrase>
JWT_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
ENCRYPTION_KEY=<random-hex-32>
```
> `JWT_COOKIE_SECURE=0` car HTTP. Passer a `1` si HTTPS.
## 4. Installer le script de deploy
```bash
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
sudo chmod +x /usr/local/bin/deploy-lesstime
```
Si le repo Gitea est prive, configurer un token :
```bash
echo "ton-token-gitea" | sudo tee /etc/lesstime-release-token
sudo chmod 600 /etc/lesstime-release-token
```
## 5. Deployer une release
```bash
sudo /usr/local/bin/deploy-lesstime v0.2.1
```
Le script telecharge l'artefact, extrait les fichiers, clear le cache et lance les migrations.
## 6. Generer les cles JWT
```bash
cd /var/www/lesstime
sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --env=prod
```
## 7. Configurer Nginx
```bash
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
## 8. Creer le premier user admin
Hasher un mot de passe :
```bash
php /var/www/lesstime/bin/console security:hash-password --env=prod
```
Choisir `App\Entity\User`, taper le mdp, copier le hash. Puis :
```bash
sudo -u postgres psql lesstime -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
```
## 9. Tester
```bash
curl http://project.malio-dev.fr/api/version
curl http://project.malio-dev.fr/
```
---
# Connecter le serveur MCP a Claude Code
Le serveur MCP expose 22 tools (projets, taches, time tracking avec liaison tickets client, metadonnees) via le endpoint HTTP `/_mcp`.
## 1. Generer un token API
Sur le serveur (ou en local via Docker) :
```bash
# Production (serveur)
php /var/www/lesstime/bin/console app:generate-api-token admin --env=prod
# Dev (Docker)
docker exec -it php-lesstime-fpm php bin/console app:generate-api-token admin
```
La commande affiche un token de 64 caracteres. Ce token est lie a l'utilisateur et stocke en base (champ `apiToken` de l'entite `User`).
## 2. Configurer Claude Code
### Transport HTTP (recommande pour la prod)
Creer ou modifier `.mcp.json` a la racine du projet :
```json
{
"mcpServers": {
"lesstime": {
"type": "http",
"url": "http://project.malio-dev.fr/_mcp",
"headers": {
"Authorization": "Bearer <ton-token>"
}
}
}
}
```
### Transport STDIO (dev local via Docker)
```json
{
"mcpServers": {
"lesstime-local": {
"command": "docker",
"args": [
"exec",
"-i",
"php-lesstime-fpm",
"php",
"bin/console",
"mcp:server"
]
}
}
}
```
### Transport STDIO via SSH (prod sans endpoint HTTP)
```json
{
"mcpServers": {
"lesstime": {
"command": "ssh",
"args": [
"user@serveur",
"php",
"/var/www/lesstime/bin/console",
"mcp:server",
"--env=prod"
]
}
}
}
```
## 3. Redemarrer Claude Code
Apres modification de `.mcp.json`, relancer Claude Code pour qu'il detecte le serveur.
## 4. Verifier
Demander a Claude d'utiliser un outil MCP, par exemple :
- "Liste les projets sur Lesstime"
- "Cree une tache dans le projet LT"
## Tools disponibles
| Domaine | Tools |
|---------|-------|
| Projets | list-projects, get-project, create-project, update-project |
| Taches | list-tasks, get-task, create-task, update-task, delete-task |
| Metadonnees | list-statuses, list-priorities, list-efforts, list-tags, list-groups, create-group, update-group |
| Time tracking | list-time-entries, create-time-entry, update-time-entry, delete-time-entry (supporte clientTicketId) |
| Reference | list-users, list-clients |

View File

@@ -0,0 +1,802 @@
# User Avatar Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users upload a cropped profile avatar that replaces initials everywhere in the app.
**Architecture:** New `avatarFileName` column on User entity, dedicated upload/serve/delete controllers, `UserAvatar.vue` component with `vue-advanced-cropper` for circular crop, and a `/profile` page for management.
**Tech Stack:** PHP 8.4/Symfony 8, Doctrine migration, `vue-advanced-cropper`, Nuxt 4 SPA
---
## File Structure
### Backend (create)
- `src/Controller/UserAvatarController.php` — upload, serve, delete avatar (3 routes)
### Backend (modify)
- `src/Entity/User.php` — add `avatarFileName` field + `getAvatarUrl()` virtual getter
- `config/services.yaml` — add `avatar_upload_dir` parameter + wire controller
### Frontend (create)
- `frontend/components/user/UserAvatar.vue` — reusable avatar display (image or initials fallback)
- `frontend/components/user/AvatarCropper.vue` — crop modal using `vue-advanced-cropper`
- `frontend/services/avatar.ts` — avatar API service (upload, remove, getUrl)
- `frontend/pages/profile.vue` — profile page with avatar management
### Frontend (modify)
- `frontend/services/dto/user-data.ts` — add `avatarUrl` to `UserData`
- `frontend/stores/auth.ts` — add `refreshUser()` action
- `frontend/components/ui/AppTopNav.vue` — use `UserAvatar` + link "Mon profil" to `/profile`
- `frontend/components/task/TaskCard.vue:47-59` — replace initials with `UserAvatar`
- `frontend/pages/projects/[id]/archives.vue:49-55` — replace initials with `UserAvatar`
- `frontend/components/admin/AdminClientTicketTab.vue:82` — use `UserAvatar` for submitter
- `frontend/middleware/auth.global.ts` — allow `/profile` for all authenticated users
---
## Task 1: Backend — User entity + migration
**Files:**
- Modify: `src/Entity/User.php`
- Create: migration file (generated)
- [ ] **Step 1: Add `avatarFileName` field to User entity**
In `src/Entity/User.php`, add after the `$apiToken` field:
```php
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['me:read', 'user:list'])]
private ?string $avatarFileName = null;
```
Add getter/setter:
```php
public function getAvatarFileName(): ?string
{
return $this->avatarFileName;
}
public function setAvatarFileName(?string $avatarFileName): static
{
$this->avatarFileName = $avatarFileName;
return $this;
}
```
Add virtual `avatarUrl` getter (serialized, read-only):
```php
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])]
public function getAvatarUrl(): ?string
{
if (null === $this->avatarFileName) {
return null;
}
return '/api/users/' . $this->id . '/avatar';
}
```
- [ ] **Step 2: Generate and run migration**
```bash
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:diff
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/User.php migrations/
git commit -m "feat(avatar) : add avatarFileName field to User entity"
```
---
## Task 2: Backend — Avatar controller
**Files:**
- Create: `src/Controller/UserAvatarController.php`
- Modify: `config/services.yaml`
- [ ] **Step 1: Add `avatar_upload_dir` parameter in `config/services.yaml`**
Add to `parameters:` section:
```yaml
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
```
Add service wiring:
```yaml
App\Controller\UserAvatarController:
arguments:
$avatarUploadDir: '%avatar_upload_dir%'
```
- [ ] **Step 2: Create `UserAvatarController.php`**
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
class UserAvatarController extends AbstractController
{
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly string $avatarUploadDir,
) {}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_upload', methods: ['POST'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function upload(int $id, Request $request): JsonResponse
{
$user = $this->findUserOrFail($id);
$this->assertCanManageAvatar($user);
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('No valid file uploaded.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('File size exceeds 5 MB limit.');
}
$mimeType = $file->getClientMimeType();
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException('Invalid file type. Allowed: JPEG, PNG, WebP, GIF.');
}
// Delete previous avatar file if exists
$this->deleteAvatarFile($user);
$extension = $file->guessExtension() ?? 'bin';
$fileName = Uuid::v4()->toRfc4122() . '.' . $extension;
if (!is_dir($this->avatarUploadDir)) {
mkdir($this->avatarUploadDir, 0o775, true);
}
$file->move($this->avatarUploadDir, $fileName);
$user->setAvatarFileName($fileName);
$this->entityManager->flush();
return new JsonResponse(['avatarUrl' => $user->getAvatarUrl()]);
}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_serve', methods: ['GET'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function serve(int $id): BinaryFileResponse
{
$user = $this->findUserOrFail($id);
if (null === $user->getAvatarFileName()) {
throw new NotFoundHttpException('No avatar set.');
}
$filePath = $this->avatarUploadDir . '/' . $user->getAvatarFileName();
if (!file_exists($filePath)) {
throw new NotFoundHttpException('Avatar file not found on disk.');
}
$response = new BinaryFileResponse($filePath);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $user->getAvatarFileName());
$extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION);
$mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'];
$response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream');
$response->headers->set('Cache-Control', 'public, max-age=86400');
return $response;
}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_delete', methods: ['DELETE'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function delete(int $id): Response
{
$user = $this->findUserOrFail($id);
$this->assertCanManageAvatar($user);
$this->deleteAvatarFile($user);
$user->setAvatarFileName(null);
$this->entityManager->flush();
return new Response(null, Response::HTTP_NO_CONTENT);
}
private function findUserOrFail(int $id): User
{
$user = $this->entityManager->getRepository(User::class)->find($id);
if (null === $user) {
throw new NotFoundHttpException('User not found.');
}
return $user;
}
private function assertCanManageAvatar(User $user): void
{
$currentUser = $this->getUser();
if ($currentUser !== $user && !$this->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('You can only manage your own avatar.');
}
}
private function deleteAvatarFile(User $user): void
{
if (null === $user->getAvatarFileName()) {
return;
}
$filePath = $this->avatarUploadDir . '/' . $user->getAvatarFileName();
if (file_exists($filePath)) {
unlink($filePath);
}
}
}
```
- [ ] **Step 3: Commit**
```bash
git add src/Controller/UserAvatarController.php config/services.yaml
git commit -m "feat(avatar) : add avatar upload/serve/delete controller"
```
---
## Task 3: Frontend — Install vue-advanced-cropper + DTO + service
**Files:**
- Modify: `frontend/services/dto/user-data.ts`
- Create: `frontend/services/avatar.ts`
- Modify: `frontend/stores/auth.ts`
- [ ] **Step 1: Install vue-advanced-cropper**
```bash
cd frontend && npm install vue-advanced-cropper
```
- [ ] **Step 2: Update `UserData` DTO**
In `frontend/services/dto/user-data.ts`, add `avatarUrl` to `UserData`:
```typescript
export type UserData = {
id: number
'@id'?: string
username: string
roles: string[]
client?: { id: number; name: string } | null
allowedProjects?: Project[]
avatarUrl?: string | null
}
```
- [ ] **Step 3: Create `frontend/services/avatar.ts`**
```typescript
export function useAvatarService() {
const api = useApi()
async function upload(userId: number, file: Blob): Promise<{ avatarUrl: string }> {
const formData = new FormData()
formData.append('file', file, 'avatar.png')
return $fetch(`/api/users/${userId}/avatar`, {
method: 'POST',
body: formData,
credentials: 'include',
})
}
async function remove(userId: number): Promise<void> {
await api.delete(`/users/${userId}/avatar`)
}
function getUrl(userId: number): string {
return `/api/users/${userId}/avatar`
}
return { upload, remove, getUrl }
}
```
- [ ] **Step 4: Add `refreshUser` to auth store**
In `frontend/stores/auth.ts`, add to actions:
```typescript
async refreshUser() {
try {
const me = await getCurrentUser()
this.user = me
} catch {
// Silently fail — user session might have expired
}
}
```
- [ ] **Step 5: Commit**
```bash
git add frontend/package.json frontend/package-lock.json frontend/services/dto/user-data.ts frontend/services/avatar.ts frontend/stores/auth.ts
git commit -m "feat(avatar) : add avatar service, DTO update, and cropper dependency"
```
---
## Task 4: Frontend — UserAvatar component
**Files:**
- Create: `frontend/components/user/UserAvatar.vue`
- [ ] **Step 1: Create `UserAvatar.vue`**
```vue
<template>
<span
class="inline-flex shrink-0 items-center justify-center rounded-full"
:class="sizeClasses"
:title="user.username"
>
<img
v-if="user.avatarUrl && !imgError"
:src="user.avatarUrl"
:alt="user.username"
class="h-full w-full rounded-full object-cover"
@error="imgError = true"
/>
<span
v-else
class="flex h-full w-full items-center justify-center rounded-full bg-primary-500 font-bold text-white"
:class="textSizeClass"
>
{{ user.username.substring(0, 2).toUpperCase() }}
</span>
</span>
</template>
<script setup lang="ts">
const props = defineProps<{
user: { id?: number; username: string; avatarUrl?: string | null }
size?: 'xs' | 'sm' | 'md' | 'lg'
}>()
const imgError = ref(false)
watch(() => props.user.avatarUrl, () => {
imgError.value = false
})
const sizeClasses = computed(() => {
const map = {
xs: 'h-5 w-5',
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
return map[props.size ?? 'sm']
})
const textSizeClass = computed(() => {
const map = {
xs: 'text-[10px]',
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
}
return map[props.size ?? 'sm']
})
</script>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/components/user/UserAvatar.vue
git commit -m "feat(avatar) : add UserAvatar component with image/initials fallback"
```
---
## Task 5: Frontend — AvatarCropper component
**Files:**
- Create: `frontend/components/user/AvatarCropper.vue`
- [ ] **Step 1: Create `AvatarCropper.vue`**
```vue
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<h3 class="mb-4 text-lg font-bold text-neutral-900">
{{ $t('profile.cropAvatar') }}
</h3>
<div class="mx-auto mb-4 h-72 w-72">
<Cropper
ref="cropperRef"
:src="imageSrc"
:stencil-component="CircleStencil"
:stencil-props="{ aspectRatio: 1 }"
:canvas="{ width: 256, height: 256 }"
class="h-full w-full"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
@click="emit('cancel')"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
:disabled="cropping"
@click="onConfirm"
>
{{ $t('common.confirm') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
const props = defineProps<{
imageFile: File
}>()
const emit = defineEmits<{
(e: 'crop', blob: Blob): void
(e: 'cancel'): void
}>()
const cropperRef = ref()
const cropping = ref(false)
const imageSrc = ref('')
onMounted(() => {
imageSrc.value = URL.createObjectURL(props.imageFile)
})
onUnmounted(() => {
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
}
})
async function onConfirm() {
cropping.value = true
try {
const { canvas } = cropperRef.value.getResult()
if (!canvas) return
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
if (blob) {
emit('crop', blob)
}
} finally {
cropping.value = false
}
}
</script>
```
- [ ] **Step 2: Commit**
```bash
git add frontend/components/user/AvatarCropper.vue
git commit -m "feat(avatar) : add AvatarCropper modal with vue-advanced-cropper"
```
---
## Task 6: Frontend — Profile page
**Files:**
- Create: `frontend/pages/profile.vue`
- Modify: `frontend/middleware/auth.global.ts`
- [ ] **Step 1: Create `frontend/pages/profile.vue`**
```vue
<template>
<div class="mx-auto max-w-lg px-4 py-10">
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
<!-- Current avatar -->
<UserAvatar
v-if="auth.user"
:user="auth.user"
size="lg"
/>
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
<div class="flex gap-3">
<label
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
>
{{ $t('profile.changeAvatar') }}
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="hidden"
@change="onFileSelect"
/>
</label>
<button
v-if="auth.user?.avatarUrl"
type="button"
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
:disabled="removing"
@click="onRemove"
>
{{ $t('profile.removeAvatar') }}
</button>
</div>
</div>
<!-- Crop modal -->
<AvatarCropper
v-if="selectedFile"
:image-file="selectedFile"
@crop="onCrop"
@cancel="selectedFile = null"
/>
</div>
</template>
<script setup lang="ts">
const auth = useAuthStore()
const { upload, remove } = useAvatarService()
const selectedFile = ref<File | null>(null)
const removing = ref(false)
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
selectedFile.value = file
}
input.value = ''
}
async function onCrop(blob: Blob) {
selectedFile.value = null
if (!auth.user) return
await upload(auth.user.id, blob)
await auth.refreshUser()
}
async function onRemove() {
if (!auth.user) return
removing.value = true
try {
await remove(auth.user.id)
await auth.refreshUser()
} finally {
removing.value = false
}
}
</script>
```
- [ ] **Step 2: Allow `/profile` for ROLE_CLIENT in middleware**
In `frontend/middleware/auth.global.ts`, update the client redirect block to also allow `/profile`:
Change:
```typescript
if (!isPortalRoute && !isLoginRoute) {
```
To:
```typescript
const isProfileRoute = to.path === '/profile'
if (!isPortalRoute && !isLoginRoute && !isProfileRoute) {
```
- [ ] **Step 3: Add i18n keys**
In `frontend/i18n/locales/fr.json`, add under a `"profile"` key:
```json
"profile": {
"title": "Mon profil",
"changeAvatar": "Changer l'avatar",
"removeAvatar": "Supprimer l'avatar",
"cropAvatar": "Recadrer l'avatar"
}
```
- [ ] **Step 4: Commit**
```bash
git add frontend/pages/profile.vue frontend/middleware/auth.global.ts frontend/i18n/locales/fr.json
git commit -m "feat(avatar) : add profile page with avatar upload and crop"
```
---
## Task 7: Frontend — Replace initials everywhere
**Files:**
- Modify: `frontend/components/ui/AppTopNav.vue`
- Modify: `frontend/components/task/TaskCard.vue`
- Modify: `frontend/pages/projects/[id]/archives.vue`
- Modify: `frontend/components/admin/AdminClientTicketTab.vue`
- [ ] **Step 1: Update `AppTopNav.vue`**
Replace the icon + username display (lines 12-14):
```vue
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
```
With:
```vue
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
<Icon v-else name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
```
Make "Mon profil" button navigate to `/profile`:
```vue
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
@click="navigateTo('/profile')"
>
Mon profil
</button>
```
- [ ] **Step 2: Update `TaskCard.vue`**
Replace lines 47-59 (the assignee initials span + empty state):
```vue
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
size="xs"
class="ml-auto"
/>
<span
v-else
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
>
<Icon name="mdi:account-outline" size="14" />
</span>
```
- [ ] **Step 3: Update `archives.vue`**
Replace lines 49-55 (the assignee initials span):
```vue
<UserAvatar
v-if="task.assignee"
:user="task.assignee"
size="xs"
/>
```
- [ ] **Step 4: Update `AdminClientTicketTab.vue`**
Replace the submitter `<td>` at line 82. The `getSubmitterName` function returns a username string. We need to look up the full user to get `avatarUrl`. Modify the function and display:
Change the `<td>`:
```vue
<td class="px-3 py-3 text-neutral-600">
<div class="flex items-center gap-2">
<UserAvatar
v-if="getSubmitterUser(ticket.submittedBy)"
:user="getSubmitterUser(ticket.submittedBy)!"
size="sm"
/>
{{ getSubmitterName(ticket.submittedBy) }}
</div>
</td>
```
Add helper function:
```typescript
function getSubmitterUser(iri: string | null): UserData | undefined {
if (!iri) return undefined
const match = iri.match(/\/api\/users\/(\d+)/)
if (!match) return undefined
const id = Number(match[1])
return users.value.find(u => u.id === id)
}
```
- [ ] **Step 5: Commit**
```bash
git add frontend/components/ui/AppTopNav.vue frontend/components/task/TaskCard.vue frontend/pages/projects/[id]/archives.vue frontend/components/admin/AdminClientTicketTab.vue
git commit -m "feat(avatar) : replace initials with UserAvatar component everywhere"
```
---
## Task 8: Manual testing
- [ ] **Step 1: Rebuild and test**
```bash
make dev-nuxt
```
- [ ] **Step 2: Test flow**
1. Login as `admin` / `admin`
2. Navigate to profile via header dropdown → "Mon profil"
3. Upload an image → verify crop modal appears with circular stencil
4. Confirm crop → verify avatar appears on profile page
5. Check header — avatar should replace the icon
6. Navigate to a project board — assignee cards should show avatar
7. Navigate to archives — same check
8. Go to admin ticket tab — submitter should show avatar + name
9. Remove avatar → verify initials return everywhere
10. Login as `client-liot` / `client` → verify profile page accessible from portal
- [ ] **Step 3: Final commit if any fixes needed**

View File

@@ -0,0 +1,112 @@
# User Avatar — Design Spec
## Goal
Allow users to upload a profile avatar image (with client-side circular crop) that replaces initials everywhere in the app.
## Backend
### Entity Changes
**User** — add nullable field:
- `avatarFileName: ?string` (length 255) — UUID-based filename stored on disk
### Storage
- Directory: `var/uploads/avatars/`
- Parameter in `services.yaml`: `avatar_upload_dir`
### Endpoints
All under `/api/users/{id}/avatar`:
| Method | Description | Auth |
|--------|-------------|------|
| `POST` | Upload avatar (multipart file) | Owner or ROLE_ADMIN |
| `GET` | Serve avatar image (inline) | ROLE_USER or ROLE_CLIENT |
| `DELETE` | Remove avatar | Owner or ROLE_ADMIN |
**POST** accepts a single `file` field. Validates: image MIME (jpeg, png, webp, gif), max 5 MB. Stores with UUID filename, updates `avatarFileName`. Deletes previous file if exists.
**GET** returns the image with proper `Content-Type`. Returns 404 if no avatar.
**DELETE** removes file from disk, sets `avatarFileName` to null.
These are custom Symfony controllers (not API Platform resources) under `/api/` with `priority: 1`.
### Serialization
Add a virtual `avatarUrl` field to User serialization (group `user:read`):
- If `avatarFileName` is set: `/api/users/{id}/avatar`
- If null: `null`
This way the frontend knows if an avatar exists from any user payload.
### Migration
- Add `avatar_file_name` column (VARCHAR 255, nullable) to `user` table.
## Frontend
### New Components
**`UserAvatar.vue`** (`frontend/components/user/UserAvatar.vue`):
- Props: `user: { id: number, username: string, avatarUrl?: string | null }`, `size: 'xs' | 'sm' | 'md' | 'lg'`
- Sizes: xs=20px, sm=24px, md=32px, lg=48px
- If `avatarUrl`: `<img>` rounded-full, object-cover
- Else: initials badge (current bg-primary-500 style), 2 first chars of username uppercased
- Handles `@error` on img to fallback to initials (broken image)
**`AvatarCropper.vue`** (`frontend/components/user/AvatarCropper.vue`):
- Uses `vue-advanced-cropper` with `CircleStencil`
- Props: `imageFile: File`
- Emits: `crop(blob: Blob)`, `cancel`
- Fixed output size: 256x256px
- Modal overlay with crop area + confirm/cancel buttons
### New Page
**`/profile`** (`frontend/pages/profile.vue`):
- Shows current avatar (large) with "Change" button
- File input triggers AvatarCropper modal
- On confirm: POST blob to `/api/users/{id}/avatar`
- On success: refresh auth store user data
- "Remove avatar" button if avatar exists
- Accessible from "Mon profil" button in AppTopNav dropdown
### New Service
**`frontend/services/avatar.ts`**:
- `upload(userId: number, file: Blob): Promise<void>` — POST multipart
- `remove(userId: number): Promise<void>` — DELETE
- `getUrl(userId: number): string` — returns URL path
### DTO Update
**`UserData`** — add: `avatarUrl?: string | null`
### Replacement Points
Replace initials/icon with `<UserAvatar>` in:
| File | Current display | Size |
|------|----------------|------|
| `TaskCard.vue:48-53` | Initials badge (h-5 w-5) | xs |
| `archives.vue:50-55` | Initials badge (h-5 w-5) | xs |
| `AppTopNav.vue:13` | `mdi:account-circle-outline` icon | md |
| `AdminClientTicketTab.vue` | Username text for submitter | sm |
| `ClientTicketDetailModal.vue` | submittedBy display | sm |
### Auth Store
After avatar upload/delete, re-fetch current user data so `avatarUrl` updates everywhere reactively.
## Dependencies
- `vue-advanced-cropper` — npm install in frontend/
## Out of Scope
- Server-side image processing/resize
- Multiple image formats conversion
- Avatar for clients (entities), only users

View File

@@ -10,15 +10,13 @@
input-class="w-full"
/>
<div>
<MalioInputText
v-model="form.tokenId"
:label="$t('bookstack.settings.tokenId')"
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
input-class="w-full"
type="password"
/>
</div>
<MalioInputText
v-model="form.tokenId"
:label="$t('bookstack.settings.tokenId')"
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
input-class="w-full"
type="password"
/>
<div>
<MalioInputText

View File

@@ -79,7 +79,16 @@
</span>
</td>
<td class="px-3 py-3 text-neutral-600">{{ getProjectName(ticket.project) }}</td>
<td class="px-3 py-3 text-neutral-600">{{ getSubmitterName(ticket.submittedBy) }}</td>
<td class="px-3 py-3 text-neutral-600">
<div class="flex items-center gap-2">
<UserAvatar
v-if="getSubmitterUser(ticket.submittedBy)"
:user="getSubmitterUser(ticket.submittedBy)!"
size="sm"
/>
{{ getSubmitterName(ticket.submittedBy) }}
</div>
</td>
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
<td class="px-3 py-3">
<div class="flex items-center gap-2">
@@ -216,7 +225,7 @@ const { t } = useI18n()
const clientTicketService = useClientTicketService()
const projectService = useProjectService()
const userService = useUserService()
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
const tickets = ref<ClientTicket[]>([])
const projects = ref<Project[]>([])
@@ -261,36 +270,29 @@ const detailTicket = ref<ClientTicket | null>(null)
const availableStatusTransitions = computed(() => {
if (!statusTarget.value) return []
const current = statusTarget.value.status
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
{ label: t('clientTicket.status.new'), value: 'new' },
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
{ label: t('clientTicket.status.done'), value: 'done' },
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
]
// Filter out forbidden transitions
return allStatuses.filter(s => {
if (s.value === current) return false
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
return true
})
return getAvailableStatusTransitions(statusTarget.value.status, t)
})
function getProjectName(iri: string): string {
const match = iri.match(/\/api\/projects\/(\d+)/)
if (!match) return ''
const id = Number(match[1])
const id = extractIdFromIri(iri)
if (!id) return ''
return projects.value.find(p => p.id === id)?.name ?? ''
}
function getSubmitterName(iri: string | null): string {
if (!iri) return '-'
const match = iri.match(/\/api\/users\/(\d+)/)
if (!match) return ''
const id = Number(match[1])
const id = extractIdFromIri(iri)
if (!id) return ''
return users.value.find(u => u.id === id)?.username ?? ''
}
function getSubmitterUser(iri: string | null): UserData | undefined {
if (!iri) return undefined
const id = extractIdFromIri(iri)
if (!id) return undefined
return users.value.find(u => u.id === id)
}
function openDetail(ticket: ClientTicket) {
detailTicket.value = ticket
detailOpen.value = true

View File

@@ -191,7 +191,7 @@
</template>
<script setup lang="ts">
import type { ClientTicket } from '~/services/dto/client-ticket'
import type { ClientTicket, ClientTicketWrite } from '~/services/dto/client-ticket'
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { useClientTicketService } from '~/services/client-tickets'
@@ -243,7 +243,7 @@ const canEdit = computed(() => {
if (!sub) return false
// submittedBy can be an IRI string or an embedded object
if (typeof sub === 'string') return sub === `/api/users/${userId}`
if (typeof sub === 'object' && 'id' in sub) return (sub as any).id === userId
if (typeof sub === 'object' && 'id' in sub) return (sub as { id: number }).id === userId
return false
})
@@ -270,7 +270,7 @@ async function saveEdit() {
if (props.ticket.type === 'bug') {
data.url = editForm.url || null
}
await clientTicketService.update(props.ticket.id, data as any)
await clientTicketService.update(props.ticket.id, data as Partial<ClientTicketWrite>)
isEditing.value = false
emit('refresh')
} finally {

View File

@@ -211,7 +211,7 @@ const props = defineProps<{
const { t } = useI18n()
const clientTicketService = useClientTicketService()
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
const isOpen = ref(false)
const isLoading = ref(false)
@@ -238,18 +238,7 @@ const isUpdatingStatus = ref(false)
const availableStatusTransitions = computed(() => {
if (!statusTarget.value) return []
const current = statusTarget.value.status
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
{ label: t('clientTicket.status.new'), value: 'new' },
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
{ label: t('clientTicket.status.done'), value: 'done' },
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
]
return allStatuses.filter(s => {
if (s.value === current) return false
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
return true
})
return getAvailableStatusTransitions(statusTarget.value.status, t)
})
async function loadTickets() {

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un client' : 'Ajouter un client'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.code"

View File

@@ -117,7 +117,7 @@ async function loadItems() {
const [g, t, at] = await Promise.all([
groupService.getByProject(props.projectId),
taskService.getByProject(props.projectId),
taskService.getByProjectArchived(props.projectId),
taskService.getByProject(props.projectId, true),
])
allGroups.value = g
activeTasks.value = t

View File

@@ -44,13 +44,12 @@
>
{{ tag.label }}
</span>
<span
<UserAvatar
v-if="task.assignee"
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
:title="task.assignee.username"
>
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
</span>
:user="task.assignee"
size="xs"
class="ml-auto"
/>
<span
v-else
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"

View File

@@ -28,12 +28,13 @@
<!-- File info -->
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
<p class="text-xs text-neutral-400">{{ formatSize(doc.size) }}</p>
<p class="text-xs text-neutral-400">{{ formatFileSize(doc.size) }}</p>
</div>
<!-- Delete button -->
<button
v-if="isAdmin"
type="button"
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
@click.stop="$emit('delete', doc)"
>
@@ -47,6 +48,7 @@
<script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
defineProps<{
documents: TaskDocument[]
@@ -72,9 +74,4 @@ function getIconForMime(mimeType: string): string {
return 'heroicons:paper-clip'
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}
</script>

View File

@@ -56,7 +56,7 @@
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
<p class="text-sm text-neutral-400">{{ formatSize(document.size) }}</p>
<p class="text-sm text-neutral-400">{{ formatFileSize(document.size) }}</p>
<a
:href="downloadUrl"
download
@@ -77,6 +77,7 @@
<script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
const props = defineProps<{
document: TaskDocument | null
@@ -98,12 +99,6 @@ const downloadUrl = computed(() => props.document ? getDownloadUrl(props.documen
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}
// Focus overlay for keyboard events
watch(() => props.document, (doc) => {
if (doc) {

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un ticket' : 'Ajouter un ticket'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('tasks.editTask') : $t('tasks.addTask')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.title"
@@ -267,7 +267,7 @@ async function handleArchive() {
if (timerStore.activeEntry?.task) {
const taskIri = typeof timerStore.activeEntry.task === 'string'
? timerStore.activeEntry.task
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop()
}

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un effort' : 'Ajouter un effort'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.title"

View File

@@ -24,7 +24,7 @@
{{ task.project.code }}-{{ task.number }}
</span>
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
{{ isEditing ? 'Modifier un ticket' : 'Ajouter un ticket' }}
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
</h2>
</div>
<button
@@ -65,6 +65,20 @@
@blur="touched.title = true"
/>
<!-- Project select (create mode with project list) -->
<div v-if="showProjectSelect" class="mt-4">
<MalioSelect
v-model="form.projectId"
:options="projectOptions"
label="Projet *"
empty-option-label="Sélectionner un projet"
min-width="w-full"
/>
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
Le projet est requis
</p>
</div>
<!-- Two-column selects -->
<div class="mt-4 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<MalioSelect
@@ -154,7 +168,7 @@
/>
<TaskDocumentList
v-if="isEditing && task"
:documents="documents"
:documents="localDocuments"
:is-admin="isAdmin"
@preview="openPreview"
@delete="handleDeleteDocument"
@@ -164,7 +178,7 @@
<TaskDocumentPreview
:document="previewDoc"
:has-prev="previewIndex > 0"
:has-next="previewIndex < documents.length - 1"
:has-next="previewIndex < localDocuments.length - 1"
@close="previewDoc = null"
@prev="prevPreview"
@next="nextPreview"
@@ -266,6 +280,8 @@ import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks'
import type { Project } from '~/services/dto/project'
const props = defineProps<{
modelValue: boolean
task: Task | null
@@ -276,6 +292,7 @@ const props = defineProps<{
tags: TaskTag[]
groups: TaskGroup[]
users: UserData[]
projects?: Project[]
}>()
const emit = defineEmits<{
@@ -289,6 +306,7 @@ const isOpen = computed({
})
function close() {
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
isOpen.value = false
}
@@ -317,10 +335,12 @@ const form = reactive({
groupId: null as number | null,
tagIds: [] as number[],
clientTicketId: null as number | null,
projectId: null as number | null,
})
const touched = reactive({
title: false,
project: false,
})
const statusOptions = computed(() =>
@@ -339,8 +359,22 @@ const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const groupOptions = computed(() =>
props.groups.map(g => ({ label: g.title, value: g.id }))
const groupOptions = computed(() => {
let filtered = props.groups
if (showProjectSelect.value && form.projectId) {
filtered = filtered.filter(g => g.project?.id === form.projectId)
}
return filtered.map(g => ({ label: g.title, value: g.id }))
})
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
const projectOptions = computed(() =>
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
)
const resolvedProjectId = computed(() =>
showProjectSelect.value ? form.projectId : props.projectId
)
const canArchive = computed(() => {
@@ -384,18 +418,35 @@ function populateForm(task: Task | null) {
form.groupId = null
form.tagIds = []
form.clientTicketId = null
form.projectId = null
}
touched.title = false
touched.project = false
}
watch(() => props.modelValue, async (open) => {
if (open) {
confirmDeleteDocOpen.value = false
documentToDelete.value = null
populateForm(props.task)
try {
clientTickets.value = await clientTicketService.getAll({ project: props.projectId })
} catch {
const pid = resolvedProjectId.value
if (pid) {
try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = []
}
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
try {
const settings = await getGiteaSettings()
giteaUrl.value = settings.url ?? ''
} catch {
// Gitea not available
}
}
}
})
@@ -405,17 +456,6 @@ watch(() => props.task, (task) => {
}
})
watch(() => props.modelValue, async (open) => {
if (open && props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
try {
const settings = await getGiteaSettings()
giteaUrl.value = settings.url ?? ''
} catch {
// Gitea not available
}
}
})
const { create, update, remove } = useTaskService()
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
const clientTicketService = useClientTicketService()
@@ -426,6 +466,22 @@ const clientTicketOptions = computed(() =>
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')}${ct.title}`, value: ct.id }))
)
// Reset group and reload client tickets when project changes in create mode
watch(() => form.projectId, async (pid) => {
if (!showProjectSelect.value) return
form.groupId = null
form.clientTicketId = null
if (pid) {
try {
clientTickets.value = await clientTicketService.getAll({ project: pid })
} catch {
clientTickets.value = []
}
} else {
clientTickets.value = []
}
})
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
@@ -440,7 +496,6 @@ function ticketStatusClass(status: string): string {
}
const localDocuments = ref<TaskDocument[]>([])
const documents = computed(() => localDocuments.value)
const previewDoc = ref<TaskDocument | null>(null)
// Sync documents from task prop when modal opens or task changes
@@ -455,7 +510,7 @@ async function refreshDocuments() {
const previewIndex = computed(() => {
if (!previewDoc.value) return -1
return documents.value.findIndex(d => d.id === previewDoc.value!.id)
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
})
function openPreview(doc: TaskDocument) {
@@ -464,13 +519,13 @@ function openPreview(doc: TaskDocument) {
function prevPreview() {
if (previewIndex.value > 0) {
previewDoc.value = documents.value[previewIndex.value - 1]
previewDoc.value = localDocuments.value[previewIndex.value - 1]
}
}
function nextPreview() {
if (previewIndex.value < documents.value.length - 1) {
previewDoc.value = documents.value[previewIndex.value + 1]
if (previewIndex.value < localDocuments.value.length - 1) {
previewDoc.value = localDocuments.value[previewIndex.value + 1]
}
}
@@ -513,7 +568,7 @@ async function handleArchive() {
if (timerStore.activeEntry?.task) {
const taskIri = typeof timerStore.activeEntry.task === 'string'
? timerStore.activeEntry.task
: (timerStore.activeEntry.task as any)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as any)?.id}`
: (timerStore.activeEntry.task as Task)?.['@id'] ?? `/api/tasks/${(timerStore.activeEntry.task as Task)?.id}`
if (taskIri === `/api/tasks/${props.task.id}`) {
await timerStore.stop()
}
@@ -542,7 +597,9 @@ async function handleUnarchive() {
async function handleSubmit() {
touched.title = true
touched.project = true
if (!form.title.trim()) return
if (showProjectSelect.value && !form.projectId) return
isSubmitting.value = true
try {
@@ -554,7 +611,7 @@ async function handleSubmit() {
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${props.projectId}`,
project: `/api/projects/${resolvedProjectId.value}`,
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
}

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier une priorité' : 'Ajouter une priorité'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un statut' : 'Ajouter un statut'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un tag' : 'Ajouter un tag'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"

View File

@@ -21,7 +21,7 @@
<!-- Full display: title + project + type dot + duration -->
<template v-if="sizeLevel >= 3">
<div class="flex items-center gap-1">
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div>
<span class="ml-auto shrink-0 text-[10px] tabular-nums opacity-80">{{ duration }}</span>
</div>
<div v-if="entry.project" class="truncate text-[10px] opacity-80">{{ entry.project.name }}</div>
@@ -39,13 +39,13 @@
<!-- Medium: title + duration -->
<template v-else-if="sizeLevel === 2">
<div class="font-semibold truncate">{{ entry.title || 'Sans titre' }}</div>
<div class="font-semibold truncate">{{ entry.title || $t('common.untitled') }}</div>
<div class="text-[10px] tabular-nums opacity-80">{{ duration }}</div>
</template>
<!-- Small: title only -->
<template v-else-if="sizeLevel === 1">
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || 'Sans titre' }}</div>
<div class="font-semibold truncate text-[10px] leading-tight">{{ entry.title || $t('common.untitled') }}</div>
</template>
<!-- Tiny: just a colored bar, no text -->

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un temps' : 'Ajouter une Activité'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
@@ -117,7 +117,7 @@
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
@@ -257,7 +257,7 @@ async function onSubmit() {
if (isEditing.value && props.entry) {
await update(props.entry.id, payload)
} else {
await create(payload as any)
await create(payload as TimeEntryWrite)
}
emit('saved')

View File

@@ -1,7 +1,7 @@
<template>
<div class="space-y-2">
<div v-if="entries.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 py-12 text-center text-sm text-neutral-400">
Aucune activité pour cette période
{{ $t('timeEntries.noEntries') }}
</div>
<div
@@ -20,7 +20,7 @@
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate text-sm font-semibold text-neutral-900">
{{ entry.title || 'Sans titre' }}
{{ entry.title || $t('common.untitled') }}
</span>
<span
v-for="tag in entry.tags"
@@ -56,7 +56,7 @@
<!-- Delete action -->
<button
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
title="Supprimer"
:title="$t('common.delete')"
@click.stop="emit('deleteEntry', entry)"
>
<Icon name="mdi:delete-outline" size="18" />

View File

@@ -99,7 +99,7 @@
:style="{ backgroundColor: entry.project?.color ?? '#94a3b8' }"
/>
<div class="min-w-0">
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || 'Sans titre' }}</div>
<div class="truncate text-xs font-medium text-neutral-800">{{ entry.title || $t('common.untitled') }}</div>
<div class="text-[10px] text-neutral-500">
{{ formatTime(entry.startedAt) }} {{ entry.stoppedAt ? formatTime(entry.stoppedAt) : '...' }}
</div>
@@ -141,6 +141,8 @@
<script setup lang="ts">
import type { TimeEntry } from '~/services/dto/time-entry'
const { t } = useI18n()
const props = defineProps<{
entries: TimeEntry[]
startDate: Date
@@ -459,7 +461,7 @@ function onMoveStart(payload: { entry: TimeEntry; offsetY: number }, sourceDayIn
dragState.value = {
entryId: entry.id,
entry,
title: entry.title || 'Sans titre',
title: entry.title || t('common.untitled'),
color: entry.project?.color ?? '#94a3b8',
durationMinutes,
ghostHeightPx: Math.max((durationMinutes / 60) * hourHeight, 20),

View File

@@ -10,14 +10,16 @@
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<NotificationBell />
<div class="group relative flex gap-2 sm:gap-4">
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
<Icon v-else name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
@click="navigateTo('/profile')"
>
Mon profil
{{ $t('profile.title') }}
</button>
<button
type="button"
@@ -43,7 +45,7 @@ defineProps<{
const auth = useAuthStore()
const ui = useUiStore()
const handleLogout = async () => {
async function handleLogout() {
await auth.logout()
await navigateTo('/login')
}

View File

@@ -2,7 +2,7 @@
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.confirmDeleteTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">

View File

@@ -4,19 +4,18 @@
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">Supprimer le statut « {{ statusLabel }} »</h3>
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ taskCount }} tâche{{ taskCount > 1 ? 's sont liées' : ' est liée' }} à ce statut.
Choisissez les déplacer :
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
</p>
<div class="mt-4">
<MalioSelect
v-model="targetStatusId"
:options="targetOptions"
label="Déplacer vers"
empty-option-label="Backlog (sans statut)"
:label="$t('taskStatuses.moveTo')"
:empty-option-label="$t('taskStatuses.backlog')"
min-width="w-full"
/>
</div>
@@ -27,7 +26,7 @@
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="cancel"
>
Annuler
{{ $t('common.cancel') }}
</button>
<button
type="button"
@@ -35,7 +34,7 @@
:disabled="isProcessing"
@click="confirm"
>
Supprimer
{{ $t('common.delete') }}
</button>
</div>
</div>

View File

@@ -0,0 +1,88 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<h3 class="mb-4 text-lg font-bold text-neutral-900">
{{ $t('profile.cropAvatar') }}
</h3>
<div class="mx-auto mb-4 h-72 w-72">
<Cropper
ref="cropperRef"
:src="imageSrc"
:stencil-component="CircleStencil"
:stencil-props="{ aspectRatio: 1 }"
:canvas="{ width: 256, height: 256 }"
class="h-full w-full"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
@click="emit('cancel')"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
:disabled="cropping"
@click="onConfirm"
>
{{ $t('common.confirm') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
const props = defineProps<{
imageFile: File
}>()
const emit = defineEmits<{
(e: 'crop', blob: Blob): void
(e: 'cancel'): void
}>()
const cropperRef = ref()
const cropping = ref(false)
const imageSrc = ref('')
onMounted(() => {
imageSrc.value = URL.createObjectURL(props.imageFile)
})
onUnmounted(() => {
if (imageSrc.value) {
URL.revokeObjectURL(imageSrc.value)
}
})
async function onConfirm() {
cropping.value = true
try {
const { canvas } = cropperRef.value.getResult()
if (!canvas) return
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
if (blob) {
emit('crop', blob)
}
} finally {
cropping.value = false
}
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<span
class="inline-flex shrink-0 items-center justify-center rounded-full"
:class="sizeClasses"
:title="user.username"
>
<img
v-if="user.avatarUrl && !imgError"
:src="user.avatarUrl"
:alt="user.username"
class="h-full w-full rounded-full object-cover"
@error="imgError = true"
/>
<span
v-else
class="flex h-full w-full items-center justify-center rounded-full bg-primary-500 font-bold text-white"
:class="textSizeClass"
>
{{ user.username.substring(0, 2).toUpperCase() }}
</span>
</span>
</template>
<script setup lang="ts">
const props = defineProps<{
user: { id?: number; username: string; avatarUrl?: string | null }
size?: 'xs' | 'sm' | 'md' | 'lg'
}>()
const imgError = ref(false)
watch(() => props.user.avatarUrl, () => {
imgError.value = false
})
const sizeClasses = computed(() => {
const map = {
xs: 'h-5 w-5',
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
return map[props.size ?? 'sm']
})
const textSizeClass = computed(() => {
const map = {
xs: 'text-[10px]',
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
}
return map[props.size ?? 'sm']
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'">
<AppDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.username"
@@ -90,6 +90,8 @@ import { useProjectService } from '~/services/projects'
import type { Client } from '~/services/dto/client'
import type { Project } from '~/services/dto/project'
const { t } = useI18n()
const props = defineProps<{
modelValue: boolean
item: UserData | null
@@ -114,7 +116,7 @@ const clients = ref<Client[]>([])
const allProjects = ref<Project[]>([])
const clientOptions = computed(() => [
{ label: 'Aucun client', value: null as number | null },
{ label: t('common.noClient'), value: null as number | null },
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
])
@@ -190,7 +192,7 @@ async function handleSubmit() {
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`),
}
if (form.password) {
payload.password = form.password
payload.plainPassword = form.password
}
if (isEditing.value && props.item) {

View File

@@ -29,13 +29,14 @@ export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
toastSuccessKey?: string
}
export const useApi = (): ApiClient => {
let isHandlingUnauthorized = false
export function useApi(): ApiClient {
const config = useRuntimeConfig()
const baseURL = config.public.apiBase || '/api'
const toast = useToast()
const auth = useAuthStore()
const nuxtApp = useNuxtApp()
let isHandlingUnauthorized = false
const i18n = nuxtApp.$i18n as
| {
t: (key: string) => string
@@ -45,7 +46,7 @@ export const useApi = (): ApiClient => {
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
const extractErrorMessage = (error: unknown, responseData?: unknown): string => {
function extractErrorMessage(error: unknown, responseData?: unknown): string {
const data = responseData ?? (error as FetchError)?.data
if (typeof data === 'string') {
@@ -169,20 +170,23 @@ export const useApi = (): ApiClient => {
}
})
const request = <T>(
function request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
url: string,
options: ApiFetchOptions<'json'> = {}
) => {
) {
const needsJsonBody = method === 'POST' || method === 'PUT'
const needsMergePatch = method === 'PATCH'
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
const headers = new Headers(options.headers as HeadersInit | undefined)
if (needsMergePatch && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/merge-patch+json')
} else if (needsJsonBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
if (!isFormData) {
if (needsMergePatch && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/merge-patch+json')
} else if (needsJsonBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
}
return client<T>(url, { ...options, method, headers })

View File

@@ -1,8 +1,8 @@
export const useAppVersion = () => {
export function useAppVersion() {
const api = useApi()
const version = useState<string | null>('app-version', () => null)
const load = async () => {
async function load(): Promise<string | null> {
if (version.value) {
return version.value
}

View File

@@ -0,0 +1,26 @@
export function useAvatarService() {
const api = useApi()
async function upload(userId: number, file: Blob): Promise<{ avatarUrl: string }> {
const formData = new FormData()
formData.append('file', file, 'avatar.png')
return api.post<{ avatarUrl: string }>(
`/users/${userId}/avatar`,
formData as unknown as Record<string, unknown>,
{
toastSuccessKey: 'profile.avatarUpdated',
}
)
}
async function remove(userId: number): Promise<void> {
await api.delete(`/users/${userId}/avatar`)
}
function getUrl(userId: number): string {
return `/api/users/${userId}/avatar`
}
return { upload, remove, getUrl }
}

View File

@@ -1,3 +1,5 @@
import type { ClientTicketStatus } from '~/services/dto/client-ticket'
export function useClientTicketHelpers() {
function typeBadgeClass(type: string): string {
switch (type) {
@@ -25,5 +27,22 @@ export function useClientTicketHelpers() {
})
}
return { typeBadgeClass, statusBadgeClass, formatDate }
function getAvailableStatusTransitions(
current: ClientTicketStatus,
t: (key: string) => string,
): { label: string; value: ClientTicketStatus }[] {
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
{ label: t('clientTicket.status.new'), value: 'new' },
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
{ label: t('clientTicket.status.done'), value: 'done' },
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
]
return allStatuses.filter(s => {
if (s.value === current) return false
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
return true
})
}
return { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions }
}

View File

@@ -22,43 +22,66 @@
"clients": {
"created": "Client créé avec succès.",
"updated": "Client mis à jour avec succès.",
"deleted": "Client supprimé avec succès."
"deleted": "Client supprimé avec succès.",
"addClient": "Ajouter un client",
"editClient": "Modifier un client"
},
"projects": {
"title": "Projets",
"created": "Projet créé avec succès.",
"updated": "Projet mis à jour avec succès.",
"deleted": "Projet supprimé avec succès.",
"archived": "Projet archivé avec succès.",
"unarchived": "Projet désarchivé avec succès.",
"showArchived": "Voir les projets archivés",
"hideArchived": "Masquer les projets archivés"
"hideArchived": "Masquer les projets archivés",
"noProjects": "Aucun projet trouvé.",
"noArchivedProjects": "Aucun projet archivé.",
"addProject": "Ajouter un projet",
"addProjectShort": "Projet",
"editProject": "Modifier un projet"
},
"taskStatuses": {
"created": "Statut créé avec succès.",
"updated": "Statut mis à jour avec succès.",
"deleted": "Statut supprimé avec succès."
"deleted": "Statut supprimé avec succès.",
"addStatus": "Ajouter un statut",
"editStatus": "Modifier un statut",
"deleteStatus": "Supprimer le statut « {label} »",
"linkedTasks": "{count} tâche est liée à ce statut. Choisissez où les déplacer :",
"linkedTasksPlural": "{count} tâches sont liées à ce statut. Choisissez où les déplacer :",
"moveTo": "Déplacer vers",
"backlog": "Backlog (sans statut)"
},
"taskEfforts": {
"created": "Effort créé avec succès.",
"updated": "Effort mis à jour avec succès.",
"deleted": "Effort supprimé avec succès."
"deleted": "Effort supprimé avec succès.",
"addEffort": "Ajouter un effort",
"editEffort": "Modifier un effort"
},
"taskPriorities": {
"created": "Priorité créée avec succès.",
"updated": "Priorité mise à jour avec succès.",
"deleted": "Priorité supprimée avec succès."
"deleted": "Priorité supprimée avec succès.",
"addPriority": "Ajouter une priorité",
"editPriority": "Modifier une priorité"
},
"taskTags": {
"created": "Tag créé avec succès.",
"updated": "Tag mis à jour avec succès.",
"deleted": "Tag supprimé avec succès."
"deleted": "Tag supprimé avec succès.",
"addTag": "Ajouter un tag",
"editTag": "Modifier un tag"
},
"taskGroups": {
"created": "Groupe créé avec succès.",
"updated": "Groupe mis à jour avec succès.",
"deleted": "Groupe supprimé avec succès.",
"archived": "Groupe archivé avec succès.",
"unarchived": "Groupe désarchivé avec succès."
"unarchived": "Groupe désarchivé avec succès.",
"addGroup": "Ajouter un groupe",
"editGroup": "Modifier un groupe"
},
"taskDocuments": {
"title": "Documents",
@@ -78,17 +101,24 @@
"archived": "Ticket archivé avec succès.",
"unarchived": "Ticket désarchivé avec succès.",
"deleteConfirmTitle": "Supprimer le ticket",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible."
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
"addTask": "Ajouter un ticket",
"editTask": "Modifier un ticket"
},
"users": {
"created": "Utilisateur créé avec succès.",
"updated": "Utilisateur mis à jour avec succès.",
"deleted": "Utilisateur supprimé avec succès."
"deleted": "Utilisateur supprimé avec succès.",
"addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur"
},
"timeEntries": {
"created": "Temps enregistré",
"updated": "Temps modifié",
"deleted": "Temps supprimé"
"deleted": "Temps supprimé",
"noEntries": "Aucune activité pour cette période",
"addEntry": "Ajouter une Activité",
"editEntry": "Modifier un temps"
},
"archive": {
"title": "Archives",
@@ -112,7 +142,8 @@
"allEfforts": "Tous les efforts",
"allAssignees": "Tous",
"noTasks": "Aucune tâche",
"backlog": "Backlog"
"backlog": "Backlog",
"createTask": "Créer une tâche"
},
"dashboard": {
"title": "Tableau de bord",
@@ -168,7 +199,12 @@
"cancel": "Annuler",
"save": "Enregistrer",
"edit": "Modifier",
"delete": "Supprimer",
"add": "Ajouter",
"loading": "Chargement...",
"archived": "Archivé",
"noClient": "Aucun client",
"untitled": "Sans titre",
"dateFilter": "Date",
"today": "Aujourd'hui",
"thisWeek": "Cette semaine",
@@ -237,6 +273,7 @@
"new": "Nouveau ticket",
"created": "Ticket créé avec succès.",
"deleted": "Ticket supprimé avec succès.",
"updated": "Ticket mis à jour avec succès.",
"statusUpdated": "Statut du ticket mis à jour.",
"type": {
"bug": "Bug",
@@ -290,6 +327,12 @@
"days": "Il y a {n}j"
}
},
"profile": {
"title": "Mon profil",
"changeAvatar": "Changer l'avatar",
"removeAvatar": "Supprimer l'avatar",
"cropAvatar": "Recadrer l'avatar"
},
"bookstack": {
"settings": {
"title": "Configuration BookStack",

View File

@@ -5,7 +5,3 @@
</main>
</div>
</template>
<script setup lang="ts">
const { version } = useAppVersion()
</script>

View File

@@ -123,9 +123,9 @@
</div>
</aside>
<div class="h-full flex-1 flex flex-col min-h-0">
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<AppTopNav :user="auth.user" />
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
<slot/>
</main>
@@ -148,6 +148,7 @@ import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import { useAppVersion } from '~/composables/useAppVersion'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
const auth = useAuthStore()
@@ -211,9 +212,9 @@ async function loadRefData() {
if (refData.loaded) return
const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([
api.get<any>('/users'),
api.get<any>('/projects'),
api.get<any>('/task_tags'),
api.get<HydraCollection<UserData>>('/users'),
api.get<HydraCollection<Project>>('/projects'),
api.get<HydraCollection<TaskTag>>('/task_tags'),
])
refData.users = extractHydraMembers(usersData)
refData.projects = extractHydraMembers(projectsData)
@@ -242,11 +243,6 @@ function onCompleteSaved() {
timerStore.clearPendingEntry()
})
}
const handleLogout = async () => {
await auth.logout()
await navigateTo('/login')
}
</script>
<style scoped>

View File

@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const auth = useAuthStore()
if (!auth.isAuthenticated || !auth.user?.roles?.includes('ROLE_ADMIN')) {
return navigateTo('/')
}
})

View File

@@ -10,17 +10,16 @@ export default defineNuxtRouteMiddleware(async (to) => {
return navigateTo('/login')
}
const isClientOnly = auth.isAuthenticated
&& auth.user?.roles?.includes('ROLE_CLIENT')
&& !auth.user?.roles?.includes('ROLE_ADMIN')
if (isLogin && auth.isAuthenticated) {
const isClientOnly = auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
return navigateTo(isClientOnly ? '/portal' : '/')
}
// ROLE_CLIENT without ROLE_ADMIN: redirect to /portal, block internal pages
if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')) {
const isPortalRoute = to.path.startsWith('/portal')
const isLoginRoute = to.path === '/login'
if (!isPortalRoute && !isLoginRoute) {
return navigateTo('/portal')
}
const isProfileRoute = to.path === '/profile'
if (isClientOnly && !to.path.startsWith('/portal') && !isProfileRoute) {
return navigateTo('/portal')
}
})

View File

@@ -23,14 +23,6 @@ export default defineNuxtConfig({
devServer: {
port: 3002,
},
nitro: {
devProxy: {
'/api': {
target: 'http://nginx',
changeOrigin: true,
},
},
},
components: [
{path: '~/components', pathPrefix: false},
],

View File

@@ -18,6 +18,7 @@
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-advanced-cropper": "^2.8.9",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4"
}
@@ -6789,6 +6790,12 @@
"consola": "^3.2.3"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clipboardy": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz",
@@ -7301,6 +7308,12 @@
}
}
},
"node_modules/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -7578,6 +7591,12 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/easy-bem": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz",
"integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==",
"license": "MIT"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -13869,6 +13888,24 @@
}
}
},
"node_modules/vue-advanced-cropper": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz",
"integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==",
"license": "MIT",
"dependencies": {
"classnames": "^2.2.6",
"debounce": "^1.2.0",
"easy-bem": "^1.0.2"
},
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-bundle-renderer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/vue-bundle-renderer/-/vue-bundle-renderer-2.2.0.tgz",

View File

@@ -22,6 +22,7 @@
"nuxt-toast": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-advanced-cropper": "^2.8.9",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4"
}

View File

@@ -35,6 +35,7 @@
</template>
<script setup lang="ts">
definePageMeta({ middleware: ['admin'] })
useHead({ title: 'Administration' })
const tabs = [

View File

@@ -471,7 +471,7 @@ const lineOptions = {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx: any) => `${formatHours(ctx.raw)}`,
label: (ctx: { raw: unknown }) => `${formatHours(ctx.raw as number)}`,
},
},
},
@@ -480,7 +480,7 @@ const lineOptions = {
beginAtZero: true,
grid: { color: '#f3f4f6' },
ticks: {
callback: (value: any) => `${value}h`,
callback: (value: number | string) => `${value}h`,
},
},
x: {

View File

@@ -48,7 +48,6 @@ useHead({
title: 'Connexion'
})
const router = useRouter()
const auth = useAuthStore()
const {version} = useAppVersion()
@@ -56,7 +55,7 @@ const username = ref('')
const password = ref('')
const isSubmitting = ref(false)
const handleSubmit = async () => {
async function handleSubmit() {
if (isSubmitting.value) return
isSubmitting.value = true
@@ -64,7 +63,7 @@ const handleSubmit = async () => {
await auth.login(username.value, password.value)
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
await router.push(isClient ? '/portal' : '/')
await navigateTo(isClient ? '/portal' : '/')
} finally {
isSubmitting.value = false
}

View File

@@ -214,6 +214,11 @@ async function onDropBacklog(event: DragEvent) {
}
// Modal
function openTaskCreate() {
selectedTask.value = null
taskModalOpen.value = true
}
function openTaskEdit(task: Task) {
selectedTask.value = task
taskModalOpen.value = true
@@ -229,28 +234,37 @@ onMounted(() => {
</script>
<template>
<div>
<div class="min-w-0">
<!-- Header + Filters -->
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
<div class="flex gap-1">
<div class="flex items-center gap-2">
<button
class="rounded-lg p-2 transition-colors"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
class="flex items-center gap-1.5 rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
@click="openTaskCreate"
>
<Icon name="mdi:view-column-outline" size="20" />
</button>
<button
class="rounded-lg p-2 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')"
@click="viewMode = 'list'"
>
<Icon name="mdi:view-list-outline" size="20" />
<Icon name="mdi:plus" size="18" />
{{ $t('myTasks.createTask') }}
</button>
<div class="flex gap-1">
<button
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
>
<Icon name="mdi:view-column-outline" size="18" />
</button>
<button
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')"
@click="viewMode = 'list'"
>
<Icon name="mdi:view-list-outline" size="18" />
</button>
</div>
</div>
</div>
@@ -314,11 +328,11 @@ onMounted(() => {
<!-- Kanban View -->
<div v-if="viewMode === 'kanban'">
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
<div
v-for="status in sortedStatuses"
:key="status.id"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(status.id)"
@@ -326,24 +340,26 @@ onMounted(() => {
@drop.prevent="onDropStatus($event, status)"
>
<div
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
:style="{ backgroundColor: status.color }"
>
{{ status.label }} ({{ tasksByStatus(status.id).length }})
</div>
<div class="flex flex-col gap-3 p-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
<div class="min-h-0 flex-1 overflow-y-auto p-3">
<div class="flex flex-col gap-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
</p>
</div>
</div>
</div>
</div>
@@ -446,6 +462,7 @@ onMounted(() => {
:tags="tags"
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
:users="users"
:projects="projects"
@saved="onSaved"
/>
</div>

View File

@@ -53,10 +53,8 @@ const ticketCountByProject = computed(() => {
const counts: Record<number, number> = {}
for (const ticket of tickets.value) {
if (ticket.status === 'new' || ticket.status === 'in_progress') {
// Extract project ID from IRI
const match = ticket.project.match(/\/api\/projects\/(\d+)/)
if (match) {
const projectId = Number(match[1])
const projectId = extractIdFromIri(ticket.project)
if (projectId) {
counts[projectId] = (counts[projectId] ?? 0) + 1
}
}

View File

@@ -31,13 +31,13 @@
</div>
<!-- Kanban board -->
<div v-else class="mt-4 flex flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
<div v-else class="mt-4 flex h-[calc(100vh-200px)] flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
<div
v-for="col in columns"
:key="col.status"
class="min-w-0 flex-1 sm:min-w-[280px]"
class="flex min-w-0 flex-1 flex-col sm:min-w-[280px]"
>
<div class="mb-3 flex items-center gap-2">
<div class="mb-3 flex shrink-0 items-center gap-2">
<div class="h-2 w-2 rounded-full" :class="col.dotClass" />
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
@@ -45,7 +45,7 @@
</span>
</div>
<div
class="min-h-[60px] space-y-2 rounded-lg border-2 border-transparent p-1 transition-colors"
class="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-lg border-2 border-transparent p-1 transition-colors"
:class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''"
@dragover.prevent="onDragOver(col.status)"
@dragleave="onDragLeave"

View File

@@ -0,0 +1,91 @@
<template>
<div class="mx-auto max-w-lg px-4 py-10">
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
<!-- Current avatar -->
<UserAvatar
v-if="auth.user"
:user="auth.user"
size="lg"
/>
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
<div class="flex gap-3">
<label
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
>
{{ $t('profile.changeAvatar') }}
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
class="hidden"
@change="onFileSelect"
/>
</label>
<button
v-if="auth.user?.avatarUrl"
type="button"
class="rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
:disabled="removing"
@click="onRemove"
>
{{ $t('profile.removeAvatar') }}
</button>
</div>
</div>
<!-- Crop modal -->
<AvatarCropper
v-if="selectedFile"
:image-file="selectedFile"
@crop="onCrop"
@cancel="selectedFile = null"
/>
</div>
</template>
<script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService'
const auth = useAuthStore()
const { upload, remove } = useAvatarService()
const selectedFile = ref<File | null>(null)
const removing = ref(false)
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
selectedFile.value = file
}
input.value = ''
}
async function onCrop(blob: Blob) {
selectedFile.value = null
if (!auth.user) return
try {
await upload(auth.user.id, blob)
await auth.refreshUser()
} catch {
// Upload error — $fetch will throw on non-2xx
}
}
async function onRemove() {
if (!auth.user) return
removing.value = true
try {
await remove(auth.user.id)
await auth.refreshUser()
} finally {
removing.value = false
}
}
</script>

View File

@@ -46,13 +46,11 @@
>
{{ task.group.title }}
</span>
<span
<UserAvatar
v-if="task.assignee"
class="flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
:title="task.assignee.username"
>
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
</span>
:user="task.assignee"
size="xs"
/>
</div>
</div>
</div>
@@ -130,7 +128,7 @@ const filteredTasks = computed(() => {
async function loadData() {
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
projectService.getById(projectId.value),
taskService.getByProjectArchived(projectId.value),
taskService.getByProject(projectId.value, true),
statusService.getAll(),
effortService.getAll(),
priorityService.getAll(),

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="min-w-0">
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
@@ -62,11 +62,11 @@
</div>
<!-- Kanban -->
<div class="mt-6 flex gap-4 overflow-x-auto pb-4">
<div class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
<div
v-for="status in statuses"
:key="status.id"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(status.id)"
@@ -74,24 +74,26 @@
@drop.prevent="onDropStatus($event, status)"
>
<div
class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
:style="{ backgroundColor: status.color }"
>
{{ status.label }} ({{ tasksByStatus(status.id).length }})
</div>
<div class="flex flex-col gap-3 p-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
Aucun ticket
</p>
<div class="min-h-0 flex-1 overflow-y-auto p-3">
<div class="flex flex-col gap-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
Aucun ticket
</p>
</div>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
<div>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Projets</h1>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
<div class="flex items-center gap-2 sm:gap-3">
<button
class="flex items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium transition sm:px-3"
@@ -18,8 +18,8 @@
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
@click="openCreate"
>
<span class="hidden sm:inline">+ Ajouter un projet</span>
<span class="sm:hidden">+ Projet</span>
<span class="hidden sm:inline">+ {{ $t('projects.addProject') }}</span>
<span class="sm:hidden">+ {{ $t('projects.addProjectShort') }}</span>
</button>
</div>
</div>
@@ -40,7 +40,7 @@
v-if="project.archived"
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
>
Archivé
{{ $t('common.archived') }}
</span>
</div>
<button
@@ -59,7 +59,7 @@
v-if="projects.length === 0 && !isLoading"
class="col-span-full py-12 text-center text-neutral-400"
>
{{ showArchived ? 'Aucun projet archivé.' : 'Aucun projet trouvé.' }}
{{ showArchived ? $t('projects.noArchivedProjects') : $t('projects.noProjects') }}
</div>
</div>

View File

@@ -126,6 +126,7 @@ import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import { useTimeEntryService } from '~/services/time-entries'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
useHead({ title: 'Suivi des temps' })
@@ -284,24 +285,10 @@ async function onPaste() {
await loadEntries()
}
onMounted(() => {
updatePageHeaderHeight()
if (!pageHeaderEl.value || typeof ResizeObserver === 'undefined') {
return
}
pageHeaderResizeObserver = new ResizeObserver(() => {
updatePageHeaderHeight()
})
pageHeaderResizeObserver.observe(pageHeaderEl.value)
})
onBeforeUnmount(() => {
pageHeaderResizeObserver?.disconnect()
})
async function onDelete(entry: TimeEntry) {
await timeEntryService.remove(entry.id)
await loadEntries()
@@ -322,9 +309,9 @@ async function loadReferenceData() {
const api = useApi()
const [usersData, projectsData, typesData] = await Promise.all([
api.get<any>('/users'),
api.get<any>('/projects'),
api.get<any>('/task_tags'),
api.get<HydraCollection<UserData>>('/users'),
api.get<HydraCollection<Project>>('/projects'),
api.get<HydraCollection<TaskTag>>('/task_tags'),
])
users.value = extractHydraMembers(usersData)
@@ -333,6 +320,15 @@ async function loadReferenceData() {
}
onMounted(async () => {
updatePageHeaderHeight()
if (pageHeaderEl.value && typeof ResizeObserver !== 'undefined') {
pageHeaderResizeObserver = new ResizeObserver(() => {
updatePageHeaderHeight()
})
pageHeaderResizeObserver.observe(pageHeaderEl.value)
}
await loadReferenceData()
await loadEntries()
})

View File

@@ -1,22 +1,22 @@
import type { UserData } from './dto/user-data'
export const getCurrentUser = () => {
const api = useApi()
return api.get<UserData>('/me', {}, { toastErrorKey: 'errors.auth.session' })
export function getCurrentUser() {
const api = useApi()
return api.get<UserData>('/me', {}, { toastErrorKey: 'errors.auth.session' })
}
export const login = (username: string, password: string) => {
const api = useApi()
return api.post('/login_check', { username, password }, {
toastOn401: true,
toastErrorKey: 'errors.auth.login'
})
export function login(username: string, password: string) {
const api = useApi()
return api.post('/login_check', { username, password }, {
toastOn401: true,
toastErrorKey: 'errors.auth.login'
})
}
export const logout = () => {
const api = useApi()
return api.post('/logout', {}, {
toastErrorKey: 'errors.auth.logout',
toastSuccessKey: 'success.auth.logout'
})
export function logout() {
const api = useApi()
return api.post('/logout', {}, {
toastErrorKey: 'errors.auth.logout',
toastSuccessKey: 'success.auth.logout'
})
}

View File

@@ -32,7 +32,7 @@ export function useClientTicketService() {
async function update(id: number, data: Partial<ClientTicketWrite>): Promise<ClientTicket> {
return api.patch<ClientTicket>(`/client_tickets/${id}`, data as Record<string, unknown>, {
toastSuccessKey: 'clientTicket.statusUpdated',
toastSuccessKey: 'clientTicket.updated',
})
}

View File

@@ -7,11 +7,12 @@ export type UserData = {
roles: string[]
client?: { id: number; name: string } | null
allowedProjects?: Project[]
avatarUrl?: string | null
}
export type UserWrite = {
username: string
password?: string
plainPassword?: string
roles: string[]
client?: string | null
allowedProjects?: string[]

View File

@@ -15,30 +15,24 @@ export function useTaskDocumentService() {
return extractHydraMembers(data)
}
async function upload(taskId: number, file: File): Promise<TaskDocument> {
async function uploadWithRelation(relationField: string, relationIri: string, file: File): Promise<TaskDocument> {
const formData = new FormData()
formData.append('file', file)
formData.append('task', `/api/tasks/${taskId}`)
formData.append(relationField, relationIri)
return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
return $fetch<TaskDocument>(`${baseURL}/task_documents`, {
method: 'POST',
body: formData,
credentials: 'include',
// Do NOT set Content-Type — browser sets multipart boundary automatically
})
}
async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
const formData = new FormData()
formData.append('file', file)
formData.append('clientTicket', `/api/client_tickets/${clientTicketId}`)
async function upload(taskId: number, file: File): Promise<TaskDocument> {
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
}
return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
method: 'POST',
body: formData,
credentials: 'include',
// Do NOT set Content-Type — browser sets multipart boundary automatically
})
async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
return uploadWithRelation('clientTicket', `/api/client_tickets/${clientTicketId}`, file)
}
async function getByTicket(clientTicketId: number): Promise<TaskDocument[]> {

View File

@@ -10,18 +10,10 @@ export function useTaskService() {
return extractHydraMembers(data)
}
async function getByProject(projectId: number): Promise<Task[]> {
async function getByProject(projectId: number, archived = false): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks', {
project: `/api/projects/${projectId}`,
archived: false,
})
return extractHydraMembers(data)
}
async function getByProjectArchived(projectId: number): Promise<Task[]> {
const data = await api.get<HydraCollection<Task>>('/tasks', {
project: `/api/projects/${projectId}`,
archived: true,
archived,
})
return extractHydraMembers(data)
}
@@ -49,5 +41,5 @@ export function useTaskService() {
})
}
return { getAll, getByProject, getByProjectArchived, getFiltered, create, update, remove }
return { getAll, getByProject, getFiltered, create, update, remove }
}

View File

@@ -58,6 +58,14 @@ export const useAuthStore = defineStore('auth', {
this.checked = true
this.isLoading = false
}
},
async refreshUser() {
try {
const me = await getCurrentUser()
this.user = me
} catch {
// Silently fail — user session might have expired
}
}
}
})

View File

@@ -66,6 +66,11 @@ export const useTimerStore = defineStore('timer', () => {
startTicking()
}
function toIri<T extends { '@id'?: string; id: number }>(entity: T | string, prefix: string): string {
if (typeof entity === 'string') return entity
return entity['@id'] ?? `${prefix}/${entity.id}`
}
async function startFromTask(task: Task) {
const authStore = useAuthStore()
if (!authStore.user) return
@@ -79,11 +84,9 @@ export const useTimerStore = defineStore('timer', () => {
startedAt: new Date().toISOString(),
user: `/api/users/${authStore.user.id}`,
title: task.title,
project: task.project
? (typeof task.project === 'string' ? task.project : (task.project['@id'] ?? (task.project.id ? `/api/projects/${task.project.id}` : null)))
: null,
task: typeof task === 'string' ? task : (task['@id'] ?? `/api/tasks/${task.id}`),
tags: task.tags?.map((t) => typeof t === 'string' ? t : (t['@id'] ?? `/api/task_tags/${t.id}`)) ?? [],
project: task.project ? toIri(task.project, '/api/projects') : null,
task: toIri(task, '/api/tasks'),
tags: task.tags?.map(t => toIri(t, '/api/task_tags')) ?? [],
})
startTicking()
}

5
frontend/utils/format.ts Normal file
View File

@@ -0,0 +1,5 @@
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}

11
frontend/utils/iri.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* Extract the numeric ID from an API Platform IRI string.
* Example: "/api/projects/5" → 5
*/
export function extractIdFromIri(iri: string | null | undefined): number {
if (!iri) return 0
const lastSlash = iri.lastIndexOf('/')
if (lastSlash === -1) return 0
const id = Number(iri.substring(lastSlash + 1))
return Number.isFinite(id) ? id : 0
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260315205331 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD avatar_file_name VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" DROP avatar_file_name');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260315210619 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE UNIQUE INDEX uniq_task_project_number ON task (project_id, number)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP INDEX uniq_task_project_number');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260316124157 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE time_entry ADD client_ticket_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0C9B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_6E537C0C9B2097DD ON time_entry (client_ticket_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0C9B2097DD');
$this->addSql('DROP INDEX IDX_6E537C0C9B2097DD');
$this->addSql('ALTER TABLE time_entry DROP client_ticket_id');
}
}

96
script/deploy-release.sh Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: ./script/deploy-release.sh v0.1.0
# Requires: curl, tar, (optional) rsync
#
# Auth token: set RELEASE_TOKEN env var or create /etc/lesstime-release-token
umask 002
TAG="${1:-}"
if [ -z "$TAG" ]; then
echo "Usage: $0 v0.1.0" >&2
exit 1
fi
REPO_OWNER="MALIO-DEV"
REPO_NAME="Lesstime"
GITEA_API="https://gitea.malio.fr/api/v1"
DEPLOY_DIR="/var/www/lesstime"
if [ -f /etc/lesstime-release-token ] && [ -z "${RELEASE_TOKEN:-}" ]; then
RELEASE_TOKEN="$(cat /etc/lesstime-release-token)"
fi
tmp_dir="$(mktemp -d)"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT
release_json="$tmp_dir/release.json"
curl_opts=(-sS)
if [ -n "${RELEASE_TOKEN:-}" ]; then
curl_opts+=(-H "Authorization: token ${RELEASE_TOKEN}")
fi
curl "${curl_opts[@]}" \
"${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" \
-o "$release_json"
asset_url="$(python3 - "$release_json" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], 'r'))
assets = data.get("assets", [])
for a in assets:
name = a.get("name", "")
if name.startswith("lesstime-") and name.endswith(".tar.gz"):
print(a.get("browser_download_url", ""))
break
PY
)"
if [ -z "$asset_url" ]; then
echo "Release asset not found for tag ${TAG}" >&2
exit 1
fi
archive="$tmp_dir/artefact.tar.gz"
curl "${curl_opts[@]}" -L "$asset_url" -o "$archive"
tar -xzf "$archive" -C "$tmp_dir"
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete --no-perms --no-owner --no-group \
--exclude ".env" \
--exclude ".env.local" \
--exclude "config/jwt" \
--exclude "var" \
"$tmp_dir"/ "$DEPLOY_DIR"/
else
cp -a "$tmp_dir"/. "$DEPLOY_DIR"/
fi
# Ensure Nginx can traverse the deploy path.
chmod o+rx "$(dirname "$DEPLOY_DIR")" "$DEPLOY_DIR" 2>/dev/null || true
# Create frontend/dist symlink if needed (nginx serves from frontend/dist)
if [ -d "${DEPLOY_DIR}/frontend/.output/public" ] && [ ! -L "${DEPLOY_DIR}/frontend/dist" ]; then
ln -sfn "${DEPLOY_DIR}/frontend/.output/public" "${DEPLOY_DIR}/frontend/dist"
fi
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
# Ensure var/log exists and is writable by PHP (www-data)
mkdir -p "${DEPLOY_DIR}/var/log"
chown www-data:www-data "${DEPLOY_DIR}/var/log"
chmod 775 "${DEPLOY_DIR}/var/log"
if [ -f "${DEPLOY_DIR}/.env.local" ]; then
echo "Clearing cache..."
php "${DEPLOY_DIR}/bin/console" cache:clear --env=prod --no-debug
echo "Running migrations (if any)..."
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
else
echo "Skip post-deploy: ${DEPLOY_DIR}/.env.local not found" >&2
fi

View File

@@ -17,6 +17,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/branches',
normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class,
security: "is_granted('ROLE_USER')",
),
new Post(
uriTemplate: '/tasks/{taskId}/gitea/branches',
@@ -24,6 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class,
processor: GiteaBranchProcessor::class,
security: "is_granted('ROLE_USER')",
),
],
)]

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/branch-name/{type}',
normalizationContext: ['groups' => ['gitea_branch_name:read']],
provider: GiteaBranchNameProvider::class,
security: "is_granted('ROLE_USER')",
),
],
)]

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/pull-requests',
normalizationContext: ['groups' => ['gitea_pr:read']],
provider: GiteaPullRequestProvider::class,
security: "is_granted('ROLE_USER')",
),
],
)]

View File

@@ -7,8 +7,10 @@ namespace App\Controller;
use App\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -17,11 +19,12 @@ class TaskDocumentDownloadController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly string $uploadDir,
) {}
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(int $id): BinaryFileResponse
{
$document = $this->entityManager->getRepository(TaskDocument::class)->find($id);
@@ -30,6 +33,14 @@ class TaskDocumentDownloadController extends AbstractController
throw new NotFoundHttpException('Document not found.');
}
// ROLE_CLIENT can only download documents from their own tickets
if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_USER')) {
$ticket = $document->getClientTicket();
if (null === $ticket || $ticket->getSubmittedBy() !== $this->security->getUser()) {
throw new AccessDeniedHttpException('You do not have access to this document.');
}
}
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (!file_exists($filePath)) {
@@ -40,9 +51,12 @@ class TaskDocumentDownloadController extends AbstractController
$mimeType = $document->getMimeType() ?? 'application/octet-stream';
// Inline for images and PDFs, attachment for everything else
$disposition = str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType
? ResponseHeaderBag::DISPOSITION_INLINE
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
// SVG files are always served as attachment to prevent XSS via embedded JavaScript
$disposition = 'image/svg+xml' === $mimeType
? ResponseHeaderBag::DISPOSITION_ATTACHMENT
: (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType
? ResponseHeaderBag::DISPOSITION_INLINE
: ResponseHeaderBag::DISPOSITION_ATTACHMENT);
$response->setContentDisposition($disposition, $document->getOriginalName());
$response->headers->set('Content-Type', $mimeType);

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
class UserAvatarController extends AbstractController
{
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly string $avatarUploadDir,
) {}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_upload', methods: ['POST'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function upload(int $id, Request $request): JsonResponse
{
$user = $this->findUserOrFail($id);
$this->assertCanManageAvatar($user);
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('No valid file uploaded.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('File size exceeds 5 MB limit.');
}
$mimeType = $file->getMimeType();
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException('Invalid file type. Allowed: JPEG, PNG, WebP, GIF.');
}
// Delete previous avatar file if exists
$this->deleteAvatarFile($user);
$extension = $file->guessExtension() ?? 'bin';
$fileName = Uuid::v4()->toRfc4122().'.'.$extension;
if (!is_dir($this->avatarUploadDir)) {
mkdir($this->avatarUploadDir, 0o775, true);
}
$file->move($this->avatarUploadDir, $fileName);
$user->setAvatarFileName($fileName);
$this->entityManager->flush();
return new JsonResponse(['avatarUrl' => $user->getAvatarUrl()]);
}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_serve', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function serve(int $id): BinaryFileResponse
{
$user = $this->findUserOrFail($id);
if (null === $user->getAvatarFileName()) {
throw new NotFoundHttpException('No avatar set.');
}
$filePath = $this->avatarUploadDir.'/'.$user->getAvatarFileName();
if (!file_exists($filePath)) {
throw new NotFoundHttpException('Avatar file not found on disk.');
}
$response = new BinaryFileResponse($filePath);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $user->getAvatarFileName());
$extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION);
$mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'];
$response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream');
$response->headers->set('Cache-Control', 'public, max-age=86400');
return $response;
}
#[Route('/api/users/{id}/avatar', name: 'user_avatar_delete', methods: ['DELETE'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function delete(int $id): Response
{
$user = $this->findUserOrFail($id);
$this->assertCanManageAvatar($user);
$this->deleteAvatarFile($user);
$user->setAvatarFileName(null);
$this->entityManager->flush();
return new Response(null, Response::HTTP_NO_CONTENT);
}
private function findUserOrFail(int $id): User
{
$user = $this->entityManager->getRepository(User::class)->find($id);
if (null === $user) {
throw new NotFoundHttpException('User not found.');
}
return $user;
}
private function assertCanManageAvatar(User $user): void
{
$currentUser = $this->getUser();
if ($currentUser !== $user && !$this->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('You can only manage your own avatar.');
}
}
private function deleteAvatarFile(User $user): void
{
if (null === $user->getAvatarFileName()) {
return;
}
$filePath = $this->avatarUploadDir.'/'.$user->getAvatarFileName();
if (file_exists($filePath)) {
unlink($filePath);
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Project;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class ProjectAllowedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private Security $security,
) {}
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
if (Project::class !== $resourceClass) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
return;
}
// Only restrict for ROLE_CLIENT users who are NOT admins
if (!in_array('ROLE_CLIENT', $user->getRoles(), true) || in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$allowedProjectIds = $user->getAllowedProjects()->map(
fn (Project $project) => $project->getId(),
)->toArray();
if ([] === $allowedProjectIds) {
$queryBuilder->andWhere('1 = 0');
return;
}
$queryBuilder
->andWhere($rootAlias.'.id IN (:allowed_project_ids)')
->setParameter('allowed_project_ids', $allowedProjectIds)
;
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use App\Repository\BookStackConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: BookStackConfigurationRepository::class)]
class BookStackConfiguration
@@ -16,6 +17,7 @@ class BookStackConfiguration
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Url]
private ?string $url = null;
#[ORM\Column(type: 'text', nullable: true)]

View File

@@ -19,6 +19,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
@@ -54,6 +55,27 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class ClientTicket
{
public const string TYPE_BUG = 'bug';
public const string TYPE_IMPROVEMENT = 'improvement';
public const string TYPE_OTHER = 'other';
public const array TYPES = [
self::TYPE_BUG,
self::TYPE_IMPROVEMENT,
self::TYPE_OTHER,
];
public const string STATUS_NEW = 'new';
public const string STATUS_IN_PROGRESS = 'in_progress';
public const string STATUS_DONE = 'done';
public const string STATUS_REJECTED = 'rejected';
public const array STATUSES = [
self::STATUS_NEW,
self::STATUS_IN_PROGRESS,
self::STATUS_DONE,
self::STATUS_REJECTED,
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -66,6 +88,7 @@ class ClientTicket
#[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\Choice(choices: self::TYPES)]
private ?string $type = null;
#[ORM\Column(length: 255)]
@@ -78,10 +101,12 @@ class ClientTicket
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
#[Assert\Url]
private ?string $url = null;
#[ORM\Column(length: 20)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\Choice(choices: self::STATUSES)]
private ?string $status = 'new';
#[ORM\Column(type: 'text', nullable: true)]

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use App\Repository\GiteaConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: GiteaConfigurationRepository::class)]
class GiteaConfiguration
@@ -16,6 +17,7 @@ class GiteaConfiguration
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Url]
private ?string $url = null;
#[ORM\Column(type: 'text', nullable: true)]

Some files were not shown because too many files have changed in this diff Show More