116 lines
3.3 KiB
TypeScript
116 lines
3.3 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
|
import { api, onUnauthorized } from "../api/client";
|
|
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>;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthState | undefined>(undefined);
|
|
|
|
const STORAGE_KEY = "mobilityops.demo-user";
|
|
|
|
function readCachedUser(): CurrentUser | null {
|
|
const stored = sessionStorage.getItem(STORAGE_KEY);
|
|
return stored ? (JSON.parse(stored) as CurrentUser) : null;
|
|
}
|
|
|
|
function cacheUser(user: CurrentUser | null) {
|
|
if (user) {
|
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
|
} else {
|
|
sessionStorage.removeItem(STORAGE_KEY);
|
|
}
|
|
}
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
// The cached value only avoids a login-screen flash while the server round trip below is
|
|
// in flight; it is never trusted on its own. `loading` stays true until the server has
|
|
// 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<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);
|
|
cacheUser(confirmed);
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return;
|
|
setUser(null);
|
|
cacheUser(null);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
}));
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(
|
|
() =>
|
|
onUnauthorized(() => {
|
|
setUser(null);
|
|
cacheUser(null);
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const loginAs = useCallback(async (role: Role) => {
|
|
setLoading(true);
|
|
try {
|
|
const loggedIn = await api.post<CurrentUser>("/api/v1/demo/login", { role });
|
|
setUser(loggedIn);
|
|
cacheUser(loggedIn);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
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/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.
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<AuthContext.Provider value={{ user, loading, demoMode, loginAs, loginWithPassword, logout }}>{children}</AuthContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useAuth(): AuthState {
|
|
const ctx = useContext(AuthContext);
|
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
|
return ctx;
|
|
}
|