@arcforgelabs/flow 2026.10.25 → 2026.10.26
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/README.md +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +375 -0
- package/server/app.js +11 -0
- package/server/auth/people.js +26 -0
- package/server/auth/routes.js +13 -0
- package/server/core/invoices.js +7 -0
- package/server/core/ledger.js +6 -3
- package/server/core/links.js +3 -3
- package/server/core/pipeline.js +488 -0
- package/server/db/migrations/0008_people_appearance.sql +1 -0
- package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
- package/server/db/migrations/meta/0008_snapshot.json +2479 -0
- package/server/db/migrations/meta/0009_snapshot.json +2809 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +71 -2
- package/server/index.js +9 -0
- package/server/integrations/port.js +3 -0
- package/server/integrations/seazona/feed.js +11 -0
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/xero/import.js +22 -0
- package/server/integrations/xero/index.js +10 -2
- package/server/integrations/xero/lookup.js +94 -0
- package/server/theme/color-contrast.js +39 -2
- package/server/theme/contrast-audit.js +63 -0
- package/server/theme/themes.js +10 -2
- package/server/ui/app-page.js +5 -10
- package/server/ui/app.js +351 -105
- package/server/ui/appearance.js +3 -2
- package/server/ui/clerk-appearance.js +18 -2
- package/server/ui/flow.css +246 -22
- package/server/ui/invoicing.js +451 -0
- package/server/ui/jobs.js +153 -0
- package/server/ui/logos/seazona.svg +1 -0
- package/server/ui/logos/xero.svg +1 -0
- package/server/ui/routes.js +5 -1
package/server/ui/app.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/assets/ui/appearance.js';
|
|
10
10
|
import { flowClerkAppearance } from '/assets/ui/clerk-appearance.js';
|
|
11
11
|
import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/assets/theme/themes.js';
|
|
12
|
+
import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
13
|
+
import { renderJobs } from '/assets/ui/jobs.js';
|
|
12
14
|
|
|
13
15
|
const $ = (id) => document.getElementById(id);
|
|
14
16
|
|
|
@@ -47,6 +49,8 @@ function clerkAppearance(kind) {
|
|
|
47
49
|
function mountClerk(kind, element, options = {}) {
|
|
48
50
|
const Clerk = window.Clerk;
|
|
49
51
|
const props = { ...options, appearance: clerkAppearance(kind) };
|
|
52
|
+
// The "Manage account" modal does not inherit the button's appearance.
|
|
53
|
+
if (kind === 'userButton') props.userProfileProps = { ...options.userProfileProps, appearance: clerkAppearance('modal') };
|
|
50
54
|
if (kind === 'signIn') Clerk.mountSignIn(element, props);
|
|
51
55
|
else if (kind === 'userButton') Clerk.mountUserButton(element, props);
|
|
52
56
|
else if (kind === 'profile') Clerk.mountUserProfile(element, props);
|
|
@@ -70,8 +74,10 @@ function unmountAllIn(container) {
|
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
// Clerk reads its appearance when a component mounts, so a new appearance
|
|
73
|
-
// remounts each one (after the tokens have changed on <html>).
|
|
77
|
+
// remounts each one (after the tokens have changed on <html>). Modals Clerk
|
|
78
|
+
// opens itself use the appearance from Clerk.load(), so update that too.
|
|
74
79
|
onAppearanceChange(() => {
|
|
80
|
+
window.Clerk?.__internal_updateProps?.({ appearance: clerkAppearance('modal') });
|
|
75
81
|
for (const [element, { kind, options }] of [...mounted]) {
|
|
76
82
|
unmountClerk(element);
|
|
77
83
|
mountClerk(kind, element, options);
|
|
@@ -109,7 +115,7 @@ async function flow(path, options = {}) {
|
|
|
109
115
|
headers: { Authorization: `Bearer ${token}`, ...(options.body ? { 'Content-Type': 'application/json' } : {}) },
|
|
110
116
|
});
|
|
111
117
|
const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
|
112
|
-
if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code });
|
|
118
|
+
if (!res.ok) throw Object.assign(new Error(body.error || `HTTP ${res.status}`), { status: res.status, code: body.code, details: body.details });
|
|
113
119
|
return body;
|
|
114
120
|
}
|
|
115
121
|
|
|
@@ -180,42 +186,75 @@ function roleSelect(value, attrs = {}) {
|
|
|
180
186
|
|
|
181
187
|
let me = null;
|
|
182
188
|
|
|
189
|
+
// The sidebar lists the views in this order, under their group's heading.
|
|
183
190
|
const VIEWS = {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
191
|
+
jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
|
|
192
|
+
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
193
|
+
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
194
|
+
integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
195
|
+
account: { label: 'Account', icon: 'person', group: 'Settings', render: renderAccount },
|
|
196
|
+
appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
|
|
188
197
|
};
|
|
189
198
|
|
|
199
|
+
// Line icons for the sidebar, drawn on a 24-unit grid in the text colour.
|
|
200
|
+
const ICON_PATHS = {
|
|
201
|
+
jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
|
|
202
|
+
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
203
|
+
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
|
|
204
|
+
plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
|
|
205
|
+
person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
|
|
206
|
+
palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function icon(name) {
|
|
210
|
+
const node = h('span', { class: 'fx-ico', 'aria-hidden': 'true' });
|
|
211
|
+
node.innerHTML = `<svg viewBox="0 0 24 24">${ICON_PATHS[name] ?? ''}</svg>`;
|
|
212
|
+
return node;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The view and, for one record, its id: /invoices/<id> is ['invoices', '<id>'].
|
|
190
216
|
function currentView() {
|
|
191
|
-
const name = location.pathname.replace(/^\/+|\/+$/g, '');
|
|
192
|
-
if (VIEWS[name] && (!VIEWS[name].ownerOnly || me?.person.role === 'owner')) return name;
|
|
193
|
-
return
|
|
217
|
+
const [name, id = null] = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
|
|
218
|
+
if (VIEWS[name] && (!VIEWS[name].ownerOnly || me?.person.role === 'owner')) return [name, id];
|
|
219
|
+
return ['jobs', null];
|
|
194
220
|
}
|
|
195
221
|
|
|
196
222
|
async function route() {
|
|
197
223
|
const main = $('main');
|
|
198
224
|
unmountAllIn(main);
|
|
199
|
-
const view = currentView();
|
|
200
|
-
|
|
225
|
+
const [view, id] = currentView();
|
|
226
|
+
const path = id ? `/${view}/${id}` : `/${view}`;
|
|
227
|
+
if (location.pathname !== path) history.replaceState(null, '', path);
|
|
201
228
|
for (const link of document.querySelectorAll('[data-nav]')) {
|
|
202
229
|
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
203
230
|
}
|
|
204
231
|
main.replaceChildren();
|
|
232
|
+
main.className = 'fx-main';
|
|
205
233
|
document.title = `${VIEWS[view].label} · Flow`;
|
|
206
|
-
await VIEWS[view].render(main);
|
|
234
|
+
await VIEWS[view].render(main, id);
|
|
207
235
|
main.focus({ preventScroll: true });
|
|
208
236
|
}
|
|
209
237
|
|
|
210
|
-
//
|
|
238
|
+
// Opens a path in place; the address changes, the page stays.
|
|
239
|
+
function go(path, { replace = false } = {}) {
|
|
240
|
+
if (path !== location.pathname) history[replace ? 'replaceState' : 'pushState'](null, '', path);
|
|
241
|
+
route();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// A link's click opens it in place (a new tab still works).
|
|
211
245
|
function navigate(event) {
|
|
212
246
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
|
|
213
247
|
event.preventDefault();
|
|
214
|
-
|
|
215
|
-
if (href !== location.pathname) history.pushState(null, '', href);
|
|
216
|
-
route();
|
|
248
|
+
go(event.currentTarget.getAttribute('href'));
|
|
217
249
|
}
|
|
218
250
|
|
|
251
|
+
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
252
|
+
// takes a path, or a link's click event.
|
|
253
|
+
const ui = () => ({
|
|
254
|
+
h, flow, toast, notice, pageHeader, dialog, me,
|
|
255
|
+
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
256
|
+
});
|
|
257
|
+
|
|
219
258
|
function renderSignedOut() {
|
|
220
259
|
document.body.dataset.state = 'signed-out';
|
|
221
260
|
document.title = 'Sign in · Flow';
|
|
@@ -236,9 +275,15 @@ function renderSignedOut() {
|
|
|
236
275
|
function renderShell() {
|
|
237
276
|
document.body.dataset.state = 'signed-in';
|
|
238
277
|
const nav = $('nav');
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
278
|
+
const items = [];
|
|
279
|
+
let group = null;
|
|
280
|
+
for (const [name, view] of Object.entries(VIEWS)) {
|
|
281
|
+
if (view.ownerOnly && me?.person.role !== 'owner') continue;
|
|
282
|
+
if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
|
|
283
|
+
group = view.group;
|
|
284
|
+
items.push(h('a', { href: `/${name}`, 'data-nav': name, onclick: navigate }, icon(view.icon), h('span', {}, view.label)));
|
|
285
|
+
}
|
|
286
|
+
nav.replaceChildren(...items);
|
|
242
287
|
mountClerk('userButton', $('user-button'), { afterSignOutUrl: '/sign-in', showName: false });
|
|
243
288
|
window.addEventListener('popstate', route);
|
|
244
289
|
return route();
|
|
@@ -348,39 +393,137 @@ function personRow(person, host) {
|
|
|
348
393
|
h('td', { class: 'fx-table__actions' }, h('div', { class: 'fx-row fx-row--end' }, actions)));
|
|
349
394
|
}
|
|
350
395
|
|
|
351
|
-
// Integrations:
|
|
352
|
-
//
|
|
396
|
+
// Integrations: a classic catalogue. Each system is a card with its logo;
|
|
397
|
+
// the card opens a side panel where an owner turns it on and enters its
|
|
398
|
+
// settings. Credentials are write-only: the server says whether each is set,
|
|
353
399
|
// never its value (server/integrations/setup.js).
|
|
354
|
-
const STATE_LABEL = { ready: '
|
|
355
|
-
const STATE_TONE = { ready: 'success', disabled: '
|
|
400
|
+
const STATE_LABEL = { ready: 'Connected', disabled: 'Not set up', failed: 'Needs attention', restart_needed: 'Restart needed', not_started: 'Starting' };
|
|
401
|
+
const STATE_TONE = { ready: 'success', disabled: 'mute', failed: 'danger', restart_needed: 'warn', not_started: 'info' };
|
|
402
|
+
|
|
403
|
+
// What the catalogue shows for each adapter: its group, one line on what it
|
|
404
|
+
// does, and its logo (server/ui/logos/, each maker's own mark).
|
|
405
|
+
const CATALOGUE = {
|
|
406
|
+
xero: { maker: 'Xero', category: 'accounting', blurb: 'Send invoices to Xero as drafts and bring payments back.', logo: 'xero.svg' },
|
|
407
|
+
seazona: { maker: 'Seazona', category: 'lab', blurb: 'Bring lab cases and orders into Flow as jobs.', logo: 'seazona.svg' },
|
|
408
|
+
};
|
|
409
|
+
const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software']];
|
|
410
|
+
const CATEGORY_LABEL = Object.fromEntries(CATEGORIES);
|
|
411
|
+
|
|
412
|
+
function integrationLogo(integration, size = '') {
|
|
413
|
+
const file = CATALOGUE[integration.id]?.logo;
|
|
414
|
+
return h('span', { class: `fx-logo${size ? ` fx-logo--${size}` : ''}`, 'aria-hidden': 'true' },
|
|
415
|
+
file ? h('img', { src: `/assets/ui/logos/${file}`, alt: '' }) : icon('plug'));
|
|
416
|
+
}
|
|
356
417
|
|
|
357
418
|
async function renderIntegrations(main) {
|
|
358
|
-
const
|
|
419
|
+
const state = { tab: 'all', category: 'all', q: '' };
|
|
420
|
+
const tabs = h('div', { class: 'fx-seg', role: 'tablist' });
|
|
421
|
+
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Search integrations', 'aria-label': 'Search integrations' });
|
|
422
|
+
const category = h('select', { class: 'fx-select', 'aria-label': 'Category' },
|
|
423
|
+
CATEGORIES.map(([id, label]) => h('option', { value: id }, id === 'all' ? 'All categories' : label)));
|
|
424
|
+
const notices = h('div', { class: 'fx-fields' });
|
|
425
|
+
const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
|
|
359
426
|
main.append(
|
|
360
|
-
|
|
361
|
-
|
|
427
|
+
h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, h('h1', { class: 'fx-title' }, 'Integrations'))),
|
|
428
|
+
h('div', { class: 'fx-int-bar' }, tabs, search),
|
|
429
|
+
notices, content);
|
|
362
430
|
let body;
|
|
363
431
|
try {
|
|
364
432
|
body = await flow('/api/integrations');
|
|
365
433
|
} catch (error) {
|
|
366
|
-
|
|
434
|
+
content.replaceChildren(notice(error.message, 'danger'));
|
|
367
435
|
return;
|
|
368
436
|
}
|
|
369
437
|
const canChange = Boolean(me.ownerActions?.allowed);
|
|
370
|
-
|
|
438
|
+
notices.replaceChildren(...[
|
|
371
439
|
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.'),
|
|
372
440
|
body.secretsKey ? null : notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
|
|
373
|
-
...body.integrations.map((integration) => integrationCard(integration, { canChange, organisations: body.organisations })),
|
|
374
441
|
].filter(Boolean));
|
|
375
|
-
|
|
442
|
+
const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
|
|
443
|
+
const connected = (i) => i.enabled;
|
|
444
|
+
|
|
445
|
+
const draw = () => {
|
|
446
|
+
tabs.replaceChildren(...[['all', 'All integrations'], ['yours', 'Your integrations']].map(([id, label]) => h('button', {
|
|
447
|
+
type: 'button', role: 'tab', class: 'fx-seg__item', 'aria-selected': String(state.tab === id), 'aria-checked': String(state.tab === id),
|
|
448
|
+
onclick: () => { state.tab = id; draw(); },
|
|
449
|
+
}, label)));
|
|
450
|
+
const q = state.q.toLowerCase();
|
|
451
|
+
const matches = (i) => !q || `${i.name} ${CATALOGUE[i.id]?.blurb ?? ''}`.toLowerCase().includes(q);
|
|
452
|
+
const sections = [];
|
|
453
|
+
if (state.tab === 'yours') {
|
|
454
|
+
const yours = list.filter(connected).filter(matches);
|
|
455
|
+
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
456
|
+
yours.length ? h('div', { class: 'fx-int-grid' }, yours.map(integrationTile))
|
|
457
|
+
: h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All integrations.')));
|
|
458
|
+
} else {
|
|
459
|
+
// Recommended: what is not connected yet, once the catalogue is long
|
|
460
|
+
// enough that a short row helps (with a few, it would repeat the grid).
|
|
461
|
+
const recommended = q || list.length < 4 ? [] : list.filter((i) => !connected(i)).slice(0, 3);
|
|
462
|
+
if (recommended.length) {
|
|
463
|
+
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
464
|
+
h('h2', { class: 'fx-int-heading' }, 'Recommended for you'),
|
|
465
|
+
h('div', { class: 'fx-int-grid' }, recommended.map(integrationTile))));
|
|
466
|
+
}
|
|
467
|
+
const all = list.filter(matches).filter((i) => state.category === 'all' || CATALOGUE[i.id]?.category === state.category);
|
|
468
|
+
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
469
|
+
h('div', { class: 'fx-int-heading-row' }, h('h2', { class: 'fx-int-heading' }, 'All integrations'), category),
|
|
470
|
+
all.length ? h('div', { class: 'fx-int-grid' }, all.map(integrationTile)) : h('p', { class: 'fx-empty' }, 'No integration matches.')));
|
|
471
|
+
}
|
|
472
|
+
content.replaceChildren(...sections);
|
|
473
|
+
};
|
|
474
|
+
search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
|
|
475
|
+
category.addEventListener('change', () => { state.category = category.value; draw(); });
|
|
476
|
+
draw();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function integrationTile(integration) {
|
|
480
|
+
const href = `/integrations/${integration.id}`;
|
|
481
|
+
const entry = CATALOGUE[integration.id];
|
|
482
|
+
const on = integration.enabled;
|
|
483
|
+
const attention = ['failed', 'restart_needed'].includes(integration.state);
|
|
484
|
+
return h('a', { class: 'fx-int-card', href, onclick: navigate },
|
|
485
|
+
h('header', { class: 'fx-int-card__head' },
|
|
486
|
+
integrationLogo(integration),
|
|
487
|
+
h('h3', {}, integration.name),
|
|
488
|
+
on ? h('span', { class: 'fx-status', 'data-tone': STATE_TONE[integration.state] ?? 'mute' }, STATE_LABEL[integration.state] ?? integration.state) : null),
|
|
489
|
+
h('p', { class: 'fx-int-card__blurb' }, entry?.blurb ?? ''),
|
|
490
|
+
h('span', { class: 'fx-int-card__btn' }, attention ? 'Fix' : on ? 'Manage' : '+ Connect'));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// One integration's page (/integrations/<id>): its logo and maker, Connect
|
|
494
|
+
// with each step shown as it happens, the setup steps, its settings, and the
|
|
495
|
+
// facts beside them.
|
|
496
|
+
async function renderIntegration(main, id) {
|
|
497
|
+
const host = h('div', { class: 'fx-int-page' }, h('div', { class: 'fx-skeleton' }));
|
|
498
|
+
main.append(host);
|
|
499
|
+
let body;
|
|
500
|
+
try {
|
|
501
|
+
body = await flow('/api/integrations');
|
|
502
|
+
} catch (error) {
|
|
503
|
+
host.replaceChildren(notice(error.message, 'danger'));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const found = body.integrations.find((i) => i.id === id);
|
|
507
|
+
if (!found) {
|
|
508
|
+
host.replaceChildren(notice('No integration by that name.', 'danger'));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const canChange = Boolean(me.ownerActions?.allowed);
|
|
376
512
|
|
|
377
|
-
|
|
378
|
-
const card = h('section', { class: 'fx-panel', 'aria-label': integration.name });
|
|
379
|
-
const draw = (current) => {
|
|
513
|
+
const draw = (current, progress = null) => {
|
|
380
514
|
const inputs = new Map();
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
515
|
+
const rows = [];
|
|
516
|
+
for (const field of current.fields) {
|
|
517
|
+
if (field.source === 'host') {
|
|
518
|
+
// The host sets it: a value to read, not a box to type in.
|
|
519
|
+
rows.push(settingRow({
|
|
520
|
+
label: field.label,
|
|
521
|
+
hint: 'Set by whoever hosts Flow.',
|
|
522
|
+
control: field.secret ? h('span', { class: 'fx-dim' }, 'Set') : h('code', { class: 'fx-setting__value' }, field.value),
|
|
523
|
+
}));
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
const disabled = !canChange;
|
|
384
527
|
let input;
|
|
385
528
|
if (field.kind === 'choice' || field.kind === 'onoff') {
|
|
386
529
|
const choices = field.kind === 'onoff' ? [{ value: 'off', label: 'Off' }, { value: 'on', label: 'On' }] : field.choices;
|
|
@@ -388,40 +531,64 @@ function integrationCard(integration, { canChange, organisations }) {
|
|
|
388
531
|
field.kind === 'choice' ? h('option', { value: '' }, 'Choose…') : null,
|
|
389
532
|
choices.map((c) => h('option', { value: c.value, selected: (field.value ?? (field.kind === 'onoff' ? 'off' : '')) === c.value }, c.label)));
|
|
390
533
|
} else if (field.secret) {
|
|
391
|
-
input = h('input', { class: 'fx-input', type: 'password', autocomplete: 'off', disabled, placeholder: field.set ? '
|
|
534
|
+
input = h('input', { class: 'fx-input', type: 'password', autocomplete: 'off', disabled, placeholder: field.set ? 'Saved' : 'Paste it here' });
|
|
392
535
|
} else {
|
|
393
536
|
input = h('input', { class: 'fx-input', type: field.kind === 'url' ? 'url' : 'text', autocomplete: 'off', disabled, value: field.value ?? '' });
|
|
394
537
|
}
|
|
395
538
|
inputs.set(field.name, { field, input });
|
|
396
539
|
const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { input.value = suggestion; } }, 'Use this')) : null);
|
|
407
|
-
});
|
|
540
|
+
rows.push(settingRow({
|
|
541
|
+
label: field.label,
|
|
542
|
+
hint: field.unreadable ? 'Stored with another key. Enter it again.' : field.help,
|
|
543
|
+
control: suggestion && !disabled
|
|
544
|
+
? h('div', { class: 'fx-setting__stack' }, input,
|
|
545
|
+
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'))
|
|
546
|
+
: input,
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
408
549
|
|
|
409
550
|
const values = () => {
|
|
410
551
|
const out = {};
|
|
411
552
|
for (const [name, { field, input }] of inputs) {
|
|
412
|
-
if (field.source === 'host') continue;
|
|
413
553
|
const value = input.value.trim();
|
|
414
554
|
if (field.secret) { if (value) out[name] = value; continue; }
|
|
415
555
|
if (value !== (field.value ?? (field.kind === 'onoff' ? 'off' : ''))) out[name] = value || null;
|
|
416
556
|
}
|
|
417
557
|
return out;
|
|
418
558
|
};
|
|
559
|
+
const put = (enabled) => flow(`/api/integrations/${current.id}`, { method: 'PUT', body: JSON.stringify({ values: values(), ...(enabled === undefined ? {} : { enabled }) }) });
|
|
560
|
+
|
|
561
|
+
// Connect: each step is shown as it runs, then ticked or crossed.
|
|
562
|
+
const connect = async () => {
|
|
563
|
+
const steps = [
|
|
564
|
+
{ label: 'Saving settings', state: 'running' },
|
|
565
|
+
{ label: `Starting ${current.name}`, state: 'waiting' },
|
|
566
|
+
];
|
|
567
|
+
const started = Date.now();
|
|
568
|
+
const show = () => draw(current, { steps, started });
|
|
569
|
+
show();
|
|
570
|
+
let next;
|
|
571
|
+
try {
|
|
572
|
+
next = (await put(true)).integration;
|
|
573
|
+
} catch (error) {
|
|
574
|
+
steps[0] = { label: error.message, state: 'failed' };
|
|
575
|
+
show();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
steps[0].state = 'done';
|
|
579
|
+
steps[1].state = next.state === 'ready' ? 'done' : 'failed';
|
|
580
|
+
if (next.state !== 'ready') steps[1].label = next.error ? `${current.name} could not start: ${next.error}` : `${current.name}: ${STATE_LABEL[next.state] ?? next.state}`;
|
|
581
|
+
if (next.state === 'ready') steps.push({ label: `${current.name} is connected`, state: 'done' });
|
|
582
|
+
current = next;
|
|
583
|
+
show();
|
|
584
|
+
setTimeout(() => { if (steps.every((step) => step.state === 'done')) draw(current); }, 2400);
|
|
585
|
+
};
|
|
419
586
|
const save = async (enabled) => {
|
|
420
587
|
try {
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
draw(
|
|
588
|
+
const next = (await put(enabled)).integration;
|
|
589
|
+
toast(enabled === false ? `${next.name} is disconnected.` : `${next.name} settings saved.`, 'success');
|
|
590
|
+
current = next;
|
|
591
|
+
draw(current);
|
|
425
592
|
} catch (error) {
|
|
426
593
|
toast(error.message, 'danger');
|
|
427
594
|
}
|
|
@@ -429,64 +596,119 @@ function integrationCard(integration, { canChange, organisations }) {
|
|
|
429
596
|
|
|
430
597
|
const on = current.enabled;
|
|
431
598
|
const guide = current.guide;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
599
|
+
const hostOwned = current.enabledBy === 'host';
|
|
600
|
+
const entry = CATALOGUE[current.id];
|
|
601
|
+
const mode = current.fields.find((f) => /_MODE$/.test(f.name));
|
|
602
|
+
const modeLabel = mode?.choices?.find((c) => c.value === mode.value)?.label ?? mode?.value;
|
|
603
|
+
const busy = progress && progress.steps.some((step) => step.state === 'running');
|
|
604
|
+
|
|
605
|
+
const primary = !canChange || hostOwned ? null : on
|
|
606
|
+
? h('button', { class: 'fx-btn', type: 'button', onclick: () => save(false) }, 'Disconnect')
|
|
607
|
+
: h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: busy, onclick: connect },
|
|
608
|
+
busy ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : null, busy ? 'Connecting' : 'Connect');
|
|
609
|
+
|
|
610
|
+
host.replaceChildren(
|
|
611
|
+
h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
612
|
+
h('a', { href: '/integrations', onclick: navigate }, 'Integrations'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, current.name)),
|
|
613
|
+
h('header', { class: 'fx-int-hero' },
|
|
614
|
+
integrationLogo(current, 'xl'),
|
|
615
|
+
h('div', { class: 'fx-int-hero__text' },
|
|
616
|
+
h('h1', { class: 'fx-title' }, current.name),
|
|
617
|
+
h('p', { class: 'fx-int-hero__maker' }, entry?.maker ?? current.name, h('span', { class: 'fx-dim' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')),
|
|
618
|
+
h('p', { class: 'fx-int-hero__blurb' }, entry?.blurb ?? ''),
|
|
619
|
+
h('div', { class: 'fx-int-hero__actions' },
|
|
620
|
+
primary,
|
|
621
|
+
on ? h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute' }, STATE_LABEL[current.state] ?? current.state) : null,
|
|
622
|
+
progress ? connectProgress(progress) : null))),
|
|
623
|
+
h('div', { class: 'fx-int-page__grid' },
|
|
624
|
+
h('div', { class: 'fx-int-page__main' }, ...[
|
|
625
|
+
!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,
|
|
626
|
+
!body.secretsKey ? notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
|
|
627
|
+
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
628
|
+
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
629
|
+
guide ? h('section', { class: 'fx-int-section' },
|
|
630
|
+
h('h2', { class: 'fx-int-section__title' }, 'Set up'),
|
|
631
|
+
h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
|
|
632
|
+
guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `Open ${current.name} developer site ↗`) : null) : null,
|
|
633
|
+
rows.length ? h('section', { class: 'fx-int-section' },
|
|
634
|
+
h('h2', { class: 'fx-int-section__title' }, 'Settings'),
|
|
635
|
+
h('div', { class: 'fx-settings' }, rows),
|
|
636
|
+
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,
|
|
637
|
+
].filter(Boolean)),
|
|
638
|
+
h('aside', { class: 'fx-int-page__meta' },
|
|
639
|
+
h('dl', { class: 'fx-meta' }, ...[
|
|
640
|
+
['Status', h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute' }, STATE_LABEL[current.state] ?? current.state)],
|
|
641
|
+
['Category', h('span', { class: 'fx-chip fx-chip--static' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')],
|
|
642
|
+
modeLabel ? ['Mode', modeLabel] : null,
|
|
643
|
+
['Turned on by', current.enabledBy === 'host' ? 'The host' : current.enabledBy === 'flow' ? 'An owner, here' : '—'],
|
|
644
|
+
current.updatedAt ? ['Last changed', new Date(current.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })] : null,
|
|
645
|
+
guide?.link ? ['Documentation', h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, 'Developer site ↗')] : null,
|
|
646
|
+
].filter(Boolean).map(([label, value]) => h('div', {}, h('dt', {}, label), h('dd', {}, value)))))));
|
|
450
647
|
};
|
|
451
|
-
|
|
452
|
-
|
|
648
|
+
document.title = `${found.name} · Flow`;
|
|
649
|
+
draw(found);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// One setting, laid out the same on every settings screen: a short name, one
|
|
653
|
+
// line of smaller text under it, and the control on the right. Longer help
|
|
654
|
+
// lives behind a link, not here. A wide control (a grid of choices) sits on
|
|
655
|
+
// its own line under the name.
|
|
656
|
+
function settingRow({ label, hint, control, wide = false }) {
|
|
657
|
+
const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
|
|
658
|
+
const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
|
|
659
|
+
return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}` },
|
|
660
|
+
h('div', { class: 'fx-setting__text' },
|
|
661
|
+
id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
|
|
662
|
+
hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
|
|
663
|
+
h('div', { class: 'fx-setting__control' }, control));
|
|
664
|
+
}
|
|
665
|
+
settingRow.next = 0;
|
|
666
|
+
|
|
667
|
+
// The steps of a Connect, each ticked, running or crossed, with the time taken.
|
|
668
|
+
function connectProgress({ steps, started }) {
|
|
669
|
+
const seconds = Math.max(0, Math.round((Date.now() - started) / 1000));
|
|
670
|
+
return h('div', { class: 'fx-progress', role: 'status' },
|
|
671
|
+
h('div', { class: 'fx-progress__head' }, h('span', { class: 'fx-strong' }, 'Connecting'), h('span', { class: 'fx-dim' }, `${seconds}s`)),
|
|
672
|
+
h('ol', {}, steps.map((step) => h('li', { 'data-state': step.state },
|
|
673
|
+
h('span', { class: 'fx-progress__mark', 'aria-hidden': 'true' }, step.state === 'done' ? '✓' : step.state === 'failed' ? '!' : ''),
|
|
674
|
+
step.label))));
|
|
453
675
|
}
|
|
454
676
|
|
|
455
677
|
// Once Xero is on: each organisation's connection, the history copy, and
|
|
456
678
|
// Connect, which goes to Xero and comes back.
|
|
457
679
|
function xeroConnection(organisations, canChange) {
|
|
458
|
-
const host = h('div', { class: 'fx-
|
|
680
|
+
const host = h('div', { class: 'fx-settings' });
|
|
459
681
|
for (const organisation of organisations) {
|
|
460
|
-
const row =
|
|
682
|
+
const row = settingRow({ label: organisation.name, hint: 'Checking…' });
|
|
461
683
|
host.append(row);
|
|
462
684
|
(async () => {
|
|
685
|
+
let hint;
|
|
686
|
+
let control = null;
|
|
463
687
|
try {
|
|
464
688
|
const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
|
|
465
|
-
const parts = [h('span', {}, organisation.name)];
|
|
466
689
|
if (status.connected) {
|
|
467
|
-
parts.push(h('span', { class: 'fx-tag', 'data-tone': 'success' }, `Connected to ${status.connection?.tenantName ?? 'Xero'}`));
|
|
468
690
|
const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
|
|
469
|
-
|
|
691
|
+
hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.` : ''}`;
|
|
470
692
|
} else {
|
|
471
|
-
|
|
693
|
+
hint = 'Not connected.';
|
|
472
694
|
}
|
|
473
695
|
if (canChange) {
|
|
474
|
-
|
|
696
|
+
control = h('button', { class: `fx-btn fx-btn--sm${status.connected ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
475
697
|
try {
|
|
476
698
|
const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
|
|
477
699
|
location.assign(authorizeUrl);
|
|
478
700
|
} catch (error) {
|
|
479
701
|
toast(error.message, 'danger');
|
|
480
702
|
}
|
|
481
|
-
} }, status.connected ? 'Connect again' : 'Connect to Xero')
|
|
703
|
+
} }, status.connected ? 'Connect again' : 'Connect to Xero');
|
|
482
704
|
}
|
|
483
|
-
row.replaceChildren(...parts);
|
|
484
705
|
} catch (error) {
|
|
485
|
-
|
|
706
|
+
hint = error.message;
|
|
486
707
|
}
|
|
708
|
+
row.replaceWith(settingRow({ label: organisation.name, hint, control }));
|
|
487
709
|
})();
|
|
488
710
|
}
|
|
489
|
-
return h('div', {}, h('
|
|
711
|
+
return h('div', {}, h('h2', { class: 'fx-int-section__title' }, 'Xero organisation'), host);
|
|
490
712
|
}
|
|
491
713
|
|
|
492
714
|
async function invitePerson() {
|
|
@@ -524,11 +746,13 @@ function renderAccount(main) {
|
|
|
524
746
|
mountClerk('profile', host, { routing: 'virtual' });
|
|
525
747
|
}
|
|
526
748
|
|
|
527
|
-
// Appearance: light or dark, the design system and an accent colour
|
|
528
|
-
//
|
|
749
|
+
// Appearance: light or dark, the design system and an accent colour. Each
|
|
750
|
+
// person's choice is saved with their account (PUT /api/me/appearance) and
|
|
751
|
+
// applied when they sign in; the browser keeps a copy so the next page paints
|
|
752
|
+
// in it at once.
|
|
529
753
|
function renderAppearance(main) {
|
|
530
754
|
main.append(
|
|
531
|
-
pageHeader('Appearance', { subtitle: '
|
|
755
|
+
pageHeader('Appearance', { subtitle: 'Saved with your account, on every device.' }),
|
|
532
756
|
h('div', { class: 'fx-appearance', id: 'appearance' }));
|
|
533
757
|
drawAppearance();
|
|
534
758
|
}
|
|
@@ -564,13 +788,13 @@ function drawAppearance() {
|
|
|
564
788
|
|
|
565
789
|
const modes = [{ value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }, { value: 'system', label: 'Auto' }];
|
|
566
790
|
const modeGroup = radioGroup('Mode', modes, modes.findIndex((m) => m.value === colorMode),
|
|
567
|
-
(m) =>
|
|
791
|
+
(m) => chooseAppearance({ colorMode: m.value }),
|
|
568
792
|
(m) => h('button', { type: 'button', class: 'fx-seg__item' }, m.label));
|
|
569
793
|
modeGroup.classList.add('fx-seg');
|
|
570
794
|
|
|
571
795
|
const familyIndex = THEME_FAMILIES.findIndex((f) => f.id === family);
|
|
572
796
|
const families = radioGroup('Design system', THEME_FAMILIES, familyIndex,
|
|
573
|
-
(f) =>
|
|
797
|
+
(f) => chooseAppearance({ family: f.id }),
|
|
574
798
|
(f) => {
|
|
575
799
|
// The card shows the family as it is, not the active theme.
|
|
576
800
|
const t = tokensFor(f.id, mode);
|
|
@@ -591,30 +815,51 @@ function drawAppearance() {
|
|
|
591
815
|
const presetIndex = accent ? ACCENT_PRESETS.findIndex((p) => p.id === accent) : 0;
|
|
592
816
|
const customHex = accent && presetIndex < 0 ? normalizeAccent(accent) : null;
|
|
593
817
|
const accents = radioGroup('Accent colour', ACCENT_PRESETS, presetIndex,
|
|
594
|
-
(p) =>
|
|
818
|
+
(p) => chooseAppearance({ accent: p.hex ? p.id : null }),
|
|
595
819
|
(p) => p.hex
|
|
596
820
|
? h('button', { type: 'button', class: 'fx-swatch', title: p.label, 'aria-label': p.label, style: { background: p.hex } })
|
|
597
821
|
: h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', title: `${active.label}'s own`, 'aria-label': `${active.label}'s own accent` },
|
|
598
822
|
h('span', { 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } })));
|
|
599
823
|
accents.classList.add('fx-swatches');
|
|
600
824
|
const custom = h('label', { class: 'fx-swatch-custom' },
|
|
601
|
-
h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) =>
|
|
825
|
+
h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) => chooseAppearance({ accent: event.target.value }) }),
|
|
602
826
|
'Custom');
|
|
603
827
|
const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label : `${active.label}'s own`;
|
|
604
828
|
|
|
605
|
-
host.replaceChildren(
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
829
|
+
host.replaceChildren(h('div', { class: 'fx-settings' },
|
|
830
|
+
settingRow({ label: 'Mode', hint: 'Auto follows this device.', control: modeGroup }),
|
|
831
|
+
settingRow({ label: 'Accent colour', hint: `Using ${accentLabel}.`, control: h('div', { class: 'fx-row' }, accents, custom) }),
|
|
832
|
+
settingRow({ label: 'Design system', hint: 'Colours, type and corners.', control: families, wide: true })));
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// A person's own change: show it, then save it to their account once they
|
|
836
|
+
// are signed in. Signed out (the sign-in screen's switch) it stays in this
|
|
837
|
+
// browser.
|
|
838
|
+
let savingAppearance = null;
|
|
839
|
+
function chooseAppearance(update) {
|
|
840
|
+
setAppearance(update);
|
|
841
|
+
saveAccountAppearance();
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function accountAppearance() {
|
|
845
|
+
const { colorMode, family, accent } = getAppearance();
|
|
846
|
+
return { colorMode, themeFamily: family, ...(accent ? { accent } : {}) };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function saveAccountAppearance() {
|
|
850
|
+
if (!me) return;
|
|
851
|
+
clearTimeout(savingAppearance);
|
|
852
|
+
savingAppearance = setTimeout(() => {
|
|
853
|
+
flow('/api/me/appearance', { method: 'PUT', body: JSON.stringify(accountAppearance()) })
|
|
854
|
+
.catch(() => toast('Flow could not save your appearance to your account. It stays in this browser for now.', 'danger'));
|
|
855
|
+
}, 300);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// On sign-in: the account's appearance wins. An account with none yet takes
|
|
859
|
+
// this browser's current choice.
|
|
860
|
+
function adoptAccountAppearance(saved) {
|
|
861
|
+
if (!saved) return saveAccountAppearance();
|
|
862
|
+
setAppearance({ colorMode: saved.colorMode, family: saved.themeFamily, accent: saved.accent ?? null });
|
|
618
863
|
}
|
|
619
864
|
|
|
620
865
|
onAppearanceChange(() => {
|
|
@@ -639,7 +884,7 @@ function drawModeToggles() {
|
|
|
639
884
|
}
|
|
640
885
|
|
|
641
886
|
for (const button of document.querySelectorAll('[data-mode-toggle]')) {
|
|
642
|
-
button.addEventListener('click', () =>
|
|
887
|
+
button.addEventListener('click', () => chooseAppearance({ colorMode: getAppearance().mode === 'dark' ? 'light' : 'dark' }));
|
|
643
888
|
}
|
|
644
889
|
onAppearanceChange(drawModeToggles);
|
|
645
890
|
drawModeToggles();
|
|
@@ -687,6 +932,7 @@ async function start() {
|
|
|
687
932
|
}
|
|
688
933
|
try {
|
|
689
934
|
me = await flow('/api/me');
|
|
935
|
+
adoptAccountAppearance(me.appearance);
|
|
690
936
|
} catch (error) {
|
|
691
937
|
renderNoAccess(error);
|
|
692
938
|
return;
|