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 "@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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
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 { 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 > 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user