Files
fotospiel-app/resources/js/admin/pages/ProfilePage.tsx

422 lines
17 KiB
TypeScript

import React from 'react';
import { Loader2, ShieldCheck, ShieldX, Mail, User as UserIcon, Globe, Lock } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { AdminLayout } from '../components/AdminLayout';
import { useAuth } from '../auth/context';
import {
fetchTenantProfile,
updateTenantProfile,
type TenantAccountProfile,
type UpdateTenantProfilePayload,
} from '../api';
import { getApiErrorMessage, isApiError } from '../lib/apiError';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
type FieldErrors = Record<string, string>;
function extractFieldErrors(error: unknown): FieldErrors {
if (isApiError(error) && error.meta && typeof error.meta.errors === 'object') {
const entries = error.meta.errors as Record<string, unknown>;
const mapped: FieldErrors = {};
Object.entries(entries).forEach(([key, value]) => {
if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') {
mapped[key] = value[0];
}
});
return mapped;
}
return {};
}
const DEFAULT_LOCALES = ['de', 'en'];
const AUTO_LOCALE_OPTION = '__auto__';
export default function ProfilePage() {
const { t } = useTranslation(['settings', 'common']);
const { refreshProfile } = useAuth();
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
const [loading, setLoading] = React.useState(true);
const [infoForm, setInfoForm] = React.useState({
name: '',
email: '',
preferred_locale: '',
});
const [passwordForm, setPasswordForm] = React.useState({
current_password: '',
password: '',
password_confirmation: '',
});
const [infoErrors, setInfoErrors] = React.useState<FieldErrors>({});
const [passwordErrors, setPasswordErrors] = React.useState<FieldErrors>({});
const [savingInfo, setSavingInfo] = React.useState(false);
const [savingPassword, setSavingPassword] = React.useState(false);
const availableLocales = React.useMemo(() => {
const candidates = new Set(DEFAULT_LOCALES);
if (typeof document !== 'undefined') {
const lang = document.documentElement.lang;
if (lang) {
const short = lang.toLowerCase().split('-')[0];
candidates.add(short);
}
}
if (profile?.preferred_locale) {
candidates.add(profile.preferred_locale.toLowerCase());
}
return Array.from(candidates).sort();
}, [profile?.preferred_locale]);
const selectedLocale = infoForm.preferred_locale && infoForm.preferred_locale !== '' ? infoForm.preferred_locale : AUTO_LOCALE_OPTION;
React.useEffect(() => {
let cancelled = false;
async function loadProfile(): Promise<void> {
setLoading(true);
try {
const data = await fetchTenantProfile();
if (cancelled) {
return;
}
setProfile(data);
setInfoForm({
name: data.name ?? '',
email: data.email ?? '',
preferred_locale: data.preferred_locale ?? '',
});
} catch (error) {
if (!cancelled) {
toast.error(getApiErrorMessage(error, t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.')));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadProfile();
return () => {
cancelled = true;
};
}, [t]);
const handleInfoSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setInfoErrors({});
setSavingInfo(true);
const payload: UpdateTenantProfilePayload = {
name: infoForm.name,
email: infoForm.email,
preferred_locale: infoForm.preferred_locale || null,
};
try {
const updated = await updateTenantProfile(payload);
setProfile(updated);
toast.success(t('settings:profile.toasts.updated', 'Profil wurde aktualisiert.'));
setInfoForm({
name: updated.name ?? '',
email: updated.email ?? '',
preferred_locale: updated.preferred_locale ?? '',
});
setPasswordForm((prev) => ({ ...prev, current_password: '' }));
await refreshProfile();
} catch (error) {
const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
toast.error(message);
const fieldErrors = extractFieldErrors(error);
if (Object.keys(fieldErrors).length > 0) {
setInfoErrors(fieldErrors);
}
} finally {
setSavingInfo(false);
}
},
[infoForm.email, infoForm.name, infoForm.preferred_locale, refreshProfile, t]
);
const handlePasswordSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPasswordErrors({});
setSavingPassword(true);
const payload: UpdateTenantProfilePayload = {
name: infoForm.name,
email: infoForm.email,
preferred_locale: infoForm.preferred_locale || null,
current_password: passwordForm.current_password || undefined,
password: passwordForm.password || undefined,
password_confirmation: passwordForm.password_confirmation || undefined,
};
try {
const updated = await updateTenantProfile(payload);
setProfile(updated);
toast.success(t('settings:profile.toasts.passwordChanged', 'Passwort wurde aktualisiert.'));
setPasswordForm({ current_password: '', password: '', password_confirmation: '' });
await refreshProfile();
} catch (error) {
const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
toast.error(message);
const fieldErrors = extractFieldErrors(error);
if (Object.keys(fieldErrors).length > 0) {
setPasswordErrors(fieldErrors);
}
} finally {
setSavingPassword(false);
}
},
[infoForm.email, infoForm.name, infoForm.preferred_locale, passwordForm, refreshProfile, t]
);
if (loading) {
return (
<AdminLayout title={t('settings:profile.title', 'Profil')} subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}>
<div className="flex min-h-[320px] items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
{t('common:loading', 'Wird geladen …')}
</div>
</div>
</AdminLayout>
);
}
if (!profile) {
return (
<AdminLayout title={t('settings:profile.title', 'Profil')} subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}>
<div className="rounded-3xl border border-rose-200/60 bg-rose-50/70 p-8 text-center text-sm text-rose-600">
{t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.')}
</div>
</AdminLayout>
);
}
return (
<AdminLayout
title={t('settings:profile.title', 'Profil')}
subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}
>
<Card className="border-0 bg-white/90 shadow-xl shadow-rose-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<UserIcon className="h-5 w-5 text-rose-500" />
{t('settings:profile.sections.account.heading', 'Account-Informationen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('settings:profile.sections.account.description', 'Passe Name, E-Mail und Sprache deiner Admin-Oberfläche an.')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInfoSubmit} className="space-y-6">
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-name" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<UserIcon className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.name', 'Anzeigename')}
</Label>
<Input
id="profile-name"
value={infoForm.name}
onChange={(event) => setInfoForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('settings:profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
/>
{infoErrors.name && <p className="text-sm text-rose-500">{infoErrors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-email" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<Mail className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.email', 'E-Mail-Adresse')}
</Label>
<Input
id="profile-email"
type="email"
value={infoForm.email}
onChange={(event) => setInfoForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="admin@example.com"
/>
{infoErrors.email && <p className="text-sm text-rose-500">{infoErrors.email}</p>}
</div>
</div>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-locale" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<Globe className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.locale', 'Bevorzugte Sprache')}
</Label>
<Select
value={selectedLocale}
onValueChange={(value) =>
setInfoForm((prev) => ({ ...prev, preferred_locale: value === AUTO_LOCALE_OPTION ? '' : value }))
}
>
<SelectTrigger id="profile-locale" aria-label={t('settings:profile.fields.locale', 'Bevorzugte Sprache')}>
<SelectValue placeholder={t('settings:profile.placeholders.locale', 'Systemsprache verwenden')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={AUTO_LOCALE_OPTION}>{t('settings:profile.locale.auto', 'Automatisch')}</SelectItem>
{availableLocales.map((locale) => (
<SelectItem key={locale} value={locale} className="capitalize">
{locale}
</SelectItem>
))}
</SelectContent>
</Select>
{infoErrors.preferred_locale && <p className="text-sm text-rose-500">{infoErrors.preferred_locale}</p>}
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-sm font-semibold text-slate-800">
{profile.email_verified ? (
<>
<ShieldCheck className="h-4 w-4 text-emerald-500" />
{t('settings:profile.status.emailVerified', 'E-Mail bestätigt')}
</>
) : (
<>
<ShieldX className="h-4 w-4 text-rose-500" />
{t('settings:profile.status.emailNotVerified', 'Bestätigung erforderlich')}
</>
)}
</Label>
<div className="rounded-2xl border border-slate-200/70 bg-slate-50/70 p-3 text-sm text-slate-600">
{profile.email_verified
? t('settings:profile.status.verifiedHint', 'Bestätigt am {{date}}.', {
date: profile.email_verified_at ? new Date(profile.email_verified_at).toLocaleString() : '',
})
: t('settings:profile.status.unverifiedHint', 'Wir senden dir eine neue Bestätigung, sobald du die E-Mail änderst.')}
</div>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button type="submit" disabled={savingInfo} className="flex items-center gap-2">
{savingInfo && <Loader2 className="h-4 w-4 animate-spin" />}
{t('settings:profile.actions.save', 'Speichern')}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
if (!profile) {
return;
}
setInfoForm({
name: profile.name ?? '',
email: profile.email ?? '',
preferred_locale: profile.preferred_locale ?? '',
});
setInfoErrors({});
}}
>
{t('common:actions.reset', 'Zurücksetzen')}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card className="border-0 bg-white/90 shadow-xl shadow-indigo-100/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Lock className="h-5 w-5 text-indigo-500" />
{t('settings:profile.sections.password.heading', 'Passwort ändern')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('settings:profile.sections.password.description', 'Wähle ein sicheres Passwort, um dein Admin-Konto zu schützen.')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handlePasswordSubmit} className="space-y-6">
<div className="grid gap-6 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="profile-current-password" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.currentPassword', 'Aktuelles Passwort')}
</Label>
<Input
id="profile-current-password"
type="password"
autoComplete="current-password"
value={passwordForm.current_password}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, current_password: event.target.value }))}
/>
{passwordErrors.current_password && <p className="text-sm text-rose-500">{passwordErrors.current_password}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-new-password" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.newPassword', 'Neues Passwort')}
</Label>
<Input
id="profile-new-password"
type="password"
autoComplete="new-password"
value={passwordForm.password}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, password: event.target.value }))}
/>
{passwordErrors.password && <p className="text-sm text-rose-500">{passwordErrors.password}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-password-confirmation" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.passwordConfirmation', 'Passwort bestätigen')}
</Label>
<Input
id="profile-password-confirmation"
type="password"
autoComplete="new-password"
value={passwordForm.password_confirmation}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, password_confirmation: event.target.value }))}
/>
{passwordErrors.password_confirmation && <p className="text-sm text-rose-500">{passwordErrors.password_confirmation}</p>}
</div>
</div>
<div className="rounded-2xl border border-slate-200/70 bg-slate-50/70 p-4 text-xs text-slate-600">
{t('settings:profile.sections.password.hint', 'Dein Passwort muss mindestens 8 Zeichen lang sein und eine Mischung aus Buchstaben und Zahlen enthalten.')}
</div>
<div className="flex flex-wrap gap-3">
<Button type="submit" variant="secondary" disabled={savingPassword} className="flex items-center gap-2">
{savingPassword && <Loader2 className="h-4 w-4 animate-spin" />}
{t('settings:profile.actions.updatePassword', 'Passwort aktualisieren')}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setPasswordForm({ current_password: '', password: '', password_confirmation: '' });
setPasswordErrors({});
}}
>
{t('common:actions.reset', 'Zurücksetzen')}
</Button>
</div>
</form>
</CardContent>
</Card>
</AdminLayout>
);
}