From 597b1d1974939c5b884bcf70524e9a5b08ec3152 Mon Sep 17 00:00:00 2001 From: John Wang Date: Sun, 25 Oct 2026 09:09:11 -0700 Subject: [PATCH 01/10] feat(invites): add the invite API client and types createInvitesApi is a typed client for SystemForge's invite API, sending the browser's cookies and throwing ApiError with the status and the API's message. Wire types are snake_case, as the API sends them. Refs: RMI-SYSTEMFORGEWEB-027 Co-Authored-By: Claude Opus 5.5 --- packages/invites/package.json | 61 ++++++++++++++ packages/invites/src/api.ts | 138 ++++++++++++++++++++++++++++++++ packages/invites/src/index.ts | 19 +++++ packages/invites/src/types.ts | 119 +++++++++++++++++++++++++++ packages/invites/src/useLoad.ts | 29 +++++++ packages/invites/tsconfig.json | 9 +++ packages/invites/tsup.config.ts | 10 +++ pnpm-lock.yaml | 27 +++++++ 8 files changed, 412 insertions(+) create mode 100644 packages/invites/package.json create mode 100644 packages/invites/src/api.ts create mode 100644 packages/invites/src/index.ts create mode 100644 packages/invites/src/types.ts create mode 100644 packages/invites/src/useLoad.ts create mode 100644 packages/invites/tsconfig.json create mode 100644 packages/invites/tsup.config.ts diff --git a/packages/invites/package.json b/packages/invites/package.json new file mode 100644 index 0000000..421939b --- /dev/null +++ b/packages/invites/package.json @@ -0,0 +1,61 @@ +{ + "name": "@plexusone/invites", + "version": "0.1.0", + "description": "Pages for SystemForge invite-gated sign-up: an operator page for invite codes, email invites, the waitlist and member allowances, and a member page to invite people", + "homepage": "https://plexusone.github.io/systemforge-web/packages/invites/", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src/", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@plexusone/ui": "workspace:^" + }, + "devDependencies": { + "@plexusone/ui": "workspace:*", + "@testing-library/react": "^16.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsup": "^8.0.1", + "typescript": "^5.3.3" + }, + "keywords": [ + "systemforge", + "invites", + "signup", + "waitlist", + "react" + ], + "author": "SystemForge Team", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/plexusone/systemforge-web.git", + "directory": "packages/invites" + }, + "bugs": { + "url": "https://github.com/plexusone/systemforge-web/issues" + } +} diff --git a/packages/invites/src/api.ts b/packages/invites/src/api.ts new file mode 100644 index 0000000..bbc5b64 --- /dev/null +++ b/packages/invites/src/api.ts @@ -0,0 +1,138 @@ +import type { + Allowance, + CodeKind, + CodeOptions, + CreatedCode, + EmailInvite, + InviteCode, + InviteOptions, + InviteSettings, + JoinedAccount, + MyInvites, + WaitlistEntry, +} from './types'; + +/** An error from the invite API, with its HTTP status. */ +export class ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +/** The invite API. Every call runs as the signed-in person. */ +export interface InvitesApi { + settings(): Promise; + + /** The caller's allowance and codes. */ + myInvites(): Promise; + /** Creates a single-use code for the caller; fails with 409 when none are left. */ + createMyCode(): Promise; + revokeMyCode(id: string): Promise; + + // Platform administrators only. + invites(all?: boolean): Promise; + addInvites( + emails: string[], + options?: InviteOptions + ): Promise<{ invites: EmailInvite[]; failed: { email: string; error: string }[] }>; + revokeInvite(email: string): Promise; + codes(filter?: { kind?: CodeKind; label?: string; activeOnly?: boolean }): Promise; + createCode(options: CodeOptions): Promise; + revokeCode(id: string): Promise; + waitlist(): Promise; + inviteFromWaitlist( + emails: string[], + options?: InviteOptions + ): Promise<{ invites: EmailInvite[]; error?: string }>; + removeFromWaitlist(email: string): Promise; + allowances(): Promise; + /** Sets a member's allowance by email; null returns them to the default. */ + setAllowance(email: string, granted: number | null, note?: string): Promise; + joined(filter?: { codeId?: string; label?: string; inviter?: string }): Promise; +} + +function query(params: Record): string { + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) if (v) q.set(k, v); + const s = q.toString(); + return s ? `?${s}` : ''; +} + +/** + * createInvitesApi returns a client for the invite API served at baseUrl, + * such as '/invites/api' behind the host's own session or + * 'https://auth.example.com/signup-invites/api' on SystemAuth. Requests + * carry the browser's cookies. + */ +export function createInvitesApi(baseUrl: string): InvitesApi { + const base = baseUrl.replace(/\/+$/, ''); + + async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${base}${path}`, { + ...init, + credentials: 'include', + headers: { + Accept: 'application/json', + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + }, + }); + if (!res.ok) { + let message = res.statusText || `request failed with status ${res.status}`; + try { + const body = (await res.json()) as { error?: string }; + if (body.error) message = body.error; + } catch { + // Not JSON; the status line is the message. + } + throw new ApiError(res.status, message); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } + const post = (path: string, body?: unknown) => + request(path, { + method: 'POST', + body: body === undefined ? undefined : JSON.stringify(body), + }); + const enc = encodeURIComponent; + + return { + settings: () => request('/settings'), + myInvites: () => request('/me/invites'), + createMyCode: () => post('/me/codes'), + revokeMyCode: (id) => post(`/me/codes/${enc(id)}/revoke`), + + invites: (all) => + request<{ invites: EmailInvite[] }>(`/admin/invites${all ? '?status=all' : ''}`).then( + (r) => r.invites + ), + addInvites: (emails, options) => post('/admin/invites', { emails, ...options }), + revokeInvite: (email) => request(`/admin/invites/${enc(email)}`, { method: 'DELETE' }), + codes: (f) => + request<{ codes: InviteCode[] }>( + `/admin/codes${query({ kind: f?.kind, label: f?.label, status: f?.activeOnly ? 'active' : undefined })}` + ).then((r) => r.codes), + createCode: (options) => post('/admin/codes', options), + revokeCode: (id) => post(`/admin/codes/${enc(id)}/revoke`), + waitlist: () => + request<{ waitlist: WaitlistEntry[] }>('/admin/waitlist').then((r) => r.waitlist), + inviteFromWaitlist: (emails, options) => post('/admin/waitlist/invite', { emails, ...options }), + removeFromWaitlist: (email) => + request(`/admin/waitlist/${enc(email)}`, { method: 'DELETE' }), + allowances: () => + request<{ allowances: Allowance[] }>('/admin/allowances').then((r) => r.allowances), + setAllowance: (email, granted, note) => + request('/admin/allowances', { + method: 'PUT', + body: JSON.stringify({ email, granted, note: note ?? '' }), + }), + joined: (f) => + request<{ joined: JoinedAccount[] }>( + `/admin/joined${query({ code_id: f?.codeId, label: f?.label, inviter: f?.inviter })}` + ).then((r) => r.joined), + }; +} diff --git a/packages/invites/src/index.ts b/packages/invites/src/index.ts new file mode 100644 index 0000000..a1a4096 --- /dev/null +++ b/packages/invites/src/index.ts @@ -0,0 +1,19 @@ +// @plexusone/invites — pages for SystemForge invite-gated sign-up. + +export { createInvitesApi, ApiError } from './api'; +export type { InvitesApi } from './api'; +export type { + Allowance, + CodeKind, + CodeOptions, + CodeStatus, + CreatedCode, + EmailInvite, + InviteCode, + InviteOptions, + InviteSettings, + InviteStatus, + JoinedAccount, + MyInvites, + WaitlistEntry, +} from './types'; diff --git a/packages/invites/src/types.ts b/packages/invites/src/types.ts new file mode 100644 index 0000000..cc60b27 --- /dev/null +++ b/packages/invites/src/types.ts @@ -0,0 +1,119 @@ +// Wire types of the SystemForge invite API (signupapi). JSON is snake_case, +// as on every SystemAuth API. + +/** Who created a code. */ +export type CodeKind = 'operator' | 'member'; + +/** Whether a code can still be used. */ +export type CodeStatus = 'active' | 'used_up' | 'expired' | 'revoked'; + +/** An invite code as listed. The code itself is never listed. */ +export interface InviteCode { + id: string; + kind: CodeKind; + /** The code's last four characters, to tell codes apart. */ + hint: string; + label?: string; + note?: string; + created_by_principal_id?: string; + created_by_label?: string; + max_uses: number; + use_count: number; + status: CodeStatus; + created_at: string; + expires_at?: string; + revoked_at?: string; +} + +/** A new code, returned once: copy it or its link now. */ +export interface CreatedCode { + code: InviteCode; + /** The code, formatted XXXX-XXXX-XXXX-XXXX. */ + invite_code: string; + /** The invite link, when the deployment knows its sign-up page. */ + link?: string; +} + +/** Whether an email invite can still be used. */ +export type InviteStatus = 'pending' | 'redeemed' | 'expired'; + +/** An email invite. */ +export interface EmailInvite { + email: string; + note?: string; + invited_by?: string; + invited_by_principal_id?: string; + status: InviteStatus; + created_at: string; + expires_at?: string; + redeemed_at?: string; + redeemed_by?: string; +} + +/** An address waiting to be told that sign-up is open. */ +export interface WaitlistEntry { + email: string; + consent_version: string; + joined_at: string; + last_submitted_at: string; + invited_at?: string; +} + +/** A member's invite allowance. */ +export interface Allowance { + principal_id: string; + email: string; + /** The allowance an operator set; null means the deployment's default. */ + granted: number | null; + effective: number; + held: number; + note?: string; + updated_at: string; +} + +/** An account an invite admitted. */ +export interface JoinedAccount { + source: 'code' | 'invite'; + principal_id?: string; + email: string; + joined_at: string; + code_id?: string; + code_kind?: CodeKind; + code_label?: string; + code_hint?: string; + invited_by_principal_id?: string; + invited_by_label?: string; +} + +/** The deployment's invite settings, as the signed-in person sees them. */ +export interface InviteSettings { + default_allowance: number; + member_code_ttl_seconds: number; + signup_url: string; + is_admin: boolean; +} + +/** What a member sees of their own invites. */ +export interface MyInvites { + allowance: number; + held: number; + remaining: number; + member_code_ttl_seconds: number; + codes: InviteCode[]; +} + +/** Options for new email invites, also used to invite from the waitlist. */ +export interface InviteOptions { + note?: string; + /** 0 or absent: the invites do not expire. */ + expires_in_days?: number; +} + +/** A new operator code. */ +export interface CodeOptions { + label?: string; + note?: string; + max_uses: number; + /** 0 or absent: the code does not expire. */ + expires_in_days?: number; +} diff --git a/packages/invites/src/useLoad.ts b/packages/invites/src/useLoad.ts new file mode 100644 index 0000000..821adf5 --- /dev/null +++ b/packages/invites/src/useLoad.ts @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** The state of a load: data, an error, or neither while loading. */ +export interface Load { + data?: T; + error?: Error; + reload: () => void; +} + +/** useLoad runs fn when deps change and on reload, keeping the latest result. */ +export function useLoad(fn: () => Promise, deps: unknown[]): Load { + const [state, setState] = useState<{ data?: T; error?: Error }>({}); + const [n, setN] = useState(0); + const reload = useCallback(() => setN((x) => x + 1), []); + useEffect(() => { + let live = true; + fn().then( + (data) => live && setState({ data }), + (error: unknown) => + live && setState({ error: error instanceof Error ? error : new Error(String(error)) }) + ); + return () => { + live = false; + }; + // fn is recreated each render; deps and n decide when to load. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...deps, n]); + return { ...state, reload }; +} diff --git a/packages/invites/tsconfig.json b/packages/invites/tsconfig.json new file mode 100644 index 0000000..06d4de5 --- /dev/null +++ b/packages/invites/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/__tests__"] +} diff --git a/packages/invites/tsup.config.ts b/packages/invites/tsup.config.ts new file mode 100644 index 0000000..35dd61c --- /dev/null +++ b/packages/invites/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + clean: true, + external: ['react', 'react-dom', '@plexusone/ui'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2dfc5e..6fd1296 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,33 @@ importers: specifier: ^5.3.3 version: 5.9.3 + packages/invites: + devDependencies: + '@plexusone/ui': + specifier: workspace:* + version: link:../ui + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + react: + specifier: ^19.0.0 + version: 19.2.5 + react-dom: + specifier: ^19.0.0 + version: 19.2.5(react@19.2.5) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.9)(typescript@5.9.3)(yaml@2.8.4) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + packages/pages: devDependencies: '@plexusone/auth': From bd2c35aecc373813b5235e10e07db1938c29be58 Mon Sep 17 00:00:00 2001 From: John Wang Date: Sun, 25 Oct 2026 09:09:14 -0700 Subject: [PATCH 02/10] feat(invites): add the operator page for invites InviteAdminPage manages invite codes, email invites, the waitlist and member allowances, and lists the accounts invites admitted. New codes and links are shown once with a copy button, revoking asks inline, and a refusal for non-administrators reads as such. Refs: RMI-SYSTEMFORGEWEB-028 Co-Authored-By: Claude Opus 5.5 --- packages/invites/src/components/ui.tsx | 177 +++++ packages/invites/src/index.ts | 2 + .../invites/src/pages/InviteAdminPage.tsx | 686 ++++++++++++++++++ 3 files changed, 865 insertions(+) create mode 100644 packages/invites/src/components/ui.tsx create mode 100644 packages/invites/src/pages/InviteAdminPage.tsx diff --git a/packages/invites/src/components/ui.tsx b/packages/invites/src/components/ui.tsx new file mode 100644 index 0000000..8eea212 --- /dev/null +++ b/packages/invites/src/components/ui.tsx @@ -0,0 +1,177 @@ +import { useState, type ReactNode } from 'react'; +import { Button, Input } from '@plexusone/ui'; +import type { CodeStatus, CreatedCode, InviteStatus } from '../types'; + +export function PageHeader({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: ReactNode; + children?: ReactNode; +}): ReactNode { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {children &&
{children}
} +
+ ); +} + +export function ErrorNote({ children }: { children: ReactNode }): ReactNode { + return ( +

+ {children} +

+ ); +} + +export function Note({ children }: { children: ReactNode }): ReactNode { + return ( +

+ {children} +

+ ); +} + +export function Loading({ what }: { what: string }): ReactNode { + return

Loading {what}…

; +} + +export function Empty({ children }: { children: ReactNode }): ReactNode { + return ( +

+ {children} +

+ ); +} + +const LABEL: Record = { + active: 'Active', + used_up: 'Used', + expired: 'Expired', + revoked: 'Revoked', + pending: 'Pending', + redeemed: 'Joined', +}; + +const TONE: Record = { + active: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400', + pending: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400', + used_up: 'bg-muted text-muted-foreground', + redeemed: 'bg-muted text-muted-foreground', + expired: 'bg-amber-500/15 text-amber-700 dark:text-amber-400', + revoked: 'bg-destructive/15 text-destructive', +}; + +export function StatusBadge({ status }: { status: CodeStatus | InviteStatus }): ReactNode { + return ( + + {LABEL[status]} + + ); +} + +/** A date as people read it, or a dash. */ +export function formatDate(iso?: string): string { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +/** A code's short name: its last four characters. */ +export function codeName(hint: string): string { + return `…${hint}`; +} + +/** + * ShowOnce shows a new code and its link with a copy button. The API returns + * them only once, so the person is told to copy them now. + */ +export function ShowOnce({ + created, + onDone, +}: { + created: CreatedCode; + onDone: () => void; +}): ReactNode { + const value = created.link ?? created.invite_code; + const [copied, setCopied] = useState(); + async function copy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + } catch { + setCopied(false); + } + } + return ( +
+

+ Copy this {created.link ? 'link' : 'code'} now. It will not be shown again. +

+
+ e.currentTarget.select()} + /> + +
+ {created.link &&

Code: {created.invite_code}

} + {copied === true &&

Copied.

} + {copied === false && ( +

+ Copying is not available here; select it and copy. +

+ )} + +
+ ); +} + +/** + * useRun runs an action, tracking busy and error state, and calls after on + * success. + */ +export function useRun(): { + busy: boolean; + error?: string; + run: (what: string, fn: () => Promise) => Promise; + clear: () => void; +} { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + async function run(what: string, fn: () => Promise) { + setBusy(true); + setError(undefined); + try { + await fn(); + } catch (err) { + setError(`${what}: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setBusy(false); + } + } + return { busy, error, run, clear: () => setError(undefined) }; +} diff --git a/packages/invites/src/index.ts b/packages/invites/src/index.ts index a1a4096..908072c 100644 --- a/packages/invites/src/index.ts +++ b/packages/invites/src/index.ts @@ -2,6 +2,8 @@ export { createInvitesApi, ApiError } from './api'; export type { InvitesApi } from './api'; +export { InviteAdminPage, splitEmails } from './pages/InviteAdminPage'; +export type { InviteAdminPageProps } from './pages/InviteAdminPage'; export type { Allowance, CodeKind, diff --git a/packages/invites/src/pages/InviteAdminPage.tsx b/packages/invites/src/pages/InviteAdminPage.tsx new file mode 100644 index 0000000..b26d639 --- /dev/null +++ b/packages/invites/src/pages/InviteAdminPage.tsx @@ -0,0 +1,686 @@ +import { useState, type FormEvent, type ReactNode } from 'react'; +import { Button, Input, Label } from '@plexusone/ui'; +import { ApiError, type InvitesApi } from '../api'; +import { + Empty, + ErrorNote, + Loading, + Note, + PageHeader, + ShowOnce, + StatusBadge, + codeName, + formatDate, + useRun, +} from '../components/ui'; +import type { CreatedCode, EmailInvite, InviteCode, WaitlistEntry } from '../types'; +import { useLoad, type Load } from '../useLoad'; + +export interface InviteAdminPageProps { + /** The invite API, from createInvitesApi. */ + api: InvitesApi; + /** The page heading. Default: "Invites". */ + title?: string; +} + +const SECTIONS = [ + { id: 'codes', label: 'Invite codes' }, + { id: 'invites', label: 'Email invites' }, + { id: 'waitlist', label: 'Waitlist' }, + { id: 'allowances', label: 'Member allowances' }, + { id: 'joined', label: 'Joined' }, +] as const; + +type Section = (typeof SECTIONS)[number]['id']; + +/** + * InviteAdminPage is the operator page for invite-gated sign-up: invite + * codes, email invites, the waitlist, member allowances, and the accounts + * invites admitted. It is for platform administrators; others get the + * API's refusal. + */ +export function InviteAdminPage({ api, title = 'Invites' }: InviteAdminPageProps): ReactNode { + const [section, setSection] = useState
('codes'); + return ( + <> + +
+ {SECTIONS.map((s) => ( + + ))} +
+
+ {section === 'codes' && } + {section === 'invites' && } + {section === 'waitlist' && } + {section === 'allowances' && } + {section === 'joined' && } +
+ + ); +} + +/** loadState renders a load's error or loading state, or nothing once loaded. */ +function loadState(load: Load, what: string): ReactNode { + if (load.error) { + if (load.error instanceof ApiError && load.error.status === 403) { + return Only platform administrators can manage invites.; + } + return ( + + Could not load {what}: {load.error.message} + + ); + } + if (load.data === undefined) return ; + return null; +} + +/** splitEmails reads addresses separated by commas, spaces or new lines. */ +export function splitEmails(text: string): string[] { + return text + .split(/[\s,;]+/) + .map((s) => s.trim()) + .filter(Boolean); +} + +function days(value: string): number { + const n = Number.parseInt(value, 10); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +function Field({ + id, + label, + children, +}: { + id: string; + label: string; + children: ReactNode; +}): ReactNode { + return ( +
+ + {children} +
+ ); +} + +function ConfirmRow({ + prompt, + action, + busy, + onConfirm, + onCancel, +}: { + prompt: string; + action: string; + busy: boolean; + onConfirm: () => void; + onCancel: () => void; +}): ReactNode { + return ( +
+ {prompt} + + +
+ ); +} + +// --- Invite codes --- + +function CodesSection({ api }: { api: InvitesApi }): ReactNode { + const codes = useLoad(() => api.codes({ kind: 'operator' }), [api]); + const [created, setCreated] = useState(); + const [label, setLabel] = useState(''); + const [maxUses, setMaxUses] = useState('10'); + const [expires, setExpires] = useState('14'); + const [note, setNote] = useState(''); + const action = useRun(); + + function submit(e: FormEvent) { + e.preventDefault(); + void action.run('Create code', async () => { + setCreated( + await api.createCode({ + label: label.trim(), + note: note.trim(), + max_uses: Math.max(1, Number.parseInt(maxUses, 10) || 1), + expires_in_days: days(expires), + }) + ); + setLabel(''); + setNote(''); + codes.reload(); + }); + } + + return ( + <> +
+ + setLabel(e.target.value)} + /> + + + setMaxUses(e.target.value)} + /> + + + setExpires(e.target.value)} + /> + + + setNote(e.target.value)} + /> + +
+ +
+
+ {action.error && {action.error}} + {created && setCreated(undefined)} />} + {loadState(codes, 'invite codes') ?? + (codes.data!.length === 0 ? ( + No invite codes yet. Create one above and share its link. + ) : ( +
    + {codes.data!.map((c) => ( + + ))} +
+ ))} + + ); +} + +function CodeRow({ + code: c, + api, + onChange, +}: { + code: InviteCode; + api: InvitesApi; + onChange: () => void; +}): ReactNode { + const [confirm, setConfirm] = useState(false); + const action = useRun(); + const name = c.label ? `${c.label} ${codeName(c.hint)}` : codeName(c.hint); + return ( +
  • +
    +
    + {c.label && {c.label}} + {codeName(c.hint)} + +
    +
    + {c.use_count} of {c.max_uses} used · created {formatDate(c.created_at)} + {c.expires_at && ` · expires ${formatDate(c.expires_at)}`} +
    +
    + {c.note &&

    {c.note}

    } + {c.status === 'active' && !confirm && ( + + )} + {confirm && ( + setConfirm(false)} + onConfirm={() => + void action.run('Revoke', async () => { + await api.revokeCode(c.id); + setConfirm(false); + onChange(); + }) + } + /> + )} + {action.error && {action.error}} +
  • + ); +} + +// --- Email invites --- + +function InvitesSection({ api }: { api: InvitesApi }): ReactNode { + const invites = useLoad(() => api.invites(true), [api]); + const [emails, setEmails] = useState(''); + const [note, setNote] = useState(''); + const [expires, setExpires] = useState('0'); + const [result, setResult] = useState(); + const action = useRun(); + + function submit(e: FormEvent) { + e.preventDefault(); + const list = splitEmails(emails); + if (list.length === 0) return; + void action.run('Invite', async () => { + const r = await api.addInvites(list, { note: note.trim(), expires_in_days: days(expires) }); + const failed = r.failed.map((f) => `${f.email} (${f.error})`).join(', '); + setResult( + `Invited ${r.invites.length} ${r.invites.length === 1 ? 'address' : 'addresses'}.` + + (failed ? ` Not invited: ${failed}.` : '') + ); + setEmails(''); + invites.reload(); + }); + } + + return ( + <> +
    + +