From 0bc80c9f937263dbcf0f5e6e2ff0858aeb6532e2 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 5 Jul 2026 05:35:56 +0200 Subject: [PATCH] Add auth, settings, and admin UI to the SPA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/auth/auth.controller.ts | 9 + apps/api/src/common/api-exception.filter.ts | 13 +- apps/web/package.json | 5 +- apps/web/src/App.tsx | 25 +++ apps/web/src/auth/auth-context.tsx | 57 +++++ apps/web/src/auth/guards.tsx | 30 +++ apps/web/src/components/forms.tsx | 70 ++++++ apps/web/src/i18n/index.ts | 8 +- apps/web/src/layout/TopBar.tsx | 50 ++++- apps/web/src/lib/api.ts | 49 ++++- apps/web/src/main.tsx | 9 +- apps/web/src/pages/AdminSettingsPage.tsx | 71 ++++++ apps/web/src/pages/SettingsPage.tsx | 208 ++++++++++++++++++ .../web/src/pages/auth/ForgotPasswordPage.tsx | 51 +++++ apps/web/src/pages/auth/LoginPage.tsx | 78 +++++++ apps/web/src/pages/auth/ResetPasswordPage.tsx | 63 ++++++ apps/web/src/pages/auth/SignupPage.tsx | 108 +++++++++ apps/web/src/pages/auth/VerifyEmailPage.tsx | 78 +++++++ apps/web/src/styles/base.css | 191 ++++++++++++++++ packages/shared/i18n/de/auth.json | 70 ++++++ packages/shared/i18n/de/errors.json | 32 ++- packages/shared/i18n/de/settings.json | 38 ++++ packages/shared/i18n/en/auth.json | 70 ++++++ packages/shared/i18n/en/errors.json | 32 ++- packages/shared/i18n/en/settings.json | 38 ++++ packages/shared/src/auth.ts | 2 + pnpm-lock.yaml | 39 ++++ 27 files changed, 1468 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/auth/auth-context.tsx create mode 100644 apps/web/src/auth/guards.tsx create mode 100644 apps/web/src/components/forms.tsx create mode 100644 apps/web/src/pages/AdminSettingsPage.tsx create mode 100644 apps/web/src/pages/SettingsPage.tsx create mode 100644 apps/web/src/pages/auth/ForgotPasswordPage.tsx create mode 100644 apps/web/src/pages/auth/LoginPage.tsx create mode 100644 apps/web/src/pages/auth/ResetPasswordPage.tsx create mode 100644 apps/web/src/pages/auth/SignupPage.tsx create mode 100644 apps/web/src/pages/auth/VerifyEmailPage.tsx create mode 100644 packages/shared/i18n/de/auth.json create mode 100644 packages/shared/i18n/de/settings.json create mode 100644 packages/shared/i18n/en/auth.json create mode 100644 packages/shared/i18n/en/settings.json diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 5ee8a2e..ed18358 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -15,6 +15,7 @@ import type { Response } from 'express'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { AppConfig } from '../config/app-config.service'; 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 { AuthService } from './auth.service'; import { SessionsService } from './sessions.service'; @@ -25,8 +26,16 @@ export class AuthController { private readonly auth: AuthService, private readonly sessions: SessionsService, 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() @Post('signup') @HttpCode(201) diff --git a/apps/api/src/common/api-exception.filter.ts b/apps/api/src/common/api-exception.filter.ts index 7713b3c..8f75df9 100644 --- a/apps/api/src/common/api-exception.filter.ts +++ b/apps/api/src/common/api-exception.filter.ts @@ -23,17 +23,18 @@ export class ApiExceptionFilter implements ExceptionFilter { if (exception instanceof HttpException) { const status = exception.getStatus(); - const code = codeForStatus(status); + const payload = exception.getResponse() as + string | { code?: string; details?: Record }; + // 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 // the (developer-provided, English) exception message as fallback. const message = translateErrorCode(code, language) ?? exception.message; // Field-level validation/conflict details pass through untouched — // they carry i18n keys the client resolves per field. - const payload = exception.getResponse(); - const details = - typeof payload === 'object' && payload !== null && 'details' in payload - ? (payload as { details: Record }).details - : undefined; + const details = typeof payload === 'object' ? payload.details : undefined; response.status(status).json(apiError(code, message, details)); return; } diff --git a/apps/web/package.json b/apps/web/package.json index 4fafb82..88454e6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,13 +15,16 @@ }, "dependencies": { "@dorfteich/shared": "workspace:*", + "@hookform/resolvers": "^5.4.0", "@tanstack/react-query": "^5.66.0", "i18next": "^26.3.4", "i18next-browser-languagedetector": "^8.2.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.80.0", "react-i18next": "^17.0.8", - "react-router-dom": "^7.1.0" + "react-router-dom": "^7.1.0", + "zod": "^4.4.3" }, "devDependencies": { "@playwright/test": "^1.61.1", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 52e8842..4de8d66 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,14 +1,39 @@ import { Route, Routes } from 'react-router-dom'; +import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards'; import { AppLayout } from './layout/AppLayout'; +import { AdminSettingsPage } from './pages/AdminSettingsPage'; import { HomePage } from './pages/HomePage'; 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 { return ( }> } /> + + }> + } /> + } /> + } /> + + {/* Verify/reset work regardless of session state (mail links). */} + } /> + } /> + + }> + } /> + + }> + } /> + + } /> diff --git a/apps/web/src/auth/auth-context.tsx b/apps/web/src/auth/auth-context.tsx new file mode 100644 index 0000000..6621928 --- /dev/null +++ b/apps/web/src/auth/auth-context.tsx @@ -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; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +async function fetchCurrentUser(): Promise { + try { + return await apiGet('/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 {children}; +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth requires an ancestor'); + return context; +} diff --git a/apps/web/src/auth/guards.tsx b/apps/web/src/auth/guards.tsx new file mode 100644 index 0000000..f508550 --- /dev/null +++ b/apps/web/src/auth/guards.tsx @@ -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 ; + } + return ; +} + +/** 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 ; + return ; +} + +export function RequireSiteAdmin(): React.JSX.Element { + const { user, isLoading } = useAuth(); + if (isLoading) return <>; + if (!user?.isSiteAdmin) return ; + return ; +} diff --git a/apps/web/src/components/forms.tsx b/apps/web/src/components/forms.tsx new file mode 100644 index 0000000..ea9626c --- /dev/null +++ b/apps/web/src/components/forms.tsx @@ -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 ( + + ); +} + +/** 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 ( +

+ {t(`errors:${code}`, t('errors:internal_error'))} +

+ ); +} + +export function FormSuccess({ message }: { message: string | null }): React.JSX.Element | null { + if (!message) return null; + return ( +

+ {message} +

+ ); +} + +/** 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] }); + } + } +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 31473f3..a807fad 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -1,7 +1,11 @@ +import deAuth from '@dorfteich/shared/i18n/de/auth.json'; import deCommon from '@dorfteich/shared/i18n/de/common.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 enErrors from '@dorfteich/shared/i18n/en/errors.json'; +import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import i18n from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; @@ -17,8 +21,8 @@ void i18n .use(initReactI18next) .init({ resources: { - en: { common: enCommon, errors: enErrors }, - de: { common: deCommon, errors: deErrors }, + en: { common: enCommon, errors: enErrors, auth: enAuth, settings: enSettings }, + de: { common: deCommon, errors: deErrors, auth: deAuth, settings: deSettings }, }, defaultNS: 'common', fallbackLng: 'en', diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index ff34d8d..b132075 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -1,5 +1,8 @@ +import { useState } from 'react'; 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 { sidebarCollapsed: boolean; @@ -8,6 +11,16 @@ interface TopBarProps { export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): React.JSX.Element { const { t } = useTranslation(); + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const [menuOpen, setMenuOpen] = useState(false); + + async function handleLogout(): Promise { + setMenuOpen(false); + await logout(); + navigate('/login'); + } + return (
+ {menuOpen && ( +
+ setMenuOpen(false)}> + {t('auth:menu.settings')} + + {user.isSiteAdmin && ( + setMenuOpen(false)}> + {t('auth:menu.admin')} + + )} + +
+ )} + + ) : ( + + )}
); } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c0d271e..061db9a 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,20 +1,51 @@ import type { ApiErrorBody, HealthResponse } from '@dorfteich/shared'; /** - * Minimal typed fetch helper for the REST api. Non-2xx responses reject - * with the uniform ApiErrorBody so callers can show localized errors. + * Typed fetch helper for the REST api. Non-2xx responses reject with an + * ApiError carrying the uniform body — callers translate `code` via the + * errors namespace and map `details` onto form fields. */ -export async function apiGet(path: string): Promise { - const response = await fetch(`/api/v1${path}`, { - headers: { Accept: 'application/json' }, - }); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as ApiErrorBody | null; - throw body ?? { code: `http_${response.status}`, message: response.statusText }; +export class ApiError extends Error { + constructor( + readonly status: number, + readonly body: ApiErrorBody, + ) { + super(body.message); } +} + +async function requestJson(method: string, path: string, body?: unknown): Promise { + 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; } +export const apiGet = (path: string): Promise => requestJson('GET', path); +export const apiPost = (path: string, body?: unknown): Promise => + requestJson('POST', path, body); +export const apiPatch = (path: string, body?: unknown): Promise => + requestJson('PATCH', path, body); +export const apiDelete = (path: string): Promise => requestJson('DELETE', path); + export function fetchHealth(): Promise { return apiGet('/healthz'); } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 2d9a5c8..1161bc8 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { App } from './App'; +import { AuthProvider } from './auth/auth-context'; import './i18n'; import './styles/tokens.css'; import './styles/base.css'; @@ -18,9 +19,11 @@ if (!container) { createRoot(container).render( - - - + + + + + , ); diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx new file mode 100644 index 0000000..0084bf9 --- /dev/null +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -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(null); + const [saved, setSaved] = useState(false); + + const settings = useQuery({ + queryKey: ['admin', 'settings'], + queryFn: () => apiGet('/admin/settings'), + }); + + const form = useForm({ 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

{t('settings:admin.title')}

; + + return ( + <> +

{t('settings:admin.title')}

+
+
+ + + + + + + + + + + + + +
+ + ); +} diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..be64990 --- /dev/null +++ b/apps/web/src/pages/SettingsPage.tsx @@ -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 ( + <> +

{t('settings:title')}

+ + + + + ); +} + +function ProfileSection(): React.JSX.Element { + const { t, i18n } = useTranslation(); + const { user, refresh } = useAuth(); + const [error, setError] = useState(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 ( +
+

{t('settings:profile.title')}

+
+ + + + + + + + + + +
+ ); +} + +function PasswordSection(): React.JSX.Element { + const { t } = useTranslation(); + const [error, setError] = useState(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 ( +
+

{t('settings:password.title')}

+
+ + + + + + + + + + +
+ ); +} + +function SessionsSection(): React.JSX.Element { + const { t, i18n } = useTranslation(); + const queryClient = useQueryClient(); + const sessions = useQuery({ + queryKey: ['sessions'], + queryFn: () => apiGet('/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 ( +
+

{t('settings:sessions.title')}

+ + + + + + + + + + + {(sessions.data ?? []).map((session) => ( + + + + + + + ))} + +
{t('settings:sessions.device')}{t('settings:sessions.created')}{t('settings:sessions.lastSeen')}
+ {session.userAgent ?? '—'} + {session.current && {t('settings:sessions.current')}} + {formatTime(session.createdAt)}{formatTime(session.lastSeenAt)} + {!session.current && ( + + )} +
+ {others.length > 0 ? ( + + ) : ( +

{t('settings:sessions.empty')}

+ )} +
+ ); +} diff --git a/apps/web/src/pages/auth/ForgotPasswordPage.tsx b/apps/web/src/pages/auth/ForgotPasswordPage.tsx new file mode 100644 index 0000000..11e8ce9 --- /dev/null +++ b/apps/web/src/pages/auth/ForgotPasswordPage.tsx @@ -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(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 ( +
+

{t('auth:forgot.title')}

+

{t('auth:forgot.body')}

+
+ + + {!sent && ( + <> + + + + + + )} + +

+ {t('auth:forgot.backToLogin')} +

+
+ ); +} diff --git a/apps/web/src/pages/auth/LoginPage.tsx b/apps/web/src/pages/auth/LoginPage.tsx new file mode 100644 index 0000000..125bae6 --- /dev/null +++ b/apps/web/src/pages/auth/LoginPage.tsx @@ -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(null); + const [resent, setResent] = useState(false); + + const form = useForm({ 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 { + const value = form.getValues('usernameOrEmail'); + if (value.includes('@')) { + await apiPost('/auth/resend-verification', { email: value }); + setResent(true); + } + } + + return ( +
+

{t('auth:login.title')}

+
+ + {unverified && !resent && ( +

+ {t('auth:login.resendHint')}{' '} + +

+ )} + {resent &&

{t('auth:verify.resent')}

} + + + + + + + + +

+ {t('auth:login.forgot')} +

+

+ {t('auth:login.noAccount')} {t('auth:login.signupLink')} +

+
+ ); +} diff --git a/apps/web/src/pages/auth/ResetPasswordPage.tsx b/apps/web/src/pages/auth/ResetPasswordPage.tsx new file mode 100644 index 0000000..4728fe8 --- /dev/null +++ b/apps/web/src/pages/auth/ResetPasswordPage.tsx @@ -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(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 ( +
+

{t('auth:reset.success.title')}

+

{t('auth:reset.success.body')}

+ + {t('auth:reset.success.login')} + +
+ ); + } + + return ( +
+

{t('auth:reset.title')}

+
+ + + + + + +
+ ); +} diff --git a/apps/web/src/pages/auth/SignupPage.tsx b/apps/web/src/pages/auth/SignupPage.tsx new file mode 100644 index 0000000..7e5f6aa --- /dev/null +++ b/apps/web/src/pages/auth/SignupPage.tsx @@ -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(null); + const [registered, setRegistered] = useState(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({ + resolver: zodResolver(signupInputSchema) as Resolver, + 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 ( +
+

{t('auth:signup.title')}

+

{t('auth:signup.closed')}

+

+ {t('auth:signup.loginLink')} +

+
+ ); + } + + if (registered) { + return ( +
+

{t('auth:signup.success.title')}

+

{t('auth:signup.success.body', { email: registered })}

+ +
+ ); + } + + return ( +
+

{t('auth:signup.title')}

+
+ + + + + + + + + + + + + + + +

+ {t('auth:signup.haveAccount')} {t('auth:signup.loginLink')} +

+
+ ); +} diff --git a/apps/web/src/pages/auth/VerifyEmailPage.tsx b/apps/web/src/pages/auth/VerifyEmailPage.tsx new file mode 100644 index 0000000..9d42e36 --- /dev/null +++ b/apps/web/src/pages/auth/VerifyEmailPage.tsx @@ -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('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 ( +
+

{t('auth:verify.title')}

+
+ ); + } + + if (state === 'success') { + return ( +
+

{t('auth:verify.success.title')}

+

{t('auth:verify.success.body')}

+ + {t('auth:verify.success.login')} + +
+ ); + } + + return ( +
+

{t('auth:verify.error.title')}

+

{t('errors:token_invalid')}

+ {resent ? ( +

{t('auth:verify.resent')}

+ ) : ( +
{ + event.preventDefault(); + void apiPost('/auth/resend-verification', { email }).then(() => setResent(true)); + }} + > +

{t('auth:verify.error.resendPrompt')}

+ + +
+ )} +
+ ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 370defa..2e21ff6 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -139,3 +139,194 @@ button { .status-pill--error { 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); +} diff --git a/packages/shared/i18n/de/auth.json b/packages/shared/i18n/de/auth.json new file mode 100644 index 0000000..e2ce098 --- /dev/null +++ b/packages/shared/i18n/de/auth.json @@ -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" + } +} diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index c8114ec..f55709d 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -7,5 +7,35 @@ "gone": "Diese Ressource ist nicht mehr verfügbar.", "payload_too_large": "Die übermittelten Daten sind zu groß.", "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." + } + } } diff --git a/packages/shared/i18n/de/settings.json b/packages/shared/i18n/de/settings.json new file mode 100644 index 0000000..ada199f --- /dev/null +++ b/packages/shared/i18n/de/settings.json @@ -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." + } +} diff --git a/packages/shared/i18n/en/auth.json b/packages/shared/i18n/en/auth.json new file mode 100644 index 0000000..54e0373 --- /dev/null +++ b/packages/shared/i18n/en/auth.json @@ -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" + } +} diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index df2c348..c38c4b8 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -7,5 +7,35 @@ "gone": "This resource is no longer available.", "payload_too_large": "The submitted data is too large.", "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." + } + } } diff --git a/packages/shared/i18n/en/settings.json b/packages/shared/i18n/en/settings.json new file mode 100644 index 0000000..1395014 --- /dev/null +++ b/packages/shared/i18n/en/settings.json @@ -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." + } +} diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts index e749929..2f5cb6f 100644 --- a/packages/shared/src/auth.ts +++ b/packages/shared/src/auth.ts @@ -46,6 +46,8 @@ export const signupInputSchema = z.object({ locale: z.enum(['de', 'en']).default('en'), }); export type SignupInput = z.infer; +/** Form-side type: locale is optional before Zod applies its default. */ +export type SignupFormInput = z.input; export const loginInputSchema = z.object({ usernameOrEmail: z.string().min(1, 'validation.required'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 795c172..34eefdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: '@dorfteich/shared': specifier: workspace:* 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': specifier: ^5.66.0 version: 5.101.2(react@19.2.7) @@ -141,12 +144,18 @@ importers: react-dom: specifier: ^19.0.0 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: 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) react-router-dom: specifier: ^7.1.0 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: '@playwright/test': specifier: ^1.61.1 @@ -819,6 +828,11 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} 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': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1279,6 +1293,9 @@ packages: '@standard-schema/spec@1.1.0': 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': resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==} engines: {node: '>=10'} @@ -2888,6 +2905,12 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} peerDependencies: @@ -3558,6 +3581,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@angular-devkit/core@19.2.24(chokidar@4.0.3)': @@ -4015,6 +4041,11 @@ snapshots: '@eslint/core': 0.17.0 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': dependencies: '@humanfs/types': 0.15.0 @@ -4429,6 +4460,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@swc/core-darwin-arm64@1.15.43': optional: true @@ -6124,6 +6157,10 @@ snapshots: react: 19.2.7 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): dependencies: '@babel/runtime': 7.29.7 @@ -6786,3 +6823,5 @@ snapshots: yoctocolors-cjs@2.1.3: {} zod@3.25.76: {} + + zod@4.4.3: {}