Files
warmbly/web/src/lib/api/client/Request.ts
T
Matthew Meszaros 734cb5fe08 feat: make self-hosted onboarding survivable by fixing invite_only, which could not onboard anyone (the accept route is JWT-only, so redeeming the invitation that would create your account required already having one, making the self-host default silently identical to fully closed), threading the invitation token through registration so an invited person lands in the inviting organization instead of a stray workspace, gating SSO just-in-time provisioning behind DISABLE_REGISTRATION (it bypassed the gate entirely, so an instance set to true was still open to anyone the IdP would assert) with SSO_AUTO_PROVISION as the opt-out, correcting the OIDC redirect URL that pointed at /api/v1 against a route at /v1 and 404'd every SSO login, scoping the first-launch exemption so it no longer overrides an explicit lockdown, preserving the remaining TTL when restoring a losing setup token so a public endpoint cannot hold the claim window open forever, replacing a generic 403 with typed registration_invite_only, registration_closed, invitation_invalid, setup_token_invalid and setup_already_complete codes that name the next step, logging why no claim link was issued on an already-claimed instance instead of staying silent, adding a warmblyctl operator CLI (status with health checks and a non-zero exit, reissuable setup-link, user create/list/reset-password/grant-admin/revoke-admin/disable-2fa, hash-password) so a locked-out operator no longer needs hand-written psql, adding read-only instance configuration over 104 environment variables with structural secret redaction and fingerprints, 35 health checks, a database-backed settings tier for the three keys no environment variable owns, hiding the signup form when the config already says invite_only rather than failing the whole form with a toast, and documenting first run, accounts and access, configuration, instance health and troubleshooting alongside the root .env.example the README told operators to write but never shipped (#114)
2026-08-16 05:58:11 +02:00

114 lines
3.7 KiB
TypeScript

import type { AxiosRequestConfig } from "axios"
import Client from "./Client"
import getToken from "@/lib/helper/getToken"
import isExpired from "@/lib/helper/isExpired";
import { NoToken, SessionExpired } from "@/lib/errors/auth";
import refreshTokenFn from "./auth/refreshToken";
import setToken from "@/lib/helper/setToken";
import reviveDates from "@/lib/helper/reviveDates";
import type { AppError } from "./normalizeError";
import { clearTokens } from "@/lib/auth";
import type Token from "@/lib/api/models/auth/Token";
interface AuthRequestConfig extends AxiosRequestConfig {
authorization?: boolean
}
// Refresh lock: only one refresh at a time, others wait for it
let refreshPromise: Promise<Token> | null = null;
async function ensureValidToken(): Promise<Token> {
const token = getToken();
if (!token) {
throw NoToken;
}
if (token.access_token && !isExpired(token.access_token_expires_at)) {
return token;
}
// Access token expired — need to refresh
if (!token.refresh_token || isExpired(token.refresh_token_expires_at)) {
clearTokens();
throw SessionExpired;
}
// If a refresh is already in progress, wait for it
if (refreshPromise) {
try {
await refreshPromise;
const updated = getToken();
if (updated && updated.access_token && !isExpired(updated.access_token_expires_at)) {
return updated;
}
throw SessionExpired;
} catch {
throw SessionExpired;
}
}
// Start a new refresh
refreshPromise = refreshTokenFn(token.refresh_token);
try {
const newToken = await refreshPromise;
setToken(newToken);
return newToken;
} catch {
clearTokens();
throw SessionExpired;
} finally {
refreshPromise = null;
}
}
export default async function Request<T>(config: AuthRequestConfig): Promise<T> {
if (config.authorization) {
const token = await ensureValidToken();
config.headers = {
...config.headers,
Authorization: `Bearer ${token.access_token}`,
}
}
try {
const res = await Client.request(config)
return reviveDates(res.data)
} catch (error) {
const appErr = error as AppError;
// Only an authorized 401 means the session is gone: refresh, retry once,
// then give up. A public 401 reaches the caller with its code intact.
if (config.authorization && (appErr?.status === 401 || appErr?.redirect)) {
try {
const token = await ensureValidToken();
config.headers = {
...config.headers,
Authorization: `Bearer ${token.access_token}`,
}
const res = await Client.request(config)
return reviveDates(res.data)
} catch {
clearTokens();
throw SessionExpired;
}
}
// A denied WRITE action (edit/save/delete) gets one clear, app-wide
// popup explaining the missing permission (or plan). Reads that 403 are
// intentionally left to page-level gating (locked surfaces / NoAccess),
// so we only surface this for mutating methods.
if (appErr?.status === 403 && typeof window !== "undefined") {
const method = String(config.method ?? "get").toUpperCase();
if (method !== "GET" && method !== "HEAD") {
window.dispatchEvent(
new CustomEvent("permission-denied", {
detail: { message: appErr.message },
}),
);
}
}
throw error;
}
}