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

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 { 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();
}
}

View File

@ -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
};
}