@volter/world-platform 2.0.2 → 2.0.4
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/client/platform.css +9 -0
- package/client/platform.tsx +98 -18
- package/dist/client/platform.bundle.js +83 -83
- package/dist/client/platform.css +9 -0
- package/dist/client/platform.tsx +98 -18
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +1 -1
- package/dist/src/db/migrations/0001_world_access.sql +8 -0
- package/dist/src/db/migrations/meta/0001_snapshot.json +930 -0
- package/dist/src/db/migrations/meta/_journal.json +7 -0
- package/dist/src/db/migrations.js +6 -0
- package/dist/src/db/pack-migrations.js +1 -1
- package/dist/src/db/schema.d.ts +104 -0
- package/dist/src/db/schema.js +9 -0
- package/dist/src/directory.js +3 -1
- package/dist/src/doors.js +6 -3
- package/dist/src/platform.js +192 -24
- package/dist/src/store.d.ts +12 -0
- package/dist/src/store.js +24 -2
- package/dist/src/webhooks.js +2 -2
- package/package.json +6 -6
- package/src/audit.ts +1 -1
- package/src/db/migrations/0001_world_access.sql +8 -0
- package/src/db/migrations/meta/0001_snapshot.json +930 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations.ts +6 -0
- package/src/db/pack-migrations.ts +1 -1
- package/src/db/schema.ts +10 -0
- package/src/directory.ts +2 -1
- package/src/doors.ts +6 -3
- package/src/platform.ts +127 -17
- package/src/store.ts +21 -2
- package/src/webhooks.ts +2 -2
package/client/platform.css
CHANGED
|
@@ -67,6 +67,9 @@ kbd { font: 11px/1 var(--mono); padding: 3px 5px; border: 1px solid var(--line-s
|
|
|
67
67
|
/* cards */
|
|
68
68
|
section.card { margin: 0 0 16px; padding: 18px 20px; }
|
|
69
69
|
section.card h3 { margin: 0 0 6px; font-size: 15px; }
|
|
70
|
+
.invite-list { list-style: none; margin: 8px 0 0; padding: 0; }
|
|
71
|
+
.invite-list li { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 10px 0; border-top: 1px solid var(--line); }
|
|
72
|
+
.invite-list li:first-child { border-top: 0; }
|
|
70
73
|
section.card.flush { padding: 0; overflow: hidden; }
|
|
71
74
|
.card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 16px 20px; border-bottom: 1px solid var(--line); }
|
|
72
75
|
.card-head h3 { margin: 0 0 2px; }
|
|
@@ -226,3 +229,9 @@ main.booting .side .skeleton, main.booting .content .skeleton { border: 0; backg
|
|
|
226
229
|
.previews { display: inline-flex; flex-wrap: wrap; gap: 6px 16px; list-style: none; margin: 0; padding: 0; vertical-align: middle; }
|
|
227
230
|
.previews li { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; }
|
|
228
231
|
.worlds-table tr:has(+ .preview-row) td { border-bottom: 0; }
|
|
232
|
+
|
|
233
|
+
/* who reaches a World: the member list in its access dialog, and the mark in the Worlds table */
|
|
234
|
+
fieldset.member-pick { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; max-height: 260px; overflow-y: auto; min-width: 0; }
|
|
235
|
+
fieldset.member-pick legend { font-size: 12.5px; color: var(--muted); margin-bottom: 4px; }
|
|
236
|
+
.name-line { display: inline-flex; align-items: center; gap: 8px; }
|
|
237
|
+
.badge.restricted { font-size: 10px; }
|
package/client/platform.tsx
CHANGED
|
@@ -20,6 +20,11 @@ const ORIGIN = window.location.origin;
|
|
|
20
20
|
const SUPPORT_KEY = 'volter.platform.support';
|
|
21
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
22
|
if (new URLSearchParams(window.location.search).has('support')) { try { window.history.replaceState(null, '', window.location.pathname); } catch { /* fine */ } }
|
|
23
|
+
// the plan a person chose on the pricing page (`/?plan=team`): the tab keeps it through signing in and making an org,
|
|
24
|
+
// and the org's Billing offers it once
|
|
25
|
+
const PLAN_KEY = 'volter.platform.plan';
|
|
26
|
+
{ const chosen = new URLSearchParams(window.location.search).get('plan'); if (chosen && /^[a-z0-9-]{1,32}$/.test(chosen)) { try { sessionStorage.setItem(PLAN_KEY, chosen); } catch { /* storage refused */ } try { const u = new URL(window.location.href); u.searchParams.delete('plan'); window.history.replaceState(null, '', `${u.pathname}${u.search}${u.hash}`); } catch { /* fine */ } } }
|
|
27
|
+
const planIntent = (): string | null => { try { return sessionStorage.getItem(PLAN_KEY); } catch { return null; } };
|
|
23
28
|
/** The catalog's vendors, most used first, as the shell was handed them (pages.ts): what a World can stand in for. */
|
|
24
29
|
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
30
|
|
|
@@ -71,8 +76,10 @@ const linkTo = (to: Place) => (e: React.MouseEvent<HTMLAnchorElement>): void =>
|
|
|
71
76
|
type PlatformInfo = { name: string; provider: { kind: 'volter' | 'oidc' | 'github'; name: string; account?: string }; billing: boolean; support: boolean; site?: string };
|
|
72
77
|
type Person = { id: string; email?: string; name?: string; support?: { until: string; reason: string } };
|
|
73
78
|
type OrgWorld = { name: string; host: string; base: string; provisionedAt: string };
|
|
79
|
+
/** An invitation to your address still pending, where the provider keeps them (GET /-/orgs). */
|
|
80
|
+
type Invitation = { id: string; org: { id: string; slug: string | null; name: string }; role: string; expiresAt: string | null };
|
|
74
81
|
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 }
|
|
82
|
+
type WorldRow = { org: string; world: string; name: string; base: string; origin?: string; twins: Array<{ vendor: string }>; /** kept to named members */ restricted?: boolean };
|
|
76
83
|
const slugOf = (o: OrgRow): string => o.slug ?? o.id;
|
|
77
84
|
const isAdmin = (o: OrgRow): boolean => o.role === 'admin' || o.role === 'org:admin';
|
|
78
85
|
const NAME = /^[a-z0-9][a-z0-9-]{0,38}$/;
|
|
@@ -84,7 +91,7 @@ function App(): React.JSX.Element {
|
|
|
84
91
|
const [version, setVersion] = useState(0);
|
|
85
92
|
const bump = useCallback(() => setVersion((v) => v + 1), []);
|
|
86
93
|
// 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' });
|
|
94
|
+
const [me, setMe] = useState<{ state: 'loading' } | { state: 'out' } | { state: 'error'; error: string } | { state: 'in'; person: Person; orgs: OrgRow[]; invitations: Invitation[] }>({ state: 'loading' });
|
|
88
95
|
const loadMe = useCallback(async () => {
|
|
89
96
|
const r = await door('/-/orgs').catch(() => null);
|
|
90
97
|
if (!r) { setMe({ state: 'error', error: `${ORIGIN} did not answer` }); return; }
|
|
@@ -93,8 +100,8 @@ function App(): React.JSX.Element {
|
|
|
93
100
|
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
101
|
if (r.status === 401) { setMe({ state: 'out' }); return; }
|
|
95
102
|
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 });
|
|
103
|
+
const b = (await r.json()) as { person: Person; orgs: OrgRow[]; invitations?: Invitation[] };
|
|
104
|
+
setMe({ state: 'in', person: b.person, orgs: b.orgs, invitations: b.invitations ?? [] });
|
|
98
105
|
}, []);
|
|
99
106
|
useEffect(() => { void loadMe(); }, [loadMe, version]);
|
|
100
107
|
const worlds = useDoor<{ worlds: WorldRow[] }>(me.state === 'in' ? '/-/worlds' : null, version);
|
|
@@ -114,10 +121,12 @@ function App(): React.JSX.Element {
|
|
|
114
121
|
if (me.state === 'loading' || info.state === 'loading') return <Booting />;
|
|
115
122
|
if (me.state === 'out') return <SignIn info={p} />;
|
|
116
123
|
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;
|
|
124
|
+
const { person, orgs, invitations } = me;
|
|
125
|
+
const joined = async (slug: string): Promise<void> => { await loadMe(); bump(); go({ kind: 'org', org: slug, page: 'worlds' }); };
|
|
118
126
|
const current = at.kind === 'org' ? orgs.find((o) => o.slug === at.org || o.id === at.org) ?? null : null;
|
|
119
127
|
// home is your first org, or making one; an org named by its id (a mail's link) is shown by its slug
|
|
120
|
-
|
|
128
|
+
// (with a plan chosen on the pricing page, home is the Billing of the first org you administer)
|
|
129
|
+
if (at.kind === 'home') { const buying = Boolean(p?.billing && planIntent()); const first = buying ? orgs.find(isAdmin) : orgs[0]; go(first ? { kind: 'org', org: slugOf(first), page: buying ? 'billing' : 'worlds' } : { kind: 'you', page: 'new' }, true); return <Booting />; }
|
|
121
130
|
if (at.kind === 'org' && current && at.org !== slugOf(current)) { go({ ...at, org: slugOf(current) }, true); return <Booting />; }
|
|
122
131
|
// the org the sidebar shows: the one you are in, else your first
|
|
123
132
|
const shown = current ?? (at.kind === 'you' ? orgs[0] ?? null : null);
|
|
@@ -165,6 +174,7 @@ function App(): React.JSX.Element {
|
|
|
165
174
|
</nav>
|
|
166
175
|
</aside>
|
|
167
176
|
<div className="content">
|
|
177
|
+
{invitations.length && !(at.kind === 'you' && at.page === 'new') ? <Invitations list={invitations} onJoined={joined} /> : null}
|
|
168
178
|
{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
179
|
{current?.held && at.kind === 'org' && at.page !== 'billing' ? <p role="alert">This org is held: {current.held}.</p> : null}
|
|
170
180
|
{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}
|
|
@@ -174,7 +184,7 @@ function App(): React.JSX.Element {
|
|
|
174
184
|
{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
185
|
{at.kind === 'you' && at.page === 'account' ? <AccountPage person={person} info={p} onSignOut={signOut} /> : null}
|
|
176
186
|
{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}
|
|
187
|
+
{at.kind === 'you' && at.page === 'new' ? <CreateOrg first={orgs.length === 0} site={p?.site} invitations={invitations} onJoined={joined} onMade={async (slug) => { await loadMe(); bump(); go({ kind: 'org', org: slug, page: p?.billing && planIntent() ? 'billing' : 'worlds' }); }} /> : null}
|
|
178
188
|
</div>
|
|
179
189
|
</div>
|
|
180
190
|
<Palette open={palette} onClose={() => setPalette(false)} orgs={orgs} shown={shown} worlds={worlds.state === 'ok' ? worlds.data.worlds : []} pages={pages} support={Boolean(p?.support)} />
|
|
@@ -244,10 +254,12 @@ function SignIn({ info }: { info: PlatformInfo | null }): React.JSX.Element {
|
|
|
244
254
|
return (
|
|
245
255
|
<div className="card" role="main">
|
|
246
256
|
<div className="brand"><Brand href="/" /></div>
|
|
247
|
-
<h2>Sign in to Volter World</h2>
|
|
257
|
+
<h2>{signedOut ? 'Sign in to Volter World' : 'Sign in or create your account'}</h2>
|
|
248
258
|
{signedOut ? <p role="status">You are signed out.</p> : <p>Your orgs, their Worlds and your teammates.</p>}
|
|
249
|
-
|
|
250
|
-
|
|
259
|
+
{/* Volter's account is where GitHub, Google and email are offered: the button names them, not the account a
|
|
260
|
+
newcomer has never heard of */}
|
|
261
|
+
<a className="button primary wide" href={`/-/sign-in${next !== '/' && !signedOut ? `?next=${encodeURIComponent(next)}` : ''}`}>{info?.provider.kind === 'volter' ? 'Continue with GitHub, Google or email' : `Continue with ${provider}`}</a>
|
|
262
|
+
{info?.provider.kind === 'volter' ? <p className="small">New here? Your account is made on the way, the one account for every Volter product.</p> : null}
|
|
251
263
|
{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
264
|
</div>
|
|
253
265
|
);
|
|
@@ -268,6 +280,7 @@ function Head({ title, count, children, lede }: { title: string; count?: number;
|
|
|
268
280
|
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
281
|
const slug = slugOf(org);
|
|
270
282
|
const [making, setMaking] = useState(false); const [q, setQ] = useState(''); const [keyFor, setKeyFor] = useState<string | null>(null);
|
|
283
|
+
const [accessFor, setAccessFor] = useState<string | null>(null);
|
|
271
284
|
// a read-only link: a read key (the platform's key door, 30 days) carried in the World's dashboard address
|
|
272
285
|
const [link, setLink] = useState<{ world: string; url: string } | null>(null);
|
|
273
286
|
const shareLink = async (w: WorldRow): Promise<void> => {
|
|
@@ -299,7 +312,7 @@ function WorldsPage({ org, rows, load, orgs, allRows, version, readOnly, onChang
|
|
|
299
312
|
<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
313
|
<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
314
|
<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>
|
|
315
|
+
<td><span className="vendor-cell"><Initial of={short} small /><span className="who"><span className="name-line"><a href={`/-/worlds/${w.name}/open`}>{short}</a>{w.restricted ? <span className="badge restricted" title="Only some members of the org reach it">Restricted</span> : null}</span>{w.origin ? <span className="mode mono">{new URL(w.origin).host}</span> : null}</span></span></td>
|
|
303
316
|
<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
317
|
<td className="mode">{rec?.host ?? '—'}</td>
|
|
305
318
|
<td className="mode">{rec ? <span title={when(rec.provisionedAt)}>{ago(rec.provisionedAt)}</span> : '—'}</td>
|
|
@@ -307,6 +320,7 @@ function WorldsPage({ org, rows, load, orgs, allRows, version, readOnly, onChang
|
|
|
307
320
|
<Menu label={`More for ${w.name}`} items={[
|
|
308
321
|
{ 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
322
|
!readOnly ? { label: 'Make a key…', onSelect: () => setKeyFor(w.name) } : null,
|
|
323
|
+
isAdmin(org) && !readOnly ? { label: 'Manage access…', onSelect: () => setAccessFor(w.name) } : null,
|
|
310
324
|
!readOnly ? { label: 'Share a read-only link…', onSelect: () => void shareLink(w) } : null,
|
|
311
325
|
w.origin ? { label: 'Copy its address', onSelect: () => { void navigator.clipboard?.writeText(w.origin!).then(() => toast('Copied')); } } : null,
|
|
312
326
|
!readOnly ? { label: 'Delete World…', danger: true, onSelect: () => void remove(w.name) } : null,
|
|
@@ -320,6 +334,7 @@ function WorldsPage({ org, rows, load, orgs, allRows, version, readOnly, onChang
|
|
|
320
334
|
</div>
|
|
321
335
|
)}
|
|
322
336
|
<WorldKey world={keyFor} onClose={() => setKeyFor(null)} />
|
|
337
|
+
<WorldAccess org={org} world={accessFor} onClose={() => setAccessFor(null)} onSaved={onChanged} />
|
|
323
338
|
<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
339
|
<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
340
|
{link ? <CopyField label="Link" value={link.url} secret /> : null}
|
|
@@ -493,6 +508,8 @@ const money = (cents: number): string => (cents % 100 === 0 ? `$${cents / 100}`
|
|
|
493
508
|
function BillingPage({ org, admin, version, flags }: { org: string; admin: boolean; version: number; flags: Record<string, boolean> }): React.JSX.Element {
|
|
494
509
|
// back from Polar's checkout (`/<org>/billing?upgraded=1`): the first read is Polar's state now, not the cache
|
|
495
510
|
const [fresh] = useState(() => new URLSearchParams(window.location.search).has('upgraded'));
|
|
511
|
+
// the plan chosen on the pricing page, offered here once
|
|
512
|
+
const [intent] = useState(() => { const i = planIntent(); try { sessionStorage.removeItem(PLAN_KEY); } catch { /* fine */ } return i; });
|
|
496
513
|
useEffect(() => { if (fresh) { try { window.history.replaceState(null, '', window.location.pathname); } catch { /* fine */ } } }, [fresh]);
|
|
497
514
|
const [again, setAgain] = useState(0);
|
|
498
515
|
const load = useDoor<BillingInfo>(`/-/orgs/${org}/billing${fresh ? '?fresh=1' : ''}`, version + again);
|
|
@@ -516,6 +533,7 @@ function BillingPage({ org, admin, version, flags }: { org: string; admin: boole
|
|
|
516
533
|
<Head title="Billing" lede={<>For {org}. Payment, invoices and receipts are handled by Polar{admin ? '' : '; an admin changes the plan'}.</>} />
|
|
517
534
|
{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
535
|
{info.paid ? <div role="status" className="notice ok"><strong>Payment received.</strong> Your org is on {info.plan.name}.</div> : null}
|
|
536
|
+
{(() => { const chosen = intent && admin && info.plan.id === 'free' && !info.pendingCheckout ? info.plans.find((c) => c.id === intent && c.priceMonthly > 0) : undefined; return chosen ? <div role="status" className="notice info"><strong>You chose {chosen.name}.</strong> {chosen.trialDays && !sub ? `Its ${chosen.trialDays}-day trial starts at Polar's checkout, which asks for a card and charges it when the trial ends.` : "Polar's checkout takes the payment."} <button type="button" className="primary" disabled={leaving} onClick={() => void leaveFor(`/-/orgs/${org}/checkout`)}>{leaving ? 'Opening Polar…' : chosen.trialDays && !sub ? `Start the ${chosen.trialDays}-day trial` : `Upgrade to ${chosen.name}`}</button></div> : null; })()}
|
|
519
537
|
{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
538
|
<div className="billing-grid">
|
|
521
539
|
<section className="card plan-card" aria-labelledby="plan-now">
|
|
@@ -716,6 +734,48 @@ function MakeToken({ open, kind, base, onClose, onMade }: { open: boolean; kind:
|
|
|
716
734
|
}
|
|
717
735
|
|
|
718
736
|
/** A key for one World, for an app or a job: made here (a browser session on the World makes none), shown once. */
|
|
737
|
+
/** Who reaches a World (architecture, "A World may be kept to some of its org"): the whole org, or only the members
|
|
738
|
+
* ticked here. Admins always reach it; the members it leaves out lose the keys they hold there when it is saved. */
|
|
739
|
+
function WorldAccess({ org, world, onClose, onSaved }: { org: OrgRow; world: string | null; onClose: () => void; onSaved: () => void }): React.JSX.Element {
|
|
740
|
+
const slug = slugOf(org); const short = world?.split('/')[1] ?? '';
|
|
741
|
+
const [members, setMembers] = useState<MemberRow[] | null>(null); const [only, setOnly] = useState(false); const [picked, setPicked] = useState<Set<string>>(new Set());
|
|
742
|
+
const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
|
|
743
|
+
useEffect(() => {
|
|
744
|
+
if (!world) return; setMembers(null); setError(null);
|
|
745
|
+
void Promise.all([door(`/-/worlds/${world}/access`).then((r) => r.json()), door(`/-/orgs/${slug}/members`).then((r) => r.json())]).then(([a, m]) => {
|
|
746
|
+
const access = a as { restricted?: boolean; members?: string[]; error?: string }; const list = (m as { members?: MemberRow[] }).members ?? [];
|
|
747
|
+
if (access.error) { setError(access.error); return; }
|
|
748
|
+
setOnly(Boolean(access.restricted)); setPicked(new Set(access.members ?? [])); setMembers(list);
|
|
749
|
+
}).catch(() => setError(`${ORIGIN} did not answer`));
|
|
750
|
+
}, [world, slug]);
|
|
751
|
+
const others = (members ?? []).filter((m) => !m.role.endsWith('admin'));
|
|
752
|
+
const leftOut = only ? others.filter((m) => !picked.has(m.userId)).length : 0;
|
|
753
|
+
const save = async (e: React.FormEvent): Promise<void> => {
|
|
754
|
+
e.preventDefault(); if (!world) return; setBusy(true);
|
|
755
|
+
const done = await act<{ revokedFor: number }>(`/-/worlds/${world}/access`, { method: 'PUT', body: JSON.stringify({ restricted: only, members: only ? [...picked] : [] }) }, only ? `${short} is kept to the members picked` : `Everyone in ${org.name} reaches ${short}`);
|
|
756
|
+
setBusy(false); if (done) { onSaved(); onClose(); }
|
|
757
|
+
};
|
|
758
|
+
return (
|
|
759
|
+
<Dialog open={world !== null} onClose={() => { if (!busy) onClose(); }} title={`Who reaches ${world ?? ''}`} footer={<><button type="button" onClick={onClose} disabled={busy}>Cancel</button><button type="submit" form="world-access" disabled={busy || members === null}>{busy ? 'Saving…' : 'Save'}</button></>}>
|
|
760
|
+
<form id="world-access" onSubmit={save} className="stack" aria-label="Who reaches the World">
|
|
761
|
+
{error ? <p role="alert">{error}</p> : members === null ? <p className="mode">Loading the members…</p> : <>
|
|
762
|
+
<fieldset className="choice"><legend>Access</legend>
|
|
763
|
+
<label className={!only ? 'choice-card on' : 'choice-card'}><input type="radio" name="worldAccess" checked={!only} onChange={() => setOnly(false)} /><span><strong>Everyone in {org.name}</strong><span className="mode">Every member, and everyone who joins later.</span></span></label>
|
|
764
|
+
<label className={only ? 'choice-card on' : 'choice-card'}><input type="radio" name="worldAccess" checked={only} onChange={() => setOnly(true)} /><span><strong>Only these members</strong><span className="mode">Admins always reach it.</span></span></label>
|
|
765
|
+
</fieldset>
|
|
766
|
+
{only ? <fieldset className="member-pick"><legend>Members</legend>
|
|
767
|
+
{others.length === 0 ? <p className="mode">Every member of {org.name} is an admin, so everyone reaches it.</p> : others.map((m) => (
|
|
768
|
+
<label key={m.userId} className="check"><input type="checkbox" checked={picked.has(m.userId)} onChange={(e) => { const next = new Set(picked); if (e.target.checked) next.add(m.userId); else next.delete(m.userId); setPicked(next); }} /> <span>{m.name ?? m.email ?? m.userId}{m.name && m.email ? <span className="mode"> {m.email}</span> : null}</span></label>
|
|
769
|
+
))}
|
|
770
|
+
{(members ?? []).filter((m) => m.role.endsWith('admin')).map((m) => <label key={m.userId} className="check"><input type="checkbox" checked disabled /> <span>{m.name ?? m.email ?? m.userId} <span className="mode">admin</span></span></label>)}
|
|
771
|
+
</fieldset> : null}
|
|
772
|
+
{leftOut > 0 ? <p className="mode">{leftOut === 1 ? 'The member left out loses' : `The ${leftOut} members left out lose`} the keys they hold in {short} when you save.</p> : null}
|
|
773
|
+
</>}
|
|
774
|
+
</form>
|
|
775
|
+
</Dialog>
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
719
779
|
function WorldKey({ world, onClose }: { world: string | null; onClose: () => void }): React.JSX.Element {
|
|
720
780
|
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
781
|
useEffect(() => { if (world) { setName(''); setScope('write'); setExpiry('90'); setError(null); setMade(null); } }, [world]);
|
|
@@ -889,8 +949,8 @@ function ActivityPage({ org, version }: { org: string; version: number }): React
|
|
|
889
949
|
}
|
|
890
950
|
/** An audit event as a sentence, the way the peers' logs read ("Created a World"); an event not named here is spelled out. */
|
|
891
951
|
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',
|
|
952
|
+
ORG_CREATE: 'Created the org', ORG_RENAME: 'Renamed the org', ORG_DELETE: 'Deleted the org', MEMBER_INVITE: 'Invited or added a member', MEMBER_JOIN: 'Joined by invitation', INVITE_REVOKE: 'Revoked an invitation', MEMBER_REMOVE: 'Removed a member', MEMBER_ROLE: 'Changed a role',
|
|
953
|
+
WORLD_PROVISION: 'Created a World', WORLD_DELETE: 'Deleted a World', WORLD_ACCESS_SET: 'Changed who reaches 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
954
|
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
955
|
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
956
|
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',
|
|
@@ -914,7 +974,26 @@ function AccountPage({ person, info, onSignOut }: { person: Person; info: Platfo
|
|
|
914
974
|
}
|
|
915
975
|
|
|
916
976
|
/** 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
|
-
|
|
977
|
+
/** The invitations to your address still pending: each org's name, the role, and Join (POST /-/invitations/{id}/join). */
|
|
978
|
+
function Invitations({ list, onJoined }: { list: Invitation[]; onJoined: (slug: string) => Promise<void> }): React.JSX.Element {
|
|
979
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
980
|
+
const join = async (inv: Invitation): Promise<void> => {
|
|
981
|
+
setBusy(inv.id);
|
|
982
|
+
const b = await act<{ joined?: string }>(`/-/invitations/${encodeURIComponent(inv.id)}/join`, { method: 'POST' });
|
|
983
|
+
setBusy(null);
|
|
984
|
+
if (b?.joined) { toast(`Joined ${inv.org.name}`); await onJoined(b.joined); }
|
|
985
|
+
};
|
|
986
|
+
return (
|
|
987
|
+
<section className="card" aria-labelledby="invited">
|
|
988
|
+
<h3 id="invited">{list.length === 1 ? 'You are invited' : 'You are invited to these orgs'}</h3>
|
|
989
|
+
<ul className="invite-list">
|
|
990
|
+
{list.map((inv) => <li key={inv.id}><span className="vendor-cell"><Initial of={inv.org.name} small /><span><strong>{inv.org.name}</strong> <span className="mode">as {inv.role === 'org:admin' ? 'an admin' : 'a member'}</span></span></span><button type="button" className="primary" disabled={busy !== null} onClick={() => void join(inv)}>{busy === inv.id ? 'Joining…' : `Join ${inv.org.name}`}</button></li>)}
|
|
991
|
+
</ul>
|
|
992
|
+
</section>
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function CreateOrg({ first, site, invitations, onJoined, onMade }: { first: boolean; site?: string | undefined; invitations: Invitation[]; onJoined: (slug: string) => Promise<void>; onMade: (slug: string) => Promise<void> }): React.JSX.Element {
|
|
918
997
|
const [name, setName] = useState(''); const [accepted, setAccepted] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
|
|
919
998
|
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
999
|
const create = async (e: React.FormEvent): Promise<void> => {
|
|
@@ -926,11 +1005,12 @@ function CreateOrg({ first, site, onMade }: { first: boolean; site?: string | un
|
|
|
926
1005
|
};
|
|
927
1006
|
return (
|
|
928
1007
|
<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.'} />
|
|
1008
|
+
<Head title={first ? 'Welcome to Volter World' : 'Create an org'} lede={first && invitations.length ? 'Your team is waiting: join the org you were invited to, or make one of your own.' : 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.'} />
|
|
1009
|
+
{invitations.length ? <Invitations list={invitations} onJoined={onJoined} /> : null}
|
|
930
1010
|
<section className="card" aria-labelledby="create-org">
|
|
931
|
-
<h3 id="create-org" className=
|
|
1011
|
+
<h3 id="create-org" className={invitations.length ? undefined : 'sr'}>{invitations.length ? 'Or make your own org' : 'Create an org'}</h3>
|
|
932
1012
|
<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>
|
|
1013
|
+
<label>Org name <input autoFocus={!invitations.length} 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
1014
|
<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
1015
|
{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
1016
|
{error ? <p role="alert">{error}</p> : null}
|
|
@@ -986,7 +1066,7 @@ function GettingStarted({ orgs, worlds, firstWorld, version }: { orgs: string[];
|
|
|
986
1066
|
// the way in is the app's folder (docs/contributing/architecture.md): the command makes the World from the vendors
|
|
987
1067
|
// the app uses; Create World above is the way for a person without a terminal
|
|
988
1068
|
const steps: Array<[boolean, React.ReactNode]> = [
|
|
989
|
-
[orgs.length > 0, 'Create an org'],
|
|
1069
|
+
[orgs.length > 0, 'Create or join an org'],
|
|
990
1070
|
[tokensMade === true, <>Install the command and sign it in: <code>npm install -g @volter/world</code>, then <code>volter login {ORIGIN}</code></>],
|
|
991
1071
|
[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
1072
|
[false, <>Send it what your app did: <code>volter world changeset -m "…"</code>, then <code>volter world push</code></>],
|