diff --git a/docs/superpowers/plans/2026-03-15-user-avatar.md b/docs/superpowers/plans/2026-03-15-user-avatar.md new file mode 100644 index 0000000..11f893f --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-user-avatar.md @@ -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 +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 { + 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 + + + +``` + +- [ ] **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 + + + +``` + +- [ ] **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 + + + +``` + +- [ ] **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 + +``` + +With: + +```vue + + +``` + +Make "Mon profil" button navigate to `/profile`: + +```vue + +``` + +- [ ] **Step 2: Update `TaskCard.vue`** + +Replace lines 47-59 (the assignee initials span + empty state): + +```vue + + + + +``` + +- [ ] **Step 3: Update `archives.vue`** + +Replace lines 49-55 (the assignee initials span): + +```vue + +``` + +- [ ] **Step 4: Update `AdminClientTicketTab.vue`** + +Replace the submitter `` 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 ``: +```vue + +
+ + {{ getSubmitterName(ticket.submittedBy) }} +
+ +``` + +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**