Add auth, settings, and admin UI to the SPA

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>
This commit is contained in:
Claude Fable 5 2026-07-05 05:35:56 +02:00
parent 1314096c94
commit 0bc80c9f93
27 changed files with 1468 additions and 26 deletions

View File

@ -15,6 +15,7 @@ import type { Response } from 'express';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { RateLimit } from '../rate-limit/rate-limit.guard'; import { RateLimit } from '../rate-limit/rate-limit.guard';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { AuthedRequest, Public, SESSION_COOKIE, toCurrentUser } from './auth.guard'; import { AuthedRequest, Public, SESSION_COOKIE, toCurrentUser } from './auth.guard';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { SessionsService } from './sessions.service'; import { SessionsService } from './sessions.service';
@ -25,8 +26,16 @@ export class AuthController {
private readonly auth: AuthService, private readonly auth: AuthService,
private readonly sessions: SessionsService, private readonly sessions: SessionsService,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly settings: InstanceSettingsService,
) {} ) {}
/** Public: the SPA hides the signup route while registration is closed. */
@Public()
@Get('registration')
async registration(): Promise<{ mode: 'open' | 'closed' }> {
return { mode: await this.settings.get('auth.registrationMode') };
}
@Public() @Public()
@Post('signup') @Post('signup')
@HttpCode(201) @HttpCode(201)

View File

@ -23,17 +23,18 @@ export class ApiExceptionFilter implements ExceptionFilter {
if (exception instanceof HttpException) { if (exception instanceof HttpException) {
const status = exception.getStatus(); const status = exception.getStatus();
const code = codeForStatus(status); const payload = exception.getResponse() as
string | { code?: string; details?: Record<string, string[]> };
// A specific code thrown by the handler (e.g. registration_closed)
// wins over the generic status mapping.
const code =
typeof payload === 'object' && payload.code ? payload.code : codeForStatus(status);
// Catalogued codes get the localized text; uncatalogued ones keep // Catalogued codes get the localized text; uncatalogued ones keep
// the (developer-provided, English) exception message as fallback. // the (developer-provided, English) exception message as fallback.
const message = translateErrorCode(code, language) ?? exception.message; const message = translateErrorCode(code, language) ?? exception.message;
// Field-level validation/conflict details pass through untouched — // Field-level validation/conflict details pass through untouched —
// they carry i18n keys the client resolves per field. // they carry i18n keys the client resolves per field.
const payload = exception.getResponse(); const details = typeof payload === 'object' ? payload.details : undefined;
const details =
typeof payload === 'object' && payload !== null && 'details' in payload
? (payload as { details: Record<string, string[]> }).details
: undefined;
response.status(status).json(apiError(code, message, details)); response.status(status).json(apiError(code, message, details));
return; return;
} }

View File

@ -15,13 +15,16 @@
}, },
"dependencies": { "dependencies": {
"@dorfteich/shared": "workspace:*", "@dorfteich/shared": "workspace:*",
"@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"i18next": "^26.3.4", "i18next": "^26.3.4",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.80.0",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"react-router-dom": "^7.1.0" "react-router-dom": "^7.1.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",

View File

@ -1,14 +1,39 @@
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards';
import { AppLayout } from './layout/AppLayout'; import { AppLayout } from './layout/AppLayout';
import { AdminSettingsPage } from './pages/AdminSettingsPage';
import { HomePage } from './pages/HomePage'; import { HomePage } from './pages/HomePage';
import { NotFoundPage } from './pages/NotFoundPage'; import { NotFoundPage } from './pages/NotFoundPage';
import { SettingsPage } from './pages/SettingsPage';
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
import { LoginPage } from './pages/auth/LoginPage';
import { ResetPasswordPage } from './pages/auth/ResetPasswordPage';
import { SignupPage } from './pages/auth/SignupPage';
import { VerifyEmailPage } from './pages/auth/VerifyEmailPage';
export function App(): React.JSX.Element { export function App(): React.JSX.Element {
return ( return (
<Routes> <Routes>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
<Route element={<RequireAnonymous />}>
<Route path="login" element={<LoginPage />} />
<Route path="signup" element={<SignupPage />} />
<Route path="forgot-password" element={<ForgotPasswordPage />} />
</Route>
{/* Verify/reset work regardless of session state (mail links). */}
<Route path="verify-email" element={<VerifyEmailPage />} />
<Route path="reset-password" element={<ResetPasswordPage />} />
<Route element={<RequireAuth />}>
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route element={<RequireSiteAdmin />}>
<Route path="admin" element={<AdminSettingsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>
</Routes> </Routes>

View File

@ -0,0 +1,57 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { CurrentUser } from '@dorfteich/shared';
import { createContext, useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiError, apiGet, apiPost } from '../lib/api';
interface AuthContextValue {
user: CurrentUser | null;
isLoading: boolean;
/** Re-reads /auth/me — call after login or profile changes. */
refresh: () => Promise<unknown>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
async function fetchCurrentUser(): Promise<CurrentUser | null> {
try {
return await apiGet<CurrentUser>('/auth/me');
} catch (error) {
if (error instanceof ApiError && error.status === 401) return null;
throw error;
}
}
export function AuthProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
const queryClient = useQueryClient();
const { i18n } = useTranslation();
const query = useQuery({ queryKey: ['auth', 'me'], queryFn: fetchCurrentUser, retry: false });
// The profile locale wins over browser detection (issue #17).
const userLocale = query.data?.locale;
useEffect(() => {
if (userLocale && i18n.language !== userLocale) {
void i18n.changeLanguage(userLocale);
}
}, [userLocale, i18n]);
const value: AuthContextValue = {
user: query.data ?? null,
isLoading: query.isPending,
refresh: () => queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }),
logout: async () => {
await apiPost('/auth/logout');
queryClient.setQueryData(['auth', 'me'], null);
queryClient.clear();
},
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth requires an <AuthProvider> ancestor');
return context;
}

View File

@ -0,0 +1,30 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from './auth-context';
/** Wraps private routes: anonymous visitors land on /login and return after. */
export function RequireAuth(): React.JSX.Element {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <></>;
if (!user) {
const next = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?next=${next}`} replace />;
}
return <Outlet />;
}
/** Wraps auth pages: signed-in users have no business on /login etc. */
export function RequireAnonymous(): React.JSX.Element {
const { user, isLoading } = useAuth();
if (isLoading) return <></>;
if (user) return <Navigate to="/" replace />;
return <Outlet />;
}
export function RequireSiteAdmin(): React.JSX.Element {
const { user, isLoading } = useAuth();
if (isLoading) return <></>;
if (!user?.isSiteAdmin) return <Navigate to="/" replace />;
return <Outlet />;
}

View File

@ -0,0 +1,70 @@
import { useTranslation } from 'react-i18next';
import { ApiError } from '../lib/api';
/**
* Small form building blocks shared by the auth/settings pages. Error
* values are i18n keys from the shared Zod schemas or the api's
* field-level details; they resolve through the errors namespace.
*/
export function Field({
label,
error,
hint,
children,
}: {
label: string;
error?: string;
hint?: string;
children: React.ReactNode;
}): React.JSX.Element {
const { t } = useTranslation();
return (
<label className="field">
<span className="field__label">{label}</span>
{children}
{hint && !error && <span className="field__hint">{hint}</span>}
{error && (
<span className="field__error" role="alert">
{t(`errors:${error}`, t('errors:bad_request'))}
</span>
)}
</label>
);
}
/** Top-of-form banner for non-field errors (wrong password, closed registration…). */
export function FormError({ error }: { error: unknown }): React.JSX.Element | null {
const { t } = useTranslation();
if (!error) return null;
const code = error instanceof ApiError ? error.body.code : 'internal_error';
// Field-level details render at their fields; suppress the banner then.
if (error instanceof ApiError && error.body.details && code === 'bad_request') return null;
return (
<p className="form-banner form-banner--error" role="alert">
{t(`errors:${code}`, t('errors:internal_error'))}
</p>
);
}
export function FormSuccess({ message }: { message: string | null }): React.JSX.Element | null {
if (!message) return null;
return (
<p className="form-banner form-banner--ok" role="status">
{message}
</p>
);
}
/** Maps api field details onto react-hook-form's setError. */
export function applyFieldErrors(
error: unknown,
setError: (name: string, error: { message: string }) => void,
): void {
if (error instanceof ApiError && error.body.details) {
for (const [field, keys] of Object.entries(error.body.details)) {
if (keys[0]) setError(field, { message: keys[0] });
}
}
}

View File

@ -1,7 +1,11 @@
import deAuth from '@dorfteich/shared/i18n/de/auth.json';
import deCommon from '@dorfteich/shared/i18n/de/common.json'; import deCommon from '@dorfteich/shared/i18n/de/common.json';
import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAuth from '@dorfteich/shared/i18n/en/auth.json';
import enCommon from '@dorfteich/shared/i18n/en/common.json'; import enCommon from '@dorfteich/shared/i18n/en/common.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json';
import i18n from 'i18next'; import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector'; import LanguageDetector from 'i18next-browser-languagedetector';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
@ -17,8 +21,8 @@ void i18n
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
resources: { resources: {
en: { common: enCommon, errors: enErrors }, en: { common: enCommon, errors: enErrors, auth: enAuth, settings: enSettings },
de: { common: deCommon, errors: deErrors }, de: { common: deCommon, errors: deErrors, auth: deAuth, settings: deSettings },
}, },
defaultNS: 'common', defaultNS: 'common',
fallbackLng: 'en', fallbackLng: 'en',

View File

@ -1,5 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
interface TopBarProps { interface TopBarProps {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
@ -8,6 +11,16 @@ interface TopBarProps {
export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): React.JSX.Element { export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
const { user, logout } = useAuth();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
async function handleLogout(): Promise<void> {
setMenuOpen(false);
await logout();
navigate('/login');
}
return ( return (
<header className="topbar"> <header className="topbar">
<button <button
@ -24,8 +37,39 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
Dorfteich Dorfteich
</Link> </Link>
<span className="topbar__spacer" /> <span className="topbar__spacer" />
{/* User menu arrives with authentication (issue #16). */} {user ? (
<span className="sidebar__hint">{t('layout.user.anonymous')}</span> <div className="user-menu">
<button
type="button"
className="user-menu__trigger"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen(!menuOpen)}
>
{user.displayName}
</button>
{menuOpen && (
<div className="user-menu__list" role="menu">
<Link role="menuitem" to="/settings" onClick={() => setMenuOpen(false)}>
{t('auth:menu.settings')}
</Link>
{user.isSiteAdmin && (
<Link role="menuitem" to="/admin" onClick={() => setMenuOpen(false)}>
{t('auth:menu.admin')}
</Link>
)}
<button type="button" role="menuitem" onClick={() => void handleLogout()}>
{t('auth:menu.logout')}
</button>
</div>
)}
</div>
) : (
<nav className="topbar__nav">
<Link to="/login">{t('auth:menu.login')}</Link>
<Link to="/signup">{t('auth:menu.signup')}</Link>
</nav>
)}
</header> </header>
); );
} }

View File

@ -1,20 +1,51 @@
import type { ApiErrorBody, HealthResponse } from '@dorfteich/shared'; import type { ApiErrorBody, HealthResponse } from '@dorfteich/shared';
/** /**
* Minimal typed fetch helper for the REST api. Non-2xx responses reject * Typed fetch helper for the REST api. Non-2xx responses reject with an
* with the uniform ApiErrorBody so callers can show localized errors. * ApiError carrying the uniform body callers translate `code` via the
* errors namespace and map `details` onto form fields.
*/ */
export async function apiGet<T>(path: string): Promise<T> { export class ApiError extends Error {
const response = await fetch(`/api/v1${path}`, { constructor(
headers: { Accept: 'application/json' }, readonly status: number,
}); readonly body: ApiErrorBody,
if (!response.ok) { ) {
const body = (await response.json().catch(() => null)) as ApiErrorBody | null; super(body.message);
throw body ?? { code: `http_${response.status}`, message: response.statusText };
} }
}
async function requestJson<T>(method: string, path: string, body?: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(`/api/v1${path}`, {
method,
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch {
throw new ApiError(0, { code: 'network', message: 'network error' });
}
if (!response.ok) {
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
throw new ApiError(
response.status,
parsed ?? { code: `http_${response.status}`, message: response.statusText },
);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T; return (await response.json()) as T;
} }
export const apiGet = <T>(path: string): Promise<T> => requestJson<T>('GET', path);
export const apiPost = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('POST', path, body);
export const apiPatch = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('PATCH', path, body);
export const apiDelete = <T>(path: string): Promise<T> => requestJson<T>('DELETE', path);
export function fetchHealth(): Promise<HealthResponse> { export function fetchHealth(): Promise<HealthResponse> {
return apiGet<HealthResponse>('/healthz'); return apiGet<HealthResponse>('/healthz');
} }

View File

@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { App } from './App'; import { App } from './App';
import { AuthProvider } from './auth/auth-context';
import './i18n'; import './i18n';
import './styles/tokens.css'; import './styles/tokens.css';
import './styles/base.css'; import './styles/base.css';
@ -18,9 +19,11 @@ if (!container) {
createRoot(container).render( createRoot(container).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</AuthProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,
); );

View File

@ -0,0 +1,71 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Field, FormError, FormSuccess } from '../components/forms';
import { apiGet, apiPatch } from '../lib/api';
interface InstanceSettings {
'auth.registrationMode': 'open' | 'closed';
'instance.name': string;
'instance.defaultLocale': 'de' | 'en';
}
export function AdminSettingsPage(): React.JSX.Element {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const settings = useQuery({
queryKey: ['admin', 'settings'],
queryFn: () => apiGet<InstanceSettings>('/admin/settings'),
});
const form = useForm<InstanceSettings>({ values: settings.data });
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
setSaved(false);
try {
await apiPatch('/admin/settings', input);
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
setSaved(true);
} catch (err) {
setError(err);
}
});
if (!settings.data) return <h1>{t('settings:admin.title')}</h1>;
return (
<>
<h1>{t('settings:admin.title')}</h1>
<section className="settings-section">
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
<FormSuccess message={saved ? t('settings:admin.saved') : null} />
<Field label={t('settings:admin.instanceName')}>
<input type="text" {...form.register('instance.name')} />
</Field>
<Field label={t('settings:admin.defaultLocale')}>
<select {...form.register('instance.defaultLocale')}>
<option value="de">{t('settings:profile.locales.de')}</option>
<option value="en">{t('settings:profile.locales.en')}</option>
</select>
</Field>
<Field label={t('settings:admin.registrationMode')}>
<select {...form.register('auth.registrationMode')}>
<option value="open">{t('settings:admin.registrationOpen')}</option>
<option value="closed">{t('settings:admin.registrationClosed')}</option>
</select>
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('settings:admin.save')}
</button>
</form>
</section>
</>
);
}

View File

@ -0,0 +1,208 @@
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>
);
}

View File

@ -0,0 +1,51 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { forgotPasswordInputSchema } from '@dorfteich/shared';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Field, FormError, FormSuccess } from '../../components/forms';
import { apiPost } from '../../lib/api';
export function ForgotPasswordPage(): React.JSX.Element {
const { t } = useTranslation();
const [error, setError] = useState<unknown>(null);
const [sent, setSent] = useState(false);
const form = useForm<{ email: string }>({ resolver: zodResolver(forgotPasswordInputSchema) });
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
try {
await apiPost('/auth/forgot-password', input);
setSent(true);
} catch (err) {
setError(err);
}
});
return (
<div className="auth-card">
<h1>{t('auth:forgot.title')}</h1>
<p>{t('auth:forgot.body')}</p>
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
<FormSuccess message={sent ? t('auth:forgot.sent') : null} />
{!sent && (
<>
<Field label={t('auth:forgot.email')} error={form.formState.errors.email?.message}>
<input type="email" autoComplete="email" {...form.register('email')} />
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('auth:forgot.submit')}
</button>
</>
)}
</form>
<p className="auth-card__links">
<Link to="/login">{t('auth:forgot.backToLogin')}</Link>
</p>
</div>
);
}

View File

@ -0,0 +1,78 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { LoginInput, loginInputSchema } from '@dorfteich/shared';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../auth/auth-context';
import { Field, FormError } from '../../components/forms';
import { ApiError, apiPost } from '../../lib/api';
export function LoginPage(): React.JSX.Element {
const { t } = useTranslation();
const { refresh } = useAuth();
const navigate = useNavigate();
const [params] = useSearchParams();
const [error, setError] = useState<unknown>(null);
const [resent, setResent] = useState(false);
const form = useForm<LoginInput>({ resolver: zodResolver(loginInputSchema) });
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
try {
await apiPost('/auth/login', input);
await refresh();
navigate(params.get('next') ?? '/', { replace: true });
} catch (err) {
setError(err);
}
});
const unverified = error instanceof ApiError && error.body.code === 'email_unverified';
async function resendVerification(): Promise<void> {
const value = form.getValues('usernameOrEmail');
if (value.includes('@')) {
await apiPost('/auth/resend-verification', { email: value });
setResent(true);
}
}
return (
<div className="auth-card">
<h1>{t('auth:login.title')}</h1>
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
{unverified && !resent && (
<p className="form-banner">
{t('auth:login.resendHint')}{' '}
<button type="button" className="linklike" onClick={() => void resendVerification()}>
{t('auth:login.resendLink')}
</button>
</p>
)}
{resent && <p className="form-banner form-banner--ok">{t('auth:verify.resent')}</p>}
<Field
label={t('auth:login.usernameOrEmail')}
error={form.formState.errors.usernameOrEmail?.message}
>
<input type="text" autoComplete="username" {...form.register('usernameOrEmail')} />
</Field>
<Field label={t('auth:login.password')} error={form.formState.errors.password?.message}>
<input type="password" autoComplete="current-password" {...form.register('password')} />
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('auth:login.submit')}
</button>
</form>
<p className="auth-card__links">
<Link to="/forgot-password">{t('auth:login.forgot')}</Link>
</p>
<p className="auth-card__links">
{t('auth:login.noAccount')} <Link to="/signup">{t('auth:login.signupLink')}</Link>
</p>
</div>
);
}

View File

@ -0,0 +1,63 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { passwordSchema } from '@dorfteich/shared';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom';
import { z } from 'zod';
import { Field, FormError } from '../../components/forms';
import { apiPost } from '../../lib/api';
const formSchema = z.object({ password: passwordSchema });
export function ResetPasswordPage(): React.JSX.Element {
const { t } = useTranslation();
const [params] = useSearchParams();
const [error, setError] = useState<unknown>(null);
const [done, setDone] = useState(false);
const form = useForm({ resolver: zodResolver(formSchema) });
const token = params.get('token') ?? '';
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
try {
await apiPost('/auth/reset-password', { token, password: input.password });
setDone(true);
} catch (err) {
setError(err);
}
});
if (done) {
return (
<div className="auth-card">
<h1>{t('auth:reset.success.title')}</h1>
<p>{t('auth:reset.success.body')}</p>
<Link className="button" to="/login">
{t('auth:reset.success.login')}
</Link>
</div>
);
}
return (
<div className="auth-card">
<h1>{t('auth:reset.title')}</h1>
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
<Field
label={t('auth:reset.password')}
hint={t('auth:signup.passwordHint')}
error={form.formState.errors.password?.message}
>
<input type="password" autoComplete="new-password" {...form.register('password')} />
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('auth:reset.submit')}
</button>
</form>
</div>
);
}

View File

@ -0,0 +1,108 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { SignupFormInput, signupInputSchema } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Field, FormError, applyFieldErrors } from '../../components/forms';
import { apiGet, apiPost } from '../../lib/api';
export function SignupPage(): React.JSX.Element {
const { t, i18n } = useTranslation();
const [error, setError] = useState<unknown>(null);
const [registered, setRegistered] = useState<string | null>(null);
const registration = useQuery({
queryKey: ['auth', 'registration'],
queryFn: () => apiGet<{ mode: 'open' | 'closed' }>('/auth/registration'),
});
type SignupFormValues = SignupFormInput;
// One confined cast: RHF cannot express Zod's input/output split
// (locale is optional on input, defaulted on output) without it.
const form = useForm<SignupFormValues>({
resolver: zodResolver(signupInputSchema) as Resolver<SignupFormValues>,
defaultValues: { locale: i18n.language === 'de' ? 'de' : 'en' },
});
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
try {
await apiPost('/auth/signup', input);
setRegistered(input.email);
} catch (err) {
setError(err);
applyFieldErrors(err, (name, fieldError) =>
form.setError(name as keyof SignupFormValues, fieldError),
);
}
});
if (registration.data?.mode === 'closed') {
return (
<div className="auth-card">
<h1>{t('auth:signup.title')}</h1>
<p className="form-banner">{t('auth:signup.closed')}</p>
<p className="auth-card__links">
<Link to="/login">{t('auth:signup.loginLink')}</Link>
</p>
</div>
);
}
if (registered) {
return (
<div className="auth-card">
<h1>{t('auth:signup.success.title')}</h1>
<p>{t('auth:signup.success.body', { email: registered })}</p>
<button
type="button"
className="linklike"
onClick={() => void apiPost('/auth/resend-verification', { email: registered })}
>
{t('auth:signup.success.resend')}
</button>
</div>
);
}
return (
<div className="auth-card">
<h1>{t('auth:signup.title')}</h1>
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
<Field
label={t('auth:signup.username')}
hint={t('auth:signup.usernameHint')}
error={form.formState.errors.username?.message}
>
<input type="text" autoComplete="username" {...form.register('username')} />
</Field>
<Field label={t('auth:signup.email')} error={form.formState.errors.email?.message}>
<input type="email" autoComplete="email" {...form.register('email')} />
</Field>
<Field
label={t('auth:signup.displayName')}
error={form.formState.errors.displayName?.message}
>
<input type="text" autoComplete="name" {...form.register('displayName')} />
</Field>
<Field
label={t('auth:signup.password')}
hint={t('auth:signup.passwordHint')}
error={form.formState.errors.password?.message}
>
<input type="password" autoComplete="new-password" {...form.register('password')} />
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('auth:signup.submit')}
</button>
</form>
<p className="auth-card__links">
{t('auth:signup.haveAccount')} <Link to="/login">{t('auth:signup.loginLink')}</Link>
</p>
</div>
);
}

View File

@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom';
import { apiPost } from '../../lib/api';
type VerifyState = 'pending' | 'success' | 'error';
export function VerifyEmailPage(): React.JSX.Element {
const { t } = useTranslation();
const [params] = useSearchParams();
const [state, setState] = useState<VerifyState>('pending');
const [email, setEmail] = useState('');
const [resent, setResent] = useState(false);
const token = params.get('token');
useEffect(() => {
if (!token) {
setState('error');
return;
}
apiPost('/auth/verify-email', { token })
.then(() => setState('success'))
.catch(() => setState('error'));
}, [token]);
if (state === 'pending') {
return (
<div className="auth-card">
<h1>{t('auth:verify.title')}</h1>
</div>
);
}
if (state === 'success') {
return (
<div className="auth-card">
<h1>{t('auth:verify.success.title')}</h1>
<p>{t('auth:verify.success.body')}</p>
<Link className="button" to="/login">
{t('auth:verify.success.login')}
</Link>
</div>
);
}
return (
<div className="auth-card">
<h1>{t('auth:verify.error.title')}</h1>
<p>{t('errors:token_invalid')}</p>
{resent ? (
<p className="form-banner form-banner--ok">{t('auth:verify.resent')}</p>
) : (
<form
onSubmit={(event) => {
event.preventDefault();
void apiPost('/auth/resend-verification', { email }).then(() => setResent(true));
}}
>
<p>{t('auth:verify.error.resendPrompt')}</p>
<label className="field">
<span className="field__label">{t('auth:forgot.email')}</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<button type="submit" className="button">
{t('auth:verify.error.resend')}
</button>
</form>
)}
</div>
);
}

View File

@ -139,3 +139,194 @@ button {
.status-pill--error { .status-pill--error {
color: var(--color-danger); color: var(--color-danger);
} }
/* Forms */
.field {
display: block;
margin-bottom: var(--space-4);
}
.field__label {
display: block;
margin-bottom: var(--space-1);
font-size: 0.9rem;
color: var(--color-text-muted);
}
.field input,
.field select {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius);
font: inherit;
background: var(--color-bg);
}
.field input:focus-visible,
.field select:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
.field__hint,
.field__error {
display: block;
margin-top: var(--space-1);
font-size: 0.85rem;
}
.field__hint {
color: var(--color-text-muted);
}
.field__error {
color: var(--color-danger);
}
.button {
display: inline-block;
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius);
background: var(--color-accent);
color: var(--color-accent-contrast);
cursor: pointer;
text-decoration: none;
font: inherit;
}
.button:disabled {
opacity: 0.6;
cursor: default;
}
.linklike {
background: none;
border: none;
padding: 0;
color: var(--color-accent);
text-decoration: underline;
cursor: pointer;
font: inherit;
}
.form-banner {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius);
margin-bottom: var(--space-4);
font-size: 0.95rem;
}
.form-banner--error {
border-color: var(--color-danger);
color: var(--color-danger);
}
.form-banner--ok {
border-color: var(--color-ok);
color: var(--color-ok);
}
/* Auth pages */
.auth-card {
max-width: 24rem;
margin: var(--space-8) auto;
}
.auth-card__links {
margin-top: var(--space-4);
font-size: 0.95rem;
}
/* Settings */
.settings-section {
max-width: 36rem;
margin-bottom: var(--space-8);
padding-bottom: var(--space-6);
border-bottom: 1px solid var(--color-border);
}
.table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--space-4);
}
.table th,
.table td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
font-size: 0.95rem;
}
.table th {
color: var(--color-text-muted);
font-weight: 400;
}
.badge {
display: inline-block;
margin-left: var(--space-2);
padding: 0 var(--space-2);
border-radius: 999px;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
font-size: 0.8rem;
color: var(--color-text-muted);
}
/* User menu */
.user-menu {
position: relative;
}
.user-menu__trigger {
background: none;
border: 1px solid transparent;
border-radius: var(--radius);
padding: var(--space-1) var(--space-3);
cursor: pointer;
}
.user-menu__trigger:hover {
border-color: var(--color-border);
}
.user-menu__list {
position: absolute;
right: 0;
top: calc(100% + 4px);
min-width: 12rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgb(0 0 0 / 0.08);
display: flex;
flex-direction: column;
z-index: 10;
}
.user-menu__list a,
.user-menu__list button {
padding: var(--space-2) var(--space-3);
text-align: left;
background: none;
border: none;
color: var(--color-text);
text-decoration: none;
cursor: pointer;
font: inherit;
}
.user-menu__list a:hover,
.user-menu__list button:hover {
background: var(--color-bg-subtle);
}
.topbar__nav {
display: flex;
gap: var(--space-4);
}

View File

@ -0,0 +1,70 @@
{
"login": {
"title": "Anmelden",
"usernameOrEmail": "Benutzername oder E-Mail",
"password": "Passwort",
"submit": "Anmelden",
"forgot": "Passwort vergessen?",
"noAccount": "Noch kein Konto?",
"signupLink": "Jetzt registrieren",
"resendHint": "Keine Bestätigungs-Mail bekommen?",
"resendLink": "Erneut senden"
},
"signup": {
"title": "Konto anlegen",
"username": "Benutzername",
"usernameHint": "Buchstaben, Ziffern, Bindestriche — deine Adresse im Dorfteich.",
"email": "E-Mail-Adresse",
"displayName": "Anzeigename",
"password": "Passwort",
"passwordHint": "Mindestens 10 Zeichen. Ein kurzer Satz funktioniert gut.",
"submit": "Registrieren",
"haveAccount": "Schon registriert?",
"loginLink": "Anmelden",
"closed": "Die Registrierung ist auf dieser Instanz derzeit geschlossen.",
"success": {
"title": "Schau in dein Postfach",
"body": "Wir haben einen Bestätigungslink an {{email}} geschickt. Er ist 24 Stunden gültig.",
"resend": "Mail erneut senden"
}
},
"verify": {
"title": "E-Mail-Adresse wird bestätigt …",
"success": {
"title": "E-Mail-Adresse bestätigt",
"body": "Dein Konto ist aktiv — du kannst dich jetzt anmelden.",
"login": "Zur Anmeldung"
},
"error": {
"title": "Dieser Link hat nicht funktioniert",
"resendPrompt": "Gib deine E-Mail-Adresse ein, wir schicken dir einen frischen Link:",
"resend": "Neuen Link senden"
},
"resent": "Falls die Adresse zu einem unbestätigten Konto gehört, ist eine neue Mail unterwegs."
},
"forgot": {
"title": "Passwort zurücksetzen",
"body": "Gib deine E-Mail-Adresse ein. Falls ein Konto existiert, bekommst du einen Link zum Zurücksetzen (eine Stunde gültig).",
"email": "E-Mail-Adresse",
"submit": "Link senden",
"sent": "Erledigt — schau in dein Postfach.",
"backToLogin": "Zurück zur Anmeldung"
},
"reset": {
"title": "Neues Passwort setzen",
"password": "Neues Passwort",
"submit": "Passwort speichern",
"success": {
"title": "Passwort gespeichert",
"body": "Alle bisherigen Sitzungen wurden abgemeldet. Melde dich mit dem neuen Passwort an.",
"login": "Zur Anmeldung"
}
},
"menu": {
"settings": "Einstellungen",
"admin": "Administration",
"logout": "Abmelden",
"login": "Anmelden",
"signup": "Registrieren"
}
}

View File

@ -7,5 +7,35 @@
"gone": "Diese Ressource ist nicht mehr verfügbar.", "gone": "Diese Ressource ist nicht mehr verfügbar.",
"payload_too_large": "Die übermittelten Daten sind zu groß.", "payload_too_large": "Die übermittelten Daten sind zu groß.",
"rate_limited": "Zu viele Anfragen — bitte versuche es später erneut.", "rate_limited": "Zu viele Anfragen — bitte versuche es später erneut.",
"internal_error": "Interner Serverfehler." "internal_error": "Interner Serverfehler.",
"registration_closed": "Die Registrierung ist auf dieser Instanz derzeit geschlossen.",
"token_invalid": "Dieser Link ist ungültig oder abgelaufen.",
"login_failed": "Benutzername/E-Mail oder Passwort ist falsch.",
"login_backoff": "Zu viele Fehlversuche — bitte warte ein paar Minuten.",
"email_unverified": "Bitte bestätige zuerst deine E-Mail-Adresse.",
"account_disabled": "Dieses Konto wurde deaktiviert.",
"password_incorrect": "Das aktuelle Passwort ist falsch.",
"csrf_origin_mismatch": "Die Anfrage kam von einer unerwarteten Herkunft.",
"cannot_revoke_current_session": "Beende deine aktuelle Sitzung über die Abmeldung.",
"network": "Der Server war nicht erreichbar.",
"validation": {
"required": "Dieses Feld ist erforderlich.",
"taken": "Dieser Wert ist bereits vergeben.",
"username": {
"tooShort": "Der Benutzername braucht mindestens 3 Zeichen.",
"tooLong": "Der Benutzername darf höchstens 32 Zeichen haben.",
"charset": "Erlaubt sind nur Buchstaben, Ziffern und Bindestriche."
},
"password": {
"tooShort": "Das Passwort braucht mindestens 10 Zeichen.",
"tooLong": "Das Passwort darf höchstens 128 Zeichen haben.",
"tooCommon": "Dieses Passwort ist zu verbreitet."
},
"email": {
"invalid": "Bitte gib eine gültige E-Mail-Adresse ein."
},
"displayName": {
"required": "Bitte gib einen Anzeigenamen ein."
}
}
} }

View File

@ -0,0 +1,38 @@
{
"title": "Einstellungen",
"profile": {
"title": "Profil",
"displayName": "Anzeigename",
"locale": "Sprache",
"locales": { "de": "Deutsch", "en": "English" },
"save": "Speichern",
"saved": "Gespeichert."
},
"password": {
"title": "Passwort ändern",
"current": "Aktuelles Passwort",
"new": "Neues Passwort",
"submit": "Passwort ändern",
"changed": "Passwort geändert. Andere Geräte wurden abgemeldet."
},
"sessions": {
"title": "Aktive Sitzungen",
"current": "Diese Sitzung",
"created": "Angemeldet",
"lastSeen": "Zuletzt aktiv",
"device": "Gerät",
"revoke": "Abmelden",
"revokeAll": "Alle anderen Sitzungen abmelden",
"empty": "Keine weiteren aktiven Sitzungen."
},
"admin": {
"title": "Administration",
"instanceName": "Name der Instanz",
"defaultLocale": "Standardsprache",
"registrationMode": "Selbst-Registrierung",
"registrationOpen": "Offen — alle können sich registrieren",
"registrationClosed": "Geschlossen — keine neuen Registrierungen",
"save": "Speichern",
"saved": "Gespeichert."
}
}

View File

@ -0,0 +1,70 @@
{
"login": {
"title": "Sign in",
"usernameOrEmail": "Username or e-mail",
"password": "Password",
"submit": "Sign in",
"forgot": "Forgot your password?",
"noAccount": "No account yet?",
"signupLink": "Register now",
"resendHint": "Didn't get the confirmation mail?",
"resendLink": "Send it again"
},
"signup": {
"title": "Create your account",
"username": "Username",
"usernameHint": "Letters, digits, hyphens — this is your address inside Dorfteich.",
"email": "E-mail address",
"displayName": "Display name",
"password": "Password",
"passwordHint": "At least 10 characters. A short sentence works well.",
"submit": "Register",
"haveAccount": "Already registered?",
"loginLink": "Sign in",
"closed": "Registration is currently closed on this instance.",
"success": {
"title": "Check your inbox",
"body": "We sent a confirmation link to {{email}}. It is valid for 24 hours.",
"resend": "Send the mail again"
}
},
"verify": {
"title": "Confirming your e-mail address …",
"success": {
"title": "E-mail address confirmed",
"body": "Your account is active — you can sign in now.",
"login": "Go to sign-in"
},
"error": {
"title": "This link did not work",
"resendPrompt": "Enter your e-mail address and we will send a fresh link:",
"resend": "Send new link"
},
"resent": "If the address belongs to an unconfirmed account, a new mail is on its way."
},
"forgot": {
"title": "Reset password",
"body": "Enter your e-mail address. If an account exists, you will receive a reset link (valid for one hour).",
"email": "E-mail address",
"submit": "Send reset link",
"sent": "Done — check your inbox.",
"backToLogin": "Back to sign-in"
},
"reset": {
"title": "Set a new password",
"password": "New password",
"submit": "Save password",
"success": {
"title": "Password saved",
"body": "All previous sessions were signed out. Sign in with your new password.",
"login": "Go to sign-in"
}
},
"menu": {
"settings": "Settings",
"admin": "Administration",
"logout": "Sign out",
"login": "Sign in",
"signup": "Register"
}
}

View File

@ -7,5 +7,35 @@
"gone": "This resource is no longer available.", "gone": "This resource is no longer available.",
"payload_too_large": "The submitted data is too large.", "payload_too_large": "The submitted data is too large.",
"rate_limited": "Too many requests — please try again later.", "rate_limited": "Too many requests — please try again later.",
"internal_error": "Internal server error." "internal_error": "Internal server error.",
"registration_closed": "Registration is currently closed on this instance.",
"token_invalid": "This link is invalid or has expired.",
"login_failed": "Username/e-mail or password is incorrect.",
"login_backoff": "Too many failed attempts — please wait a few minutes.",
"email_unverified": "Please confirm your e-mail address first.",
"account_disabled": "This account has been disabled.",
"password_incorrect": "The current password is incorrect.",
"csrf_origin_mismatch": "The request came from an unexpected origin.",
"cannot_revoke_current_session": "Use sign-out to end your current session.",
"network": "The server could not be reached.",
"validation": {
"required": "This field is required.",
"taken": "This value is already taken.",
"username": {
"tooShort": "The username needs at least 3 characters.",
"tooLong": "The username can have at most 32 characters.",
"charset": "Only letters, digits, and hyphens are allowed."
},
"password": {
"tooShort": "The password needs at least 10 characters.",
"tooLong": "The password can have at most 128 characters.",
"tooCommon": "This password is too common."
},
"email": {
"invalid": "Please enter a valid e-mail address."
},
"displayName": {
"required": "Please enter a display name."
}
}
} }

View File

@ -0,0 +1,38 @@
{
"title": "Settings",
"profile": {
"title": "Profile",
"displayName": "Display name",
"locale": "Language",
"locales": { "de": "Deutsch", "en": "English" },
"save": "Save",
"saved": "Saved."
},
"password": {
"title": "Change password",
"current": "Current password",
"new": "New password",
"submit": "Change password",
"changed": "Password changed. Other devices were signed out."
},
"sessions": {
"title": "Active sessions",
"current": "This session",
"created": "Signed in",
"lastSeen": "Last active",
"device": "Device",
"revoke": "Sign out",
"revokeAll": "Sign out all other sessions",
"empty": "No other active sessions."
},
"admin": {
"title": "Administration",
"instanceName": "Instance name",
"defaultLocale": "Default language",
"registrationMode": "Self-registration",
"registrationOpen": "Open — anyone can register",
"registrationClosed": "Closed — no new registrations",
"save": "Save",
"saved": "Saved."
}
}

View File

@ -46,6 +46,8 @@ export const signupInputSchema = z.object({
locale: z.enum(['de', 'en']).default('en'), locale: z.enum(['de', 'en']).default('en'),
}); });
export type SignupInput = z.infer<typeof signupInputSchema>; export type SignupInput = z.infer<typeof signupInputSchema>;
/** Form-side type: locale is optional before Zod applies its default. */
export type SignupFormInput = z.input<typeof signupInputSchema>;
export const loginInputSchema = z.object({ export const loginInputSchema = z.object({
usernameOrEmail: z.string().min(1, 'validation.required'), usernameOrEmail: z.string().min(1, 'validation.required'),

39
pnpm-lock.yaml generated
View File

@ -126,6 +126,9 @@ importers:
'@dorfteich/shared': '@dorfteich/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
'@hookform/resolvers':
specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.80.0(react@19.2.7))
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.66.0 specifier: ^5.66.0
version: 5.101.2(react@19.2.7) version: 5.101.2(react@19.2.7)
@ -141,12 +144,18 @@ importers:
react-dom: react-dom:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.7(react@19.2.7) version: 19.2.7(react@19.2.7)
react-hook-form:
specifier: ^7.80.0
version: 7.80.0(react@19.2.7)
react-i18next: react-i18next:
specifier: ^17.0.8 specifier: ^17.0.8
version: 17.0.8(i18next@26.3.4(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3) version: 17.0.8(i18next@26.3.4(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3)
react-router-dom: react-router-dom:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
zod:
specifier: ^4.4.3
version: 4.4.3
devDependencies: devDependencies:
'@playwright/test': '@playwright/test':
specifier: ^1.61.1 specifier: ^1.61.1
@ -819,6 +828,11 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@hookform/resolvers@5.4.0':
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
peerDependencies:
react-hook-form: ^7.55.0
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@ -1279,6 +1293,9 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/core-darwin-arm64@1.15.43': '@swc/core-darwin-arm64@1.15.43':
resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==} resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2888,6 +2905,12 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.7 react: ^19.2.7
react-hook-form@7.80.0:
resolution: {integrity: sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@17.0.8: react-i18next@17.0.8:
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
peerDependencies: peerDependencies:
@ -3558,6 +3581,9 @@ packages:
zod@3.25.76: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots: snapshots:
'@angular-devkit/core@19.2.24(chokidar@4.0.3)': '@angular-devkit/core@19.2.24(chokidar@4.0.3)':
@ -4015,6 +4041,11 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.7))':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.80.0(react@19.2.7)
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
dependencies: dependencies:
'@humanfs/types': 0.15.0 '@humanfs/types': 0.15.0
@ -4429,6 +4460,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@swc/core-darwin-arm64@1.15.43': '@swc/core-darwin-arm64@1.15.43':
optional: true optional: true
@ -6124,6 +6157,10 @@ snapshots:
react: 19.2.7 react: 19.2.7
scheduler: 0.27.0 scheduler: 0.27.0
react-hook-form@7.80.0(react@19.2.7):
dependencies:
react: 19.2.7
react-i18next@17.0.8(i18next@26.3.4(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3): react-i18next@17.0.8(i18next@26.3.4(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3):
dependencies: dependencies:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7
@ -6786,3 +6823,5 @@ snapshots:
yoctocolors-cjs@2.1.3: {} yoctocolors-cjs@2.1.3: {}
zod@3.25.76: {} zod@3.25.76: {}
zod@4.4.3: {}