@arcforgelabs/flow 2026.10.19 → 2026.10.21

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.
@@ -1,195 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <meta name="referrer" content="no-referrer">
7
- <title>Sign in to Flow</title>
8
- <style>
9
- :root { color-scheme: light dark; --fg: #1d1d1f; --bg: #f7f7f8; --muted: #66666e; --line: #d9d9de; --accent: #2f5bd3; }
10
- @media (prefers-color-scheme: dark) { :root { --fg: #ececf0; --bg: #16161a; --muted: #a0a0aa; --line: #34343c; --accent: #7d9cf0; } }
11
- body { margin: 0; font: 16px/1.5 system-ui, sans-serif; color: var(--fg); background: var(--bg); }
12
- main { max-width: 760px; margin: 0 auto; padding: 32px 16px; }
13
- h1 { font-size: 1.4rem; margin: 0 0 16px; }
14
- h2 { font-size: 1.1rem; margin: 32px 0 8px; }
15
- p.note { color: var(--muted); }
16
- .error { color: #c62828; }
17
- table { width: 100%; border-collapse: collapse; }
18
- th, td { text-align: left; padding: 6px 4px; border-bottom: 1px solid var(--line); vertical-align: middle; }
19
- form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
20
- input, select, button { font: inherit; padding: 6px 8px; }
21
- button { cursor: pointer; }
22
- [hidden] { display: none !important; }
23
- </style>
24
- </head>
25
- <body>
26
- <main>
27
- <h1>Flow</h1>
28
- <p id="status" class="note">Loading sign-in…</p>
29
- <div id="sign-in"></div>
30
- <section id="signed-in" hidden>
31
- <p>Signed in as <strong id="me-email"></strong> (<span id="me-role"></span>). <button id="sign-out" type="button">Sign out</button></p>
32
- <p id="owner-note" class="note" hidden></p>
33
- <section id="people-section" hidden>
34
- <h2>People</h2>
35
- <table>
36
- <thead><tr><th>Email</th><th>Role</th><th>Access</th><th></th></tr></thead>
37
- <tbody id="people"></tbody>
38
- </table>
39
- <h2>Invite someone</h2>
40
- <form id="invite">
41
- <input name="email" type="email" required placeholder="name@example.com" autocomplete="off">
42
- <select name="role"><option value="staff">staff</option><option value="admin">admin</option><option value="owner">owner</option></select>
43
- <button type="submit">Invite</button>
44
- </form>
45
- <p id="people-error" class="error" role="alert"></p>
46
- </section>
47
- </section>
48
- </main>
49
- <script type="module">
50
- // Flow's sign-in page. Clerk's sign-in widget signs people in; Flow then
51
- // decides what they may do (GET /api/me). Every Flow request carries the
52
- // session token in the Authorization header. Text from the server is only
53
- // ever set with textContent.
54
- const $ = (id) => document.getElementById(id);
55
- const status = (text, isError = false) => { $('status').textContent = text; $('status').className = isError ? 'error' : 'note'; };
56
-
57
- const config = await fetch('/api/auth/config').then((res) => res.json()).catch(() => null);
58
- if (!config || config.mode !== 'clerk') {
59
- status('Sign-in is not configured on this installation. The operator sets FLOW_AUTH=clerk (docs/SELF_HOSTING.md).', true);
60
- } else {
61
- await loadClerk(config.clerk);
62
- const Clerk = window.Clerk;
63
- await Clerk.load({ ui: { ClerkUI: window.__internal_ClerkUICtor } });
64
- Clerk.addListener(({ user }) => { if (user && $('signed-in').hidden) showSignedIn(); });
65
- if (Clerk.user) {
66
- await showSignedIn();
67
- } else {
68
- status(config.publicSignUp ? 'Sign in, or ask for access.' : 'Sign in with the account you were invited with.');
69
- // With public sign-up off the widget never creates a Clerk account: no
70
- // sign-up link, and an OAuth sign-in for an unknown email is not turned
71
- // into a sign-up (transferable: false).
72
- Clerk.mountSignIn($('sign-in'), {
73
- forceRedirectUrl: '/sign-in',
74
- withSignUp: config.publicSignUp,
75
- transferable: config.publicSignUp,
76
- ...(config.publicSignUp ? {} : { appearance: { elements: { footerAction: { display: 'none' } } } }),
77
- });
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 = () => { status('Could not load sign-in. Try again shortly.', true); reject(new Error(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 new Error(body.error || `HTTP ${res.status}`);
113
- return body;
114
- }
115
-
116
- async function showSignedIn() {
117
- $('sign-in').replaceChildren();
118
- $('sign-out').onclick = () => window.Clerk.signOut({ redirectUrl: '/sign-in' });
119
- let me;
120
- try {
121
- me = await flow('/api/me');
122
- } catch (error) {
123
- status(error.message, true);
124
- $('signed-in').hidden = false;
125
- $('me-email').textContent = window.Clerk.user?.primaryEmailAddress?.emailAddress ?? '';
126
- $('me-role').textContent = 'no Flow access';
127
- return;
128
- }
129
- status('');
130
- $('signed-in').hidden = false;
131
- $('me-email').textContent = me.person.email;
132
- $('me-role').textContent = me.person.role;
133
- if (me.person.role !== 'owner') return;
134
- if (!me.ownerActions?.allowed) {
135
- $('owner-note').hidden = false;
136
- $('owner-note').textContent = 'Managing people needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.';
137
- return;
138
- }
139
- $('people-section').hidden = false;
140
- $('invite').onsubmit = async (event) => {
141
- event.preventDefault();
142
- const form = new FormData(event.target);
143
- await act(() => flow('/api/people', { method: 'POST', body: JSON.stringify({ email: form.get('email'), role: form.get('role') }) }));
144
- event.target.reset();
145
- };
146
- await renderPeople(me.person.id);
147
- }
148
-
149
- async function act(fn) {
150
- $('people-error').textContent = '';
151
- try {
152
- await fn();
153
- } catch (error) {
154
- $('people-error').textContent = error.message;
155
- }
156
- const me = await flow('/api/me');
157
- await renderPeople(me.person.id);
158
- }
159
-
160
- async function renderPeople(selfId) {
161
- const { people } = await flow('/api/people');
162
- const rows = people.map((person) => {
163
- const tr = document.createElement('tr');
164
- const email = document.createElement('td');
165
- email.textContent = person.email + (person.invited ? ' (invited)' : '');
166
- const role = document.createElement('td');
167
- const access = document.createElement('td');
168
- const actions = document.createElement('td');
169
- if (person.id === selfId) {
170
- role.textContent = person.role;
171
- access.textContent = person.status;
172
- } else {
173
- const select = document.createElement('select');
174
- for (const value of ['owner', 'admin', 'staff']) select.add(new Option(value, value, false, value === person.role));
175
- select.onchange = () => act(() => flow(`/api/people/${person.id}`, { method: 'PATCH', body: JSON.stringify({ role: select.value }) }));
176
- role.append(select);
177
- const toggle = document.createElement('button');
178
- toggle.type = 'button';
179
- toggle.textContent = person.status === 'active' ? 'Turn off' : person.status === 'pending' ? 'Approve' : 'Turn on';
180
- toggle.onclick = () => act(() => flow(`/api/people/${person.id}`, { method: 'PATCH', body: JSON.stringify({ status: person.status === 'active' ? 'disabled' : 'active' }) }));
181
- access.append(`${person.status} `, toggle);
182
- const remove = document.createElement('button');
183
- remove.type = 'button';
184
- remove.textContent = 'Remove';
185
- remove.onclick = () => { if (confirm(`Remove ${person.email}?`)) act(() => flow(`/api/people/${person.id}`, { method: 'DELETE' })); };
186
- actions.append(remove);
187
- }
188
- tr.append(email, role, access, actions);
189
- return tr;
190
- });
191
- $('people').replaceChildren(...rows);
192
- }
193
- </script>
194
- </body>
195
- </html>