309 lines
9.4 KiB
TypeScript
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;
|
|
}
|