From 8f7555cfe4b99fd53e893e8772062a7e9c5960ec Mon Sep 17 00:00:00 2001 From: Git Agent Date: Sun, 23 Aug 2026 11:01:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(api,web):=20auth=20et=20first-run=20?= =?UTF-8?q?=E2=80=94=20bootstrap=20admin,=20login,=20session,=20gardes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/src/auth/auth.controller.ts | 25 ++++++++++- apps/api/src/auth/auth.service.ts | 66 ++++++++++++++++++++++++++-- apps/api/src/config/env.ts | 11 ++++- apps/web/src/App.tsx | 35 ++++++++++++++- apps/web/src/api/client.ts | 11 +++++ apps/web/src/auth/routing.test.ts | 15 +++++++ apps/web/src/auth/routing.ts | 9 ++++ apps/web/src/pages/LoginPage.tsx | 46 ++++++++++++++++--- apps/web/src/pages/ProfilePage.tsx | 64 +++++++++++++++++++++++++-- apps/web/src/styles/app.css | 39 ++++++++++++++++ docker-compose.yaml | 2 + packages/shared/src/index.ts | 19 ++++++++ 12 files changed, 326 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/auth/routing.test.ts create mode 100644 apps/web/src/auth/routing.ts diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 9e6c92f..9ba269c 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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); + } } diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 40174ee..7fbf4a9 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + 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 = { 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 { + 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(); + } } diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 4aba511..fd55b3b 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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 }; } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 70be6aa..be34fb9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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) { if (route.name === "login") return ; @@ -40,9 +41,11 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi export function App() { const [route, setRoute] = useState(parseRoute()); const [session, setSession] = useState({ 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 ( +
+
+

Cabinet de curiosites numerique

+

ReadaBook

+ Verification de session. +
+
+ ); + } + + if (sessionChecked && !session.user && isPrivateRoute(route)) { + return ; + } + return renderRoute(route, session, refreshSession); } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index c128780..6f9190e 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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(path: string, options: RequestOptions = {}): Promise { + return request("/auth/status"); + }, async bootstrap(input: BootstrapAdminDto): Promise { const result = await request("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) }); return result; @@ -86,6 +94,9 @@ export const api = { async logout(): Promise { await request<{ ok: true }>("/auth/logout", { method: "POST" }); }, + async updateMe(input: UpdateAccountDto): Promise { + return request("/auth/me", { method: "PATCH", body: JSON.stringify(input) }); + }, async books(query: Partial = {}): Promise { return request(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks }); }, diff --git a/apps/web/src/auth/routing.test.ts b/apps/web/src/auth/routing.test.ts new file mode 100644 index 0000000..0e53960 --- /dev/null +++ b/apps/web/src/auth/routing.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/auth/routing.ts b/apps/web/src/auth/routing.ts new file mode 100644 index 0000000..8c051a4 --- /dev/null +++ b/apps/web/src/auth/routing.ts @@ -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); +} diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 9195481..65608bf 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -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 }) { - const [email, setEmail] = useState("admin@readabook.local"); + const [status, setStatus] = useState(null); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(); + 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<

Entrer dans le cabinet

+ {status?.initialAdminPasswordIsDefault && ( +
+ +
+ Acces admin initial + {status.initialAdminEmail} + {DEFAULT_INITIAL_PASSWORD} + Change ces identifiants dans Mon compte > Securite apres connexion. +
+
+ )}