M8: add operational authentication mode
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user