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,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<void>) {
|
||||
if (route.name === "login") return <LoginPage onSessionChange={refreshSession} />;
|
||||
@ -40,9 +41,11 @@ function renderRoute(route: Route, session: Session, refreshSession: () => Promi
|
||||
export function App() {
|
||||
const [route, setRoute] = useState(parseRoute());
|
||||
const [session, setSession] = useState<Session>({ 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 (
|
||||
<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);
|
||||
}
|
||||
|
||||
@ -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<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("readabook:session-expired"));
|
||||
}
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
@ -75,6 +80,9 @@ export const api = {
|
||||
return { user: null, degraded: false };
|
||||
}
|
||||
},
|
||||
async authStatus(): Promise<AuthStatusDto> {
|
||||
return request<AuthStatusDto>("/auth/status");
|
||||
},
|
||||
async bootstrap(input: BootstrapAdminDto): Promise<UserDto> {
|
||||
const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) });
|
||||
return result;
|
||||
@ -86,6 +94,9 @@ export const api = {
|
||||
async logout(): Promise<void> {
|
||||
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[]> {
|
||||
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 { 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<void> }) {
|
||||
const [email, setEmail] = useState("admin@readabook.local");
|
||||
const [status, setStatus] = useState<AuthStatusDto | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
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) {
|
||||
event.preventDefault();
|
||||
setError(undefined);
|
||||
@ -32,6 +53,17 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
|
||||
<KeyRound size={24} />
|
||||
<h2>Entrer dans le cabinet</h2>
|
||||
<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">
|
||||
<label>
|
||||
Email
|
||||
@ -46,9 +78,11 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
|
||||
Initialiser le premier admin
|
||||
</button>
|
||||
{status && !status.hasUsers && (
|
||||
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
|
||||
Initialiser le premier admin
|
||||
</button>
|
||||
)}
|
||||
</Panel>
|
||||
</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 { api } from "../api/client";
|
||||
import { Panel } from "../components/ui";
|
||||
import { ErrorRibbon, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
|
||||
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() {
|
||||
await api.logout();
|
||||
await onSessionChange();
|
||||
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 (
|
||||
<div className="page-grid">
|
||||
<Panel className="span-2 profile-panel">
|
||||
<Panel className="profile-panel">
|
||||
<UserRound size={28} />
|
||||
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
|
||||
<p>{session.user?.email ?? "Session non connectee"}</p>
|
||||
@ -23,6 +51,36 @@ export function ProfilePage({ session, onSessionChange }: { session: Session; on
|
||||
Sortir
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -326,6 +326,45 @@ input {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user