Files
fotospiel-app/resources/js/admin/mobile/ProfileAccountPage.tsx
Codex Agent 7a6f489b8b
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add tenant admin account edit page
2026-01-13 15:09:25 +01:00

303 lines
11 KiB
TypeScript

import React from 'react';
import { useTranslation } from 'react-i18next';
import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
import { fetchTenantProfile, updateTenantProfile, type TenantAccountProfile } from '../api';
import { getApiErrorMessage, getApiValidationMessage } from '../lib/apiError';
import { ADMIN_PROFILE_PATH } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
import i18n from '../i18n';
type ProfileFormState = {
name: string;
email: string;
preferredLocale: string;
currentPassword: string;
password: string;
passwordConfirmation: string;
};
const LOCALE_OPTIONS = [
{ value: '', labelKey: 'profile.locale.auto', fallback: 'Automatisch' },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
];
export default function MobileProfileAccountPage() {
const { t } = useTranslation('settings');
const { text, muted, danger, subtle, primary, accentSoft } = useAdminTheme();
const back = useBackNavigation(ADMIN_PROFILE_PATH);
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
const [form, setForm] = React.useState<ProfileFormState>({
name: '',
email: '',
preferredLocale: '',
currentPassword: '',
password: '',
passwordConfirmation: '',
});
const [loading, setLoading] = React.useState(true);
const [savingAccount, setSavingAccount] = React.useState(false);
const [savingPassword, setSavingPassword] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const loadErrorMessage = t('profile.errors.load', 'Profil konnte nicht geladen werden.');
const dateFormatter = React.useMemo(
() =>
new Intl.DateTimeFormat(i18n.language || 'de', {
day: '2-digit',
month: 'long',
year: 'numeric',
}),
[i18n.language],
);
React.useEffect(() => {
(async () => {
setLoading(true);
try {
const data = await fetchTenantProfile();
setProfile(data);
setForm((prev) => ({
...prev,
name: data.name ?? '',
email: data.email ?? '',
preferredLocale: data.preferred_locale ?? '',
}));
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, loadErrorMessage));
} finally {
setLoading(false);
}
})();
}, []);
const verifiedAt = profile?.email_verified_at ? new Date(profile.email_verified_at) : null;
const verifiedDate = verifiedAt ? dateFormatter.format(verifiedAt) : null;
const emailStatusLabel = profile?.email_verified
? t('profile.status.emailVerified', 'E-Mail bestätigt')
: t('profile.status.emailNotVerified', 'Bestätigung erforderlich');
const emailHint = profile?.email_verified
? t('profile.status.verifiedHint', 'Bestätigt am {{date}}.', { date: verifiedDate ?? '' })
: t('profile.status.unverifiedHint', 'Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung.');
const buildPayload = (includePassword: boolean) => ({
name: form.name.trim(),
email: form.email.trim(),
preferred_locale: form.preferredLocale ? form.preferredLocale : null,
...(includePassword
? {
current_password: form.currentPassword,
password: form.password,
password_confirmation: form.passwordConfirmation,
}
: {}),
});
const handleAccountSave = async () => {
setSavingAccount(true);
try {
const updated = await updateTenantProfile(buildPayload(false));
setProfile(updated);
setError(null);
toast.success(t('profile.toasts.updated', 'Profil wurde aktualisiert.'));
} catch (err) {
const message = getApiValidationMessage(err, t('profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
setError(message);
toast.error(message);
} finally {
setSavingAccount(false);
}
};
const handlePasswordSave = async () => {
setSavingPassword(true);
try {
const updated = await updateTenantProfile(buildPayload(true));
setProfile(updated);
setError(null);
setForm((prev) => ({
...prev,
currentPassword: '',
password: '',
passwordConfirmation: '',
}));
toast.success(t('profile.toasts.passwordChanged', 'Passwort wurde aktualisiert.'));
} catch (err) {
const message = getApiValidationMessage(err, t('profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
setError(message);
toast.error(message);
} finally {
setSavingPassword(false);
}
};
const passwordReady =
form.currentPassword.trim().length > 0 &&
form.password.trim().length > 0 &&
form.passwordConfirmation.trim().length > 0;
return (
<MobileShell
activeTab="profile"
title={t('profile.title', 'Profil')}
onBack={back}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<XStack alignItems="center" space="$3">
<XStack
width={48}
height={48}
borderRadius={16}
alignItems="center"
justifyContent="center"
backgroundColor={accentSoft}
>
<User size={20} color={primary} />
</XStack>
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{form.name || profile?.email || t('profile.title', 'Profil')}
</Text>
<Text fontSize="$sm" color={muted}>
{form.email || profile?.email || '—'}
</Text>
</YStack>
</XStack>
<XStack alignItems="center" space="$2" flexWrap="wrap">
{profile?.email_verified ? (
<CheckCircle2 size={14} color={subtle} />
) : (
<MailWarning size={14} color={subtle} />
)}
<PillBadge tone={profile?.email_verified ? 'success' : 'warning'}>
{emailStatusLabel}
</PillBadge>
<Text fontSize="$xs" color={muted}>
{emailHint}
</Text>
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<User size={16} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.sections.account.heading', 'Account-Informationen')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
{t('profile.loading', 'Lädt ...')}
</Text>
) : (
<YStack space="$3">
<MobileField label={t('profile.fields.name', 'Anzeigename')}>
<MobileInput
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.email', 'E-Mail-Adresse')}>
<MobileInput
value={form.email}
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="mail@beispiel.de"
type="email"
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.locale', 'Bevorzugte Sprache')}>
<MobileSelect
value={form.preferredLocale}
onChange={(event) => setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))}
>
{LOCALE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label ?? t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
<CTAButton
label={t('profile.actions.save', 'Speichern')}
onPress={handleAccountSave}
disabled={savingAccount || loading}
loading={savingAccount}
/>
</YStack>
)}
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Lock size={16} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.sections.password.heading', 'Passwort ändern')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')}
</Text>
<YStack space="$3">
<MobileField label={t('profile.fields.currentPassword', 'Aktuelles Passwort')}>
<MobileInput
value={form.currentPassword}
onChange={(event) => setForm((prev) => ({ ...prev, currentPassword: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.newPassword', 'Neues Passwort')} hint={t('profile.sections.password.hint', 'Nutze mindestens 8 Zeichen und kombiniere Buchstaben sowie Zahlen für mehr Sicherheit.')}>
<MobileInput
value={form.password}
onChange={(event) => setForm((prev) => ({ ...prev, password: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.passwordConfirmation', 'Passwort bestätigen')}>
<MobileInput
value={form.passwordConfirmation}
onChange={(event) => setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<CTAButton
label={t('profile.actions.updatePassword', 'Passwort aktualisieren')}
onPress={handlePasswordSave}
disabled={!passwordReady || savingPassword || loading}
loading={savingPassword}
tone="ghost"
/>
</YStack>
</MobileCard>
</MobileShell>
);
}