Add js web stuff to landing page + documentation
This commit is contained in:
308
go/jsruntime/auth/AuthContext.ts
Normal file
308
go/jsruntime/auth/AuthContext.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user