@arcforgelabs/flow 2026.10.35 → 2026.10.37

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.
@@ -75,10 +75,10 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
75
75
  if (!given) runtime = createXeroRuntime({ settings: requireXeroSettings(env ?? process.env) });
76
76
  const { settings } = current();
77
77
  if (reconfigured) await current().guardFor(db).resume();
78
- log.info?.(`[xero] mode ${settings.mode}; writes ${writesAllowed(settings) ? 'on' : 'off (XERO_ALLOW_LIVE_WRITES)'}; redirect URI ${settings.redirectUri ?? 'not set (FLOW_PUBLIC_ORIGIN)'}`);
78
+ log.info?.(`[xero] mode ${settings.mode}; writes ${writesAllowed(settings) ? 'on' : settings.backgroundJobs === false ? 'off (FLOW_BACKGROUND_JOBS)' : 'off (XERO_ALLOW_LIVE_WRITES)'}; redirect URI ${settings.redirectUri ?? 'not set (FLOW_PUBLIC_ORIGIN)'}`);
79
79
  // The history import (import.js) runs in the background outside replay
80
- // mode. Replay tests run its slices directly.
81
- if (settings.mode !== 'replay') {
80
+ // mode, where background jobs run. Replay tests run its slices directly.
81
+ if (settings.mode !== 'replay' && settings.backgroundJobs !== false) {
82
82
  importer = createImportWorker({ db, runtime: current(), log });
83
83
  await importer.start();
84
84
  }
@@ -11,6 +11,8 @@
11
11
  // replay no network: recorded, synthetic Xero responses
12
12
  // (server/integrations/xero/fixtures). Development and CI.
13
13
 
14
+ import { backgroundJobsOn } from '../../core/host.js';
15
+
14
16
  export const XERO_MODES = Object.freeze(['live', 'sandbox', 'replay']);
15
17
 
16
18
  // Xero's own public endpoints. They are the same for every installation.
@@ -154,6 +156,9 @@ export function resolveXeroSettings(env = process.env) {
154
156
  tenantId,
155
157
  tenantName,
156
158
  liveWrites: readOnOff(env, 'XERO_ALLOW_LIVE_WRITES'),
159
+ // Off in a Replit workspace (server/core/host.js): no history read and
160
+ // no writes, whatever XERO_ALLOW_LIVE_WRITES says.
161
+ backgroundJobs: backgroundJobsOn(env),
157
162
  dayLimit: readPositiveInteger(env, 'XERO_DAY_LIMIT', DEFAULT_XERO_DAY_LIMIT),
158
163
  usageWarningFraction: readFraction(env, 'XERO_USAGE_WARNING_FRACTION', 0.7),
159
164
  taxTypes: resolveTaxTypes(env),
@@ -177,5 +182,6 @@ export function resolveXeroSettings(env = process.env) {
177
182
  // Whether this installation may write to Xero now. Reads always may.
178
183
  export function writesAllowed(settings) {
179
184
  if (!settings) return false;
185
+ if (settings.backgroundJobs === false) return false;
180
186
  return settings.mode !== 'live' || settings.liveWrites === true;
181
187
  }
package/server/ui/app.js CHANGED
@@ -13,7 +13,7 @@ import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
13
13
  import { automationSection } from '/assets/ui/automations.js';
14
14
  import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
15
15
  import { renderPriceList } from '/assets/ui/price-list.js';
16
- import { renderCustomFields } from '/assets/ui/custom-fields.js';
16
+ import { lucide, renderCustomFields } from '/assets/ui/custom-fields.js';
17
17
  import { renderJobs } from '/assets/ui/jobs.js';
18
18
  import { startMonitoring } from '/assets/ui/monitoring.js';
19
19
  import { openSearch } from '/assets/ui/search.js';
@@ -59,6 +59,7 @@ function mountClerk(kind, element, options = {}) {
59
59
  // The "Manage account" modal does not inherit the button's appearance.
60
60
  if (kind === 'userButton') props.userProfileProps = { ...options.userProfileProps, appearance: clerkAppearance('modal') };
61
61
  if (kind === 'signIn') Clerk.mountSignIn(element, props);
62
+ else if (kind === 'signUp') Clerk.mountSignUp(element, props);
62
63
  else if (kind === 'userButton') Clerk.mountUserButton(element, props);
63
64
  else if (kind === 'profile') Clerk.mountUserProfile(element, props);
64
65
  mounted.set(element, { kind, options });
@@ -69,6 +70,7 @@ function unmountClerk(element) {
69
70
  if (!entry) return;
70
71
  const Clerk = window.Clerk;
71
72
  if (entry.kind === 'signIn') Clerk.unmountSignIn(element);
73
+ else if (entry.kind === 'signUp') Clerk.unmountSignUp(element);
72
74
  else if (entry.kind === 'userButton') Clerk.unmountUserButton(element);
73
75
  else if (entry.kind === 'profile') Clerk.unmountUserProfile(element);
74
76
  mounted.delete(element);
@@ -181,12 +183,13 @@ function statusPill(person) {
181
183
  if (person.status === 'pending') return h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'Waiting for approval');
182
184
  if (person.status === 'disabled') return h('span', { class: 'fx-status', 'data-tone': 'mute' }, 'Turned off');
183
185
  if (person.invited) return h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Invited');
184
- return h('span', { class: 'fx-status', 'data-tone': 'success' }, 'Active');
186
+ // Active is the usual state, so it shows nothing.
187
+ return null;
185
188
  }
186
189
 
187
190
  function roleSelect(value, attrs = {}) {
188
191
  return h('select', { class: 'fx-select', ...attrs },
189
- ['owner', 'admin', 'staff'].map((role) => h('option', { value: role, selected: role === value }, ROLE_LABEL[role])));
192
+ ['owner', 'admin', 'staff'].map((role) => h('option', { value: role, selected: role === value, title: ROLE_HINT[role] }, ROLE_LABEL[role])));
190
193
  }
191
194
 
192
195
  // ── Views ──────────────────────────────────────────────────────────────────
@@ -284,6 +287,13 @@ document.addEventListener('keydown', (event) => {
284
287
  document.addEventListener('click', (event) => {
285
288
  if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]')) closeMenu();
286
289
  });
290
+ // Folded, a click on the sidebar's empty space opens it out again, as in
291
+ // Claude. A click on a page, the account photo or Search still does its job.
292
+ document.addEventListener('click', (event) => {
293
+ if (NARROW.matches || !readFolded() || !event.target.closest?.('.fx-side')) return;
294
+ if (event.target.closest('a, button, input, select, textarea, [role="button"], [role="menuitem"], #user-button')) return;
295
+ toggleMenu();
296
+ });
287
297
 
288
298
  // Search, from the sidebar foot or Ctrl+K (⌘K): the pages this person may
289
299
  // open, invoices and jobs (search.js).
@@ -363,6 +373,13 @@ function renderSignedOut() {
363
373
  document.body.dataset.state = 'signed-out';
364
374
  document.title = 'Sign in · Flow';
365
375
  const panel = $('sign-in');
376
+ // The link in an invitation email (POST /api/people) carries a Clerk
377
+ // ticket: the invitee makes their account here, on that address only.
378
+ if (new URLSearchParams(location.search).has('__clerk_ticket')) {
379
+ $('sign-in-note').textContent = 'Finish your invitation to Flow.';
380
+ mountClerk('signUp', panel, { forceRedirectUrl: '/sign-in', signInUrl: '/sign-in' });
381
+ return;
382
+ }
366
383
  $('sign-in-note').textContent = config.publicSignUp
367
384
  ? 'Sign in, or ask for access with your work email.'
368
385
  : 'Sign in with the account you were invited with.';
@@ -442,13 +459,12 @@ function renderNoAccess(error) {
442
459
  // removes. Managing people needs a verified owner sign-in (/api/me says).
443
460
  async function renderPeople(main) {
444
461
  const canManage = Boolean(me.ownerActions?.allowed);
445
- const invite = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: !canManage, onclick: () => invitePerson() }, 'Invite someone');
462
+ const invite = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: !canManage, onclick: () => invitePerson() }, 'Invite');
446
463
  const tableHost = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
447
464
  main.append(...[
448
- pageHeader('People', { subtitle: 'Who can sign in to Flow, and what each person can do.', actions: [invite] }),
465
+ pageHeader('People', { actions: [invite] }),
449
466
  canManage ? null : notice('Managing people needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
450
467
  tableHost,
451
- h('dl', { class: 'fx-roles' }, ['owner', 'admin', 'staff'].flatMap((role) => [h('dt', {}, ROLE_LABEL[role]), h('dd', {}, ROLE_HINT[role])])),
452
468
  ].filter(Boolean));
453
469
  if (!canManage) {
454
470
  tableHost.replaceChildren(h('p', { class: 'fx-empty' }, 'The people list opens after a verified owner sign-in.'));
@@ -469,12 +485,34 @@ async function refreshPeople(host) {
469
485
  const rows = people.map((person) => personRow(person, host));
470
486
  host.replaceChildren(...[
471
487
  waiting ? notice(`${waiting} ${waiting === 1 ? 'person is' : 'people are'} waiting for approval.`, 'info') : null,
472
- h('table', { class: 'fx-table' },
473
- h('thead', {}, h('tr', {}, h('th', {}, 'Person'), h('th', {}, 'Role'), h('th', {}, 'Access'), h('th', { class: 'fx-table__actions' }, h('span', { class: 'fx-sr' }, 'Actions')))),
488
+ h('table', { class: 'fx-table fx-people' },
489
+ h('thead', { class: 'fx-sr' }, h('tr', {}, h('th', {}, 'Person'), h('th', {}, 'Role'), h('th', {}, 'Access'), h('th', {}, 'Actions'))),
474
490
  h('tbody', {}, rows)),
475
491
  ].filter(Boolean));
476
492
  }
477
493
 
494
+ let peopleMenus = 0;
495
+
496
+ // A row's ⋯ menu, as on the Custom fields page.
497
+ function rowMenu(label, items) {
498
+ const id = `fx-people-menu-${++peopleMenus}`;
499
+ const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, lucide(h, 'ellipsis'));
500
+ const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
501
+ panel.addEventListener('toggle', (event) => {
502
+ button.setAttribute('aria-expanded', String(event.newState === 'open'));
503
+ if (event.newState !== 'open') return;
504
+ const close = () => panel.hidePopover();
505
+ panel.replaceChildren(...items.map((entry) => (entry === '-'
506
+ ? h('div', { class: 'fx-pop__sep' })
507
+ : h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
508
+ h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
509
+ const at = button.getBoundingClientRect();
510
+ panel.style.top = `${Math.round(at.bottom + 6)}px`;
511
+ panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
512
+ });
513
+ return [button, panel];
514
+ }
515
+
478
516
  function personRow(person, host) {
479
517
  const self = person.id === me.person.id;
480
518
  const act = async (fn, done) => {
@@ -493,24 +531,28 @@ function personRow(person, host) {
493
531
  h('span', { class: 'fx-person__name' }, person.name || person.email, self ? h('span', { class: 'fx-tag' }, 'You') : null),
494
532
  person.name ? h('span', { class: 'fx-person__email' }, person.email) : null));
495
533
  const role = self
496
- ? h('span', {}, ROLE_LABEL[person.role])
534
+ ? h('span', { title: ROLE_HINT[person.role] }, ROLE_LABEL[person.role])
497
535
  : roleSelect(person.role, {
498
536
  'aria-label': `Role for ${person.email}`,
537
+ title: ROLE_HINT[person.role],
499
538
  onchange: (event) => act(() => patch({ role: event.target.value }), `${person.email} is now ${ROLE_LABEL[event.target.value].toLowerCase()}.`),
500
539
  });
501
540
  const actions = [];
502
541
  if (!self) {
503
542
  if (person.status === 'pending') {
504
543
  actions.push(h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'active' }), `${person.email} can sign in.`) }, 'Approve'));
505
- } else if (person.status === 'active') {
506
- actions.push(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'disabled' }), `${person.email} is turned off.`) }, 'Turn off'));
507
- } else {
508
- actions.push(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'active' }), `${person.email} is turned on.`) }, 'Turn on'));
509
544
  }
510
- actions.push(h('button', {
511
- class: 'fx-btn fx-btn--ghost fx-btn--danger fx-btn--sm',
512
- type: 'button',
513
- onclick: async () => {
545
+ const items = [];
546
+ if (person.invited && person.status === 'active') {
547
+ items.push({ icon: 'mail', text: 'Email invite again', run: () => act(() => flow(`/api/people/${person.id}/invite`, { method: 'POST' }), `Invite emailed to ${person.email}.`) });
548
+ }
549
+ if (person.status === 'active') items.push({ icon: 'circle-off', text: 'Turn off', run: () => act(() => patch({ status: 'disabled' }), `${person.email} is turned off.`) });
550
+ if (person.status === 'disabled') items.push({ icon: 'circle-check', text: 'Turn on', run: () => act(() => patch({ status: 'active' }), `${person.email} is turned on.`) });
551
+ items.push('-', {
552
+ icon: 'trash-2',
553
+ text: 'Remove',
554
+ tone: 'danger',
555
+ run: async () => {
514
556
  const ok = await dialog({
515
557
  title: `Remove ${person.email}?`,
516
558
  body: h('p', {}, 'They can no longer sign in to Flow. Records they made stay.'),
@@ -519,7 +561,8 @@ function personRow(person, host) {
519
561
  });
520
562
  if (ok) await act(() => flow(`/api/people/${person.id}`, { method: 'DELETE' }), `${person.email} is removed.`);
521
563
  },
522
- }, 'Remove'));
564
+ });
565
+ actions.push(...rowMenu(`More for ${person.email}`, items));
523
566
  }
524
567
  return h('tr', {},
525
568
  h('td', {}, who),
@@ -907,7 +950,27 @@ function xeroConnection(organisations, canChange) {
907
950
  return h('div', {}, h('h2', { class: 'fx-int-section__title' }, 'Xero organisation'), host);
908
951
  }
909
952
 
953
+ // When Clerk cannot email an invitation, the owner sends the invitee this
954
+ // message instead. It names the sign-in address and the email to use.
955
+ function inviteMessage(person) {
956
+ return [
957
+ 'You have access to Flow.',
958
+ `Sign in at ${location.origin}/sign-in with ${person.email}.`,
959
+ 'Use Continue with Google if that address is a Google account.',
960
+ ].join('\n');
961
+ }
962
+
963
+ async function copyInvite(person) {
964
+ try {
965
+ await navigator.clipboard.writeText(inviteMessage(person));
966
+ return true;
967
+ } catch {
968
+ return false;
969
+ }
970
+ }
971
+
910
972
  async function invitePerson() {
973
+ const hint = h('p', { class: 'fx-dim' }, ROLE_HINT.staff);
911
974
  const data = await dialog({
912
975
  title: 'Invite someone',
913
976
  body: h('div', { class: 'fx-fields' },
@@ -916,14 +979,16 @@ async function invitePerson() {
916
979
  h('input', { class: 'fx-input', name: 'email', type: 'email', required: true, autocomplete: 'off', placeholder: 'name@example.com' })),
917
980
  h('label', { class: 'fx-field' },
918
981
  h('span', { class: 'fx-label' }, 'Role'),
919
- roleSelect('staff', { name: 'role' })),
920
- h('p', { class: 'fx-dim' }, 'They sign in with this email. Flow prepares their sign-in account.')),
921
- confirm: 'Send invitation',
982
+ roleSelect('staff', { name: 'role', onchange: (event) => { hint.textContent = ROLE_HINT[event.target.value]; } })),
983
+ hint),
984
+ confirm: 'Send invite',
922
985
  });
923
986
  if (!data) return;
924
987
  try {
925
- await flow('/api/people', { method: 'POST', body: JSON.stringify({ email: data.get('email'), role: data.get('role') }) });
926
- toast(`${data.get('email')} is invited.`, 'success');
988
+ const { person, invite } = await flow('/api/people', { method: 'POST', body: JSON.stringify({ email: data.get('email'), role: data.get('role') }) });
989
+ if (invite?.emailed) toast(`Invite emailed to ${person.email}.`, 'success');
990
+ else if (await copyInvite(person)) toast(`${person.email} is invited, but the email could not be sent. A sign-in message is copied: paste it into an email to them.`, 'info');
991
+ else toast(`${person.email} is invited, but the email could not be sent. Send them: ${inviteMessage(person)}`, 'info');
927
992
  } catch (error) {
928
993
  toast(error.message, 'danger');
929
994
  }
@@ -0,0 +1,99 @@
1
+ // The bulk action bar: while a person has ticked rows in a list, it floats
2
+ // at the foot of the page, like ClickUp's. It shows "N selected ×" (the ×
3
+ // clears the ticks), then the actions in groups split by a thin line, then
4
+ // More. An action has a Lucide icon and one word; an icon-only action gives
5
+ // its name on hover and to a screen reader. More opens every action, with a
6
+ // search box, grouped under small heads.
7
+ //
8
+ // bulkBar(ui) answers { element, draw(state) }. state = { count, onClear,
9
+ // groups: [[action]], more: [{ head, actions: [action] }] }; an action is
10
+ // { icon, label, title?, run, primary?, iconOnly?, tone? }. title is the
11
+ // long name, for the hover tip and the More menu. A count of 0 hides the
12
+ // bar; Esc clears the ticks while it shows.
13
+ import { lucide } from '/assets/ui/custom-fields.js';
14
+
15
+ let barCount = 0;
16
+
17
+ export function bulkBar(ui) {
18
+ const { h } = ui;
19
+ const id = `fx-bulk-more-${++barCount}`;
20
+ const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': 'Ticked invoices', hidden: true });
21
+ const panel = h('div', { class: 'fx-pop fx-bulk__more', id, popover: 'auto', role: 'menu' });
22
+ let current = null;
23
+ let busy = false;
24
+
25
+ const run = async (action) => {
26
+ if (busy) return;
27
+ busy = true;
28
+ element.setAttribute('aria-busy', 'true');
29
+ try { await action.run(); } finally {
30
+ busy = false;
31
+ element.removeAttribute('aria-busy');
32
+ }
33
+ };
34
+
35
+ const button = (action) => h('button', {
36
+ type: 'button',
37
+ class: `fx-bulk__act${action.primary ? ' fx-bulk__act--primary' : ''}${action.iconOnly ? ' fx-bulk__act--icon' : ''}`,
38
+ ...(action.tone ? { 'data-tone': action.tone } : {}),
39
+ ...(action.iconOnly ? { 'aria-label': action.title ?? action.label, 'data-tip': action.title ?? action.label } : action.title ? { title: action.title } : {}),
40
+ onclick: () => run(action),
41
+ }, lucide(h, action.icon), action.iconOnly ? null : h('span', { class: 'fx-bulk__word' }, action.label));
42
+
43
+ // More: a search box over every action, under small heads.
44
+ const search = h('input', { class: 'fx-input fx-bulk__search', type: 'search', placeholder: 'Search…', 'aria-label': 'Search actions' });
45
+ const list = h('div', { class: 'fx-bulk__list' });
46
+ const drawMore = () => {
47
+ const q = search.value.trim().toLowerCase();
48
+ const parts = [];
49
+ for (const { head, actions } of current?.more ?? []) {
50
+ const found = actions.filter((a) => !q || `${a.label} ${a.title ?? ''}`.toLowerCase().includes(q));
51
+ if (!found.length) continue;
52
+ parts.push(h('p', { class: 'fx-pop__head' }, head), h('div', { class: 'fx-bulk__grid' }, found.map((action) => h('button', {
53
+ type: 'button', class: 'fx-pop__item', role: 'menuitem', ...(action.tone ? { 'data-tone': action.tone } : {}),
54
+ onclick: () => { panel.hidePopover?.(); run(action); },
55
+ }, h('span', { class: 'fx-pop__tick' }, lucide(h, action.icon)), h('span', {}, action.title ?? action.label)))));
56
+ }
57
+ list.replaceChildren(...(parts.length ? parts : [h('p', { class: 'fx-bulk__none' }, 'No action matches.')]));
58
+ };
59
+ search.addEventListener('input', drawMore);
60
+ const onKey = (event) => {
61
+ if (!element.isConnected) { document.removeEventListener('keydown', onKey); return; }
62
+ if (event.key === 'Escape' && !element.hidden && !panel.matches?.(':popover-open') && !document.querySelector('dialog[open]')) current?.onClear();
63
+ };
64
+ document.addEventListener?.('keydown', onKey);
65
+ panel.append(search, list);
66
+ const more = h('button', {
67
+ type: 'button', class: 'fx-bulk__act', popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false',
68
+ }, lucide(h, 'ellipsis'), h('span', { class: 'fx-bulk__word' }, 'More'));
69
+ // The menu opens above the bar, at its right edge.
70
+ panel.addEventListener('toggle', (event) => {
71
+ more.setAttribute('aria-expanded', String(event.newState === 'open'));
72
+ if (event.newState !== 'open') return;
73
+ search.value = '';
74
+ drawMore();
75
+ const at = more.getBoundingClientRect();
76
+ panel.style.top = `${Math.round(Math.max(8, at.top - panel.offsetHeight - 8))}px`;
77
+ panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
78
+ search.focus();
79
+ });
80
+
81
+ function draw(state) {
82
+ current = state;
83
+ element.hidden = !state?.count;
84
+ if (element.hidden) { panel.hidePopover?.(); return; }
85
+ const count = h('span', { class: 'fx-bulk__count' },
86
+ h('span', {}, `${state.count} selected`),
87
+ h('button', { type: 'button', class: 'fx-bulk__clear', 'aria-label': 'Clear the ticks', 'data-tip': 'Clear', onclick: state.onClear }, lucide(h, 'x')));
88
+ const groups = state.groups.filter((g) => g.length);
89
+ const parts = [];
90
+ groups.forEach((group, i) => {
91
+ if (i) parts.push(h('span', { class: 'fx-bulk__sep', 'aria-hidden': 'true' }));
92
+ parts.push(...group.map(button));
93
+ });
94
+ if ((state.more ?? []).some((g) => g.actions.length)) parts.push(h('span', { class: 'fx-bulk__sep', 'aria-hidden': 'true' }), more, panel);
95
+ element.replaceChildren(count, h('span', { class: 'fx-bulk__acts' }, ...parts));
96
+ }
97
+
98
+ return { element, draw };
99
+ }