Files
vantage/adminsite/lib/api.ts
T
mrhid6andClaude Opus 5 cb90ed12fb feat(adminsite): create and renew a free instance
Adds the create form with a live slug preview, a renew action inside the
seven-day window, and a deletion countdown that renders only when the
backend has actually promised a date.

The progress bar denominator now follows the tier; a 30-day Free licence
was rendering as an 8% sliver against the hardcoded 365.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:30:42 +01:00

228 lines
7.3 KiB
TypeScript

/*
* The typed client for the licensing service.
*
* The browser calls admin directly, so every request carries credentials and
* every failure mode is one of three: the API is unreachable (NotConnected),
* the caller is not signed in (ApiError 401, which layouts redirect on), or the
* request was refused (ApiError with the backend's own message, which is
* customer-facing and should be shown verbatim).
*/
export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
export class NotConnected extends Error {
constructor() {
super("not connected");
this.name = "NotConnected";
}
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
if (!API_BASE) throw new NotConnected();
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
} catch {
// Network-level failure, DNS, or a CORS preflight the browser refused.
throw new NotConnected();
}
if (res.status === 204) return undefined as T;
const body = await res.json().catch(() => null);
if (!res.ok) {
throw new ApiError(res.status, body?.error ?? `request failed (${res.status})`);
}
return body as T;
}
const post = <T,>(path: string, payload?: unknown) =>
req<T>(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined });
// --- types ---------------------------------------------------------------
export type Deployment = "cloud" | "self_hosted";
export type Tier = "free" | "professional" | "self_hosted";
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
export interface Session {
kind: "staff" | "customer";
email: string;
account_id?: string;
}
export interface Limits {
max_servers: number;
max_secret_groups: number;
max_channels: number;
}
export interface Account {
account_id: string;
name: string;
billing_email: string;
paddle_customer_id?: string;
status: "active" | "suspended";
created_at: string;
}
export interface Instance {
instance_id: string;
account_id: string;
name: string;
slug?: string;
deployment: Deployment;
tier?: Tier;
status: InstanceStatus;
current_license?: string;
relink_count: number;
inject_failed_at?: string | null;
notices_sent?: string[];
created_at: string;
}
export interface License {
license_id: string;
instance_id: string;
account_id: string;
tier: Tier;
deployment: Deployment;
limits: Limits;
features: string[];
issued_at: string;
expires_at: string;
superseded_by?: string;
issued_by: string;
reason: "new" | "renewal" | "tier_change" | "relink" | "manual";
}
export interface Subscription {
subscription_id: string;
account_id: string;
instance_id?: string;
tier: Tier;
term: string;
status: string;
current_period_end: string;
}
export interface Plan {
tier: Tier;
name: string;
deployment: Deployment;
limits: Limits;
features: string[];
paddle_product_id?: string;
paddle_price_ids?: Record<string, string>;
active: boolean;
}
export interface CustomerUser {
user_id: string;
account_id: string;
email: string;
verified_at?: string | null;
created_at: string;
}
export interface AuditEntry {
actor: string;
action: string;
account_id?: string;
target?: string;
detail?: string;
ip?: string;
created_at: string;
}
export interface AccountResponse {
account: Account;
instances: Instance[];
max_relinks: number;
}
export interface StaffAccountResponse {
account: Account;
instances: Instance[];
subscriptions: Subscription[];
users: CustomerUser[];
audit: AuditEntry[];
}
export type InjectionState = "current" | "stale" | "missing" | "none_issued";
export interface StaffInstanceResponse {
instance: Instance;
account: Account;
licenses: License[];
injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null };
}
// --- calls ---------------------------------------------------------------
export const api = {
me: () => req<Session>("/auth/me"),
login: (email: string, password: string) => post<Session>("/auth/login", { email, password }),
staffLogin: (email: string, password: string) =>
post<Session>("/auth/staff/login", { email, password }),
logout: () => post<{ ok: boolean }>("/auth/logout"),
signup: (payload: { name: string; email: string; password: string; website?: string }) =>
post<{ pending: boolean }>("/auth/signup", payload),
verify: (token: string) =>
req<{ verified: boolean }>(`/auth/verify?token=${encodeURIComponent(token)}`),
account: () => req<AccountResponse>("/api/account"),
link: (instance_id: string, name: string) =>
post<Instance>("/api/instances/link", { instance_id, name }),
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
relink: (id: string, instance_id: string) =>
post<License>(`/api/instances/${id}/relink`, { instance_id }),
license: (id: string) => req<License & { blob?: string }>(`/api/instances/${id}/license`),
licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
subscriptions: () => req<Subscription[]>("/api/subscriptions"),
staff: {
accounts: (q?: string) =>
req<Account[]>(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),
account: (id: string) => req<StaffAccountResponse>(`/api/staff/accounts/${id}`),
instances: (params?: Record<string, string>) =>
req<Instance[]>(
`/api/staff/instances${params ? `?${new URLSearchParams(params)}` : ""}`,
),
instance: (id: string) => req<StaffInstanceResponse>(`/api/staff/instances/${id}`),
issue: (id: string, payload: { tier: Tier; term?: string; reason?: string }) =>
post<License>(`/api/staff/instances/${id}/issue`, payload),
relink: (id: string, instance_id: string) =>
post<License>(`/api/staff/instances/${id}/relink`, { instance_id }),
licenses: (params?: Record<string, string>) =>
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
plans: () => req<Plan[]>("/api/staff/plans"),
updatePlan: (tier: Tier, plan: Omit<Plan, "tier" | "deployment">) =>
req<{ updated: boolean }>(`/api/staff/plans/${tier}`, {
method: "PUT",
body: JSON.stringify(plan),
}),
audit: (accountId?: string) =>
req<AuditEntry[]>(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
injectionHealth: () =>
req<{ failed: Instance[]; count: number }>("/api/staff/health/injection"),
subscriptions: (status?: string) =>
req<Subscription[]>(`/api/staff/subscriptions${status ? `?status=${status}` : ""}`),
},
};