M15: synchronize contracts and acceptance
This commit is contained in:
@@ -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"]);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 d’automatisation 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 d’audit",
|
||||
"users": "Utilisateurs"
|
||||
"audit": "Historique d’audit"
|
||||
},
|
||||
"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" }
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user