30e4bc3a4f
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>
386 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|