fix(api,web): corrections auth — gestion d'erreur login, messages explicites

- web: auth/errors (+ tests), LoginPage, client API
- api: auth controller/service

Refs: #14
This commit is contained in:
Git Agent
2026-08-23 11:10:08 +02:00
parent 8f7555cfe4
commit 0af88a63ca
6 changed files with 74 additions and 8 deletions

View File

@ -19,7 +19,7 @@ export class AuthController {
constructor(private readonly auth: AuthService) {} constructor(private readonly auth: AuthService) {}
@Get("status") @Get("status")
status() { async status() {
return this.auth.status(); return this.auth.status();
} }

View File

@ -33,12 +33,23 @@ export class AuthService implements OnModuleInit {
return this.createUser({ ...input, role: "admin" }); return this.createUser({ ...input, role: "admin" });
} }
status() { async status() {
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get(); 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 { return {
hasUsers: Boolean(existing), hasUsers: Boolean(existing),
initialAdminEmail: this.database.config.initialAdminEmail.toLowerCase(), initialAdminEmail: this.database.config.initialAdminEmail.toLowerCase(),
initialAdminPasswordIsDefault: this.database.config.initialAdminPasswordIsDefault initialAdminPasswordIsDefault
}; };
} }

View File

@ -30,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<T>(error: unknown): T | undefined { export function getApiFallback<T>(error: unknown): T | undefined {
return error instanceof ApiFallbackError ? (error.fallback as 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<T>(path: string, options: RequestOptions = {}): Promise<T> { async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
try { try {
const response = await fetch(`${API_BASE}${path}`, { const response = await fetch(`${API_BASE}${path}`, {
@ -50,7 +72,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
window.dispatchEvent(new CustomEvent("readabook:session-expired")); 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 ApiHttpError(response.status, apiErrorMessage(detail, `${response.status} ${response.statusText}`));
} }
return (await response.json()) as T; return (await response.json()) as T;

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import { FormEvent, useEffect, useState } from "react";
import { KeyRound, LogIn, ShieldAlert } from "lucide-react"; import { KeyRound, LogIn, ShieldAlert } from "lucide-react";
import type { AuthStatusDto } from "@readabook/shared"; import type { AuthStatusDto } from "@readabook/shared";
import { api } from "../api/client"; import { api } from "../api/client";
import { loginErrorMessage } from "../auth/errors";
import { navigate } from "../router"; import { navigate } from "../router";
import { ErrorRibbon, Panel } from "../components/ui"; import { ErrorRibbon, Panel } from "../components/ui";
@ -38,7 +39,7 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
await onSessionChange(); await onSessionChange();
navigate("/home"); navigate("/home");
} catch (loginError) { } catch (loginError) {
setError(loginError instanceof Error ? loginError.message : "Connexion impossible"); setError(loginErrorMessage(loginError));
} }
} }
@ -53,14 +54,20 @@ 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 && ( {status?.hasUsers && (
<div className="initial-admin-box"> <div className="initial-admin-box">
<ShieldAlert size={18} /> <ShieldAlert size={18} />
<div> <div>
<strong>Acces admin initial</strong> <strong>Acces admin initial</strong>
<span>{status.initialAdminEmail}</span> <span>{status.initialAdminEmail}</span>
<code>{DEFAULT_INITIAL_PASSWORD}</code> {status.initialAdminPasswordIsDefault ? (
<small>Change ces identifiants dans Mon compte &gt; Securite apres connexion.</small> <>
<code>{DEFAULT_INITIAL_PASSWORD}</code>
<small>Mot de passe par defaut atteste par le serveur. Change-le dans Mon compte &gt; Securite.</small>
</>
) : (
<small>Utilise le mot de passe configure au demarrage ou deja modifie dans le compte.</small>
)}
</div> </div>
</div> </div>
)} )}