auth: sign-in ways, sign-in mode, and the admin who keeps a password (S1)

Spec 12 S1: migration 0012 (password_hash nullable, user_identities, sessions.method and the
OpenID token columns), auth.mode (only "password" until S2), GET /api/auth/methods, login body
{login, password} (email still accepted), signIn on users and /api/auth/me, a Signs in with
column on Users. The last active admin with a password can't be deleted, disabled or demoted.
This commit is contained in:
iamdoubz committed 2026-10-25 13:30:58 -05:00
1 parent ba293f8703
commit 3a9423af1f
19 files changed
+4847 -43

No files matched your search

+24 -2
View File
@@ -3,13 +3,21 @@ import { z } from 'zod';
export const PASSWORD_MIN = 10;
export const LoginSchema = z.object({
email: z.string().trim().min(3, 'Enter your email').max(254),
/** The login form's fields: `login` is an email, or a directory username (12 §4.2). */
export const LoginFields = z.object({
login: z.string().trim().min(1, 'Enter your email').max(254),
password: z.string().min(1, 'Enter your password').max(200),
/** The authenticator code, or a recovery code ("k7m3q-x9tpw"), when two-step sign-in is on. */
totp: z.string().trim().max(20).optional(),
});
/** POST /api/auth/login. `email`, the field's name before 12, is still accepted. */
export const LoginSchema = z.preprocess(
(v) =>
v && typeof v === 'object' && !('login' in v) && 'email' in v ? { ...v, login: v.email } : v,
LoginFields,
);
export const ChangePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Enter your current password').max(200),
newPassword: z
@@ -28,8 +36,22 @@ export type AuthUser = {
role: Role;
mustChangePassword: boolean;
extension: { id: string; number: string; name: string } | null;
signIn: SignInWays;
};
/** The ways a user can sign in (12 §2): their password, a connected OpenID account, the directory. */
export type SignInWays = {
password: boolean;
oidc: { email: string | null } | null;
ldap: boolean;
};
/** 12 §1.1: which ways the login page offers. */
export type AuthMode = 'password' | 'mixed' | 'oidc';
/** GET /api/auth/methods (public): what the login page shows. */
export type AuthMethods = { mode: AuthMode; ldap: boolean; oidc: { label: string } | null };
/** Two-step sign-in (TOTP, 08 §2.4) for the signed-in user. */
export type TotpStatus = { enabled: boolean; recoveryCodesLeft: number };
/** Start: the secret to scan (not stored until a code confirms it). */
+2
View File
@@ -116,6 +116,8 @@ export const SettingsSchemas = {
})
.nullable()
.default(null),
/** Sign-in (12 §1.1): passwords only, OpenID only (admins keep passwords), or both. */
'auth.mode': z.enum(['password', 'mixed', 'oidc']).default('password'),
// Not in 02 §3's table but used by the generator (03 §3).
'system.transportHash': z.string().nullable().default(null),
};
+2
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { z } from 'zod';
import type { SignInWays } from './auth.ts';
export const RoleSchema = z.enum(['admin', 'user']);
@@ -34,6 +35,7 @@ export type UserDto = {
lastLoginAt: string | null;
createdAt: string;
extension: { id: string; number: string } | null;
signIn: SignInWays;
};
export type ListResponse<T> = { items: T[]; nextCursor: string | null };