@volter/world-platform 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +29 -0
- package/client/platform.css +228 -0
- package/client/platform.tsx +1049 -0
- package/client/reserved.ts +3 -0
- package/dist/client/platform.bundle.js +237 -0
- package/dist/client/platform.css +228 -0
- package/dist/client/platform.tsx +1049 -0
- package/dist/client/reserved.d.ts +1 -0
- package/dist/client/reserved.js +3 -0
- package/dist/client/reserved.ts +3 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +23 -0
- package/dist/src/backup.d.ts +37 -0
- package/dist/src/backup.js +94 -0
- package/dist/src/biller.d.ts +59 -0
- package/dist/src/biller.js +1 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +112 -0
- package/dist/src/db/migrations/0000_init.sql +143 -0
- package/dist/src/db/migrations/meta/0000_snapshot.json +877 -0
- package/dist/src/db/migrations/meta/_journal.json +13 -0
- package/dist/src/db/migrations.d.ts +4 -0
- package/dist/src/db/migrations.js +33 -0
- package/dist/src/db/open.d.ts +11 -0
- package/dist/src/db/open.js +114 -0
- package/dist/src/db/pack-migrations.d.ts +1 -0
- package/dist/src/db/pack-migrations.js +33 -0
- package/dist/src/db/schema.d.ts +1803 -0
- package/dist/src/db/schema.js +123 -0
- package/dist/src/directory.d.ts +75 -0
- package/dist/src/directory.js +199 -0
- package/dist/src/doors.d.ts +16 -0
- package/dist/src/doors.js +123 -0
- package/dist/src/identity.d.ts +88 -0
- package/dist/src/identity.js +314 -0
- package/dist/src/labs.d.ts +14 -0
- package/dist/src/labs.js +7 -0
- package/dist/src/mail.d.ts +16 -0
- package/dist/src/mail.js +27 -0
- package/dist/src/pages.d.ts +15 -0
- package/dist/src/pages.js +73 -0
- package/dist/src/platform.d.ts +77 -0
- package/dist/src/platform.js +1845 -0
- package/dist/src/sample.d.ts +11 -0
- package/dist/src/sample.js +93 -0
- package/dist/src/store.d.ts +110 -0
- package/dist/src/store.js +142 -0
- package/dist/src/tokens.d.ts +69 -0
- package/dist/src/tokens.js +96 -0
- package/dist/src/webhooks.d.ts +60 -0
- package/dist/src/webhooks.js +92 -0
- package/package.json +78 -0
- package/src/audit.ts +26 -0
- package/src/backup.ts +73 -0
- package/src/biller.ts +45 -0
- package/src/cli.ts +99 -0
- package/src/db/migrations/0000_init.sql +143 -0
- package/src/db/migrations/meta/0000_snapshot.json +877 -0
- package/src/db/migrations/meta/_journal.json +13 -0
- package/src/db/migrations.ts +33 -0
- package/src/db/open.ts +92 -0
- package/src/db/pack-migrations.ts +19 -0
- package/src/db/schema.ts +137 -0
- package/src/directory.ts +216 -0
- package/src/doors.ts +138 -0
- package/src/identity.ts +279 -0
- package/src/labs.ts +8 -0
- package/src/mail.ts +27 -0
- package/src/pages.ts +69 -0
- package/src/platform.ts +1183 -0
- package/src/sample.ts +84 -0
- package/src/store.ts +154 -0
- package/src/tokens.ts +94 -0
- package/src/webhooks.ts +85 -0
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
// THE PLATFORM'S PAGES (docs/contributing/architecture.md, "The hosted product"): where people and orgs are — signing
|
|
2
|
+
// in through the platform's access provider, an org's Worlds, members, settings and activity, your own tokens, and a
|
|
3
|
+
// Billing page only when a biller is attached. Laid out as Neon's and Vercel's org consoles are: the org switcher heads
|
|
4
|
+
// the sidebar, the org's pages under it, then yours; the address is the page (`/<org>/settings/tokens`), so any page can
|
|
5
|
+
// be linked. Every read and write is a door of this platform (`/-/…`), with the session cookie as the credential — or,
|
|
6
|
+
// for the operator's consented support view, the support token the page was opened with. A World opens on its own origin
|
|
7
|
+
// with a pass (`/-/worlds/<org>/<world>/open`); nothing here holds a World's key. Results are toasts, acts that cannot be
|
|
8
|
+
// taken back are confirmed in a dialog (the worst by typing the name), and loading is a skeleton. Locators are roles and
|
|
9
|
+
// text.
|
|
10
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
+
import { createRoot } from 'react-dom/client';
|
|
12
|
+
import { applyTheme, ThemeChoice, ago, At, Brand, closeDrawer, closeMenu, ConfirmHost, MenuDismiss, confirmAct, CopyField, day, Dialog, DrawerButton, Empty, failure, Initial, Mark, Menu, Meter, Pending, Skeleton, SubNav, toast, Toaster, useDoor as useKitDoor, vendorName, when, type Load } from '@volter/world-console/kit';
|
|
13
|
+
import { RESERVED_ORG_NAMES } from './reserved.ts';
|
|
14
|
+
import { agentPrompt } from '../../../packages/cli/src/agent-prompt.ts';
|
|
15
|
+
|
|
16
|
+
const ROOT = document.getElementById('root')!;
|
|
17
|
+
const ORIGIN = window.location.origin;
|
|
18
|
+
// the operator's consented support view: the shell carries its token once (`/?support=tok_su_…`); the tab keeps it, so a
|
|
19
|
+
// reload stays in the view, and the address drops it
|
|
20
|
+
const SUPPORT_KEY = 'volter.platform.support';
|
|
21
|
+
const SUPPORT = ((): string => { const t = ROOT.dataset.token ?? ''; try { if (t) sessionStorage.setItem(SUPPORT_KEY, t); return t || (sessionStorage.getItem(SUPPORT_KEY) ?? ''); } catch { return t; } })();
|
|
22
|
+
if (new URLSearchParams(window.location.search).has('support')) { try { window.history.replaceState(null, '', window.location.pathname); } catch { /* fine */ } }
|
|
23
|
+
/** The catalog's vendors, most used first, as the shell was handed them (pages.ts): what a World can stand in for. */
|
|
24
|
+
const CATALOG: Array<{ vendor: string; supported: boolean }> = (ROOT.dataset.vendors ?? '').split(',').filter(Boolean).map((v) => { const [vendor, s] = v.split(':'); return { vendor: vendor!, supported: s === 's' }; });
|
|
25
|
+
|
|
26
|
+
async function door(path: string, init: RequestInit = {}): Promise<Response> {
|
|
27
|
+
const typed = (init.headers as Record<string, string> | undefined)?.['content-type'];
|
|
28
|
+
return fetch(`${ORIGIN}${path}`, { ...init, credentials: 'same-origin', headers: { ...(init.headers ?? {}), ...(SUPPORT ? { 'x-volter-token': SUPPORT } : {}), ...(init.body && !typed ? { 'content-type': 'application/json' } : {}) } });
|
|
29
|
+
}
|
|
30
|
+
function useDoor<T>(path: string | null, version = 0): Load<T> { return useKitDoor<T>(door, path, version); }
|
|
31
|
+
/** Sign out: a POST from this page (the platform refuses a GET), then the front page says so. */
|
|
32
|
+
async function signOutNow(): Promise<void> { await door('/-/sign-out', { method: 'POST', redirect: 'manual' }).catch(() => null); window.location.assign('/?signed-out=1'); }
|
|
33
|
+
/** A write through a door: its result said as a toast (the door's own words when it refused); the answer, or null. */
|
|
34
|
+
async function act<T = Record<string, unknown>>(path: string, init: RequestInit, done?: string): Promise<T | null> {
|
|
35
|
+
const r = await door(path, init).catch(() => null);
|
|
36
|
+
if (!r) { toast(`${ORIGIN} did not answer`, 'error'); return null; }
|
|
37
|
+
if (!r.ok) { toast(await failure(r), 'error'); return null; }
|
|
38
|
+
const body = (await r.json().catch(() => ({}))) as T;
|
|
39
|
+
if (done) toast(done);
|
|
40
|
+
return body;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Where you are: a page of yours (`account`, `help`, `new`), or an org and one of its pages (settings has sub-pages). */
|
|
44
|
+
const ORG_PAGES = [['worlds', 'Worlds'], ['members', 'Members'], ['billing', 'Billing'], ['activity', 'Activity'], ['settings', 'Settings']] as const;
|
|
45
|
+
type OrgPage = (typeof ORG_PAGES)[number][0];
|
|
46
|
+
const SETTINGS = [['general', 'General'], ['security', 'Security'], ['tokens', 'Org tokens'], ['webhooks', 'Webhooks'], ['labs', 'Labs'], ['support', 'Support access']] as const;
|
|
47
|
+
type SettingsPage = (typeof SETTINGS)[number][0];
|
|
48
|
+
type Place = { kind: 'home' } | { kind: 'you'; page: 'account' | 'help' | 'new' } | { kind: 'org'; org: string; page: OrgPage; sub?: SettingsPage };
|
|
49
|
+
const YOU_TITLES = { account: 'Account', help: 'Help', new: 'Create an org' } as const;
|
|
50
|
+
function place(): Place {
|
|
51
|
+
const parts = window.location.pathname.split('/').filter(Boolean).map((p) => { try { return decodeURIComponent(p); } catch { return ''; } });
|
|
52
|
+
const [first, second, third] = parts;
|
|
53
|
+
if (!first) return { kind: 'home' };
|
|
54
|
+
if (first === 'account' || first === 'help' || first === 'new') return { kind: 'you', page: first };
|
|
55
|
+
const page = ORG_PAGES.find(([k]) => k === second)?.[0] ?? 'worlds';
|
|
56
|
+
const sub = page === 'settings' ? SETTINGS.find(([k]) => k === third)?.[0] ?? 'general' : undefined;
|
|
57
|
+
return { kind: 'org', org: first, page, ...(sub ? { sub } : {}) };
|
|
58
|
+
}
|
|
59
|
+
const pathOf = (p: Place): string => (p.kind === 'home' ? '/' : p.kind === 'you' ? `/${p.page}` : `/${encodeURIComponent(p.org)}${p.page === 'worlds' ? '' : `/${p.page}`}${p.page === 'settings' && p.sub && p.sub !== 'general' ? `/${p.sub}` : ''}`);
|
|
60
|
+
function go(p: Place, replace = false): void {
|
|
61
|
+
if (replace) window.history.replaceState(null, '', pathOf(p)); else window.history.pushState(null, '', pathOf(p));
|
|
62
|
+
window.dispatchEvent(new PopStateEvent('popstate')); window.scrollTo(0, 0);
|
|
63
|
+
}
|
|
64
|
+
/** A link inside the app: the address changes, the page does not reload; a pick in the phone's drawer closes it. */
|
|
65
|
+
function Go({ to, children, className, current }: { to: Place; children: React.ReactNode; className?: string; current?: boolean }): React.JSX.Element {
|
|
66
|
+
return <a href={pathOf(to)} className={className} aria-current={current ? 'page' : undefined} onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); closeMenu(e); closeDrawer(); go(to); }}>{children}</a>;
|
|
67
|
+
}
|
|
68
|
+
const linkTo = (to: Place) => (e: React.MouseEvent<HTMLAnchorElement>): void => { if (e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); go(to); };
|
|
69
|
+
|
|
70
|
+
/** What this platform says of itself (`GET /-/platform`): its provider, and whether billing and the Help form are here. */
|
|
71
|
+
type PlatformInfo = { name: string; provider: { kind: 'volter' | 'oidc' | 'github'; name: string; account?: string }; billing: boolean; support: boolean; site?: string };
|
|
72
|
+
type Person = { id: string; email?: string; name?: string; support?: { until: string; reason: string } };
|
|
73
|
+
type OrgWorld = { name: string; host: string; base: string; provisionedAt: string };
|
|
74
|
+
type OrgRow = { id: string; slug: string | null; name: string; role: string; held?: string | null; flags?: Record<string, boolean>; worlds?: OrgWorld[]; security?: { membersCanInvite: boolean } };
|
|
75
|
+
type WorldRow = { org: string; world: string; name: string; base: string; origin?: string; twins: Array<{ vendor: string }> };
|
|
76
|
+
const slugOf = (o: OrgRow): string => o.slug ?? o.id;
|
|
77
|
+
const isAdmin = (o: OrgRow): boolean => o.role === 'admin' || o.role === 'org:admin';
|
|
78
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,38}$/;
|
|
79
|
+
|
|
80
|
+
function App(): React.JSX.Element {
|
|
81
|
+
const [at, setAt] = useState<Place>(place());
|
|
82
|
+
useEffect(() => { const on = (): void => setAt(place()); window.addEventListener('popstate', on); return () => window.removeEventListener('popstate', on); }, []);
|
|
83
|
+
const info = useDoor<PlatformInfo>('/-/platform');
|
|
84
|
+
const [version, setVersion] = useState(0);
|
|
85
|
+
const bump = useCallback(() => setVersion((v) => v + 1), []);
|
|
86
|
+
// who you are and your orgs: a 401 is signed out (the sign-in card), anything else refused is said with a retry
|
|
87
|
+
const [me, setMe] = useState<{ state: 'loading' } | { state: 'out' } | { state: 'error'; error: string } | { state: 'in'; person: Person; orgs: OrgRow[] }>({ state: 'loading' });
|
|
88
|
+
const loadMe = useCallback(async () => {
|
|
89
|
+
const r = await door('/-/orgs').catch(() => null);
|
|
90
|
+
if (!r) { setMe({ state: 'error', error: `${ORIGIN} did not answer` }); return; }
|
|
91
|
+
// a support session that has ended is dropped from the tab, and the tab signs in as itself again
|
|
92
|
+
// (reloaded only once the token is gone: a tab whose storage refuses the removal is signed out here, not looped)
|
|
93
|
+
if (r.status === 401 && SUPPORT) { let cleared = false; try { sessionStorage.removeItem(SUPPORT_KEY); cleared = sessionStorage.getItem(SUPPORT_KEY) === null; } catch { /* storage refused */ } if (cleared) { window.location.assign('/'); return; } setMe({ state: 'out' }); return; }
|
|
94
|
+
if (r.status === 401) { setMe({ state: 'out' }); return; }
|
|
95
|
+
if (!r.ok) { setMe({ state: 'error', error: r.status === 429 ? 'You are asking faster than the platform allows. Wait a moment and try again.' : await failure(r) }); return; }
|
|
96
|
+
const b = (await r.json()) as { person: Person; orgs: OrgRow[] };
|
|
97
|
+
setMe({ state: 'in', person: b.person, orgs: b.orgs });
|
|
98
|
+
}, []);
|
|
99
|
+
useEffect(() => { void loadMe(); }, [loadMe, version]);
|
|
100
|
+
const worlds = useDoor<{ worlds: WorldRow[] }>(me.state === 'in' ? '/-/worlds' : null, version);
|
|
101
|
+
useEffect(() => { document.body.className = me.state === 'out' ? 'portal' : ''; }, [me.state]);
|
|
102
|
+
const [palette, setPalette] = useState(false);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const on = (e: KeyboardEvent): void => {
|
|
105
|
+
const typing = e.target instanceof HTMLElement && (e.target.closest('input, textarea, select, [contenteditable]') !== null);
|
|
106
|
+
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !typing)) { e.preventDefault(); setPalette(true); }
|
|
107
|
+
};
|
|
108
|
+
window.addEventListener('keydown', on); return () => window.removeEventListener('keydown', on);
|
|
109
|
+
}, []);
|
|
110
|
+
// the page's title says where you are, as the tab strip shows it
|
|
111
|
+
useEffect(() => { document.title = at.kind === 'org' ? `${ORG_PAGES.find(([k]) => k === at.page)?.[1] ?? ''} · ${at.org} · Volter World` : at.kind === 'you' ? `${YOU_TITLES[at.page]} · Volter World` : 'Volter World'; }, [at]);
|
|
112
|
+
|
|
113
|
+
const p = info.state === 'ok' ? info.data : null;
|
|
114
|
+
if (me.state === 'loading' || info.state === 'loading') return <Booting />;
|
|
115
|
+
if (me.state === 'out') return <SignIn info={p} />;
|
|
116
|
+
if (me.state === 'error') return <main className="boot"><Brand href="/" /><div role="alert" className="error-state"><span>{me.error}</span><button type="button" onClick={() => void loadMe()}>Try again</button></div><button type="button" onClick={() => void signOutNow()}>Sign in again</button></main>;
|
|
117
|
+
const { person, orgs } = me;
|
|
118
|
+
const current = at.kind === 'org' ? orgs.find((o) => o.slug === at.org || o.id === at.org) ?? null : null;
|
|
119
|
+
// home is your first org, or making one; an org named by its id (a mail's link) is shown by its slug
|
|
120
|
+
if (at.kind === 'home') { const first = orgs[0]; go(first ? { kind: 'org', org: slugOf(first), page: 'worlds' } : { kind: 'you', page: 'new' }, true); return <Booting />; }
|
|
121
|
+
if (at.kind === 'org' && current && at.org !== slugOf(current)) { go({ ...at, org: slugOf(current) }, true); return <Booting />; }
|
|
122
|
+
// the org the sidebar shows: the one you are in, else your first
|
|
123
|
+
const shown = current ?? (at.kind === 'you' ? orgs[0] ?? null : null);
|
|
124
|
+
const admin = shown ? isAdmin(shown) : false;
|
|
125
|
+
const rows = worlds.state === 'ok' && shown ? worlds.data.worlds.filter((w) => w.name.startsWith(`${slugOf(shown)}/`)) : [];
|
|
126
|
+
const title = at.kind === 'you' ? YOU_TITLES[at.page] : at.kind === 'org' ? ORG_PAGES.find(([k]) => k === at.page)![1] : '';
|
|
127
|
+
const orgPage = (page: OrgPage): Place => ({ kind: 'org', org: shown ? slugOf(shown) : '', page });
|
|
128
|
+
const on = (page: OrgPage): boolean => at.kind === 'org' && at.page === page;
|
|
129
|
+
const signOut = (): void => { try { sessionStorage.removeItem(SUPPORT_KEY); } catch { /* fine */ } if (SUPPORT) window.location.assign('/'); else void signOutNow(); };
|
|
130
|
+
const pages = ORG_PAGES.filter(([k]) => (k !== 'billing' || p?.billing) && (k !== 'activity' || admin));
|
|
131
|
+
return (
|
|
132
|
+
<main className="app">
|
|
133
|
+
{person.support ? <p role="alert" className="banner">Viewing as support: read only, until {when(person.support.until)}. Reason on record: {person.support.reason}.</p> : null}
|
|
134
|
+
<header>
|
|
135
|
+
<DrawerButton />
|
|
136
|
+
<Brand href="/" onClick={(e) => { e.preventDefault(); go({ kind: 'home' }); }} />
|
|
137
|
+
<nav aria-label="breadcrumb" className="crumbs">
|
|
138
|
+
{shown && at.kind === 'org' ? <><span className="crumb-sep" aria-hidden="true">/</span><Go to={orgPage('worlds')} className="crumb-world">{shown.name}</Go>{at.page !== 'worlds' ? <><span className="crumb-sep" aria-hidden="true">/</span><span>{title}</span></> : null}</> : <><span className="crumb-sep" aria-hidden="true">/</span><span>{title}</span></>}
|
|
139
|
+
</nav>
|
|
140
|
+
<button type="button" className="search-button" onClick={() => setPalette(true)} aria-label="Search: jump to an org, a page or a World"><span>Search…</span><kbd>{MOD_K}</kbd></button>
|
|
141
|
+
{p?.site ? <a href={`${p.site}/docs/`} className="head-link">Docs</a> : null}
|
|
142
|
+
<details className="menu avatar-menu">
|
|
143
|
+
<summary role="button" aria-haspopup="menu" aria-label={`You: ${person.email ?? person.name ?? person.id}`}><Initial of={person.name ?? person.email ?? '?'} small /></summary>
|
|
144
|
+
<div className="popover menu-list" role="menu">
|
|
145
|
+
<div className="menu-who"><strong>{person.name ?? person.email ?? person.id}</strong>{person.name && person.email ? <span className="mode">{person.email}</span> : null}</div>
|
|
146
|
+
<Go to={{ kind: 'you', page: 'account' }}>Account and tokens</Go>
|
|
147
|
+
{p?.support ? <Go to={{ kind: 'you', page: 'help' }}>Help</Go> : null}
|
|
148
|
+
{p?.site ? <a role="menuitem" href={`${p.site}/docs/`}>Docs</a> : null}
|
|
149
|
+
<div className="menu-theme"><span className="mode">Theme</span><ThemeChoice /></div>
|
|
150
|
+
<button type="button" role="menuitem" onClick={signOut}>Sign out</button>
|
|
151
|
+
</div>
|
|
152
|
+
</details>
|
|
153
|
+
</header>
|
|
154
|
+
<div className="shell">
|
|
155
|
+
<aside className="side">
|
|
156
|
+
<nav aria-label="Your orgs and pages" className="side-nav">
|
|
157
|
+
<OrgSwitcher orgs={orgs} shown={shown} />
|
|
158
|
+
{shown ? <>
|
|
159
|
+
<p className="side-group">Org</p>
|
|
160
|
+
{pages.map(([k, label]) => <Go key={k} to={orgPage(k)} className={on(k) ? 'on' : undefined} current={on(k)}>{label}</Go>)}
|
|
161
|
+
</> : null}
|
|
162
|
+
<p className="side-group">You</p>
|
|
163
|
+
<Go to={{ kind: 'you', page: 'account' }} className={at.kind === 'you' && at.page === 'account' ? 'on' : undefined} current={at.kind === 'you' && at.page === 'account'}>Account</Go>
|
|
164
|
+
{p?.support ? <Go to={{ kind: 'you', page: 'help' }} className={at.kind === 'you' && at.page === 'help' ? 'on' : undefined} current={at.kind === 'you' && at.page === 'help'}>Help</Go> : null}
|
|
165
|
+
</nav>
|
|
166
|
+
</aside>
|
|
167
|
+
<div className="content">
|
|
168
|
+
{at.kind === 'org' && !current ? <Empty title={`You are not a member of ${at.org}`} action={<Go to={{ kind: 'home' }} className="button">Your orgs</Go>}>An org shows here once one of its admins adds you, or you accept an invitation to it.</Empty> : null}
|
|
169
|
+
{current?.held && at.kind === 'org' && at.page !== 'billing' ? <p role="alert">This org is held: {current.held}.</p> : null}
|
|
170
|
+
{at.kind === 'org' && current && at.page === 'worlds' ? <WorldsPage org={current} rows={rows} load={worlds} orgs={orgs.map(slugOf)} allRows={worlds.state === 'ok' ? worlds.data.worlds.length : 0} version={version} readOnly={Boolean(person.support) || Boolean(current.held)} onChanged={bump} /> : null}
|
|
171
|
+
{at.kind === 'org' && current && at.page === 'members' ? <MembersPage org={current} me={person.id} onChanged={bump} /> : null}
|
|
172
|
+
{at.kind === 'org' && current && at.page === 'billing' ? (p?.billing ? <BillingPage org={slugOf(current)} admin={isAdmin(current)} version={version} flags={current.flags ?? {}} /> : <Empty title="No billing here">This platform does not bill: nothing here is limited or priced.</Empty>) : null}
|
|
173
|
+
{at.kind === 'org' && current && at.page === 'settings' ? <SettingsPage org={current} sub={at.sub ?? 'general'} info={p} version={version} onChanged={bump} onDeleted={() => { bump(); go({ kind: 'home' }); }} /> : null}
|
|
174
|
+
{at.kind === 'org' && current && at.page === 'activity' ? (isAdmin(current) ? <ActivityPage org={slugOf(current)} version={version} /> : <Empty title="Activity is for admins">An org's activity log is its admins'.</Empty>) : null}
|
|
175
|
+
{at.kind === 'you' && at.page === 'account' ? <AccountPage person={person} info={p} onSignOut={signOut} /> : null}
|
|
176
|
+
{at.kind === 'you' && at.page === 'help' ? (p?.support ? <HelpPage orgs={orgs.map(slugOf)} onChanged={bump} /> : <Empty title="No Help form here">This platform has no Help form: ask whoever runs it.</Empty>) : null}
|
|
177
|
+
{at.kind === 'you' && at.page === 'new' ? <CreateOrg first={orgs.length === 0} site={p?.site} onMade={async (slug) => { await loadMe(); bump(); go({ kind: 'org', org: slug, page: 'worlds' }); }} /> : null}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
<Palette open={palette} onClose={() => setPalette(false)} orgs={orgs} shown={shown} worlds={worlds.state === 'ok' ? worlds.data.worlds : []} pages={pages} support={Boolean(p?.support)} />
|
|
181
|
+
</main>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The app's own first paint: the shell's shape while who-you-are is asked. */
|
|
186
|
+
function Booting(): React.JSX.Element {
|
|
187
|
+
return <main className="app booting" aria-busy="true"><header><Brand href="/" /></header><div className="shell"><aside className="side"><Skeleton lines={6} label="Opening…" /></aside><div className="content"><Skeleton lines={4} label="Opening…" /></div></div></main>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The org switcher: the orgs you are in, searchable once there are several, and a new one. */
|
|
191
|
+
function OrgSwitcher({ orgs, shown }: { orgs: OrgRow[]; shown: OrgRow | null }): React.JSX.Element {
|
|
192
|
+
const [q, setQ] = useState('');
|
|
193
|
+
const list = orgs.filter((o) => `${o.name} ${o.slug ?? ''}`.toLowerCase().includes(q.trim().toLowerCase()));
|
|
194
|
+
return (
|
|
195
|
+
<details className="menu branch-picker top-picker">
|
|
196
|
+
<summary><Initial of={shown?.name ?? '+'} small /><span className="branch-name">{shown ? shown.name : 'No org yet'}</span>{shown ? <span className="badge">{isAdmin(shown) ? 'admin' : 'member'}</span> : null}</summary>
|
|
197
|
+
<div className="popover">
|
|
198
|
+
{orgs.length > 4 ? <input type="search" aria-label="Find an org" placeholder="Find an org…" value={q} onChange={(e) => setQ(e.target.value)} /> : <p className="mode">Your orgs</p>}
|
|
199
|
+
<ul className="branch-list">
|
|
200
|
+
{list.map((o) => <li key={o.id}><Go to={{ kind: 'org', org: slugOf(o), page: 'worlds' }} current={shown?.id === o.id}><span className="vendor-cell"><Initial of={o.name} small />{o.name}</span><span className="mode">{isAdmin(o) ? 'admin' : 'member'}</span></Go></li>)}
|
|
201
|
+
{list.length === 0 ? <li className="mode">No org matches.</li> : null}
|
|
202
|
+
</ul>
|
|
203
|
+
<Go to={{ kind: 'you', page: 'new' }} className="button">+ Create an org</Go>
|
|
204
|
+
</div>
|
|
205
|
+
</details>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The search shortcut as this machine's keyboard writes it: ⌘K on a Mac, Ctrl K elsewhere (either key opens it). */
|
|
210
|
+
const MOD_K = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘K' : 'Ctrl K';
|
|
211
|
+
/** ⌘K: jump anywhere by typing — an org, one of its pages, a World (opened as yourself), your account. */
|
|
212
|
+
function Palette({ open, onClose, orgs, shown, worlds, pages, support }: { open: boolean; onClose: () => void; orgs: OrgRow[]; shown: OrgRow | null; worlds: WorldRow[]; pages: ReadonlyArray<readonly [OrgPage, string]>; support: boolean }): React.JSX.Element {
|
|
213
|
+
const [q, setQ] = useState(''); const [i, setI] = useState(0);
|
|
214
|
+
useEffect(() => { if (open) { setQ(''); setI(0); } }, [open]);
|
|
215
|
+
type Item = { label: string; hint: string; run: () => void };
|
|
216
|
+
const items: Item[] = useMemo(() => {
|
|
217
|
+
const all: Item[] = [];
|
|
218
|
+
if (shown) for (const [k, label] of pages) all.push({ label, hint: shown.name, run: () => go({ kind: 'org', org: slugOf(shown), page: k }) });
|
|
219
|
+
for (const w of worlds) all.push({ label: `Open ${w.name}`, hint: 'World', run: () => { window.location.assign(`/-/worlds/${w.name}/open`); } });
|
|
220
|
+
for (const o of orgs) if (o.id !== shown?.id) all.push({ label: `Switch to ${o.name}`, hint: 'org', run: () => go({ kind: 'org', org: slugOf(o), page: 'worlds' }) });
|
|
221
|
+
all.push({ label: 'Account and tokens', hint: 'you', run: () => go({ kind: 'you', page: 'account' }) });
|
|
222
|
+
if (support) all.push({ label: 'Help', hint: 'you', run: () => go({ kind: 'you', page: 'help' }) });
|
|
223
|
+
all.push({ label: 'Create an org', hint: 'new', run: () => go({ kind: 'you', page: 'new' }) });
|
|
224
|
+
const t = q.trim().toLowerCase();
|
|
225
|
+
return t ? all.filter((x) => `${x.label} ${x.hint}`.toLowerCase().includes(t)) : all;
|
|
226
|
+
}, [q, orgs, shown, worlds, pages, support]);
|
|
227
|
+
const pick = (x: Item | undefined): void => { if (!x) return; onClose(); x.run(); };
|
|
228
|
+
return (
|
|
229
|
+
<Dialog open={open} onClose={onClose} title="Jump to">
|
|
230
|
+
<input autoFocus type="search" aria-label="Jump to" placeholder="An org, a page or a World…" value={q} onChange={(e) => { setQ(e.target.value); setI(0); }} onKeyDown={(e) => { if (e.key === 'ArrowDown') { e.preventDefault(); setI((n) => Math.min(items.length - 1, n + 1)); } if (e.key === 'ArrowUp') { e.preventDefault(); setI((n) => Math.max(0, n - 1)); } if (e.key === 'Enter') { e.preventDefault(); pick(items[i]); } }} />
|
|
231
|
+
<ul className="palette" role="listbox" aria-label="Places">
|
|
232
|
+
{items.slice(0, 12).map((x, n) => <li key={`${x.label}-${x.hint}`} role="option" aria-selected={n === i}><button type="button" onMouseEnter={() => setI(n)} onClick={() => pick(x)}><span>{x.label}</span><span className="mode">{x.hint}</span></button></li>)}
|
|
233
|
+
{items.length === 0 ? <li className="mode">Nothing matches.</li> : null}
|
|
234
|
+
</ul>
|
|
235
|
+
</Dialog>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Signed out: one card, one way in — the platform's provider. The pitch is the site's, not this page's. */
|
|
240
|
+
function SignIn({ info }: { info: PlatformInfo | null }): React.JSX.Element {
|
|
241
|
+
const next = `${window.location.pathname}${window.location.search}`;
|
|
242
|
+
const signedOut = new URLSearchParams(window.location.search).has('signed-out');
|
|
243
|
+
const provider = info?.provider.name ?? 'your account';
|
|
244
|
+
return (
|
|
245
|
+
<div className="card" role="main">
|
|
246
|
+
<div className="brand"><Brand href="/" /></div>
|
|
247
|
+
<h2>Sign in to Volter World</h2>
|
|
248
|
+
{signedOut ? <p role="status">You are signed out.</p> : <p>Your orgs, their Worlds and your teammates.</p>}
|
|
249
|
+
<a className="button primary wide" href={`/-/sign-in${next !== '/' && !signedOut ? `?next=${encodeURIComponent(next)}` : ''}`}>Continue with {provider}</a>
|
|
250
|
+
{info?.provider.kind === 'volter' ? <p className="small">New to Volter? You make your account on the way, with GitHub, Google or your email.</p> : null}
|
|
251
|
+
{info?.site ? <p className="links"><a href={`${info.site}/`}>Volter World</a><a href={`${info.site}/docs/`}>Docs</a><a href={`${info.site}/terms.html`}>Terms</a><a href={`${info.site}/privacy.html`}>Privacy</a></p> : null}
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A page's head: its title, what it holds, and its actions on the right. */
|
|
257
|
+
function Head({ title, count, children, lede }: { title: string; count?: number; children?: React.ReactNode; lede?: React.ReactNode }): React.JSX.Element {
|
|
258
|
+
return (
|
|
259
|
+
<div className="page-head">
|
|
260
|
+
<div className="content-head"><h2 id="page">{title}{count !== undefined ? <span className="count">{count}</span> : null}</h2>{children ? <div className="head-actions">{children}</div> : null}</div>
|
|
261
|
+
{lede ? <p className="lede">{lede}</p> : null}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Worlds ───────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
267
|
+
/** An org's Worlds, as Neon lists projects: each opens as yourself on its own page; a new one from the head's button. */
|
|
268
|
+
function WorldsPage({ org, rows, load, orgs, allRows, version, readOnly, onChanged }: { org: OrgRow; rows: WorldRow[]; load: Load<unknown>; orgs: string[]; allRows: number; version: number; readOnly: boolean; onChanged: () => void }): React.JSX.Element {
|
|
269
|
+
const slug = slugOf(org);
|
|
270
|
+
const [making, setMaking] = useState(false); const [q, setQ] = useState(''); const [keyFor, setKeyFor] = useState<string | null>(null);
|
|
271
|
+
// a read-only link: a read key (the platform's key door, 30 days) carried in the World's dashboard address
|
|
272
|
+
const [link, setLink] = useState<{ world: string; url: string } | null>(null);
|
|
273
|
+
const shareLink = async (w: WorldRow): Promise<void> => {
|
|
274
|
+
const label = `Read-only link · ${new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;
|
|
275
|
+
const made = await act<{ key: string }>(`/-/worlds/${w.name}/keys`, { method: 'POST', body: JSON.stringify({ name: label, scope: 'read', expiresInDays: 30 }) });
|
|
276
|
+
if (!made?.key) return;
|
|
277
|
+
const at = w.origin ?? new URL(w.base).origin;
|
|
278
|
+
setLink({ world: w.name, url: `${at}/-/console/${w.name}#${new URLSearchParams({ token: made.key }).toString()}` });
|
|
279
|
+
};
|
|
280
|
+
const record = new Map((org.worlds ?? []).map((w) => [w.name, w]));
|
|
281
|
+
const shown = rows.filter((w) => `${w.name} ${w.twins.map((t) => t.vendor).join(' ')}`.toLowerCase().includes(q.trim().toLowerCase()));
|
|
282
|
+
const remove = async (name: string): Promise<void> => {
|
|
283
|
+
const ok = await confirmAct({ title: `Delete ${name}?`, body: <>Its host stops serving it and its whole history goes with it. Apps and keys that use it stop working at once. This cannot be undone.</>, action: 'Delete World', danger: true, typed: name.split('/')[1] ?? name });
|
|
284
|
+
if (ok && await act(`/-/worlds/${name}`, { method: 'DELETE' }, `Deleted ${name}`)) onChanged();
|
|
285
|
+
};
|
|
286
|
+
const make = !readOnly ? <button type="button" className="primary" onClick={() => setMaking(true)}>Create World</button> : null;
|
|
287
|
+
// an org with no Worlds may take a sample one, made and seeded by the platform (apps/platform/src/sample.ts)
|
|
288
|
+
const [sampling, setSampling] = useState(false);
|
|
289
|
+
const trySample = async (): Promise<void> => { setSampling(true); const made = await act<{ name: string }>('/-/worlds/sample', { method: 'POST', body: JSON.stringify({ org: slug }) }); setSampling(false); if (made) { toast(`Made ${made.name}: open it to see Stripe and Slack as an app left them`); onChanged(); } };
|
|
290
|
+
return (
|
|
291
|
+
<>
|
|
292
|
+
<Head title="Worlds" count={load.state === 'ok' ? rows.length : undefined}>{rows.length > 0 ? <input type="search" className="toolbar-search" aria-label="Find a World" placeholder="Find a World…" value={q} onChange={(e) => setQ(e.target.value)} /> : null}{make}</Head>
|
|
293
|
+
<GettingStarted orgs={orgs} worlds={allRows - rows.filter((r) => r.name.split('/')[1] === 'sample').length} firstWorld={rows.find((r) => r.name.split('/')[1] !== 'sample')?.name} version={version} />
|
|
294
|
+
{load.state !== 'ok' ? <Pending load={load} what="the org's Worlds" retry={onChanged} /> : rows.length === 0 ? (
|
|
295
|
+
<Empty title="No Worlds yet" action={<div className="actions-row">{make}{!readOnly ? <button type="button" onClick={() => void trySample()} disabled={sampling}>{sampling ? 'Making the sample…' : 'Try a sample World'}</button> : null}</div>}>A World stands in for the services your app uses: your app talks to it as it would to Stripe or GitHub, nothing real happens, and you see everything it did on each vendor's own screens.{!readOnly ? <span className="mode sample-note">The sample is a small shop's Stripe and Slack, seeded as its app would leave them. It counts as one of the org's Worlds; delete it when you are done.</span> : null}</Empty>
|
|
296
|
+
) : (
|
|
297
|
+
<div className="block">
|
|
298
|
+
<table className="dense worlds-table" aria-label={`${slug}'s Worlds`}>
|
|
299
|
+
<thead><tr><th>World</th><th>Stand-ins for</th><th>Host</th><th>Created</th><th className="num"><span className="sr">Actions</span></th></tr></thead>
|
|
300
|
+
<tbody>{shown.map((w) => { const rec = record.get(w.name); const short = w.name.split('/')[1] ?? w.name; return (<React.Fragment key={w.name}>
|
|
301
|
+
<tr>
|
|
302
|
+
<td><span className="vendor-cell"><Initial of={short} small /><span className="who"><a href={`/-/worlds/${w.name}/open`}>{short}</a>{w.origin ? <span className="mode mono">{new URL(w.origin).host}</span> : null}</span></span></td>
|
|
303
|
+
<td><span className="marks" title={w.twins.map((t) => vendorName(t.vendor)).join(', ')}>{w.twins.slice(0, 5).map((t) => <Mark key={t.vendor} vendor={t.vendor} />)}{w.twins.length > 5 ? <span className="mode">+{w.twins.length - 5}</span> : null}<span className="mode">{w.twins.length === 1 ? vendorName(w.twins[0]!.vendor) : `${w.twins.length} vendors`}</span></span></td>
|
|
304
|
+
<td className="mode">{rec?.host ?? '—'}</td>
|
|
305
|
+
<td className="mode">{rec ? <span title={when(rec.provisionedAt)}>{ago(rec.provisionedAt)}</span> : '—'}</td>
|
|
306
|
+
<td className="num row-actions"><a className="button primary" href={`/-/worlds/${w.name}/open`} aria-label={`Open ${w.name}`}>Open</a>
|
|
307
|
+
<Menu label={`More for ${w.name}`} items={[
|
|
308
|
+
{ label: 'Copy the command to use it', onSelect: () => { void navigator.clipboard?.writeText(`volter remote add origin ${w.name}`).then(() => toast('Copied: volter remote add origin …')); } },
|
|
309
|
+
!readOnly ? { label: 'Make a key…', onSelect: () => setKeyFor(w.name) } : null,
|
|
310
|
+
!readOnly ? { label: 'Share a read-only link…', onSelect: () => void shareLink(w) } : null,
|
|
311
|
+
w.origin ? { label: 'Copy its address', onSelect: () => { void navigator.clipboard?.writeText(w.origin!).then(() => toast('Copied')); } } : null,
|
|
312
|
+
!readOnly ? { label: 'Delete World…', danger: true, onSelect: () => void remove(w.name) } : null,
|
|
313
|
+
]} />
|
|
314
|
+
</td>
|
|
315
|
+
</tr>
|
|
316
|
+
<PreviewRows world={w.name} readOnly={readOnly} />
|
|
317
|
+
</React.Fragment>); })}</tbody>
|
|
318
|
+
</table>
|
|
319
|
+
{shown.length === 0 ? <p className="mode table-note">No World matches “{q}”.</p> : null}
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
<WorldKey world={keyFor} onClose={() => setKeyFor(null)} />
|
|
323
|
+
<Dialog open={Boolean(link)} onClose={() => setLink(null)} title={`A read-only link to ${link?.world ?? ''}`} footer={<button type="button" className="primary" onClick={() => setLink(null)}>Done</button>}>
|
|
324
|
+
<p className="mode">Whoever opens it sees the World's pages and changes nothing, without an account, for 30 days. Copy it now: it is shown this once. The World lists it under Settings › Keys, where revoking it closes the link at once.</p>
|
|
325
|
+
{link ? <CopyField label="Link" value={link.url} secret /> : null}
|
|
326
|
+
</Dialog>
|
|
327
|
+
<CreateWorld open={making} org={slug} taken={rows.map((w) => w.name.split('/')[1] ?? '')} onClose={() => setMaking(false)} onMade={(name) => { setMaking(false); toast(`Created ${name}. Open it when you are ready`); onChanged(); }} />
|
|
328
|
+
</>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A new World: its name (checked as you type) and the vendors it stands in for, picked from the catalog. */
|
|
333
|
+
function CreateWorld({ open, org, taken, onClose, onMade }: { open: boolean; org: string; taken: string[]; onClose: () => void; onMade: (name: string) => void }): React.JSX.Element {
|
|
334
|
+
const [name, setName] = useState(''); const [picked, setPicked] = useState<string[]>([]); const [q, setQ] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
|
|
335
|
+
useEffect(() => { if (open) { setName(''); setPicked([]); setQ(''); setError(null); } }, [open]);
|
|
336
|
+
const nameIssue = !name ? null : !NAME.test(name) ? 'Lowercase letters, digits and dashes, starting with a letter or digit.' : taken.includes(name) ? `${org} already has a World named ${name}.` : null;
|
|
337
|
+
// what the host Worlds are made on actually serves; the catalog's marks (supported or preview) where it names them
|
|
338
|
+
const served = useDoor<{ vendors: string[] }>(open ? '/-/vendors' : null);
|
|
339
|
+
const marks = new Map(CATALOG.map((c) => [c.vendor, c.supported]));
|
|
340
|
+
const catalog = served.state === 'ok' ? served.data.vendors.map((vendor) => ({ vendor, supported: marks.get(vendor) ?? true })).sort((a, b) => Number(b.supported) - Number(a.supported) || a.vendor.localeCompare(b.vendor))
|
|
341
|
+
: CATALOG.length ? CATALOG : ['github', 'stripe', 'slack', 'openai', 'anthropic', 'aws', 'resend', 'twilio', 'linear', 'notion'].map((vendor) => ({ vendor, supported: true }));
|
|
342
|
+
const term = q.trim().toLowerCase();
|
|
343
|
+
const matches = catalog.filter((c) => !term || c.vendor.includes(term) || vendorName(c.vendor).toLowerCase().includes(term));
|
|
344
|
+
const toggle = (v: string): void => setPicked((l) => (l.includes(v) ? l.filter((x) => x !== v) : [...l, v]));
|
|
345
|
+
const submit = async (e: React.FormEvent): Promise<void> => {
|
|
346
|
+
e.preventDefault(); if (nameIssue || !name || picked.length === 0) return; setBusy(true); setError(null);
|
|
347
|
+
const r = await door('/-/worlds', { method: 'POST', body: JSON.stringify({ org, world: name, vendors: picked }) }).catch(() => null);
|
|
348
|
+
setBusy(false);
|
|
349
|
+
if (!r) { setError(`${ORIGIN} did not answer`); return; }
|
|
350
|
+
if (!r.ok) { setError(await failure(r)); return; }
|
|
351
|
+
onMade(`${org}/${name}`);
|
|
352
|
+
};
|
|
353
|
+
return (
|
|
354
|
+
<Dialog open={open} onClose={() => { if (!busy) onClose(); }} title="Create a World" wide footer={<><button type="button" onClick={onClose} disabled={busy}>Cancel</button><button type="submit" form="create-world" disabled={busy || !name || Boolean(nameIssue) || picked.length === 0}>{busy ? 'Creating… about ten seconds' : 'Create World'}</button></>}>
|
|
355
|
+
<form id="create-world" onSubmit={submit} className="stack" aria-label="Create a World">
|
|
356
|
+
<label>Name <input autoFocus name="world" value={name} onChange={(e) => setName(e.target.value.toLowerCase())} placeholder="staging" aria-invalid={Boolean(nameIssue)} aria-describedby="world-name-help" autoComplete="off" spellCheck={false} /></label>
|
|
357
|
+
<p id="world-name-help" className={nameIssue ? 'field-error' : 'mode'}>{nameIssue ?? <>Its address: <code>{org}/{name || 'staging'}</code></>}</p>
|
|
358
|
+
<fieldset className="vendor-picker">
|
|
359
|
+
<legend>Stands in for {picked.length ? <span className="mode">· {picked.length} picked</span> : null}</legend>
|
|
360
|
+
{picked.length ? <div className="chips">{picked.map((v) => <button key={v} type="button" className="chip" onClick={() => toggle(v)} aria-label={`Remove ${vendorName(v)}`}><Mark vendor={v} />{vendorName(v)} <span aria-hidden="true">✕</span></button>)}</div> : null}
|
|
361
|
+
<input type="search" aria-label="Find a vendor" placeholder="Find a vendor: Stripe, Slack, GitHub…" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); const first = matches[0]?.vendor ?? (NAME.test(term) ? term : ''); if (first) { toggle(first); setQ(''); } } }} />
|
|
362
|
+
<div className="vendor-grid" role="group" aria-label="Vendors">
|
|
363
|
+
{matches.slice(0, 48).map((c) => <button key={c.vendor} type="button" className="vendor-option" aria-pressed={picked.includes(c.vendor)} onClick={() => toggle(c.vendor)}><Mark vendor={c.vendor} /><span className="vendor-label">{vendorName(c.vendor)}</span>{!c.supported ? <span className="mode">preview</span> : null}</button>)}
|
|
364
|
+
</div>
|
|
365
|
+
{matches.length === 0 ? <p className="mode">No twin named “{q}” in the catalog.</p> : matches.length > 48 ? <p className="mode">{matches.length - 48} more: type to narrow.</p> : null}
|
|
366
|
+
</fieldset>
|
|
367
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
368
|
+
</form>
|
|
369
|
+
</Dialog>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Members ──────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
374
|
+
type MemberRow = { id: string; userId: string; email: string | null; name: string | null; role: string };
|
|
375
|
+
type PendingRow = { id: string; email: string; role: string; createdAt: string | null; expiresAt: string | null };
|
|
376
|
+
/** The org's people, as GitHub's and Vercel's team pages list them: who, their role (an admin changes it in place), the
|
|
377
|
+
* pending invitations with their expiry, and invitations sent from a dialog, several at once, each with a role. */
|
|
378
|
+
function MembersPage({ org, me, onChanged }: { org: OrgRow; me: string; onChanged: () => void }): React.JSX.Element {
|
|
379
|
+
const slug = slugOf(org); const admin = isAdmin(org); const canInvite = admin || Boolean(org.security?.membersCanInvite);
|
|
380
|
+
const [version, setVersion] = useState(0); const reload = (): void => { setVersion((v) => v + 1); onChanged(); };
|
|
381
|
+
const load = useDoor<{ members: MemberRow[]; pending?: PendingRow[] }>(`/-/orgs/${slug}/members`, version);
|
|
382
|
+
const [inviting, setInviting] = useState(false); const [q, setQ] = useState('');
|
|
383
|
+
if (load.state !== 'ok') return <><Head title="Members" /><Pending load={load} what="the members" retry={reload} /></>;
|
|
384
|
+
const { members, pending = [] } = load.data;
|
|
385
|
+
const admins = members.filter((m) => m.role.endsWith('admin')).length;
|
|
386
|
+
const shown = members.filter((m) => `${m.name ?? ''} ${m.email ?? ''}`.toLowerCase().includes(q.trim().toLowerCase()));
|
|
387
|
+
const setRole = async (m: MemberRow, role: 'admin' | 'member'): Promise<void> => { if (await act(`/-/orgs/${slug}/members/${m.userId}`, { method: 'PATCH', body: JSON.stringify({ role }) }, `${m.name ?? m.email ?? 'They'} ${role === 'admin' ? 'is now an admin' : 'is now a member'}`)) reload(); };
|
|
388
|
+
const remove = async (m: MemberRow): Promise<void> => {
|
|
389
|
+
const self = m.userId === me; const who = m.name ?? m.email ?? m.userId;
|
|
390
|
+
const ok = await confirmAct(self ? { title: `Leave ${org.name}?`, body: 'You lose its Worlds at once. An admin can add you again.', action: 'Leave org', danger: true } : { title: `Remove ${who}?`, body: <>They lose {org.name} and every World in it at once. Keys they made in a World stay until revoked there.</>, action: 'Remove member', danger: true });
|
|
391
|
+
if (ok && await act(`/-/orgs/${slug}/members/${m.userId}`, { method: 'DELETE' }, self ? `You left ${org.name}` : `Removed ${who}`)) { if (self) window.location.assign('/'); else reload(); }
|
|
392
|
+
};
|
|
393
|
+
const revoke = async (i: PendingRow): Promise<void> => { if (await act(`/-/orgs/${slug}/invitations/${i.id}`, { method: 'DELETE' }, `Invitation to ${i.email} revoked`)) reload(); };
|
|
394
|
+
const resend = async (i: PendingRow): Promise<void> => {
|
|
395
|
+
// a fresh invitation (a new link and a new week) to the same address and role replaces the old one, in one act
|
|
396
|
+
if (await act(`/-/orgs/${slug}/invitations/${i.id}/resend`, { method: 'POST' }, `Sent ${i.email} a new invitation`)) reload();
|
|
397
|
+
};
|
|
398
|
+
return (
|
|
399
|
+
<>
|
|
400
|
+
<Head title="Members" count={members.length} lede="People in this org open its Worlds as themselves. Admins manage members, billing and settings.">
|
|
401
|
+
{members.length > 6 ? <input type="search" className="toolbar-search" aria-label="Find a member" placeholder="Find a member…" value={q} onChange={(e) => setQ(e.target.value)} /> : null}
|
|
402
|
+
{canInvite ? <button type="button" className="primary" onClick={() => setInviting(true)}>Invite people</button> : null}
|
|
403
|
+
</Head>
|
|
404
|
+
<div className="block">
|
|
405
|
+
<table className="dense" aria-label={`Members of ${slug}`}>
|
|
406
|
+
<thead><tr><th>Member</th><th>Role</th><th className="num"><span className="sr">Actions</span></th></tr></thead>
|
|
407
|
+
<tbody>{shown.map((m) => {
|
|
408
|
+
const who = m.email ?? m.userId; const isAdminRole = m.role.endsWith('admin'); const self = m.userId === me; const lastAdmin = isAdminRole && admins === 1;
|
|
409
|
+
return (
|
|
410
|
+
<tr key={m.id}>
|
|
411
|
+
<td><span className="vendor-cell"><Initial of={m.name ?? who} small /><span className="who"><span>{m.name ?? who}{self ? <span className="badge you">you</span> : null}</span>{m.name ? <span className="mode">{who}</span> : null}</span></span></td>
|
|
412
|
+
<td>{admin ? <select aria-label={`Role of ${who}`} value={isAdminRole ? 'admin' : 'member'} disabled={lastAdmin} title={lastAdmin ? 'The only admin: make someone else an admin first' : undefined} onChange={(e) => void setRole(m, e.target.value === 'admin' ? 'admin' : 'member')}><option value="admin">Admin</option><option value="member">Member</option></select> : <span className="badge">{isAdminRole ? 'admin' : 'member'}</span>}</td>
|
|
413
|
+
<td className="num">{admin || self ? <Menu label={`More for ${who}`} items={[
|
|
414
|
+
self ? { label: 'Leave org…', danger: true, disabled: lastAdmin, onSelect: () => void remove(m) } : null,
|
|
415
|
+
admin && !self ? { label: 'Remove from org…', danger: true, disabled: lastAdmin, onSelect: () => void remove(m) } : null,
|
|
416
|
+
]} /> : null}</td>
|
|
417
|
+
</tr>
|
|
418
|
+
);
|
|
419
|
+
})}</tbody>
|
|
420
|
+
</table>
|
|
421
|
+
{admins === 1 && admin ? <p className="mode table-note">An org keeps at least one admin: to leave it or step down, make someone else an admin first.</p> : null}
|
|
422
|
+
</div>
|
|
423
|
+
{pending.length > 0 ? (
|
|
424
|
+
<div className="block">
|
|
425
|
+
<div className="block-head"><h3>Pending invitations <span className="count">{pending.length}</span></h3></div>
|
|
426
|
+
<table className="dense pending-table" aria-label="Pending invitations">
|
|
427
|
+
<thead><tr><th>Email</th><th>Role</th><th>Invited</th><th>Expires</th><th className="num"><span className="sr">Actions</span></th></tr></thead>
|
|
428
|
+
<tbody>{pending.map((i) => { const expired = i.expiresAt ? Date.parse(i.expiresAt) <= Date.now() : false; return (
|
|
429
|
+
<tr key={i.id}>
|
|
430
|
+
<td>{i.email}</td>
|
|
431
|
+
<td><span className="badge">{i.role.endsWith('admin') ? 'admin' : 'member'}</span></td>
|
|
432
|
+
<td className="mode">{i.createdAt ? <span title={when(i.createdAt)}>{ago(i.createdAt)}</span> : '—'}</td>
|
|
433
|
+
<td className="mode">{expired ? <span className="badge warn">expired</span> : i.expiresAt ? day(i.expiresAt) : '—'}</td>
|
|
434
|
+
<td className="num">{admin ? <Menu label={`More for the invitation to ${i.email}`} items={[{ label: 'Resend invitation', onSelect: () => void resend(i) }, { label: 'Revoke invitation', danger: true, onSelect: () => void revoke(i) }]} /> : null}</td>
|
|
435
|
+
</tr>
|
|
436
|
+
); })}</tbody>
|
|
437
|
+
</table>
|
|
438
|
+
</div>
|
|
439
|
+
) : null}
|
|
440
|
+
<InviteDialog open={inviting} org={org} onClose={() => setInviting(false)} onSent={() => { setInviting(false); reload(); }} />
|
|
441
|
+
</>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Invite people: an address and a role per row, more rows for more people, sent together (Vercel's "Add more"). */
|
|
446
|
+
function InviteDialog({ open, org, onClose, onSent }: { open: boolean; org: OrgRow; onClose: () => void; onSent: () => void }): React.JSX.Element {
|
|
447
|
+
const slug = slugOf(org); const admin = isAdmin(org);
|
|
448
|
+
const [rows, setRows] = useState<Array<{ email: string; role: 'member' | 'admin' }>>([{ email: '', role: 'member' }]); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
|
|
449
|
+
useEffect(() => { if (open) { setRows([{ email: '', role: 'member' }]); setError(null); } }, [open]);
|
|
450
|
+
const valid = rows.filter((r) => r.email.trim()).every((r) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(r.email.trim())) && rows.some((r) => r.email.trim());
|
|
451
|
+
const send = async (e: React.FormEvent): Promise<void> => {
|
|
452
|
+
e.preventDefault(); if (!valid) return; setBusy(true); setError(null);
|
|
453
|
+
const said: string[] = []; const failed: string[] = [];
|
|
454
|
+
for (const r of rows.filter((x) => x.email.trim())) {
|
|
455
|
+
const email = r.email.trim();
|
|
456
|
+
const res = await door(`/-/orgs/${slug}/members`, { method: 'POST', body: JSON.stringify({ email, role: r.role }) }).catch(() => null);
|
|
457
|
+
if (!res || !res.ok) { failed.push(`${email}: ${res ? await failure(res) : 'no answer'}`); continue; }
|
|
458
|
+
const body = (await res.json().catch(() => ({}))) as { pending?: boolean };
|
|
459
|
+
// someone already known is added at once; an admin among them gets the role now (an invitation carries it when the platform keeps it)
|
|
460
|
+
if (!body.pending && r.role === 'admin') { const list = await door(`/-/orgs/${slug}/members`).then((x) => x.json()).catch(() => ({ members: [] })) as { members: MemberRow[] }; const m = list.members.find((x) => x.email?.toLowerCase() === email.toLowerCase()); if (m) await door(`/-/orgs/${slug}/members/${m.userId}`, { method: 'PATCH', body: JSON.stringify({ role: 'admin' }) }); }
|
|
461
|
+
said.push(body.pending ? `invited ${email}` : `added ${email}`);
|
|
462
|
+
}
|
|
463
|
+
setBusy(false);
|
|
464
|
+
if (said.length) toast(`${said.join(', ')}. They get an email`.replace(/^./, (c) => c.toUpperCase()));
|
|
465
|
+
if (failed.length) { setError(failed.join('\n')); return; }
|
|
466
|
+
onSent();
|
|
467
|
+
};
|
|
468
|
+
return (
|
|
469
|
+
<Dialog open={open} onClose={() => { if (!busy) onClose(); }} title={`Invite people to ${org.name}`} footer={<><button type="button" onClick={onClose} disabled={busy}>Cancel</button><button type="submit" form="invite" disabled={busy || !valid}>{busy ? 'Sending…' : rows.filter((r) => r.email.trim()).length > 1 ? 'Send invitations' : 'Send invitation'}</button></>}>
|
|
470
|
+
<form id="invite" onSubmit={send} className="stack" aria-label="Invite people">
|
|
471
|
+
<p className="mode">Someone with an account here joins at once; anyone else gets an email and joins when they sign in with that address. Invitations last a week.</p>
|
|
472
|
+
{rows.map((r, i) => (
|
|
473
|
+
<div key={i} className="invite-row">
|
|
474
|
+
<label className="grow">{i === 0 ? 'Email' : <span className="sr">Email</span>}<input autoFocus={i === 0} type="email" value={r.email} onChange={(e) => setRows(rows.map((x, j) => (j === i ? { ...x, email: e.target.value } : x)))} placeholder="teammate@company.com" /></label>
|
|
475
|
+
<label>{i === 0 ? 'Role' : <span className="sr">Role</span>}<select value={r.role} disabled={!admin} onChange={(e) => setRows(rows.map((x, j) => (j === i ? { ...x, role: e.target.value === 'admin' ? 'admin' : 'member' } : x)))}><option value="member">Member</option><option value="admin">Admin</option></select></label>
|
|
476
|
+
{rows.length > 1 ? <button type="button" className="icon-button" aria-label={`Remove row ${i + 1}`} onClick={() => setRows(rows.filter((_, j) => j !== i))}>✕</button> : null}
|
|
477
|
+
</div>
|
|
478
|
+
))}
|
|
479
|
+
<button type="button" className="link-button" onClick={() => setRows([...rows, { email: '', role: 'member' }])}>+ Add another</button>
|
|
480
|
+
{error ? <p role="alert" className="pre">{error}</p> : null}
|
|
481
|
+
</form>
|
|
482
|
+
</Dialog>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── Billing ──────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
487
|
+
type PlanCard = { id: string; name: string; priceMonthly: number; limits: { worlds: number | null; twinsPerWorld: number | null; worldHours: number | null; members: number | null }; pitch: string; features: string[]; trialDays?: number | null };
|
|
488
|
+
type BillingInfo = { plan: { id: string; name: string; priceMonthly: number; limits: PlanCard['limits'] }; plans: PlanCard[]; pendingCheckout: { id: string; url: string } | null; paid: boolean; lastTickAt: string | null; readAt?: string; subscription: { status: string; currentPeriodEnd: string | null; trialEnd: string | null; cancelAtPeriodEnd: boolean } | null; usageByDay?: Array<{ day: string; hours: number; worlds: number }>; spendCap?: boolean; overageCentsPerHour?: number | null; reminders?: { trialEnding: string | null; renewal: string | null; sent: string[] }; usage: Record<string, number>; meters: Array<{ name: string; label: string }>; worlds: number; restriction: { status: string; graceEnds: string; meter: string; reason?: string } | null };
|
|
489
|
+
const money = (cents: number): string => (cents % 100 === 0 ? `$${cents / 100}` : `$${(cents / 100).toFixed(2)}`);
|
|
490
|
+
|
|
491
|
+
/** The org's plan, its usage against the plan's limits this period, the plans side by side, and the ways to pay and to
|
|
492
|
+
* manage payment — the shape Neon's and PostHog's billing pages share. */
|
|
493
|
+
function BillingPage({ org, admin, version, flags }: { org: string; admin: boolean; version: number; flags: Record<string, boolean> }): React.JSX.Element {
|
|
494
|
+
// back from Polar's checkout (`/<org>/billing?upgraded=1`): the first read is Polar's state now, not the cache
|
|
495
|
+
const [fresh] = useState(() => new URLSearchParams(window.location.search).has('upgraded'));
|
|
496
|
+
useEffect(() => { if (fresh) { try { window.history.replaceState(null, '', window.location.pathname); } catch { /* fine */ } } }, [fresh]);
|
|
497
|
+
const [again, setAgain] = useState(0);
|
|
498
|
+
const load = useDoor<BillingInfo>(`/-/orgs/${org}/billing${fresh ? '?fresh=1' : ''}`, version + again);
|
|
499
|
+
const [leaving, setLeaving] = useState(false);
|
|
500
|
+
if (load.state !== 'ok') return <><Head title="Billing" /><Pending load={load} what="billing" retry={() => setAgain((n) => n + 1)} lines={5} /></>;
|
|
501
|
+
const info = load.data;
|
|
502
|
+
// Polar's checkout and portal are pages of their own: the person is taken there, as every peer's billing does
|
|
503
|
+
const leaveFor = async (path: string): Promise<void> => { setLeaving(true); const b = await act<{ url?: string; pending?: boolean }>(path, { method: 'POST', body: '{}' }); if (b?.url) { if (b.pending) toast('You already have a checkout open: this is the same one'); window.location.assign(b.url); return; } setLeaving(false); };
|
|
504
|
+
const setCap = async (on: boolean): Promise<void> => { if (await act(`/-/orgs/${org}/billing`, { method: 'PATCH', body: JSON.stringify({ spendCap: on }) }, on ? 'Spend cap on' : 'Spend cap off: hours beyond the plan are billed')) setAgain((n) => n + 1); };
|
|
505
|
+
const sub = info.subscription; const team = info.plans.find((p) => p.priceMonthly > 0); const trialDays = team?.trialDays ?? null;
|
|
506
|
+
const status = sub?.status === 'trialing' ? { label: 'Trial', tone: 'info' } : sub?.cancelAtPeriodEnd ? { label: 'Cancels', tone: 'warn' } : sub ? { label: 'Active', tone: 'ok' } : { label: 'Free', tone: '' };
|
|
507
|
+
// this period: the calendar month in UTC, as the meter counts it; the pace, from what is used so far
|
|
508
|
+
const now = new Date(); const start = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1); const end = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
|
|
509
|
+
const elapsed = (now.getTime() - start) / (end - start);
|
|
510
|
+
const hours = info.usage.world_hours ?? 0; const pace = elapsed > 0.04 && hours > 0 ? Math.round(hours / elapsed) : null;
|
|
511
|
+
const limitH = info.plan.limits.worldHours;
|
|
512
|
+
const over = pace !== null && limitH !== null && pace > limitH;
|
|
513
|
+
const others = info.meters.filter((m) => m.name !== 'world_hours');
|
|
514
|
+
return (
|
|
515
|
+
<>
|
|
516
|
+
<Head title="Billing" lede={<>For {org}. Payment, invoices and receipts are handled by Polar{admin ? '' : '; an admin changes the plan'}.</>} />
|
|
517
|
+
{info.restriction ? <div role="alert" className={`notice ${info.restriction.status === 'restricted' ? 'danger' : 'warn'}`}><strong>{info.restriction.status === 'restricted' ? 'Restricted' : 'Over the plan'}.</strong> {restrictionCopy(info)}</div> : null}
|
|
518
|
+
{info.paid ? <div role="status" className="notice ok"><strong>Payment received.</strong> Your org is on {info.plan.name}.</div> : null}
|
|
519
|
+
{info.pendingCheckout ? <div role="status" className="notice info">Your checkout at Polar is still open. <a href={info.pendingCheckout.url}>Continue to checkout</a></div> : null}
|
|
520
|
+
<div className="billing-grid">
|
|
521
|
+
<section className="card plan-card" aria-labelledby="plan-now">
|
|
522
|
+
<p className="eyebrow">Current plan</p>
|
|
523
|
+
<h3 id="plan-now">{info.plan.name} {status.tone ? <span className={`badge ${status.tone}`}>{status.label}</span> : null}</h3>
|
|
524
|
+
<p className="price-line"><span className="price">{money(info.plan.priceMonthly)}</span><span className="mode"> / month{info.overageCentsPerHour && info.spendCap === false ? `, plus ${money(info.overageCentsPerHour)} per world-hour beyond the plan` : ''}</span></p>
|
|
525
|
+
<p className="mode">{sub?.trialEnd && sub.status === 'trialing' ? `Trial ends ${day(sub.trialEnd)}; the card is charged then.` : sub?.cancelAtPeriodEnd && sub.currentPeriodEnd ? `Ends ${day(sub.currentPeriodEnd)}; back to Free after that.` : sub?.currentPeriodEnd ? `Renews ${day(sub.currentPeriodEnd)}.` : 'No card on file.'}</p>
|
|
526
|
+
{admin ? <div className="card-actions">{info.plan.id === 'free'
|
|
527
|
+
? <button type="button" className="primary" disabled={leaving} onClick={() => void leaveFor(`/-/orgs/${org}/checkout`)}>{leaving ? 'Opening Polar…' : trialDays && !sub ? `Start a ${trialDays}-day trial of ${team?.name ?? 'Team'}` : `Upgrade to ${team?.name ?? 'Team'}`}</button>
|
|
528
|
+
: <button type="button" disabled={leaving} onClick={() => void leaveFor(`/-/orgs/${org}/billing-portal`)}>{leaving ? 'Opening Polar…' : 'Manage billing'}</button>}
|
|
529
|
+
{info.plan.id !== 'free' ? <span className="mode">Invoices, payment method and cancellation are in Polar's portal.</span> : null}</div> : null}
|
|
530
|
+
</section>
|
|
531
|
+
<section className="card" aria-labelledby="usage-now">
|
|
532
|
+
<p className="eyebrow">This period · {new Date(start).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })} to {new Date(end - 1).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })}</p>
|
|
533
|
+
<h3 id="usage-now" className="sr">Usage</h3>
|
|
534
|
+
<div className="meters">
|
|
535
|
+
<Meter label="Worlds held" value={info.worlds} limit={info.plan.limits.worlds} />
|
|
536
|
+
<Meter label="World-hours" value={hours} limit={limitH} note={pace !== null ? <span className={over ? 'warn-text' : undefined}>On pace for about {pace.toLocaleString()} by the end of the period{over ? (info.spendCap === false ? `; the hours past ${limitH} are billed` : `; past ${limitH} the org is warned, then held`) : ''}.</span> : null} />
|
|
537
|
+
</div>
|
|
538
|
+
<dl className="stats">{others.map((m) => <div key={m.name}><dt>{m.label.charAt(0).toUpperCase() + m.label.slice(1)}</dt><dd>{(info.usage[m.name] ?? 0).toLocaleString()}</dd></div>)}</dl>
|
|
539
|
+
<p className="mode small">{info.lastTickAt ? <>Metered hourly; last at <At iso={info.lastTickAt} />.</> : 'Metered hourly; the first hour has not been metered yet.'}</p>
|
|
540
|
+
</section>
|
|
541
|
+
</div>
|
|
542
|
+
{flags.usage_by_day && info.usageByDay && info.usageByDay.length > 0 ? (
|
|
543
|
+
<section className="card" aria-labelledby="by-day"><h3 id="by-day">World-hours by day</h3>
|
|
544
|
+
<div className="day-bars" role="img" aria-label="World-hours by day this period">{info.usageByDay.map((d) => { const max = Math.max(1, ...info.usageByDay!.map((x) => x.hours)); return <div key={d.day} className="day-bar" title={`${d.day}: ${d.hours} h, ${d.worlds} World${d.worlds === 1 ? '' : 's'}`}><span style={{ height: `${Math.max(3, Math.round((d.hours / max) * 100))}%` }} /><em>{Number(d.day.slice(8))}</em></div>; })}</div>
|
|
545
|
+
</section>
|
|
546
|
+
) : null}
|
|
547
|
+
{info.plan.id !== 'free' && info.overageCentsPerHour && admin ? (
|
|
548
|
+
<section className="card setting-row" aria-labelledby="cap">
|
|
549
|
+
<div><h3 id="cap">Spend cap</h3><p className="mode">{info.spendCap !== false ? `On: past the plan's ${limitH} world-hours, the org is warned and then held, never billed more.` : `Off: hours past the plan's ${limitH} are billed at ${money(info.overageCentsPerHour)} each, and nothing is held.`}</p></div>
|
|
550
|
+
<Switch label="Spend cap" checked={info.spendCap !== false} onChange={(on) => void setCap(on)} />
|
|
551
|
+
</section>
|
|
552
|
+
) : null}
|
|
553
|
+
<h3 className="section-title">Plans</h3>
|
|
554
|
+
<div className="plans">{info.plans.map((p) => { const current = p.id === info.plan.id; return (
|
|
555
|
+
<section key={p.id} className={current ? 'card plan current' : 'card plan'} aria-labelledby={`plan-${p.id}`}>
|
|
556
|
+
<h4 id={`plan-${p.id}`}>{p.name} {current ? <span className="badge">current</span> : null}</h4>
|
|
557
|
+
<p className="price-line"><span className="price">{money(p.priceMonthly)}</span><span className="mode"> / month</span></p>
|
|
558
|
+
<p className="mode">{p.pitch}</p>
|
|
559
|
+
<ul className="checks">{p.features.map((f) => <li key={f}>{f}</li>)}</ul>
|
|
560
|
+
{!current && admin && p.priceMonthly > info.plan.priceMonthly ? <button type="button" className="primary" disabled={leaving} onClick={() => void leaveFor(`/-/orgs/${org}/checkout`)}>{p.trialDays && !sub ? `Start a ${p.trialDays}-day trial` : `Upgrade to ${p.name}`}</button> : null}
|
|
561
|
+
{!current && admin && p.priceMonthly < info.plan.priceMonthly ? <button type="button" disabled={leaving} onClick={() => void leaveFor(`/-/orgs/${org}/billing-portal`)}>Downgrade in the portal</button> : null}
|
|
562
|
+
</section>
|
|
563
|
+
); })}</div>
|
|
564
|
+
{info.reminders?.trialEnding || info.reminders?.renewal ? <p className="mode small">We email the admins seven days before the {info.reminders.trialEnding ? 'trial ends' : 'renewal'} ({day((info.reminders.trialEnding ?? info.reminders.renewal)!)}).</p> : null}
|
|
565
|
+
</>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** What the restriction machine's position means for this org, in the person's words — the reason named. */
|
|
570
|
+
function restrictionCopy(info: BillingInfo): string {
|
|
571
|
+
const r = info.restriction!; const until = day(r.graceEnds); const allows = info.plan.limits.worlds ?? 0;
|
|
572
|
+
if (r.meter === 'worlds') {
|
|
573
|
+
const lead = r.reason === 'no_subscription' ? `Your subscription ended, so this org is on the Free plan, which allows ${allows} World${allows === 1 ? '' : 's'}; it holds ${info.worlds}.` : `The ${info.plan.name} plan allows ${allows} World${allows === 1 ? '' : 's'}; this org holds ${info.worlds}.`;
|
|
574
|
+
return r.status === 'restricted' ? `${lead} Nothing was deleted, but since ${until} the Worlds no longer refresh and new ones are refused, until you upgrade or delete Worlds down to ${allows}.` : `${lead} Nothing is deleted, and the Worlds keep working until ${until}. New Worlds beyond ${allows} are refused from now on.`;
|
|
575
|
+
}
|
|
576
|
+
return r.status === 'restricted' ? `The plan's world-hours ran out and the grace period ended ${until}: upgrade to make and refresh Worlds again.` : `This period passed the plan's world-hours. Everything keeps working until ${until}; after that, making and refreshing Worlds is held until you upgrade or the next period begins.`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** An on/off setting, drawn as a switch (a checkbox underneath, so it is one to a screen reader). */
|
|
580
|
+
function Switch({ label, checked, onChange, disabled }: { label: string; checked: boolean; onChange: (on: boolean) => void; disabled?: boolean }): React.JSX.Element {
|
|
581
|
+
return <label className="switch"><input type="checkbox" role="switch" aria-label={label} checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} /><span aria-hidden="true" /></label>;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ── Settings ─────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
585
|
+
/** An org's settings as sub-pages (Vercel's and Neon's): General with the danger zone, Security, Org tokens, Webhooks,
|
|
586
|
+
* Labs, Support access. They are the admins'. */
|
|
587
|
+
function SettingsPage({ org, sub, info, version, onChanged, onDeleted }: { org: OrgRow; sub: SettingsPage; info: PlatformInfo | null; version: number; onChanged: () => void; onDeleted: () => void }): React.JSX.Element {
|
|
588
|
+
const slug = slugOf(org);
|
|
589
|
+
const tabs = SETTINGS.filter(([k]) => k !== 'support' || info?.support);
|
|
590
|
+
const to = (k: SettingsPage): Place => ({ kind: 'org', org: slug, page: 'settings', sub: k });
|
|
591
|
+
return (
|
|
592
|
+
<>
|
|
593
|
+
<Head title="Settings" />
|
|
594
|
+
<SubNav label="Settings" items={tabs.map(([k, label]) => ({ label, href: pathOf(to(k)), on: sub === k, onClick: linkTo(to(k)) }))} />
|
|
595
|
+
{!isAdmin(org) ? <Empty title="Settings are for admins">You are a member of {org.name}. Ask one of its admins to change its settings.</Empty> : <div className="settings">
|
|
596
|
+
{sub === 'general' ? <GeneralSettings org={org} onChanged={onChanged} onDeleted={onDeleted} /> : null}
|
|
597
|
+
{sub === 'security' ? <SecuritySettings org={slug} onChanged={onChanged} /> : null}
|
|
598
|
+
{sub === 'tokens' ? <TokensPanel kind="org" org={slug} /> : null}
|
|
599
|
+
{sub === 'webhooks' ? (org.flags?.webhooks ? <Webhooks org={slug} /> : <Empty title="Webhooks are in Labs" action={<a className="button" href={pathOf(to('labs'))} onClick={linkTo(to('labs'))}>Open Labs</a>}>Turn them on for {org.name} under Labs, then add an endpoint here.</Empty>) : null}
|
|
600
|
+
{sub === 'labs' ? <Labs org={slug} onChanged={onChanged} /> : null}
|
|
601
|
+
{sub === 'support' && info?.support ? <SupportAccess org={slug} version={version} /> : null}
|
|
602
|
+
</div>}
|
|
603
|
+
</>
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function GeneralSettings({ org, onChanged, onDeleted }: { org: OrgRow; onChanged: () => void; onDeleted: () => void }): React.JSX.Element {
|
|
608
|
+
const slug = slugOf(org); const held = org.worlds?.length ?? 0;
|
|
609
|
+
const [name, setName] = useState(org.name); const [busy, setBusy] = useState(false);
|
|
610
|
+
useEffect(() => { setName(org.name); }, [org.name]);
|
|
611
|
+
const rename = async (e: React.FormEvent): Promise<void> => { e.preventDefault(); setBusy(true); if (await act(`/-/orgs/${slug}`, { method: 'PATCH', body: JSON.stringify({ name: name.trim() }) }, 'Name saved')) onChanged(); setBusy(false); };
|
|
612
|
+
const remove = async (): Promise<void> => {
|
|
613
|
+
const ok = await confirmAct({ title: `Delete ${org.name}?`, body: <>This deletes the org for every member{held ? <>, and its <strong>{held} World{held === 1 ? '' : 's'}</strong> with their whole history</> : ''}. Its members keep their accounts. This cannot be undone.</>, action: held ? `Delete org and ${held} World${held === 1 ? '' : 's'}` : 'Delete org', danger: true, typed: slug });
|
|
614
|
+
if (ok && await act(`/-/orgs/${slug}${held ? '?everything=1' : ''}`, { method: 'DELETE' }, `Deleted ${org.name}`)) onDeleted();
|
|
615
|
+
};
|
|
616
|
+
return (
|
|
617
|
+
<>
|
|
618
|
+
<section className="card" aria-labelledby="general">
|
|
619
|
+
<h3 id="general">Org name</h3>
|
|
620
|
+
<form onSubmit={rename} className="stack"><label><span className="sr">Org name</span><input name="orgRename" value={name} onChange={(e) => setName(e.target.value)} maxLength={64} required /></label><p className="mode">The name people see. The org's address stays <code>{slug}</code>, so nothing your code or the command points at moves.</p><div className="card-actions"><button type="submit" disabled={busy || !name.trim() || name.trim() === org.name}>Save</button></div></form>
|
|
621
|
+
</section>
|
|
622
|
+
<section className="card" aria-labelledby="ids">
|
|
623
|
+
<h3 id="ids">Address and id</h3>
|
|
624
|
+
<CopyField label="Address" value={slug} />
|
|
625
|
+
<CopyField label="Org id (the API's and the audit log's)" value={org.id} />
|
|
626
|
+
</section>
|
|
627
|
+
<section className="card danger-zone" aria-labelledby="danger">
|
|
628
|
+
<h3 id="danger">Danger zone</h3>
|
|
629
|
+
<div className="setting-row"><div><strong>Delete this org</strong><p className="mode">For every member{held ? `, with its ${held} World${held === 1 ? '' : 's'}` : ''}. You type its address to confirm.</p></div><button type="button" className="danger-outline" onClick={() => void remove()}>Delete org…</button></div>
|
|
630
|
+
</section>
|
|
631
|
+
</>
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Security: the switches the peers share (PostHog's members-can-invite and personal keys; approved email domains). */
|
|
636
|
+
function SecuritySettings({ org, onChanged }: { org: string; onChanged: () => void }): React.JSX.Element {
|
|
637
|
+
type Sec = { membersCanInvite: boolean; membersCanUsePersonalTokens: boolean; approvedEmailDomains: string[] };
|
|
638
|
+
const [again, setAgain] = useState(0);
|
|
639
|
+
const load = useDoor<{ security: Sec }>(`/-/orgs/${org}/security`, again);
|
|
640
|
+
const [domains, setDomains] = useState<string | null>(null);
|
|
641
|
+
if (load.state !== 'ok') return <Pending load={load} what="the security settings" retry={() => setAgain((n) => n + 1)} />;
|
|
642
|
+
const sec = load.data.security;
|
|
643
|
+
const save = async (patch: Partial<Sec>, said: string): Promise<void> => { if (await act(`/-/orgs/${org}/security`, { method: 'PATCH', body: JSON.stringify({ ...sec, ...patch }) }, said)) { setAgain((n) => n + 1); onChanged(); } };
|
|
644
|
+
const shownDomains = domains ?? sec.approvedEmailDomains.join(', ');
|
|
645
|
+
return (
|
|
646
|
+
<section className="card" aria-label="Security">
|
|
647
|
+
<div className="setting-row"><div><strong>Members may invite</strong><p className="mode">Otherwise only admins add and invite people.</p></div><Switch label="Members may invite" checked={sec.membersCanInvite} onChange={(on) => void save({ membersCanInvite: on }, on ? 'Members may invite' : 'Only admins invite')} /></div>
|
|
648
|
+
<div className="setting-row"><div><strong>Personal tokens may open this org</strong><p className="mode">Off: only people signed in, and org tokens, reach it; a member's personal token is refused.</p></div><Switch label="Personal tokens may open this org" checked={sec.membersCanUsePersonalTokens} onChange={(on) => void save({ membersCanUsePersonalTokens: on }, on ? 'Personal tokens allowed' : 'Personal tokens refused')} /></div>
|
|
649
|
+
<form className="setting-row stack-row" onSubmit={(e) => { e.preventDefault(); void save({ approvedEmailDomains: shownDomains.split(',').map((d) => d.trim()).filter(Boolean) }, 'Approved domains saved').then(() => setDomains(null)); }}>
|
|
650
|
+
<label className="grow"><strong>Approved email domains</strong><span className="mode">Invitations go only to these domains. Empty: any.</span><input name="approvedEmailDomains" value={shownDomains} onChange={(e) => setDomains(e.target.value)} placeholder="acme.com, acme.dev" /></label>
|
|
651
|
+
<button type="submit" disabled={domains === null}>Save</button>
|
|
652
|
+
</form>
|
|
653
|
+
</section>
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Tokens — yours (for the command and scripts) or the org's (for CI, acting as the org): a table of what exists, made in
|
|
658
|
+
* a dialog, the token itself shown once in another with a copy button. */
|
|
659
|
+
function TokensPanel({ kind, org }: { kind: 'personal' | 'org'; org?: string }): React.JSX.Element {
|
|
660
|
+
type TokenRow = { id: string; name: string; createdAt: string; last4: string; lastUsedAt?: string; expiresAt?: string | null; scopes?: string[] };
|
|
661
|
+
const base = kind === 'org' ? `/-/orgs/${org}/tokens` : '/-/tokens';
|
|
662
|
+
const [again, setAgain] = useState(0);
|
|
663
|
+
const load = useDoor<{ tokens: TokenRow[] }>(base, again);
|
|
664
|
+
const [making, setMaking] = useState(false); const [made, setMade] = useState<{ token: string; name: string } | null>(null);
|
|
665
|
+
const revoke = async (t: TokenRow): Promise<void> => {
|
|
666
|
+
const ok = await confirmAct({ title: `Revoke ${t.name}?`, body: <>Whatever uses the token ending <code>…{t.last4}</code> is refused from now on.</>, action: 'Revoke token', danger: true });
|
|
667
|
+
if (ok && await act(`${base}/${t.id}`, { method: 'DELETE' }, `Revoked ${t.name}`)) setAgain((n) => n + 1);
|
|
668
|
+
};
|
|
669
|
+
const title = kind === 'org' ? 'Org tokens' : 'Personal access tokens';
|
|
670
|
+
return (
|
|
671
|
+
<section className="card flush" aria-label={title}>
|
|
672
|
+
<div className="card-head"><div><h3>{title}</h3><p className="mode">{kind === 'org' ? <>Act as the org, not a person: for CI and scripts. <code>volter login {ORIGIN} --token …</code>, then <code>volter remote add origin {org}/<world></code>.</> : <>Sign the <code>volter</code> command in as you without a browser, for scripts and CI. In a terminal, <code>volter login {ORIGIN}</code> does it through the browser instead.</>}</p></div><button type="button" className="primary" onClick={() => setMaking(true)}>Create token</button></div>
|
|
673
|
+
{load.state !== 'ok' ? <Pending load={load} what="the tokens" retry={() => setAgain((n) => n + 1)} /> : load.data.tokens.length === 0 ? <p className="mode table-note">No tokens yet.</p> : (
|
|
674
|
+
<table className="dense tokens-table" aria-label={kind === 'org' ? 'Org tokens' : 'Your tokens'}>
|
|
675
|
+
<thead><tr><th>Name</th><th>Access</th><th>Created</th><th>Last used</th><th>Expires</th><th className="num"><span className="sr">Actions</span></th></tr></thead>
|
|
676
|
+
<tbody>{load.data.tokens.map((t) => { const expired = t.expiresAt ? Date.parse(t.expiresAt) <= Date.now() : false; return (
|
|
677
|
+
<tr key={t.id}><td><span className="who"><span>{t.name}</span><span className="mode mono">…{t.last4}</span></span></td><td className="mode">{scopeWords(t.scopes)}</td><td className="mode"><span title={when(t.createdAt)}>{day(t.createdAt)}</span></td><td className="mode">{t.lastUsedAt ? <span title={when(t.lastUsedAt)}>{ago(t.lastUsedAt)}</span> : 'never'}</td><td className="mode">{t.expiresAt === null ? 'never' : expired ? <span className="badge warn">expired</span> : t.expiresAt ? day(t.expiresAt) : '—'}</td><td className="num"><Menu label={`More for ${t.name}`} items={[{ label: 'Revoke…', danger: true, onSelect: () => void revoke(t) }]} /></td></tr>
|
|
678
|
+
); })}</tbody>
|
|
679
|
+
</table>
|
|
680
|
+
)}
|
|
681
|
+
<MakeToken open={making} kind={kind} base={base} onClose={() => setMaking(false)} onMade={(m) => { setMaking(false); setMade(m); setAgain((n) => n + 1); }} />
|
|
682
|
+
<Dialog open={Boolean(made)} onClose={() => setMade(null)} title={`${made?.name ?? 'Token'} is ready`} footer={<button type="button" className="primary" onClick={() => setMade(null)}>Done</button>}>
|
|
683
|
+
<p className="notice warn">Copy it now: it is shown this once and never again.</p>
|
|
684
|
+
{made ? <CopyField label="Token" value={made.token} secret /> : null}
|
|
685
|
+
{made ? <CopyField label="Use it" value={`volter login ${ORIGIN} --token ${made.token}`} secret /> : null}
|
|
686
|
+
</Dialog>
|
|
687
|
+
</section>
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function MakeToken({ open, kind, base, onClose, onMade }: { open: boolean; kind: 'personal' | 'org'; base: string; onClose: () => void; onMade: (m: { token: string; name: string }) => void }): React.JSX.Element {
|
|
692
|
+
const [name, setName] = useState(''); const [expiry, setExpiry] = useState('30'); const [access, setAccess] = useState<'all' | 'read' | 'custom'>(kind === 'org' ? 'read' : 'all'); const [custom, setCustom] = useState<Record<string, 'none' | 'read' | 'write'>>({ worlds: 'read' }); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
|
|
693
|
+
useEffect(() => { if (open) { setName(''); setExpiry(kind === 'org' ? '90' : '30'); setAccess(kind === 'org' ? 'read' : 'all'); setError(null); } }, [open, kind]);
|
|
694
|
+
const submit = async (e: React.FormEvent): Promise<void> => {
|
|
695
|
+
e.preventDefault(); setBusy(true); setError(null);
|
|
696
|
+
const r = await door(base, { method: 'POST', body: JSON.stringify({ name: name.trim(), expiresInDays: expiry === 'never' ? null : Number(expiry), scopes: scopesFor(access, custom) }) }).catch(() => null);
|
|
697
|
+
setBusy(false);
|
|
698
|
+
if (!r) { setError(`${ORIGIN} did not answer`); return; }
|
|
699
|
+
const b = (await r.json().catch(() => ({}))) as { token?: string; error?: string };
|
|
700
|
+
if (!r.ok || !b.token) { setError(b.error ?? `answered ${r.status}`); return; }
|
|
701
|
+
onMade({ token: b.token, name: name.trim() });
|
|
702
|
+
};
|
|
703
|
+
return (
|
|
704
|
+
<Dialog open={open} onClose={() => { if (!busy) onClose(); }} title={kind === 'org' ? 'Create an org token' : 'Create a personal token'} footer={<><button type="button" onClick={onClose} disabled={busy}>Cancel</button><button type="submit" form="make-token" disabled={busy || !name.trim()}>{busy ? 'Creating…' : 'Create token'}</button></>}>
|
|
705
|
+
<form id="make-token" onSubmit={submit} className="stack" aria-label="Create a token">
|
|
706
|
+
<label>Name <input autoFocus name="tokenName" value={name} onChange={(e) => setName(e.target.value)} placeholder={kind === 'org' ? 'github-actions' : 'my laptop'} maxLength={80} required /></label>
|
|
707
|
+
<label>Expires <select name="tokenExpiry" value={expiry} onChange={(e) => setExpiry(e.target.value)}><option value="7">in 7 days</option><option value="30">in 30 days</option><option value="90">in 90 days</option><option value="365">in a year</option><option value="never">never (not recommended)</option></select></label>
|
|
708
|
+
<fieldset className="choice"><legend>Access</legend>
|
|
709
|
+
{([['all', 'Full access', kind === 'org' ? 'Everything an admin may do in the org.' : 'Everything you may do.'], ['read', 'Read only', 'Read Worlds, orgs, members, billing and activity; change nothing.'], ['custom', 'Choose', 'Pick read or read-and-write per kind of thing.']] as const).map(([k, t, d]) => <label key={k} className={access === k ? 'choice-card on' : 'choice-card'}><input type="radio" name="tokenAccess" value={k} checked={access === k} onChange={() => setAccess(k)} /><span><strong>{t}</strong><span className="mode">{d}</span></span></label>)}
|
|
710
|
+
</fieldset>
|
|
711
|
+
{access === 'custom' ? <div className="scope-grid">{SCOPE_OBJECTS.map((o) => <label key={o}><span>{o}</span><select aria-label={`Scope: ${o}`} value={custom[o] ?? 'none'} onChange={(e) => setCustom({ ...custom, [o]: e.target.value as 'none' | 'read' | 'write' })}><option value="none">No access</option><option value="read">Read</option><option value="write">Read and write</option></select></label>)}</div> : null}
|
|
712
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
713
|
+
</form>
|
|
714
|
+
</Dialog>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** A key for one World, for an app or a job: made here (a browser session on the World makes none), shown once. */
|
|
719
|
+
function WorldKey({ world, onClose }: { world: string | null; onClose: () => void }): React.JSX.Element {
|
|
720
|
+
const [name, setName] = useState(''); const [scope, setScope] = useState<'write' | 'read'>('write'); const [expiry, setExpiry] = useState('90'); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null); const [made, setMade] = useState<{ key: string; base: string } | null>(null);
|
|
721
|
+
useEffect(() => { if (world) { setName(''); setScope('write'); setExpiry('90'); setError(null); setMade(null); } }, [world]);
|
|
722
|
+
const submit = async (e: React.FormEvent): Promise<void> => {
|
|
723
|
+
e.preventDefault(); if (!world) return; setBusy(true); setError(null);
|
|
724
|
+
const r = await door(`/-/worlds/${world}/keys`, { method: 'POST', body: JSON.stringify({ name: name.trim(), scope, expiresInDays: expiry === 'never' ? null : Number(expiry) }) }).catch(() => null);
|
|
725
|
+
setBusy(false);
|
|
726
|
+
const b = r ? (await r.json().catch(() => ({}))) as { key?: string; base?: string; error?: string } : {};
|
|
727
|
+
if (!r || !r.ok || !b.key) { setError(b.error ?? (r ? `answered ${r.status}` : `${ORIGIN} did not answer`)); return; }
|
|
728
|
+
setMade({ key: b.key, base: b.base ?? '' });
|
|
729
|
+
};
|
|
730
|
+
return (
|
|
731
|
+
<Dialog open={world !== null} onClose={() => { if (!busy) onClose(); }} title={`Make a key for ${world ?? ''}`} footer={made ? <button type="button" className="primary" onClick={onClose}>Done</button> : <><button type="button" onClick={onClose} disabled={busy}>Cancel</button><button type="submit" form="world-key" disabled={busy || !name.trim()}>{busy ? 'Making…' : 'Make key'}</button></>}>
|
|
732
|
+
{made ? <div className="stack">
|
|
733
|
+
<p>Copy it now: it is shown once. It is yours, and is revoked if you leave the org; its World lists it under Settings.</p>
|
|
734
|
+
<CopyField label="Key" value={made.key} secret />
|
|
735
|
+
{made.base ? <CopyField label="The World's address" value={made.base} /> : null}
|
|
736
|
+
</div> : (
|
|
737
|
+
<form id="world-key" onSubmit={submit} className="stack" aria-label="Make a key">
|
|
738
|
+
<label>Name <input autoFocus name="keyName" value={name} onChange={(e) => setName(e.target.value)} placeholder="staging app" maxLength={80} required /></label>
|
|
739
|
+
<label>Access <select name="keyScope" value={scope} onChange={(e) => setScope(e.target.value === 'read' ? 'read' : 'write')}><option value="write">Read and write</option><option value="read">Read only</option></select></label>
|
|
740
|
+
<label>Expires <select name="keyExpiry" value={expiry} onChange={(e) => setExpiry(e.target.value)}><option value="7">in 7 days</option><option value="30">in 30 days</option><option value="90">in 90 days</option><option value="365">in a year</option><option value="never">never (not recommended)</option></select></label>
|
|
741
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
742
|
+
</form>
|
|
743
|
+
)}
|
|
744
|
+
</Dialog>
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Scopes: `object:action` as Sentry's and PostHog's — a token opens only what its scopes name. */
|
|
749
|
+
const SCOPE_OBJECTS = ['worlds', 'orgs', 'members', 'billing', 'tokens', 'activity'] as const;
|
|
750
|
+
function scopesFor(access: 'all' | 'read' | 'custom', custom: Record<string, 'none' | 'read' | 'write'>): string[] {
|
|
751
|
+
if (access === 'all') return ['*'];
|
|
752
|
+
if (access === 'read') return SCOPE_OBJECTS.map((o) => `${o}:read`);
|
|
753
|
+
const chosen = SCOPE_OBJECTS.filter((o) => custom[o] && custom[o] !== 'none').map((o) => `${o}:${custom[o]}`);
|
|
754
|
+
return chosen.length ? chosen : ['worlds:read'];
|
|
755
|
+
}
|
|
756
|
+
function scopeWords(scopes?: string[]): string {
|
|
757
|
+
if (!scopes || scopes.includes('*')) return 'Full access';
|
|
758
|
+
if (SCOPE_OBJECTS.every((o) => scopes.includes(`${o}:read`)) && scopes.every((s) => s.endsWith(':read'))) return 'Read only';
|
|
759
|
+
return scopes.map((s) => s.replace(':write', ' (write)').replace(':read', '')).join(', ');
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Labs (Twenty's lab, Sentry's Early Adopter): early-access features, each a switch, or all of them with one. */
|
|
763
|
+
function Labs({ org, onChanged }: { org: string; onChanged: () => void }): React.JSX.Element {
|
|
764
|
+
type Feature = { key: string; name: string; description: string; since: string; on: boolean };
|
|
765
|
+
const [again, setAgain] = useState(0);
|
|
766
|
+
const load = useDoor<{ features: Feature[]; earlyAdopter: boolean }>(`/-/orgs/${org}/labs`, again);
|
|
767
|
+
if (load.state !== 'ok') return <Pending load={load} what="Labs" retry={() => setAgain((n) => n + 1)} />;
|
|
768
|
+
const { features, earlyAdopter } = load.data;
|
|
769
|
+
const set = async (patch: { flags?: Record<string, boolean>; earlyAdopter?: boolean }, said: string): Promise<void> => { if (await act(`/-/orgs/${org}/labs`, { method: 'PATCH', body: JSON.stringify(patch) }, said)) { setAgain((n) => n + 1); onChanged(); } };
|
|
770
|
+
return (
|
|
771
|
+
<section className="card" aria-label="Labs">
|
|
772
|
+
<p className="mode">Features that are new: try them before everyone has them. Each can be turned off again.</p>
|
|
773
|
+
<div className="setting-row"><div><strong>Early adopter</strong><p className="mode">Every new feature is on for this org as soon as it lands.</p></div><Switch label="Early adopter" checked={earlyAdopter} onChange={(on) => void set({ earlyAdopter: on }, on ? 'Every new feature is on' : 'Early adopter off')} /></div>
|
|
774
|
+
{features.map((f) => <div key={f.key} className="setting-row"><div><strong>{f.name}</strong> <span className="badge">since {day(f.since)}</span><p className="mode">{f.description}</p></div><Switch label={f.name} checked={earlyAdopter || f.on} disabled={earlyAdopter} onChange={(on) => void set({ flags: { [f.key]: on } }, `${f.name} ${on ? 'on' : 'off'}`)} /></div>)}
|
|
775
|
+
</section>
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Webhooks out: endpoints and the events they want, the secret once, a test event, each endpoint's deliveries. */
|
|
780
|
+
function Webhooks({ org }: { org: string }): React.JSX.Element {
|
|
781
|
+
type Endpoint = { id: string; url: string; events: string[]; description: string; createdAt: string; consecutiveFailures: number; lastFailedAt: string | null; disabledAt: string | null };
|
|
782
|
+
type Delivery = { id: string; event: string; attempt: number; state: string; nextAt: string; attempts: Array<{ at: string; status: number | null; ok: boolean; response: string }> };
|
|
783
|
+
const [again, setAgain] = useState(0);
|
|
784
|
+
const load = useDoor<{ webhooks: Endpoint[]; events: string[] }>(`/-/orgs/${org}/webhooks`, again);
|
|
785
|
+
const [adding, setAdding] = useState(false); const [secret, setSecret] = useState<string | null>(null); const [viewing, setViewing] = useState<Endpoint | null>(null); const [deliveries, setDeliveries] = useState<Delivery[] | null>(null);
|
|
786
|
+
const reload = (): void => setAgain((n) => n + 1);
|
|
787
|
+
const show = async (w: Endpoint): Promise<void> => { setViewing(w); setDeliveries(null); const b = await act<{ deliveries: Delivery[] }>(`/-/orgs/${org}/webhooks/${w.id}/deliveries`, {}); setDeliveries(b?.deliveries ?? []); };
|
|
788
|
+
const test = async (w: Endpoint): Promise<void> => { const b = await act<{ delivery: Delivery }>(`/-/orgs/${org}/webhooks/${w.id}/test`, { method: 'POST' }); if (b) { const a = b.delivery.attempts[0]; toast(b.delivery.state === 'delivered' ? `Test delivered (${a?.status})` : `Test not delivered (${a?.status ?? a?.response}); retrying`, b.delivery.state === 'delivered' ? 'ok' : 'error'); reload(); } };
|
|
789
|
+
const remove = async (w: Endpoint): Promise<void> => { if (await confirmAct({ title: 'Remove this endpoint?', body: <>Nothing more is sent to <code>{w.url}</code>.</>, action: 'Remove endpoint', danger: true }) && await act(`/-/orgs/${org}/webhooks/${w.id}`, { method: 'DELETE' }, 'Endpoint removed')) reload(); };
|
|
790
|
+
if (load.state !== 'ok') return <Pending load={load} what="the webhooks" retry={reload} />;
|
|
791
|
+
const state = (w: Endpoint): React.JSX.Element => (w.disabledAt ? <span className="badge warn">disabled</span> : w.consecutiveFailures > 0 ? <span className="badge warn">failing · {w.consecutiveFailures}</span> : <span className="badge ok">active</span>);
|
|
792
|
+
return (
|
|
793
|
+
<section className="card flush" aria-label="Webhooks">
|
|
794
|
+
<div className="card-head"><div><h3>Webhooks</h3><p className="mode">Events the org raises (a World made, a member added, the plan changed) sent to your endpoint as JSON, signed with Standard Webhooks. Retried with backoff; twenty failures in a row disable the endpoint and email the admins.</p></div><button type="button" className="primary" onClick={() => setAdding(true)}>Add endpoint</button></div>
|
|
795
|
+
{load.data.webhooks.length === 0 ? <p className="mode table-note">No endpoints yet.</p> : (
|
|
796
|
+
<table className="dense" aria-label="Endpoints">
|
|
797
|
+
<thead><tr><th>Endpoint</th><th>Events</th><th>Status</th><th className="num"><span className="sr">Actions</span></th></tr></thead>
|
|
798
|
+
<tbody>{load.data.webhooks.map((w) => <tr key={w.id}><td><span className="who"><code>{w.url}</code>{w.description ? <span className="mode">{w.description}</span> : null}</span></td><td className="mode">{w.events.includes('*') ? 'Every event' : w.events.join(', ')}</td><td>{state(w)}</td><td className="num"><Menu label={`More for ${w.url}`} items={[
|
|
799
|
+
{ label: 'Send a test event', onSelect: () => void test(w) },
|
|
800
|
+
{ label: 'Deliveries', onSelect: () => void show(w) },
|
|
801
|
+
w.disabledAt ? { label: 'Enable', onSelect: () => void act(`/-/orgs/${org}/webhooks/${w.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: true }) }, 'Endpoint enabled').then(reload) } : { label: 'Disable', onSelect: () => void act(`/-/orgs/${org}/webhooks/${w.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: false }) }, 'Endpoint disabled').then(reload) },
|
|
802
|
+
{ label: 'Remove…', danger: true, onSelect: () => void remove(w) },
|
|
803
|
+
]} /></td></tr>)}</tbody>
|
|
804
|
+
</table>
|
|
805
|
+
)}
|
|
806
|
+
<AddEndpoint open={adding} org={org} names={load.data.events} onClose={() => setAdding(false)} onMade={(s) => { setAdding(false); setSecret(s); reload(); }} />
|
|
807
|
+
<Dialog open={Boolean(secret)} onClose={() => setSecret(null)} title="Endpoint added" footer={<button type="button" className="primary" onClick={() => setSecret(null)}>Done</button>}>
|
|
808
|
+
<p className="notice warn">Copy its signing secret now: it is shown this once. Your receiver verifies each delivery with it.</p>
|
|
809
|
+
{secret ? <CopyField label="Signing secret" value={secret} secret /> : null}
|
|
810
|
+
</Dialog>
|
|
811
|
+
<Dialog open={Boolean(viewing)} onClose={() => setViewing(null)} title="Deliveries" wide>
|
|
812
|
+
{viewing ? <p className="mode"><code>{viewing.url}</code>: the last fifty, newest first.</p> : null}
|
|
813
|
+
{deliveries === null ? <Skeleton lines={4} label="Loading the deliveries…" /> : deliveries.length === 0 ? <p className="mode">No deliveries yet.</p> : <table className="dense"><thead><tr><th>When</th><th>Event</th><th>Attempt</th><th>Answer</th></tr></thead><tbody>{deliveries.map((d) => { const last = d.attempts[d.attempts.length - 1]; return <tr key={d.id}><td className="mode">{last ? <span title={when(last.at)}>{ago(last.at)}</span> : '—'}</td><td>{d.event}</td><td>{d.attempt}{d.state === 'pending' ? <span className="mode"> · next {ago(d.nextAt)}</span> : <span className={`badge ${d.state === 'delivered' ? 'ok' : 'warn'}`}>{d.state}</span>}</td><td className="mode">{last?.status ?? '—'} {(last?.response ?? '').slice(0, 60)}</td></tr>; })}</tbody></table>}
|
|
814
|
+
</Dialog>
|
|
815
|
+
</section>
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function AddEndpoint({ open, org, names, onClose, onMade }: { open: boolean; org: string; names: string[]; onClose: () => void; onMade: (secret: string) => void }): React.JSX.Element {
|
|
820
|
+
const [url, setUrl] = useState(''); const [description, setDescription] = useState(''); const [all, setAll] = useState(true); const [chosen, setChosen] = useState<string[]>([]); const [error, setError] = useState<string | null>(null); const [busy, setBusy] = useState(false);
|
|
821
|
+
useEffect(() => { if (open) { setUrl(''); setDescription(''); setAll(true); setChosen([]); setError(null); } }, [open]);
|
|
822
|
+
const submit = async (e: React.FormEvent): Promise<void> => {
|
|
823
|
+
e.preventDefault(); setBusy(true); setError(null);
|
|
824
|
+
const r = await door(`/-/orgs/${org}/webhooks`, { method: 'POST', body: JSON.stringify({ url, description, events: all ? ['*'] : chosen }) }).catch(() => null);
|
|
825
|
+
setBusy(false);
|
|
826
|
+
const b = r ? ((await r.json().catch(() => ({}))) as { secret?: string; error?: string }) : { error: `${ORIGIN} did not answer` };
|
|
827
|
+
if (!r?.ok || !b.secret) { setError(b.error ?? 'refused'); return; }
|
|
828
|
+
onMade(b.secret);
|
|
829
|
+
};
|
|
830
|
+
return (
|
|
831
|
+
<Dialog open={open} onClose={() => { if (!busy) onClose(); }} title="Add an endpoint" footer={<><button type="button" onClick={onClose} disabled={busy}>Cancel</button><button type="submit" form="add-endpoint" disabled={busy || !url || (!all && chosen.length === 0)}>{busy ? 'Adding…' : 'Add endpoint'}</button></>}>
|
|
832
|
+
<form id="add-endpoint" onSubmit={submit} className="stack" aria-label="Add an endpoint">
|
|
833
|
+
<label>Endpoint URL <input autoFocus name="webhookUrl" type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/hooks/volter" required /></label>
|
|
834
|
+
<label><span>Description <span className="mode">(optional)</span></span><input name="webhookDescription" value={description} onChange={(e) => setDescription(e.target.value)} maxLength={200} /></label>
|
|
835
|
+
<fieldset className="choice"><legend>Events</legend>
|
|
836
|
+
<label className={all ? 'choice-card on' : 'choice-card'}><input type="radio" name="webhookEvents" checked={all} onChange={() => setAll(true)} /><span><strong>Every event</strong><span className="mode">Including ones added later.</span></span></label>
|
|
837
|
+
<label className={!all ? 'choice-card on' : 'choice-card'}><input type="radio" name="webhookEvents" checked={!all} onChange={() => setAll(false)} /><span><strong>Choose events</strong></span></label>
|
|
838
|
+
</fieldset>
|
|
839
|
+
{!all ? <div className="scope-grid">{names.map((n) => <label key={n} className="check"><input type="checkbox" checked={chosen.includes(n)} onChange={(e) => setChosen(e.target.checked ? [...chosen, n] : chosen.filter((x) => x !== n))} /> <code>{n}</code></label>)}</div> : null}
|
|
840
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
841
|
+
</form>
|
|
842
|
+
</Dialog>
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Support access: whether support may open the org, until when, and the way to take it back. */
|
|
847
|
+
function SupportAccess({ org, version }: { org: string; version: number }): React.JSX.Element {
|
|
848
|
+
const [again, setAgain] = useState(0);
|
|
849
|
+
const load = useDoor<{ supportAccess: { until: string } | null }>(`/-/orgs/${org}/support-access`, version + again);
|
|
850
|
+
if (load.state !== 'ok') return <Pending load={load} what="support access" />;
|
|
851
|
+
const access = load.data.supportAccess;
|
|
852
|
+
return (
|
|
853
|
+
<section className="card" aria-label="Support access">
|
|
854
|
+
<div className="setting-row"><div><strong>Support may open this org</strong><p className="mode">{access ? <>Read only, until {when(access.until)}. Each time they do is in Activity, with their reason.</> : 'Not allowed. When you write to support from Help you can allow it for seven days, read only.'}</p></div>
|
|
855
|
+
{access ? <button type="button" className="danger-outline" onClick={() => void act(`/-/orgs/${org}/support-access`, { method: 'DELETE' }, 'Support access revoked').then(() => setAgain((n) => n + 1))}>Revoke</button> : <span className="badge">off</span>}</div>
|
|
856
|
+
</section>
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ── Activity ─────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
861
|
+
type AuditRow = { at: string; event: string; actor: { id?: string; email?: string; operator?: boolean; platform?: boolean }; target?: string };
|
|
862
|
+
const AREAS: Array<[string, string, RegExp]> = [['all', 'All activity', /./], ['worlds', 'Worlds', /^WORLD_/], ['members', 'Members', /^(MEMBER_|INVITE_)/], ['tokens', 'Tokens', /TOKEN/], ['billing', 'Billing', /^(PLAN_|QUOTA_|SPEND_|REMINDER_)/], ['settings', 'Settings', /^(ORG_|SECURITY_|LABS_|WEBHOOK_|SUPPORT_|IMPERSONATION)/]];
|
|
863
|
+
/** What happened in the org: the audit log, newest first, filtered by area and searched, downloadable as kept. */
|
|
864
|
+
function ActivityPage({ org, version }: { org: string; version: number }): React.JSX.Element {
|
|
865
|
+
const [again, setAgain] = useState(0);
|
|
866
|
+
const load = useDoor<{ entries: AuditRow[] }>(`/-/orgs/${org}/audit`, version + again);
|
|
867
|
+
const [area, setArea] = useState('all'); const [q, setQ] = useState(''); const [shown, setShown] = useState(50);
|
|
868
|
+
const who = (a: AuditRow['actor']): string => (a.platform ? 'Volter World' : a.operator ? 'Support' : a.email ?? a.id ?? '?');
|
|
869
|
+
const test = AREAS.find(([k]) => k === area)?.[2] ?? /./;
|
|
870
|
+
const rows = load.state === 'ok' ? load.data.entries.filter((r) => test.test(r.event) && `${r.event} ${who(r.actor)} ${r.target ?? ''}`.toLowerCase().includes(q.trim().toLowerCase())) : [];
|
|
871
|
+
return (
|
|
872
|
+
<>
|
|
873
|
+
<Head title="Activity" lede="Every admin act in the org, who did it and when.">
|
|
874
|
+
<a className="button" href={`/-/orgs/${org}/audit.jsonl`} download={`${org}-activity.jsonl`}>Download log</a>
|
|
875
|
+
<a className="button" href={`/-/orgs/${org}/export`} download={`${org}-export.json`}>Export org</a>
|
|
876
|
+
</Head>
|
|
877
|
+
<div className="toolbar"><select aria-label="Area" value={area} onChange={(e) => { setArea(e.target.value); setShown(50); }}>{AREAS.map(([k, label]) => <option key={k} value={k}>{label}</option>)}</select><input type="search" aria-label="Search the activity" placeholder="Search by person, act or thing…" value={q} onChange={(e) => { setQ(e.target.value); setShown(50); }} /></div>
|
|
878
|
+
{load.state !== 'ok' ? <Pending load={load} what="the activity" retry={() => setAgain((n) => n + 1)} lines={6} /> : rows.length === 0 ? <Empty title={load.data.entries.length === 0 ? 'Nothing yet' : 'Nothing matches'}>{load.data.entries.length === 0 ? 'Acts in the org (a World made, a member added, a token made) show here.' : 'Try another area or search.'}</Empty> : (
|
|
879
|
+
<div className="block">
|
|
880
|
+
<table className="dense" aria-label={`${org}'s activity`}>
|
|
881
|
+
<thead><tr><th>When</th><th>What</th><th>Who</th><th>On</th></tr></thead>
|
|
882
|
+
<tbody>{rows.slice(0, shown).map((r, i) => <tr key={`${r.at}-${i}`}><td className="mode nowrap"><span title={when(r.at)}>{ago(r.at)}</span></td><td>{sentence(r.event)}</td><td><span className="vendor-cell"><Initial of={who(r.actor)} small />{who(r.actor)}</span></td><td className="mode">{r.target ?? ''}</td></tr>)}</tbody>
|
|
883
|
+
</table>
|
|
884
|
+
{rows.length > shown ? <div className="table-note"><button type="button" onClick={() => setShown((n) => n + 50)}>Show {Math.min(50, rows.length - shown)} more</button></div> : null}
|
|
885
|
+
</div>
|
|
886
|
+
)}
|
|
887
|
+
</>
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
/** An audit event as a sentence, the way the peers' logs read ("Created a World"); an event not named here is spelled out. */
|
|
891
|
+
const SAID: Record<string, string> = {
|
|
892
|
+
ORG_CREATE: 'Created the org', ORG_RENAME: 'Renamed the org', ORG_DELETE: 'Deleted the org', MEMBER_INVITE: 'Invited or added a member', INVITE_REVOKE: 'Revoked an invitation', MEMBER_REMOVE: 'Removed a member', MEMBER_ROLE: 'Changed a role',
|
|
893
|
+
WORLD_PROVISION: 'Created a World', WORLD_DELETE: 'Deleted a World', WORLD_OPEN: 'Opened a World', PREVIEW_CREATE: 'Made a preview', PREVIEW_DELETE: 'Removed a preview', WORLD_CLAIM: 'Claimed a World into the org', TOKEN_CREATE: 'Made a token', TOKEN_REVOKE: 'Revoked a token', ORG_TOKEN_CREATE: 'Made an org token', ORG_TOKEN_REVOKE: 'Revoked an org token',
|
|
894
|
+
SECURITY_CHANGE: 'Changed security settings', SUPPORT_REQUEST: 'Wrote to support', SUPPORT_ACCESS_GRANTED: 'Allowed support access', SUPPORT_ACCESS_REVOKED: 'Revoked support access', IMPERSONATION: 'Support opened the org',
|
|
895
|
+
WEBHOOK_CREATE: 'Added a webhook endpoint', WEBHOOK_CHANGE: 'Changed a webhook endpoint', WEBHOOK_DELETE: 'Removed a webhook endpoint', WEBHOOK_DISABLED: 'A webhook endpoint was disabled', SPEND_CAP_CHANGE: 'Changed the spend cap', LABS_CHANGE: 'Changed Labs',
|
|
896
|
+
REMINDER_SENT: 'Sent a billing reminder', PLAN_CHECKOUT: 'Opened a checkout', PLAN_CHANGE: 'The plan changed', QUOTA_WARNED: 'Went over the plan', QUOTA_RESTRICTED: 'The org was held', QUOTA_CLEARED: 'Back within the plan',
|
|
897
|
+
};
|
|
898
|
+
const sentence = (event: string): string => { if (SAID[event]) return SAID[event]!; const s = event.toLowerCase().replace(/_/g, ' '); return s.charAt(0).toUpperCase() + s.slice(1); };
|
|
899
|
+
|
|
900
|
+
// ── You ──────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
901
|
+
/** You: who the platform knows you as, where your sign-in is kept, and the tokens that sign the command in as you. */
|
|
902
|
+
function AccountPage({ person, info, onSignOut }: { person: Person; info: PlatformInfo | null; onSignOut: () => void }): React.JSX.Element {
|
|
903
|
+
return (
|
|
904
|
+
<>
|
|
905
|
+
<Head title="Account" />
|
|
906
|
+
<section className="card profile" aria-labelledby="you">
|
|
907
|
+
<Initial of={person.name ?? person.email ?? '?'} />
|
|
908
|
+
<div className="grow"><h3 id="you">{person.name ?? person.email ?? person.id}</h3>{person.name && person.email ? <p className="mode">{person.email}</p> : null}{info ? <p className="mode">Signed in with {info.provider.name}{info.provider.account ? <>. Your name, email and how you sign in are kept there.</> : '.'}</p> : null}</div>
|
|
909
|
+
<div className="card-actions">{info?.provider.account ? <a className="button" href={info.provider.account}>Manage your {info.provider.name} account</a> : null}<button type="button" onClick={onSignOut}>Sign out</button></div>
|
|
910
|
+
</section>
|
|
911
|
+
<TokensPanel kind="personal" />
|
|
912
|
+
</>
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** A new org: its address, and the site's terms accepted where the platform has a site. The first is where a new person starts. */
|
|
917
|
+
function CreateOrg({ first, site, onMade }: { first: boolean; site?: string | undefined; onMade: (slug: string) => Promise<void> }): React.JSX.Element {
|
|
918
|
+
const [name, setName] = useState(''); const [accepted, setAccepted] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
|
|
919
|
+
const issue = !name ? null : !NAME.test(name) ? 'Lowercase letters, digits and dashes, starting with a letter or digit.' : (RESERVED_ORG_NAMES as readonly string[]).includes(name) ? `${name} is one of this platform's own pages: choose another.` : null;
|
|
920
|
+
const create = async (e: React.FormEvent): Promise<void> => {
|
|
921
|
+
e.preventDefault(); if (issue || !name) return; setBusy(true); setError(null);
|
|
922
|
+
const r = await door('/-/orgs', { method: 'POST', body: JSON.stringify({ name, ...(site ? { accepted } : {}) }) }).catch(() => null);
|
|
923
|
+
const body = r ? ((await r.json().catch(() => ({}))) as { error?: string; slug?: string; name?: string }) : { error: `${ORIGIN} did not answer` };
|
|
924
|
+
if (!r?.ok) { setBusy(false); setError(body.error ?? 'refused'); return; }
|
|
925
|
+
toast(`Created ${body.slug ?? name}`); await onMade(body.slug ?? body.name ?? '');
|
|
926
|
+
};
|
|
927
|
+
return (
|
|
928
|
+
<div className="narrow">
|
|
929
|
+
<Head title={first ? 'Welcome to Volter World' : 'Create an org'} lede={first ? 'An org holds your Worlds and your teammates. Make one to start; you can invite people and make more orgs later.' : 'An org holds Worlds and the people who work on them.'} />
|
|
930
|
+
<section className="card" aria-labelledby="create-org">
|
|
931
|
+
<h3 id="create-org" className="sr">Create an org</h3>
|
|
932
|
+
<form onSubmit={create} className="stack">
|
|
933
|
+
<label>Org name <input autoFocus name="orgName" value={name} onChange={(e) => setName(e.target.value.toLowerCase())} placeholder="acme" aria-invalid={Boolean(issue)} aria-describedby="org-name-help" autoComplete="off" spellCheck={false} required /></label>
|
|
934
|
+
<p id="org-name-help" className={issue ? 'field-error' : 'mode'}>{issue ?? <>Its address: <code>{ORIGIN}/{name || 'acme'}</code>, and <code>{name || 'acme'}/<world></code> for its Worlds. You can change the name people see later, not the address.</>}</p>
|
|
935
|
+
{site ? <label className="check"><input name="accepted" type="checkbox" checked={accepted} onChange={(e) => setAccepted(e.target.checked)} required /> <span>I agree to the <a href={`${site}/terms.html`} target="_blank" rel="noreferrer">terms of service</a> and the <a href={`${site}/privacy.html`} target="_blank" rel="noreferrer">privacy policy</a></span></label> : null}
|
|
936
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
937
|
+
<div className="card-actions"><button type="submit" disabled={busy || !name || Boolean(issue) || (Boolean(site) && !accepted)}>{busy ? 'Creating…' : 'Create org'}</button></div>
|
|
938
|
+
</form>
|
|
939
|
+
</section>
|
|
940
|
+
</div>
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** Help: ask a person — what it is about, how urgent, where, and the consent for support to look. */
|
|
945
|
+
function HelpPage({ orgs, onChanged }: { orgs: string[]; onChanged?: () => void }): React.JSX.Element {
|
|
946
|
+
const [category, setCategory] = useState('worlds'); const [severity, setSeverity] = useState('normal'); const [subject, setSubject] = useState(''); const [message, setMessage] = useState('');
|
|
947
|
+
const [org, setOrg] = useState(orgs[0] ?? ''); const [world, setWorld] = useState(''); const [allow, setAllow] = useState(false); const [sent, setSent] = useState<{ id: string; copyTo: string | null; until: string | null } | null>(null); const [busy, setBusy] = useState(false);
|
|
948
|
+
useEffect(() => { if (!org && orgs[0]) setOrg(orgs[0]); }, [orgs, org]);
|
|
949
|
+
const send = async (e: React.FormEvent): Promise<void> => {
|
|
950
|
+
e.preventDefault(); setBusy(true);
|
|
951
|
+
const b = await act<{ id?: string; copyTo?: string | null; access?: { until: string } | null }>('/-/support', { method: 'POST', body: JSON.stringify({ category, severity, subject, message, ...(org ? { org } : {}), ...(world ? { world } : {}), ...(allow && org ? { allowAccessDays: 7 } : {}) }) });
|
|
952
|
+
setBusy(false);
|
|
953
|
+
if (b?.id) { setSent({ id: b.id, copyTo: b.copyTo ?? null, until: b.access?.until ?? null }); setSubject(''); setMessage(''); setWorld(''); setAllow(false); onChanged?.(); }
|
|
954
|
+
};
|
|
955
|
+
if (sent) return (
|
|
956
|
+
<div className="narrow"><Head title="Help" /><section className="card sent" role="status"><h3>We have your request</h3><p>Your ticket is <code>{sent.id}</code>.{sent.copyTo ? ` A copy went to ${sent.copyTo}.` : ''} A person answers, usually within a working day.</p>{sent.until ? <p className="mode">Support may open {org}, read only, until {when(sent.until)}. You can revoke it under Settings › Support access.</p> : null}<div className="card-actions"><button type="button" onClick={() => setSent(null)}>Write another</button></div></section></div>
|
|
957
|
+
);
|
|
958
|
+
return (
|
|
959
|
+
<div className="narrow">
|
|
960
|
+
<Head title="Help" lede="Ask a person. Say what you saw and where: the org and the World go along with it." />
|
|
961
|
+
<section className="card" aria-label="Write to support">
|
|
962
|
+
<form onSubmit={send} className="stack">
|
|
963
|
+
<div className="two"><label>About <select name="helpCategory" value={category} onChange={(e) => setCategory(e.target.value)}><option value="worlds">A World or a twin</option><option value="billing">Billing</option><option value="account">My account or team</option><option value="security">Security</option><option value="other">Something else</option></select></label>
|
|
964
|
+
<label>How urgent <select name="helpSeverity" value={severity} onChange={(e) => setSeverity(e.target.value)}><option value="low">Low: a question</option><option value="normal">Normal</option><option value="high">High: something is broken</option><option value="urgent">Urgent: we cannot work</option></select></label></div>
|
|
965
|
+
<div className="two">{orgs.length > 0 ? <label>Org <select name="helpOrg" value={org} onChange={(e) => setOrg(e.target.value)}>{orgs.map((o) => <option key={o} value={o}>{o}</option>)}</select></label> : null}
|
|
966
|
+
<label><span>World <span className="mode">(optional)</span></span><input name="helpWorld" value={world} onChange={(e) => setWorld(e.target.value)} placeholder="acme/staging" /></label></div>
|
|
967
|
+
<label>Subject <input name="helpSubject" value={subject} onChange={(e) => setSubject(e.target.value)} maxLength={200} required /></label>
|
|
968
|
+
<label>What happened <textarea name="helpMessage" value={message} onChange={(e) => setMessage(e.target.value)} rows={6} maxLength={5000} required /></label>
|
|
969
|
+
{org ? <label className="check"><input type="checkbox" name="helpAllow" checked={allow} onChange={(e) => setAllow(e.target.checked)} /> <span>Let support open {org}, read only, for seven days, so they see what you see. Admins only; you can revoke it.</span></label> : null}
|
|
970
|
+
<div className="card-actions"><button type="submit" disabled={busy || !subject.trim() || !message.trim()}>{busy ? 'Sending…' : 'Send to support'}</button></div>
|
|
971
|
+
</form>
|
|
972
|
+
</section>
|
|
973
|
+
</div>
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** The first run: what a person does next, with the real commands, as a checklist with its progress; dismissed for good. */
|
|
978
|
+
function GettingStarted({ orgs, worlds, firstWorld, version }: { orgs: string[]; worlds: number; firstWorld?: string | undefined; version: number }): React.JSX.Element | null {
|
|
979
|
+
const KEY = 'volter.console.onboarding';
|
|
980
|
+
const [dismissed, setDismissed] = useState<boolean>(() => { try { return localStorage.getItem(KEY) === 'done'; } catch { return false; } });
|
|
981
|
+
const [tokensMade, setTokensMade] = useState<boolean | null>(null);
|
|
982
|
+
const mounted = useRef(true); useEffect(() => () => { mounted.current = false; }, []);
|
|
983
|
+
useEffect(() => { void door('/-/tokens').then(async (r) => { if (mounted.current) setTokensMade(r.ok ? ((await r.json()) as { tokens: unknown[] }).tokens.length > 0 : false); }).catch(() => undefined); }, [version]);
|
|
984
|
+
if (dismissed) return null;
|
|
985
|
+
const org = orgs[0];
|
|
986
|
+
// the way in is the app's folder (docs/contributing/architecture.md): the command makes the World from the vendors
|
|
987
|
+
// the app uses; Create World above is the way for a person without a terminal
|
|
988
|
+
const steps: Array<[boolean, React.ReactNode]> = [
|
|
989
|
+
[orgs.length > 0, 'Create an org'],
|
|
990
|
+
[tokensMade === true, <>Install the command and sign it in: <code>npm install -g @volter/world</code>, then <code>volter login {ORIGIN}</code></>],
|
|
991
|
+
[worlds > 0, <>In your app's folder, make its World here: <code>volter world init</code>, then <code>volter remote add origin {firstWorld ?? `${org ?? '<org>'}/<world>`}{firstWorld ? '' : ' --create'}</code></>],
|
|
992
|
+
[false, <>Send it what your app did: <code>volter world changeset -m "…"</code>, then <code>volter world push</code></>],
|
|
993
|
+
];
|
|
994
|
+
const done = steps.filter(([d]) => d).length;
|
|
995
|
+
return (
|
|
996
|
+
<section aria-labelledby="getting-started" className="onboarding">
|
|
997
|
+
<div className="onboarding-head"><h3 id="getting-started">Get started</h3><span className="mode">{done} of {steps.length}</span><div className="progress" aria-hidden="true"><span style={{ width: `${(done / steps.length) * 100}%` }} /></div><button type="button" className="icon-button" aria-label="Dismiss getting started" onClick={() => { try { localStorage.setItem(KEY, 'done'); } catch { /* no storage */ } setDismissed(true); }}>✕</button></div>
|
|
998
|
+
<ol>{steps.map(([d, text], i) => <li key={i} className={d ? 'done' : undefined}><span className="tick" aria-hidden="true">{d ? '✓' : i + 1}</span><span>{text}</span><span className="sr">{d ? '(done)' : ''}</span></li>)}</ol>
|
|
999
|
+
<p className="mode onboarding-alt">No terminal? <strong>Create World</strong> above makes one here, with the vendors you pick.</p>
|
|
1000
|
+
<AskYourAgent world={firstWorld} />
|
|
1001
|
+
</section>
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** A World's pull-request previews (the setup-world Action makes them as branches), under its row: each opened as
|
|
1006
|
+
* yourself, removed by hand if its pull request never closes. Nothing shows while a World has none. */
|
|
1007
|
+
function PreviewRows({ world, readOnly }: { world: string; readOnly: boolean }): React.JSX.Element | null {
|
|
1008
|
+
type Row = { label: string; createdAt: string; expiresAt: string | null; open: string; live: boolean };
|
|
1009
|
+
const [rows, setRows] = useState<Row[]>([]);
|
|
1010
|
+
const load = useCallback(() => { void door(`/-/worlds/${world}/previews`).then(async (r) => { if (r.ok) setRows(((await r.json()) as { previews: Row[] }).previews); }).catch(() => undefined); }, [world]);
|
|
1011
|
+
useEffect(load, [load]);
|
|
1012
|
+
const remove = async (label: string): Promise<void> => {
|
|
1013
|
+
if (!(await confirmAct({ title: `Remove the preview ${label}?`, body: "Its branch and everything written to it go; the pull request's next run makes a new one.", action: 'Remove preview', danger: true }))) return;
|
|
1014
|
+
if (await act(`/-/worlds/${world}/previews/${label}`, { method: 'DELETE' }, `Removed ${label}`)) load();
|
|
1015
|
+
};
|
|
1016
|
+
if (!rows.length) return null;
|
|
1017
|
+
return (
|
|
1018
|
+
<tr className="preview-row">
|
|
1019
|
+
<td colSpan={5}>
|
|
1020
|
+
<span className="mode">Previews</span>
|
|
1021
|
+
<ul className="previews">{rows.map((p) => (
|
|
1022
|
+
<li key={p.label}>
|
|
1023
|
+
<code>{p.label}</code>
|
|
1024
|
+
<span className="mode" title={when(p.createdAt)}>{ago(p.createdAt)}{p.expiresAt ? <> · ends <span title={when(p.expiresAt)}>{day(p.expiresAt)}</span></> : null}{p.live ? null : ' · not running'}</span>
|
|
1025
|
+
{p.live ? <a href={p.open}>Open</a> : null}
|
|
1026
|
+
{!readOnly ? <button type="button" className="link-button" onClick={() => void remove(p.label)}>Remove</button> : null}
|
|
1027
|
+
</li>
|
|
1028
|
+
))}</ul>
|
|
1029
|
+
</td>
|
|
1030
|
+
</tr>
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Get started's other way: the same setup as a prompt for the person's coding agent (packages/cli/src/agent-prompt.ts, the
|
|
1035
|
+
* one text), with this platform and the org's first World filled in, copied whole. */
|
|
1036
|
+
function AskYourAgent({ world }: { world?: string | undefined }): React.JSX.Element {
|
|
1037
|
+
const text = agentPrompt({ platform: ORIGIN, ...(world ? { world } : {}) });
|
|
1038
|
+
const copy = (): void => { void navigator.clipboard?.writeText(text).then(() => toast('Copied the prompt: paste it into your coding agent'), () => toast('Select the prompt and copy it', 'error')); };
|
|
1039
|
+
return (
|
|
1040
|
+
<details className="ask-agent">
|
|
1041
|
+
<summary><span>Or ask your coding agent to do all of this</span><button type="button" onClick={(e) => { e.preventDefault(); copy(); }}>Copy prompt</button></summary>
|
|
1042
|
+
<pre>{text}</pre>
|
|
1043
|
+
<p className="mode">Paste it into Claude Code, Cursor, VS Code or Codex. You approve the sign-in in your browser; the agent does the rest.</p>
|
|
1044
|
+
</details>
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
applyTheme(); // the person's theme before the first paint
|
|
1049
|
+
createRoot(ROOT).render(<><App /><Toaster /><ConfirmHost /><MenuDismiss /></>);
|