@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.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +11 -2
- package/server/auth/clerk.js +35 -0
- package/server/auth/routes.js +45 -6
- package/server/core/host.js +24 -0
- package/server/index.js +9 -3
- package/server/integrations/seazona/index.js +1 -1
- package/server/integrations/seazona/settings.js +5 -2
- package/server/integrations/sentry/index.js +5 -2
- package/server/integrations/xero/index.js +3 -3
- package/server/integrations/xero/settings.js +6 -0
- package/server/ui/app.js +88 -23
- package/server/ui/bulk-bar.js +99 -0
- package/server/ui/flow.css +84 -11
- package/server/ui/invoicing.js +234 -30
- package/server/ui/job-board.js +58 -34
- package/server/ui/jobs.js +27 -13
- package/server/ui/routes.js +1 -1
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
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.
|
|
9
|
+
"version": "2026.10.37",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -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:
|
|
386
|
+
linksTo: linkedSystems(systems, system),
|
|
378
387
|
});
|
|
379
388
|
}));
|
|
380
389
|
|
package/server/auth/clerk.js
CHANGED
|
@@ -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;
|
package/server/auth/routes.js
CHANGED
|
@@ -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
|
-
//
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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', {
|
|
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', {
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
921
|
-
confirm: 'Send
|
|
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(
|
|
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
|
}
|