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:
Git Agent
2026-08-23 11:01:00 +02:00
parent e94524f119
commit 8f7555cfe4
12 changed files with 326 additions and 16 deletions

View File

@ -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 "@fastify/cookie";
import { FastifyReply } from "fastify"; 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 { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { AuthGuard } from "./auth.guard.js"; import { AuthGuard } from "./auth.guard.js";
import { AuthService } from "./auth.service.js"; import { AuthService } from "./auth.service.js";
@ -11,6 +18,11 @@ import { CurrentUser, CurrentUserParam } from "./current-user.js";
export class AuthController { export class AuthController {
constructor(private readonly auth: AuthService) {} constructor(private readonly auth: AuthService) {}
@Get("status")
status() {
return this.auth.status();
}
@Post("bootstrap") @Post("bootstrap")
async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) { async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) {
return this.auth.bootstrapAdmin(body); return this.auth.bootstrapAdmin(body);
@ -40,4 +52,13 @@ export class AuthController {
me(@CurrentUserParam() user: CurrentUser) { me(@CurrentUserParam() user: CurrentUser) {
return { user }; return { user };
} }
@Patch("me")
@UseGuards(AuthGuard)
updateMe(
@CurrentUserParam() user: CurrentUser,
@Body(new ZodValidationPipe(UpdateAccountSchema)) body: UpdateAccountDto
) {
return this.auth.updateOwnAccount(user.id, body);
}
} }

View File

@ -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 { eq } from "drizzle-orm";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
import argon2 from "argon2"; 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 { DatabaseService } from "../database/database.service.js";
import { users } from "../database/schema.js"; import { users } from "../database/schema.js";
import { CurrentUser } from "./current-user.js"; import { CurrentUser } from "./current-user.js";
@Injectable() @Injectable()
export class AuthService { export class AuthService implements OnModuleInit {
readonly cookieName: string; readonly cookieName: string;
private readonly secret: Uint8Array; private readonly secret: Uint8Array;
@ -17,6 +17,10 @@ export class AuthService {
this.secret = new TextEncoder().encode(database.config.jwtSecret); this.secret = new TextEncoder().encode(database.config.jwtSecret);
} }
async onModuleInit(): Promise<void> {
await this.ensureInitialAdmin();
}
get cookieSecure(): boolean { get cookieSecure(): boolean {
return this.database.config.cookieSecure; return this.database.config.cookieSecure;
} }
@ -29,6 +33,15 @@ export class AuthService {
return this.createUser({ ...input, role: "admin" }); 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 }> { 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(); 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))) { if (!user || !(await argon2.verify(user.passwordHash, input.password))) {
@ -65,6 +78,35 @@ export class AuthService {
.all(); .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) { async createUser(input: CreateUserDto) {
const now = this.database.now(); const now = this.database.now();
const passwordHash = await argon2.hash(input.password); const passwordHash = await argon2.hash(input.password);
@ -125,4 +167,22 @@ export class AuthService {
.setExpirationTime("7d") .setExpirationTime("7d")
.sign(this.secret); .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();
}
} }

View File

@ -11,8 +11,14 @@ export type AppConfig = {
cookieName: string; cookieName: string;
cookieSecure: boolean; cookieSecure: boolean;
openLibraryEnabled: 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 { export function loadConfig(): AppConfig {
const databasePath = resolve(process.env.DATABASE_PATH ?? "./data/readabook.sqlite"); const databasePath = resolve(process.env.DATABASE_PATH ?? "./data/readabook.sqlite");
const storageDir = resolve(process.env.STORAGE_DIR ?? "./data/storage"); 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", jwtSecret: process.env.JWT_SECRET ?? "dev-change-me-readabook",
cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session", cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session",
cookieSecure: process.env.COOKIE_SECURE === "true", 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
}; };
} }

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Session } from "./api/types"; import type { Session } from "./api/types";
import { api } from "./api/client"; import { api } from "./api/client";
import { isPrivateRoute } from "./auth/routing";
import { AppShell } from "./layout/AppShell"; import { AppShell } from "./layout/AppShell";
import { AdminPage } from "./pages/AdminPage"; import { AdminPage } from "./pages/AdminPage";
import { BookPage } from "./pages/BookPage"; import { BookPage } from "./pages/BookPage";
@ -11,7 +12,7 @@ import { ProfilePage } from "./pages/ProfilePage";
import { ReaderPage } from "./pages/ReaderPage"; import { ReaderPage } from "./pages/ReaderPage";
import { SearchPage } from "./pages/SearchPage"; import { SearchPage } from "./pages/SearchPage";
import { SetupPage } from "./pages/SetupPage"; 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>) { function renderRoute(route: Route, session: Session, refreshSession: () => Promise<void>) {
if (route.name === "login") return <LoginPage onSessionChange={refreshSession} />; if (route.name === "login") return <LoginPage onSessionChange={refreshSession} />;
@ -40,9 +41,11 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
export function App() { export function App() {
const [route, setRoute] = useState(parseRoute()); const [route, setRoute] = useState(parseRoute());
const [session, setSession] = useState<Session>({ user: null, degraded: false }); const [session, setSession] = useState<Session>({ user: null, degraded: false });
const [sessionChecked, setSessionChecked] = useState(false);
async function refreshSession() { async function refreshSession() {
setSession(await api.session()); setSession(await api.session());
setSessionChecked(true);
} }
useEffect(() => { useEffect(() => {
@ -55,5 +58,35 @@ export function App() {
return () => window.removeEventListener("popstate", listener); 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); return renderRoute(route, session, refreshSession);
} }

View File

@ -1,12 +1,14 @@
import type { import type {
BookDto, BookDto,
BookQueryDto, BookQueryDto,
AuthStatusDto,
BootstrapAdminDto, BootstrapAdminDto,
CreateLibraryDto, CreateLibraryDto,
JobDto, JobDto,
LibraryDto, LibraryDto,
LoginDto, LoginDto,
ProgressDto, ProgressDto,
UpdateAccountDto,
UpdateProgressDto, UpdateProgressDto,
UserDto UserDto
} from "@readabook/shared"; } from "@readabook/shared";
@ -44,6 +46,9 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
}); });
if (!response.ok) { if (!response.ok) {
if (response.status === 401 && typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("readabook:session-expired"));
}
const detail = await response.text(); const detail = await response.text();
throw new Error(detail || `${response.status} ${response.statusText}`); throw new Error(detail || `${response.status} ${response.statusText}`);
} }
@ -75,6 +80,9 @@ export const api = {
return { user: null, degraded: false }; return { user: null, degraded: false };
} }
}, },
async authStatus(): Promise<AuthStatusDto> {
return request<AuthStatusDto>("/auth/status");
},
async bootstrap(input: BootstrapAdminDto): Promise<UserDto> { async bootstrap(input: BootstrapAdminDto): Promise<UserDto> {
const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) }); const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) });
return result; return result;
@ -86,6 +94,9 @@ export const api = {
async logout(): Promise<void> { async logout(): Promise<void> {
await request<{ ok: true }>("/auth/logout", { method: "POST" }); 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[]> { async books(query: Partial<BookQueryDto> = {}): Promise<BookDto[]> {
return request<BookDto[]>(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks }); return request<BookDto[]>(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks });
}, },

View 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);
});
});

View 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);
}

View File

@ -1,14 +1,35 @@
import { FormEvent, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { KeyRound, LogIn } from "lucide-react"; import { KeyRound, LogIn, ShieldAlert } from "lucide-react";
import type { AuthStatusDto } from "@readabook/shared";
import { api } from "../api/client"; import { api } from "../api/client";
import { navigate } from "../router"; import { navigate } from "../router";
import { ErrorRibbon, Panel } from "../components/ui"; import { ErrorRibbon, Panel } from "../components/ui";
const DEFAULT_INITIAL_PASSWORD = "readabook-admin-change-me";
export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<void> }) { 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 [password, setPassword] = useState("");
const [error, setError] = useState<string>(); 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) { async function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
setError(undefined); setError(undefined);
@ -32,6 +53,17 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
<KeyRound size={24} /> <KeyRound size={24} />
<h2>Entrer dans le cabinet</h2> <h2>Entrer dans le cabinet</h2>
<ErrorRibbon message={error} /> <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 &gt; Securite apres connexion.</small>
</div>
</div>
)}
<form onSubmit={submit} className="stack-form"> <form onSubmit={submit} className="stack-form">
<label> <label>
Email Email
@ -46,9 +78,11 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
Se connecter Se connecter
</button> </button>
</form> </form>
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}> {status && !status.hasUsers && (
Initialiser le premier admin <button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
</button> Initialiser le premier admin
</button>
)}
</Panel> </Panel>
</div> </div>
); );

View File

@ -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 type { Session } from "../api/types";
import { api } from "../api/client"; import { api } from "../api/client";
import { Panel } from "../components/ui"; import { ErrorRibbon, Panel } from "../components/ui";
import { navigate } from "../router"; import { navigate } from "../router";
export function ProfilePage({ session, onSessionChange }: { session: Session; onSessionChange: () => Promise<void> }) { 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() { async function logout() {
await api.logout(); await api.logout();
await onSessionChange(); await onSessionChange();
navigate("/login"); 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 ( return (
<div className="page-grid"> <div className="page-grid">
<Panel className="span-2 profile-panel"> <Panel className="profile-panel">
<UserRound size={28} /> <UserRound size={28} />
<h1>{session.user?.name ?? "Lecteur invite"}</h1> <h1>{session.user?.name ?? "Lecteur invite"}</h1>
<p>{session.user?.email ?? "Session non connectee"}</p> <p>{session.user?.email ?? "Session non connectee"}</p>
@ -23,6 +51,36 @@ export function ProfilePage({ session, onSessionChange }: { session: Session; on
Sortir Sortir
</button> </button>
</Panel> </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> </div>
); );
} }

View File

@ -326,6 +326,45 @@ input {
background: rgba(169, 72, 52, 0.18); 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 { .retry-row {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -11,6 +11,8 @@ services:
STORAGE_DIR: /data/storage STORAGE_DIR: /data/storage
JWT_SECRET: ${JWT_SECRET:-dev-change-me-readabook} JWT_SECRET: ${JWT_SECRET:-dev-change-me-readabook}
OPEN_LIBRARY_ENABLED: ${OPEN_LIBRARY_ENABLED:-true} 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: volumes:
- ./data:/data - ./data:/data
- ./data/library:/library:ro - ./data/library:/library:ro

View File

@ -25,6 +25,25 @@ export const LoginSchema = z.object({
}); });
export type LoginDto = z.infer<typeof LoginSchema>; 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({ export const CreateUserSchema = z.object({
email: z.string().email(), email: z.string().email(),
password: z.string().min(8), password: z.string().min(8),