diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 9e6c92f..a8da930 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") + async 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..d6a6f8d 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,26 @@ export class AuthService { return this.createUser({ ...input, role: "admin" }); } + async status() { + const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get(); + const initialAdmin = this.database.db + .select({ passwordHash: users.passwordHash, role: users.role }) + .from(users) + .where(eq(users.email, this.database.config.initialAdminEmail.toLowerCase())) + .get(); + const initialAdminPasswordIsDefault = + Boolean(initialAdmin) && + initialAdmin?.role === "admin" && + this.database.config.initialAdminPasswordIsDefault && + (await argon2.verify(initialAdmin.passwordHash, this.database.config.initialAdminPassword)); + + return { + hasUsers: Boolean(existing), + initialAdminEmail: this.database.config.initialAdminEmail.toLowerCase(), + 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 +89,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 +178,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..93825f2 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"; @@ -28,10 +30,32 @@ export class ApiFallbackError extends Error { } } +export class ApiHttpError extends Error { + constructor( + public readonly status: number, + message: string + ) { + super(message); + } +} + export function getApiFallback(error: unknown): T | undefined { return error instanceof ApiFallbackError ? (error.fallback as T) : undefined; } +function apiErrorMessage(detail: string, fallback: string): string { + if (!detail) return fallback; + try { + const parsed = JSON.parse(detail) as { message?: unknown; error?: unknown }; + if (typeof parsed.message === "string") return parsed.message; + if (Array.isArray(parsed.message)) return parsed.message.join(", "); + if (typeof parsed.error === "string") return parsed.error; + } catch { + return detail; + } + return fallback; +} + async function request(path: string, options: RequestOptions = {}): Promise { try { const response = await fetch(`${API_BASE}${path}`, { @@ -44,8 +68,11 @@ 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 +116,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/errors.test.ts b/apps/web/src/auth/errors.test.ts new file mode 100644 index 0000000..35f5594 --- /dev/null +++ b/apps/web/src/auth/errors.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { ApiHttpError } from "../api/client"; +import { loginErrorMessage } from "./errors"; + +describe("login error messages", () => { + it("maps invalid credentials", () => { + expect(loginErrorMessage(new ApiHttpError(401, "Invalid credentials"))).toBe("Identifiants invalides."); + expect(loginErrorMessage(new ApiHttpError(403, "Forbidden"))).toBe("Identifiants invalides."); + }); + + it("maps server and network errors", () => { + expect(loginErrorMessage(new ApiHttpError(500, "Internal error"))).toBe("Serveur d'authentification indisponible."); + expect(loginErrorMessage(new TypeError("fetch failed"))).toBe("Connexion au serveur impossible."); + }); +}); diff --git a/apps/web/src/auth/errors.ts b/apps/web/src/auth/errors.ts new file mode 100644 index 0000000..22089d7 --- /dev/null +++ b/apps/web/src/auth/errors.ts @@ -0,0 +1,11 @@ +import { ApiHttpError } from "../api/client"; + +export function loginErrorMessage(error: unknown): string { + if (error instanceof ApiHttpError) { + if (error.status === 401 || error.status === 403) return "Identifiants invalides."; + if (error.status >= 500) return "Serveur d'authentification indisponible."; + return "Connexion impossible."; + } + if (error instanceof TypeError) return "Connexion au serveur impossible."; + return "Connexion impossible."; +} 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..98de627 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -1,14 +1,36 @@ -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 { loginErrorMessage } from "../auth/errors"; 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); @@ -17,7 +39,7 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise< await onSessionChange(); navigate("/home"); } catch (loginError) { - setError(loginError instanceof Error ? loginError.message : "Connexion impossible"); + setError(loginErrorMessage(loginError)); } } @@ -32,6 +54,23 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<

Entrer dans le cabinet

+ {status?.hasUsers && ( +
+ +
+ Acces admin initial + {status.initialAdminEmail} + {status.initialAdminPasswordIsDefault ? ( + <> + {DEFAULT_INITIAL_PASSWORD} + Mot de passe par defaut atteste par le serveur. Change-le dans Mon compte > Securite. + + ) : ( + Utilise le mot de passe configure au demarrage ou deja modifie dans le compte. + )} +
+
+ )}