feat(api,web): auth et first-run — bootstrap admin, login, session, gardes
- api: auth controller/service, config env - web: App/LoginPage/ProfilePage, garde d'auth, client API, styles - shared: types/contrats auth - compose: variables d'environnement first-run Refs: #14
This commit is contained in:
@ -1,7 +1,14 @@
|
||||
import { Body, Controller, Get, Post, Res, UseGuards } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Patch, Post, Res, UseGuards } from "@nestjs/common";
|
||||
import "@fastify/cookie";
|
||||
import { FastifyReply } from "fastify";
|
||||
import { BootstrapAdminDto, BootstrapAdminSchema, LoginDto, LoginSchema } from "@readabook/shared";
|
||||
import {
|
||||
BootstrapAdminDto,
|
||||
BootstrapAdminSchema,
|
||||
LoginDto,
|
||||
LoginSchema,
|
||||
UpdateAccountDto,
|
||||
UpdateAccountSchema
|
||||
} from "@readabook/shared";
|
||||
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
|
||||
import { AuthGuard } from "./auth.guard.js";
|
||||
import { AuthService } from "./auth.service.js";
|
||||
@ -11,6 +18,11 @@ import { CurrentUser, CurrentUserParam } from "./current-user.js";
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Get("status")
|
||||
status() {
|
||||
return this.auth.status();
|
||||
}
|
||||
|
||||
@Post("bootstrap")
|
||||
async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) {
|
||||
return this.auth.bootstrapAdmin(body);
|
||||
@ -40,4 +52,13 @@ export class AuthController {
|
||||
me(@CurrentUserParam() user: CurrentUser) {
|
||||
return { user };
|
||||
}
|
||||
|
||||
@Patch("me")
|
||||
@UseGuards(AuthGuard)
|
||||
updateMe(
|
||||
@CurrentUserParam() user: CurrentUser,
|
||||
@Body(new ZodValidationPipe(UpdateAccountSchema)) body: UpdateAccountDto
|
||||
) {
|
||||
return this.auth.updateOwnAccount(user.id, body);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { ConflictException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConflictException, Injectable, OnModuleInit, UnauthorizedException } from "@nestjs/common";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import argon2 from "argon2";
|
||||
import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateUserDto } from "@readabook/shared";
|
||||
import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateAccountDto, UpdateUserDto } from "@readabook/shared";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { users } from "../database/schema.js";
|
||||
import { CurrentUser } from "./current-user.js";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
export class AuthService implements OnModuleInit {
|
||||
readonly cookieName: string;
|
||||
private readonly secret: Uint8Array;
|
||||
|
||||
@ -17,6 +17,10 @@ export class AuthService {
|
||||
this.secret = new TextEncoder().encode(database.config.jwtSecret);
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.ensureInitialAdmin();
|
||||
}
|
||||
|
||||
get cookieSecure(): boolean {
|
||||
return this.database.config.cookieSecure;
|
||||
}
|
||||
@ -29,6 +33,15 @@ export class AuthService {
|
||||
return this.createUser({ ...input, role: "admin" });
|
||||
}
|
||||
|
||||
status() {
|
||||
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
|
||||
return {
|
||||
hasUsers: Boolean(existing),
|
||||
initialAdminEmail: this.database.config.initialAdminEmail.toLowerCase(),
|
||||
initialAdminPasswordIsDefault: this.database.config.initialAdminPasswordIsDefault
|
||||
};
|
||||
}
|
||||
|
||||
async login(input: LoginDto): Promise<{ token: string; user: CurrentUser }> {
|
||||
const user = this.database.db.select().from(users).where(eq(users.email, input.email.toLowerCase())).get();
|
||||
if (!user || !(await argon2.verify(user.passwordHash, input.password))) {
|
||||
@ -65,6 +78,35 @@ export class AuthService {
|
||||
.all();
|
||||
}
|
||||
|
||||
async updateOwnAccount(id: number, input: UpdateAccountDto) {
|
||||
const user = this.database.db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (!user || !(await argon2.verify(user.passwordHash, input.currentPassword))) {
|
||||
throw new UnauthorizedException("Current password is invalid");
|
||||
}
|
||||
|
||||
const values: Partial<typeof users.$inferInsert> = { updatedAt: this.database.now() };
|
||||
if (input.email) values.email = input.email.toLowerCase();
|
||||
if (input.name !== undefined) values.name = input.name;
|
||||
if (input.newPassword) values.passwordHash = await argon2.hash(input.newPassword);
|
||||
|
||||
try {
|
||||
return this.database.db
|
||||
.update(users)
|
||||
.set(values)
|
||||
.where(eq(users.id, id))
|
||||
.returning({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt
|
||||
})
|
||||
.get();
|
||||
} catch {
|
||||
throw new ConflictException("Email already exists");
|
||||
}
|
||||
}
|
||||
|
||||
async createUser(input: CreateUserDto) {
|
||||
const now = this.database.now();
|
||||
const passwordHash = await argon2.hash(input.password);
|
||||
@ -125,4 +167,22 @@ export class AuthService {
|
||||
.setExpirationTime("7d")
|
||||
.sign(this.secret);
|
||||
}
|
||||
|
||||
private async ensureInitialAdmin(): Promise<void> {
|
||||
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
|
||||
if (existing) return;
|
||||
|
||||
const now = this.database.now();
|
||||
this.database.db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: this.database.config.initialAdminEmail.toLowerCase(),
|
||||
name: "Initial administrator",
|
||||
passwordHash: await argon2.hash(this.database.config.initialAdminPassword),
|
||||
role: "admin",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,8 +11,14 @@ export type AppConfig = {
|
||||
cookieName: string;
|
||||
cookieSecure: boolean;
|
||||
openLibraryEnabled: boolean;
|
||||
initialAdminEmail: string;
|
||||
initialAdminPassword: string;
|
||||
initialAdminPasswordIsDefault: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_INITIAL_ADMIN_EMAIL = "admin@readabook.local";
|
||||
const DEFAULT_INITIAL_ADMIN_PASSWORD = "readabook-admin-change-me";
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const databasePath = resolve(process.env.DATABASE_PATH ?? "./data/readabook.sqlite");
|
||||
const storageDir = resolve(process.env.STORAGE_DIR ?? "./data/storage");
|
||||
@ -29,6 +35,9 @@ export function loadConfig(): AppConfig {
|
||||
jwtSecret: process.env.JWT_SECRET ?? "dev-change-me-readabook",
|
||||
cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session",
|
||||
cookieSecure: process.env.COOKIE_SECURE === "true",
|
||||
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false"
|
||||
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false",
|
||||
initialAdminEmail: process.env.INITIAL_ADMIN_EMAIL ?? DEFAULT_INITIAL_ADMIN_EMAIL,
|
||||
initialAdminPassword: process.env.INITIAL_ADMIN_PASSWORD ?? DEFAULT_INITIAL_ADMIN_PASSWORD,
|
||||
initialAdminPasswordIsDefault: !process.env.INITIAL_ADMIN_PASSWORD
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Session } from "./api/types";
|
||||
import { api } from "./api/client";
|
||||
import { isPrivateRoute } from "./auth/routing";
|
||||
import { AppShell } from "./layout/AppShell";
|
||||
import { AdminPage } from "./pages/AdminPage";
|
||||
import { BookPage } from "./pages/BookPage";
|
||||
@ -11,7 +12,7 @@ import { ProfilePage } from "./pages/ProfilePage";
|
||||
import { ReaderPage } from "./pages/ReaderPage";
|
||||
import { SearchPage } from "./pages/SearchPage";
|
||||
import { SetupPage } from "./pages/SetupPage";
|
||||
import { parseRoute, type Route } from "./router";
|
||||
import { navigate, parseRoute, type Route } from "./router";
|
||||
|
||||
function renderRoute(route: Route, session: Session, refreshSession: () => Promise<void>) {
|
||||
if (route.name === "login") return <LoginPage onSessionChange={refreshSession} />;
|
||||
@ -40,9 +41,11 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
|
||||
export function App() {
|
||||
const [route, setRoute] = useState(parseRoute());
|
||||
const [session, setSession] = useState<Session>({ user: null, degraded: false });
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
|
||||
async function refreshSession() {
|
||||
setSession(await api.session());
|
||||
setSessionChecked(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -55,5 +58,35 @@ export function App() {
|
||||
return () => window.removeEventListener("popstate", listener);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
setSession({ user: null, degraded: false });
|
||||
setSessionChecked(true);
|
||||
if (isPrivateRoute(parseRoute())) navigate("/login");
|
||||
};
|
||||
window.addEventListener("readabook:session-expired", listener);
|
||||
return () => window.removeEventListener("readabook:session-expired", listener);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionChecked && !session.user && isPrivateRoute(route)) navigate("/login");
|
||||
}, [route, session.user, sessionChecked]);
|
||||
|
||||
if (!sessionChecked && isPrivateRoute(route)) {
|
||||
return (
|
||||
<div className="auth-surface">
|
||||
<section className="auth-hero">
|
||||
<p>Cabinet de curiosites numerique</p>
|
||||
<h1>ReadaBook</h1>
|
||||
<span>Verification de session.</span>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionChecked && !session.user && isPrivateRoute(route)) {
|
||||
return <LoginPage onSessionChange={refreshSession} />;
|
||||
}
|
||||
|
||||
return renderRoute(route, session, refreshSession);
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import type {
|
||||
BookDto,
|
||||
BookQueryDto,
|
||||
AuthStatusDto,
|
||||
BootstrapAdminDto,
|
||||
CreateLibraryDto,
|
||||
JobDto,
|
||||
LibraryDto,
|
||||
LoginDto,
|
||||
ProgressDto,
|
||||
UpdateAccountDto,
|
||||
UpdateProgressDto,
|
||||
UserDto
|
||||
} from "@readabook/shared";
|
||||
@ -44,6 +46,9 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("readabook:session-expired"));
|
||||
}
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
@ -75,6 +80,9 @@ export const api = {
|
||||
return { user: null, degraded: false };
|
||||
}
|
||||
},
|
||||
async authStatus(): Promise<AuthStatusDto> {
|
||||
return request<AuthStatusDto>("/auth/status");
|
||||
},
|
||||
async bootstrap(input: BootstrapAdminDto): Promise<UserDto> {
|
||||
const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) });
|
||||
return result;
|
||||
@ -86,6 +94,9 @@ export const api = {
|
||||
async logout(): Promise<void> {
|
||||
await request<{ ok: true }>("/auth/logout", { method: "POST" });
|
||||
},
|
||||
async updateMe(input: UpdateAccountDto): Promise<UserDto> {
|
||||
return request<UserDto>("/auth/me", { method: "PATCH", body: JSON.stringify(input) });
|
||||
},
|
||||
async books(query: Partial<BookQueryDto> = {}): Promise<BookDto[]> {
|
||||
return request<BookDto[]>(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks });
|
||||
},
|
||||
|
||||
15
apps/web/src/auth/routing.test.ts
Normal file
15
apps/web/src/auth/routing.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isPrivateRoute, isPublicRoute } from "./routing";
|
||||
|
||||
describe("auth route guards", () => {
|
||||
it("keeps login and setup public", () => {
|
||||
expect(isPublicRoute({ name: "login" })).toBe(true);
|
||||
expect(isPublicRoute({ name: "setup", step: "admin" })).toBe(true);
|
||||
});
|
||||
|
||||
it("marks catalogue routes private", () => {
|
||||
expect(isPrivateRoute({ name: "home" })).toBe(true);
|
||||
expect(isPrivateRoute({ name: "search" })).toBe(true);
|
||||
expect(isPrivateRoute({ name: "book", bookId: 1 })).toBe(true);
|
||||
});
|
||||
});
|
||||
9
apps/web/src/auth/routing.ts
Normal file
9
apps/web/src/auth/routing.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { Route } from "../router";
|
||||
|
||||
export function isPublicRoute(route: Route): boolean {
|
||||
return route.name === "login" || route.name === "setup";
|
||||
}
|
||||
|
||||
export function isPrivateRoute(route: Route): boolean {
|
||||
return !isPublicRoute(route);
|
||||
}
|
||||
@ -1,14 +1,35 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { KeyRound, LogIn } from "lucide-react";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { KeyRound, LogIn, ShieldAlert } from "lucide-react";
|
||||
import type { AuthStatusDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { navigate } from "../router";
|
||||
import { ErrorRibbon, Panel } from "../components/ui";
|
||||
|
||||
const DEFAULT_INITIAL_PASSWORD = "readabook-admin-change-me";
|
||||
|
||||
export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<void> }) {
|
||||
const [email, setEmail] = useState("admin@readabook.local");
|
||||
const [status, setStatus] = useState<AuthStatusDto | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.authStatus()
|
||||
.then((nextStatus) => {
|
||||
if (!alive) return;
|
||||
setStatus(nextStatus);
|
||||
setEmail((current) => current || nextStatus.initialAdminEmail);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setError("Statut d'authentification indisponible.");
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(undefined);
|
||||
@ -32,6 +53,17 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
|
||||
<KeyRound size={24} />
|
||||
<h2>Entrer dans le cabinet</h2>
|
||||
<ErrorRibbon message={error} />
|
||||
{status?.initialAdminPasswordIsDefault && (
|
||||
<div className="initial-admin-box">
|
||||
<ShieldAlert size={18} />
|
||||
<div>
|
||||
<strong>Acces admin initial</strong>
|
||||
<span>{status.initialAdminEmail}</span>
|
||||
<code>{DEFAULT_INITIAL_PASSWORD}</code>
|
||||
<small>Change ces identifiants dans Mon compte > Securite apres connexion.</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={submit} className="stack-form">
|
||||
<label>
|
||||
Email
|
||||
@ -46,9 +78,11 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
|
||||
Initialiser le premier admin
|
||||
</button>
|
||||
{status && !status.hasUsers && (
|
||||
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
|
||||
Initialiser le premier admin
|
||||
</button>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,19 +1,47 @@
|
||||
import { LogOut, UserRound } from "lucide-react";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { KeyRound, LogOut, UserRound } from "lucide-react";
|
||||
import type { Session } from "../api/types";
|
||||
import { api } from "../api/client";
|
||||
import { Panel } from "../components/ui";
|
||||
import { ErrorRibbon, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
|
||||
export function ProfilePage({ session, onSessionChange }: { session: Session; onSessionChange: () => Promise<void> }) {
|
||||
const [email, setEmail] = useState(session.user?.email ?? "");
|
||||
const [name, setName] = useState(session.user?.name ?? "");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [error, setError] = useState<string>();
|
||||
const [success, setSuccess] = useState<string>();
|
||||
|
||||
async function logout() {
|
||||
await api.logout();
|
||||
await onSessionChange();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
async function updateSecurity(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(undefined);
|
||||
setSuccess(undefined);
|
||||
try {
|
||||
await api.updateMe({
|
||||
email: email === session.user?.email ? undefined : email,
|
||||
name: name || null,
|
||||
currentPassword,
|
||||
newPassword: newPassword || undefined
|
||||
});
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setSuccess("Identifiants mis a jour.");
|
||||
await onSessionChange();
|
||||
} catch (updateError) {
|
||||
setError(updateError instanceof Error ? updateError.message : "Mise a jour impossible");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Panel className="span-2 profile-panel">
|
||||
<Panel className="profile-panel">
|
||||
<UserRound size={28} />
|
||||
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
|
||||
<p>{session.user?.email ?? "Session non connectee"}</p>
|
||||
@ -23,6 +51,36 @@ export function ProfilePage({ session, onSessionChange }: { session: Session; on
|
||||
Sortir
|
||||
</button>
|
||||
</Panel>
|
||||
<Panel className="span-2">
|
||||
<div className="section-heading">
|
||||
<h2>Securite</h2>
|
||||
<KeyRound size={20} />
|
||||
</div>
|
||||
<p className="muted-copy">Change l'email et le mot de passe admin initial des que le cabinet est installe.</p>
|
||||
<ErrorRibbon message={error} />
|
||||
{success && <div className="success-ribbon">{success}</div>}
|
||||
<form className="stack-form" onSubmit={updateSecurity}>
|
||||
<label>
|
||||
Email
|
||||
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />
|
||||
</label>
|
||||
<label>
|
||||
Nom
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Mot de passe actuel
|
||||
<input value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} type="password" required />
|
||||
</label>
|
||||
<label>
|
||||
Nouveau mot de passe
|
||||
<input value={newPassword} onChange={(event) => setNewPassword(event.target.value)} type="password" minLength={8} />
|
||||
</label>
|
||||
<button className="primary-button" type="submit">
|
||||
Enregistrer
|
||||
</button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -326,6 +326,45 @@ input {
|
||||
background: rgba(169, 72, 52, 0.18);
|
||||
}
|
||||
|
||||
.success-ribbon {
|
||||
margin: 10px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(45, 111, 99, 0.72);
|
||||
border-radius: var(--radius);
|
||||
color: #d8fff5;
|
||||
background: rgba(45, 111, 99, 0.18);
|
||||
}
|
||||
|
||||
.initial-admin-box {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
margin: 12px 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(213, 168, 77, 0.45);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(213, 168, 77, 0.12);
|
||||
}
|
||||
|
||||
.initial-admin-box div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.initial-admin-box code {
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--brass);
|
||||
}
|
||||
|
||||
.muted-copy,
|
||||
.initial-admin-box span,
|
||||
.initial-admin-box small {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.retry-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -11,6 +11,8 @@ services:
|
||||
STORAGE_DIR: /data/storage
|
||||
JWT_SECRET: ${JWT_SECRET:-dev-change-me-readabook}
|
||||
OPEN_LIBRARY_ENABLED: ${OPEN_LIBRARY_ENABLED:-true}
|
||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@readabook.local}
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./data/library:/library:ro
|
||||
|
||||
@ -25,6 +25,25 @@ export const LoginSchema = z.object({
|
||||
});
|
||||
export type LoginDto = z.infer<typeof LoginSchema>;
|
||||
|
||||
export const AuthStatusSchema = z.object({
|
||||
hasUsers: z.boolean(),
|
||||
initialAdminEmail: z.string().email(),
|
||||
initialAdminPasswordIsDefault: z.boolean()
|
||||
});
|
||||
export type AuthStatusDto = z.infer<typeof AuthStatusSchema>;
|
||||
|
||||
export const UpdateAccountSchema = z
|
||||
.object({
|
||||
email: z.string().email().optional(),
|
||||
name: z.string().min(1).max(120).nullable().optional(),
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8).optional()
|
||||
})
|
||||
.refine((value) => value.email !== undefined || value.name !== undefined || value.newPassword !== undefined, {
|
||||
message: "At least one account field must be changed"
|
||||
});
|
||||
export type UpdateAccountDto = z.infer<typeof UpdateAccountSchema>;
|
||||
|
||||
export const CreateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
|
||||
Reference in New Issue
Block a user