die tenant admin oauth authentifizierung wurde implementiert und funktioniert jetzt. Zudem wurde das marketing frontend dashboard implementiert.
This commit is contained in:
421
resources/js/admin/pages/ProfilePage.tsx
Normal file
421
resources/js/admin/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
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(): JSX.Element {
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user