M8: add operational authentication mode

This commit is contained in:
NuklearRabbit
2026-08-10 02:06:50 +02:00
parent 4cdf667dc1
commit 0bfcf71ff7
20 changed files with 345 additions and 24 deletions
+7
View File
@@ -6,6 +6,13 @@ export interface CurrentUser {
role: Role;
}
export interface SystemStatus {
service: string;
environment: string;
demo_mode: boolean;
knowledge_provider: string;
}
export interface Vehicle {
public_ref: string;
make: string;
+5 -5
View File
@@ -75,7 +75,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
export function Layout() {
const { t } = useTranslation(["navigation", "common", "auth"]);
const { user, logout } = useAuth();
const { user, logout, demoMode } = useAuth();
const { manifest } = useDemoManifest();
const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide();
const navigate = useNavigate();
@@ -231,7 +231,7 @@ export function Layout() {
<span className="environment-dot" />
<div><strong>{t("sidebarEnvironment")}</strong><span>{t("sidebarEnvironmentDetail")}</span></div>
</div>
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
{demoMode && user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset">
<ApiErrorNotice error={resetError} />
{!resetConfirming ? (
@@ -313,8 +313,8 @@ export function Layout() {
)}
</div>
<div className="topbar-meta">
<DemoGuideTrigger />
<DemoBadge />
{demoMode && <DemoGuideTrigger />}
{demoMode && <DemoBadge />}
{user && (
<details className="operator-menu">
<summary className="operator">
@@ -350,7 +350,7 @@ export function Layout() {
</button>
</nav>
<DemoGuide />
{demoMode && <DemoGuide />}
</div>
);
}
+23 -6
View File
@@ -1,11 +1,13 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { api, onUnauthorized } from "../api/client";
import type { CurrentUser, Role } from "../api/types";
import type { CurrentUser, Role, SystemStatus } from "../api/types";
interface AuthState {
user: CurrentUser | null;
loading: boolean;
demoMode: boolean;
loginAs: (role: Role) => Promise<void>;
loginWithPassword: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
@@ -32,11 +34,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// confirmed (or rejected) the session cookie.
const [user, setUser] = useState<CurrentUser | null>(() => readCachedUser());
const [loading, setLoading] = useState(true);
const [demoMode, setDemoMode] = useState(true);
useEffect(() => {
let cancelled = false;
api
.get<CurrentUser>("/api/v1/demo/session")
api.get<SystemStatus>("/api/v1/system/status")
.then((system) => setDemoMode(system.demo_mode))
.catch(() => setDemoMode(true))
.finally(() => api
.get<CurrentUser>("/api/v1/auth/session")
.then((confirmed) => {
if (cancelled) return;
setUser(confirmed);
@@ -49,7 +55,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
})
.finally(() => {
if (!cancelled) setLoading(false);
});
}));
return () => {
cancelled = true;
};
@@ -75,11 +81,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, []);
const loginWithPassword = useCallback(async (email: string, password: string) => {
setLoading(true);
try {
const loggedIn = await api.post<CurrentUser>("/api/v1/auth/login", { email, password });
setUser(loggedIn);
cacheUser(loggedIn);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(async () => {
setUser(null);
cacheUser(null);
try {
await api.post("/api/v1/demo/logout");
await api.post("/api/v1/auth/logout");
} catch {
// Best effort: the cookie is cleared server-side when it works, and the client has
// already dropped its own state either way.
@@ -87,7 +104,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
return (
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>{children}</AuthContext.Provider>
<AuthContext.Provider value={{ user, loading, demoMode, loginAs, loginWithPassword, logout }}>{children}</AuthContext.Provider>
);
}
+5 -1
View File
@@ -19,5 +19,9 @@
"roleOperationsManager": "Operations manager",
"roleRentalEmployee": "Rental employee",
"switchRole": "Switch role",
"logout": "Log out"
"logout": "Log out",
"emailLabel": "Email address",
"passwordLabel": "Password",
"signIn": "Sign in",
"passwordLoginFailed": "Sign-in failed. Check your credentials."
}
+5 -1
View File
@@ -19,5 +19,9 @@
"roleOperationsManager": "Responsable des opérations",
"roleRentalEmployee": "Collaborateur de location",
"switchRole": "Changer de rôle",
"logout": "Déconnexion"
"logout": "Déconnexion",
"emailLabel": "Adresse e-mail",
"passwordLabel": "Mot de passe",
"signIn": "Se connecter",
"passwordLoginFailed": "Connexion impossible. Vérifiez vos identifiants."
}
+5 -1
View File
@@ -19,5 +19,9 @@
"roleOperationsManager": "Operationsmanager",
"roleRentalEmployee": "Verhuurmedewerker",
"switchRole": "Wissel van rol",
"logout": "Uitloggen"
"logout": "Uitloggen",
"emailLabel": "E-mailadres",
"passwordLabel": "Wachtwoord",
"signIn": "Aanmelden",
"passwordLoginFailed": "Aanmelden mislukt. Controleer je gegevens."
}
+3 -3
View File
@@ -46,7 +46,7 @@ export function Dashboard() {
const { t } = useTranslation(["dashboard", "common", "integrations", "quality"]);
const { formatTime, formatShortDate, formatNumber } = useLocaleFormat();
const greetingPeriod = useGreetingPeriod();
const { user } = useAuth();
const { user, demoMode } = useAuth();
const { manifest } = useDemoManifest();
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
const [searchParams, setSearchParams] = useSearchParams();
@@ -114,7 +114,7 @@ export function Dashboard() {
<div className="page dashboard-page">
<PageHeader eyebrow={t("eyebrow")} title={greetingTitle} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
<section className="demo-start-panel" aria-label={t("demoStart.title")}>
{demoMode && <section className="demo-start-panel" aria-label={t("demoStart.title")}>
<div>
<Icon name="spark" />
<div>
@@ -132,7 +132,7 @@ export function Dashboard() {
)}
<Link className="button button-primary" to="/scenarios">{t("demoStart.viewScenarios")} <Icon name="chevron" /></Link>
</div>
</section>
</section>}
<section className="readiness-band" aria-labelledby="readiness-heading">
<div className="readiness-label">
+21 -4
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext";
@@ -10,10 +10,12 @@ import { PRODUCT_NAME } from "../product";
export function Login() {
const { t } = useTranslation(["auth", "common"]);
const { loginAs, loading } = useAuth();
const { loginAs, loginWithPassword, loading, demoMode } = useAuth();
const { manifest } = useDemoManifest();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
async function handleLogin(role: Role, guided = false) {
setError(null);
@@ -25,6 +27,17 @@ export function Login() {
}
}
async function handlePasswordLogin(event: FormEvent) {
event.preventDefault();
setError(null);
try {
await loginWithPassword(email, password);
navigate("/dashboard");
} catch {
setError(t("passwordLoginFailed"));
}
}
const orgName = manifest?.organization_name ?? t("common:orgName");
const description = t("defaultDescription", { productName: PRODUCT_NAME });
@@ -61,7 +74,7 @@ export function Login() {
<p className="login-intro">{t("accessIntro")}</p>
{error && <p className="error" role="alert">{error}</p>}
<button
{demoMode ? <><button
type="button"
className="button button-primary login-guide-cta"
disabled={loading}
@@ -82,7 +95,11 @@ export function Login() {
<span><strong>{t("exploreAsRentalEmployee")}</strong><small>{t("exploreAsRentalEmployeeDetail")}</small></span>
<Icon name="chevron" />
</button>
</div>
</div></> : <form className="login-options" onSubmit={handlePasswordLogin}>
<label>{t("emailLabel")}<input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} required /></label>
<label>{t("passwordLabel")}<input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} minLength={8} required /></label>
<button type="submit" className="button button-primary" disabled={loading}>{t("signIn")}</button>
</form>}
<div className="access-note"><Icon name="shield" /><p><strong>{t("safeByDesignTitle")}</strong><span>{t("safeByDesignDetail")}</span></p></div>
</div>
</section>