422 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|