Files
Lesstime/docs/superpowers/plans/2026-03-15-user-avatar.md
matthieu 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

22 KiB

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:

#[ORM\Column(length: 255, nullable: true)]
#[Groups(['me:read', 'user:list'])]
private ?string $avatarFileName = null;

Add getter/setter:

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):

#[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
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
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:

avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'

Add service wiring:

App\Controller\UserAvatarController:
    arguments:
        $avatarUploadDir: '%avatar_upload_dir%'
  • Step 2: Create UserAvatarController.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
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

cd frontend && npm install vue-advanced-cropper
  • Step 2: Update UserData DTO

In frontend/services/dto/user-data.ts, add avatarUrl to UserData:

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
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:

async refreshUser() {
    try {
        const me = await getCurrentUser()
        this.user = me
    } catch {
        // Silently fail — user session might have expired
    }
}
  • Step 5: Commit
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

<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
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

<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
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

<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:

if (!isPortalRoute && !isLoginRoute) {

To:

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:

"profile": {
    "title": "Mon profil",
    "changeAvatar": "Changer l'avatar",
    "removeAvatar": "Supprimer l'avatar",
    "cropAvatar": "Recadrer l'avatar"
}
  • Step 4: Commit
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):

<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />

With:

<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:

<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):

<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):

<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>:

<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:

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
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
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