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 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 08:57:31 +02:00
parent cc95395889
commit be3680d0df
4 changed files with 208 additions and 23 deletions
+1
View File
@@ -200,6 +200,7 @@ export class AuthService {
passwordHash: true, passwordHash: true,
ldapDn: true, ldapDn: true,
avatarPath: true, avatarPath: true,
accentColor: true,
}, },
}); });
+48
View File
@@ -272,6 +272,54 @@ export class UserController {
return { success: true }; 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 * GET /users/me/avatar
* Stream the current user's avatar image. * Stream the current user's avatar image.
@@ -6,9 +6,13 @@ import {
fetchCurrentUser, fetchCurrentUser,
changePasswordAction, changePasswordAction,
uploadAvatarAction, uploadAvatarAction,
deleteAvatarAction,
updateAccentColorAction,
} from '@/lib/auth-actions'; } from '@/lib/auth-actions';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
const DEFAULT_ACCENT = '#ffed00';
export function AccountSettingsForm() { export function AccountSettingsForm() {
const t = useTranslations('settings'); const t = useTranslations('settings');
const tAuth = useTranslations('auth'); const tAuth = useTranslations('auth');
@@ -17,7 +21,6 @@ export function AccountSettingsForm() {
const [isLocalUser, setIsLocalUser] = useState<boolean | null>(null); const [isLocalUser, setIsLocalUser] = useState<boolean | null>(null);
const [username, setUsername] = useState<string>(''); const [username, setUsername] = useState<string>('');
const [hasAvatar, setHasAvatar] = useState(false); const [hasAvatar, setHasAvatar] = useState(false);
const [avatarSrc, setAvatarSrc] = useState('/api-proxy/users/me/avatar');
const [avatarKey, setAvatarKey] = useState(0); const [avatarKey, setAvatarKey] = useState(0);
// Password change state // Password change state
@@ -27,17 +30,24 @@ export function AccountSettingsForm() {
const [isPwPending, startPwTransition] = useTransition(); const [isPwPending, startPwTransition] = useTransition();
const pwFormRef = useRef<HTMLFormElement>(null); const pwFormRef = useRef<HTMLFormElement>(null);
// Avatar upload state // Avatar upload/delete state
const [avatarError, setAvatarError] = useState<string | null>(null); const [avatarError, setAvatarError] = useState<string | null>(null);
const [avatarSuccess, setAvatarSuccess] = useState(false); const [avatarSuccess, setAvatarSuccess] = useState(false);
const [isAvatarPending, startAvatarTransition] = useTransition(); const [isAvatarPending, startAvatarTransition] = useTransition();
// Accent color state
const [accentColor, setAccentColor] = useState<string>(DEFAULT_ACCENT);
const [accentSuccess, setAccentSuccess] = useState(false);
const [accentError, setAccentError] = useState<string | null>(null);
const [isAccentPending, startAccentTransition] = useTransition();
useEffect(() => { useEffect(() => {
fetchCurrentUser().then((u) => { fetchCurrentUser().then((u) => {
if (u) { if (u) {
setIsLocalUser(u.isLocalUser ?? false); setIsLocalUser(u.isLocalUser ?? false);
setUsername(u.displayName ?? u.username ?? ''); setUsername(u.displayName ?? u.username ?? '');
setHasAvatar(!!u.hasAvatar); setHasAvatar(!!u.hasAvatar);
setAccentColor(u.accentColor ?? DEFAULT_ACCENT);
} }
}); });
}, []); }, []);
@@ -60,30 +70,23 @@ export function AccountSettingsForm() {
startPwTransition(async () => { startPwTransition(async () => {
const result = await changePasswordAction(currentPassword, newPassword); 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<HTMLInputElement>) { async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setAvatarError(null); setAvatarError(null);
setAvatarSuccess(false); setAvatarSuccess(false);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
startAvatarTransition(async () => { startAvatarTransition(async () => {
const result = await uploadAvatarAction(formData); const result = await uploadAvatarAction(formData);
if (result.success) { if (result.success) {
setAvatarSuccess(true); setAvatarSuccess(true);
setHasAvatar(true); setHasAvatar(true);
setAvatarKey((k) => k + 1); setAvatarKey((k) => k + 1);
// Sync to auth store so header avatar updates immediately
bumpAvatarVersion(); bumpAvatarVersion();
if (user) setUser({ ...user, hasAvatar: true }); if (user) setUser({ ...user, hasAvatar: true });
} else { } 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() || '?'; const userInitial = username.charAt(0).toUpperCase() || '?';
return ( return (
@@ -102,7 +148,6 @@ export function AccountSettingsForm() {
{t('account.avatarLabel')} {t('account.avatarLabel')}
</h2> </h2>
<div className="flex items-center gap-4 mb-4"> <div className="flex items-center gap-4 mb-4">
{/* Avatar preview with initial fallback */}
<div className="relative h-16 w-16 shrink-0"> <div className="relative h-16 w-16 shrink-0">
{hasAvatar ? ( {hasAvatar ? (
<img <img
@@ -125,15 +170,16 @@ export function AccountSettingsForm() {
{avatarSuccess && ( {avatarSuccess && (
<div className="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-4 py-3 text-sm text-green-800 dark:text-green-200 mb-3"> <div className="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-4 py-3 text-sm text-green-800 dark:text-green-200 mb-3">
{t('account.uploadSuccess')} {hasAvatar ? t('account.uploadSuccess') : t('account.deleteAvatarSuccess')}
</div> </div>
)} )}
{avatarError && ( {avatarError && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-3"> <div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-3">
{t('account.uploadError')} {avatarError === 'deleteError' ? t('account.deleteAvatarError') : t('account.uploadError')}
</div> </div>
)} )}
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2 cursor-pointer"> <label className="inline-flex items-center gap-2 cursor-pointer">
<input <input
type="file" type="file"
@@ -146,6 +192,60 @@ export function AccountSettingsForm() {
{isAvatarPending ? '...' : t('account.uploadCta')} {isAvatarPending ? '...' : t('account.uploadCta')}
</span> </span>
</label> </label>
{hasAvatar && (
<button
type="button"
disabled={isAvatarPending}
onClick={handleDeleteAvatar}
className="inline-flex items-center justify-center rounded-md border border-destructive/40 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
>
{t('account.deleteAvatarCta')}
</button>
)}
</div>
</section>
{/* ── Accent color section ────────────────────────────────── */}
<section>
<h2 className="text-sm font-semibold text-foreground mb-1">
{t('account.accentColorLabel')}
</h2>
<p className="text-xs text-muted-foreground mb-4">{t('account.accentColorHelp')}</p>
<div className="flex items-center gap-3 mb-4">
<input
type="color"
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
className="h-10 w-16 cursor-pointer rounded-md border border-input p-1"
/>
<span className="font-mono text-sm text-muted-foreground">{accentColor}</span>
<button
type="button"
onClick={handleResetAccentColor}
disabled={isAccentPending}
className="text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
{t('account.accentColorReset')}
</button>
</div>
{accentSuccess && (
<div className="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-4 py-3 text-sm text-green-800 dark:text-green-200 mb-3">
{t('account.accentColorSuccess')}
</div>
)}
{accentError && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-3">
{t('account.accentColorError')}
</div>
)}
<button
type="button"
onClick={handleSaveAccentColor}
disabled={isAccentPending}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isAccentPending ? '...' : t('account.accentColorSave')}
</button>
</section> </section>
{/* ── Password section ────────────────────────────────────── */} {/* ── Password section ────────────────────────────────────── */}
+36
View File
@@ -14,6 +14,7 @@ export interface AuthUser {
mustChangePassword: boolean; mustChangePassword: boolean;
isLocalUser?: boolean; isLocalUser?: boolean;
hasAvatar?: boolean; hasAvatar?: boolean;
accentColor?: string | null;
} }
export interface LoginResult { export interface LoginResult {
@@ -200,6 +201,41 @@ export async function uploadAvatarAction(
} }
} }
export async function deleteAvatarAction(): Promise<{ success: boolean; error?: string }> {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
if (!session) return { success: false, error: 'notAuthenticated' };
try {
const response = await fetch(`${API_URL}/users/me/avatar`, {
method: 'DELETE',
headers: { Cookie: `session=${session}` },
});
if (!response.ok) return { success: false, error: 'deleteError' };
return { success: true };
} catch {
return { success: false, error: 'networkError' };
}
}
export async function updateAccentColorAction(
color: string | null,
): Promise<{ success: boolean; error?: string }> {
const cookieStore = await cookies();
const session = cookieStore.get('session')?.value;
if (!session) return { success: false, error: 'notAuthenticated' };
try {
const response = await fetch(`${API_URL}/users/me/accent-color`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Cookie: `session=${session}` },
body: JSON.stringify({ color }),
});
if (!response.ok) return { success: false, error: 'saveError' };
return { success: true };
} catch {
return { success: false, error: 'networkError' };
}
}
/** /**
* Fetch the current authenticated user from the API. * Fetch the current authenticated user from the API.
* Uses the session cookie for authentication. * Uses the session cookie for authentication.