@volter/world-platform 2.0.3 → 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.
@@ -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; }
@@ -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
- if (at.kind === 'home') { const first = orgs[0]; go(first ? { kind: 'org', org: slugOf(first), page: 'worlds' } : { kind: 'you', page: 'new' }, true); return <Booting />; }
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
- <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}
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
- function CreateOrg({ first, site, onMade }: { first: boolean; site?: string | undefined; onMade: (slug: string) => Promise<void> }): React.JSX.Element {
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="sr">Create an org</h3>
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'}/&lt;world&gt;</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></>],
@@ -1,4 +1,4 @@
1
- export declare const AUDIT_EVENTS: readonly ["ORG_CREATE", "ORG_RENAME", "ORG_DELETE", "MEMBER_INVITE", "INVITE_REVOKE", "MEMBER_REMOVE", "MEMBER_ROLE", "WORLD_PROVISION", "WORLD_DELETE", "WORLD_OPEN", "WORLD_CLAIM", "PREVIEW_CREATE", "PREVIEW_DELETE", "TOKEN_CREATE", "TOKEN_REVOKE", "ORG_TOKEN_CREATE", "ORG_TOKEN_REVOKE", "SECURITY_CHANGE", "SUPPORT_REQUEST", "SUPPORT_ACCESS_GRANTED", "SUPPORT_ACCESS_REVOKED", "IMPERSONATION", "WEBHOOK_CREATE", "WEBHOOK_CHANGE", "WEBHOOK_DELETE", "WEBHOOK_DISABLED", "SPEND_CAP_CHANGE", "LABS_CHANGE", "REMINDER_SENT", "PLAN_CHECKOUT", "PLAN_CHANGE", "QUOTA_WARNED", "QUOTA_RESTRICTED", "QUOTA_CLEARED"];
1
+ export declare const AUDIT_EVENTS: readonly ["ORG_CREATE", "ORG_RENAME", "ORG_DELETE", "MEMBER_INVITE", "MEMBER_JOIN", "INVITE_REVOKE", "MEMBER_REMOVE", "MEMBER_ROLE", "WORLD_PROVISION", "WORLD_DELETE", "WORLD_OPEN", "WORLD_ACCESS_SET", "WORLD_CLAIM", "PREVIEW_CREATE", "PREVIEW_DELETE", "TOKEN_CREATE", "TOKEN_REVOKE", "ORG_TOKEN_CREATE", "ORG_TOKEN_REVOKE", "SECURITY_CHANGE", "SUPPORT_REQUEST", "SUPPORT_ACCESS_GRANTED", "SUPPORT_ACCESS_REVOKED", "IMPERSONATION", "WEBHOOK_CREATE", "WEBHOOK_CHANGE", "WEBHOOK_DELETE", "WEBHOOK_DISABLED", "SPEND_CAP_CHANGE", "LABS_CHANGE", "REMINDER_SENT", "PLAN_CHECKOUT", "PLAN_CHANGE", "QUOTA_WARNED", "QUOTA_RESTRICTED", "QUOTA_CLEARED"];
2
2
  export type AuditEvent = (typeof AUDIT_EVENTS)[number];
3
3
  export type AuditEntry = {
4
4
  at: string;
package/dist/src/audit.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { and, desc, eq } from 'drizzle-orm';
5
5
  import { openDatabase } from "./db/open.js";
6
6
  import { audit as auditRows } from "./db/schema.js";
7
- export const AUDIT_EVENTS = ['ORG_CREATE', 'ORG_RENAME', 'ORG_DELETE', 'MEMBER_INVITE', 'INVITE_REVOKE', 'MEMBER_REMOVE', 'MEMBER_ROLE', 'WORLD_PROVISION', 'WORLD_DELETE', 'WORLD_OPEN', 'WORLD_CLAIM', 'PREVIEW_CREATE', 'PREVIEW_DELETE', 'TOKEN_CREATE', 'TOKEN_REVOKE', 'ORG_TOKEN_CREATE', 'ORG_TOKEN_REVOKE', 'SECURITY_CHANGE', 'SUPPORT_REQUEST', 'SUPPORT_ACCESS_GRANTED', 'SUPPORT_ACCESS_REVOKED', 'IMPERSONATION', 'WEBHOOK_CREATE', 'WEBHOOK_CHANGE', 'WEBHOOK_DELETE', 'WEBHOOK_DISABLED', 'SPEND_CAP_CHANGE', 'LABS_CHANGE', 'REMINDER_SENT', 'PLAN_CHECKOUT', 'PLAN_CHANGE', 'QUOTA_WARNED', 'QUOTA_RESTRICTED', 'QUOTA_CLEARED'];
7
+ export const AUDIT_EVENTS = ['ORG_CREATE', 'ORG_RENAME', 'ORG_DELETE', 'MEMBER_INVITE', 'MEMBER_JOIN', 'INVITE_REVOKE', 'MEMBER_REMOVE', 'MEMBER_ROLE', 'WORLD_PROVISION', 'WORLD_DELETE', 'WORLD_OPEN', 'WORLD_ACCESS_SET', 'WORLD_CLAIM', 'PREVIEW_CREATE', 'PREVIEW_DELETE', 'TOKEN_CREATE', 'TOKEN_REVOKE', 'ORG_TOKEN_CREATE', 'ORG_TOKEN_REVOKE', 'SECURITY_CHANGE', 'SUPPORT_REQUEST', 'SUPPORT_ACCESS_GRANTED', 'SUPPORT_ACCESS_REVOKED', 'IMPERSONATION', 'WEBHOOK_CREATE', 'WEBHOOK_CHANGE', 'WEBHOOK_DELETE', 'WEBHOOK_DISABLED', 'SPEND_CAP_CHANGE', 'LABS_CHANGE', 'REMINDER_SENT', 'PLAN_CHECKOUT', 'PLAN_CHANGE', 'QUOTA_WARNED', 'QUOTA_RESTRICTED', 'QUOTA_CLEARED'];
8
8
  export class Audit {
9
9
  db;
10
10
  constructor(stateDir) { this.db = openDatabase(stateDir); }
@@ -0,0 +1,8 @@
1
+ CREATE TABLE `world_access` (
2
+ `org_id` text NOT NULL,
3
+ `name` text NOT NULL,
4
+ `members` text NOT NULL,
5
+ `set_by` text NOT NULL,
6
+ `set_at` text NOT NULL,
7
+ PRIMARY KEY(`org_id`, `name`)
8
+ );