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,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 &gt; 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>
);

View File

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