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:
@@ -200,6 +200,7 @@ export class AuthService {
|
|||||||
passwordHash: true,
|
passwordHash: true,
|
||||||
ldapDn: true,
|
ldapDn: true,
|
||||||
avatarPath: true,
|
avatarPath: true,
|
||||||
|
accentColor: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user