@arcforgelabs/flow 2026.10.19 → 2026.10.20-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 +3 -1
- package/npm-shrinkwrap.json +102 -2
- package/package.json +11 -1
- package/server/app.js +14 -2
- package/server/auth/routes.js +7 -6
- package/server/index.js +10 -2
- package/server/integrations/port.js +2 -2
- package/server/integrations/xero/index.js +1 -1
- package/server/integrations/xero/routes.js +10 -13
- package/server/theme/color-contrast.js +54 -0
- package/server/theme/font-families.js +28 -0
- package/server/theme/openclaw-palettes.js +988 -0
- package/server/theme/themes.js +194 -0
- package/server/ui/app-page.js +66 -0
- package/server/ui/app.js +555 -0
- package/server/ui/appearance.js +129 -0
- package/server/ui/clerk-appearance.js +76 -0
- package/server/ui/flow-mark.svg +1 -0
- package/server/ui/flow.css +314 -0
- package/server/ui/page.js +90 -0
- package/server/ui/routes.js +48 -0
- package/server/auth/sign-in.html +0 -195
package/README.md
CHANGED
|
@@ -16,7 +16,8 @@ from an earlier private codebase one module at a time (`CONTRIBUTING.md`,
|
|
|
16
16
|
"Porting from the earlier codebase"). Today it holds the repository's rules,
|
|
17
17
|
CI, the release track (npm, container image and GitHub Release, on a beta and
|
|
18
18
|
a stable channel), the integration port, a health check, Clerk sign-in with
|
|
19
|
-
roles kept in Flow,
|
|
19
|
+
roles kept in Flow, a People page for owners, the design systems Arc Forge
|
|
20
|
+
Labs' Field uses, and the first part of the core: the PostgreSQL data model
|
|
20
21
|
for organisations, contacts, the price list and invoices. It is not ready to
|
|
21
22
|
run a lab.
|
|
22
23
|
|
|
@@ -29,6 +30,7 @@ run a lab.
|
|
|
29
30
|
| `CONTRIBUTING.md` | How to file issues and pull requests, porting rules, merging |
|
|
30
31
|
| `SECURITY.md` | How to report a security problem, the trust model, and the rules held hard |
|
|
31
32
|
| `LICENSE` | MIT licence |
|
|
33
|
+
| `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
|
|
32
34
|
| `docs/DATA_MODEL.md` | The PostgreSQL records, the rules the database holds, and how to change the schema |
|
|
33
35
|
| `docs/RELEASING.md` | Versions and channels, the release workflows, how to cut and roll back a release, and the one-time setup |
|
|
34
36
|
| `docs/SELF_HOSTING.md` | Running a Flow release with Docker, Node or Replit, the update notice, and Clerk sign-in |
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.20-beta.1",
|
|
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.20-beta.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.23.2",
|
|
13
|
+
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
|
14
|
+
"@fontsource-variable/dm-sans": "5.3.0",
|
|
15
|
+
"@fontsource-variable/geist": "5.3.0",
|
|
16
|
+
"@fontsource-variable/ibm-plex-sans": "5.3.0",
|
|
17
|
+
"@fontsource-variable/instrument-sans": "5.3.0",
|
|
18
|
+
"@fontsource-variable/jetbrains-mono": "5.3.0",
|
|
19
|
+
"@fontsource-variable/lora": "5.3.0",
|
|
20
|
+
"@fontsource-variable/nunito-sans": "5.3.0",
|
|
21
|
+
"@fontsource-variable/playfair-display": "5.3.0",
|
|
22
|
+
"@fontsource-variable/space-grotesk": "5.3.0",
|
|
13
23
|
"drizzle-orm": "0.45.3",
|
|
14
24
|
"express": "5.3.0",
|
|
15
25
|
"express-rate-limit": "8.7.0",
|
|
@@ -951,6 +961,96 @@
|
|
|
951
961
|
"node": ">=18"
|
|
952
962
|
}
|
|
953
963
|
},
|
|
964
|
+
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
|
965
|
+
"version": "5.3.0",
|
|
966
|
+
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.3.0.tgz",
|
|
967
|
+
"integrity": "sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==",
|
|
968
|
+
"license": "OFL-1.1",
|
|
969
|
+
"funding": {
|
|
970
|
+
"url": "https://github.com/sponsors/ayuhito"
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
"node_modules/@fontsource-variable/dm-sans": {
|
|
974
|
+
"version": "5.3.0",
|
|
975
|
+
"resolved": "https://registry.npmjs.org/@fontsource-variable/dm-sans/-/dm-sans-5.3.0.tgz",
|
|
976
|
+
"integrity": "sha512-BpUG5bqePiDFMBM4ZtLSlPdAIOM990uB1dlXzIf4aw21yQR7BmykedLXXXMqGWsR18aMLOsbWccwJNh3ztTQaA==",
|
|
977
|
+
"license": "OFL-1.1",
|
|
978
|
+
"funding": {
|
|
979
|
+
"url": "https://github.com/sponsors/ayuhito"
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
"node_modules/@fontsource-variable/geist": {
|
|
983
|
+
"version": "5.3.0",
|
|
984
|
+
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
|
|
985
|
+
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
|
|
986
|
+
"license": "OFL-1.1",
|
|
987
|
+
"funding": {
|
|
988
|
+
"url": "https://github.com/sponsors/ayuhito"
|
|
989
|
+
}
|
|
990
|
+
},
|
|
991
|
+
"node_modules/@fontsource-variable/ibm-plex-sans": {
|
|
992
|
+
"version": "5.3.0",
|
|
993
|
+
"resolved": "https://registry.npmjs.org/@fontsource-variable/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz",
|
|
994
|
+
"integrity": "sha512-agG8tXFEo0hD9+J7npa4vbbWult52eMLVaQ6WQRlhs/iCAojrMAoejru85W9HTVXHfyUj96KM7gp/KGAS87XaQ==",
|
|
995
|
+
"license": "OFL-1.1",
|
|
996
|
+
"funding": {
|
|
997
|
+
"url": "https://github.com/sponsors/ayuhito"
|
|
998
|
+
}
|
|
999
|
+
},
|
|
1000
|
+
"node_modules/@fontsource-variable/instrument-sans": {
|
|
1001
|
+
"version": "5.3.0",
|
|
1002
|
+
"resolved": "https://registry.npmjs.org/@fontsource-variable/instrument-sans/-/instrument-sans-5.3.0.tgz",
|
|
1003
|
+
"integrity": "sha512-u4gKbDBTNFGkg997tfQn3eHOhHuquWUFTRT/rwzuKtrxX5P2ekfs2x+LgBPP4P32+cC+vUwF1Cr+IdRoPQbrGw==",
|
|
1004
|
+
"license": "OFL-1.1",
|
|
1005
|
+
"funding": {
|
|
1006
|
+
"url": "https://github.com/sponsors/ayuhito"
|
|
1007
|
+
}
|
|
1008
|
+
},
|
|
1009
|
+
"node_modules/@fontsource-variable/jetbrains-mono": {
|
|
1010
|
+
"version": "5.3.0",
|
|
1011
|
+
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
|
|
1012
|
+
"integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==",
|
|
1013
|
+
"license": "OFL-1.1",
|
|
1014
|
+
"funding": {
|
|
1015
|
+
"url": "https://github.com/sponsors/ayuhito"
|
|
1016
|
+
}
|
|
1017
|
+
},
|
|
1018
|
+
"node_modules/@fontsource-variable/lora": {
|
|
1019
|
+
"version": "5.3.0",
|
|
1020
|
+
"resolved": "https://registry.npmjs.org/@fontsource-variable/lora/-/lora-5.3.0.tgz",
|
|
1021
|
+
"integrity": "sha512-2ph8KiZ8Bn141hWuC3ErtpJ+Ui8p2MJQn8TJNRSRmLmnCIOCtHfiBM9R7WLRPeUG/q6hXyHOxWQ8DvRSX+ixvQ==",
|
|
1022
|
+
"license": "OFL-1.1",
|
|
1023
|
+
"funding": {
|
|
1024
|
+
"url": "https://github.com/sponsors/ayuhito"
|
|
1025
|
+
}
|
|
1026
|
+
},
|
|
1027
|
+
"node_modules/@fontsource-variable/nunito-sans": {
|
|
1028
|
+
"version": "5.3.0",
|
|
1029
|
+
"resolved": "https://registry.npmjs.org/@fontsource-variable/nunito-sans/-/nunito-sans-5.3.0.tgz",
|
|
1030
|
+
"integrity": "sha512-Vk1PSfckNDwgw2yGcsvJP3o/0zEW8Rp6KzbD8Ql/6iRHQshnhhAjt+cSIfAUyp5vQuSlleGHFW2LfYaaex0diw==",
|
|
1031
|
+
"license": "OFL-1.1",
|
|
1032
|
+
"funding": {
|
|
1033
|
+
"url": "https://github.com/sponsors/ayuhito"
|
|
1034
|
+
}
|
|
1035
|
+
},
|
|
1036
|
+
"node_modules/@fontsource-variable/playfair-display": {
|
|
1037
|
+
"version": "5.3.0",
|
|
1038
|
+
"resolved": "https://registry.npmjs.org/@fontsource-variable/playfair-display/-/playfair-display-5.3.0.tgz",
|
|
1039
|
+
"integrity": "sha512-IHdzTvE8eGIXHteZql7KXoqdc3Gfm+Zs1u4XlSC3MeplkwviT3sx/P6gI0tLwg/xIYLF4ZJYveZ5V+bl9cnDwA==",
|
|
1040
|
+
"license": "OFL-1.1",
|
|
1041
|
+
"funding": {
|
|
1042
|
+
"url": "https://github.com/sponsors/ayuhito"
|
|
1043
|
+
}
|
|
1044
|
+
},
|
|
1045
|
+
"node_modules/@fontsource-variable/space-grotesk": {
|
|
1046
|
+
"version": "5.3.0",
|
|
1047
|
+
"resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz",
|
|
1048
|
+
"integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==",
|
|
1049
|
+
"license": "OFL-1.1",
|
|
1050
|
+
"funding": {
|
|
1051
|
+
"url": "https://github.com/sponsors/ayuhito"
|
|
1052
|
+
}
|
|
1053
|
+
},
|
|
954
1054
|
"node_modules/@stablelib/base64": {
|
|
955
1055
|
"version": "1.0.1",
|
|
956
1056
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.20-beta.1",
|
|
4
4
|
"description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@clerk/backend": "3.23.2",
|
|
38
|
+
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
|
39
|
+
"@fontsource-variable/dm-sans": "5.3.0",
|
|
40
|
+
"@fontsource-variable/geist": "5.3.0",
|
|
41
|
+
"@fontsource-variable/ibm-plex-sans": "5.3.0",
|
|
42
|
+
"@fontsource-variable/instrument-sans": "5.3.0",
|
|
43
|
+
"@fontsource-variable/jetbrains-mono": "5.3.0",
|
|
44
|
+
"@fontsource-variable/lora": "5.3.0",
|
|
45
|
+
"@fontsource-variable/nunito-sans": "5.3.0",
|
|
46
|
+
"@fontsource-variable/playfair-display": "5.3.0",
|
|
47
|
+
"@fontsource-variable/space-grotesk": "5.3.0",
|
|
38
48
|
"drizzle-orm": "0.45.3",
|
|
39
49
|
"express": "5.3.0",
|
|
40
50
|
"express-rate-limit": "8.7.0",
|
package/server/app.js
CHANGED
|
@@ -4,6 +4,9 @@ import { createSignInRouter } from './auth/routes.js';
|
|
|
4
4
|
import { deployedBuildStatus } from './deployed-build.js';
|
|
5
5
|
import { integrationsHealth } from './integrations/health.js';
|
|
6
6
|
import { rateLimit } from './rate-limit.js';
|
|
7
|
+
import { DEFAULT_FAMILY } from './theme/themes.js';
|
|
8
|
+
import { messagePage } from './ui/page.js';
|
|
9
|
+
import { createUiAssetsRouter } from './ui/routes.js';
|
|
7
10
|
|
|
8
11
|
// The Flow HTTP app. Routes are added per module (invoices, bills, payments,
|
|
9
12
|
// dashboard).
|
|
@@ -19,7 +22,8 @@ import { rateLimit } from './rate-limit.js';
|
|
|
19
22
|
// (server/deployed-build.js); `updates` is a server/update-check.js checker.
|
|
20
23
|
// `integrations` is the started registry (server/integrations/index.js); each
|
|
21
24
|
// adapter's own routes are mounted under /api/<id>, and GET /health reports
|
|
22
|
-
// each one (server/integrations/health.js).
|
|
25
|
+
// each one (server/integrations/health.js). `defaultFamily` is the design
|
|
26
|
+
// system every page starts on (FLOW_THEME, server/theme/themes.js).
|
|
23
27
|
export function createApp({
|
|
24
28
|
build = deployedBuildStatus(),
|
|
25
29
|
database = null,
|
|
@@ -30,6 +34,7 @@ export function createApp({
|
|
|
30
34
|
rateLimitPerMinute = 120,
|
|
31
35
|
signInRateLimitPerMinute = 30,
|
|
32
36
|
clerkProxyRateLimitPerMinute = 300,
|
|
37
|
+
defaultFamily = DEFAULT_FAMILY,
|
|
33
38
|
log = console,
|
|
34
39
|
} = {}) {
|
|
35
40
|
const app = express();
|
|
@@ -59,6 +64,9 @@ export function createApp({
|
|
|
59
64
|
app.use(auth.clerkProxy.path, rateLimit({ limit: clerkProxyRateLimitPerMinute, windowMs: 60_000 }), auth.clerkProxy.handler);
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
// The design systems, Flow's components and the faces every page loads.
|
|
68
|
+
app.use(createUiAssetsRouter());
|
|
69
|
+
|
|
62
70
|
app.use('/api', rateLimit({ limit: rateLimitPerMinute, windowMs: 60_000 }));
|
|
63
71
|
|
|
64
72
|
// Machine access first, with its own authentication (issue #9). A Clerk
|
|
@@ -82,7 +90,10 @@ export function createApp({
|
|
|
82
90
|
}
|
|
83
91
|
}
|
|
84
92
|
|
|
85
|
-
|
|
93
|
+
// An adapter's own pages (such as the end of connecting Xero) use
|
|
94
|
+
// `messagePage` so they look like the rest of Flow.
|
|
95
|
+
const page = (options) => messagePage({ ...options, defaultFamily });
|
|
96
|
+
for (const { path, handler } of integrations?.routes({ db: database?.db ?? null, personAuth: auth?.personAuth ?? null, log, messagePage: page }) ?? []) {
|
|
86
97
|
app.use(path, handler);
|
|
87
98
|
}
|
|
88
99
|
|
|
@@ -91,6 +102,7 @@ export function createApp({
|
|
|
91
102
|
db: database?.db ?? null,
|
|
92
103
|
clerk: auth?.clerk ?? null,
|
|
93
104
|
personAuth: auth?.personAuth ?? null,
|
|
105
|
+
defaultFamily,
|
|
94
106
|
rateLimitPerMinute: signInRateLimitPerMinute,
|
|
95
107
|
log,
|
|
96
108
|
}));
|
package/server/auth/routes.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { fileURLToPath } from 'node:url';
|
|
2
1
|
import express from 'express';
|
|
3
2
|
import { FlowError } from '../core/errors.js';
|
|
4
3
|
import { rateLimit } from '../rate-limit.js';
|
|
@@ -13,8 +12,7 @@ import {
|
|
|
13
12
|
updatePerson,
|
|
14
13
|
} from './people.js';
|
|
15
14
|
import { isPlainEmail } from './settings.js';
|
|
16
|
-
|
|
17
|
-
const signInPage = fileURLToPath(new URL('./sign-in.html', import.meta.url));
|
|
15
|
+
import { APP_PATHS, appPage } from '../ui/app-page.js';
|
|
18
16
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
19
17
|
const ERROR_STATUS = { invalid: 400, forbidden: 403, not_found: 404, conflict: 409 };
|
|
20
18
|
|
|
@@ -49,8 +47,10 @@ function sendError(res, error, log) {
|
|
|
49
47
|
// off, person routes answer 503 and nothing else is mounted. The sign-in page
|
|
50
48
|
// and its config have their own limit per client (`rateLimitPerMinute`);
|
|
51
49
|
// /api/me and /api/people sit under the app's /api limit as well.
|
|
52
|
-
|
|
50
|
+
// `defaultFamily` is the installation's design system (FLOW_THEME).
|
|
51
|
+
export function createSignInRouter({ settings, db, clerk, personAuth, defaultFamily, rateLimitPerMinute = 30, log = console }) {
|
|
53
52
|
const router = express.Router();
|
|
53
|
+
const page = appPage({ defaultFamily });
|
|
54
54
|
const signInOn = settings.mode === 'clerk';
|
|
55
55
|
const signInLimit = rateLimit({ limit: rateLimitPerMinute, windowMs: 60_000 });
|
|
56
56
|
|
|
@@ -72,10 +72,11 @@ export function createSignInRouter({ settings, db, clerk, personAuth, rateLimitP
|
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
router.get('/', signInLimit, (_req, res) => res.redirect(302, '/sign-in'));
|
|
75
|
-
|
|
75
|
+
// Signed out, every one of these shows sign-in.
|
|
76
|
+
router.get(APP_PATHS, signInLimit, (_req, res) => {
|
|
76
77
|
res.set('Cache-Control', 'no-store');
|
|
77
78
|
res.set('X-Frame-Options', 'DENY');
|
|
78
|
-
res.
|
|
79
|
+
res.type('html').send(page);
|
|
79
80
|
});
|
|
80
81
|
|
|
81
82
|
if (!signInOn) {
|
package/server/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createPersonAuth } from './auth/session.js';
|
|
|
6
6
|
import { resolveSignInSettings } from './auth/settings.js';
|
|
7
7
|
import { migrateDatabase, openDatabase } from './db/index.js';
|
|
8
8
|
import { createIntegrations } from './integrations/index.js';
|
|
9
|
+
import { resolveDefaultFamily } from './theme/themes.js';
|
|
9
10
|
import { createUpdateChecker, UPDATE_CHECK_INTERVAL_MS } from './update-check.js';
|
|
10
11
|
|
|
11
12
|
const port = Number(process.env.FLOW_PORT || process.env.PORT || 19300);
|
|
@@ -18,6 +19,13 @@ try {
|
|
|
18
19
|
console.error(`[auth] ${error.message}`);
|
|
19
20
|
process.exit(1);
|
|
20
21
|
}
|
|
22
|
+
let defaultFamily;
|
|
23
|
+
try {
|
|
24
|
+
defaultFamily = resolveDefaultFamily();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error(`[ui] ${error.message}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
21
29
|
let integrations;
|
|
22
30
|
try {
|
|
23
31
|
integrations = createIntegrations();
|
|
@@ -76,8 +84,8 @@ const trustProxy = process.env.REPL_ID || process.env.REPLIT_DEPLOYMENT ? 1 : fa
|
|
|
76
84
|
|
|
77
85
|
const updates = createUpdateChecker();
|
|
78
86
|
|
|
79
|
-
createApp({ database, auth, trustProxy, updates, integrations }).listen(port, () => {
|
|
80
|
-
console.log(`[flow] listening on ${port}`);
|
|
87
|
+
createApp({ database, auth, trustProxy, updates, integrations, defaultFamily }).listen(port, () => {
|
|
88
|
+
console.log(`[flow] listening on ${port}; design system ${defaultFamily} (FLOW_THEME)`);
|
|
81
89
|
});
|
|
82
90
|
|
|
83
91
|
// The update notice: a release build logs when a newer release is out on its
|
|
@@ -102,7 +102,7 @@ export function defineAdapter(adapter) {
|
|
|
102
102
|
// stop() ends that work (tests, shutdown).
|
|
103
103
|
start: start ?? null,
|
|
104
104
|
stop: stop ?? null,
|
|
105
|
-
// Optional HTTP routes: routes({ db, personAuth, log }) returns a router
|
|
105
|
+
// Optional HTTP routes: routes({ db, personAuth, log, messagePage }) returns a router
|
|
106
106
|
// mounted at /api/<id> (registry.routes).
|
|
107
107
|
routes: routes ?? null,
|
|
108
108
|
});
|
|
@@ -195,7 +195,7 @@ export function createRegistry({ log = console } = {}) {
|
|
|
195
195
|
.filter((a) => ready(a) && typeof a.capabilities[capability] === 'function');
|
|
196
196
|
},
|
|
197
197
|
// The adapters' own HTTP routes, each under /api/<id>. `context` is
|
|
198
|
-
// { db, personAuth, log }. An enabled, ready adapter's router is built
|
|
198
|
+
// { db, personAuth, log, messagePage }. An enabled, ready adapter's router is built
|
|
199
199
|
// once, here; a request to an adapter that is off gets its turned-off
|
|
200
200
|
// answer (503), so a turned-off adapter never reads its settings or
|
|
201
201
|
// tables.
|
|
@@ -49,7 +49,7 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
49
49
|
// cursor is the next `since`.
|
|
50
50
|
pullPayments: ({ organisationId, since }) => readPayments(database, current(), organisationId, { since: new Date(since) }),
|
|
51
51
|
},
|
|
52
|
-
routes: ({ db, personAuth, log }) => createXeroRouter({ db, personAuth, runtime: current(), log }),
|
|
52
|
+
routes: ({ db, personAuth, log, messagePage }) => createXeroRouter({ db, personAuth, runtime: current(), log, messagePage }),
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -9,6 +9,8 @@ import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
|
|
|
9
9
|
import { decryptToken, deleteConnection, describeConnection, readConnection, saveConnection } from './connections.js';
|
|
10
10
|
import { authorizeUrl, exchangeCode, fetchConnections, revokeToken, selectConnection } from './oauth.js';
|
|
11
11
|
import { writesAllowed } from './settings.js';
|
|
12
|
+
import { DEFAULT_FAMILY } from '../../theme/themes.js';
|
|
13
|
+
import { messagePage as themedMessagePage } from '../../ui/page.js';
|
|
12
14
|
|
|
13
15
|
// The Xero adapter's routes, mounted at /api/xero (server/integrations/port.js).
|
|
14
16
|
//
|
|
@@ -33,14 +35,6 @@ import { writesAllowed } from './settings.js';
|
|
|
33
35
|
|
|
34
36
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
35
37
|
|
|
36
|
-
function escapeHtml(value) {
|
|
37
|
-
return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function page(title, text) {
|
|
41
|
-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title></head>`
|
|
42
|
-
+ `<body style="font-family: system-ui, sans-serif; max-width: 36rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5"><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p></body></html>`;
|
|
43
|
-
}
|
|
44
38
|
|
|
45
39
|
export function sendError(res, error, log) {
|
|
46
40
|
const status = error instanceof FlowError ? (error.status ?? 400) : 500;
|
|
@@ -75,7 +69,10 @@ export const handle = (log, fn) => async (req, res) => {
|
|
|
75
69
|
}
|
|
76
70
|
};
|
|
77
71
|
|
|
78
|
-
|
|
72
|
+
// `messagePage` draws the callback's answer in the installation's design
|
|
73
|
+
// system (server/ui/page.js); the server's app passes it.
|
|
74
|
+
export function createXeroRouter({ db, personAuth, runtime, log = console, messagePage = (options) => themedMessagePage({ ...options, defaultFamily: DEFAULT_FAMILY }) }) {
|
|
75
|
+
const page = (title, text, tone) => messagePage({ title, text, tone });
|
|
79
76
|
const { settings } = runtime;
|
|
80
77
|
const router = express.Router();
|
|
81
78
|
router.use(express.json({ limit: '256kb' }));
|
|
@@ -116,10 +113,10 @@ export function createXeroRouter({ db, personAuth, runtime, log = console }) {
|
|
|
116
113
|
res.set('Cache-Control', 'no-store');
|
|
117
114
|
const entry = runtime.states.consume(req.query.state);
|
|
118
115
|
if (!entry) {
|
|
119
|
-
return res.status(400).type('html').send(page('Xero was not connected', 'This approval link has expired or was already used. Start again from Flow.'));
|
|
116
|
+
return res.status(400).type('html').send(page('Xero was not connected', 'This approval link has expired or was already used. Start again from Flow.', 'danger'));
|
|
120
117
|
}
|
|
121
118
|
if (req.query.error) {
|
|
122
|
-
return res.status(400).type('html').send(page('Xero was not connected', 'The approval was cancelled in Xero. Start again from Flow if you meant to connect.'));
|
|
119
|
+
return res.status(400).type('html').send(page('Xero was not connected', 'The approval was cancelled in Xero. Start again from Flow if you meant to connect.', 'danger'));
|
|
123
120
|
}
|
|
124
121
|
try {
|
|
125
122
|
const tokens = await exchangeCode(settings, runtime.transport, typeof req.query.code === 'string' ? req.query.code : '');
|
|
@@ -153,11 +150,11 @@ export function createXeroRouter({ db, personAuth, runtime, log = console }) {
|
|
|
153
150
|
});
|
|
154
151
|
runtime.remember(entry.organisationId, tokens);
|
|
155
152
|
log.info?.(`[xero] organisation ${entry.organisationId} connected to Xero tenant ${connection.tenantId} (${settings.mode}) by person ${entry.personId}`);
|
|
156
|
-
return res.type('html').send(page('Xero is connected', `Flow is connected to ${connection.tenantName || 'your Xero organisation'} (${settings.mode}). You can close this tab
|
|
153
|
+
return res.type('html').send(page('Xero is connected', `Flow is connected to ${connection.tenantName || 'your Xero organisation'} (${settings.mode}). You can close this tab.`, 'success'));
|
|
157
154
|
} catch (error) {
|
|
158
155
|
log.error?.(`[xero] connect failed: ${error.code ?? 'error'}: ${error.message}`);
|
|
159
156
|
const message = error instanceof FlowError ? error.message : 'Something went wrong talking to Xero. Try again.';
|
|
160
|
-
return res.status(error.status && error.status < 500 ? error.status : 502).type('html').send(page('Xero was not connected', message));
|
|
157
|
+
return res.status(error.status && error.status < 500 ? error.status : 502).type('html').send(page('Xero was not connected', message, 'danger'));
|
|
161
158
|
}
|
|
162
159
|
});
|
|
163
160
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// WCAG 2.1 contrast helpers for theme tokens (server/theme/themes.js and
|
|
2
|
+
// scripts/port-openclaw-themes.mjs). Colours are #rrggbb.
|
|
3
|
+
|
|
4
|
+
export const HEX = /^#[0-9a-f]{6}$/i;
|
|
5
|
+
|
|
6
|
+
function rgb(hex) {
|
|
7
|
+
return [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function luminance(hex) {
|
|
11
|
+
const [r, g, b] = rgb(hex).map((c) => {
|
|
12
|
+
const s = c / 255;
|
|
13
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
14
|
+
});
|
|
15
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function contrast(a, b) {
|
|
19
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
20
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Black or white, whichever reads better on the fill. */
|
|
24
|
+
export function inkOn(fill) {
|
|
25
|
+
return luminance(fill) > 0.179 ? '#000000' : '#ffffff';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The colour, darkened or lightened just enough to read as text on `bg`. */
|
|
29
|
+
export function readableOn(color, bg, target = 4.5) {
|
|
30
|
+
if (!HEX.test(color) || !HEX.test(bg)) return color;
|
|
31
|
+
const toward = luminance(bg) > 0.179 ? [0, 0, 0] : [255, 255, 255];
|
|
32
|
+
const from = rgb(color);
|
|
33
|
+
for (let pct = 0; pct <= 100; pct += 2) {
|
|
34
|
+
const mixed = `#${from.map((c, i) => Math.round(c + (toward[i] - c) * pct / 100).toString(16).padStart(2, '0')).join('')}`;
|
|
35
|
+
if (contrast(mixed, bg) >= target) return mixed;
|
|
36
|
+
}
|
|
37
|
+
return toward[0] ? '#ffffff' : '#000000';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A boundary colour for controls on `bg` (an input's or an outlined button's
|
|
42
|
+
* edge): `bg` mixed toward `ink` just enough to stand out at `target`
|
|
43
|
+
* (WCAG 1.4.11 asks 3:1 for the parts that show where a control is).
|
|
44
|
+
*/
|
|
45
|
+
export function boundaryOn(bg, ink, target = 3) {
|
|
46
|
+
if (!HEX.test(bg) || !HEX.test(ink)) return ink;
|
|
47
|
+
const from = rgb(bg);
|
|
48
|
+
const toward = rgb(ink);
|
|
49
|
+
for (let pct = 0; pct <= 100; pct += 2) {
|
|
50
|
+
const mixed = `#${from.map((c, i) => Math.round(c + (toward[i] - c) * pct / 100).toString(16).padStart(2, '0')).join('')}`;
|
|
51
|
+
if (contrast(mixed, bg) >= target) return mixed;
|
|
52
|
+
}
|
|
53
|
+
return ink;
|
|
54
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Each design system's faces, as the fontsource packages Flow serves under
|
|
2
|
+
// /assets/fonts/<package>/ (server/ui/routes.js). A page loads only the chosen
|
|
3
|
+
// family's faces, plus JetBrains Mono for codes. Shared by the server and the
|
|
4
|
+
// browser, so it imports nothing.
|
|
5
|
+
export const MONO_FONT = 'jetbrains-mono';
|
|
6
|
+
|
|
7
|
+
export const FAMILY_FONTS = {
|
|
8
|
+
harbour: ['nunito-sans', 'playfair-display'],
|
|
9
|
+
beacon: ['atkinson-hyperlegible-next'],
|
|
10
|
+
tide: ['ibm-plex-sans'],
|
|
11
|
+
claw: ['instrument-sans'],
|
|
12
|
+
knot: ['geist'],
|
|
13
|
+
dash: ['dm-sans'],
|
|
14
|
+
absolutely: ['space-grotesk'],
|
|
15
|
+
phosphor: [],
|
|
16
|
+
crt: [],
|
|
17
|
+
manuscript: ['lora'],
|
|
18
|
+
rose: ['dm-sans'],
|
|
19
|
+
miami: ['space-grotesk'],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Every package a family can ask for, so the server serves only these. */
|
|
23
|
+
export const FONT_PACKAGES = [...new Set([MONO_FONT, ...Object.values(FAMILY_FONTS).flat()])];
|
|
24
|
+
|
|
25
|
+
/** The stylesheet URLs a family needs, mono first. */
|
|
26
|
+
export function fontStylesheets(family) {
|
|
27
|
+
return [MONO_FONT, ...(FAMILY_FONTS[family] ?? [])].map((name) => `/assets/fonts/${name}/index.css`);
|
|
28
|
+
}
|