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:
parent
1314096c94
commit
0bc80c9f93
@ -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)
|
||||
|
||||
@ -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<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
|
||||
// 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<string, string[]> }).details
|
||||
: undefined;
|
||||
const details = typeof payload === 'object' ? payload.details : undefined;
|
||||
response.status(status).json(apiError(code, message, details));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 (
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<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>
|
||||
</Routes>
|
||||
|
||||
57
apps/web/src/auth/auth-context.tsx
Normal file
57
apps/web/src/auth/auth-context.tsx
Normal 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;
|
||||
}
|
||||
30
apps/web/src/auth/guards.tsx
Normal file
30
apps/web/src/auth/guards.tsx
Normal 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 />;
|
||||
}
|
||||
70
apps/web/src/components/forms.tsx
Normal file
70
apps/web/src/components/forms.tsx
Normal 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] });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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<void> {
|
||||
setMenuOpen(false);
|
||||
await logout();
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<button
|
||||
@ -24,8 +37,39 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
||||
Dorfteich
|
||||
</Link>
|
||||
<span className="topbar__spacer" />
|
||||
{/* User menu arrives with authentication (issue #16). */}
|
||||
<span className="sidebar__hint">{t('layout.user.anonymous')}</span>
|
||||
{user ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<T>(path: string): Promise<T> {
|
||||
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<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;
|
||||
}
|
||||
|
||||
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> {
|
||||
return apiGet<HealthResponse>('/healthz');
|
||||
}
|
||||
|
||||
@ -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(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
71
apps/web/src/pages/AdminSettingsPage.tsx
Normal file
71
apps/web/src/pages/AdminSettingsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
208
apps/web/src/pages/SettingsPage.tsx
Normal file
208
apps/web/src/pages/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/pages/auth/ForgotPasswordPage.tsx
Normal file
51
apps/web/src/pages/auth/ForgotPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/web/src/pages/auth/LoginPage.tsx
Normal file
78
apps/web/src/pages/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
apps/web/src/pages/auth/ResetPasswordPage.tsx
Normal file
63
apps/web/src/pages/auth/ResetPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
apps/web/src/pages/auth/SignupPage.tsx
Normal file
108
apps/web/src/pages/auth/SignupPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/web/src/pages/auth/VerifyEmailPage.tsx
Normal file
78
apps/web/src/pages/auth/VerifyEmailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
70
packages/shared/i18n/de/auth.json
Normal file
70
packages/shared/i18n/de/auth.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
packages/shared/i18n/de/settings.json
Normal file
38
packages/shared/i18n/de/settings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
70
packages/shared/i18n/en/auth.json
Normal file
70
packages/shared/i18n/en/auth.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
packages/shared/i18n/en/settings.json
Normal file
38
packages/shared/i18n/en/settings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
@ -46,6 +46,8 @@ export const signupInputSchema = z.object({
|
||||
locale: z.enum(['de', 'en']).default('en'),
|
||||
});
|
||||
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({
|
||||
usernameOrEmail: z.string().min(1, 'validation.required'),
|
||||
|
||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user