docs : add user avatar implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
802
docs/superpowers/plans/2026-03-15-user-avatar.md
Normal file
802
docs/superpowers/plans/2026-03-15-user-avatar.md
Normal 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**
|
||||
Reference in New Issue
Block a user