@hanzo/rooms 0.1.83 → 0.1.84

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/dist/Me.js CHANGED
@@ -20,7 +20,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
20
20
  // personal one first, with its plan — switched with `onOrg`; Create
21
21
  // organization, Organization settings, and Hanzo Team (opened in the same
22
22
  // organization for a team, offered as "Create a team" for a personal one); and
23
- // All organizations for a SuperAdmin (support mode).
23
+ // All organizations for a SuperAdmin, which opens the chooser (Orgs.tsx).
24
24
  //
25
25
  // IAM is the source of every fact here: names and roles from the token's `orgs`
26
26
  // claim, display names and which org is personal from `GET /v1/iam/organizations`,
@@ -36,13 +36,12 @@ import { Button, Dialog, DialogContent, DialogTitle, DropdownMenu, DropdownMenuC
36
36
  import { api } from './lib/api.js';
37
37
  import { createOrg, say } from './lib/org.js';
38
38
  import { list } from './lib/list.js';
39
- import { org as current, orgs, pick, renew, scope, superAdmin } from './lib/session.js';
39
+ import { choose, org as current, orgs, pick, renew, scope, superAdmin } from './lib/session.js';
40
40
  import { ENTRY } from './lib/host.js';
41
41
  import { leave } from './dev.js';
42
42
  import { asked, KEYS, legal, out, pages } from './help.js';
43
43
  import { showSettings } from './open.js';
44
44
  import { site, where } from './where.js';
45
- import { SupportPicker } from './Support.js';
46
45
  import { BAD } from './lib/mix.js';
47
46
  /** Where an organization's own settings live: a page of the site (`site()`), not of the app. */
48
47
  const ORG_SETTINGS = '/settings/organization';
@@ -138,7 +137,6 @@ export function Me({ host, narrow = false, above = false, onOrg, }) {
138
137
  const [account, setAccount] = useState(false);
139
138
  const [help, setHelp] = useState(false);
140
139
  const [creating, setCreating] = useState(false);
141
- const [supporting, setSupporting] = useState(false);
142
140
  const [keys, setKeys] = useState(false);
143
141
  const row = useRef(null);
144
142
  const current = host.org;
@@ -184,7 +182,7 @@ export function Me({ host, narrow = false, above = false, onOrg, }) {
184
182
  setHelp(false);
185
183
  }, placement: narrow && !above ? 'right-end' : above ? 'top-end' : 'top-start', children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(XStack, { ref: (el) => {
186
184
  row.current = el instanceof HTMLElement ? el : null;
187
- }, render: "button", "data-slot": "me", "aria-label": `Account: ${[who, current].filter(Boolean).join(' · ')}`, ...{ title: who }, width: narrow ? 36 : '100%', minH: narrow ? 36 : 48, px: narrow ? 0 : '$2', gap: "$2.5", rounded: narrow ? 999 : '$5', items: "center", justify: narrow ? 'center' : 'flex-start', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, cursor: "pointer", children: [_jsx(Face, { host: host, size: narrow ? 26 : 32 }), narrow ? null : (_jsxs(_Fragment, { children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$3", fontWeight: "600", color: "$ink", numberOfLines: 1, text: "left", children: who }), plan ? (_jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, text: "left", children: plan })) : null] }), _jsx(ChevronsUpDown, { size: 14, "aria-hidden": true, opacity: 0.6 })] }))] }) }), _jsxs(DropdownMenuContent, { minW: 260, maxW: "calc(100vw - 16px)", "aria-labelledby": undefined, "aria-label": "Account", onCloseAutoFocus: back, children: [_jsxs(DropdownMenuSub, { open: account, onOpenChange: setAccount, children: [_jsxs(DropdownMenuSubTrigger, { minH: 48, gap: "$2.5", children: [_jsx(Face, { host: host, size: 28 }), _jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", fontWeight: "600", color: "$ink", numberOfLines: 1, children: who }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: plan || host.person.email || '' })] })] }), account ? (_jsxs(DropdownMenuSubContent, { minW: 260, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [_jsx(Meter, { read: read, onPlans: () => leave('here'), onBilling: () => (setOpen(false), showSettings('billing')) }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuLabel, { children: "Organizations" }), all.map((o) => (_jsx(DropdownMenuItem, { onSelect: () => o.name !== current && onOrg(o.name), children: _jsx(Line, { icon: _jsx(Mark, { org: o }), sub: [o.personal ? 'Personal' : said(o.role), planFor(o)].filter(Boolean).join(' · '), trail: o.name === current ? _jsx(Check, { size: 16, "aria-label": "Current" }) : null, children: o.display }) }, o.name))), _jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Plus, { size: 16, "aria-hidden": true }), children: "Create organization" }) }), _jsx(DropdownMenuItem, { onSelect: () => window.location.assign(site(ORG_SETTINGS)), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), children: "Organization settings" }) }), where().team ? null : here && !here.personal ? (_jsx(DropdownMenuItem, { onSelect: () => out(`${ENTRY}&org=${encodeURIComponent(here.name)}`), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), trail: _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }), children: "Open in Hanzo Team" }) })) : (_jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), children: "Create a team" }) })), superAdmin() ? (_jsx(DropdownMenuItem, { onSelect: () => setSupporting(true), children: _jsx(Line, { icon: _jsx(Building2, { size: 16, "aria-hidden": true }), children: "All organizations" }) })) : null] })) : null] }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { onSelect: () => leave('here'), children: _jsx(Line, { icon: _jsx(Sparkles, { size: 16, "aria-hidden": true }), children: upgrade(read.value?.label?.name) }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Palette, { size: 16, "aria-hidden": true }), children: "Personalization" }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('account'), children: _jsx(Line, { icon: _jsx(CircleUserRound, { size: 16, "aria-hidden": true }), children: "Profile" }) }), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+, Meta+,", onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘,' : 'Ctrl+,' }), children: "Settings" }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuSub, { open: help, onOpenChange: setHelp, children: [_jsx(DropdownMenuSubTrigger, { children: _jsx(Line, { icon: _jsx(LifeBuoy, { size: 16, "aria-hidden": true }), children: "Help" }) }), help ? (_jsxs(DropdownMenuSubContent, { minW: 240, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [pages().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], trail: p.key === 'help' ? _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }) : null, children: p.label }) }, p.key))), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+/ Meta+/", onSelect: () => setKeys(true), children: _jsx(Line, { icon: _jsx(Keyboard, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘/' : 'Ctrl+/' }), children: "Keyboard shortcuts" }) }), _jsx(DropdownMenuSeparator, {}), legal().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], children: p.label }) }, p.key)))] })) : null] }), host.signOut ? (_jsx(DropdownMenuItem, { onSelect: () => host.signOut?.(), children: _jsx(Line, { icon: _jsx(LogOut, { size: 16, "aria-hidden": true }), children: "Log out" }) })) : null] })] }), _jsx(Create, { open: creating, onOpenChange: setCreating }), _jsx(Shortcuts, { open: keys, onOpenChange: setKeys, apple: apple }), supporting ? _jsx(SupportPicker, { onClose: () => setSupporting(false) }) : null] }));
185
+ }, render: "button", "data-slot": "me", "aria-label": `Account: ${[who, current].filter(Boolean).join(' · ')}`, ...{ title: who }, width: narrow ? 36 : '100%', minH: narrow ? 36 : 48, px: narrow ? 0 : '$2', gap: "$2.5", rounded: narrow ? 999 : '$5', items: "center", justify: narrow ? 'center' : 'flex-start', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, cursor: "pointer", children: [_jsx(Face, { host: host, size: narrow ? 26 : 32 }), narrow ? null : (_jsxs(_Fragment, { children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$3", fontWeight: "600", color: "$ink", numberOfLines: 1, text: "left", children: who }), plan ? (_jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, text: "left", children: plan })) : null] }), _jsx(ChevronsUpDown, { size: 14, "aria-hidden": true, opacity: 0.6 })] }))] }) }), _jsxs(DropdownMenuContent, { minW: 260, maxW: "calc(100vw - 16px)", "aria-labelledby": undefined, "aria-label": "Account", onCloseAutoFocus: back, children: [_jsxs(DropdownMenuSub, { open: account, onOpenChange: setAccount, children: [_jsxs(DropdownMenuSubTrigger, { minH: 48, gap: "$2.5", children: [_jsx(Face, { host: host, size: 28 }), _jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", fontWeight: "600", color: "$ink", numberOfLines: 1, children: who }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: plan || host.person.email || '' })] })] }), account ? (_jsxs(DropdownMenuSubContent, { minW: 260, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [_jsx(Meter, { read: read, onPlans: () => leave('here'), onBilling: () => (setOpen(false), showSettings('billing')) }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuLabel, { children: "Organizations" }), all.map((o) => (_jsx(DropdownMenuItem, { onSelect: () => o.name !== current && onOrg(o.name), children: _jsx(Line, { icon: _jsx(Mark, { org: o }), sub: [o.personal ? 'Personal' : said(o.role), planFor(o)].filter(Boolean).join(' · '), trail: o.name === current ? _jsx(Check, { size: 16, "aria-label": "Current" }) : null, children: o.display }) }, o.name))), _jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Plus, { size: 16, "aria-hidden": true }), children: "Create organization" }) }), _jsx(DropdownMenuItem, { onSelect: () => window.location.assign(site(ORG_SETTINGS)), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), children: "Organization settings" }) }), where().team ? null : here && !here.personal ? (_jsx(DropdownMenuItem, { onSelect: () => out(`${ENTRY}&org=${encodeURIComponent(here.name)}`), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), trail: _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }), children: "Open in Hanzo Team" }) })) : (_jsx(DropdownMenuItem, { onSelect: () => setCreating(true), children: _jsx(Line, { icon: _jsx(Users, { size: 16, "aria-hidden": true }), children: "Create a team" }) })), superAdmin() ? (_jsx(DropdownMenuItem, { onSelect: choose, children: _jsx(Line, { icon: _jsx(Building2, { size: 16, "aria-hidden": true }), children: "All organizations" }) })) : null] })) : null] }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { onSelect: () => leave('here'), children: _jsx(Line, { icon: _jsx(Sparkles, { size: 16, "aria-hidden": true }), children: upgrade(read.value?.label?.name) }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Palette, { size: 16, "aria-hidden": true }), children: "Personalization" }) }), _jsx(DropdownMenuItem, { onSelect: () => showSettings('account'), children: _jsx(Line, { icon: _jsx(CircleUserRound, { size: 16, "aria-hidden": true }), children: "Profile" }) }), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+, Meta+,", onSelect: () => showSettings('general'), children: _jsx(Line, { icon: _jsx(Settings, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘,' : 'Ctrl+,' }), children: "Settings" }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuSub, { open: help, onOpenChange: setHelp, children: [_jsx(DropdownMenuSubTrigger, { children: _jsx(Line, { icon: _jsx(LifeBuoy, { size: 16, "aria-hidden": true }), children: "Help" }) }), help ? (_jsxs(DropdownMenuSubContent, { minW: 240, maxW: "calc(100vw - 16px)", pointerEvents: "auto", children: [pages().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], trail: p.key === 'help' ? _jsx(ArrowUpRight, { size: 14, "aria-hidden": true, opacity: 0.6 }) : null, children: p.label }) }, p.key))), _jsx(DropdownMenuItem, { "aria-keyshortcuts": "Control+/ Meta+/", onSelect: () => setKeys(true), children: _jsx(Line, { icon: _jsx(Keyboard, { size: 16, "aria-hidden": true }), trail: _jsx(DropdownMenuShortcut, { "aria-hidden": true, children: apple ? '⌘/' : 'Ctrl+/' }), children: "Keyboard shortcuts" }) }), _jsx(DropdownMenuSeparator, {}), legal().map((p) => (_jsx(DropdownMenuItem, { onSelect: () => out(p.href), children: _jsx(Line, { icon: ICONS[p.key], children: p.label }) }, p.key)))] })) : null] }), host.signOut ? (_jsx(DropdownMenuItem, { onSelect: () => host.signOut?.(), children: _jsx(Line, { icon: _jsx(LogOut, { size: 16, "aria-hidden": true }), children: "Log out" }) })) : null] })] }), _jsx(Create, { open: creating, onOpenChange: setCreating }), _jsx(Shortcuts, { open: keys, onOpenChange: setKeys, apple: apple })] }));
188
186
  }
189
187
  /** The app's keys (help.ts `KEYS`), one row each, spelled for this platform. */
190
188
  function Shortcuts({ open, onOpenChange, apple }) {
package/dist/Orgs.js CHANGED
@@ -28,11 +28,13 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
28
28
  // Free is offered a team, and an open one is sent on (`forward`). Behind the
29
29
  // door, and only there, nothing opens without a live plan.
30
30
  import { dev } from './lib/host.js';
31
- import { useCallback, useEffect, useMemo, useState } from 'react';
31
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
32
32
  import { useIam } from '@hanzo/iam/react';
33
33
  import { Text, View, XStack, YStack } from '@hanzo/gui';
34
- import { Button } from '@hanzo/ui';
35
- import { Minus, Plus, X } from 'lucide-react';
34
+ import { Avatar, Button, Tag } from '@hanzo/ui';
35
+ import { SearchInput } from '@hanzo/ui/product/Filters';
36
+ import { label, said as spoken } from '@hanzo/build/plan';
37
+ import { Check, Minus, Plus, X } from 'lucide-react';
36
38
  import { Waiting } from './waiting.js';
37
39
  import { LOGIN, enter } from './lib/destination.js';
38
40
  import { useAi } from './lib/ai.js';
@@ -40,7 +42,8 @@ import { useHost } from './lib/hostname.js';
40
42
  import { useHydrated } from './lib/hydrated.js';
41
43
  import { api } from './lib/api.js';
42
44
  import { usePersonal, useTier } from './lib/tier.js';
43
- import { ORG, PICKED, assumed, hasSession, org, orgs as claimed, pick, renew, scope } from './lib/session.js';
45
+ import { CHOOSE, ORG, PICKED, assumed, bearer, hasSession, org, orgs as claimed, pick, renew, role, scope, superAdmin, support } from './lib/session.js';
46
+ import { matches, refusal, slugify } from './lib/orgname.js';
44
47
  import { team as workspace } from './lib/host.js';
45
48
  import { checkoutUrl, money } from './lib/pay.js';
46
49
  import { cartUrl, charge, priced, usePlan, usePlans } from './lib/plans.js';
@@ -119,9 +122,17 @@ export function Orgs({ children, front = false, forward, signup = false, }) {
119
122
  window.removeEventListener('focus', back);
120
123
  };
121
124
  }, [reload]);
125
+ // The chooser over the room, asked for from the account menu (session.ts `choose`).
126
+ const [choosing, setChoosing] = useState(false);
127
+ useEffect(() => {
128
+ const ask = () => setChoosing(true);
129
+ window.addEventListener(CHOOSE, ask);
130
+ return () => window.removeEventListener(CHOOSE, ask);
131
+ }, []);
122
132
  const choose = useCallback((o) => {
123
133
  pick(o);
124
134
  setPicked(true);
135
+ setChoosing(false);
125
136
  }, []);
126
137
  // The organization a link arrives for. hanzo.ai opens the workspace at
127
138
  // `?signin&org=<the org it is working in>` (Me.tsx); once signed in, an org
@@ -198,7 +209,10 @@ export function Orgs({ children, front = false, forward, signup = false, }) {
198
209
  return door ? _jsx(Start, { onFounded: setFounding, first: true }) : _jsx(Create, {});
199
210
  }
200
211
  if (!current || (members.length > 1 && !picked))
201
- return _jsx(Pick, { members: members, onPick: choose });
212
+ return _jsx(Pick, { members: members, current: current, onPick: choose });
213
+ // Asked for over an open room, the room keeps its place under the chooser and closing it goes back.
214
+ if (choosing)
215
+ return _jsxs(_Fragment, { children: [_jsx(Held, { veiled: true, children: children }), _jsx(Pick, { members: members, current: current, onPick: choose, onClose: () => setChoosing(false) })] });
202
216
  if (open)
203
217
  return forward ? _jsx(Forward, { to: forward }) : _jsx(_Fragment, { children: _jsx(Held, { veiled: false, children: children }) });
204
218
  // IN THE WORKSPACE NOTHING OPENS WITHOUT A LIVE PLAN. Every branch here
@@ -458,9 +472,253 @@ function CreateDialog({ onClose }) {
458
472
  ? `A team organization runs on ${plan.name} at ${price} per seat a month, at least ${Number(plan.limits?.minSeats ?? 1)} seats.`
459
473
  : `A personal organization runs on ${plan.name} at ${price} a month.`, ' ', "Nothing in it works until that plan is live, so creating one takes you straight to checkout, where you choose the seats and the term.", ' ', "Each plan includes session and daily usage limits; usage never draws your credit balance.", ' ', "Payment is on the provider's hosted page."] })) : null, refused ? (_jsx(Text, { render: "p", role: "alert", mt: 14, fontSize: "$3", color: "var(--foreground)", children: refused })) : null, _jsxs(XStack, { justify: "flex-end", gap: 10, mt: 24, flexWrap: "wrap", children: [_jsx(Button, { type: "button", size: "lg", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", size: "lg", shrink: 1, py: "$2", disabled: !plan || busy, children: _jsx(Text, { fontFamily: "$body", fontSize: "$3", color: "inherit", text: "center", children: busy ? 'Creating…' : paid ? `Create and subscribe — ${price}${seat} / month` : 'Create' }) })] }), _jsx(Rule, { mt: 18, mb: 8 }), _jsx(Text, { render: "p", text: "right", fontSize: "$2", color: muted, fontFamily: "$mono", children: "Esc to close" })] }) }));
460
474
  }
461
- /** Several organizations: which one, this time. */
462
- function Pick({ members, onPick }) {
463
- return (_jsx(Page, { label: "Choose an organization", children: _jsxs(Column, { children: [_jsx(Brand, {}), _jsx(Heading, { title: "Choose an organization", lede: _jsx(_Fragment, { children: "Everything you open \u2014 chats, agents, credit \u2014 is this one's." }) }), _jsx(Rule, {}), _jsx(View, { display: "grid", gap: 10, children: members.map((o) => (_jsx(View, { render: "button", onClick: () => onPick(o), items: "flex-start", px: 16, py: 14, rounded: 12, borderWidth: 1, borderColor: "var(--border)", bg: "transparent", cursor: "pointer", children: _jsx(Text, { fontSize: "$4", color: "var(--foreground)", text: "left", children: o }) }, o))) })] }) }));
475
+ /**
476
+ * A page of IAM's organization list (`GET /v1/iam/organizations`): the reader's
477
+ * own organizations whole on the first page, then, for a SuperAdmin only, every
478
+ * other one, newest first. `q` narrows it by name or display name on the server;
479
+ * `cursor` continues it. IAM decides the scope from the bearer, so this asks the
480
+ * same thing for everyone.
481
+ */
482
+ async function listing(q, cursor, limit, signal) {
483
+ const query = new URLSearchParams({ limit: String(limit) });
484
+ if (q.trim())
485
+ query.set('q', q.trim());
486
+ if (cursor)
487
+ query.set('cursor', cursor);
488
+ const res = await fetch(`${api()}/v1/iam/organizations?${query}`, {
489
+ headers: { Authorization: `Bearer ${bearer() ?? ''}` },
490
+ signal,
491
+ });
492
+ if (!res.ok)
493
+ throw new Error(`IAM answered ${res.status}.`);
494
+ return (await res.json());
495
+ }
496
+ /** How each of the reader's organizations appears, by name, from IAM's rows: the first page carries all of them. */
497
+ function useMarks(members) {
498
+ const [rows, setRows] = useState({});
499
+ const key = members.join(',');
500
+ useEffect(() => {
501
+ if (!key)
502
+ return;
503
+ const stop = new AbortController();
504
+ listing('', '', 1, stop.signal)
505
+ .then((page) => {
506
+ if (!stop.signal.aborted)
507
+ setRows(Object.fromEntries((page.organizations ?? []).map((o) => [o.name, o])));
508
+ })
509
+ .catch(() => { });
510
+ return () => stop.abort();
511
+ }, [key]);
512
+ return rows;
513
+ }
514
+ /** Each organization's plan by its family (`Max 20x`), billing asked in its name; absent where it names none. */
515
+ function useBilled(members) {
516
+ const [plans, setPlans] = useState({});
517
+ const key = members.join(',');
518
+ useEffect(() => {
519
+ const stop = new AbortController();
520
+ for (const o of key.split(',').filter(Boolean)) {
521
+ fetch(`${api()}/v1/billing/tier`, { headers: { ...scope(), 'X-Org-Id': o }, signal: stop.signal })
522
+ .then((r) => (r.ok ? r.json() : null))
523
+ .then((t) => {
524
+ const plan = t ? spoken(label(t.plan, t.tier?.name)) : '';
525
+ if (plan && !stop.signal.aborted)
526
+ setPlans((had) => ({ ...had, [o]: plan }));
527
+ })
528
+ .catch(() => { });
529
+ }
530
+ return () => stop.abort();
531
+ }, [key]);
532
+ return plans;
533
+ }
534
+ /** A role as a person reads it: `owner` → Owner. */
535
+ const said = (role) => (role ? role.charAt(0).toUpperCase() + role.slice(1) : '');
536
+ /** An organization's mark: its image, else its emoji, else its initial in its own hue (keyed on the slug, so a rename keeps it). */
537
+ function Mark({ org }) {
538
+ const emoji = org.mark?.avatar ? '' : org.mark?.emoji;
539
+ return (_jsx(Avatar, { size: "xl", shape: "rounded", src: org.mark?.avatar || (emoji ? null : org.mark?.logo) || null, name: org.display, colorSeed: org.name, icon: emoji ? _jsx(Text, { fontSize: "$7", children: emoji }) : undefined, alt: "" }));
540
+ }
541
+ /** One organization: its mark, its name and slug, the reader's role and its plan; the current one checked. */
542
+ function Choice({ org, current = false, note, disabled, onPress }) {
543
+ return (_jsx(View, { render: "li", display: "block", children: _jsxs(XStack, { render: "button", onClick: onPress, disabled: disabled, "aria-current": current || undefined, width: "100%", items: "center", gap: 12, px: 12, py: 10, rounded: 12, borderWidth: 1, borderColor: current ? '$ink' : '$edge', bg: "transparent", cursor: "pointer", hoverStyle: { bg: '$hover' }, children: [_jsx(Mark, { org: org }), _jsxs(YStack, { flex: 1, minW: 0, gap: 4, children: [_jsx(Text, { fontSize: "$4", fontWeight: "500", color: "$ink", numberOfLines: 1, text: "left", children: org.display }), _jsxs(XStack, { items: "center", gap: 6, flexWrap: "wrap", children: [_jsx(Text, { fontSize: "$2", fontFamily: "$mono", color: "$soft", numberOfLines: 1, children: org.name }), org.role ? _jsx(Tag, { variant: "outline", children: said(org.role) }) : null, org.plan ? _jsx(Tag, { children: org.plan }) : null, note ? (_jsx(Text, { fontSize: "$2", color: "$soft", children: note })) : null] })] }), current ? _jsx(Check, { size: 16, "aria-label": "Current" }) : null] }) }));
544
+ }
545
+ /**
546
+ * WHICH ORGANIZATION, THIS TIME — and, for a SuperAdmin, any organization.
547
+ *
548
+ * The reader's organizations are the token's `orgs` (no other list is theirs),
549
+ * drawn with IAM's rows beside them: the mark, the display name, the role the
550
+ * token signs and the plan billing reads in each one's name. The search filters
551
+ * them as it is typed, by slug or display name. A person founds another here
552
+ * (`Found`); a SuperAdmin, whom IAM never lets own a customer organization, is
553
+ * shown every organization instead (`Everyone`), stepped into through IAM.
554
+ */
555
+ function Pick({ members, current, onPick, onClose, }) {
556
+ // IAM's verdict as it signed it into the token (authz `Claims.Sudo`). It only
557
+ // decides what is drawn: IAM answers anyone else's list with their own
558
+ // organizations and refuses them the step in.
559
+ const sudo = superAdmin();
560
+ const [q, setQ] = useState('');
561
+ const [founding, setFounding] = useState(false);
562
+ const marks = useMarks(members);
563
+ const plans = useBilled(members);
564
+ const mine = members.map((name) => ({
565
+ name,
566
+ display: marks[name]?.displayName || name,
567
+ mark: marks[name],
568
+ role: role(name) ?? '',
569
+ plan: plans[name],
570
+ }));
571
+ const shown = mine.filter((o) => matches(o, q));
572
+ useEffect(() => {
573
+ if (!onClose || founding)
574
+ return;
575
+ const key = (e) => {
576
+ if (e.key === 'Escape')
577
+ onClose();
578
+ };
579
+ document.addEventListener('keydown', key);
580
+ return () => document.removeEventListener('keydown', key);
581
+ }, [onClose, founding]);
582
+ return (_jsxs(Page, { label: "Choose an organization", children: [_jsxs(Column, { children: [_jsxs(XStack, { items: "flex-start", justify: "space-between", gap: 12, children: [_jsx(Brand, {}), onClose ? (_jsx(Button, { variant: "ghost", size: "icon-sm", mt: -6, mr: -8, onClick: onClose, "aria-label": "Close", children: _jsx(X, { size: 16, "aria-hidden": true }) })) : null] }), _jsx(Heading, { title: "Choose an organization", lede: _jsx(_Fragment, { children: "Everything you open \u2014 chats, agents, credit \u2014 is this one's." }) }), _jsx(Rule, {}), _jsx(XStack, { render: "form", onSubmit: (e) => {
583
+ e.preventDefault();
584
+ if (shown[0])
585
+ onPick(shown[0].name);
586
+ }, children: _jsx(SearchInput, { name: "organizations", value: q, onChange: setQ, placeholder: sudo ? 'Search every organization' : 'Find an organization', actions: sudo ? null : (_jsxs(Button, { type: "button", onClick: () => setFounding(true), children: [_jsx(Plus, { size: 16, "aria-hidden": true }), "New organization"] })) }) }), _jsx(YStack, { render: "ul", "aria-label": "Your organizations", m: 0, p: 0, mt: 14, gap: 8, children: shown.map((o) => (_jsx(Choice, { org: o, current: o.name === current, onPress: () => onPick(o.name) }, o.name))) }), shown.length === 0 ? (_jsxs(Text, { render: "p", mt: 14, fontSize: "$3", color: "$soft", children: ["None of yours matches \u201C", q.trim(), "\u201D."] })) : null, sudo ? _jsx(Everyone, { q: q, skip: members }) : null] }), founding ? _jsx(Found, { onClose: () => setFounding(false), onMade: onPick }) : null] }));
587
+ }
588
+ /**
589
+ * EVERY ORGANIZATION, for a SuperAdmin to step into.
590
+ *
591
+ * The list and the search are IAM's, a page at a time, and IAM records each new
592
+ * search under the operator's name. Opening one is IAM's `assume` (session.ts
593
+ * `support`): the operator's own token re-scoped to it and the step recorded in
594
+ * that organization's audit log. Nobody is made a member of anything.
595
+ */
596
+ function Everyone({ q, skip }) {
597
+ const [rows, setRows] = useState([]);
598
+ const [cursor, setCursor] = useState('');
599
+ const [loading, setLoading] = useState(false);
600
+ const [refused, setRefused] = useState(null);
601
+ const [stepping, setStepping] = useState(null);
602
+ // Only the newest read lands: a search typed while a page is in flight
603
+ // supersedes it, so a slow answer to an old query never overwrites the new one.
604
+ const asked = useRef(0);
605
+ const load = useCallback(async (query, from) => {
606
+ const mine = ++asked.current;
607
+ setLoading(true);
608
+ setRefused(null);
609
+ try {
610
+ const next = await listing(query, from, 20);
611
+ if (mine !== asked.current)
612
+ return;
613
+ setRows((had) => {
614
+ // IAM's cursor is a position, so an org created mid-walk can shift a row
615
+ // across a page edge; each org is kept once.
616
+ const all = from ? [...had, ...(next.organizations ?? [])] : next.organizations ?? [];
617
+ return all.filter((o, i) => all.findIndex((x) => x.name === o.name) === i);
618
+ });
619
+ setCursor(next.cursor ?? '');
620
+ }
621
+ catch (e) {
622
+ if (mine === asked.current)
623
+ setRefused(e instanceof Error ? e.message : 'The list did not load.');
624
+ }
625
+ finally {
626
+ if (mine === asked.current)
627
+ setLoading(false);
628
+ }
629
+ }, []);
630
+ useEffect(() => {
631
+ const id = setTimeout(() => void load(q, ''), 250);
632
+ return () => clearTimeout(id);
633
+ }, [q, load]);
634
+ const enter = (org) => {
635
+ setStepping(org);
636
+ setRefused(null);
637
+ support(org).catch((e) => {
638
+ setRefused(e instanceof Error ? e.message : 'IAM did not step in.');
639
+ setStepping(null);
640
+ });
641
+ };
642
+ const others = rows.filter((o) => !skip.includes(o.name));
643
+ return (_jsxs(YStack, { render: "section", "aria-label": "All organizations", mt: 36, children: [_jsx(Text, { render: "h2", fontSize: "$6", fontWeight: "500", color: "$ink", children: "All organizations" }), _jsx(Text, { render: "p", mt: 4, fontSize: "$3", color: "$soft", lineHeight: 21, children: "Support mode. Step into any organization to see it as it does; IAM records every step under your name." }), _jsx(YStack, { render: "ul", "aria-label": "Every organization", m: 0, p: 0, mt: 14, gap: 8, children: others.map((o) => (_jsx(Choice, { org: { name: o.name, display: o.displayName || o.name, mark: o }, note: stepping === o.name ? 'Stepping in…' : undefined, disabled: stepping !== null, onPress: () => enter(o.name) }, o.name))) }), !loading && others.length === 0 && !refused ? (_jsx(Text, { render: "p", mt: 8, fontSize: "$3", color: "$soft", children: "No organization matches." })) : null, refused ? (_jsx(Text, { render: "p", role: "alert", mt: 8, fontSize: "$3", color: BAD, children: refused })) : null, cursor ? (_jsx(Button, { self: "flex-start", mt: 10, onClick: () => void load(q, cursor), disabled: loading, children: loading ? 'Loading…' : 'More' })) : null] }));
644
+ }
645
+ /**
646
+ * FOUND AN ORGANIZATION: its name, then the slug IAM keys it by.
647
+ *
648
+ * The slug follows the name the way IAM's onboarding makes it, until it is typed
649
+ * over; a slug typed is IAM's to take as it is, so one IAM would not keep is
650
+ * refused here with the rule, never rewritten. IAM's own onboarding makes it
651
+ * (`POST /v1/iam/onboard`): the organization, the reader as its owner and its
652
+ * first API key, the founder's account untouched. It is sent the name when the
653
+ * name makes the slug, and the slug when the slug was typed, the display name
654
+ * then written on the row (`/v1/iam/organizations/profile`). The token in hand
655
+ * predates it, so it is refreshed before the organization opens.
656
+ */
657
+ function Found({ onClose, onMade }) {
658
+ const { sdk } = useIam();
659
+ const [name, setName] = useState('');
660
+ const [typed, setTyped] = useState(null);
661
+ const slug = typed ?? slugify(name);
662
+ const wrong = slug ? refusal(slug) : null;
663
+ const [busy, setBusy] = useState(false);
664
+ const [refused, setRefused] = useState(null);
665
+ useEffect(() => {
666
+ const key = (e) => {
667
+ if (e.key === 'Escape')
668
+ onClose();
669
+ };
670
+ document.addEventListener('keydown', key);
671
+ return () => document.removeEventListener('keydown', key);
672
+ }, [onClose]);
673
+ const submit = async (e) => {
674
+ e.preventDefault();
675
+ const display = name.trim();
676
+ if (busy)
677
+ return;
678
+ if (!display)
679
+ return setRefused('Give it a name.');
680
+ if (wrong)
681
+ return setRefused(wrong);
682
+ setBusy(true);
683
+ setRefused(null);
684
+ const headers = { Authorization: `Bearer ${bearer() ?? ''}`, 'Content-Type': 'application/json' };
685
+ const why = async (r) => {
686
+ const body = (await r.json().catch(() => null));
687
+ return body?.error || body?.detail || body?.msg || body?.message || `IAM answered ${r.status}.`;
688
+ };
689
+ try {
690
+ const named = slugify(display) === slug;
691
+ const r = await fetch(`${api()}/v1/iam/onboard`, { method: 'POST', headers, body: JSON.stringify({ name: named ? display : slug }) });
692
+ if (!r.ok)
693
+ throw new Error(await why(r));
694
+ const made = (await r.json().catch(() => null))?.org;
695
+ if (!made)
696
+ throw new Error('IAM made no organization. Try again.');
697
+ renew();
698
+ await sdk.refreshAccessToken().catch(() => null);
699
+ if (!named) {
700
+ const p = await fetch(`${api()}/v1/iam/organizations/profile`, {
701
+ method: 'POST',
702
+ headers: { ...headers, Authorization: `Bearer ${bearer() ?? ''}` },
703
+ body: JSON.stringify({ owner: 'admin', name: made, displayName: display }),
704
+ });
705
+ if (!p.ok)
706
+ throw new Error(`${made} is made, and it is still called ${made}: ${await why(p)}`);
707
+ }
708
+ // A token that does not carry it yet is minted on the next load, which opens it.
709
+ if (!claimed().includes(made)) {
710
+ pick(made);
711
+ window.location.reload();
712
+ return;
713
+ }
714
+ onMade(made);
715
+ }
716
+ catch (err) {
717
+ setRefused(err instanceof Error ? err.message : 'Could not create it.');
718
+ setBusy(false);
719
+ }
720
+ };
721
+ return (_jsx(YStack, { role: "dialog", "aria-modal": true, "aria-label": "New organization", position: "fixed", inset: 0, z: DIALOG, bg: shade(28), overflowY: "auto", py: "6vh", px: 16, onClick: onClose, children: _jsxs(YStack, { render: "form", onSubmit: submit, onClick: (e) => e.stopPropagation(), width: "100%", maxW: 480, mx: "auto", bg: "var(--background)", rounded: 16, borderWidth: 1, borderColor: "$edge", boxShadow: `0 24px 64px ${shade(28)}`, pt: 24, px: 24, pb: 20, children: [_jsxs(XStack, { items: "flex-start", justify: "space-between", gap: 12, children: [_jsxs(YStack, { shrink: 1, minW: "auto", children: [_jsx(Text, { render: "h2", fontSize: "$8", fontWeight: "500", color: "$ink", children: "New organization" }), _jsx(Text, { render: "p", mt: 6, fontSize: "$3", color: "$soft", lineHeight: 21, children: "You own it. Its agents, credit and members are its own." })] }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", mr: -8, onClick: onClose, "aria-label": "Close", children: _jsx(X, { size: 16, "aria-hidden": true }) })] }), _jsxs(View, { render: "label", display: "block", mt: 20, children: [_jsx(Text, { display: "block", fontSize: "$4", fontWeight: "500", mb: 8, color: "$ink", children: "Name" }), _jsx(Text, { render: _jsx("input", { value: name, onChange: (e) => setName(e.target.value), placeholder: "Acme", maxLength: 100, autoFocus: true }), ...FIELD })] }), _jsxs(View, { render: "label", display: "block", mt: 16, children: [_jsx(Text, { display: "block", fontSize: "$4", fontWeight: "500", mb: 8, color: "$ink", children: "Slug" }), _jsx(Text, { render: _jsx("input", { value: slug, onChange: (e) => setTyped(e.target.value), placeholder: "acme", spellCheck: false, autoCapitalize: "off", autoCorrect: "off", "aria-invalid": Boolean(slug && wrong) || undefined }), ...FIELD, fontFamily: "$mono" }), _jsx(Text, { display: "block", fontSize: "$3", color: slug && wrong ? BAD : '$soft', mt: 8, children: slug && wrong ? wrong : 'How every request names it: lowercase letters, digits and hyphens. It does not change.' })] }), refused ? (_jsx(Text, { render: "p", role: "alert", mt: 14, fontSize: "$3", color: BAD, children: refused })) : null, _jsxs(XStack, { justify: "flex-end", gap: 10, mt: 22, flexWrap: "wrap", children: [_jsx(Button, { type: "button", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", disabled: busy || !name.trim() || Boolean(wrong), children: busy ? 'Creating…' : 'Create and open' })] })] }) }));
464
722
  }
465
723
  /** On the workspace's host, an organization whose plan is not live: it is offered the per-seat Team plan. */
466
724
  function Subscribe({ org, members, onSwitch, }) {
package/dist/Support.d.ts CHANGED
@@ -5,10 +5,3 @@
5
5
  * token is.
6
6
  */
7
7
  export declare function SupportBanner(): import("react").JSX.Element;
8
- /**
9
- * Every organization, for a SuperAdmin to step into. The list and the search are
10
- * IAM's, a page at a time; picking one asks IAM to step in.
11
- */
12
- export declare function SupportPicker({ onClose }: {
13
- onClose: () => void;
14
- }): import("react").JSX.Element;
package/dist/Support.js CHANGED
@@ -2,40 +2,20 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Support mode: a SuperAdmin steps into any organization to see what it sees.
4
4
  //
5
- // Everything here is IAM's. The list is IAM's organization list, which answers a
6
- // SuperAdmin every organization and anyone else only their own; the step in and
7
- // the step out are IAM's `assume` and `release`, which answer the operator's own
8
- // token re-scoped and record each step in the organization's audit log
9
- // (lib/auth/session.ts `support`). This file only draws them, and only for the
10
- // person IAM signs as a SuperAdmin (`superAdmin`); nothing here decides who may.
11
- import { useCallback, useEffect, useRef, useState } from 'react';
12
- import { Text, View, XStack, YStack } from '@hanzo/gui';
5
+ // Everything here is IAM's. The list of every organization is the chooser's
6
+ // (Orgs.tsx `Everyone`), IAM's organization list, which answers a SuperAdmin
7
+ // every organization and anyone else only their own; the step in and the step
8
+ // out are IAM's `assume` and `release`, which answer the operator's own token
9
+ // re-scoped and record each step in the organization's audit log
10
+ // (lib/session.ts `support`). This file draws the strip that says so while it
11
+ // lasts; nothing here decides who may.
12
+ import { useEffect, useState } from 'react';
13
+ import { Text, XStack } from '@hanzo/gui';
13
14
  import { Button } from '@hanzo/ui';
14
- import { X } from 'lucide-react';
15
15
  import { useHydrated } from './lib/hydrated.js';
16
- import { api } from './lib/api.js';
17
- import { abandon, assumed, bearer, named, support } from './lib/session.js';
18
- import { BAD, mix } from './lib/mix.js';
19
- /** The banner rides above the room's sheets, the picker between them. gui carries a `calc()` to the page unchanged;
20
- * its types only name custom properties. */
16
+ import { abandon, assumed, named, support } from './lib/session.js';
17
+ /** The banner rides above the room's sheets. gui carries a `calc()` to the page unchanged; its types only name custom properties. */
21
18
  const BANNER = 'calc(var(--z-modal) + 30)';
22
- const DIALOG = 'calc(var(--z-modal) + 20)';
23
- const SCRIM = mix('var(--pure-black)', 28, 'srgb');
24
- const muted = 'var(--muted-foreground)';
25
- /** A page of IAM's organization list: `q` narrows it, `cursor` continues it. */
26
- async function page(q, cursor) {
27
- const query = new URLSearchParams({ limit: '20' });
28
- if (q.trim())
29
- query.set('q', q.trim());
30
- if (cursor)
31
- query.set('cursor', cursor);
32
- const res = await fetch(`${api()}/v1/iam/organizations?${query}`, {
33
- headers: { Authorization: `Bearer ${bearer() ?? ''}` },
34
- });
35
- if (!res.ok)
36
- throw new Error(`IAM answered ${res.status}.`);
37
- return (await res.json());
38
- }
39
19
  /**
40
20
  * The strip across the top of every page while a SuperAdmin is inside an
41
21
  * organization that is not theirs: which one, as whom, and the way out. It reads
@@ -63,60 +43,3 @@ export function SupportBanner() {
63
43
  };
64
44
  return (_jsxs(XStack, { role: "status", "aria-label": "Support mode", position: "fixed", t: 52, l: "50%", x: "-50%", maxW: "calc(100vw - 32px)", z: BANNER, items: "center", justify: "center", gap: 12, flexWrap: "wrap", pt: 6, pr: 8, pb: 6, pl: 16, rounded: 999, bg: "var(--foreground)", children: [_jsxs(Text, { fontSize: "$2", color: "var(--background)", children: ["Support mode: you are in ", _jsx("strong", { children: org }), who ? ` as ${who}` : '', ". IAM records it under your name."] }), _jsx(Button, { size: "sm", onClick: leave, disabled: busy, children: busy ? 'Leaving' : 'Leave' })] }));
65
45
  }
66
- /**
67
- * Every organization, for a SuperAdmin to step into. The list and the search are
68
- * IAM's, a page at a time; picking one asks IAM to step in.
69
- */
70
- export function SupportPicker({ onClose }) {
71
- const [q, setQ] = useState('');
72
- const [rows, setRows] = useState([]);
73
- const [cursor, setCursor] = useState('');
74
- const [loading, setLoading] = useState(false);
75
- const [refused, setRefused] = useState(null);
76
- const [stepping, setStepping] = useState(null);
77
- // Only the newest read lands: a search typed while a page is in flight
78
- // supersedes it, so a slow answer to an old query never overwrites the new one.
79
- const asked = useRef(0);
80
- const load = useCallback(async (query, from) => {
81
- const mine = ++asked.current;
82
- setLoading(true);
83
- setRefused(null);
84
- try {
85
- const next = await page(query, from);
86
- if (mine !== asked.current)
87
- return;
88
- setRows((had) => {
89
- // IAM's cursor is a position, so an org created mid-walk can shift a row
90
- // across a page edge; each org is kept once.
91
- const all = from ? [...had, ...(next.organizations ?? [])] : next.organizations ?? [];
92
- return all.filter((o, i) => all.findIndex((x) => x.name === o.name) === i);
93
- });
94
- setCursor(next.cursor ?? '');
95
- }
96
- catch (e) {
97
- if (mine === asked.current)
98
- setRefused(e instanceof Error ? e.message : 'The list did not load.');
99
- }
100
- finally {
101
- if (mine === asked.current)
102
- setLoading(false);
103
- }
104
- }, []);
105
- useEffect(() => {
106
- const id = setTimeout(() => void load(q, ''), 250);
107
- return () => clearTimeout(id);
108
- }, [q, load]);
109
- const enter = (org) => {
110
- setStepping(org);
111
- setRefused(null);
112
- support(org).catch((e) => {
113
- setRefused(e instanceof Error ? e.message : 'IAM did not step in.');
114
- setStepping(null);
115
- });
116
- };
117
- return (_jsx(YStack, { role: "dialog", "aria-modal": true, "aria-label": "All organizations", onKeyDown: (e) => {
118
- // gui types the native event; on the web it is the DOM's, which carries the key.
119
- if ('key' in e && e.key === 'Escape')
120
- onClose();
121
- }, position: "fixed", inset: 0, z: DIALOG, bg: SCRIM, overflowY: "auto", py: "6vh", px: 16, onClick: onClose, children: _jsxs(YStack, { onClick: (e) => e.stopPropagation(), width: "100%", maxW: 560, mx: "auto", bg: "var(--background)", rounded: 16, pt: 24, px: 24, pb: 16, children: [_jsxs(XStack, { items: "flex-start", justify: "space-between", gap: 12, children: [_jsxs(YStack, { shrink: 1, minW: "auto", children: [_jsx(Text, { render: "h2", fontSize: "$7", fontWeight: "500", color: "var(--text-primary)", children: "All organizations" }), _jsx(Text, { render: "p", mt: 6, fontSize: "$3", color: muted, lineHeight: 21, children: "Support mode. Step into any organization to see its chats, agents and billing as it does. IAM records every step under your name." })] }), _jsx(Button, { variant: "ghost", size: "icon-sm", mr: -8, onClick: onClose, "aria-label": "Close", children: _jsx(X, { size: 16, "aria-hidden": true }) })] }), _jsx(Text, { render: _jsx("input", { value: q, onChange: (e) => setQ(e.target.value), placeholder: "Find an organization", "aria-label": "Find an organization", autoFocus: true }), width: "100%", mt: 16, fontSize: "$3", px: 12, py: 10, rounded: 10, borderWidth: 1, borderColor: "var(--border)", bg: "var(--background)", color: "var(--foreground)" }), _jsxs(View, { role: "list", "aria-label": "Organizations", mt: 10, display: "grid", gap: 2, children: [rows.map((o) => (_jsxs(XStack, { render: "button", role: "listitem", onClick: () => enter(o.name), disabled: stepping !== null, items: "baseline", justify: "space-between", gap: 12, width: "100%", px: 12, py: 10, rounded: 10, borderWidth: 0, bg: "transparent", cursor: "pointer", children: [_jsx(Text, { fontSize: "$3", fontWeight: "500", color: "var(--foreground)", text: "left", children: o.displayName || o.name }), _jsx(Text, { fontSize: "$2", color: muted, text: "left", children: stepping === o.name ? 'Stepping in' : o.name })] }, o.name))), !loading && rows.length === 0 && !refused ? (_jsx(Text, { render: "p", mx: 12, my: 8, fontSize: "$3", color: muted, children: "No organization matches." })) : null] }), refused ? (_jsx(Text, { render: "p", mx: 12, my: 8, fontSize: "$3", color: BAD, children: refused })) : null, cursor ? (_jsx(Button, { self: "flex-start", mt: 8, onClick: () => void load(q, cursor), disabled: loading, children: loading ? 'Loading' : 'More' })) : null] }) }));
122
- }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * An organization's name as IAM keys it, said once for the chooser.
3
+ *
4
+ * IAM refuses a name that is not already canonical rather than rewriting it
5
+ * (hanzoai/iam pkg/schema/orgname.go `OrgName`: lowercasing "Admin" would make
6
+ * it the reserved org), so a slug typed here is checked and refused, never
7
+ * folded. What IS folded is a display name into the slug it suggests, the way
8
+ * IAM's own onboarding does it (internal/oidc/onboard.go `slugifyOrg`), so the
9
+ * slug shown is the slug IAM makes of the same name.
10
+ */
11
+ /** The longest organization name IAM takes. */
12
+ export declare const LONGEST = 55;
13
+ /**
14
+ * The slug IAM's onboarding makes of a name: lowercase ASCII letters and digits,
15
+ * every other run one hyphen, none at either end, at most `LONGEST` long.
16
+ */
17
+ export declare function slugify(name: string): string;
18
+ /**
19
+ * Why IAM would not take `slug` as an organization's name, or null when it
20
+ * would. A name onboarding keeps exactly as typed: IAM's shape with single inner
21
+ * hyphens, since onboarding folds a double one. The names IAM holds for the
22
+ * platform are IAM's to refuse, in its own words.
23
+ */
24
+ export declare function refusal(slug: string): string | null;
25
+ /** Whether an organization answers what was typed: its name or its display name, in any case (IAM's `matches`). */
26
+ export declare function matches(o: {
27
+ name: string;
28
+ display: string;
29
+ }, typed: string): boolean;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * An organization's name as IAM keys it, said once for the chooser.
3
+ *
4
+ * IAM refuses a name that is not already canonical rather than rewriting it
5
+ * (hanzoai/iam pkg/schema/orgname.go `OrgName`: lowercasing "Admin" would make
6
+ * it the reserved org), so a slug typed here is checked and refused, never
7
+ * folded. What IS folded is a display name into the slug it suggests, the way
8
+ * IAM's own onboarding does it (internal/oidc/onboard.go `slugifyOrg`), so the
9
+ * slug shown is the slug IAM makes of the same name.
10
+ */
11
+ /** The longest organization name IAM takes. */
12
+ export const LONGEST = 55;
13
+ /**
14
+ * The slug IAM's onboarding makes of a name: lowercase ASCII letters and digits,
15
+ * every other run one hyphen, none at either end, at most `LONGEST` long.
16
+ */
17
+ export function slugify(name) {
18
+ let out = '';
19
+ let dash = false;
20
+ for (const ch of name.toLowerCase()) {
21
+ if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
22
+ out += ch;
23
+ dash = false;
24
+ }
25
+ else if (!dash) {
26
+ out += '-';
27
+ dash = true;
28
+ }
29
+ }
30
+ return out.replace(/^-+|-+$/g, '').slice(0, LONGEST).replace(/-+$/, '');
31
+ }
32
+ /**
33
+ * Why IAM would not take `slug` as an organization's name, or null when it
34
+ * would. A name onboarding keeps exactly as typed: IAM's shape with single inner
35
+ * hyphens, since onboarding folds a double one. The names IAM holds for the
36
+ * platform are IAM's to refuse, in its own words.
37
+ */
38
+ export function refusal(slug) {
39
+ if (slug.length < 2)
40
+ return 'Use at least 2 letters or numbers.';
41
+ if (slug.length > LONGEST)
42
+ return `Use at most ${LONGEST} characters.`;
43
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
44
+ return 'Use lowercase letters, digits and single hyphens between them.';
45
+ return null;
46
+ }
47
+ /** Whether an organization answers what was typed: its name or its display name, in any case (IAM's `matches`). */
48
+ export function matches(o, typed) {
49
+ const q = typed.trim().toLowerCase();
50
+ return !q || o.name.toLowerCase().includes(q) || o.display.toLowerCase().includes(q);
51
+ }
@@ -180,6 +180,10 @@ export declare const PICKED = "hanzo:org:picked";
180
180
  * app's account menu — is one the gate does not ask for again.
181
181
  */
182
182
  export declare function pick(o: string): void;
183
+ /** Fired on `window` to open the organization chooser over the room. */
184
+ export declare const CHOOSE = "hanzo:org:choose";
185
+ /** Open the chooser (`Orgs`): every organization of the person's, and every one for a SuperAdmin. */
186
+ export declare function choose(): void;
183
187
  /**
184
188
  * Whether the signed-in person is a SUPERADMIN: @hanzo/iam's `superAdmin`, which is
185
189
  * hanzoai/authz `Claims.Sudo` exactly — a person (never a program) whose own row's
@@ -437,6 +437,12 @@ export function pick(o) {
437
437
  }
438
438
  window.dispatchEvent(new Event(ORG));
439
439
  }
440
+ /** Fired on `window` to open the organization chooser over the room. */
441
+ export const CHOOSE = 'hanzo:org:choose';
442
+ /** Open the chooser (`Orgs`): every organization of the person's, and every one for a SuperAdmin. */
443
+ export function choose() {
444
+ window.dispatchEvent(new Event(CHOOSE));
445
+ }
440
446
  /**
441
447
  * Whether the signed-in person is a SUPERADMIN: @hanzo/iam's `superAdmin`, which is
442
448
  * hanzoai/authz `Claims.Sudo` exactly — a person (never a program) whose own row's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/rooms",
3
- "version": "0.1.83",
3
+ "version": "0.1.84",
4
4
  "description": "The workspace rooms — Home, Chat, Inbox, Contacts, Meet, Cal, Drive, Board, Work, Bots — and the frame that holds them, once, for every Hanzo surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",