Files
kjol/go/jsruntime/auth/AuthContext.ts

309 lines
9.4 KiB
TypeScript

import { createContext, useContext, createSignal, createMemo, onMount, JSXElement } from "solid-js";
import html from "solid-js/html";
import { getToken, setToken, clearToken, isTokenExpired } from "./useAuthFetch.js";
import { apiFetch } from "./checkBundleVersion.js";
export interface Identity {
id: string;
username: string;
email: string;
first_name: string;
last_name: string;
permissions: string[];
org_id: string | null;
org_name: string | null;
org_cert_num: string | null;
}
export interface MemberOrg {
id: string;
name: string;
certnum: string | null;
}
export interface SuperAdminState {
enabled: boolean;
orgName: string | null;
firstMemberOrgId: string | null;
}
export interface LoginResult {
success: boolean;
error?: string;
}
export interface SwitchOrgResult {
success: boolean;
org_id?: string;
org_name?: string;
org_cert_num?: string | null;
error?: string;
}
export interface AuthContextValue {
identity: () => Identity | null;
loading: () => boolean;
error: () => string | null;
isAuthenticated: () => boolean;
superAdminMode: () => SuperAdminState;
memberOrganizations: () => MemberOrg[];
login: (username: string, password: string, rememberMe?: boolean) => Promise<LoginResult>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
switchOrganization: (orgId: string) => Promise<SwitchOrgResult>;
disableSuperAdminMode: () => Promise<void>;
}
interface AuthProviderProps {
children?: JSXElement;
}
const AuthContext = createContext<AuthContextValue>();
export function AuthProvider(props: AuthProviderProps) {
const [identity, setIdentity] = createSignal<Identity | null>(null);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
const [memberOrganizations, setMemberOrganizations] = createSignal<MemberOrg[]>([]);
const [isAdmin, setIsAdmin] = createSignal(false);
const isAuthenticated = createMemo(() => !!identity());
const checkAuth = async (): Promise<void> => {
try {
setLoading(true);
setError(null);
const token = getToken();
if (!token) {
setIdentity(null);
setLoading(false);
return;
}
if (isTokenExpired()) {
clearToken();
setIdentity(null);
setLoading(false);
return;
}
// Retry transient network failures a few times so a brief blip
// self-heals instead of dropping the user to an error screen. A
// 401 is a real response and is handled below, not retried. -mta 6/3/26
let response;
for (let attempt = 0; ; attempt++) {
try {
response = await apiFetch("/api/auth/me", {
headers: {
"Authorization": `Bearer ${token}`,
},
});
break;
} catch (err) {
if (attempt >= 2) throw err;
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
}
}
// Only a definitive 401 means the session is actually invalid. A
// network error, an aborted request (e.g. the user refreshed
// mid-flight), or a 5xx must NOT clear the token -- otherwise a
// quick refresh aborts this request and silently logs the user
// out. -mta 6/3/26
if (response.status === 401) {
clearToken();
setIdentity(null);
setMemberOrganizations([]);
setIsAdmin(false);
return;
}
if (!response.ok) {
throw new Error(`Auth check failed: ${response.status}`);
}
const data = await response.json();
setIdentity(data);
const orgsResponse = await apiFetch("/api/auth/my-organizations", {
headers: {
"Authorization": `Bearer ${token}`,
},
});
if (orgsResponse.ok) {
const orgsData = await orgsResponse.json();
setMemberOrganizations(orgsData.member_organizations || []);
setIsAdmin(orgsData.is_admin || false);
}
} catch (err) {
// Transient failure (network/abort/5xx). Preserve the token so the
// next load can retry instead of forcing a logout. -mta 6/3/26
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
};
onMount(() => {
checkAuth();
});
const login = async (username: string, password: string, rememberMe = false): Promise<LoginResult> => {
try {
setLoading(true);
setError(null);
const response = await apiFetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
password,
remember_me: rememberMe,
}),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || "Login failed");
}
setToken(data.token, data.expires_at);
setIdentity(data.user);
const orgsResponse = await apiFetch("/api/auth/my-organizations", {
headers: {
"Authorization": `Bearer ${data.token}`,
},
});
if (orgsResponse.ok) {
const orgsData = await orgsResponse.json();
setMemberOrganizations(orgsData.member_organizations || []);
setIsAdmin(orgsData.is_admin || false);
}
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
return { success: false, error: message };
} finally {
setLoading(false);
}
};
const logout = async (): Promise<void> => {
const token = getToken();
if (token) {
try {
await apiFetch("/api/auth/logout", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
},
});
} catch {
// Continue with logout even if API call fails
}
}
clearToken();
setIdentity(null);
window.location.href = "/login";
};
const switchOrganization = async (orgId: string): Promise<SwitchOrgResult> => {
try {
setError(null);
const token = getToken();
if (!token) {
throw new Error("Not authenticated");
}
const response = await apiFetch("/api/auth/switch-org", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ org_id: orgId }),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || "Failed to switch organization");
}
setIdentity((prev) => prev ? {
...prev,
org_id: data.org_id,
org_name: data.org_name,
org_cert_num: data.org_cert_num,
} : null);
return {
success: true,
org_id: data.org_id,
org_name: data.org_name,
org_cert_num: data.org_cert_num,
};
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
return { success: false, error: message };
}
};
const disableSuperAdminMode = async (): Promise<void> => {
const orgs = memberOrganizations();
if (orgs.length > 0) {
await switchOrganization(orgs[0].id);
}
};
const superAdminMode = (): SuperAdminState => {
const id = identity();
const orgs = memberOrganizations();
return {
enabled: isAdmin() && id?.org_id != null && !orgs.some((org) => org.id === id.org_id),
orgName: id?.org_name || null,
firstMemberOrgId: orgs.length > 0 ? orgs[0].id : null,
};
};
const value: AuthContextValue = {
identity,
loading,
error,
isAuthenticated,
superAdminMode,
memberOrganizations,
login,
logout,
checkAuth,
switchOrganization,
disableSuperAdminMode,
};
// CRITICAL: Use lazy children evaluation
return html`<${AuthContext.Provider} value=${value}>${() => props.children}<//>`;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}