restructure project, add claudemd
This commit is contained in:
101
CLAUDE.md
Normal file
101
CLAUDE.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# kjol
|
||||
|
||||
`kjol` ("keel" in Norwegian) is a **shared, multilingual base layer** factored out of the
|
||||
user's applications so they stay in sync. It is consumed as a **git submodule** inside each
|
||||
app (at `<app>/kjol`). Current consumers: `cdrateline.com_2.0` and `Hotlap` — near-identical
|
||||
forks it was extracted from. Scope will grow to more projects and languages.
|
||||
|
||||
## Golden rules
|
||||
|
||||
1. **The framework NEVER imports application code.** Wherever kjol needs app-specific
|
||||
behavior, the app injects it (interfaces, registration functions, config structs,
|
||||
callbacks). See **Coupling inversions** below. If you find yourself wanting to
|
||||
`import "<app>/internal/..."` from kjol, invert it instead.
|
||||
2. **Never run git operations here on the user's behalf.** The user creates the submodule and
|
||||
makes all commits. You may edit files, build, and test.
|
||||
3. kjol is edited **in place** via the submodule + `go.work` — there is no publish / `go get`
|
||||
step. Editing a kjol file takes effect in the consuming app immediately.
|
||||
4. When a file exists in both apps and has drifted, reconcile by **merging best-of-both**.
|
||||
|
||||
## Organization — by language
|
||||
|
||||
Each top-level directory is one language / build root:
|
||||
|
||||
```
|
||||
kjol/
|
||||
go/ all Go. Module `kjol` (go.mod lives in go/). Imports are `kjol/<pkg>`.
|
||||
web/ all JS/TS (browser + SSR). No build system of its own; built by go/bundler.
|
||||
# future: cpp/ kotlin/ swift/
|
||||
```
|
||||
|
||||
Language-first, **not** feature-first. Consequence: the **bundler is Go** and lives in
|
||||
`go/bundler` even though it builds `web/`. Don't "fix" this by splitting it.
|
||||
|
||||
### go/ — module `kjol`
|
||||
|
||||
Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil
|
||||
l4g security snailmail validation bundler`, plus `cmd/{bundle,migrate,loc,passgen,typecheck}`.
|
||||
|
||||
Build / test (run from repo root):
|
||||
```
|
||||
go -C go build ./...
|
||||
go -C go vet ./...
|
||||
go -C go test ./...
|
||||
```
|
||||
|
||||
### web/
|
||||
|
||||
- `kit/` — Solid.js `.tsx` component kit. Apps import components as `@ui/*`.
|
||||
- `runtime/` — vendored Solid runtime + `vendor.json` (base entrypoints). The app merges its
|
||||
own `vendor.json` (chart.js, pdf-lib, ...) on top; **kjol's solid-js must resolve first** so
|
||||
there is a single reactive instance.
|
||||
- `icons/` — FontAwesome SVG source kit (the bundler scans usage and generates a per-app
|
||||
registry; the generated file is app-owned, not committed here).
|
||||
- `styles/` — `theme.css` (`@theme` scaffold + `:root` fa vars). Brand color/font tokens stay
|
||||
app-side; the app's `style.css` `@import`s this.
|
||||
- `auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts` — generic TS
|
||||
scaffolding. Apps import as `@kjol/*`. (Concrete permission constants stay app-side.)
|
||||
|
||||
**Frontend import aliases** (resolved by the bundler and mirrored in each app's tsconfig
|
||||
`paths`): `@ui/*` → `web/kit`, `@kjol/*` → `web/`, `@appgen/*` → the app's generated dir
|
||||
(e.g. the FA `faIcons` registry — app-owned, gitignored, regenerated each build). The kit's
|
||||
own imports of sibling components stay relative (`./Buttons.tsx`).
|
||||
|
||||
## Consumption (per app)
|
||||
|
||||
- `go.work` at the app root:
|
||||
```
|
||||
use (
|
||||
.
|
||||
./kjol/go
|
||||
)
|
||||
```
|
||||
then vendor with `go work vendor` (not `go mod vendor`).
|
||||
- Startup wiring the app performs (this is how the inversions get their app-side halves):
|
||||
`dbutil.RegisterAll(models.Tables)`, `l4g.SetDatabaseWriter(...)`,
|
||||
`dbutil.Init(dbutil.ConnConfig{...})`, `snailmail.Configure(snailmail.Settings{...})`, a thin
|
||||
`config.Load` wrapper over `config.Load[T]`, and an app-side `internal/httpauth` for the
|
||||
session/authn middleware.
|
||||
|
||||
## Coupling inversions (how the framework stays app-agnostic)
|
||||
|
||||
| Package | Inversion |
|
||||
|---|---|
|
||||
| `dbutil` | table names via `Register`/`RegisterAll` (not the app's `models.Tables`) |
|
||||
| `l4g` | owns the `Entry` type; DB persistence via `SetDatabaseWriter(func(Entry) error)` |
|
||||
| `config` | generic `Load[T](file, *T) error`; each app defines its own config struct |
|
||||
| `dbutil.ConnConfig`, `snailmail.Settings` | DB / mail credentials injected, never read from app config |
|
||||
| `appenv` | compile-time environment via build tags (`-tags staging` / `-tags production`); the bundler reads `appenv.Environment` for the JS `__ENV_TYPE__` define |
|
||||
| `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected |
|
||||
|
||||
## Stays app-side (never moves into kjol)
|
||||
|
||||
Domain models/repository/handlers-api, migrations, pages/routes/layouts, brand UI
|
||||
(`TopBar`/`AppSidebar`/`TransitionOverlay`), concrete permission constants, the app config
|
||||
struct, `embed.go` + `wwwroot/`, and generated artifacts (`faIcons` registry, `routes.gen.ts`,
|
||||
`public_pages.gen.go`).
|
||||
|
||||
## Provenance
|
||||
|
||||
Big-bang extraction from cdrateline + Hotlap. The detailed migration plan lives on the
|
||||
author's machine at `~/.claude/plans/foamy-humming-tulip.md`.
|
||||
308
web/auth/AuthContext.ts
Normal file
308
web/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
web/auth/ProtectedRoute.ts
Normal file
61
web/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.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>`;
|
||||
}
|
||||
51
web/auth/checkBundleVersion.js
Normal file
51
web/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;
|
||||
}
|
||||
106
web/auth/useAuthFetch.js
Normal file
106
web/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;
|
||||
}
|
||||
}
|
||||
56
web/basic.ts
Normal file
56
web/basic.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export function capitalizeFirstLetter(input: string): string {
|
||||
return input.charAt(0).toUpperCase() + input.slice(1);
|
||||
}
|
||||
|
||||
export function toSnakeCase(input: string): string {
|
||||
return input
|
||||
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
||||
.replace(/[\s\-]+/g, "_")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function snakeCaseToTitleCase(input: string): string {
|
||||
return input
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// Strips non-numeric characters from a string including spaces.
|
||||
export function sanitizeNum(input: string): string {
|
||||
return input.replaceAll(/[^\d]+/g, "");
|
||||
}
|
||||
|
||||
// Strips non-alphanumeric, non-space characters from a string.
|
||||
export function sanitizeAlphaNum(input: string): string {
|
||||
return input.replaceAll(/[^a-zA-Z0-9 ]+/g, "");
|
||||
}
|
||||
|
||||
// Strips non-alphanumeric characters from a string including spaces.
|
||||
export function sanitizeAlphaNumStrict(input: string): string {
|
||||
return input.replaceAll(/[^a-zA-Z0-9]+/g, "");
|
||||
}
|
||||
|
||||
export function numberToStringWithCommas(n: number):string {
|
||||
var str = n.toString();
|
||||
|
||||
var negative = false;
|
||||
if (str.startsWith("-")) {
|
||||
negative = true;
|
||||
str = str.slice(1);
|
||||
}
|
||||
|
||||
var result = "";
|
||||
for (let i=0; i < str.length; i++) {
|
||||
if (i > 0 && (str.length - i)%3 === 0) {
|
||||
result += ",";
|
||||
}
|
||||
result += str[i]
|
||||
}
|
||||
|
||||
if (negative) {
|
||||
result = "-" + result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
16
web/env.ts
Normal file
16
web/env.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Compile-time deployment environment: "development" | "staging" | "production".
|
||||
//
|
||||
// `__ENV_TYPE__` is substituted at bundle time by esbuild's `define`, fed the Go
|
||||
// compile-time constant `internal/constants.AppEnvironment` (see the
|
||||
// `esbuildDefine` helper in internal/bundler). The production bundles and the dev
|
||||
// HMR per-module transform both define it. The build-time SSR render of the
|
||||
// public pages does NOT, so the `typeof` guard yields "" there — the env badge
|
||||
// renders only after the client takeover, matching the prior runtime behavior.
|
||||
declare const __ENV_TYPE__: string;
|
||||
|
||||
export const ENV_TYPE: string = typeof __ENV_TYPE__ !== "undefined" ? __ENV_TYPE__ : "";
|
||||
|
||||
// True in every environment except production (and the badge-less SSR render).
|
||||
export function isNonProdEnv(): boolean {
|
||||
return ENV_TYPE !== "" && ENV_TYPE !== "production";
|
||||
}
|
||||
98
web/finance.ts
Normal file
98
web/finance.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Format a number as USD currency.
|
||||
// If `showChangeIfExists` is false, the cents value is only shown if there are leftover cents
|
||||
// after converting to dollars (i.e. amount=1000 would be shown as $10, but amount=1001 would be
|
||||
// shown as $10.01). If true, cents are always displayed.
|
||||
export function formatCurrency(amount: number, showChangeIfExists: boolean = true): string {
|
||||
const dollars = amount / 100;
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
trailingZeroDisplay: (showChangeIfExists ? "auto" : "stripIfInteger"),
|
||||
useGrouping: false,
|
||||
}).format(dollars);
|
||||
}
|
||||
|
||||
// // Same as formatMoney() but with commas.
|
||||
export function formatCurrencyWithCommas(amount: number, showChangeIfExists: boolean = true): string {
|
||||
const dollars = amount / 100;
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
trailingZeroDisplay: (showChangeIfExists ? "auto" : "stripIfInteger"),
|
||||
}).format(dollars);
|
||||
}
|
||||
|
||||
// takes a number such as 123456, and outputs (1234, 56) as strings
|
||||
export function splitCurrency(amount: number): [string, string] {
|
||||
// Calculate dollars as a string (without cents)
|
||||
const dollars = Math.floor(amount / 100).toString();
|
||||
// Calculate cents as a string with leading zero if necessary;
|
||||
const cents = (amount % 100).toString().padStart(2, "0");
|
||||
return [dollars, cents];
|
||||
}
|
||||
|
||||
export function moneyToNumber(input: string): number {
|
||||
if (!input.includes(".")) {
|
||||
input += ".00";
|
||||
} else if (input.split(".").length - 1 === 1) {
|
||||
const digits = input.split(".");
|
||||
if (digits[1].length === 1) {
|
||||
input += "0";
|
||||
}
|
||||
}
|
||||
let processed = input
|
||||
.replaceAll(".", "")
|
||||
.replaceAll(",", "")
|
||||
.replaceAll(" ", "");
|
||||
return parseInt(processed);
|
||||
}
|
||||
|
||||
export function formatRate(rate: number): string {
|
||||
return (rate / 1000).toFixed(3);
|
||||
}
|
||||
|
||||
export function processDiscount(discount: string): number {
|
||||
const discountFloat = parseFloat(discount);
|
||||
|
||||
if (discountFloat < 0) {
|
||||
return 0;
|
||||
} else if (discountFloat > 100) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return discountFloat;
|
||||
}
|
||||
|
||||
// Takes days as an input and outputs the string representation of the number
|
||||
// of days, months, or years in the term based on which unit is the best fit
|
||||
// for the amount of days along with the corresponding unit string.
|
||||
export function daysToRateTerm(days: number): [string, string] {
|
||||
const years = Math.floor(days / 365);
|
||||
const months = Math.floor((days - years*365) / 30);
|
||||
const dayRemainder = days - years*365 - months*30;
|
||||
|
||||
if (days == 0) { // Return empty string for value
|
||||
return ["", "days"];
|
||||
} else if (days <= 270 || dayRemainder > 0) {
|
||||
return [days.toString(), "days"]
|
||||
} else if (months > 0) {
|
||||
return [(months + years*12).toString(), "months"];
|
||||
} else {
|
||||
return [years.toString(), "years"];
|
||||
}
|
||||
}
|
||||
|
||||
export function rateTermToDays(value: string, unit: string): number {
|
||||
const valueNum = parseInt(value);
|
||||
|
||||
switch (unit) {
|
||||
case "months":
|
||||
const years = Math.floor(valueNum / 12);
|
||||
const months = valueNum % 12;
|
||||
return years*365 + months*30;
|
||||
case "years":
|
||||
return valueNum * 365;
|
||||
default: // case "days":
|
||||
return valueNum;
|
||||
}
|
||||
}
|
||||
20
web/hooks/useDocumentTitle.js
Normal file
20
web/hooks/useDocumentTitle.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createEffect, onCleanup } from "solid-js";
|
||||
|
||||
const SITE_NAME = "National CD Rateline";
|
||||
|
||||
export function createDocumentTitle(title) {
|
||||
createEffect(() => {
|
||||
const previousTitle = document.title;
|
||||
const t = title();
|
||||
document.title = t ? `${t} | ${SITE_NAME}` : SITE_NAME;
|
||||
|
||||
onCleanup(() => {
|
||||
document.title = previousTitle;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function PageWithTitle(props) {
|
||||
createDocumentTitle(() => props.route.title || props.route.label);
|
||||
return props.children;
|
||||
}
|
||||
127
web/kit/Accordion.tsx
Normal file
127
web/kit/Accordion.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { createSignal, createEffect, For, Show, JSXElement } from "solid-js";
|
||||
import { Icon } from "./Icons.tsx";
|
||||
|
||||
// Baseline Tailwind for the scoped .ui-accordion stack. The ui-* class
|
||||
// names are kept so page-specific CSS overrides (e.g. sale.css,
|
||||
// licensing.css) can continue to layer on top.
|
||||
|
||||
const ROOT = "ui-accordion border border-neutral-200 rounded-default overflow-hidden";
|
||||
const ITEM = "ui-accordion-item border-b border-neutral-200 last:border-b-0";
|
||||
const TRIGGER = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-neutral-900 bg-neutral-50 cursor-pointer border-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus:outline-hidden disabled:text-neutral-400 disabled:cursor-not-allowed disabled:bg-neutral-50";
|
||||
const TITLE = "ui-accordion-title flex-1";
|
||||
const CONTENT = "ui-accordion-content px-4 pb-4 text-neutral-700";
|
||||
|
||||
function iconCls(open: boolean): string {
|
||||
return "ui-accordion-icon text-neutral-500 leading-none transition-transform duration-200" +
|
||||
(open ? " rotate-180" : "");
|
||||
}
|
||||
|
||||
interface AccordionItemProps {
|
||||
startOpen?: boolean;
|
||||
isOpen?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
export function AccordionItem(props: AccordionItemProps) {
|
||||
const getStartOpen = () => typeof props.startOpen === "function" ? (props.startOpen as () => boolean)() : !!props.startOpen;
|
||||
const [isOpen, setIsOpen] = createSignal(getStartOpen());
|
||||
|
||||
createEffect(() => {
|
||||
if (props.isOpen === undefined) return;
|
||||
const v = typeof props.isOpen === "function" ? (props.isOpen as () => boolean)() : !!props.isOpen;
|
||||
setIsOpen(v);
|
||||
});
|
||||
|
||||
const isDisabled = () => typeof props.disabled === "function" ? (props.disabled as () => boolean)() : !!props.disabled;
|
||||
const title = () => typeof props.title === "function" ? (props.title as () => string)() : props.title;
|
||||
|
||||
const toggle = () => {
|
||||
if (isDisabled()) return;
|
||||
setIsOpen(!isOpen());
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={ITEM}>
|
||||
<button type="button" class={TRIGGER} disabled={isDisabled()} onclick={toggle} aria-expanded={isOpen()}>
|
||||
<span class={TITLE}>{title()}</span>
|
||||
<Show when={!isDisabled()}>
|
||||
<span class={iconCls(isOpen())}>
|
||||
<Icon icon={isOpen() ? "chevron-up" : "chevron-down"} size={18}/>
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={isOpen() && !isDisabled()}>
|
||||
<div class={CONTENT}>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AccordionItemData {
|
||||
title: string;
|
||||
content: JSXElement;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface AccordionProps {
|
||||
items?: AccordionItemData[];
|
||||
}
|
||||
|
||||
export function Accordion(props: AccordionProps) {
|
||||
const items = () => props.items || [];
|
||||
return (
|
||||
<div class={ROOT}>
|
||||
<For each={items()}>{(item) => (
|
||||
<AccordionItem title={(() => {
|
||||
const t = item.title;
|
||||
return typeof t === "function" ? (t as () => string)() : t;
|
||||
})()} disabled={(() => {
|
||||
const d = item.disabled;
|
||||
return typeof d === "function" ? (d as () => boolean)() : !!d;
|
||||
})()}>
|
||||
{item.content}
|
||||
</AccordionItem>
|
||||
)}</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SingleAccordionProps {
|
||||
items?: AccordionItemData[];
|
||||
startOpen?: number;
|
||||
}
|
||||
|
||||
export function SingleAccordion(props: SingleAccordionProps) {
|
||||
const items = () => props.items || [];
|
||||
const [openIndex, setOpenIndex] = createSignal(props.startOpen ?? 0);
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
setOpenIndex(openIndex() === index ? -1 : index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={ROOT}>
|
||||
<For each={items()}>{(item, index) => (
|
||||
<div class={ITEM}>
|
||||
<button type="button" class={TRIGGER} disabled={!!item.disabled} onclick={() => toggleItem(index())} aria-expanded={openIndex() === index()}>
|
||||
<span class={TITLE}>{item.title}</span>
|
||||
<Show when={!item.disabled}>
|
||||
<span class={iconCls(openIndex() === index())}>
|
||||
<Icon icon={openIndex() === index() ? "chevron-up" : "chevron-down"} size={18}/>
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={openIndex() === index() && !item.disabled}>
|
||||
<div class={CONTENT}>
|
||||
{item.content}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
web/kit/Alerts.tsx
Normal file
44
web/kit/Alerts.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { JSXElement, Show } from "solid-js";
|
||||
|
||||
type AlertColor = "white" | "gray" | "blue" | "green" | "red" | "yellow";
|
||||
|
||||
interface AlertProps {
|
||||
header?: string;
|
||||
class?: string;
|
||||
children: JSXElement;
|
||||
}
|
||||
|
||||
const BASE = "p-4 rounded-default shadow-xs border";
|
||||
|
||||
const COLORS: Record<AlertColor, string> = {
|
||||
white: "bg-white border-neutral-100",
|
||||
gray: "bg-neutral-50 border-neutral-200",
|
||||
blue: "bg-sky-50 border-sky-200",
|
||||
green: "bg-green-50 border-green-200",
|
||||
red: "bg-red-50 border-red-200",
|
||||
yellow: "bg-yellow-50 border-yellow-200",
|
||||
};
|
||||
|
||||
function alertClass(color: AlertColor, extra?: string): string {
|
||||
return BASE + " " + COLORS[color] + (extra ? " " + extra : "");
|
||||
}
|
||||
|
||||
function makeAlert(color: AlertColor) {
|
||||
return function Alert(props: AlertProps) {
|
||||
return (
|
||||
<div class={alertClass(color, props.class)}>
|
||||
<Show when={props.header}>
|
||||
<h3 class="font-semibold mb-2">{props.header}</h3>
|
||||
</Show>
|
||||
<p class="text-sm">{props.children}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const AlertWhite = makeAlert("white");
|
||||
export const AlertGray = makeAlert("gray");
|
||||
export const AlertBlue = makeAlert("blue");
|
||||
export const AlertGreen = makeAlert("green");
|
||||
export const AlertRed = makeAlert("red");
|
||||
export const AlertYellow = makeAlert("yellow");
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user