web/src/lib/auth.tsx
3.6 KB · sha256:e8172a5dac0531a442e76a4db154306ff9c88cc58380d2654e0fe0eb4bd2380f
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
export interface WhoAmI {
uuid: string;
name: string;
perms: {
userInfo: boolean;
userEdit: boolean;
groupInfo: boolean;
groupEdit: boolean;
trackInfo: boolean;
trackEdit: boolean;
};
}
interface AuthCtx {
token: string | null;
whoami: WhoAmI | null;
loading: boolean;
error: string | null;
signIn: (token: string) => Promise<void>;
signOut: () => void;
}
const STORAGE_KEY = "ward.token";
const Ctx = createContext<AuthCtx | null>(null);
let activeToken: string | null = null;
/** Read the current bearer token from anywhere (e.g. the api client). */
export function getActiveToken(): string | null {
return activeToken;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
});
const [whoami, setWhoami] = useState<WhoAmI | null>(null);
const [loading, setLoading] = useState<boolean>(!!token);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
activeToken = token;
if (!token) {
setWhoami(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
fetch("/api/auth/whoami", { headers: { Authorization: `Bearer ${token}` } })
.then(async (r) => {
const text = await r.text();
const data = text ? JSON.parse(text) : null;
if (cancelled) return;
if (!r.ok) {
setWhoami(null);
setError(
r.status === 401
? "Token is invalid or expired. Run `/ward web token` in-game."
: r.status === 403
? "Your account doesn't have ward.web. Ask an admin to grant it."
: (data && data.error) || `auth failed (${r.status})`,
);
// Drop the bad token so we don't keep retrying.
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
activeToken = null;
setToken(null);
} else {
setWhoami(data as WhoAmI);
}
})
.catch((e) => {
if (cancelled) return;
setWhoami(null);
setError((e as Error).message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [token]);
const signIn = useCallback(async (next: string) => {
const trimmed = next.trim();
try {
localStorage.setItem(STORAGE_KEY, trimmed);
} catch {}
activeToken = trimmed;
setToken(trimmed);
}, []);
const signOut = useCallback(() => {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
activeToken = null;
setToken(null);
setWhoami(null);
setError(null);
}, []);
const value = useMemo<AuthCtx>(
() => ({ token, whoami, loading, error, signIn, signOut }),
[token, whoami, loading, error, signIn, signOut],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useAuth(): AuthCtx {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}
/** Called by the api client to react to a 401 (token revoked / expired). */
export function handleUnauthorized() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
activeToken = null;
// Force a reload so the AuthProvider re-evaluates and shows the login.
if (typeof window !== "undefined") window.location.reload();
}