@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/server/ui/app.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
// Flow's signed-in app and its sign-in screen, at /sign-in and each view's
|
|
2
|
+
// own address (/people, /account, /appearance). Clerk's own
|
|
3
|
+
// components sign people in (SignIn), hold the account menu (UserButton) and
|
|
4
|
+
// the account page (UserProfile); Flow decides what each person may do
|
|
5
|
+
// (GET /api/me) and an owner manages people here. Every Clerk component takes
|
|
6
|
+
// Flow's design system (clerk-appearance.js) and follows it when the
|
|
7
|
+
// appearance changes. Every Flow request carries the session token in the
|
|
8
|
+
// Authorization header. Text from the server is only ever set as text.
|
|
9
|
+
import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/assets/ui/appearance.js';
|
|
10
|
+
import { flowClerkAppearance } from '/assets/ui/clerk-appearance.js';
|
|
11
|
+
import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/assets/theme/themes.js';
|
|
12
|
+
|
|
13
|
+
const $ = (id) => document.getElementById(id);
|
|
14
|
+
|
|
15
|
+
// h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
|
|
16
|
+
function h(tag, attrs = {}, ...children) {
|
|
17
|
+
const node = document.createElement(tag);
|
|
18
|
+
for (const [key, value] of Object.entries(attrs ?? {})) {
|
|
19
|
+
if (value == null || value === false) continue;
|
|
20
|
+
if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
|
21
|
+
else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
|
|
22
|
+
else node.setAttribute(key, value === true ? '' : String(value));
|
|
23
|
+
}
|
|
24
|
+
for (const child of children.flat()) {
|
|
25
|
+
if (child == null || child === false) continue;
|
|
26
|
+
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
|
27
|
+
}
|
|
28
|
+
return node;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ROLE_LABEL = { owner: 'Owner', admin: 'Admin', staff: 'Staff' };
|
|
32
|
+
const ROLE_HINT = {
|
|
33
|
+
owner: 'Manages people, connections and every record.',
|
|
34
|
+
admin: 'Runs invoicing and connections day to day.',
|
|
35
|
+
staff: 'Works on jobs and invoices.',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ── Clerk ──────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
let config = null;
|
|
41
|
+
const mounted = new Map(); // element -> { kind, options }
|
|
42
|
+
|
|
43
|
+
function clerkAppearance(kind) {
|
|
44
|
+
return flowClerkAppearance({ embedded: kind === 'profile', publicSignUp: config?.publicSignUp });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mountClerk(kind, element, options = {}) {
|
|
48
|
+
const Clerk = window.Clerk;
|
|
49
|
+
const props = { ...options, appearance: clerkAppearance(kind) };
|
|
50
|
+
if (kind === 'signIn') Clerk.mountSignIn(element, props);
|
|
51
|
+
else if (kind === 'userButton') Clerk.mountUserButton(element, props);
|
|
52
|
+
else if (kind === 'profile') Clerk.mountUserProfile(element, props);
|
|
53
|
+
mounted.set(element, { kind, options });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function unmountClerk(element) {
|
|
57
|
+
const entry = mounted.get(element);
|
|
58
|
+
if (!entry) return;
|
|
59
|
+
const Clerk = window.Clerk;
|
|
60
|
+
if (entry.kind === 'signIn') Clerk.unmountSignIn(element);
|
|
61
|
+
else if (entry.kind === 'userButton') Clerk.unmountUserButton(element);
|
|
62
|
+
else if (entry.kind === 'profile') Clerk.unmountUserProfile(element);
|
|
63
|
+
mounted.delete(element);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function unmountAllIn(container) {
|
|
67
|
+
for (const element of [...mounted.keys()]) {
|
|
68
|
+
if (container.contains(element)) unmountClerk(element);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Clerk reads its appearance when a component mounts, so a new appearance
|
|
73
|
+
// remounts each one (after the tokens have changed on <html>).
|
|
74
|
+
onAppearanceChange(() => {
|
|
75
|
+
for (const [element, { kind, options }] of [...mounted]) {
|
|
76
|
+
unmountClerk(element);
|
|
77
|
+
mountClerk(kind, element, options);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// clerk-js, then its prebuilt components (@clerk/ui), which set
|
|
82
|
+
// window.__internal_ClerkUICtor for Clerk.load().
|
|
83
|
+
function loadClerk({ publishableKey, proxyUrl, scriptUrl, uiScriptUrl }) {
|
|
84
|
+
return Promise.all([
|
|
85
|
+
loadScript(scriptUrl, (script) => {
|
|
86
|
+
script.dataset.clerkPublishableKey = publishableKey;
|
|
87
|
+
if (proxyUrl) script.dataset.clerkProxyUrl = proxyUrl;
|
|
88
|
+
}),
|
|
89
|
+
loadScript(uiScriptUrl),
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function loadScript(src, prepare = () => {}) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const script = document.createElement('script');
|
|
96
|
+
script.src = src;
|
|
97
|
+
script.crossOrigin = 'anonymous';
|
|
98
|
+
prepare(script);
|
|
99
|
+
script.onload = resolve;
|
|
100
|
+
script.onerror = () => reject(new Error(`Could not load ${src}`));
|
|
101
|
+
document.head.append(script);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function flow(path, options = {}) {
|
|
106
|
+
const token = await window.Clerk.session?.getToken();
|
|
107
|
+
const res = await fetch(path, {
|
|
108
|
+
...options,
|
|
109
|
+
headers: { Authorization: `Bearer ${token}`, ...(options.body ? { 'Content-Type': 'application/json' } : {}) },
|
|
110
|
+
});
|
|
111
|
+
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 });
|
|
113
|
+
return body;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Shared pieces ──────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function pageHeader(title, { subtitle, actions = [] } = {}) {
|
|
119
|
+
return h('header', { class: 'fx-ph' },
|
|
120
|
+
h('div', { class: 'fx-ph__text' },
|
|
121
|
+
h('h1', { class: 'fx-title' }, title),
|
|
122
|
+
subtitle ? h('p', { class: 'fx-ph__sub' }, subtitle) : null),
|
|
123
|
+
actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function notice(text, tone = 'info', title = null) {
|
|
127
|
+
return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
|
|
128
|
+
title ? h('strong', {}, title) : null,
|
|
129
|
+
h('span', {}, text));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function toast(text, tone = 'info') {
|
|
133
|
+
const region = $('toasts');
|
|
134
|
+
const item = h('div', { class: 'fx-toast', 'data-tone': tone, role: 'status' }, text);
|
|
135
|
+
region.append(item);
|
|
136
|
+
setTimeout(() => item.remove(), 4200);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A modal built on <dialog>: resolves with the button's value, or null.
|
|
140
|
+
function dialog({ title, body, confirm, tone = 'primary' }) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
|
|
143
|
+
h('h2', { class: 'fx-dialog__title' }, title),
|
|
144
|
+
body,
|
|
145
|
+
h('div', { class: 'fx-dialog__actions' },
|
|
146
|
+
h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
|
|
147
|
+
h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
|
|
148
|
+
const element = h('dialog', { class: 'fx-dialog' }, form);
|
|
149
|
+
element.addEventListener('close', () => {
|
|
150
|
+
const ok = element.returnValue === 'ok';
|
|
151
|
+
const data = ok ? new FormData(form) : null;
|
|
152
|
+
element.remove();
|
|
153
|
+
resolve(ok ? data : null);
|
|
154
|
+
});
|
|
155
|
+
document.body.append(element);
|
|
156
|
+
element.showModal();
|
|
157
|
+
element.querySelector('input, select, button[value="ok"]')?.focus();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function initials(person) {
|
|
162
|
+
const source = (person.name || person.email || '?').replace(/@.*/, '');
|
|
163
|
+
const parts = source.split(/[\s._-]+/).filter(Boolean);
|
|
164
|
+
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function statusPill(person) {
|
|
168
|
+
if (person.status === 'pending') return h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'Waiting for approval');
|
|
169
|
+
if (person.status === 'disabled') return h('span', { class: 'fx-status', 'data-tone': 'mute' }, 'Turned off');
|
|
170
|
+
if (person.invited) return h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Invited');
|
|
171
|
+
return h('span', { class: 'fx-status', 'data-tone': 'success' }, 'Active');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function roleSelect(value, attrs = {}) {
|
|
175
|
+
return h('select', { class: 'fx-select', ...attrs },
|
|
176
|
+
['owner', 'admin', 'staff'].map((role) => h('option', { value: role, selected: role === value }, ROLE_LABEL[role])));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Views ──────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
let me = null;
|
|
182
|
+
|
|
183
|
+
const VIEWS = {
|
|
184
|
+
people: { label: 'People', ownerOnly: true, render: renderPeople },
|
|
185
|
+
account: { label: 'Account', render: renderAccount },
|
|
186
|
+
appearance: { label: 'Appearance', render: renderAppearance },
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
function currentView() {
|
|
190
|
+
const name = location.pathname.replace(/^\/+|\/+$/g, '');
|
|
191
|
+
if (VIEWS[name] && (!VIEWS[name].ownerOnly || me?.person.role === 'owner')) return name;
|
|
192
|
+
return me?.person.role === 'owner' ? 'people' : 'account';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function route() {
|
|
196
|
+
const main = $('main');
|
|
197
|
+
unmountAllIn(main);
|
|
198
|
+
const view = currentView();
|
|
199
|
+
if (location.pathname !== `/${view}`) history.replaceState(null, '', `/${view}`);
|
|
200
|
+
for (const link of document.querySelectorAll('[data-nav]')) {
|
|
201
|
+
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
202
|
+
}
|
|
203
|
+
main.replaceChildren();
|
|
204
|
+
document.title = `${VIEWS[view].label} · Flow`;
|
|
205
|
+
await VIEWS[view].render(main);
|
|
206
|
+
main.focus({ preventScroll: true });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A view's link opens it in place; the address changes, the page stays.
|
|
210
|
+
function navigate(event) {
|
|
211
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
const href = event.currentTarget.getAttribute('href');
|
|
214
|
+
if (href !== location.pathname) history.pushState(null, '', href);
|
|
215
|
+
route();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderSignedOut() {
|
|
219
|
+
document.body.dataset.state = 'signed-out';
|
|
220
|
+
document.title = 'Sign in · Flow';
|
|
221
|
+
const panel = $('sign-in');
|
|
222
|
+
$('sign-in-note').textContent = config.publicSignUp
|
|
223
|
+
? 'Sign in, or ask for access with your work email.'
|
|
224
|
+
: 'Sign in with the account you were invited with.';
|
|
225
|
+
// With public sign-up off the widget never creates a Clerk account: no
|
|
226
|
+
// sign-up link, and an OAuth sign-in for an unknown email is not turned
|
|
227
|
+
// into a sign-up (transferable: false).
|
|
228
|
+
mountClerk('signIn', panel, {
|
|
229
|
+
forceRedirectUrl: '/sign-in',
|
|
230
|
+
withSignUp: config.publicSignUp,
|
|
231
|
+
transferable: config.publicSignUp,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderShell() {
|
|
236
|
+
document.body.dataset.state = 'signed-in';
|
|
237
|
+
const nav = $('nav');
|
|
238
|
+
nav.replaceChildren(...Object.entries(VIEWS)
|
|
239
|
+
.filter(([, view]) => !view.ownerOnly || me?.person.role === 'owner')
|
|
240
|
+
.map(([name, view]) => h('a', { href: `/${name}`, 'data-nav': name, onclick: navigate }, view.label)));
|
|
241
|
+
mountClerk('userButton', $('user-button'), { afterSignOutUrl: '/sign-in', showName: false });
|
|
242
|
+
window.addEventListener('popstate', route);
|
|
243
|
+
return route();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderNoAccess(error) {
|
|
247
|
+
document.body.dataset.state = 'no-access';
|
|
248
|
+
const email = window.Clerk.user?.primaryEmailAddress?.emailAddress ?? '';
|
|
249
|
+
const card = $('no-access');
|
|
250
|
+
card.replaceChildren(...[
|
|
251
|
+
h('h1', { class: 'fx-title' }, error.status === 403 ? 'Waiting for an owner' : 'Flow could not open'),
|
|
252
|
+
h('p', {}, error.message),
|
|
253
|
+
email ? h('p', { class: 'fx-dim' }, `Signed in as ${email}.`) : null,
|
|
254
|
+
h('div', { class: 'fx-row' },
|
|
255
|
+
h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => window.Clerk.signOut({ redirectUrl: '/sign-in' }) }, 'Sign out'),
|
|
256
|
+
h('button', { class: 'fx-btn', type: 'button', onclick: () => location.reload() }, 'Try again')),
|
|
257
|
+
].filter(Boolean));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// People: an owner invites, approves, changes roles, turns access off and
|
|
261
|
+
// removes. Managing people needs a verified owner sign-in (/api/me says).
|
|
262
|
+
async function renderPeople(main) {
|
|
263
|
+
const canManage = Boolean(me.ownerActions?.allowed);
|
|
264
|
+
const invite = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: !canManage, onclick: () => invitePerson() }, 'Invite someone');
|
|
265
|
+
const tableHost = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
266
|
+
main.append(...[
|
|
267
|
+
pageHeader('People', { subtitle: 'Who can sign in to Flow, and what each person can do.', actions: [invite] }),
|
|
268
|
+
canManage ? null : notice('Managing people needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
|
|
269
|
+
tableHost,
|
|
270
|
+
h('dl', { class: 'fx-roles' }, ['owner', 'admin', 'staff'].flatMap((role) => [h('dt', {}, ROLE_LABEL[role]), h('dd', {}, ROLE_HINT[role])])),
|
|
271
|
+
].filter(Boolean));
|
|
272
|
+
if (!canManage) {
|
|
273
|
+
tableHost.replaceChildren(h('p', { class: 'fx-empty' }, 'The people list opens after a verified owner sign-in.'));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
await refreshPeople(tableHost);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function refreshPeople(host) {
|
|
280
|
+
let people;
|
|
281
|
+
try {
|
|
282
|
+
({ people } = await flow('/api/people'));
|
|
283
|
+
} catch (error) {
|
|
284
|
+
host.replaceChildren(notice(error.message, 'danger'));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const waiting = people.filter((p) => p.status === 'pending').length;
|
|
288
|
+
const rows = people.map((person) => personRow(person, host));
|
|
289
|
+
host.replaceChildren(...[
|
|
290
|
+
waiting ? notice(`${waiting} ${waiting === 1 ? 'person is' : 'people are'} waiting for approval.`, 'info') : null,
|
|
291
|
+
h('table', { class: 'fx-table' },
|
|
292
|
+
h('thead', {}, h('tr', {}, h('th', {}, 'Person'), h('th', {}, 'Role'), h('th', {}, 'Access'), h('th', { class: 'fx-table__actions' }, h('span', { class: 'fx-sr' }, 'Actions')))),
|
|
293
|
+
h('tbody', {}, rows)),
|
|
294
|
+
].filter(Boolean));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function personRow(person, host) {
|
|
298
|
+
const self = person.id === me.person.id;
|
|
299
|
+
const act = async (fn, done) => {
|
|
300
|
+
try {
|
|
301
|
+
await fn();
|
|
302
|
+
if (done) toast(done, 'success');
|
|
303
|
+
} catch (error) {
|
|
304
|
+
toast(error.message, 'danger');
|
|
305
|
+
}
|
|
306
|
+
await refreshPeople(host);
|
|
307
|
+
};
|
|
308
|
+
const patch = (body) => flow(`/api/people/${person.id}`, { method: 'PATCH', body: JSON.stringify(body) });
|
|
309
|
+
const who = h('div', { class: 'fx-person' },
|
|
310
|
+
h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initials(person)),
|
|
311
|
+
h('span', { class: 'fx-person__text' },
|
|
312
|
+
h('span', { class: 'fx-person__name' }, person.name || person.email, self ? h('span', { class: 'fx-tag' }, 'You') : null),
|
|
313
|
+
person.name ? h('span', { class: 'fx-person__email' }, person.email) : null));
|
|
314
|
+
const role = self
|
|
315
|
+
? h('span', {}, ROLE_LABEL[person.role])
|
|
316
|
+
: roleSelect(person.role, {
|
|
317
|
+
'aria-label': `Role for ${person.email}`,
|
|
318
|
+
onchange: (event) => act(() => patch({ role: event.target.value }), `${person.email} is now ${ROLE_LABEL[event.target.value].toLowerCase()}.`),
|
|
319
|
+
});
|
|
320
|
+
const actions = [];
|
|
321
|
+
if (!self) {
|
|
322
|
+
if (person.status === 'pending') {
|
|
323
|
+
actions.push(h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'active' }), `${person.email} can sign in.`) }, 'Approve'));
|
|
324
|
+
} else if (person.status === 'active') {
|
|
325
|
+
actions.push(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'disabled' }), `${person.email} is turned off.`) }, 'Turn off'));
|
|
326
|
+
} else {
|
|
327
|
+
actions.push(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => act(() => patch({ status: 'active' }), `${person.email} is turned on.`) }, 'Turn on'));
|
|
328
|
+
}
|
|
329
|
+
actions.push(h('button', {
|
|
330
|
+
class: 'fx-btn fx-btn--ghost fx-btn--danger fx-btn--sm',
|
|
331
|
+
type: 'button',
|
|
332
|
+
onclick: async () => {
|
|
333
|
+
const ok = await dialog({
|
|
334
|
+
title: `Remove ${person.email}?`,
|
|
335
|
+
body: h('p', {}, 'They can no longer sign in to Flow. Records they made stay.'),
|
|
336
|
+
confirm: 'Remove',
|
|
337
|
+
tone: 'danger',
|
|
338
|
+
});
|
|
339
|
+
if (ok) await act(() => flow(`/api/people/${person.id}`, { method: 'DELETE' }), `${person.email} is removed.`);
|
|
340
|
+
},
|
|
341
|
+
}, 'Remove'));
|
|
342
|
+
}
|
|
343
|
+
return h('tr', {},
|
|
344
|
+
h('td', {}, who),
|
|
345
|
+
h('td', {}, role),
|
|
346
|
+
h('td', {}, statusPill(person)),
|
|
347
|
+
h('td', { class: 'fx-table__actions' }, h('div', { class: 'fx-row fx-row--end' }, actions)));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function invitePerson() {
|
|
351
|
+
const data = await dialog({
|
|
352
|
+
title: 'Invite someone',
|
|
353
|
+
body: h('div', { class: 'fx-fields' },
|
|
354
|
+
h('label', { class: 'fx-field' },
|
|
355
|
+
h('span', { class: 'fx-label' }, 'Email'),
|
|
356
|
+
h('input', { class: 'fx-input', name: 'email', type: 'email', required: true, autocomplete: 'off', placeholder: 'name@example.com' })),
|
|
357
|
+
h('label', { class: 'fx-field' },
|
|
358
|
+
h('span', { class: 'fx-label' }, 'Role'),
|
|
359
|
+
roleSelect('staff', { name: 'role' })),
|
|
360
|
+
h('p', { class: 'fx-dim' }, 'They sign in with this email. Flow prepares their sign-in account.')),
|
|
361
|
+
confirm: 'Send invitation',
|
|
362
|
+
});
|
|
363
|
+
if (!data) return;
|
|
364
|
+
try {
|
|
365
|
+
await flow('/api/people', { method: 'POST', body: JSON.stringify({ email: data.get('email'), role: data.get('role') }) });
|
|
366
|
+
toast(`${data.get('email')} is invited.`, 'success');
|
|
367
|
+
} catch (error) {
|
|
368
|
+
toast(error.message, 'danger');
|
|
369
|
+
}
|
|
370
|
+
await route();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Account: Clerk's own profile (name, email addresses, connected accounts,
|
|
374
|
+
// security), with the person's Flow role above it.
|
|
375
|
+
function renderAccount(main) {
|
|
376
|
+
const host = h('div', { class: 'fx-clerk-profile' });
|
|
377
|
+
main.append(
|
|
378
|
+
pageHeader('Account', { subtitle: `${me.person.email} · ${ROLE_LABEL[me.person.role]}. ${ROLE_HINT[me.person.role]}` }),
|
|
379
|
+
host);
|
|
380
|
+
// Clerk keeps its own pages (profile, security) in memory, so the URL
|
|
381
|
+
// stays Flow's.
|
|
382
|
+
mountClerk('profile', host, { routing: 'virtual' });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Appearance: light or dark, the design system and an accent colour, for
|
|
386
|
+
// this browser.
|
|
387
|
+
function renderAppearance(main) {
|
|
388
|
+
main.append(
|
|
389
|
+
pageHeader('Appearance', { subtitle: 'For this browser. Clerk\'s sign-in and account screens follow it too.' }),
|
|
390
|
+
h('div', { class: 'fx-appearance', id: 'appearance' }));
|
|
391
|
+
drawAppearance();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function radioGroup(label, items, selectedIndex, onSelect, render) {
|
|
395
|
+
const group = h('div', { role: 'radiogroup', 'aria-label': label });
|
|
396
|
+
const buttons = items.map((item, index) => {
|
|
397
|
+
const button = render(item, index);
|
|
398
|
+
button.setAttribute('role', 'radio');
|
|
399
|
+
button.setAttribute('aria-checked', String(index === selectedIndex));
|
|
400
|
+
button.tabIndex = index === selectedIndex || (selectedIndex < 0 && index === 0) ? 0 : -1;
|
|
401
|
+
button.addEventListener('click', () => onSelect(item));
|
|
402
|
+
button.addEventListener('keydown', (event) => {
|
|
403
|
+
const last = items.length - 1;
|
|
404
|
+
const next = { ArrowRight: index === last ? 0 : index + 1, ArrowDown: index === last ? 0 : index + 1, ArrowLeft: index === 0 ? last : index - 1, ArrowUp: index === 0 ? last : index - 1, Home: 0, End: last }[event.key];
|
|
405
|
+
if (next === undefined) return;
|
|
406
|
+
event.preventDefault();
|
|
407
|
+
onSelect(items[next]);
|
|
408
|
+
document.querySelector(`[aria-label="${label}"] > [role="radio"]:nth-child(${next + 1})`)?.focus();
|
|
409
|
+
});
|
|
410
|
+
return button;
|
|
411
|
+
});
|
|
412
|
+
group.append(...buttons);
|
|
413
|
+
return group;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function drawAppearance() {
|
|
417
|
+
const host = $('appearance');
|
|
418
|
+
if (!host) return;
|
|
419
|
+
const { colorMode, family, accent, mode } = getAppearance();
|
|
420
|
+
// Each card is set in its own family's faces.
|
|
421
|
+
THEME_FAMILIES.forEach((f) => loadFonts(f.id));
|
|
422
|
+
|
|
423
|
+
const modes = [{ value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }, { value: 'system', label: 'Auto' }];
|
|
424
|
+
const modeGroup = radioGroup('Mode', modes, modes.findIndex((m) => m.value === colorMode),
|
|
425
|
+
(m) => setAppearance({ colorMode: m.value }),
|
|
426
|
+
(m) => h('button', { type: 'button', class: 'fx-seg__item' }, m.label));
|
|
427
|
+
modeGroup.classList.add('fx-seg');
|
|
428
|
+
|
|
429
|
+
const familyIndex = THEME_FAMILIES.findIndex((f) => f.id === family);
|
|
430
|
+
const families = radioGroup('Design system', THEME_FAMILIES, familyIndex,
|
|
431
|
+
(f) => setAppearance({ family: f.id }),
|
|
432
|
+
(f) => {
|
|
433
|
+
// The card shows the family as it is, not the active theme.
|
|
434
|
+
const t = tokensFor(f.id, mode);
|
|
435
|
+
const s = f.swatches[mode];
|
|
436
|
+
return h('button', { type: 'button', class: 'fx-theme-card', 'data-theme-card': f.id },
|
|
437
|
+
h('span', { class: 'fx-theme-card__preview', 'aria-hidden': 'true', style: { background: s.bg, color: s.text, borderRadius: t['--t-radius-lg'], fontFamily: t['--t-body'], borderColor: t['--t-brd'] } },
|
|
438
|
+
h('span', { class: 'fx-theme-card__bar', style: { background: s.surface } }),
|
|
439
|
+
h('span', { class: 'fx-theme-card__type', style: { fontFamily: t['--t-display'], fontWeight: t['--t-display-weight'] } }, 'Aa'),
|
|
440
|
+
h('span', { class: 'fx-theme-card__chips' },
|
|
441
|
+
h('span', { style: { background: s.primary, borderRadius: t['--t-radius'] } }),
|
|
442
|
+
h('span', { style: { background: s.accent, borderRadius: t['--t-radius'] } }))),
|
|
443
|
+
h('span', { class: 'fx-theme-card__name' }, f.label),
|
|
444
|
+
h('span', { class: 'fx-theme-card__desc' }, f.description));
|
|
445
|
+
});
|
|
446
|
+
families.classList.add('fx-theme-grid');
|
|
447
|
+
|
|
448
|
+
const active = THEME_FAMILIES[Math.max(familyIndex, 0)];
|
|
449
|
+
const presetIndex = accent ? ACCENT_PRESETS.findIndex((p) => p.id === accent) : 0;
|
|
450
|
+
const customHex = accent && presetIndex < 0 ? normalizeAccent(accent) : null;
|
|
451
|
+
const accents = radioGroup('Accent colour', ACCENT_PRESETS, presetIndex,
|
|
452
|
+
(p) => setAppearance({ accent: p.hex ? p.id : null }),
|
|
453
|
+
(p) => p.hex
|
|
454
|
+
? h('button', { type: 'button', class: 'fx-swatch', title: p.label, 'aria-label': p.label, style: { background: p.hex } })
|
|
455
|
+
: h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', title: `${active.label}'s own`, 'aria-label': `${active.label}'s own accent` },
|
|
456
|
+
h('span', { 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } })));
|
|
457
|
+
accents.classList.add('fx-swatches');
|
|
458
|
+
const custom = h('label', { class: 'fx-swatch-custom' },
|
|
459
|
+
h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) => setAppearance({ accent: event.target.value }) }),
|
|
460
|
+
'Custom');
|
|
461
|
+
const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label : `${active.label}'s own`;
|
|
462
|
+
|
|
463
|
+
host.replaceChildren(
|
|
464
|
+
h('section', { class: 'fx-group' },
|
|
465
|
+
h('h2', { class: 'fx-label' }, 'Mode'),
|
|
466
|
+
modeGroup,
|
|
467
|
+
h('p', { class: 'fx-dim' }, 'Auto follows this device\'s light or dark setting.')),
|
|
468
|
+
h('section', { class: 'fx-group' },
|
|
469
|
+
h('h2', { class: 'fx-label' }, 'Design system'),
|
|
470
|
+
families,
|
|
471
|
+
h('p', { class: 'fx-dim' }, 'Each design system brings its own colours, type and corners, in light and dark. Harbour is Flow\'s own; the rest are OpenClaw\'s themes.')),
|
|
472
|
+
h('section', { class: 'fx-group' },
|
|
473
|
+
h('h2', { class: 'fx-label' }, 'Accent colour'),
|
|
474
|
+
h('div', { class: 'fx-row' }, accents, custom),
|
|
475
|
+
h('p', { class: 'fx-dim', 'aria-live': 'polite' }, `Using ${accentLabel}. Buttons, highlights and focus rings take the accent; text in the accent is adjusted until it reads clearly.`)));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
onAppearanceChange(() => {
|
|
479
|
+
const focused = document.activeElement?.closest?.('[role="radiogroup"]')?.getAttribute('aria-label');
|
|
480
|
+
drawAppearance();
|
|
481
|
+
if (focused) document.querySelector(`[aria-label="${focused}"] [aria-checked="true"]`)?.focus();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// The quick light or dark switch on the sign-in screen and in the top bar.
|
|
485
|
+
const SUN = '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle cx="12" cy="12" r="4.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
|
486
|
+
const MOON = '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>';
|
|
487
|
+
|
|
488
|
+
function drawModeToggles() {
|
|
489
|
+
const dark = getAppearance().mode === 'dark';
|
|
490
|
+
for (const button of document.querySelectorAll('[data-mode-toggle]')) {
|
|
491
|
+
const label = dark ? 'Use light mode' : 'Use dark mode';
|
|
492
|
+
button.innerHTML = dark ? SUN : MOON;
|
|
493
|
+
if (!button.classList.contains('fx-btn--icon')) button.append(` ${dark ? 'Light' : 'Dark'}`);
|
|
494
|
+
button.setAttribute('aria-label', label);
|
|
495
|
+
button.title = label;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
for (const button of document.querySelectorAll('[data-mode-toggle]')) {
|
|
500
|
+
button.addEventListener('click', () => setAppearance({ colorMode: getAppearance().mode === 'dark' ? 'light' : 'dark' }));
|
|
501
|
+
}
|
|
502
|
+
onAppearanceChange(drawModeToggles);
|
|
503
|
+
drawModeToggles();
|
|
504
|
+
|
|
505
|
+
// ── Start ──────────────────────────────────────────────────────────────────
|
|
506
|
+
|
|
507
|
+
// Clerk's words where they name the app: the installation is Flow, whatever
|
|
508
|
+
// the Clerk instance is called.
|
|
509
|
+
const LOCALIZATION = {
|
|
510
|
+
signIn: {
|
|
511
|
+
start: { title: 'Sign in to Flow', subtitle: 'Welcome back. Choose how to sign in.' },
|
|
512
|
+
password: { subtitle: 'Enter the password for your account.' },
|
|
513
|
+
},
|
|
514
|
+
signUp: { start: { title: 'Ask for access to Flow', subtitle: 'Use your work email. An owner approves new people.' } },
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
async function start() {
|
|
518
|
+
config = await fetch('/api/auth/config').then((res) => res.json()).catch(() => null);
|
|
519
|
+
if (!config || config.mode !== 'clerk') {
|
|
520
|
+
document.body.dataset.state = 'off';
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
await loadClerk(config.clerk);
|
|
525
|
+
await window.Clerk.load({
|
|
526
|
+
ui: { ClerkUI: window.__internal_ClerkUICtor },
|
|
527
|
+
appearance: flowClerkAppearance({ publicSignUp: config.publicSignUp }),
|
|
528
|
+
localization: LOCALIZATION,
|
|
529
|
+
});
|
|
530
|
+
} catch {
|
|
531
|
+
document.body.dataset.state = 'error';
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const Clerk = window.Clerk;
|
|
535
|
+
// Signing in on this page reloads it into the app. Clerk calls a new
|
|
536
|
+
// listener at once with the current user, so compare with the state the
|
|
537
|
+
// page opened in.
|
|
538
|
+
const openedSignedIn = Boolean(Clerk.user);
|
|
539
|
+
Clerk.addListener(({ user }) => {
|
|
540
|
+
if (user && !openedSignedIn) location.reload();
|
|
541
|
+
});
|
|
542
|
+
if (!openedSignedIn) {
|
|
543
|
+
renderSignedOut();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
me = await flow('/api/me');
|
|
548
|
+
} catch (error) {
|
|
549
|
+
renderNoAccess(error);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
await renderShell();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
start();
|