@arcforgelabs/flow 2026.10.48 → 2026.10.49

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/server/ui/app.js CHANGED
@@ -25,7 +25,7 @@ import { isSearchKey, openSearch } from '/assets/ui/search.js';
25
25
  import { alertStrip } from '/assets/ui/alerts.js';
26
26
  import { initialsOf, personLabel } from '/assets/ui/person.js';
27
27
  import { startTips } from '/assets/ui/tip.js';
28
- import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
28
+ import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
29
29
  import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
30
30
 
31
31
  const $ = (id) => document.getElementById(id);
@@ -332,7 +332,7 @@ function navigate(event) {
332
332
  // The shared pieces a view module (invoicing.js) builds with. Its `go`
333
333
  // takes a path, or a link's click event.
334
334
  const ui = () => ({
335
- h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, dialog, me, holdButton, tileCard, tileSection, crumbs,
335
+ h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, me, holdButton, tileCard, tileSection, crumbs,
336
336
  go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
337
337
  });
338
338
 
@@ -622,6 +622,11 @@ const CATALOGUE = {
622
622
  const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software'], ['monitoring', 'Monitoring']];
623
623
  const CATEGORY_LABEL = Object.fromEntries(CATEGORIES);
624
624
 
625
+ // The two notices every integration page can show, each one short line. The
626
+ // detail is on the ? (docs/INTEGRATIONS.md has the rest).
627
+ const readOnlyNotice = () => notice('Verified owner sign-in needed.', 'warn', 'Read only.');
628
+ const secretsKeyNotice = () => notice('Set FLOW_SECRETS_KEY, then restart Flow.', 'warn', 'No secrets key.');
629
+
625
630
  function integrationLogo(integration, size = '') {
626
631
  const file = CATALOGUE[integration.id]?.logo;
627
632
  return h('span', { class: `fx-logo${size ? ` fx-logo--${size}` : ''}`, 'aria-hidden': 'true' },
@@ -631,7 +636,7 @@ function integrationLogo(integration, size = '') {
631
636
  async function renderIntegrations(main) {
632
637
  const state = { tab: 'all', category: 'all', q: '' };
633
638
  const tabs = h('div', { class: 'fx-seg', role: 'tablist' });
634
- const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Search integrations', 'aria-label': 'Search integrations' });
639
+ const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Search', 'aria-label': 'Search integrations' });
635
640
  const category = h('select', { class: 'fx-select', 'aria-label': 'Category' },
636
641
  CATEGORIES.map(([id, label]) => h('option', { value: id }, id === 'all' ? 'All categories' : label)));
637
642
  const notices = h('div', { class: 'fx-fields' });
@@ -650,8 +655,8 @@ async function renderIntegrations(main) {
650
655
  }
651
656
  const canChange = Boolean(me.ownerActions?.allowed);
652
657
  notices.replaceChildren(...[
653
- canChange ? null : notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
654
- body.secretsKey ? null : notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
658
+ canChange ? null : readOnlyNotice(),
659
+ body.secretsKey ? null : secretsKeyNotice(),
655
660
  ].filter(Boolean));
656
661
  const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
657
662
  const connected = (i) => i.enabled;
@@ -706,8 +711,8 @@ function integrationTile(integration) {
706
711
  }
707
712
 
708
713
 
709
- // One integration's page (/integrations/<id>): its logo and maker, Connect
710
- // with each step shown as it happens, the setup steps, its settings, and the
714
+ // One integration's page (/integrations/<id>): its logo, name and Connect
715
+ // (each step shown as it runs), the setup steps (closed), its settings, and the
711
716
  // facts beside them.
712
717
  async function renderIntegration(main, id) {
713
718
  const host = h('div', { class: 'fx-int-page' }, h('div', { class: 'fx-skeleton' }));
@@ -733,6 +738,9 @@ async function renderIntegration(main, id) {
733
738
  ? h('span', { class: 'fx-status', 'data-tone': 'danger', 'data-int-status': '' }, 'Calls stopped')
734
739
  : h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute', 'data-int-status': '' }, STATE_LABEL[current.state] ?? current.state));
735
740
  let shown = found;
741
+ // Settings: closed on a phone, open on a wider screen, then as the person
742
+ // leaves it while the page redraws (Connect).
743
+ let settingsOpen = !window.matchMedia?.('(max-width: 639px)').matches;
736
744
  const alerts = alertStrip(ui(), 'integrations', {
737
745
  scope: id,
738
746
  onLoad: ({ danger }) => {
@@ -750,7 +758,7 @@ async function renderIntegration(main, id) {
750
758
  // The host sets it: a value to read, not a box to type in.
751
759
  rows.push(settingRow({
752
760
  label: field.label,
753
- hint: 'Set by whoever hosts Flow.',
761
+ help: 'Set by whoever hosts Flow.',
754
762
  control: field.secret ? h('span', { class: 'fx-dim' }, 'Set') : h('code', { class: 'fx-setting__value' }, field.value),
755
763
  }));
756
764
  continue;
@@ -772,10 +780,11 @@ async function renderIntegration(main, id) {
772
780
  const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
773
781
  rows.push(settingRow({
774
782
  label: field.label,
775
- hint: field.unreadable ? 'Stored with another key. Enter it again.' : field.help,
783
+ hint: field.unreadable ? 'Stored with another key. Enter it again.' : null,
784
+ help: field.help,
776
785
  control: suggestion && !disabled
777
786
  ? h('div', { class: 'fx-setting__stack' }, input,
778
- h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', title: suggestion, onclick: () => { input.value = suggestion; navigator.clipboard?.writeText(suggestion); toast('Address filled in and copied.', 'success'); } }, 'Use this installation\'s address'))
787
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', title: suggestion, onclick: () => { input.value = suggestion; navigator.clipboard?.writeText(suggestion); toast('Address filled in and copied.', 'success'); } }, 'Use this address'))
779
788
  : input,
780
789
  }));
781
790
  }
@@ -831,7 +840,6 @@ async function renderIntegration(main, id) {
831
840
  const on = current.enabled;
832
841
  const guide = current.guide;
833
842
  const hostOwned = current.enabledBy === 'host';
834
- const entry = CATALOGUE[current.id];
835
843
  const mode = current.fields.find((f) => /_MODE$/.test(f.name));
836
844
  const modeLabel = mode?.choices?.find((c) => c.value === mode.value)?.label ?? mode?.value;
837
845
  const busy = progress && progress.steps.some((step) => step.state === 'running');
@@ -843,44 +851,41 @@ async function renderIntegration(main, id) {
843
851
 
844
852
  host.replaceChildren(
845
853
  crumbs(['/integrations', 'Integrations'], current.name),
846
- h('header', { class: 'fx-int-hero' },
847
- integrationLogo(current, 'xl'),
848
- h('div', { class: 'fx-int-hero__text' },
849
- h('h1', { class: 'fx-title' }, current.name),
850
- h('p', { class: 'fx-int-hero__maker' }, entry?.maker ?? current.name, h('span', { class: 'fx-dim' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')),
851
- h('p', { class: 'fx-int-hero__blurb' }, entry?.blurb ?? ''),
852
- h('div', { class: 'fx-int-hero__actions' },
853
- primary,
854
- on ? statusPill(current) : null,
855
- progress ? connectProgress(progress) : null))),
856
854
  h('div', { class: 'fx-int-page__grid' },
857
855
  h('div', { class: 'fx-int-page__main' }, ...[
856
+ h('header', { class: 'fx-int-hero' },
857
+ integrationLogo(current, 'lg'),
858
+ h('h1', { class: 'fx-title' }, current.name),
859
+ h('div', { class: 'fx-int-hero__actions' },
860
+ on ? statusPill(current) : null,
861
+ primary,
862
+ progress ? connectProgress(progress) : null)),
858
863
  alerts.element,
859
- !canChange ? notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.') : null,
860
- !body.secretsKey ? notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
864
+ !canChange ? readOnlyNotice() : null,
865
+ !body.secretsKey ? secretsKeyNotice() : null,
861
866
  current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
862
867
  current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
863
868
  current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
864
869
  on && current.state === 'ready' ? personSettingsSection(current) : null,
865
870
  current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
871
+ current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'xero', 'Contacts')) : null,
866
872
  current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
867
- guide ? h('section', { class: 'fx-int-section' },
868
- h('h2', { class: 'fx-int-section__title' }, 'Set up'),
873
+ guide ? h('details', { class: 'fx-int-section fx-int-fold' },
874
+ h('summary', { class: 'fx-int-section__title' }, 'Set up'),
869
875
  h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
870
- guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `Open ${current.name} developer site ↗`) : null) : null,
871
- rows.length ? h('section', { class: 'fx-int-section' },
872
- h('h2', { class: 'fx-int-section__title' }, 'Settings'),
876
+ guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `${current.name} site ↗`) : null) : null,
877
+ rows.length ? h('details', { class: 'fx-int-section fx-int-fold', open: settingsOpen, ontoggle: (event) => { settingsOpen = event.currentTarget.open; } },
878
+ h('summary', { class: 'fx-int-section__title' }, 'Settings'),
873
879
  h('div', { class: 'fx-settings' }, rows),
874
880
  canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
875
881
  ].filter(Boolean)),
876
882
  h('aside', { class: 'fx-int-page__meta' },
877
883
  h('dl', { class: 'fx-meta' }, ...[
878
884
  ['Status', statusPill(current)],
879
- ['Category', h('span', { class: 'fx-chip fx-chip--static' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')],
880
885
  modeLabel ? ['Mode', modeLabel] : null,
881
- ['Turned on by', current.enabledBy === 'host' ? 'The host' : current.enabledBy === 'flow' ? 'An owner, here' : '—'],
882
- current.updatedAt ? ['Last changed', new Date(current.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })] : null,
883
- guide?.link ? ['Documentation', h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, 'Developer site ↗')] : null,
886
+ current.enabledBy === 'host' ? ['On by', 'Host'] : current.enabledBy === 'flow' ? ['On by', 'Owner'] : null,
887
+ current.updatedAt ? ['Changed', new Date(current.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })] : null,
888
+ guide?.link ? ['Docs', h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, `${current.name} ↗`)] : null,
884
889
  ].filter(Boolean).map(([label, value]) => h('div', {}, h('dt', {}, label), h('dd', {}, value)))))));
885
890
  };
886
891
  document.title = `${found.name} · Flow`;
@@ -910,7 +915,7 @@ function personSettingsSection(integration) {
910
915
  }
911
916
  input.disabled = false;
912
917
  });
913
- return settingRow({ label: setting.label, hint: setting.hint, control: input });
918
+ return settingRow({ label: setting.label, help: setting.hint, control: input });
914
919
  });
915
920
  section.replaceChildren(section.firstChild, h('div', { class: 'fx-settings' }, rows));
916
921
  }).catch(() => section.remove());
@@ -933,7 +938,7 @@ function xeroImportSection(organisations) {
933
938
  const organisationId = organisations?.[0]?.id;
934
939
  const canAct = ['owner', 'admin'].includes(me?.person?.role);
935
940
  const count = h('span', { class: 'fx-num fx-xi-count' }, '…');
936
- const copied = h('span', {}, 'Every Xero invoice Flow copied. Search it with no Xero call.');
941
+ const copied = h('span', {}, '');
937
942
  const switchFor = (name, label) => {
938
943
  const button = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': label, 'aria-checked': 'false', disabled: true });
939
944
  button.addEventListener('click', async () => {
@@ -954,16 +959,16 @@ function xeroImportSection(organisations) {
954
959
  const makeUnmatched = switchFor('makeUnmatched', 'Make Flow invoices for the rest');
955
960
  const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
956
961
  h('h2', { class: 'fx-set__title' }, 'Import'),
957
- settingRow({ label: 'To check', hint: 'Xero invoices that fit a Flow invoice or a Flow job.',
962
+ settingRow({ label: 'To check', help: 'Xero invoices that fit a Flow invoice or a Flow job.',
958
963
  control: h('span', { class: 'fx-row' }, count, h('a', { class: 'fx-btn fx-btn--sm fx-btn--primary', href: '/xero-import', onclick: navigate }, 'Review')) }),
959
- settingRow({ label: 'Link sure matches', hint: 'Same client, total and week, and only one fit. Flow links them.', control: autoLink }),
960
- settingRow({ label: 'Make Flow invoices for the rest', hint: 'Off: they stay in the Xero history.', control: makeUnmatched }),
961
- settingRow({ label: 'Xero history', hint: copied, control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
964
+ settingRow({ label: 'Link sure matches', help: 'Same client, total and week, and only one fit. Flow links them.', control: autoLink }),
965
+ settingRow({ label: 'Make Flow invoices for the rest', help: 'Off: they stay in the Xero history.', control: makeUnmatched }),
966
+ settingRow({ label: 'Xero history', hint: copied, help: 'Every Xero invoice Flow copied. A search makes no Xero call.', control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
962
967
  if (organisationId) {
963
968
  flow(`/api/xero/import?organisationId=${organisationId}&show=check&limit=1`)
964
969
  .then(({ counts: c, settings }) => {
965
970
  count.replaceChildren(String(c.job_fits + c.invoice_fits));
966
- copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)} Xero invoices copied, ${c.linked} in Flow. Search with no Xero call.`);
971
+ copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)} copied, ${c.linked} in Flow`);
967
972
  autoLink.setAttribute('aria-checked', String(settings.autoLink));
968
973
  makeUnmatched.setAttribute('aria-checked', String(settings.makeUnmatched));
969
974
  autoLink.disabled = makeUnmatched.disabled = !canAct;
@@ -9,7 +9,7 @@ const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/AUTOMATIONS.md
9
9
 
10
10
  // A section of the automations kept at `place`, filled when they load.
11
11
  export function automationSection(ui, place, title) {
12
- const { h, flow, toast, notice, me } = ui;
12
+ const { h, flow, toast, notice, me, settingRow } = ui;
13
13
  const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
14
14
  const disabled = !['owner', 'admin'].includes(me?.person.role);
15
15
 
@@ -27,9 +27,7 @@ export function automationSection(ui, place, title) {
27
27
  }
28
28
  }
29
29
 
30
- const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
31
- h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
32
- h('div', { class: 'fx-set__control' }, control));
30
+ const row = (label, help, control) => settingRow({ label, help, control });
33
31
 
34
32
  // A menu of the statuses the work system reported, each with its job count.
35
33
  // A status saved earlier stays in the menu even when no job has it now.
@@ -1,8 +1,9 @@
1
1
  // Contacts (/contacts, /contacts/<id>). Flow's own customers and suppliers
2
2
  // (server/core/contacts.js, docs/CONTACTS.md). The list and the record work
3
3
  // with no integration on. When Xero is connected, the record says whether
4
- // that contact is in Xero and, for an owner or admin, offers Create.
4
+ // that contact is in Xero and, for an owner or admin, offers Link and Create.
5
5
  import { initialsOf } from '/assets/ui/person.js';
6
+ import { xeroContactActions } from '/assets/ui/xero-contact.js';
6
7
 
7
8
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CONTACTS.md';
8
9
  const KINDS = [['customer', 'Customer'], ['supplier', 'Supplier']];
@@ -225,36 +226,28 @@ async function loadXero(ui, contact, canEdit, host) {
225
226
  return;
226
227
  }
227
228
  if (match.state === 'off') return;
228
- const create = match.offerCreate && canEdit
229
- ? h('button', {
230
- class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
231
- onclick: async (event) => {
232
- const button = event.currentTarget;
233
- const confirmed = await ui.dialog({
234
- title: `Create ${contact.name} in Xero?`,
235
- body: h('p', {}, 'Flow adds this client to Xero from the contact.'),
236
- confirm: 'Create',
237
- });
238
- if (!confirmed) return;
239
- button.disabled = true;
240
- try {
241
- await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id }) });
242
- toast(`${contact.name} is in Xero.`, 'success');
243
- host.replaceChildren(h('section', { class: 'fx-card' }, notice(`${contact.name} is in Xero.`, 'success')));
244
- } catch (error) {
245
- toast(error.message, error.status === 429 ? 'info' : 'danger');
246
- if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
247
- else button.disabled = false;
248
- }
249
- },
250
- }, 'Create')
251
- : null;
229
+ const actions = canEdit ? xeroContactActions(ui, {
230
+ organisationId, contact, offerLink: match.offerLink, offerCreate: match.offerCreate,
231
+ done: (outcome) => host.replaceChildren(h('section', { class: 'fx-card' },
232
+ notice(outcome === 'linked' ? `${contact.name} is linked to Xero.` : `${contact.name} is in Xero.`, 'success'))),
233
+ }) : [];
252
234
  const text = match.state === 'matched'
253
235
  ? `In Xero${match.name ? ` as ${match.name}` : ''}.`
254
236
  : match.state === 'ambiguous'
255
237
  ? `More than one Xero contact is named "${contact.name}".`
256
238
  : `No Xero contact is named "${contact.name}".`;
239
+ // Fields both sides changed (Two-way edits): alert only. A person makes the
240
+ // two the same, here or in Xero, and the next check clears it.
241
+ const differs = match.differs ?? [];
242
+ const clash = differs.length
243
+ ? notice(`Changed in both: ${differs.map((d) => FIELD_LABELS[d.field] ?? d.field).join(', ')}. Make Flow and Xero the same.`, 'warn', 'Xero differs.')
244
+ : null;
257
245
  host.replaceChildren(h('section', { class: 'fx-card fx-offer' },
258
- notice(text, match.state === 'matched' ? 'success' : 'danger', match.state === 'matched' ? 'Xero.' : 'Xero.'),
259
- create));
246
+ clash ?? notice(text, match.state === 'matched' ? 'success' : 'danger', match.state === 'matched' ? 'Xero.' : 'Xero.'),
247
+ ...actions));
260
248
  }
249
+
250
+ const FIELD_LABELS = {
251
+ name: 'Name', firstName: 'First name', lastName: 'Last name', email: 'Email', phone: 'Phone',
252
+ mobile: 'Mobile', accountNumber: 'Account', taxNumber: 'Tax number', address: 'Address',
253
+ };