dorfteich/packages/shared/src/auth.ts
Claude Fable 5 c2a4dde5cc
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m50s
CI / Build container images (pull_request) Successful in 3m57s
CI / Auth e2e pack (pull_request) Failing after 6m18s
CI / Import/export fidelity gate (pull_request) Has been skipped
Invitation flow with per-user quota (#332)
Any authenticated user can invite an e-mail address; the mailed
single-use token lets exactly one signup through even while
registration is closed. Open (pending, unexpired) invitations count
against the new instance setting invitations.maxOpenPerUser (default 5,
0 disables inviting) — plus a 20/day per-user rate limit so a
revoke-and-recreate loop cannot become a mail cannon. Only the SHA-256
token hash is stored (auth-tokens pattern); a failed signup (taken
username) un-redeems the token so the invitee can retry.

Surfaces: invitations section in the user settings (list, invite,
revoke, quota line; wide table in a focusable .table-scroll region),
signup page reads ?invitation=<token> (preview banner, e-mail prefill,
closed-mode gate opens only for a previewed-valid token), admin general
card gets the quota field (flat RHF name per #322; VS-NfD marked and
hideable).

Governance: audit actions invitation.created/revoked/accepted
(catalogue 1.10), VS-NfD profile entry (compliant: 0) + hardening-guide
row, i18n de+en including the invitation mail template.

Tests: api e2e-db (mail link, closed-mode single-use signup with
un-redeem on failure, quota + revoke frees slot, quota 0 = 403, auth
matrix), new web e2e pack invitations.spec.ts (full UI loop through
Mailpit, wired into ci.yml with its own rate-limit reset), a11y scan
waits for the new section. Full api suite (107 files / 607 tests),
auth/admin-settings/a11y packs green against a fresh local stack.

Closes #332
2026-08-05 12:44:20 +02:00

110 lines
3.7 KiB
TypeScript

import { z } from 'zod';
/**
* Auth-related schemas shared between api (runtime validation) and web
* (form validation). Field error messages are i18n keys resolved by the
* client; the api returns them inside ApiErrorBody.details.
*/
export const usernameSchema = z
.string()
.min(3, 'validation.username.tooShort')
.max(32, 'validation.username.tooLong')
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i, 'validation.username.charset');
/**
* Password policy per issue #13: length over composition rules, plus a
* small blocklist of the most common passwords (full breach-list checks
* are deliberately out of scope for v1).
*/
const COMMON_PASSWORDS = new Set([
'1234567890',
'qwertyuiop',
'password12',
'password123',
'passwort123',
'1q2w3e4r5t',
'iloveyou12',
'sonnenschein',
'schalke04!',
'aaaaaaaaaa',
'1234567890a',
'qwertz1234',
]);
export const passwordSchema = z
.string()
.min(10, 'validation.password.tooShort')
.max(128, 'validation.password.tooLong')
.refine((value) => !COMMON_PASSWORDS.has(value.toLowerCase()), 'validation.password.tooCommon');
export const signupInputSchema = z.object({
username: usernameSchema,
email: z.string().email('validation.email.invalid').max(254),
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80),
password: passwordSchema,
locale: z.enum(['de', 'en']).default('en'),
/** Invitation token (issue #332): lets this one signup through even
* while registration is closed. */
invitationToken: z.string().min(16).max(256).optional(),
});
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'),
password: z.string().min(1, 'validation.required'),
});
export type LoginInput = z.infer<typeof loginInputSchema>;
export const verifyEmailInputSchema = z.object({ token: z.string().min(16).max(256) });
export const resendVerificationInputSchema = z.object({
email: z.string().email('validation.email.invalid'),
});
export const forgotPasswordInputSchema = z.object({
email: z.string().email('validation.email.invalid'),
});
export const resetPasswordInputSchema = z.object({
token: z.string().min(16).max(256),
password: passwordSchema,
});
/** Form-side schema for the reset page (token travels via URL, not form). */
export const resetPasswordFormSchema = z.object({ password: passwordSchema });
export const updateProfileInputSchema = z.object({
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80).optional(),
locale: z.enum(['de', 'en']).optional(),
/** Auto-watch preferences (issue #93). */
autoWatchOwnPages: z.boolean().optional(),
autoWatchOnComment: z.boolean().optional(),
/** E-mail digest cadence (issue #95). */
digestFrequency: z.enum(['hourly', 'daily', 'off']).optional(),
});
export const changePasswordInputSchema = z.object({
currentPassword: z.string().min(1, 'validation.required'),
newPassword: passwordSchema,
});
/**
* What the login screen may offer (issue #214, ADR 0021): the local
* password form and/or the deploy-configured OIDC provider. `local` becomes
* switchable with #216 (`AUTH_LOCAL_ENABLED`).
*/
export interface AuthMethodsView {
local: boolean;
oidc: { label: string } | null;
}
/** Public shape of the signed-in user, returned by /auth/me. */
export interface CurrentUser {
id: string;
username: string;
email: string;
displayName: string;
locale: 'de' | 'en';
isSiteAdmin: boolean;
autoWatchOwnPages: boolean;
autoWatchOnComment: boolean;
digestFrequency: 'hourly' | 'daily' | 'off';
}