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:
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
15
apps/web/src/auth/errors.test.ts
Normal file
15
apps/web/src/auth/errors.test.ts
Normal 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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
11
apps/web/src/auth/errors.ts
Normal file
11
apps/web/src/auth/errors.ts
Normal 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.";
|
||||||
|
}
|
||||||
@ -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 > Securite apres connexion.</small>
|
<>
|
||||||
|
<code>{DEFAULT_INITIAL_PASSWORD}</code>
|
||||||
|
<small>Mot de passe par defaut atteste par le serveur. Change-le dans Mon compte > Securite.</small>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<small>Utilise le mot de passe configure au demarrage ou deja modifie dans le compte.</small>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user