@arcforgelabs/flow 2026.10.36 → 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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.36",
3
+ "version": "2026.10.37",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.36",
9
+ "version": "2026.10.37",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.36",
3
+ "version": "2026.10.37",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -245,6 +245,13 @@ function withLink(job, systems) {
245
245
  return { ...job, systemName: source?.name ?? job.sourceSystem, link: link ?? null };
246
246
  }
247
247
 
248
+ // The connected systems an invoice can link to: work systems, then the
249
+ // accounting system.
250
+ const linkedSystems = (work, accounting) => [
251
+ ...work.map((a) => ({ id: a.id, name: a.name, kind: 'work' })),
252
+ ...(accounting ? [{ id: accounting.id, name: accounting.name, kind: 'accounting' }] : []),
253
+ ];
254
+
248
255
  async function jobsByInvoice(db, organisationId, invoiceIds) {
249
256
  if (!invoiceIds.length) return new Map();
250
257
  const rows = await db.select().from(billingObligations)
@@ -291,7 +298,9 @@ async function invoiceDetail(db, organisationId, invoiceId, accounting = null, w
291
298
  ? { mode: await sendModeFor(db, organisationId, invoice.contactId), otherCustomers: 0, ...(await accounting.capabilities.sendStatus({ organisationId })) }
292
299
  : null,
293
300
  contact: contact ?? null,
294
- job: jobs.get(invoiceId) ?? null,
301
+ job: withLink(jobs.get(invoiceId) ?? null, work),
302
+ // The Linked card: each connected system, and this invoice's link there.
303
+ linksTo: linkedSystems(work, accounting),
295
304
  versions: versions.map(({ version, issuedAt, issuedBy }) => ({ version, issuedAt, issuedBy })),
296
305
  };
297
306
  }
@@ -374,7 +383,7 @@ export function createInvoicingRouter({
374
383
  sendsTo: system?.name ?? null,
375
384
  sendMode: mode,
376
385
  // The Linked column: one icon for each connected system.
377
- linksTo: [...systems.map((a) => ({ id: a.id, name: a.name, kind: 'work' })), ...(system ? [{ id: system.id, name: system.name, kind: 'accounting' }] : [])],
386
+ linksTo: linkedSystems(systems, system),
378
387
  });
379
388
  }));
380
389
 
@@ -8,6 +8,9 @@ import { normaliseEmail } from './people.js';
8
8
  // getUser(clerkUserId) the Clerk user (emails, external accounts)
9
9
  // findUserIdByEmail(email) the Clerk user with this email, or null
10
10
  // createUser({ email }) a Clerk account with no password, for an invitee
11
+ // deleteUser(clerkUserId) removes a Clerk account (an unused invitee's only)
12
+ // inviteByEmail({ email, redirectUrl })
13
+ // Clerk emails an invitation link to `email`
11
14
  //
12
15
  // The real one is loaded only when FLOW_AUTH=clerk.
13
16
  export async function loadClerkAdapter({ secretKey, publishableKey, proxyUrl, authorizedParties = [] }) {
@@ -36,9 +39,41 @@ export async function loadClerkAdapter({ secretKey, publishableKey, proxyUrl, au
36
39
  const user = await client.users.createUser({ emailAddress: [email], skipPasswordRequirement: true });
37
40
  return user.id;
38
41
  },
42
+ async deleteUser(clerkUserId) {
43
+ await client.users.deleteUser(clerkUserId);
44
+ },
45
+ // Clerk sends the email. Its link opens `redirectUrl` with a sign-up
46
+ // ticket; a pending invitation for the same address is replaced.
47
+ async inviteByEmail({ email, redirectUrl }) {
48
+ await client.invitations.createInvitation({ emailAddress: email, redirectUrl, notify: true, ignoreExisting: true });
49
+ },
39
50
  };
40
51
  }
41
52
 
53
+ // A Clerk account nobody has used: never signed in, no password and no
54
+ // connected account. Flow made it for an invitee (ensureClerkAccount).
55
+ export function unusedClerkAccount(clerkUser) {
56
+ return Boolean(clerkUser)
57
+ && !clerkUser.lastSignInAt
58
+ && clerkUser.passwordEnabled === false
59
+ && !(Array.isArray(clerkUser.externalAccounts) && clerkUser.externalAccounts.length);
60
+ }
61
+
62
+ // Has Clerk email an invitation to `email`. Clerk does not invite an address
63
+ // that already has an account, so an unused account Flow made earlier is
64
+ // removed first; the invitee's account is made when they take up the
65
+ // invitation. An address with a used account gets no email: that person can
66
+ // already sign in. Returns { emailed }.
67
+ export async function sendClerkInvitation(clerk, email, redirectUrl) {
68
+ const existing = await clerk.findUserIdByEmail(email);
69
+ if (existing) {
70
+ if (!unusedClerkAccount(await clerk.getUser(existing))) return { emailed: false };
71
+ await clerk.deleteUser(existing);
72
+ }
73
+ await clerk.inviteByEmail({ email, redirectUrl });
74
+ return { emailed: true };
75
+ }
76
+
42
77
  // An invitee needs a Clerk account before their first sign-in, because with
43
78
  // public sign-up off Flow's sign-in page never creates one. The account has
44
79
  // the invitee's email (verified, since the backend made it) and no password;
@@ -1,9 +1,10 @@
1
1
  import express from 'express';
2
2
  import { FlowError } from '../core/errors.js';
3
3
  import { rateLimit } from '../rate-limit.js';
4
- import { ensureClerkAccount } from './clerk.js';
4
+ import { ensureClerkAccount, sendClerkInvitation } from './clerk.js';
5
5
  import {
6
6
  findPersonByEmail,
7
+ findPersonById,
7
8
  invitePerson,
8
9
  listPeople,
9
10
  normaliseEmail,
@@ -139,23 +140,61 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
139
140
  }
140
141
  });
141
142
 
142
- // Invite someone. Flow also makes sure they have a Clerk account, because
143
- // with public sign-up off the sign-in page never creates one.
143
+ // The invitation email's link opens this installation's sign-in page.
144
+ const signInUrl = (req) => `${req.protocol}://${req.get('host')}/sign-in`;
145
+
146
+ // Clerk emails the invitation. If Clerk cannot, Flow makes the invitee's
147
+ // Clerk account itself (with public sign-up off the sign-in page never
148
+ // creates one) and the owner sends the sign-in message by hand.
149
+ async function inviteWithClerk(req, email) {
150
+ try {
151
+ return await sendClerkInvitation(clerk, email, signInUrl(req));
152
+ } catch (error) {
153
+ log.error?.(`[people] Clerk invitation email failed: ${error.message}`);
154
+ }
155
+ await ensureClerkAccount(clerk, email);
156
+ return { emailed: false };
157
+ }
158
+
159
+ // Invite someone.
144
160
  people.post('/', async (req, res) => {
145
161
  try {
146
162
  const email = normaliseEmail(req.body?.email);
147
163
  const role = req.body?.role;
148
164
  if (!isPlainEmail(email)) throw new FlowError('invalid', 'email must be a plain (ASCII) email address');
149
165
  if (await findPersonByEmail(db, email)) throw new FlowError('conflict', 'that email address is already on the people list');
166
+ let invite;
150
167
  try {
151
- await ensureClerkAccount(clerk, email);
168
+ invite = await inviteWithClerk(req, email);
152
169
  } catch (error) {
153
170
  log.error?.(`[people] Clerk account for an invitation failed: ${error.message}`);
154
171
  return res.status(503).json({ ok: false, code: 'sign_in_unavailable', error: 'Flow could not prepare the sign-in account. Try again shortly.' });
155
172
  }
156
173
  const person = await invitePerson(db, { email, name: req.body?.name ?? null, role, invitedBy: req.person.id });
157
- log.info?.(`[people] ${req.person.id} invited ${person.id} as ${person.role}`);
158
- res.status(201).json({ ok: true, person: publicPerson(person) });
174
+ log.info?.(`[people] ${req.person.id} invited ${person.id} as ${person.role} (${invite.emailed ? 'emailed' : 'not emailed'})`);
175
+ res.status(201).json({ ok: true, person: publicPerson(person), invite });
176
+ } catch (error) {
177
+ sendError(res, error, log);
178
+ }
179
+ });
180
+
181
+ // Email an invited person's invitation again. Only for someone who has
182
+ // not signed in yet.
183
+ people.post('/:id/invite', async (req, res) => {
184
+ try {
185
+ const person = await findPersonById(db, req.params.id);
186
+ if (!person) throw new FlowError('not_found', 'person not found');
187
+ if (person.clerkUserId || person.status !== 'active') throw new FlowError('conflict', 'only an invited person who has not signed in yet gets an invitation email');
188
+ let invite;
189
+ try {
190
+ invite = await sendClerkInvitation(clerk, person.email, signInUrl(req));
191
+ } catch (error) {
192
+ log.error?.(`[people] Clerk invitation email failed: ${error.message}`);
193
+ return res.status(503).json({ ok: false, code: 'sign_in_unavailable', error: 'Clerk could not send the invitation email. Try again shortly.' });
194
+ }
195
+ if (!invite.emailed) throw new FlowError('conflict', 'they already have a sign-in account: they can sign in now');
196
+ log.info?.(`[people] ${req.person.id} emailed the invitation for ${person.id} again`);
197
+ res.json({ ok: true, invite });
159
198
  } catch (error) {
160
199
  sendError(res, error, log);
161
200
  }
@@ -0,0 +1,24 @@
1
+ // Where this copy of Flow runs. A Replit workspace (the editor's Run button)
2
+ // shares its secrets with the published app, so without a rule it would sync,
3
+ // draft and send next to the live copy: twice the calls to Xero and Seazona,
4
+ // and a second sender. FLOW_BACKGROUND_JOBS decides; unset, it is off in a
5
+ // Replit workspace and on everywhere else (docs/SELF_HOSTING.md).
6
+
7
+ const read = (env, name) => String(env?.[name] ?? '').trim();
8
+
9
+ // Replit sets REPL_ID in the workspace and REPLIT_DEPLOYMENT on the published
10
+ // app only.
11
+ export function isReplitWorkspace(env = process.env) {
12
+ return Boolean(read(env, 'REPL_ID')) && !read(env, 'REPLIT_DEPLOYMENT');
13
+ }
14
+
15
+ // Whether this copy runs the jobs that call or write to other systems: the
16
+ // Seazona sync, the Xero read, the pipeline, the daily reconcile and the send
17
+ // worker. Off, it also never writes to Xero.
18
+ export function backgroundJobsOn(env = process.env) {
19
+ const value = read(env, 'FLOW_BACKGROUND_JOBS').toLowerCase();
20
+ if (value === 'on') return true;
21
+ if (value === 'off') return false;
22
+ if (value) throw new Error('FLOW_BACKGROUND_JOBS must be "on", "off" or unset');
23
+ return !isReplitWorkspace(env);
24
+ }
package/server/index.js CHANGED
@@ -13,6 +13,7 @@ import { createMonitoredLog } from './core/monitoring.js';
13
13
  import { createTracing, traceQueries } from './core/tracing.js';
14
14
  import { createPipelineWorker } from './core/pipeline.js';
15
15
  import { createReconcileWorker } from './core/reconcile.js';
16
+ import { backgroundJobsOn } from './core/host.js';
16
17
  import { createSendWorker } from './core/sending.js';
17
18
  import { resolveSecretsKey } from './core/secrets.js';
18
19
  import { adapters as shippedAdapters } from './integrations/adapters.js';
@@ -96,20 +97,25 @@ try {
96
97
  const integrationsOn = integrations.list().filter((a) => a.enabled).map((a) => `${a.id}${a.state === 'ready' ? '' : ` (${a.state})`}`);
97
98
  console.log(`[integrations] on: ${integrationsOn.join(', ') || 'none'}; secrets key ${secrets ? `from ${secrets.source}` : 'not set (FLOW_SECRETS_KEY)'}`);
98
99
 
100
+ // Background jobs run only where FLOW_BACKGROUND_JOBS allows: never in a
101
+ // Replit workspace, which shares the published app's secrets
102
+ // (server/core/host.js).
103
+ const jobsOn = backgroundJobsOn(process.env);
104
+ if (!jobsOn) console.log('[flow] background jobs off (FLOW_BACKGROUND_JOBS): no sync, pipeline, reconcile or sending in this copy');
99
105
  // The pipeline (server/core/pipeline.js) joins what the integrations copy into
100
106
  // Flow: jobs into the ledger, and each job to its accounting invoice. It runs
101
107
  // only while an integration that feeds it is on, and writes to no other
102
108
  // system. FLOW_PIPELINE=off stops it.
103
- if (process.env.FLOW_PIPELINE !== 'off') {
109
+ if (jobsOn && process.env.FLOW_PIPELINE !== 'off') {
104
110
  createPipelineWorker({ db: database.db, integrations, log }).start();
105
111
  }
106
112
  // The daily reconcile (server/core/reconcile.js) compares Flow's own copies
107
113
  // and opens a review item for a day that does not agree. It never calls or
108
114
  // writes to another system.
109
- createReconcileWorker({ db: database.db, integrations, log }).start();
115
+ if (jobsOn) createReconcileWorker({ db: database.db, integrations, log }).start();
110
116
  // The send worker (server/core/sending.js) sends only what a person queued
111
117
  // with Send all ready, to the accounting integration that is on.
112
- createSendWorker({ db: database.db, integrations, log }).start();
118
+ if (jobsOn) createSendWorker({ db: database.db, integrations, log }).start();
113
119
 
114
120
  let auth = null;
115
121
  if (settings.mode === 'clerk') {
@@ -111,7 +111,7 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
111
111
  const scheduler = settings.scheduled ? createSeazonaScheduler({ db, settings, run, now, log, timers }) : null;
112
112
  runtime = { settings, db, client, run, scheduler };
113
113
  scheduler?.start();
114
- log.info?.(`[seazona] mode ${settings.mode}; background sync ${scheduler ? 'on' : 'off (live mode only)'}; ${settings.rate.requestsPerMinute} requests a minute at most`);
114
+ log.info?.(`[seazona] mode ${settings.mode}; background sync ${scheduler ? 'on' : settings.mode === 'live' ? 'off (FLOW_BACKGROUND_JOBS)' : 'off (live mode only)'}; ${settings.rate.requestsPerMinute} requests a minute at most`);
115
115
  },
116
116
 
117
117
  async stop() {
@@ -8,6 +8,8 @@
8
8
  // replay recorded, synthetic responses from a file; no network at all.
9
9
  // There is no default mode. Without one the adapter does nothing.
10
10
 
11
+ import { backgroundJobsOn } from '../../core/host.js';
12
+
11
13
  export const MODES = Object.freeze(['live', 'sandbox', 'replay']);
12
14
 
13
15
  // Seazona publishes 60 requests a minute per API client. Flow stays at half of
@@ -190,8 +192,9 @@ export function resolveSeazonaSettings(env = process.env) {
190
192
  credentials: null,
191
193
  replayFile: '',
192
194
  rate,
193
- // The scheduler starts only in live mode (issue #10).
194
- scheduled: mode === 'live',
195
+ // The scheduler starts only in live mode (issue #10), and only where
196
+ // background jobs run: never in a Replit workspace (server/core/host.js).
197
+ scheduled: mode === 'live' && backgroundJobsOn(env),
195
198
  // The address of one order in Seazona's own screens, with {ref} for the
196
199
  // order number, for "Open in Seazona" on a stage move. Optional; one
197
200
  // without {ref} is ignored.
@@ -1,4 +1,5 @@
1
1
  import * as SentryNode from '@sentry/node';
2
+ import { isReplitWorkspace } from '../../core/host.js';
2
3
  import { scrubSpan } from '../../ui/monitoring.js';
3
4
 
4
5
  // The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs, the
@@ -40,7 +41,7 @@ export const SENTRY_SETUP = {
40
41
  },
41
42
  fields: [
42
43
  { name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
43
- { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production.' },
44
+ { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
44
45
  { name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
45
46
  { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
46
47
  ],
@@ -50,7 +51,9 @@ export function readSentrySettings(env) {
50
51
  const dsn = String(env.SENTRY_DSN ?? '').trim();
51
52
  if (!dsn) throw new Error('SENTRY_DSN is not set. Enter the Sentry project\'s DSN in Settings, Integrations.');
52
53
  if (!DSN_PATTERN.test(dsn)) throw new Error('SENTRY_DSN is not a Sentry DSN (https://<key>@<host>/<project id>).');
53
- const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || 'production';
54
+ // A Replit workspace shares the published app's secrets; its errors are
55
+ // labelled apart so they never read as the live app's.
56
+ const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || (isReplitWorkspace(env) ? 'workspace' : 'production');
54
57
  const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
55
58
  const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
56
59
  const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
@@ -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
  }