Files
tessera-ctl/apps/web/src/components/settings/account-settings-form.tsx
T
schalli 30e4bc3a4f style(web): Seiten auf Mosaik-Muster umgestellt
Einheitliche Seitenkoepfe mit Modul-Kachel, Knopf- und Linkklassen
statt gelber Schrift, Karten ohne harte Rahmen, Tabellenkoepfe ohne
Grossbuchstaben, Marktplatz mit Fluent-Reitern, Filterpillen und
Kachel-Karten, Einstellungs-/Verwaltungsnavigation mit Auswahlpille,
leere Zustaende mit Symbol im Kreis, Proxmox ohne leuchtende Schatten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:49:01 +02:00

386 lines
15 KiB
TypeScript

'use client';
import { useState, useTransition, useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import {
fetchCurrentUser,
changePasswordAction,
uploadAvatarAction,
deleteAvatarAction,
updateAccentColorAction,
} from '@/lib/auth-actions';
import { useAuthStore } from '@/lib/stores/auth-store';
import { BRAND_YELLOW } from '@/components/brand/brand';
import { normalizeHexColor } from '@/lib/color';
const DEFAULT_ACCENT = BRAND_YELLOW;
export function AccountSettingsForm() {
const t = useTranslations('settings');
const tAuth = useTranslations('auth');
const { user, setUser, bumpAvatarVersion } = useAuthStore();
const [isLocalUser, setIsLocalUser] = useState<boolean | null>(null);
const [username, setUsername] = useState<string>('');
const [hasAvatar, setHasAvatar] = useState(false);
const [avatarKey, setAvatarKey] = useState(0);
// Password change state
const [pwError, setPwError] = useState<string | null>(null);
const [pwSuccess, setPwSuccess] = useState(false);
const [pwMismatch, setPwMismatch] = useState(false);
const [isPwPending, startPwTransition] = useTransition();
const pwFormRef = useRef<HTMLFormElement>(null);
// Avatar upload/delete state
const [avatarError, setAvatarError] = useState<string | null>(null);
const [avatarSuccess, setAvatarSuccess] = useState(false);
const [isAvatarPending, startAvatarTransition] = useTransition();
// Accent color state
const [accentColor, setAccentColor] = useState<string>(DEFAULT_ACCENT);
const [hexInput, setHexInput] = useState<string>(DEFAULT_ACCENT);
const isHexValid = normalizeHexColor(hexInput) !== null;
const [accentSuccess, setAccentSuccess] = useState(false);
const [accentError, setAccentError] = useState<string | null>(null);
const [isAccentPending, startAccentTransition] = useTransition();
useEffect(() => {
fetchCurrentUser().then((u) => {
if (u) {
setIsLocalUser(u.isLocalUser ?? false);
setUsername(u.displayName ?? u.username ?? '');
setHasAvatar(!!u.hasAvatar);
const c = u.accentColor ?? DEFAULT_ACCENT;
setAccentColor(c);
setHexInput(c);
}
});
}, []);
async function handlePasswordSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPwError(null);
setPwSuccess(false);
setPwMismatch(false);
const formData = new FormData(e.currentTarget);
const currentPassword = formData.get('currentPassword') as string;
const newPassword = formData.get('newPassword') as string;
const confirmPassword = formData.get('confirmPassword') as string;
if (newPassword !== confirmPassword) {
setPwMismatch(true);
return;
}
startPwTransition(async () => {
const result = await changePasswordAction(currentPassword, newPassword);
if (result) setPwError(result.error);
});
}
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
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);
bumpAvatarVersion();
if (user) setUser({ ...user, hasAvatar: true });
} else {
setAvatarError(result.error ?? 'uploadError');
}
});
}
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() {
if (!isHexValid) return;
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);
setHexInput(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 (
<div className="space-y-8 max-w-lg">
{/* ── Avatar section ─────────────────────────────────────── */}
<section>
<h2 className="text-sm font-semibold text-foreground mb-4">
{t('account.avatarLabel')}
</h2>
<div className="flex items-center gap-4 mb-4">
<div className="relative h-16 w-16 shrink-0">
{hasAvatar ? (
<img
key={avatarKey}
src={`/api-proxy/users/me/avatar?t=${avatarKey}`}
alt=""
aria-hidden="true"
className="h-16 w-16 rounded-full object-cover"
onError={() => setHasAvatar(false)}
/>
) : (
<div className="h-16 w-16 rounded-full bg-primary flex items-center justify-center text-xl font-medium text-primary-foreground">
{userInitial}
</div>
)}
</div>
<div className="text-sm text-muted-foreground">
{t('account.avatarHelp')}
</div>
</div>
{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">
{hasAvatar ? t('account.uploadSuccess') : t('account.deleteAvatarSuccess')}
</div>
)}
{avatarError && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-3">
{avatarError === 'deleteError' ? t('account.deleteAvatarError') : t('account.uploadError')}
</div>
)}
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
disabled={isAvatarPending}
onChange={handleAvatarUpload}
/>
<span className="btn btn-primary">
{isAvatarPending ? '...' : t('account.uploadCta')}
</span>
</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-1">
<input
type="color"
value={accentColor}
onChange={(e) => {
setAccentColor(e.target.value);
setHexInput(e.target.value);
}}
className="h-10 w-16 cursor-pointer rounded-md border border-input p-1"
/>
<input
id="accentColorHex"
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
maxLength={7}
placeholder={DEFAULT_ACCENT}
value={hexInput}
aria-label={t('account.accentColorHex')}
aria-invalid={!isHexValid}
onChange={(e) => {
const raw = e.target.value;
setHexInput(raw);
const n = normalizeHexColor(raw);
if (n) setAccentColor(n);
}}
onBlur={() => {
const n = normalizeHexColor(hexInput);
if (n) setHexInput(n);
}}
className={`h-10 w-28 rounded-md border bg-background px-3 py-2 font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isHexValid ? 'border-input' : 'border-destructive'
}`}
/>
<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>
{!isHexValid && (
<p className="text-xs text-destructive mb-3">{t('account.accentColorHexInvalid')}</p>
)}
{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 || !isHexValid}
className="btn btn-primary"
>
{isAccentPending ? '...' : t('account.accentColorSave')}
</button>
</section>
{/* ── Password section ────────────────────────────────────── */}
<section>
<h2 className="text-sm font-semibold text-foreground mb-4">
{t('account.passwordSectionTitle')}
</h2>
{isLocalUser === false && (
<div className="rounded-md bg-muted px-4 py-3 text-sm text-muted-foreground">
{t('account.ldapManagedNotice')}
</div>
)}
{isLocalUser === true && (
<>
{pwSuccess && (
<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-4">
{tAuth('changePassword.success')}
</div>
)}
{pwError && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-4">
{tAuth(`changePassword.${pwError}`)}
</div>
)}
{pwMismatch && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive mb-4">
{tAuth('changePassword.passwordMismatch')}
</div>
)}
<form ref={pwFormRef} onSubmit={handlePasswordSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="currentPassword" className="text-sm font-medium text-foreground">
{tAuth('changePassword.currentPassword')}
</label>
<input
id="currentPassword"
name="currentPassword"
type="password"
required
autoComplete="current-password"
onChange={() => { setPwError(null); setPwMismatch(false); }}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="space-y-2">
<label htmlFor="newPassword" className="text-sm font-medium text-foreground">
{tAuth('changePassword.newPassword')}
</label>
<input
id="newPassword"
name="newPassword"
type="password"
required
minLength={8}
autoComplete="new-password"
onChange={() => { setPwError(null); setPwMismatch(false); }}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="text-sm font-medium text-foreground">
{tAuth('changePassword.confirmPassword')}
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength={8}
autoComplete="new-password"
onChange={() => { setPwError(null); setPwMismatch(false); }}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<button
type="submit"
disabled={isPwPending}
className="btn btn-primary"
>
{isPwPending ? (
<svg aria-hidden="true" className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
tAuth('changePassword.submit')
)}
</button>
</form>
</>
)}
</section>
</div>
);
}