restructure project, add claudemd

This commit is contained in:
2026-07-08 16:36:17 -04:00
parent a7964f9410
commit 2a5fbffaa2
315 changed files with 81075 additions and 0 deletions

308
web/auth/AuthContext.ts Normal file
View 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;
}

View 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.js";
import { Loader } from "../ui/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>`;
}

View 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;
}

106
web/auth/useAuthFetch.js Normal file
View 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;
}
}