@hanzo/build 0.2.44 → 0.2.46

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/lib/api/auto.d.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * DELETE /v1/auto/automations/{id} delete it, its schedule and its runs
9
9
  * POST /v1/auto/automations/{id}/run {run: {id, status}} one run, now
10
10
  * GET /v1/auto/automations/{id}/runs {data: [run]} newest first
11
+ * POST /v1/auto/automations/{id}/runs/{run}/review {post} post a held post, or discard it
11
12
  * GET /v1/auto/templates {data: [starter]} to start a new one from
12
13
  *
13
14
  * Each run is one Dev run in this organization, started as the person who last
@@ -38,6 +39,8 @@ export interface Draft {
38
39
  schedule: Schedule;
39
40
  permissions: Permissions;
40
41
  notify: boolean;
42
+ /** The connected accounts each run's answer is posted to: x, linkedin, facebook. */
43
+ postTo: string[];
41
44
  enabled: boolean;
42
45
  }
43
46
  export interface Last {
@@ -57,7 +60,13 @@ export interface Automation extends Draft {
57
60
  created: string;
58
61
  updated: string;
59
62
  }
60
- /** One run: succeeded, failed, running, queued, skipped or refused. */
63
+ /** Where one post went: its link, or the platform's reason it did not go. */
64
+ export interface Posted {
65
+ to: string;
66
+ url: string;
67
+ error: string;
68
+ }
69
+ /** One run: succeeded, failed, running, queued, review (a post waiting for you), skipped or refused. */
61
70
  export interface Run {
62
71
  id: string;
63
72
  status: string;
@@ -66,6 +75,9 @@ export interface Run {
66
75
  summary: string;
67
76
  /** The Dev run it started, or null when it started none. */
68
77
  session: string | null;
78
+ /** The post its agent wrote, for an automation that posts. */
79
+ draft: string;
80
+ posts: Posted[];
69
81
  }
70
82
  /** One automation as the platform answers it, or null for a row with no id. */
71
83
  export declare function read(raw: unknown): Automation | null;
@@ -83,6 +95,8 @@ export declare function start(t: Target, id: string): Promise<{
83
95
  status: string;
84
96
  }>;
85
97
  export declare function runs(t: Target, id: string): Promise<Run[]>;
98
+ /** Post a held post to the automation's accounts, or discard it. */
99
+ export declare function review(t: Target, id: string, runId: string, post: boolean): Promise<Run>;
86
100
  /** A schedule the way a person says it. */
87
101
  export declare function words(s: Schedule): string;
88
102
  /** A ready-made automation to start a new one from. Its schedule names no zone: the page fills in the person's. */
package/lib/api/auto.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * DELETE /v1/auto/automations/{id} delete it, its schedule and its runs
9
9
  * POST /v1/auto/automations/{id}/run {run: {id, status}} one run, now
10
10
  * GET /v1/auto/automations/{id}/runs {data: [run]} newest first
11
+ * POST /v1/auto/automations/{id}/runs/{run}/review {post} post a held post, or discard it
11
12
  * GET /v1/auto/templates {data: [starter]} to start a new one from
12
13
  *
13
14
  * Each run is one Dev run in this organization, started as the person who last
@@ -44,6 +45,7 @@ export function read(raw) {
44
45
  schedule: schedule(o.schedule),
45
46
  permissions: o.permissions === 'auto' ? 'auto' : 'ask',
46
47
  notify: o.notify === true,
48
+ postTo: (Array.isArray(o.postTo) ? o.postTo : []).filter((x) => typeof x === 'string'),
47
49
  enabled: o.enabled === true,
48
50
  draft: o.draft === true,
49
51
  next: nullable(o.next),
@@ -103,10 +105,21 @@ export async function runs(t, id) {
103
105
  return list(await call(t, 'GET', `/v1/auto/automations/${seg(id)}/runs`))
104
106
  .map((raw) => {
105
107
  const o = obj(raw);
106
- return { id: str(o.id), status: str(o.status), at: str(o.at), finished: nullable(o.finished), summary: str(o.summary), session: session(o.transcript) };
108
+ return run(o);
107
109
  })
108
110
  .filter((r) => r.id);
109
111
  }
112
+ function run(o) {
113
+ const posts = (Array.isArray(o.posts) ? o.posts : [])
114
+ .map(obj)
115
+ .filter((p) => str(p.to))
116
+ .map((p) => ({ to: str(p.to), url: /^https:\/\//.test(str(p.url)) ? str(p.url) : '', error: str(p.error) }));
117
+ return { id: str(o.id), status: str(o.status), at: str(o.at), finished: nullable(o.finished), summary: str(o.summary), session: session(o.transcript), draft: str(o.draft), posts };
118
+ }
119
+ /** Post a held post to the automation's accounts, or discard it. */
120
+ export async function review(t, id, runId, post) {
121
+ return run(obj(await call(t, 'POST', `/v1/auto/automations/${seg(id)}/runs/${seg(runId)}/review`, { post })));
122
+ }
110
123
  const DAYS = { mon: 'Mondays', tue: 'Tuesdays', wed: 'Wednesdays', thu: 'Thursdays', fri: 'Fridays', sat: 'Saturdays', sun: 'Sundays' };
111
124
  /** A schedule the way a person says it. */
112
125
  export function words(s) {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The organization's connectors: a Slack workspace, the GitHub accounts the
3
- * platform's GitHub App is installed on.
3
+ * platform's GitHub App is installed on, and the social accounts automations
4
+ * post to.
4
5
  *
5
6
  * GET /v1/provider/{id} one connector and this org's connection to it
6
7
  * POST /v1/provider/{id}/connect {return} → {authorizeUrl}: the provider's own consent page (org admin)
@@ -25,6 +26,19 @@ export interface Connector {
25
26
  }
26
27
  export declare function connector(raw: unknown, id?: string): Connector;
27
28
  export declare function read(t: Target, id: string): Promise<Connector>;
29
+ /**
30
+ * The social accounts an organization connects. An automation posts text to
31
+ * the ones that take it; Instagram and TikTok take only images and video, and
32
+ * say so. `needs` is what connecting waits on while Hanzo's app with the
33
+ * platform is not set up.
34
+ */
35
+ export declare const SOCIAL: readonly {
36
+ id: string;
37
+ name: string;
38
+ posts: boolean;
39
+ needs: string;
40
+ takes?: string;
41
+ }[];
28
42
  /** The provider's consent page for this org; its return lands on `back` when that is a Hanzo app page. */
29
43
  export declare function authorize(t: Target, id: string, back?: string): Promise<string>;
30
44
  export declare function disconnect(t: Target, id: string): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The organization's connectors: a Slack workspace, the GitHub accounts the
3
- * platform's GitHub App is installed on.
3
+ * platform's GitHub App is installed on, and the social accounts automations
4
+ * post to.
4
5
  *
5
6
  * GET /v1/provider/{id} one connector and this org's connection to it
6
7
  * POST /v1/provider/{id}/connect {return} → {authorizeUrl}: the provider's own consent page (org admin)
@@ -34,7 +35,37 @@ export async function read(t, id) {
34
35
  const CONSENT = {
35
36
  slack: /^https:\/\/slack\.com\//,
36
37
  github: /^https:\/\/github\.com\//,
38
+ x: /^https:\/\/(twitter|x)\.com\//,
39
+ linkedin: /^https:\/\/www\.linkedin\.com\//,
40
+ facebook: /^https:\/\/www\.facebook\.com\//,
41
+ instagram: /^https:\/\/www\.facebook\.com\//,
42
+ tiktok: /^https:\/\/www\.tiktok\.com\//,
37
43
  };
44
+ /**
45
+ * The social accounts an organization connects. An automation posts text to
46
+ * the ones that take it; Instagram and TikTok take only images and video, and
47
+ * say so. `needs` is what connecting waits on while Hanzo's app with the
48
+ * platform is not set up.
49
+ */
50
+ export const SOCIAL = [
51
+ { id: 'x', name: 'X', posts: true, needs: 'X posting needs Hanzo’s X developer app with paid API access; it is not set up yet.' },
52
+ { id: 'linkedin', name: 'LinkedIn', posts: true, needs: 'LinkedIn posting needs Hanzo’s LinkedIn app with Share on LinkedIn; it is not set up yet.' },
53
+ { id: 'facebook', name: 'Facebook', posts: true, needs: 'Facebook Page posting needs Hanzo’s Meta app, approved by Meta App Review to publish to Pages; it is not set up yet.' },
54
+ {
55
+ id: 'instagram',
56
+ name: 'Instagram',
57
+ posts: false,
58
+ needs: 'Instagram posting needs Hanzo’s Meta app, approved by Meta App Review; it is not set up yet.',
59
+ takes: 'Instagram takes photos and reels, not text, so automations do not post to it.',
60
+ },
61
+ {
62
+ id: 'tiktok',
63
+ name: 'TikTok',
64
+ posts: false,
65
+ needs: 'TikTok posting needs TikTok’s audit of Hanzo’s app; it is not set up yet.',
66
+ takes: 'TikTok takes videos and photos, not text, so automations do not post to it.',
67
+ },
68
+ ];
38
69
  /** The provider's consent page for this org; its return lands on `back` when that is a Hanzo app page. */
39
70
  export async function authorize(t, id, back) {
40
71
  const url = str(obj(await call(t, 'POST', `/v1/provider/${seg(id)}/connect`, back ? { return: back } : {})).authorizeUrl);
@@ -20,7 +20,11 @@ import { ChipSelect } from '@hanzo/ui/product';
20
20
  import { useLimits } from '@hanzo/ui/product/useLimits';
21
21
  import { useEffect, useId, useMemo, useRef, useState } from 'react';
22
22
  import { ago } from './ago.js';
23
- import { automation, automations, changes, create, remove, runs, save, start, starters, words } from './api/auto.js';
23
+ import { automation, automations, changes, create, remove, review, runs, save, start, starters, words } from './api/auto.js';
24
+ import { current, subscriptions } from './api/billing.js';
25
+ import { SOCIAL } from './api/provider.js';
26
+ import { Out } from './out.js';
27
+ import { useSocial } from './settings/integrations.js';
24
28
  import { Refusal } from './api/call.js';
25
29
  import { ENSO, limits as readLimits, models } from './api/models.js';
26
30
  import { Choice, Confirm, Field, Line, Sheet } from './customize/ui.js';
@@ -57,8 +61,17 @@ const PERMISSIONS = [
57
61
  { id: 'auto', label: 'Act on its own' },
58
62
  ];
59
63
  /** A run's status as the rail's dot draws it. */
60
- const DOT = { succeeded: 'done', failed: 'error', running: 'running', queued: 'running', skipped: 'stopped', refused: 'stopped' };
61
- const WORD = { succeeded: 'Succeeded', failed: 'Failed', running: 'Running', queued: 'Queued', skipped: 'Skipped', refused: 'Refused' };
64
+ const DOT = { succeeded: 'done', failed: 'error', running: 'running', queued: 'running', review: 'paused', skipped: 'stopped', refused: 'stopped' };
65
+ const WORD = {
66
+ succeeded: 'Succeeded',
67
+ failed: 'Failed',
68
+ running: 'Running',
69
+ queued: 'Queued',
70
+ review: 'Waiting for your review',
71
+ skipped: 'Skipped',
72
+ refused: 'Refused',
73
+ };
74
+ const NAME = Object.fromEntries(SOCIAL.map((p) => [p.id, p.name]));
62
75
  const live = (s) => s === 'running' || s === 'queued';
63
76
  /** The schedule a kind starts from, keeping whatever of `was` still applies. */
64
77
  function shaped(kind, was) {
@@ -107,6 +120,7 @@ const drafted = (a) => ({
107
120
  schedule: a.schedule,
108
121
  permissions: a.permissions,
109
122
  notify: a.notify,
123
+ postTo: a.postTo,
110
124
  enabled: a.draft ? true : a.enabled,
111
125
  });
112
126
  /** `next`, keeping each field of `cur` the person changed from `was` since. */
@@ -118,9 +132,9 @@ function rebase(cur, was, next) {
118
132
  }
119
133
  return out;
120
134
  }
121
- /** A run refused for want of credit. */
122
- const broke = (e) => e instanceof Refusal && e.status === 402;
123
- const BROKE = 'This organization has no credit for a run. Add credit, then Run now again.';
135
+ /** A run the platform refused for want of a plan: a free org with no credit for the run fee. */
136
+ const unplanned = (e) => e instanceof Refusal && e.status === 402;
137
+ const PLAN = 'Automations run on a paid plan; on Free each run needs $1 of credit, and this organization has none. Upgrade to run it.';
124
138
  export function Automations() {
125
139
  const host = useHost();
126
140
  const t = useTarget();
@@ -146,12 +160,12 @@ export function Automations() {
146
160
  /** One automation in the list: its name opens it, and its switch arms it. */
147
161
  function Item({ a, onOpen, onSwitch }) {
148
162
  const about = useId();
149
- return (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsxs(XStack, { render: "button", "aria-label": `Open ${a.name}`, "aria-describedby": about, onPress: onOpen, flex: 1, minW: 0, items: "center", gap: "$3", children: [_jsx(Workflow, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, flex: 1, minW: 0, style: { textAlign: 'left' }, children: a.name }), _jsxs(XStack, { id: about, items: "center", gap: "$3", children: [a.draft ? (_jsx(SizableText, { size: "$1", color: "$soft", px: "$2", rounded: "$2", borderWidth: 1, borderColor: "$borderColor", children: "Draft" })) : null, _jsx(SizableText, { size: "$1", color: "$soft", width: 220, numberOfLines: 1, style: { textAlign: 'left' }, children: a.draft ? 'Needs instructions' : words(a.schedule) }), _jsxs(XStack, { width: 150, items: "center", gap: "$2", children: [a.last ? _jsx(StatusDot, { status: DOT[a.last.status] ?? 'idle' }) : null, _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: a.last ? `${WORD[a.last.status] ?? a.last.status} · ${ago(a.last.at)}` : 'Never run' })] })] })] }), _jsx(XStack, { width: 56, justify: "flex-end", children: _jsx(Switch, { checked: a.enabled, disabled: a.draft, onCheckedChange: onSwitch, "aria-label": `${a.name} on` }) })] }));
163
+ return (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", hoverStyle: { bg: '$hover' }, children: [_jsxs(XStack, { render: "button", "aria-label": `Open ${a.name}`, "aria-describedby": about, onPress: onOpen, flex: 1, minW: 0, items: "center", gap: "$3", flexWrap: "wrap", rowGap: "$1", children: [_jsx(Workflow, { size: 14 }), _jsx(SizableText, { size: "$2", color: "$ink", numberOfLines: 1, flex: 1, minW: 120, style: { textAlign: 'left' }, children: a.name }), _jsxs(XStack, { id: about, items: "center", gap: "$3", flexWrap: "wrap", rowGap: "$1", maxW: "100%", children: [a.draft ? (_jsx(SizableText, { size: "$1", color: "$soft", px: "$2", rounded: "$2", borderWidth: 1, borderColor: "$borderColor", children: "Draft" })) : null, _jsx(SizableText, { size: "$1", color: "$soft", width: 220, numberOfLines: 1, style: { textAlign: 'left' }, children: a.draft ? 'Needs instructions' : words(a.schedule) }), _jsxs(XStack, { width: 150, items: "center", gap: "$2", children: [a.last ? _jsx(StatusDot, { status: DOT[a.last.status] ?? 'idle' }) : null, _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: a.last ? `${WORD[a.last.status] ?? a.last.status} · ${ago(a.last.at)}` : 'Never run' })] })] })] }), _jsx(XStack, { width: 56, justify: "flex-end", children: _jsx(Switch, { checked: a.enabled, disabled: a.draft, onCheckedChange: onSwitch, "aria-label": `${a.name} on` }) })] }));
150
164
  }
151
165
  function Empty({ says, children }) {
152
166
  return (_jsxs(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$4", px: "$4", py: "$8", gap: "$3", items: "center", children: [_jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: says }), children] }));
153
167
  }
154
- const BLANK = () => ({ name: '', instructions: '', model: null, schedule: { kind: 'manual' }, permissions: 'ask', notify: false, enabled: true });
168
+ const BLANK = () => ({ name: '', instructions: '', model: null, schedule: { kind: 'manual' }, permissions: 'ask', notify: false, postTo: [], enabled: true });
155
169
  /** One automation: what it does and when, its switch, Run now, and its runs. `new` writes one. */
156
170
  export function Editor({ id }) {
157
171
  const host = useHost();
@@ -172,8 +186,13 @@ export function Editor({ id }) {
172
186
  const [busy, setBusy] = useState('');
173
187
  const [note, setNote] = useState('');
174
188
  const [asking, setAsking] = useState(false);
175
- const [leaving, setLeaving] = useState(false);
176
- const [credit, setCredit] = useState(false);
189
+ // Where a move away from unsaved work was headed, while it asks first; '' for nowhere.
190
+ const [leaving, setLeaving] = useState('');
191
+ const [upgrade, setUpgrade] = useState(false);
192
+ const social = useSocial(signed, SOCIAL.filter((p) => p.posts).map((p) => p.id));
193
+ const subs = useRead(signed ? () => subscriptions(t) : null, [], [t, signed]);
194
+ // Free is what the platform says when it names no live subscription; a read that failed says nothing.
195
+ const free = !subs.loading && !subs.error && !current(subs.value);
177
196
  const nameRef = useRef(null);
178
197
  const asksRef = useRef(null);
179
198
  const offered = useRead(signed && fresh ? () => starters(t) : null, [], [t, signed, fresh]);
@@ -216,7 +235,7 @@ export function Editor({ id }) {
216
235
  // An edit answers what the last line asked for, so the line goes with it.
217
236
  const set = (p) => {
218
237
  setNote('');
219
- setCredit(false);
238
+ setUpgrade(false);
220
239
  setD((x) => ({ ...x, ...p }));
221
240
  };
222
241
  const setWhen = (p) => set({ schedule: { ...d.schedule, ...p } });
@@ -238,15 +257,15 @@ export function Editor({ id }) {
238
257
  const act = async (what, work) => {
239
258
  setBusy(what);
240
259
  setNote('');
241
- setCredit(false);
260
+ setUpgrade(false);
242
261
  try {
243
262
  await work();
244
263
  }
245
264
  catch (e) {
246
- // Wanting credit is said in these words only where a run was asked for.
247
- const run = what === 'run' && broke(e);
248
- setNote(run ? BROKE : e.message);
249
- setCredit(run);
265
+ // Wanting a plan is said in these words only where a run was asked for.
266
+ const run = what === 'run' && unplanned(e);
267
+ setNote(run ? PLAN : e.message);
268
+ setUpgrade(run);
250
269
  }
251
270
  finally {
252
271
  setBusy('');
@@ -300,8 +319,17 @@ export function Editor({ id }) {
300
319
  setNote('Not started: the previous run is still going.');
301
320
  });
302
321
  const leave = (how) => host.go(path({ kind: 'screen', screen: 'automations' }), how);
303
- // Unsaved work is not left without a word.
304
- const back = () => (dirty ? setLeaving(true) : leave());
322
+ // A held post is answered once; its buttons go when it is.
323
+ const [answered, setAnswered] = useState(() => new Set());
324
+ const answer = (r, post) => act('review', async () => {
325
+ await review(t, id, r.id, post);
326
+ setAnswered((x) => new Set(x).add(r.id));
327
+ setBeat((n) => n + 1);
328
+ setTick((n) => n + 1);
329
+ });
330
+ // Unsaved work is not left without a word, wherever the move goes.
331
+ const away = (to) => (dirty ? setLeaving(to) : host.go(to));
332
+ const back = () => away(path({ kind: 'screen', screen: 'automations' }));
305
333
  const begin = (x) => {
306
334
  const tz = x.schedule.kind === 'manual' ? undefined : zone();
307
335
  set({ name: x.name, instructions: x.instructions, schedule: tz ? { ...x.schedule, tz } : { kind: 'manual' } });
@@ -314,6 +342,8 @@ export function Editor({ id }) {
314
342
  if (!ready)
315
343
  return _jsx(Page, { title: "Automation", onBack: () => leave(), says: "Reading the automation\u2026" });
316
344
  const s = d.schedule;
345
+ // An automation that posts speaks for the organization: only an admin changes it.
346
+ const locked = !host.admin && Boolean(a?.postTo.length);
317
347
  // A new automation or a draft has nothing to switch on until it says what to do.
318
348
  const can = switchesAlone || Boolean(d.instructions.trim());
319
349
  const on = switchesAlone ? Boolean(a?.enabled) : can && d.enabled;
@@ -324,14 +354,32 @@ export function Editor({ id }) {
324
354
  setBase(drafted(a));
325
355
  setD(drafted(a));
326
356
  setNote('');
327
- }, children: "Discard changes" })) : null, _jsx(Button, { size: "sm", disabled: Boolean(busy) || !(fresh || drafty || dirty), onPress: () => complete() && void keep(), children: busy === 'save' ? 'Saving…' : fresh ? 'Create' : 'Save' })] }), note ? (_jsxs(XStack, { width: "100%", maxW: 760, mx: "auto", items: "center", gap: "$3", pt: "$2", children: [_jsx(Line, { children: note }), credit ? (_jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'settings', section: 'billing' })), children: "Add credit" })) : null] })) : null] }), _jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$5", children: [_jsxs(YStack, { width: "100%", maxW: 760, mx: "auto", gap: "$5", children: [_jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { render: "h1", size: "$6", color: "$ink", numberOfLines: 1, children: fresh ? 'New automation' : a?.name }), _jsx(SizableText, { size: "$2", color: "$soft", children: fresh
357
+ }, children: "Discard changes" })) : null, _jsx(Button, { size: "sm", disabled: Boolean(busy) || locked || !(fresh || drafty || dirty), onPress: () => complete() && void keep(), children: busy === 'save' ? 'Saving…' : fresh ? 'Create' : 'Save' })] }), note ? (_jsxs(XStack, { width: "100%", maxW: 760, mx: "auto", items: "center", gap: "$3", pt: "$2", children: [_jsx(Line, { children: note }), upgrade ? (_jsx(Button, { size: "sm", variant: "outline", onPress: () => away(path({ kind: 'screen', screen: 'plans' })), children: "See plans" })) : null] })) : null] }), _jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$5", children: [_jsxs(YStack, { width: "100%", maxW: 760, mx: "auto", gap: "$5", children: [locked ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "note", children: "This automation posts to the organization\u2019s accounts, so only an org admin changes it." })) : null, free ? (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2", rounded: "$3", borderWidth: 1, borderColor: "$borderColor", flexWrap: "wrap", rowGap: "$2", children: [_jsx(SizableText, { flex: 1, minW: 200, size: "$1", color: "$soft", children: "Paid plans include automation runs. On the Free plan each run needs $1 of credit." }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => away(path({ kind: 'screen', screen: 'plans' })), children: "See plans" })] })) : null, _jsxs(YStack, { gap: "$1", children: [_jsx(SizableText, { render: "h1", size: "$6", color: "$ink", numberOfLines: 1, children: fresh ? 'New automation' : a?.name }), _jsx(SizableText, { size: "$2", color: "$soft", children: fresh
328
358
  ? 'Name it, say what it should do, and when. It runs as you, in this organization.'
329
359
  : a?.draft
330
360
  ? 'A draft: say what it should do and save it, and it runs.'
331
- : [words(a.schedule), a.next && a.enabled ? `next ${local(a.next)}` : a.schedule.kind === 'manual' ? '' : 'off'].filter(Boolean).join(' · ') })] }), fresh && offered.value.length && !d.instructions.trim() ? (_jsxs(XStack, { gap: "$2", flexWrap: "wrap", rowGap: "$2", items: "center", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "Start from" }), offered.value.map((x) => (_jsx(Button, { size: "sm", variant: "outline", onPress: () => begin(x), "aria-label": `Start from ${x.name}`, children: x.name }, x.key)))] })) : null, _jsx(Field, { label: "Name", children: _jsx(Input, { ref: nameRef, value: d.name, onChangeText: (v) => set({ name: v }), onSubmitEditing: () => asksRef.current?.focus(), placeholder: "Morning briefing", "aria-label": "Automation name", autoFocus: fresh }) }), _jsx(Field, { label: "Instructions (required)", hint: "What the agent does each run. It works in a Dev sandbox with this organization's connectors, MCP servers and skills.", children: _jsx(Textarea, { ref: asksRef, value: d.instructions, onChangeText: (v) => set({ instructions: v }), placeholder: "What should it do each run? For example: summarize what needs my attention today across calendar, email and messages.", "aria-label": "Instructions", rows: 7 }) }), _jsx(Field, { label: "When it runs", hint: s.kind === 'cron' ? 'Five fields: minute, hour, day of month, month, day of week.' : undefined, children: _jsxs(XStack, { gap: "$2", flexWrap: "wrap", rowGap: "$2", items: "center", children: [_jsx(ChipSelect, { name: "When it runs", label: KINDS.find((k) => k.id === s.kind).label, chosen: KINDS.find((k) => k.id === s.kind), items: KINDS, onChange: (k) => set({ schedule: shaped(k.id, s) }), placement: "bottom-start", width: 200 }), s.kind === 'weekly' ? _jsx(ChipSelect, { name: "Day", label: DAYS.find((x) => x.id === s.day)?.label ?? 'Monday', chosen: DAYS.find((x) => x.id === s.day), items: DAYS, onChange: (x) => setWhen({ day: x.id }), placement: "bottom-start", width: 180 }) : null, s.kind === 'daily' || s.kind === 'weekdays' || s.kind === 'weekly' ? (_jsx(YStack, { width: 110, children: _jsx(Input, { value: s.at ?? '', onChangeText: (v) => setWhen({ at: v.trim() }), placeholder: "09:00", "aria-label": "Time of day", inputMode: "text" }) })) : null, s.kind === 'hourly' ? (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "at minute" }), _jsx(YStack, { width: 70, children: _jsx(Input, { value: (s.at ?? '00:00').slice(3), onChangeText: (v) => setWhen({ at: `00:${v.replace(/\D/g, '').slice(0, 2)}` }), placeholder: "00", "aria-label": "Minute", inputMode: "numeric" }) })] })) : null, s.kind === 'cron' ? (_jsx(YStack, { width: 200, children: _jsx(Input, { value: s.cron ?? '', onChangeText: (v) => setWhen({ cron: v }), placeholder: "0 9 * * 1-5", "aria-label": "Cron", autoCapitalize: "none" }) })) : null, s.kind !== 'manual' ? _jsx(ChipSelect, { name: "Time zone", label: s.tz || 'UTC', chosen: { id: s.tz || 'UTC', label: s.tz || 'UTC' }, items: zones, onChange: (z) => setWhen({ tz: z.id }), placeholder: "Search zones\u2026", placement: "bottom-start", width: 260 }) : null] }) }), _jsx(Field, { label: "Model", children: _jsx(XStack, { children: _jsx(ModelPicker, { size: "sm", name: "Model", models: catalog.value, scope: "chat", limits: limits, value: d.model ?? ENSO, onChange: (id) => set({ model: id === ENSO ? null : id }), loading: catalog.loading, error: catalog.error?.message ?? null }) }) }), _jsx(Field, { label: "Permissions", hint: d.permissions === 'auto' ? 'Works and uses connectors without stopping.' : 'Reads and researches, changes nothing, and ends with the actions it would take for you to approve.', children: _jsx(Choice, { label: "Permissions", value: d.permissions, options: PERMISSIONS, onChange: (p) => set({ permissions: p }) }) }), _jsxs(XStack, { items: "center", gap: "$3", children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", children: "Email me when a run ends" }), _jsx(SizableText, { size: "$1", color: "$soft", children: "One line on how it went, with a link to the run." })] }), _jsx(Switch, { checked: d.notify, onCheckedChange: (v) => set({ notify: v }), "aria-label": "Email me when a run ends" })] }), dirty && wrong && note !== wrong ? _jsx(Line, { children: wrong }) : null, a ? (_jsx(XStack, { children: _jsx(Button, { size: "sm", variant: "ghost", disabled: Boolean(busy), onPress: () => setAsking(true), children: "Delete" }) })) : null, a ? (_jsxs(YStack, { gap: "$2", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: "Runs" }), history.error && !history.value.length ? (_jsx(Line, { children: history.error.message })) : !history.value.length ? (_jsx(SizableText, { size: "$2", color: "$soft", children: history.loading ? 'Reading its runs…' : a.draft ? 'It runs once its instructions are saved.' : 'No runs yet. Run now starts one.' })) : (_jsx(YStack, { role: "list", borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", "aria-label": "Run history", children: history.value.map((r, i) => (_jsxs(XStack, { role: "listitem", items: "flex-start", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: i ? 1 : 0, borderColor: "$borderColor", children: [_jsx(YStack, { pt: "$1", children: _jsx(StatusDot, { status: DOT[r.status] ?? 'idle' }) }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", children: `${WORD[r.status] ?? r.status} · ${local(r.at)}` }), _jsx(SizableText, { size: "$1", color: "$soft", style: { whiteSpace: 'pre-wrap' }, children: r.summary || (live(r.status) ? 'Working…' : '') })] }), r.session ? (_jsx(Button, { size: "sm", variant: "ghost", onPress: () => host.go(r.session), "aria-label": `Open the Dev run of ${local(r.at)}`, children: "Open run" })) : null] }, r.id))) }))] })) : null] }), a ? (_jsx(Confirm, { what: a.name, says: "It stops running, and its schedule and its runs go with it. A run going now is stopped.", open: asking, onOpenChange: setAsking, onYes: async () => {
361
+ : [words(a.schedule), a.next && a.enabled ? `next ${local(a.next)}` : a.schedule.kind === 'manual' ? '' : 'off'].filter(Boolean).join(' · ') })] }), fresh && offered.value.length && !d.instructions.trim() ? (_jsxs(XStack, { gap: "$2", flexWrap: "wrap", rowGap: "$2", items: "center", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "Start from" }), offered.value.map((x) => (_jsx(Button, { size: "sm", variant: "outline", onPress: () => begin(x), "aria-label": `Start from ${x.name}`, children: x.name }, x.key)))] })) : null, _jsx(Field, { label: "Name", children: _jsx(Input, { ref: nameRef, value: d.name, onChangeText: (v) => set({ name: v }), onSubmitEditing: () => asksRef.current?.focus(), placeholder: "Morning briefing", "aria-label": "Automation name", autoFocus: fresh, disabled: locked }) }), _jsx(Field, { label: "Instructions (required)", hint: "What the agent does each run. It works in a Dev sandbox with this organization's connectors, MCP servers and skills.", children: _jsx(Textarea, { ref: asksRef, disabled: locked, value: d.instructions, onChangeText: (v) => set({ instructions: v }), placeholder: "What should it do each run? For example: summarize what needs my attention today across calendar, email and messages.", "aria-label": "Instructions", rows: 7 }) }), _jsx(Field, { label: "When it runs", hint: s.kind === 'cron' ? 'Five fields: minute, hour, day of month, month, day of week.' : undefined, children: _jsxs(XStack, { gap: "$2", flexWrap: "wrap", rowGap: "$2", items: "center", children: [_jsx(ChipSelect, { name: "When it runs", label: KINDS.find((k) => k.id === s.kind).label, chosen: KINDS.find((k) => k.id === s.kind), items: KINDS, onChange: (k) => set({ schedule: shaped(k.id, s) }), placement: "bottom-start", width: 200 }), s.kind === 'weekly' ? _jsx(ChipSelect, { name: "Day", label: DAYS.find((x) => x.id === s.day)?.label ?? 'Monday', chosen: DAYS.find((x) => x.id === s.day), items: DAYS, onChange: (x) => setWhen({ day: x.id }), placement: "bottom-start", width: 180 }) : null, s.kind === 'daily' || s.kind === 'weekdays' || s.kind === 'weekly' ? (_jsx(YStack, { width: 110, children: _jsx(Input, { value: s.at ?? '', onChangeText: (v) => setWhen({ at: v.trim() }), placeholder: "09:00", "aria-label": "Time of day", inputMode: "text" }) })) : null, s.kind === 'hourly' ? (_jsxs(XStack, { items: "center", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: "at minute" }), _jsx(YStack, { width: 70, children: _jsx(Input, { value: (s.at ?? '00:00').slice(3), onChangeText: (v) => setWhen({ at: `00:${v.replace(/\D/g, '').slice(0, 2)}` }), placeholder: "00", "aria-label": "Minute", inputMode: "numeric" }) })] })) : null, s.kind === 'cron' ? (_jsx(YStack, { width: 200, children: _jsx(Input, { value: s.cron ?? '', onChangeText: (v) => setWhen({ cron: v }), placeholder: "0 9 * * 1-5", "aria-label": "Cron", autoCapitalize: "none" }) })) : null, s.kind !== 'manual' ? _jsx(ChipSelect, { name: "Time zone", label: s.tz || 'UTC', chosen: { id: s.tz || 'UTC', label: s.tz || 'UTC' }, items: zones, onChange: (z) => setWhen({ tz: z.id }), placeholder: "Search zones\u2026", placement: "bottom-start", width: 260 }) : null] }) }), _jsx(Field, { label: "Model", children: _jsx(XStack, { children: _jsx(ModelPicker, { size: "sm", name: "Model", models: catalog.value, scope: "chat", limits: limits, value: d.model ?? ENSO, onChange: (id) => set({ model: id === ENSO ? null : id }), loading: catalog.loading, error: catalog.error?.message ?? null }) }) }), _jsx(Field, { label: "Permissions", hint: d.permissions === 'auto' ? 'Works and uses connectors without stopping.' : 'Reads and researches, changes nothing, and ends with the actions it would take for you to approve.', children: _jsx(Choice, { label: "Permissions", value: d.permissions, options: PERMISSIONS, onChange: (p) => set({ permissions: p }) }) }), _jsxs(Field, { label: "Post to", hint: !host.admin
362
+ ? 'An org admin chooses where automations post: the accounts are the organization’s.'
363
+ : !d.postTo.length
364
+ ? 'Turn an account on and each run’s answer is a post for it.'
365
+ : d.permissions === 'auto'
366
+ ? 'Each run posts what the agent writes, as written.'
367
+ : 'Each run holds what the agent writes for you to post or discard.', children: [_jsx(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", children: SOCIAL.filter((p) => p.posts).map((p, i) => {
368
+ const c = social.value[p.id];
369
+ const on = d.postTo.includes(p.id);
370
+ return (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: i ? 1 : 0, borderColor: "$borderColor", children: [_jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", children: p.name }), _jsx(SizableText, { size: "$1", color: "$soft", children: social.error
371
+ ? social.error.message
372
+ : !c
373
+ ? 'Reading…'
374
+ : c.connected
375
+ ? `Connected${c.account ? ` as ${c.account}` : ''}`
376
+ : `${c.available ? 'Not connected: an org admin connects it in Settings → Integrations.' : p.needs} ${on || d.permissions === 'ask'
377
+ ? 'Runs hold the post for you to read; posting it fails until it is connected.'
378
+ : 'Acting on its own, it posts only to a connected account.'}` })] }), _jsx(Switch, { checked: on, disabled: !host.admin || (!on && !c?.connected && d.permissions === 'auto'), onCheckedChange: (v) => set({ postTo: v ? [...d.postTo, p.id].sort() : d.postTo.filter((x) => x !== p.id) }), "aria-label": `Post to ${p.name}` })] }, p.id));
379
+ }) }), _jsx(XStack, { children: _jsx(Button, { size: "sm", variant: "ghost", onPress: () => away(path({ kind: 'settings', section: 'integrations' })), children: "Connect accounts" }) })] }), _jsxs(XStack, { items: "center", gap: "$3", children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", children: "Email me when a run ends" }), _jsx(SizableText, { size: "$1", color: "$soft", children: "One line on how it went, with a link to the run." })] }), _jsx(Switch, { checked: d.notify, onCheckedChange: (v) => set({ notify: v }), "aria-label": "Email me when a run ends" })] }), dirty && wrong && note !== wrong ? _jsx(Line, { children: wrong }) : null, a ? (_jsx(XStack, { children: _jsx(Button, { size: "sm", variant: "ghost", disabled: Boolean(busy), onPress: () => setAsking(true), children: "Delete" }) })) : null, a ? (_jsxs(YStack, { gap: "$2", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: "Runs" }), history.error && !history.value.length ? (_jsx(Line, { children: history.error.message })) : !history.value.length ? (_jsx(SizableText, { size: "$2", color: "$soft", children: history.loading ? 'Reading its runs…' : a.draft ? 'It runs once its instructions are saved.' : 'No runs yet. Run now starts one.' })) : (_jsx(YStack, { role: "list", borderWidth: 1, borderColor: "$borderColor", rounded: "$3", overflow: "hidden", "aria-label": "Run history", children: history.value.map((r, i) => (_jsxs(XStack, { role: "listitem", items: "flex-start", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: i ? 1 : 0, borderColor: "$borderColor", children: [_jsx(YStack, { pt: "$1", children: _jsx(StatusDot, { status: DOT[r.status] ?? 'idle' }) }), _jsxs(YStack, { flex: 1, minW: 0, gap: "$0.5", children: [_jsx(SizableText, { size: "$2", color: "$ink", children: `${WORD[r.status] ?? r.status} · ${local(r.at)}` }), _jsx(SizableText, { size: "$1", color: "$soft", style: { whiteSpace: 'pre-wrap' }, children: r.summary || (live(r.status) ? 'Working…' : '') }), r.draft ? (_jsx(YStack, { mt: "$1.5", px: "$3", py: "$2", rounded: "$2", bg: "$raised", role: "group", "aria-label": "The post", children: _jsx(SizableText, { size: "$2", color: "$ink", style: { whiteSpace: 'pre-wrap' }, children: r.draft }) })) : null, r.posts.map((x) => (_jsxs(XStack, { items: "center", gap: "$2", pt: "$1", children: [_jsx(SizableText, { size: "$1", color: "$ink", children: NAME[x.to] ?? x.to }), x.url ? (_jsx(Out, { href: x.url, label: `Open the ${NAME[x.to] ?? x.to} post`, children: _jsx(SizableText, { size: "$1", color: "$ink", textDecorationLine: "underline", children: "Posted \u00B7 open" }) })) : (_jsx(SizableText, { size: "$1", color: "$soft", children: x.error }))] }, x.to))), r.status === 'review' && host.admin && !answered.has(r.id) ? (_jsxs(XStack, { gap: "$2", pt: "$2", children: [_jsx(Button, { size: "sm", disabled: Boolean(busy), onPress: () => void answer(r, true), "aria-label": `Post the post of ${local(r.at)}`, children: "Post it" }), _jsx(Button, { size: "sm", variant: "ghost", disabled: Boolean(busy), onPress: () => void answer(r, false), "aria-label": `Discard the post of ${local(r.at)}`, children: "Discard" })] })) : r.status === 'review' && !host.admin ? (_jsx(SizableText, { size: "$1", color: "$soft", pt: "$1", children: "An org admin posts or discards it." })) : null] }), r.session ? (_jsx(Button, { size: "sm", variant: "ghost", onPress: () => host.go(r.session), "aria-label": `Open the Dev run of ${local(r.at)}`, children: "Open run" })) : null] }, r.id))) }))] })) : null] }), a ? (_jsx(Confirm, { what: a.name, says: "It stops running, and its schedule and its runs go with it. A run going now is stopped.", open: asking, onOpenChange: setAsking, onYes: async () => {
332
380
  await remove(t, a.id);
333
381
  leave({ replace: true });
334
- } })) : null, _jsxs(Sheet, { title: "Leave without saving?", open: leaving, onOpenChange: setLeaving, width: 420, children: [_jsx(SizableText, { size: "$2", color: "$soft", children: fresh ? 'This automation is not created yet.' : 'Your changes to this automation are not saved.' }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "ghost", onPress: () => setLeaving(false), children: "Keep editing" }), _jsx(Button, { size: "sm", variant: "destructive", onPress: () => leave(), children: "Leave" })] })] })] })] }));
382
+ } })) : null, _jsxs(Sheet, { title: "Leave without saving?", open: Boolean(leaving), onOpenChange: (o) => !o && setLeaving(''), width: 420, children: [_jsx(SizableText, { size: "$2", color: "$soft", children: fresh ? 'This automation is not created yet.' : 'Your changes to this automation are not saved.' }), _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "ghost", onPress: () => setLeaving(''), children: "Keep editing" }), _jsx(Button, { size: "sm", variant: "destructive", onPress: () => host.go(leaving), children: "Leave" })] })] })] })] }));
335
383
  }
336
384
  function Page({ title, says, onBack }) {
337
385
  return (_jsx(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$6", py: "$6", children: _jsxs(YStack, { width: "100%", maxW: 760, mx: "auto", gap: "$4", children: [_jsx(XStack, { children: _jsxs(Button, { size: "sm", variant: "ghost", onPress: onBack, "aria-label": "All automations", children: [_jsx(ChevronLeft, { size: 14 }), "Automations"] }) }), _jsx(SizableText, { render: "h1", size: "$6", color: "$ink", children: title }), _jsx(SizableText, { size: "$2", color: "$soft", children: says })] }) }));
package/lib/claims.d.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  /** The token's claims, or null when it is not a JWT. */
6
6
  export declare function claims(token: string | null): Record<string, unknown> | null;
7
7
  /**
8
- * Whether the token says its person administers `org`. Org admin, read off the
9
- * token's own `orgs` claim — never the reserved `admin` org, which is platform
10
- * authority and not what a builder decides anything by. The platform enforces
11
- * it again; this only decides which publish path is offered.
8
+ * Whether the token says its person administers `org`: owner or admin, read off
9
+ * the token's own `orgs` claim — never the reserved `admin` org, which is
10
+ * platform authority and not what a builder decides anything by. The platform
11
+ * enforces it again; this only decides which controls are offered.
12
12
  */
13
13
  export declare function administers(token: string | null, org: string | null): boolean;
package/lib/claims.js CHANGED
@@ -16,15 +16,20 @@ export function claims(token) {
16
16
  return null;
17
17
  }
18
18
  }
19
+ /** The coarse IAM roles that administer an org: its owner and its admins. */
20
+ const ADMIN = ['owner', 'admin'];
19
21
  /**
20
- * Whether the token says its person administers `org`. Org admin, read off the
21
- * token's own `orgs` claim — never the reserved `admin` org, which is platform
22
- * authority and not what a builder decides anything by. The platform enforces
23
- * it again; this only decides which publish path is offered.
22
+ * Whether the token says its person administers `org`: owner or admin, read off
23
+ * the token's own `orgs` claim — never the reserved `admin` org, which is
24
+ * platform authority and not what a builder decides anything by. The platform
25
+ * enforces it again; this only decides which controls are offered.
24
26
  */
25
27
  export function administers(token, org) {
26
28
  if (!org)
27
29
  return false;
28
30
  const set = claims(token)?.orgs;
29
- return (Array.isArray(set) ? set : []).some((r) => r?.org === org && r.role === 'admin');
31
+ return (Array.isArray(set) ? set : []).some((r) => {
32
+ const m = r;
33
+ return m?.org === org && ADMIN.includes(m.role);
34
+ });
30
35
  }
package/lib/host.d.ts CHANGED
@@ -37,7 +37,7 @@ export interface Host {
37
37
  chooseTheme?: (theme: Theme) => void;
38
38
  /** Who is signed in, or null. */
39
39
  person: Person | null;
40
- /** Whether the person administers `org`: an admin publishes to main, anyone else opens a review. */
40
+ /** Whether the person administers `org` (its owner or an admin): they publish to main and change its money; anyone else opens a review and reads. */
41
41
  admin: boolean;
42
42
  /** The address under the mount: '' | a session id | a slug | '-/codebases' | '-/projects' | '-/issues' | '-/artifacts' | '-/templates'. */
43
43
  path: string;
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  */
7
7
  import { SizableText, XStack, YStack } from '@hanzo/gui';
8
8
  import { CreditCard, Download, Plus, X } from '@hanzogui/lucide-icons-2';
9
- import { Button, Dialog, DialogContent, DialogTitle } from '@hanzo/ui';
9
+ import { Button, Dialog, DialogContent, DialogTitle, toast } from '@hanzo/ui';
10
10
  import { useState } from 'react';
11
11
  import { cancel, current, detach, invoices, label, methods, money, pdf, reactivate, subscriptions, } from '../api/billing.js';
12
12
  import { useRead } from '../data.js';
@@ -30,13 +30,15 @@ export function Billing() {
30
30
  return (_jsxs(YStack, { gap: "$5", children: [_jsx(Heading, { title: "Billing" }), _jsx(Soft, { children: "Sign in to see this organization\u2019s billing." })] }));
31
31
  }
32
32
  const plan = current(subs.value);
33
+ const empty = !cards.error && !cards.loading && cards.value.length === 0;
34
+ // A done act is a toast; a refused one stays on the page, beside what was tried.
33
35
  const act = async (what, done, after) => {
34
36
  setWorking(true);
35
37
  setNote('');
36
38
  try {
37
39
  await what();
38
40
  after();
39
- setNote(done);
41
+ toast.success(done);
40
42
  }
41
43
  catch (e) {
42
44
  setNote(e.message);
@@ -49,9 +51,9 @@ export function Billing() {
49
51
  ? subs.error.message
50
52
  : plan
51
53
  ? terms(plan)
52
- : 'No paid plan. Usage is paid from the balance.' })] }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'plans' })), children: "Adjust plan" })] }), _jsx(Group, { title: "Payment", detail: "The card a plan renews on and a top-up charges.", action: host.admin ? (_jsxs(Button, { size: "sm", variant: "outline", onPress: () => setAdding(true), children: [_jsx(Plus, { size: 14 }), " Add card"] })) : undefined, children: cards.error ? (_jsx(Soft, { children: cards.error.message })) : cards.loading && !cards.value.length ? (_jsx(Soft, { children: "Reading cards\u2026" })) : cards.value.length === 0 ? (_jsx(Card, { children: _jsx(Soft, { children: "No card on file." }) })) : (_jsx(Card, { children: cards.value.map((m, i) => (_jsx(Row, { first: i === 0, leading: _jsx(CreditCard, { size: 16 }), title: label(m), detail: [m.expires ? `Expires ${m.expires}` : '', m.default ? 'Default' : ''].filter(Boolean).join(' · ') || undefined, trailing: host.admin ? (_jsx(XStack, { render: "button", "aria-label": `Remove ${label(m)}`, p: "$1", onPress: () => void act(() => detach(t, m.id), `${label(m)} is removed`, cards.reload), children: _jsx(X, { size: 14 }) })) : undefined }, m.id))) })) }), _jsx(Group, { title: "Invoices", children: bills.error ? (_jsx(Soft, { children: bills.error.message })) : bills.loading && !bills.value.length ? (_jsx(Soft, { children: "Reading invoices\u2026" })) : bills.value.length === 0 ? (_jsx(Card, { children: _jsx(Soft, { children: "No invoices yet." }) })) : (_jsxs(Card, { children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", children: [_jsx(SizableText, { flex: 1, size: "$1", color: "$soft", children: "Date" }), _jsx(SizableText, { width: 90, size: "$1", color: "$soft", style: { textAlign: 'right' }, children: "Total" }), _jsx(SizableText, { width: 70, size: "$1", color: "$soft", "$max-sm": { display: 'none' }, children: "Status" }), _jsx(YStack, { width: 64 })] }), bills.value.map((b) => (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: day(b.date) || b.number || b.id }), _jsx(SizableText, { width: 90, size: "$2", color: "$ink", style: { textAlign: 'right' }, children: money(b.total, b.currency) }), _jsx(SizableText, { width: 70, size: "$1", color: "$soft", "$max-sm": { display: 'none' }, children: word(b.status) }), _jsx(XStack, { width: 64, justify: "flex-end", children: _jsx(Button, { size: "sm", variant: "ghost", "aria-label": `View invoice ${b.number || day(b.date)}`, onPress: () => setOpen(b), children: "View" }) })] }, b.id)))] })) }), plan ? (_jsx(Group, { title: "Cancellation", children: _jsx(Card, { children: _jsx(Row, { first: true, title: plan.ending ? `Your plan ends on ${day(plan.ends)}` : 'Cancel plan', detail: plan.ending ? 'Keep it, and it renews as before.' : 'It runs to the end of the period you paid for, then ends.', trailing: !host.admin ? undefined : plan.ending ? (_jsx(Button, { size: "sm", variant: "outline", disabled: working, onPress: () => void act(() => reactivate(t, plan.id), 'Your plan renews as before', subs.reload), children: "Keep plan" })) : (_jsx(Button, { size: "sm", variant: "destructive", disabled: working, onPress: () => setEnding(true), children: "Cancel" })) }) }) })) : null, _jsx(Note, { children: note }), _jsx(AddCard, { open: adding, onOpenChange: setAdding, onSaved: (m) => {
54
+ : 'No paid plan. Usage is paid from the balance.' })] }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => host.go(path({ kind: 'screen', screen: 'plans' })), children: "Adjust plan" })] }), _jsxs(Group, { title: "Payment", detail: "The card a plan renews on and a top-up charges.", action: host.admin && (cards.error || cards.value.length > 0) ? (_jsxs(Button, { size: "sm", variant: "outline", onPress: () => setAdding(true), children: [_jsx(Plus, { size: 14 }), " Add card"] })) : undefined, children: [cards.error ? (_jsx(Soft, { children: cards.error.message })) : cards.loading && !cards.value.length ? (_jsx(Soft, { children: "Reading cards\u2026" })) : empty ? (_jsxs(XStack, { items: "center", gap: "$3", px: "$4", py: "$4", borderWidth: 1, borderColor: "$borderColor", rounded: "$3", flexWrap: "wrap", children: [_jsx(CreditCard, { size: 20 }), _jsxs(YStack, { flex: 1, minW: 180, gap: "$1", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: "No card on file." }), _jsx(SizableText, { size: "$2", color: "$soft", children: host.admin ? 'Add one to pay for a plan or a top-up. Adding it charges nothing.' : 'An org admin adds cards.' })] }), host.admin ? (_jsxs(Button, { onPress: () => setAdding(true), children: [_jsx(Plus, { size: 16 }), " Add card"] })) : null] })) : (_jsx(Card, { children: cards.value.map((m, i) => (_jsx(Row, { first: i === 0, leading: _jsx(CreditCard, { size: 16 }), title: label(m), detail: [m.expires ? `Expires ${m.expires}` : '', m.default ? 'Default' : ''].filter(Boolean).join(' · ') || undefined, trailing: host.admin ? (_jsx(Button, { size: "sm", variant: "ghost", "aria-label": `Remove ${label(m)}`, disabled: working, onPress: () => void act(() => detach(t, m.id), `${label(m)} is removed`, cards.reload), children: "Remove" })) : undefined }, m.id))) })), !host.admin && cards.value.length ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "An org admin adds and removes cards." })) : null] }), _jsx(Group, { title: "Invoices", children: bills.error ? (_jsx(Soft, { children: bills.error.message })) : bills.loading && !bills.value.length ? (_jsx(Soft, { children: "Reading invoices\u2026" })) : bills.value.length === 0 ? (_jsx(Card, { children: _jsx(Soft, { children: "No invoices yet." }) })) : (_jsxs(Card, { children: [_jsxs(XStack, { px: "$3", py: "$2", gap: "$3", children: [_jsx(SizableText, { flex: 1, size: "$1", color: "$soft", children: "Date" }), _jsx(SizableText, { width: 90, size: "$1", color: "$soft", style: { textAlign: 'right' }, children: "Total" }), _jsx(SizableText, { width: 70, size: "$1", color: "$soft", "$max-sm": { display: 'none' }, children: "Status" }), _jsx(YStack, { width: 64 })] }), bills.value.map((b) => (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2.5", borderTopWidth: 1, borderColor: "$borderColor", children: [_jsx(SizableText, { flex: 1, size: "$2", color: "$ink", numberOfLines: 1, children: day(b.date) || b.number || b.id }), _jsx(SizableText, { width: 90, size: "$2", color: "$ink", style: { textAlign: 'right' }, children: money(b.total, b.currency) }), _jsx(SizableText, { width: 70, size: "$1", color: "$soft", "$max-sm": { display: 'none' }, children: word(b.status) }), _jsx(XStack, { width: 64, justify: "flex-end", children: _jsx(Button, { size: "sm", variant: "ghost", "aria-label": `View invoice ${b.number || day(b.date)}`, onPress: () => setOpen(b), children: "View" }) })] }, b.id)))] })) }), plan ? (_jsx(Group, { title: "Cancellation", children: _jsx(Card, { children: _jsx(Row, { first: true, title: plan.ending ? `Your plan ends on ${day(plan.ends)}` : 'Cancel plan', detail: plan.ending ? 'Keep it, and it renews as before.' : 'It runs to the end of the period you paid for, then ends.', trailing: !host.admin ? undefined : plan.ending ? (_jsx(Button, { size: "sm", variant: "outline", disabled: working, onPress: () => void act(() => reactivate(t, plan.id), 'Your plan renews as before', subs.reload), children: "Keep plan" })) : (_jsx(Button, { size: "sm", variant: "destructive", disabled: working, onPress: () => setEnding(true), children: "Cancel" })) }) }) })) : null, _jsx(Note, { children: note }), _jsx(AddCard, { open: adding, onOpenChange: setAdding, onSaved: (m) => {
53
55
  cards.reload();
54
- setNote(`${label(m)} is saved`);
56
+ toast.success(`${label(m)} is saved`);
55
57
  } }), plan ? (_jsx(Ending, { plan: plan, open: ending, working: working, onOpenChange: setEnding, onConfirm: () => void act(() => cancel(t, plan.id), `Your plan ends on ${day(plan.ends)}`, () => {
56
58
  setEnding(false);
57
59
  subs.reload();
@@ -101,5 +101,5 @@ export function AddCard({ open, onOpenChange, onSaved }) {
101
101
  const why = cfg.error ? cfg.error.message : !cfg.loading && !ready(p) ? 'This deployment takes no cards yet.' : '';
102
102
  return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { maxW: 440, showCloseButton: false, children: [_jsxs(XStack, { items: "center", justify: "space-between", gap: "$2", children: [_jsx(DialogTitle, { children: "Add a card" }), _jsx(XStack, { render: "button", "aria-label": "Close", p: "$1", onPress: () => onOpenChange(false), children: _jsx(X, { size: 16 }) })] }), _jsxs(YStack, { gap: "$3", children: [why ? (_jsx(SizableText, { size: "$2", color: "$soft", children: why })) : (_jsxs(_Fragment, { children: [_jsxs(YStack, { minH: 96, "aria-label": "Card details", children: [_jsx("div", { ref: setBox, style: { minHeight: 96 } }), !mounted && !note ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "Loading the card form\u2026" })) : null] }), _jsx(SizableText, { size: "$1", color: "$soft", children: p && p.environment !== 'production'
103
103
  ? 'Sandbox: use a test card. Nothing is charged.'
104
- : 'The card number goes to the payment processor, never to Hanzo.' })] })), note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "ghost", onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: !mounted || working, onPress: () => void add(), children: "Save card" })] })] })] }) }));
104
+ : 'The card number goes to the payment processor, never to Hanzo. Saving it charges nothing.' })] })), note ? (_jsx(SizableText, { size: "$1", color: "$soft", role: "status", children: note })) : null, _jsxs(XStack, { gap: "$2", justify: "flex-end", children: [_jsx(Button, { size: "sm", variant: "ghost", onPress: () => onOpenChange(false), children: "Cancel" }), _jsx(Button, { size: "sm", disabled: !mounted || working, onPress: () => void add(), children: "Save card" })] })] })] }) }));
105
105
  }
@@ -1 +1,8 @@
1
+ import { type Connector } from '../api/provider.ts';
1
2
  export declare function Integrations(): import("react").JSX.Element;
3
+ /**
4
+ * The named social connectors, by id: whether each can be connected here and
5
+ * whether it is. One that could not be read is left out, and its row reads on
6
+ * as "Reading…" rather than taking the others with it.
7
+ */
8
+ export declare function useSocial(signed: boolean, ids: readonly string[]): import("../data.ts").Read<Record<string, Connector>>;
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * Integrations: your own GitHub connection, the GitHub accounts the
4
- * organization has installed the platform's App on, and its Slack workspace.
4
+ * organization has installed the platform's App on, its Slack workspace, and
5
+ * the social accounts its automations post to.
5
6
  *
6
7
  * Connecting leaves this page once, for GitHub's or Slack's own consent screen,
7
8
  * and the platform's callback brings the person back here with the answer
@@ -10,11 +11,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
11
  * the platform's rule.
11
12
  */
12
13
  import { SizableText, XStack, YStack } from '@hanzo/gui';
13
- import { Github, Hash, Lock, Slack } from '@hanzogui/lucide-icons-2';
14
+ import { Github, Hash, Lock, Megaphone, Slack } from '@hanzogui/lucide-icons-2';
14
15
  import { Button } from '@hanzo/ui';
15
16
  import { useState } from 'react';
16
17
  import { connect, connection, disconnect as unlinkGithub, installations } from '../api/github.js';
17
- import { authorize, channels, disconnect, read } from '../api/provider.js';
18
+ import { authorize, channels, disconnect, read, SOCIAL } from '../api/provider.js';
18
19
  import { here, useBack } from '../back.js';
19
20
  import { useRead } from '../data.js';
20
21
  import { useHost, useTarget } from '../host.js';
@@ -28,12 +29,14 @@ export function Integrations() {
28
29
  const slack = useRead(signed ? () => read(t, 'slack') : null, null, [t, signed]);
29
30
  const joined = slack.value?.connected ?? false;
30
31
  const rooms = useRead(signed && joined ? () => channels(t) : null, { channels: [], next: '' }, [t, signed, joined]);
32
+ const social = useSocial(signed, SOCIAL.map((p) => p.id));
31
33
  const [working, setWorking] = useState(false);
32
34
  const [note, setNote] = useState('');
33
35
  useBack(t, signed, setNote, () => {
34
36
  mine.reload();
35
37
  installed.reload();
36
38
  slack.reload();
39
+ social.reload();
37
40
  });
38
41
  if (!signed) {
39
42
  return (_jsxs(YStack, { gap: "$5", children: [_jsx(Heading, { title: "Integrations" }), _jsx(Soft, { children: "Sign in to see this organization\u2019s integrations." })] }));
@@ -85,5 +88,31 @@ export function Integrations() {
85
88
  ? 'Not connected'
86
89
  : s.note || 'This deployment cannot connect Slack yet.', trailing: host.admin && s && (s.connected || s.available) ? (s.connected ? (_jsx(Button, { size: "sm", variant: "outline", disabled: working, onPress: () => void act(() => disconnect(t, 'slack'), 'Slack is disconnected', slack.reload), children: "Disconnect" })) : (_jsx(Button, { size: "sm", variant: "outline", disabled: working, onPress: () => void away(() => authorize(t, 'slack', here())), children: "Connect" }))) : null }), joined && rooms.error ? _jsx(Row, { title: "Channels", detail: rooms.error.message }) : null, joined
87
90
  ? rooms.value.channels.map((c) => (_jsx(Row, { leading: c.private ? _jsx(Lock, { size: 14 }) : _jsx(Hash, { size: 14 }), title: c.name || c.id, trailing: _jsx(SizableText, { size: "$1", color: c.member ? '$ink' : '$soft', children: c.member ? 'Joined' : 'Not joined' }) }, c.id)))
88
- : null] }), !host.admin ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "An org admin connects the Slack workspace." })) : null] }), _jsx(Note, { children: note })] }));
91
+ : null] }), !host.admin ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "An org admin connects the Slack workspace." })) : null] }), _jsxs(Group, { title: "Social accounts", detail: "Where automations post what their agent writes.", children: [_jsx(Card, { children: SOCIAL.map((p, i) => {
92
+ const c = social.value[p.id];
93
+ return (_jsx(Row, { first: i === 0, leading: _jsx(Megaphone, { size: 16 }), title: c?.connected && c.account ? `${p.name} · ${c.account}` : p.name, detail: social.error
94
+ ? social.error.message
95
+ : !c
96
+ ? 'Reading…'
97
+ : c.connected
98
+ ? `Connected${c.since ? ` since ${day(c.since)}` : ''}${p.takes ? `. ${p.takes}` : ''}`
99
+ : c.available
100
+ ? p.takes || 'Not connected'
101
+ : p.takes ? `${p.takes} ${p.needs}` : p.needs, trailing: host.admin && c && (c.connected || c.available) ? (c.connected ? (_jsx(Button, { size: "sm", variant: "outline", disabled: working, onPress: () => void act(() => disconnect(t, p.id), `${p.name} is disconnected`, social.reload), children: "Disconnect" })) : (_jsx(Button, { size: "sm", variant: "outline", disabled: working, onPress: () => void away(() => authorize(t, p.id, here())), "aria-label": `Connect ${p.name}`, children: "Connect" }))) : null }, p.id));
102
+ }) }), !host.admin ? (_jsx(SizableText, { size: "$1", color: "$soft", children: "An org admin connects the organization\u2019s social accounts." })) : null] }), _jsx(Note, { children: note })] }));
103
+ }
104
+ /**
105
+ * The named social connectors, by id: whether each can be connected here and
106
+ * whether it is. One that could not be read is left out, and its row reads on
107
+ * as "Reading…" rather than taking the others with it.
108
+ */
109
+ export function useSocial(signed, ids) {
110
+ const t = useTarget();
111
+ const key = ids.join(',');
112
+ return useRead(signed
113
+ ? async () => {
114
+ const got = await Promise.allSettled(ids.map((id) => read(t, id)));
115
+ return Object.fromEntries(got.flatMap((r, i) => (r.status === 'fulfilled' ? [[ids[i], r.value]] : [])));
116
+ }
117
+ : null, {}, [t, signed, key]);
89
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/build",
3
- "version": "0.2.44",
3
+ "version": "0.2.46",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",