The web app grows its account surface: login (with next-redirect, unverified-hint + resend), signup (react-hook-form + shared Zod schemas, field-level api errors, closed-registration state fed by the new public GET /auth/registration), e-mail verification, forgot/reset password; a settings page with profile (locale applies immediately), password change, and active-session management; a Site-Admin page for instance name, default locale, and registration mode. AuthProvider holds /auth/me, applies the profile locale, and backs route guards (RequireAuth/RequireAnonymous/RequireSiteAdmin); the top bar gains a user menu. All strings ship in the new auth/settings namespaces (de+ en); the exception filter now preserves handler-specific error codes. Verified live: signup → Mailpit → verify → login → profile through the Vite proxy. Closes #16 Closes #17 Closes #18 Closes #19 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
209 lines
6.6 KiB
TypeScript
209 lines
6.6 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { changePasswordInputSchema, updateProfileInputSchema } from '@dorfteich/shared';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useAuth } from '../auth/auth-context';
|
|
import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms';
|
|
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
|
|
|
interface SessionView {
|
|
id: string;
|
|
createdAt: string;
|
|
lastSeenAt: string;
|
|
userAgent: string | null;
|
|
current: boolean;
|
|
}
|
|
|
|
export function SettingsPage(): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<>
|
|
<h1>{t('settings:title')}</h1>
|
|
<ProfileSection />
|
|
<PasswordSection />
|
|
<SessionsSection />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ProfileSection(): React.JSX.Element {
|
|
const { t, i18n } = useTranslation();
|
|
const { user, refresh } = useAuth();
|
|
const [error, setError] = useState<unknown>(null);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
const form = useForm<{ displayName?: string; locale?: 'de' | 'en' }>({
|
|
resolver: zodResolver(updateProfileInputSchema),
|
|
values: { displayName: user?.displayName, locale: user?.locale },
|
|
});
|
|
|
|
const onSubmit = form.handleSubmit(async (input) => {
|
|
setError(null);
|
|
setSaved(false);
|
|
try {
|
|
await apiPatch('/users/me', input);
|
|
// The new language applies immediately, before the refetch lands.
|
|
if (input.locale) await i18n.changeLanguage(input.locale);
|
|
await refresh();
|
|
setSaved(true);
|
|
} catch (err) {
|
|
setError(err);
|
|
applyFieldErrors(err, (name, fieldError) =>
|
|
form.setError(name as 'displayName' | 'locale', fieldError),
|
|
);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<section className="settings-section">
|
|
<h2>{t('settings:profile.title')}</h2>
|
|
<form onSubmit={onSubmit} noValidate>
|
|
<FormError error={error} />
|
|
<FormSuccess message={saved ? t('settings:profile.saved') : null} />
|
|
<Field
|
|
label={t('settings:profile.displayName')}
|
|
error={form.formState.errors.displayName?.message}
|
|
>
|
|
<input type="text" autoComplete="name" {...form.register('displayName')} />
|
|
</Field>
|
|
<Field label={t('settings:profile.locale')}>
|
|
<select {...form.register('locale')}>
|
|
<option value="de">{t('settings:profile.locales.de')}</option>
|
|
<option value="en">{t('settings:profile.locales.en')}</option>
|
|
</select>
|
|
</Field>
|
|
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
|
{t('settings:profile.save')}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function PasswordSection(): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
const [error, setError] = useState<unknown>(null);
|
|
const [changed, setChanged] = useState(false);
|
|
|
|
const form = useForm<{ currentPassword: string; newPassword: string }>({
|
|
resolver: zodResolver(changePasswordInputSchema),
|
|
});
|
|
|
|
const onSubmit = form.handleSubmit(async (input) => {
|
|
setError(null);
|
|
setChanged(false);
|
|
try {
|
|
await apiPost('/users/me/change-password', input);
|
|
form.reset();
|
|
setChanged(true);
|
|
} catch (err) {
|
|
setError(err);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<section className="settings-section">
|
|
<h2>{t('settings:password.title')}</h2>
|
|
<form onSubmit={onSubmit} noValidate>
|
|
<FormError error={error} />
|
|
<FormSuccess message={changed ? t('settings:password.changed') : null} />
|
|
<Field
|
|
label={t('settings:password.current')}
|
|
error={form.formState.errors.currentPassword?.message}
|
|
>
|
|
<input
|
|
type="password"
|
|
autoComplete="current-password"
|
|
{...form.register('currentPassword')}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label={t('settings:password.new')}
|
|
hint={t('auth:signup.passwordHint')}
|
|
error={form.formState.errors.newPassword?.message}
|
|
>
|
|
<input type="password" autoComplete="new-password" {...form.register('newPassword')} />
|
|
</Field>
|
|
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
|
{t('settings:password.submit')}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function SessionsSection(): React.JSX.Element {
|
|
const { t, i18n } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const sessions = useQuery({
|
|
queryKey: ['sessions'],
|
|
queryFn: () => apiGet<SessionView[]>('/users/me/sessions'),
|
|
});
|
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
|
|
|
const revoke = useMutation({
|
|
mutationFn: (id: string) => apiDelete(`/users/me/sessions/${id}`),
|
|
onSuccess: invalidate,
|
|
});
|
|
const revokeOthers = useMutation({
|
|
mutationFn: () => apiDelete('/users/me/sessions'),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const formatTime = (iso: string) =>
|
|
new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium', timeStyle: 'short' }).format(
|
|
new Date(iso),
|
|
);
|
|
|
|
const others = (sessions.data ?? []).filter((s) => !s.current);
|
|
|
|
return (
|
|
<section className="settings-section">
|
|
<h2>{t('settings:sessions.title')}</h2>
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>{t('settings:sessions.device')}</th>
|
|
<th>{t('settings:sessions.created')}</th>
|
|
<th>{t('settings:sessions.lastSeen')}</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(sessions.data ?? []).map((session) => (
|
|
<tr key={session.id}>
|
|
<td>
|
|
{session.userAgent ?? '—'}
|
|
{session.current && <span className="badge">{t('settings:sessions.current')}</span>}
|
|
</td>
|
|
<td>{formatTime(session.createdAt)}</td>
|
|
<td>{formatTime(session.lastSeenAt)}</td>
|
|
<td>
|
|
{!session.current && (
|
|
<button
|
|
type="button"
|
|
className="linklike"
|
|
onClick={() => revoke.mutate(session.id)}
|
|
>
|
|
{t('settings:sessions.revoke')}
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{others.length > 0 ? (
|
|
<button type="button" className="button" onClick={() => revokeOthers.mutate()}>
|
|
{t('settings:sessions.revokeAll')}
|
|
</button>
|
|
) : (
|
|
<p className="sidebar__hint">{t('settings:sessions.empty')}</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|