M15: synchronize contracts and acceptance

This commit is contained in:
NuklearRabbit
2026-08-10 05:39:19 +02:00
parent 58fb515337
commit 2ee8b2d82b
38 changed files with 5091 additions and 1727 deletions
+1
View File
@@ -49,6 +49,7 @@ export const KNOWN_CODES = new Set([
"NO_CONFLICT_DETECTED",
"RECOMMENDATION_STALE",
"UNAUTHORIZED_SERVICE",
"UNKNOWN_WORKFLOW",
]);
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
+1 -1
View File
@@ -316,6 +316,7 @@ export function Layout() {
<div className="topbar-meta">
{demoMode && <DemoGuideTrigger />}
{demoMode && <DemoBadge />}
{user && <LanguageSwitcher compact />}
{user && (
<details className="operator-menu">
<summary className="operator">
@@ -323,7 +324,6 @@ export function Layout() {
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</summary>
<div className="operator-popover">
<LanguageSwitcher compact />
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
<button className="operator-logout" type="button" onClick={handleLogout}>
<Icon name="logout" /> {t("switchRole")}
+20 -5
View File
@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { api, onUnauthorized } from "../api/client";
import { api, ApiError, onUnauthorized } from "../api/client";
import type { CurrentUser, Role, SystemStatus } from "../api/types";
interface AuthState {
@@ -93,13 +93,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const logout = useCallback(async () => {
setUser(null);
cacheUser(null);
try {
await api.post("/api/v1/auth/logout");
// A logout response expires an HttpOnly cookie. Chromium can expose a very small
// race between resolving fetch() and applying that Set-Cookie header to an
// immediate top-level navigation. Confirm the server now rejects the session
// before allowing the router to continue; retrying logout is idempotent.
for (let attempt = 0; attempt < 3; attempt += 1) {
await api.post("/api/v1/auth/logout");
try {
await api.get<CurrentUser>("/api/v1/auth/session");
} catch (err) {
if (err instanceof ApiError && err.status === 401) return;
throw err;
}
}
} catch {
// Best effort: the cookie is cleared server-side when it works, and the client has
// already dropped its own state either way.
// still drops its own state when the request itself is unavailable.
} finally {
// Keep RequireAuth from redirecting to /login while the server-side invalidation
// is still in flight. Otherwise a very fast navigation can race ahead of logout.
setUser(null);
cacheUser(null);
}
}, []);
@@ -142,6 +142,10 @@
"UNAUTHORIZED_SERVICE": {
"title": "Service authorisation failed",
"explanation": "This automated request could not be authorised."
},
"UNKNOWN_WORKFLOW": {
"title": "Unknown automation workflow",
"explanation": "This workflow is not one of the four managed automation workflows."
}
},
"http": {
@@ -11,7 +11,8 @@
"quality": "Data quality",
"knowledge": "Knowledge",
"integrations": "Integrations",
"audit": "Audit trail"
"audit": "Audit trail",
"users": "Users"
},
"primaryNavLabel": "Primary navigation",
"mobileNavLabel": "Mobile navigation",
@@ -41,8 +42,7 @@
"quality": "Quality workbench",
"knowledge": "Procedure assistant",
"integrations": "Automation and integration status",
"audit": "Audit history",
"users": "Users"
"audit": "Audit history"
},
"switchRole": "Switch role",
"switchRoleTitle": "Switch demo role",
@@ -142,6 +142,10 @@
"UNAUTHORIZED_SERVICE": {
"title": "Échec de l'autorisation du service",
"explanation": "Cette demande automatisée n'a pas pu être autorisée."
},
"UNKNOWN_WORKFLOW": {
"title": "Workflow dautomatisation inconnu",
"explanation": "Ce workflow ne fait pas partie des quatre automatisations gérées."
}
},
"http": {
@@ -11,7 +11,8 @@
"quality": "Qualité des données",
"knowledge": "Connaissances",
"integrations": "Intégrations",
"audit": "Piste d'audit"
"audit": "Piste d'audit",
"users": "Utilisateurs"
},
"primaryNavLabel": "Navigation principale",
"mobileNavLabel": "Navigation mobile",
@@ -41,8 +42,7 @@
"quality": "Atelier qualité",
"knowledge": "Assistant de procédures",
"integrations": "Statut d'automatisation et d'intégration",
"audit": "Historique daudit",
"users": "Utilisateurs"
"audit": "Historique daudit"
},
"switchRole": "Changer de rôle",
"switchRoleTitle": "Changer de rôle de démo",
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Employé de location" }
"roles": { "operations_manager": "Responsable des opérations", "rental_employee": "Employé de location" }
}
@@ -142,6 +142,10 @@
"UNAUTHORIZED_SERVICE": {
"title": "Dienstautorisatie mislukt",
"explanation": "Deze geautomatiseerde aanvraag kon niet geautoriseerd worden."
},
"UNKNOWN_WORKFLOW": {
"title": "Onbekende automatiseringsworkflow",
"explanation": "Deze workflow hoort niet bij de vier beheerde automatiseringsflows."
}
},
"http": {
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Verhuurmedewerker" }
"roles": { "operations_manager": "Operationeel beheerder", "rental_employee": "Verhuurmedewerker" }
}
+3 -3
View File
@@ -18,7 +18,7 @@ export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns", "errors"]);
const { formatDateTime, formatNumber } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>();
const { manifest } = useDemoManifest();
const { manifest, loading: manifestLoading } = useDemoManifest();
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null);
@@ -126,10 +126,10 @@ export function BookingDetail() {
this is the known demo scenario, so ReturnForm's suggestedOdometerKm is correct
from its very first render -- never updated asynchronously after mount, which
previously raced with anyone already typing into the field. */}
{!returnResult && booking.status === "active" && isReturnAnomalyScenario && canonicalOdometerKm === null && (
{!returnResult && booking.status === "active" && (manifestLoading || (isReturnAnomalyScenario && canonicalOdometerKm === null)) && (
<LoadingState label={t("returns:scenario.preparing")} />
)}
{!returnResult && booking.status === "active" && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
{!returnResult && booking.status === "active" && !manifestLoading && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
<ReturnForm
bookingRef={booking.public_ref}
onRegistered={handleRegistered}