From be3680d0dfcf33f70ae9b43bd5078530ae73492d Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 2 Jul 2026 08:57:31 +0200 Subject: [PATCH] feat(user-settings): avatar delete + accent color - DELETE /users/me/avatar endpoint with file cleanup - PATCH /users/me/accent-color with hex validation (#rrggbb) - auth.service.ts: include accentColor in user select - AccountSettingsForm: delete-avatar button + accent color picker/save/reset - auth-actions.ts: deleteAvatarAction + updateAccentColorAction Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/auth/auth.service.ts | 1 + apps/api/src/user/user.controller.ts | 48 ++++++ .../settings/account-settings-form.tsx | 146 +++++++++++++++--- apps/web/src/lib/auth-actions.ts | 36 +++++ 4 files changed, 208 insertions(+), 23 deletions(-) diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 6e53649..3e80c61 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -200,6 +200,7 @@ export class AuthService { passwordHash: true, ldapDn: true, avatarPath: true, + accentColor: true, }, }); diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts index 28f53ea..8031870 100644 --- a/apps/api/src/user/user.controller.ts +++ b/apps/api/src/user/user.controller.ts @@ -272,6 +272,54 @@ export class UserController { return { success: true }; } + /** + * DELETE /users/me/avatar + * Remove the current user's profile picture. + */ + @Delete('me/avatar') + async deleteAvatar(@CurrentUser() currentUser: any) { + const user = await this.prisma.user.findUnique({ + where: { id: currentUser.id }, + select: { avatarPath: true }, + }); + + if (user?.avatarPath) { + const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..'); + const absolutePath = path.join(monorepoRoot, user.avatarPath); + if (fs.existsSync(absolutePath)) { + fs.unlinkSync(absolutePath); + } + await this.prisma.user.update({ + where: { id: currentUser.id }, + data: { avatarPath: null }, + }); + } + + return { success: true }; + } + + /** + * PATCH /users/me/accent-color + * Set or clear the current user's accent color. + * T-acc-01: hex color validated server-side. + */ + @Patch('me/accent-color') + async updateAccentColor( + @Body() body: { color: string | null }, + @CurrentUser() currentUser: any, + ) { + if (body.color !== null && body.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(body.color)) { + throw new BadRequestException('Invalid color format. Use hex (#rrggbb).'); + } + + await this.prisma.user.update({ + where: { id: currentUser.id }, + data: { accentColor: body.color ?? null }, + }); + + return { success: true }; + } + /** * GET /users/me/avatar * Stream the current user's avatar image. diff --git a/apps/web/src/components/settings/account-settings-form.tsx b/apps/web/src/components/settings/account-settings-form.tsx index 2a77100..02d08c0 100644 --- a/apps/web/src/components/settings/account-settings-form.tsx +++ b/apps/web/src/components/settings/account-settings-form.tsx @@ -6,9 +6,13 @@ import { fetchCurrentUser, changePasswordAction, uploadAvatarAction, + deleteAvatarAction, + updateAccentColorAction, } from '@/lib/auth-actions'; import { useAuthStore } from '@/lib/stores/auth-store'; +const DEFAULT_ACCENT = '#ffed00'; + export function AccountSettingsForm() { const t = useTranslations('settings'); const tAuth = useTranslations('auth'); @@ -17,7 +21,6 @@ export function AccountSettingsForm() { const [isLocalUser, setIsLocalUser] = useState(null); const [username, setUsername] = useState(''); const [hasAvatar, setHasAvatar] = useState(false); - const [avatarSrc, setAvatarSrc] = useState('/api-proxy/users/me/avatar'); const [avatarKey, setAvatarKey] = useState(0); // Password change state @@ -27,17 +30,24 @@ export function AccountSettingsForm() { const [isPwPending, startPwTransition] = useTransition(); const pwFormRef = useRef(null); - // Avatar upload state + // Avatar upload/delete state const [avatarError, setAvatarError] = useState(null); const [avatarSuccess, setAvatarSuccess] = useState(false); const [isAvatarPending, startAvatarTransition] = useTransition(); + // Accent color state + const [accentColor, setAccentColor] = useState(DEFAULT_ACCENT); + const [accentSuccess, setAccentSuccess] = useState(false); + const [accentError, setAccentError] = useState(null); + const [isAccentPending, startAccentTransition] = useTransition(); + useEffect(() => { fetchCurrentUser().then((u) => { if (u) { setIsLocalUser(u.isLocalUser ?? false); setUsername(u.displayName ?? u.username ?? ''); setHasAvatar(!!u.hasAvatar); + setAccentColor(u.accentColor ?? DEFAULT_ACCENT); } }); }, []); @@ -60,30 +70,23 @@ export function AccountSettingsForm() { startPwTransition(async () => { const result = await changePasswordAction(currentPassword, newPassword); - // changePasswordAction redirects to '/' on success, so we only reach here on error - if (result) { - setPwError(result.error); - } + if (result) setPwError(result.error); }); } async function handleAvatarUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; - setAvatarError(null); setAvatarSuccess(false); - const formData = new FormData(); formData.append('file', file); - startAvatarTransition(async () => { const result = await uploadAvatarAction(formData); if (result.success) { setAvatarSuccess(true); setHasAvatar(true); setAvatarKey((k) => k + 1); - // Sync to auth store so header avatar updates immediately bumpAvatarVersion(); if (user) setUser({ ...user, hasAvatar: true }); } else { @@ -92,6 +95,49 @@ export function AccountSettingsForm() { }); } + function handleDeleteAvatar() { + setAvatarError(null); + setAvatarSuccess(false); + startAvatarTransition(async () => { + const result = await deleteAvatarAction(); + if (result.success) { + setHasAvatar(false); + setAvatarSuccess(true); + setAvatarKey((k) => k + 1); + if (user) setUser({ ...user, hasAvatar: false }); + } else { + setAvatarError(result.error ?? 'deleteError'); + } + }); + } + + function handleSaveAccentColor() { + setAccentSuccess(false); + setAccentError(null); + startAccentTransition(async () => { + const result = await updateAccentColorAction(accentColor); + if (result.success) { + setAccentSuccess(true); + if (user) setUser({ ...user, accentColor }); + } else { + setAccentError(result.error ?? 'saveError'); + } + }); + } + + function handleResetAccentColor() { + setAccentColor(DEFAULT_ACCENT); + setAccentSuccess(false); + setAccentError(null); + startAccentTransition(async () => { + const result = await updateAccentColorAction(null); + if (result.success) { + setAccentSuccess(true); + if (user) setUser({ ...user, accentColor: null }); + } + }); + } + const userInitial = username.charAt(0).toUpperCase() || '?'; return ( @@ -102,7 +148,6 @@ export function AccountSettingsForm() { {t('account.avatarLabel')}
- {/* Avatar preview with initial fallback */}
{hasAvatar ? ( - {t('account.uploadSuccess')} + {hasAvatar ? t('account.uploadSuccess') : t('account.deleteAvatarSuccess')}
)} {avatarError && (
- {t('account.uploadError')} + {avatarError === 'deleteError' ? t('account.deleteAvatarError') : t('account.uploadError')}
)} -