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;
|
||||
}
|
||||
61
go/jsruntime/auth/ProtectedRoute.ts
Normal file
61
go/jsruntime/auth/ProtectedRoute.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { createEffect, createSignal, createMemo, JSXElement } from "solid-js";
|
||||
import html from "solid-js/html";
|
||||
import { useAuth } from "./AuthContext.ts";
|
||||
import { getToken } from "./useAuthFetch.js";
|
||||
import { hasPermission } from "./permissions.ts";
|
||||
import { Loader } from "../uikit/General.tsx";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
permissions?: string[];
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
export function ProtectedRoute(props: ProtectedRouteProps) {
|
||||
const auth = useAuth();
|
||||
const [isRedirecting, setIsRedirecting] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
// Redirect to login ONLY when there is genuinely no session. A real
|
||||
// 401 clears the token (so getToken() is null here), but a transient
|
||||
// check failure preserves it -- in that case we keep the user here and
|
||||
// show a retry screen instead of logging them out. -mta 6/3/26
|
||||
if (!auth.loading() && !auth.isAuthenticated() && !getToken() && !isRedirecting()) {
|
||||
setIsRedirecting(true);
|
||||
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
window.location.replace(`/login?redirect=${returnUrl}`);
|
||||
}
|
||||
});
|
||||
|
||||
const hasRequiredPermissions = createMemo(() => {
|
||||
const permissions = props.permissions || [];
|
||||
if (permissions.length === 0) return true;
|
||||
const userPermissions = auth.identity()?.permissions || [];
|
||||
return permissions.every((perm) => hasPermission(userPermissions, perm));
|
||||
});
|
||||
|
||||
const state = createMemo(() => {
|
||||
if (isRedirecting() || auth.loading()) return 'loading';
|
||||
if (!auth.isAuthenticated()) {
|
||||
// Token still present => the check failed transiently => offer a
|
||||
// retry. No token => the redirect effect is taking over.
|
||||
return getToken() ? 'error' : 'loading';
|
||||
}
|
||||
if (!hasRequiredPermissions()) return 'unauthorized';
|
||||
return 'ready';
|
||||
});
|
||||
|
||||
return html`<div>
|
||||
${() => state() === 'loading' && html`<${Loader} />`}
|
||||
${() => state() === 'error' && html`<div class="flex flex-col items-center justify-center min-h-[50vh] text-center p-8">
|
||||
<h1 class="text-2xl font-semibold text-neutral-800 mb-2">Connection problem</h1>
|
||||
<p class="text-neutral-600 mb-4">We couldn't verify your session. You're still signed in -- please try again.</p>
|
||||
<button onclick=${() => auth.checkAuth()} class="text-sky-600 hover:text-sky-800 hover:underline">Retry</button>
|
||||
</div>`}
|
||||
${() => state() === 'unauthorized' && html`<div class="flex flex-col items-center justify-center min-h-[50vh] text-center p-8">
|
||||
<h1 class="text-2xl font-semibold text-neutral-800 mb-2">Access Denied</h1>
|
||||
<p class="text-neutral-600 mb-4">You don't have permission to access this page.</p>
|
||||
<a href="/app/dashboard" class="text-sky-600 hover:text-sky-800 hover:underline">Return to Dashboard</a>
|
||||
</div>`}
|
||||
${() => state() === 'ready' && props.children}
|
||||
</div>`;
|
||||
}
|
||||
51
go/jsruntime/auth/checkBundleVersion.js
Normal file
51
go/jsruntime/auth/checkBundleVersion.js
Normal file
@@ -0,0 +1,51 @@
|
||||
let bundleStaleNotified = false;
|
||||
|
||||
export function checkBundleVersion(response) {
|
||||
const serverVersion = response.headers.get("X-Bundle-Version");
|
||||
const clientVersion = window.__BUNDLE_VERSION__;
|
||||
if (!serverVersion || !clientVersion || serverVersion === clientVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bundleStaleNotified) {
|
||||
return;
|
||||
}
|
||||
bundleStaleNotified = true;
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.setAttribute("role", "status");
|
||||
bar.style.cssText = [
|
||||
"position:fixed",
|
||||
"top:0",
|
||||
"left:0",
|
||||
"right:0",
|
||||
"z-index:99999",
|
||||
"padding:12px 16px",
|
||||
"background:#141620",
|
||||
"color:#fff",
|
||||
"text-align:center",
|
||||
"font:14px system-ui,sans-serif",
|
||||
"box-shadow:0 2px 8px rgba(0,0,0,0.2)",
|
||||
].join(";");
|
||||
|
||||
const message = document.createElement("span");
|
||||
message.textContent = "A new version of National CD Rateline is available.";
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = "Refresh";
|
||||
button.style.cssText = "margin-left:12px;padding:4px 12px;cursor:pointer;border:0;border-radius:4px;background:#fff;color:#141620;font:inherit";
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
bar.append(message, button);
|
||||
document.body.prepend(bar);
|
||||
}
|
||||
|
||||
export async function apiFetch(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
checkBundleVersion(response);
|
||||
return response;
|
||||
}
|
||||
13
go/jsruntime/auth/permissions.ts
Normal file
13
go/jsruntime/auth/permissions.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Generic permission check shared by the framework. The permission CONSTANTS
|
||||
// (P_APP_*, P_ORG_*, ...) are application-specific and live app-side; only this
|
||||
// membership test is generic. "*" (P_ALL) grants everything.
|
||||
export const P_ALL = "*";
|
||||
|
||||
export function hasPermission(
|
||||
permissions: string[] | null | undefined,
|
||||
permission: string,
|
||||
): boolean {
|
||||
if (!permissions) return false;
|
||||
if (permissions.includes(P_ALL)) return true;
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
106
go/jsruntime/auth/useAuthFetch.js
Normal file
106
go/jsruntime/auth/useAuthFetch.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { apiFetch } from "./checkBundleVersion.js";
|
||||
|
||||
const TOKEN_KEY = "session_key";
|
||||
const TOKEN_EXPIRY_KEY = "session_key_expiry";
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token, expiresAt) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
if (expiresAt) {
|
||||
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt.toString());
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(TOKEN_EXPIRY_KEY);
|
||||
}
|
||||
|
||||
export function isTokenExpired() {
|
||||
const expiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
|
||||
if (!expiry) return true;
|
||||
return Date.now() >= parseInt(expiry, 10) * 1000;
|
||||
}
|
||||
|
||||
export function useAuthFetch(options = {}) {
|
||||
const { onUnauthorized } = options;
|
||||
|
||||
return async (url, fetchOptions = {}) => {
|
||||
const fullUrl = url.startsWith("http") ? url : url;
|
||||
const token = getToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...fetchOptions.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await apiFetch(fullUrl, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
if (onUnauthorized) {
|
||||
onUnauthorized();
|
||||
} else {
|
||||
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
window.location.href = `/login?redirect=${returnUrl}`;
|
||||
}
|
||||
throw new AuthError("Unauthorized", 401);
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
throw new AuthError("Forbidden: insufficient permissions", 403);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
export async function authFetch(url, options = {}) {
|
||||
const fullUrl = url.startsWith("http") ? url : url;
|
||||
const token = getToken();
|
||||
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
window.location.href = `/login?redirect=${returnUrl}`;
|
||||
throw new AuthError("Unauthorized", 401);
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
throw new AuthError("Forbidden: insufficient permissions", 403);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user