@arcforgelabs/flow 2026.10.29 → 2026.10.32-beta.1
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 +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +110 -3
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/automations.js +32 -1
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoicing-rules.js +12 -0
- package/server/core/job-board.js +25 -3
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +84 -6
- package/server/core/price-list.js +348 -0
- package/server/core/queue.js +22 -0
- package/server/core/sending.js +16 -4
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +44 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +23 -2
- package/server/integrations/seazona/client.js +6 -0
- package/server/integrations/seazona/feed.js +23 -1
- package/server/integrations/seazona/governor.js +74 -21
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +51 -2
- package/server/integrations/seazona/lanes.js +251 -62
- package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/scheduler.js +37 -1
- package/server/integrations/seazona/schema.js +116 -0
- package/server/integrations/seazona/settings.js +24 -3
- package/server/integrations/seazona/store.js +74 -0
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +5 -0
- package/server/integrations/xero/client.js +27 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +125 -16
- package/server/integrations/xero/index.js +36 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +11 -2
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +35 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +48 -14
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +31 -2
- package/server/ui/invoicing-settings.js +1 -1
- package/server/ui/invoicing.js +121 -8
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/monitoring.js +14 -0
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { openReview, resolveReviewFor } from '../../core/review.js';
|
|
3
|
+
|
|
4
|
+
// Each invoice Flow sent, against Flow's copy of Xero (issue #132). People
|
|
5
|
+
// change invoices in both places after Flow sends them: a Xero draft is edited
|
|
6
|
+
// before approval, an invoice is voided in Xero, or voided in Flow. The issued
|
|
7
|
+
// version Flow sent is the merge base: the document both sides agreed on.
|
|
8
|
+
// This module compares the Xero copy with it and with the Flow invoice now.
|
|
9
|
+
// It never calls Xero and never writes to Xero or to the Flow invoice: a
|
|
10
|
+
// difference opens one review item (alert only), and a person fixes it where
|
|
11
|
+
// they choose. The item closes when a later check finds no difference.
|
|
12
|
+
|
|
13
|
+
export const OUT_OF_SYNC = 'invoice.accounting_differs';
|
|
14
|
+
export const ACTOR = 'system:xero-reconcile';
|
|
15
|
+
|
|
16
|
+
// Xero's status in the words the Flow screens show.
|
|
17
|
+
const LABELS = {
|
|
18
|
+
not_sent: 'Not in Xero',
|
|
19
|
+
sending: 'Sending to Xero',
|
|
20
|
+
not_copied: 'Sent, not read back yet',
|
|
21
|
+
draft: 'Draft in Xero',
|
|
22
|
+
approved: 'Approved in Xero',
|
|
23
|
+
part_paid: 'Part paid in Xero',
|
|
24
|
+
paid: 'Paid in Xero',
|
|
25
|
+
voided: 'Voided in Xero',
|
|
26
|
+
deleted: 'Deleted in Xero',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const GONE = new Set(['VOIDED', 'DELETED']);
|
|
30
|
+
|
|
31
|
+
function xeroState(row) {
|
|
32
|
+
switch (row.status) {
|
|
33
|
+
case 'DRAFT':
|
|
34
|
+
case 'SUBMITTED':
|
|
35
|
+
return 'draft';
|
|
36
|
+
case 'AUTHORISED':
|
|
37
|
+
return Number(row.amount_paid_cents ?? 0) > 0 ? 'part_paid' : 'approved';
|
|
38
|
+
case 'PAID':
|
|
39
|
+
return 'paid';
|
|
40
|
+
case 'VOIDED':
|
|
41
|
+
return 'voided';
|
|
42
|
+
case 'DELETED':
|
|
43
|
+
return 'deleted';
|
|
44
|
+
default:
|
|
45
|
+
return 'draft';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const num = (value) => (value === null || value === undefined ? null : Number(value));
|
|
50
|
+
const day = (value) => (value instanceof Date ? value.toISOString().slice(0, 10) : value ? String(value).slice(0, 10) : null);
|
|
51
|
+
const codes = (lines) => [...(lines ?? []).map((l) => l.itemCode ?? '')].sort().join(',');
|
|
52
|
+
|
|
53
|
+
// What differs between the Xero copy and the version Flow sent. Free text
|
|
54
|
+
// (reference, descriptions) is not compared: the copy keeps it only when the
|
|
55
|
+
// lab turns that on (XERO_KEEP_REFERENCE).
|
|
56
|
+
export function sentDifferences(sent, document, copy) {
|
|
57
|
+
const found = [];
|
|
58
|
+
const add = (field, flow, xero) => found.push({ field, flow, xero });
|
|
59
|
+
if (sent?.Contact?.ContactID && copy.xero_contact_id !== sent.Contact.ContactID) add('contact', sent.Contact.ContactID, copy.xero_contact_id);
|
|
60
|
+
if (num(copy.subtotal_cents) !== document.subtotalCents) add('subtotal', document.subtotalCents, num(copy.subtotal_cents));
|
|
61
|
+
if (num(copy.tax_cents) !== document.taxCents) add('tax', document.taxCents, num(copy.tax_cents));
|
|
62
|
+
if (num(copy.total_cents) !== document.totalCents) add('total', document.totalCents, num(copy.total_cents));
|
|
63
|
+
const lines = copy.lines ?? [];
|
|
64
|
+
if (lines.length !== document.lines.length) add('line count', document.lines.length, lines.length);
|
|
65
|
+
else if (codes(lines) !== codes(document.lines)) add('items', codes(document.lines), codes(lines));
|
|
66
|
+
if (sent?.Date && day(copy.issued_on) !== sent.Date) add('date', sent.Date, day(copy.issued_on));
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The finding for one invoice, or null when Flow and Xero agree.
|
|
71
|
+
// differs Xero's copy no longer matches the version Flow sent
|
|
72
|
+
// voided_in_xero Xero voided or deleted it; the Flow invoice is still issued
|
|
73
|
+
// voided_in_flow Flow voided it; Xero's invoice is still live
|
|
74
|
+
function findingFor(status) {
|
|
75
|
+
if (!status.xero) return null;
|
|
76
|
+
const live = !GONE.has(status.xero.status);
|
|
77
|
+
if (status.flowStatus === 'void' && live) return 'voided_in_flow';
|
|
78
|
+
if (status.flowStatus === 'issued' && status.current && !live) return 'voided_in_xero';
|
|
79
|
+
if (live && status.differences.length) return 'differs';
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const MESSAGES = {
|
|
84
|
+
differs: 'Xero no longer matches the invoice Flow sent.',
|
|
85
|
+
voided_in_xero: 'Xero voided or deleted this invoice. It is still issued in Flow.',
|
|
86
|
+
voided_in_flow: 'This invoice is void in Flow. Xero still holds it as a live invoice.',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Each invoice's Xero status, from Flow's copy (no Xero calls). Answers a map
|
|
90
|
+
// from Flow invoice id to { state, label, xero, differences, finding, message }.
|
|
91
|
+
export async function accountingStatus(db, runtime, organisationId, invoiceIds = null) {
|
|
92
|
+
const only = invoiceIds ? sql`and i.id = any(${sql.param([...new Set(invoiceIds.map(String))])}::uuid[])` : sql``;
|
|
93
|
+
// Without ids: only invoices Flow sent, for the check after each read.
|
|
94
|
+
const sentOnly = invoiceIds ? sql`` : sql`and p.id is not null`;
|
|
95
|
+
const { rows } = await db.execute(sql`
|
|
96
|
+
select i.id, i.status, i.issued_version, i.total_cents,
|
|
97
|
+
p.version as sent_version, p.state as push_state, p.request_body, p.xero_invoice_id, p.xero_invoice_number,
|
|
98
|
+
v.document,
|
|
99
|
+
x.status as x_status, x.invoice_number as x_number, x.xero_contact_id, x.subtotal_cents, x.tax_cents,
|
|
100
|
+
x.total_cents as x_total_cents, x.amount_paid_cents, x.amount_due_cents, x.lines, x.issued_on
|
|
101
|
+
from invoices i
|
|
102
|
+
left join lateral (
|
|
103
|
+
select * from xero_invoice_pushes
|
|
104
|
+
where organisation_id = i.organisation_id and invoice_id = i.id
|
|
105
|
+
order by version desc limit 1
|
|
106
|
+
) p on true
|
|
107
|
+
left join invoice_versions v on v.invoice_id = i.id and v.version = p.version
|
|
108
|
+
left join xero_invoices x on x.organisation_id = i.organisation_id and x.xero_invoice_id = p.xero_invoice_id
|
|
109
|
+
where i.organisation_id = ${organisationId} ${only} ${sentOnly}`);
|
|
110
|
+
const web = runtime.settings.endpoints?.web ?? 'https://go.xero.com';
|
|
111
|
+
const out = new Map();
|
|
112
|
+
for (const r of rows) {
|
|
113
|
+
let state;
|
|
114
|
+
if (!r.push_state) state = 'not_sent';
|
|
115
|
+
else if (['dispatching', 'ambiguous'].includes(r.push_state)) state = 'sending';
|
|
116
|
+
else if (!r.x_status) state = 'not_copied';
|
|
117
|
+
else state = xeroState({ status: r.x_status, amount_paid_cents: r.amount_paid_cents });
|
|
118
|
+
let sent = null;
|
|
119
|
+
try {
|
|
120
|
+
sent = r.request_body ? JSON.parse(r.request_body) : null;
|
|
121
|
+
} catch {
|
|
122
|
+
sent = null;
|
|
123
|
+
}
|
|
124
|
+
const xero = r.x_status ? {
|
|
125
|
+
invoiceId: r.xero_invoice_id,
|
|
126
|
+
number: r.x_number ?? r.xero_invoice_number ?? null,
|
|
127
|
+
status: r.x_status,
|
|
128
|
+
totalCents: num(r.x_total_cents),
|
|
129
|
+
amountDueCents: num(r.amount_due_cents),
|
|
130
|
+
url: `${web}/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(r.xero_invoice_id)}`,
|
|
131
|
+
} : null;
|
|
132
|
+
const status = {
|
|
133
|
+
invoiceId: r.id,
|
|
134
|
+
flowStatus: r.status,
|
|
135
|
+
sentVersion: r.sent_version ?? null,
|
|
136
|
+
current: r.sent_version !== null && r.sent_version === r.issued_version,
|
|
137
|
+
state,
|
|
138
|
+
label: LABELS[state],
|
|
139
|
+
xero,
|
|
140
|
+
differences: xero && r.document && !GONE.has(r.x_status) ? sentDifferences(sent, r.document, { ...r, total_cents: r.x_total_cents }) : [],
|
|
141
|
+
flowTotalCents: num(r.total_cents),
|
|
142
|
+
};
|
|
143
|
+
status.finding = findingFor(status);
|
|
144
|
+
status.message = status.finding ? MESSAGES[status.finding] : null;
|
|
145
|
+
out.set(r.id, status);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Opens or refreshes one review item for each finding, and closes the item of
|
|
151
|
+
// an invoice whose finding went away. Answers counts.
|
|
152
|
+
export async function checkSentInvoices(db, runtime, organisationId, { invoiceIds = null } = {}) {
|
|
153
|
+
const statuses = await accountingStatus(db, runtime, organisationId, invoiceIds);
|
|
154
|
+
const result = { checked: 0, open: 0, closed: 0 };
|
|
155
|
+
for (const status of statuses.values()) {
|
|
156
|
+
if (status.state === 'not_sent') continue;
|
|
157
|
+
result.checked += 1;
|
|
158
|
+
if (status.finding) {
|
|
159
|
+
await openReview(db, organisationId, {
|
|
160
|
+
kind: OUT_OF_SYNC,
|
|
161
|
+
entity: 'invoice',
|
|
162
|
+
flowId: status.invoiceId,
|
|
163
|
+
systems: ['flow', 'xero'],
|
|
164
|
+
evidence: { finding: status.finding, message: status.message, flowStatus: status.flowStatus, sentVersion: status.sentVersion, xero: status.xero, differences: status.differences },
|
|
165
|
+
moneyAtRiskCents: Math.abs(status.xero?.totalCents ?? status.flowTotalCents ?? 0),
|
|
166
|
+
});
|
|
167
|
+
result.open += 1;
|
|
168
|
+
} else if (await resolveReviewFor(db, organisationId, { kind: OUT_OF_SYNC, entity: 'invoice', flowId: status.invoiceId, resolution: 'no_difference', actor: ACTOR })) {
|
|
169
|
+
result.closed += 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
@@ -8,6 +8,7 @@ import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from '
|
|
|
8
8
|
import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
|
|
9
9
|
import { queueHistoryImport, resetImport } from './import.js';
|
|
10
10
|
import { MIRROR_RESOURCES, mirrorStatus, mirrorXero } from './mirror.js';
|
|
11
|
+
import { accountingStatus, checkSentInvoices } from './reconcile.js';
|
|
11
12
|
import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
12
13
|
import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
13
14
|
import { writesAllowed } from './settings.js';
|
|
@@ -27,6 +28,10 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
|
27
28
|
// would make, and what stops them (no Xero calls)
|
|
28
29
|
// POST /invoices/push owner or admin: issued invoices to Xero drafts
|
|
29
30
|
// GET /invoices/:invoiceId/pushes?organisationId= any signed-in person
|
|
31
|
+
// POST /invoices/status any signed-in person: each invoice's Xero
|
|
32
|
+
// status and any difference, from the copy
|
|
33
|
+
// POST /invoices/:invoiceId/recheck owner or admin: reads Xero's changes,
|
|
34
|
+
// then checks that one invoice again (Mark resolved)
|
|
30
35
|
// POST /obligations/:obligationId/check owner or admin: Xero's invoice for
|
|
31
36
|
// a billable job, recorded on the ledger
|
|
32
37
|
// POST /payments/sync owner or admin: Xero payments to the ledger
|
|
@@ -76,6 +81,14 @@ export const handle = (log, fn) => async (req, res) => {
|
|
|
76
81
|
|
|
77
82
|
// `messagePage` draws the callback's answer in the installation's design
|
|
78
83
|
// system (server/ui/page.js); the server's app passes it.
|
|
84
|
+
// What the Xero page says about calls for one connection: stopped until a
|
|
85
|
+
// person connects again (a refused connection or resource), and since when.
|
|
86
|
+
export function callsState(status) {
|
|
87
|
+
const resources = status.stoppedResources.map((r) => r.resource);
|
|
88
|
+
const since = status.haltedAt ?? status.stoppedResources.map((r) => r.at).filter(Boolean).sort()[0] ?? null;
|
|
89
|
+
return { stopped: Boolean(status.haltedReason) || resources.length > 0, all: Boolean(status.haltedReason), resources, since };
|
|
90
|
+
}
|
|
91
|
+
|
|
79
92
|
export function createXeroRouter({ db, personAuth, runtime, log = console, messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
|
|
80
93
|
const page = (title, text, tone) => messagePage({ title, text, tone });
|
|
81
94
|
const { settings } = runtime;
|
|
@@ -101,6 +114,7 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
101
114
|
connected: Boolean(connection && connection.mode === settings.mode),
|
|
102
115
|
connection: describeConnection(connection),
|
|
103
116
|
rateLimit: connection ? runtime.governor.status(connection.tenantId) : null,
|
|
117
|
+
calls: connection ? callsState(await runtime.guardFor(db).status(connection.tenantId)) : null,
|
|
104
118
|
lastSync: await lastSync(db, organisationId),
|
|
105
119
|
});
|
|
106
120
|
}));
|
|
@@ -155,6 +169,8 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
155
169
|
key: settings.tokenKey,
|
|
156
170
|
});
|
|
157
171
|
runtime.remember(entry.organisationId, tokens);
|
|
172
|
+
// A new connection is a person acting: calls stopped before resume.
|
|
173
|
+
await runtime.guardFor(db).resume();
|
|
158
174
|
// Another Xero organisation starts its history from the beginning.
|
|
159
175
|
if (previous && previous.tenantId !== connection.tenantId) await resetImport(db, entry.organisationId);
|
|
160
176
|
await queueHistoryImport(db, entry.organisationId, { reason: 'connected' });
|
|
@@ -205,6 +221,25 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, messa
|
|
|
205
221
|
res.json({ ok: true, mode: settings.mode, writesAllowed: writesAllowed(settings), ...preview });
|
|
206
222
|
}));
|
|
207
223
|
|
|
224
|
+
// Xero's status of each invoice, from Flow's copy: no Xero calls.
|
|
225
|
+
router.post('/invoices/status', ...signedIn, handle(log, async (req, res) => {
|
|
226
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
227
|
+
const statuses = await accountingStatus(db, runtime, organisationId, requireInvoiceIds(req.body?.invoiceIds));
|
|
228
|
+
res.json({ ok: true, invoices: [...statuses.values()] });
|
|
229
|
+
}));
|
|
230
|
+
|
|
231
|
+
// Mark resolved: read Xero's changes (a few calls, through the call guard),
|
|
232
|
+
// then check this one invoice again. The item closes only when the
|
|
233
|
+
// difference is gone; otherwise it stays open with the new evidence.
|
|
234
|
+
router.post('/invoices/:invoiceId/recheck', ...manager, handle(log, async (req, res) => {
|
|
235
|
+
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
236
|
+
const [invoiceId] = requireInvoiceIds([req.params.invoiceId], 'invoiceId');
|
|
237
|
+
await mirrorXero(db, runtime, organisationId, { resources: ['invoices'] });
|
|
238
|
+
const result = await checkSentInvoices(db, runtime, organisationId, { invoiceIds: [invoiceId] });
|
|
239
|
+
const status = (await accountingStatus(db, runtime, organisationId, [invoiceId])).get(invoiceId) ?? null;
|
|
240
|
+
res.json({ ok: true, resolved: !status?.finding, closed: result.closed, status });
|
|
241
|
+
}));
|
|
242
|
+
|
|
208
243
|
router.post('/invoices/push', ...manager, handle(log, async (req, res) => {
|
|
209
244
|
const organisationId = await requireOrganisation(db, req.body?.organisationId);
|
|
210
245
|
const invoiceIds = requireInvoiceIds(req.body?.invoiceIds);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createCallGuard } from '../call-guard.js';
|
|
1
2
|
import { createXeroClient, xeroError } from './client.js';
|
|
2
3
|
import { decryptToken, readConnection, storeRotatedToken } from './connections.js';
|
|
3
4
|
import { createGovernor } from './governor.js';
|
|
@@ -6,9 +7,21 @@ import { createReplayFetch, DEFAULT_REPLAY_FIXTURES, loadReplayFixtures } from '
|
|
|
6
7
|
import { resolveXeroSettings, writesAllowed } from './settings.js';
|
|
7
8
|
|
|
8
9
|
// The Xero adapter's state for one Flow process: settings, the rate-limit
|
|
9
|
-
// governor, the
|
|
10
|
-
//
|
|
11
|
-
// stored; the refresh token is stored
|
|
10
|
+
// governor, the call guard (its state is in PostgreSQL), the transport (real
|
|
11
|
+
// fetch, or replay), OAuth states, and the access tokens in memory. Access
|
|
12
|
+
// tokens live 30 minutes and are never stored; the refresh token is stored
|
|
13
|
+
// sealed (connections.js).
|
|
14
|
+
|
|
15
|
+
// The call guard's tables and policy (../call-guard.js, docs/XERO.md). A 401
|
|
16
|
+
// or 403 that a fresh token does not clear stops only the resource it came
|
|
17
|
+
// from: a connection can lack one scope (payments) and still serve the rest.
|
|
18
|
+
// A refused token refresh halts every call.
|
|
19
|
+
export const XERO_CALL_GUARD = Object.freeze({
|
|
20
|
+
integration: 'xero',
|
|
21
|
+
name: 'Xero',
|
|
22
|
+
tables: Object.freeze({ state: 'xero_call_guard', calls: 'xero_calls' }),
|
|
23
|
+
policy: Object.freeze({ authStops: 'resource' }),
|
|
24
|
+
});
|
|
12
25
|
|
|
13
26
|
export function requireXeroSettings(env = process.env) {
|
|
14
27
|
const settings = resolveXeroSettings(env);
|
|
@@ -31,6 +44,14 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
31
44
|
const accessTokens = new Map(); // organisation id -> { token, expiresAt }
|
|
32
45
|
const refreshing = new Map(); // organisation id -> promise
|
|
33
46
|
const queues = new Map(); // organisation id -> tail of its work queue
|
|
47
|
+
const guards = new WeakMap(); // database handle -> call guard
|
|
48
|
+
|
|
49
|
+
// The call guard for one database. Keyed by the Xero tenant id: Xero's
|
|
50
|
+
// limits are per organisation.
|
|
51
|
+
function guardFor(db) {
|
|
52
|
+
if (!guards.has(db)) guards.set(db, createCallGuard({ db, ...XERO_CALL_GUARD, now, log }));
|
|
53
|
+
return guards.get(db);
|
|
54
|
+
}
|
|
34
55
|
|
|
35
56
|
// Runs heavy Xero work for one organisation one at a time, in order: a
|
|
36
57
|
// sync and a batch of drafts draw on the same call budget, and running them
|
|
@@ -67,11 +88,29 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
67
88
|
|
|
68
89
|
// Refresh under a row lock, and store the rotated refresh token in the same
|
|
69
90
|
// transaction, so two refreshes never race and the newest token is kept.
|
|
91
|
+
// A refresh Xero refuses for good halts every call until an owner connects
|
|
92
|
+
// again or saves the settings; another failed refresh counts as a failed
|
|
93
|
+
// call.
|
|
70
94
|
async function refresh(db, organisationId) {
|
|
95
|
+
const guard = guardFor(db);
|
|
71
96
|
return db.transaction(async (tx) => {
|
|
72
97
|
const connection = usable(await readConnection(tx, organisationId, { lock: true }));
|
|
98
|
+
await guard.check(connection.tenantId, { resource: 'token' });
|
|
73
99
|
const refreshToken = decryptToken(connection.refreshTokenEnc, settings.tokenKey, organisationId);
|
|
74
|
-
|
|
100
|
+
let tokens;
|
|
101
|
+
try {
|
|
102
|
+
tokens = await refreshTokens(settings, transport, refreshToken);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// invalid_grant: the connection is gone. invalid_client: the app's
|
|
105
|
+
// client id or secret is wrong. Neither clears without a person.
|
|
106
|
+
if (error?.code === 'xero_reconnect_needed' || ['invalid_client', 'unauthorized_client'].includes(error?.oauthError)) {
|
|
107
|
+
await guard.halt(connection.tenantId, 'auth', { detail: `Xero refused the token refresh${error.oauthError ? ` (${error.oauthError})` : ''}` });
|
|
108
|
+
} else if (error?.code !== 'xero_replay_no_match') {
|
|
109
|
+
await guard.record(connection.tenantId, { resource: 'token', failureKind: error?.code === 'xero_unreachable' ? 'network' : 'token_refresh' });
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
await guard.record(connection.tenantId, { resource: 'token', statusCode: 200 });
|
|
75
114
|
await storeRotatedToken(tx, organisationId, { refreshToken: tokens.refresh_token, scope: tokens.scope, key: settings.tokenKey });
|
|
76
115
|
remember(organisationId, tokens);
|
|
77
116
|
return tokens.access_token;
|
|
@@ -96,6 +135,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
96
135
|
invalidateAccessToken: () => accessTokens.delete(organisationId),
|
|
97
136
|
fetch: transport,
|
|
98
137
|
governor,
|
|
138
|
+
guard: guardFor(db),
|
|
99
139
|
apiBase: settings.endpoints.api,
|
|
100
140
|
log,
|
|
101
141
|
});
|
|
@@ -122,6 +162,7 @@ export function createXeroRuntime({ settings, fetch, now = Date.now, log = conso
|
|
|
122
162
|
settings,
|
|
123
163
|
transport,
|
|
124
164
|
governor,
|
|
165
|
+
guardFor,
|
|
125
166
|
states,
|
|
126
167
|
remember,
|
|
127
168
|
forget: (organisationId) => accessTokens.delete(organisationId),
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// GitHub Releases (with npm's dist-tags as the fallback). Used by the Replit
|
|
3
3
|
// deploy (scripts/deploy-release.js, FLOW_DEPLOY_REF=stable|beta).
|
|
4
4
|
//
|
|
5
|
-
// Release names follow docs/RELEASING.md: vYYYY.M.
|
|
6
|
-
// vYYYY.M.
|
|
5
|
+
// Release names follow docs/RELEASING.md: vYYYY.M.N is stable, and
|
|
6
|
+
// vYYYY.M.N-beta.B is a beta of the upcoming YYYY.M.N.
|
|
7
7
|
//
|
|
8
8
|
// stable GitHub's latest release (what Flow Release Promote points at, so
|
|
9
9
|
// a rollback is followed), else npm `latest`
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Nothing here sends anything about the installation: each request is an
|
|
14
14
|
// anonymous GET of public data.
|
|
15
15
|
|
|
16
|
-
const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]
|
|
16
|
+
const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]\d*)(?:-beta\.([1-9]\d*))?$/;
|
|
17
17
|
export const CHANNELS = ["stable", "beta"];
|
|
18
18
|
export const DEFAULT_GITHUB_API = "https://api.github.com";
|
|
19
19
|
export const DEFAULT_GITHUB_DOWNLOADS = "https://github.com";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The quiet alert strip (issue 117, docs/ALERTS.md): one slim line under a
|
|
2
|
+
// page's header when something on that page needs this person. It is hidden
|
|
3
|
+
// when nothing does. It shows the worst alert first, with "+N more" when there
|
|
4
|
+
// are others; × hides that alert for this person until something new joins
|
|
5
|
+
// it, and the next one shows. Data comes from /api/alerts
|
|
6
|
+
// (server/api/alerts.js). The caller passes in app.js's shared pieces as `ui`.
|
|
7
|
+
|
|
8
|
+
const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
|
|
9
|
+
const CLOSE = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
|
|
10
|
+
|
|
11
|
+
// `onTab(tab)` opens a tab on the page, for an alert whose action names one.
|
|
12
|
+
// `scope` narrows the page to one part (an integration's own page), and
|
|
13
|
+
// `onLoad({ alerts, danger })` hears each answer (`danger`: a danger alert is
|
|
14
|
+
// open, even one this person hid), so the page can show the same state
|
|
15
|
+
// elsewhere. An action that links to the page already open is left out.
|
|
16
|
+
// Answers { element, refresh }: put `element` on the page, and call
|
|
17
|
+
// `refresh()` after a change that can fix or make an alert.
|
|
18
|
+
export function alertStrip(ui, page, { onTab = () => {}, scope = null, onLoad = () => {} } = {}) {
|
|
19
|
+
const { h, flow, go, toast } = ui;
|
|
20
|
+
const element = h('div', { class: 'fx-alerts', hidden: true, role: 'status', 'aria-live': 'polite' });
|
|
21
|
+
let alerts = [];
|
|
22
|
+
|
|
23
|
+
function draw() {
|
|
24
|
+
const [first] = alerts;
|
|
25
|
+
element.hidden = !first;
|
|
26
|
+
if (!first) {
|
|
27
|
+
element.replaceChildren();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
element.dataset.tone = first.tone;
|
|
31
|
+
const close = h('button', { class: 'fx-alerts__close', type: 'button', 'aria-label': 'Dismiss', title: 'Dismiss', onclick: () => dismiss(first.key) });
|
|
32
|
+
close.innerHTML = CLOSE;
|
|
33
|
+
let action = null;
|
|
34
|
+
if (first.action?.href && first.action.href !== location.pathname) action = h('a', { class: 'fx-alerts__action', href: first.action.href, onclick: go }, first.action.label);
|
|
35
|
+
else if (first.action?.tab) action = h('button', { class: 'fx-alerts__action', type: 'button', onclick: () => onTab(first.action.tab) }, first.action.label);
|
|
36
|
+
element.replaceChildren(...[
|
|
37
|
+
h('p', { class: 'fx-alerts__text' },
|
|
38
|
+
first.title,
|
|
39
|
+
first.moneyAtRiskCents > 0 ? h('span', { class: 'fx-alerts__money' }, ` ${money.format(first.moneyAtRiskCents / 100)} at risk.`) : null),
|
|
40
|
+
action,
|
|
41
|
+
alerts.length > 1 ? h('span', { class: 'fx-alerts__more' }, `+${alerts.length - 1} more`) : null,
|
|
42
|
+
close,
|
|
43
|
+
].filter(Boolean));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function dismiss(key) {
|
|
47
|
+
alerts = alerts.filter((a) => a.key !== key);
|
|
48
|
+
draw();
|
|
49
|
+
try {
|
|
50
|
+
await flow('/api/alerts/dismiss', { method: 'POST', body: JSON.stringify({ key }) });
|
|
51
|
+
} catch (error) {
|
|
52
|
+
toast(error.message, 'danger');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// An alert that cannot load shows nothing: the page works without it.
|
|
57
|
+
async function refresh() {
|
|
58
|
+
let danger = false;
|
|
59
|
+
try {
|
|
60
|
+
const query = new URLSearchParams({ page, ...(scope ? { scope } : {}) });
|
|
61
|
+
({ alerts, danger = false } = await flow(`/api/alerts?${query}`));
|
|
62
|
+
} catch {
|
|
63
|
+
alerts = [];
|
|
64
|
+
}
|
|
65
|
+
onLoad({ alerts, danger });
|
|
66
|
+
draw();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Coming back to the tab checks again, while this page is open.
|
|
70
|
+
const onVisible = () => {
|
|
71
|
+
if (!element.isConnected) document.removeEventListener('visibilitychange', onVisible);
|
|
72
|
+
else if (document.visibilityState === 'visible') refresh();
|
|
73
|
+
};
|
|
74
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
75
|
+
refresh();
|
|
76
|
+
return { element, refresh };
|
|
77
|
+
}
|
package/server/ui/app-page.js
CHANGED
|
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
|
|
|
51
51
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
52
52
|
</div>`;
|
|
53
53
|
|
|
54
|
-
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -12,9 +12,11 @@ import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/ass
|
|
|
12
12
|
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
|
+
import { renderPriceList } from '/assets/ui/price-list.js';
|
|
15
16
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
16
17
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
17
18
|
import { openSearch } from '/assets/ui/search.js';
|
|
19
|
+
import { alertStrip } from '/assets/ui/alerts.js';
|
|
18
20
|
|
|
19
21
|
const $ = (id) => document.getElementById(id);
|
|
20
22
|
|
|
@@ -194,10 +196,13 @@ let me = null;
|
|
|
194
196
|
const VIEWS = {
|
|
195
197
|
jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
|
|
196
198
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
197
|
-
'invoicing-settings': { label: '
|
|
199
|
+
'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
|
|
200
|
+
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main) => renderPriceList(main, ui()) },
|
|
198
201
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
199
202
|
integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
200
|
-
|
|
203
|
+
// Not in the sidebar: "Manage account" in the account menu opens the same
|
|
204
|
+
// Clerk profile. The page stays for links and Search.
|
|
205
|
+
account: { label: 'Account', icon: 'person', group: 'Settings', sidebar: false, render: renderAccount },
|
|
201
206
|
appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
|
|
202
207
|
};
|
|
203
208
|
|
|
@@ -206,6 +211,7 @@ const ICON_PATHS = {
|
|
|
206
211
|
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"/>',
|
|
207
212
|
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
208
213
|
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"/>',
|
|
214
|
+
tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
|
|
209
215
|
plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
|
|
210
216
|
person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
|
|
211
217
|
flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
|
|
@@ -368,6 +374,7 @@ function renderShell() {
|
|
|
368
374
|
let group = null;
|
|
369
375
|
for (const [name, view] of Object.entries(VIEWS)) {
|
|
370
376
|
if (view.ownerOnly && me?.person.role !== 'owner') continue;
|
|
377
|
+
if (view.sidebar === false) continue;
|
|
371
378
|
if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
|
|
372
379
|
group = view.group;
|
|
373
380
|
items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
|
|
@@ -539,6 +546,7 @@ async function renderIntegrations(main) {
|
|
|
539
546
|
const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
|
|
540
547
|
main.append(
|
|
541
548
|
h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, h('h1', { class: 'fx-title' }, 'Integrations'))),
|
|
549
|
+
alertStrip(ui(), 'integrations').element,
|
|
542
550
|
h('div', { class: 'fx-int-bar' }, tabs, search),
|
|
543
551
|
notices, content);
|
|
544
552
|
let body;
|
|
@@ -623,8 +631,24 @@ async function renderIntegration(main, id) {
|
|
|
623
631
|
return;
|
|
624
632
|
}
|
|
625
633
|
const canChange = Boolean(me.ownerActions?.allowed);
|
|
634
|
+
// The quiet alert line for this integration only (server/ui/alerts.js).
|
|
635
|
+
// While a danger alert is open, even one this person hid, the status says
|
|
636
|
+
// calls are stopped instead of the integration's state.
|
|
637
|
+
let stopped = false;
|
|
638
|
+
const statusPill = (current) => (stopped && current.state === 'ready'
|
|
639
|
+
? h('span', { class: 'fx-status', 'data-tone': 'danger', 'data-int-status': '' }, 'Calls stopped')
|
|
640
|
+
: h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute', 'data-int-status': '' }, STATE_LABEL[current.state] ?? current.state));
|
|
641
|
+
let shown = found;
|
|
642
|
+
const alerts = alertStrip(ui(), 'integrations', {
|
|
643
|
+
scope: id,
|
|
644
|
+
onLoad: ({ danger }) => {
|
|
645
|
+
stopped = danger;
|
|
646
|
+
for (const pill of host.querySelectorAll('[data-int-status]')) pill.replaceWith(statusPill(shown));
|
|
647
|
+
},
|
|
648
|
+
});
|
|
626
649
|
|
|
627
650
|
const draw = (current, progress = null) => {
|
|
651
|
+
shown = current;
|
|
628
652
|
const inputs = new Map();
|
|
629
653
|
const rows = [];
|
|
630
654
|
for (const field of current.fields) {
|
|
@@ -732,10 +756,11 @@ async function renderIntegration(main, id) {
|
|
|
732
756
|
h('p', { class: 'fx-int-hero__blurb' }, entry?.blurb ?? ''),
|
|
733
757
|
h('div', { class: 'fx-int-hero__actions' },
|
|
734
758
|
primary,
|
|
735
|
-
on ?
|
|
759
|
+
on ? statusPill(current) : null,
|
|
736
760
|
progress ? connectProgress(progress) : null))),
|
|
737
761
|
h('div', { class: 'fx-int-page__grid' },
|
|
738
762
|
h('div', { class: 'fx-int-page__main' }, ...[
|
|
763
|
+
alerts.element,
|
|
739
764
|
!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,
|
|
740
765
|
!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,
|
|
741
766
|
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
@@ -753,7 +778,7 @@ async function renderIntegration(main, id) {
|
|
|
753
778
|
].filter(Boolean)),
|
|
754
779
|
h('aside', { class: 'fx-int-page__meta' },
|
|
755
780
|
h('dl', { class: 'fx-meta' }, ...[
|
|
756
|
-
['Status',
|
|
781
|
+
['Status', statusPill(current)],
|
|
757
782
|
['Category', h('span', { class: 'fx-chip fx-chip--static' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')],
|
|
758
783
|
modeLabel ? ['Mode', modeLabel] : null,
|
|
759
784
|
['Turned on by', current.enabledBy === 'host' ? 'The host' : current.enabledBy === 'flow' ? 'An owner, here' : '—'],
|
|
@@ -768,11 +793,11 @@ async function renderIntegration(main, id) {
|
|
|
768
793
|
// One setting, laid out the same on every settings screen: a short name, one
|
|
769
794
|
// line of smaller text under it, and the control on the right. Longer help
|
|
770
795
|
// lives behind a link, not here. A wide control (a grid of choices) sits on
|
|
771
|
-
// its own line under the name.
|
|
772
|
-
function settingRow({ label, hint, control, wide = false }) {
|
|
796
|
+
// its own line under the name. `tone: 'danger'` marks a row that needs a person.
|
|
797
|
+
function settingRow({ label, hint, control, wide = false, tone = null }) {
|
|
773
798
|
const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
|
|
774
799
|
const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
|
|
775
|
-
return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}
|
|
800
|
+
return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
|
|
776
801
|
h('div', { class: 'fx-setting__text' },
|
|
777
802
|
id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
|
|
778
803
|
hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
|
|
@@ -800,16 +825,25 @@ function xeroConnection(organisations, canChange) {
|
|
|
800
825
|
(async () => {
|
|
801
826
|
let hint;
|
|
802
827
|
let control = null;
|
|
828
|
+
let stopped = false;
|
|
803
829
|
try {
|
|
804
830
|
const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
|
|
805
|
-
if (status.connected) {
|
|
831
|
+
if (status.connected && status.calls?.stopped) {
|
|
832
|
+
// A refused connection stopped calls (the call guard): say so, and
|
|
833
|
+
// make Connect again the main button, which starts them again.
|
|
834
|
+
const at = status.calls.since ? ` at ${new Date(status.calls.since).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })}` : '';
|
|
835
|
+
hint = status.calls.all
|
|
836
|
+
? `Xero refused Flow${at}. Flow stopped all calls.`
|
|
837
|
+
: `Xero refused Flow for ${status.calls.resources.join(', ')}${at}. Flow stopped those calls.`;
|
|
838
|
+
stopped = true;
|
|
839
|
+
} else if (status.connected) {
|
|
806
840
|
const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
|
|
807
841
|
hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.` : ''}`;
|
|
808
842
|
} else {
|
|
809
843
|
hint = 'Not connected.';
|
|
810
844
|
}
|
|
811
845
|
if (canChange) {
|
|
812
|
-
control = h('button', { class: `fx-btn fx-btn--sm${status.connected ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
846
|
+
control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
813
847
|
try {
|
|
814
848
|
const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
|
|
815
849
|
location.assign(authorizeUrl);
|
|
@@ -821,7 +855,7 @@ function xeroConnection(organisations, canChange) {
|
|
|
821
855
|
} catch (error) {
|
|
822
856
|
hint = error.message;
|
|
823
857
|
}
|
|
824
|
-
row.replaceWith(settingRow({ label: organisation.name, hint, control }));
|
|
858
|
+
row.replaceWith(settingRow({ label: organisation.name, hint, control, tone: stopped ? 'danger' : null }));
|
|
825
859
|
})();
|
|
826
860
|
}
|
|
827
861
|
return h('div', {}, h('h2', { class: 'fx-int-section__title' }, 'Xero organisation'), host);
|
|
@@ -854,9 +888,9 @@ async function invitePerson() {
|
|
|
854
888
|
// security), with the person's Flow role above it.
|
|
855
889
|
function renderAccount(main) {
|
|
856
890
|
const host = h('div', { class: 'fx-clerk-profile' });
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
891
|
+
// Clerk's card has its own "Account" heading; Flow's page heading is for
|
|
892
|
+
// screen readers only, so the title shows once.
|
|
893
|
+
main.append(h('h1', { class: 'fx-sr' }, 'Account'), host);
|
|
860
894
|
// Clerk keeps its own pages (profile, security) in memory, so the URL
|
|
861
895
|
// stays Flow's.
|
|
862
896
|
mountClerk('profile', host, { routing: 'virtual' });
|
|
@@ -1011,7 +1045,7 @@ drawModeToggles();
|
|
|
1011
1045
|
// the Clerk instance is called.
|
|
1012
1046
|
const LOCALIZATION = {
|
|
1013
1047
|
signIn: {
|
|
1014
|
-
start: { title: 'Sign in to Flow', subtitle: '
|
|
1048
|
+
start: { title: 'Sign in to Flow', subtitle: 'Choose how to sign in.' },
|
|
1015
1049
|
password: { subtitle: 'Enter the password for your account.' },
|
|
1016
1050
|
},
|
|
1017
1051
|
signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },
|