Repository navigation
Expand file tree
/
Copy pathsetup.html
More file actions
255 lines (234 loc) · 9.87 KB
/
Copy pathsetup.html
File metadata and controls
255 lines (234 loc) · 9.87 KB
<
8000
div class="d-none">
Edit and raw actions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
<!doctype html>
<html lang="en" data-theme="night">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Xerj Console · setup</title>
<meta name="color-scheme" content="dark light">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Big+Shoulders+Display:wght@800;900&family=JetBrains+Mono:wght@400;500&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/tokens.css">
<link rel="stylesheet" href="assets/base.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-family='monospace' font-size='14' font-weight='900' fill='%23EEBB00'%3Ez%3C/text%3E%3C/svg%3E">
<style>
body {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
padding: 2rem;
}
.auth-card {
background: var(--bg-surface, #15171a);
border: 1px solid var(--border, #2a2d33);
border-radius: 8px; padding: 2rem;
max-width: 480px; width: 100%;
color: var(--fg, #e6e8eb);
}
.auth-card h1 {
font-family: 'Big Shoulders Display', sans-serif;
font-weight: 900; font-size: 2.25rem; letter-spacing: -0.02em;
margin: 0 0 .5rem; color: #EEBB00;
}
.auth-card p.lead { color: var(--fg-mute, #8a8e95); margin: 0 0 1.5rem; }
.step { display: none; }
.step.active { display: block; }
label { display: block; font-weight: 500; margin: 1rem 0 .25rem; font-size: 0.875rem; }
input[type="email"], input[type="text"] {
width: 100%; padding: 0.625rem 0.75rem;
background: var(--bg, #0c0d10);
border: 1px solid var(--border, #2a2d33);
border-radius: 4px; color: var(--fg, #e6e8eb);
font-family: inherit; font-size: 0.9rem;
}
input:focus { outline: 2px solid #EEBB00; outline-offset: 1px; }
button {
background: #EEBB00; color: #0c0d10;
border: none; padding: 0.75rem 1.5rem;
font-weight: 600; border-radius: 4px;
cursor: pointer; margin-top: 1.5rem;
font-family: inherit; font-size: 0.9rem;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.secondary { background: transparent; color: var(--fg-mute); border: 1px solid var(--border); }
.err {
background: #3a1a1a; color: #ff8a8a;
padding: 0.75rem 1rem; border-radius: 4px;
margin: 1rem 0; font-size: 0.875rem;
font-family: 'JetBrains Mono', monospace;
}
.ok {
background: #1a3a1f; color: #8aff9c;
padding: 0.75rem 1rem; border-radius: 4px;
margin: 1rem 0; font-size: 0.875rem;
}
.role {
display: inline-block;
background: #EEBB00; color: #0c0d10;
padding: 0.125rem 0.5rem;
border-radius: 3px; font-size: 0.75rem;
font-weight: 700; letter-spacing: 0.05em;
text-transform: uppercase;
}
</style>
</head>
<body>
<div class="auth-card">
<h1>XERJ CONSOLE</h1>
<p class="lead">First-launch setup — claim this server by enrolling a passkey.</p>
<div id="err" class="err" hidden></div>
<div id="ok" class="ok" hidden></div>
<!-- Step 1: collect email + display name (token already in URL fragment) -->
<div id="step-info" class="step active">
<p>You're claiming the role <span class="role" id="role-pill">owner</span> on this server.</p>
<label for="email">Email</label>
<input id="email" type="email" placeholder="you@company.com" required autocomplete="email">
<label for="display">Display name</label>
<input id="display" type="text" placeholder="Your name" autocomplete="name">
<label for="pkname">Passkey name (visible to you only)</label>
<input id="pkname" type="text" value="My main key" autocomplete="off">
<button id="btn-enrol">Enrol passkey</button>
</div>
<!-- Step 2: in-flight WebAuthn ceremony -->
<div id="step-touch" class="step">
<p style="font-size: 1.05rem;">Touch your security key, biometric sensor, or platform passkey now…</p>
<p class="lead" style="margin-top: 1rem;">If your browser didn't prompt automatically, click <em>Try again</em>.</p>
<button id="btn-retry" class="secondary">Try again</button>
</div>
<!-- Step 3: done -->
<div id="step-done" class="step">
<p style="font-size: 1.05rem; color: #8aff9c;">✓ Passkey enrolled. Logging you in…</p>
</div>
</div>
<script type="module">
import { redeemMagic, beginEnrol, finishEnrol, b64uToBuf, bufToB64u } from './src/xerj-console-auth.js';
const $err = document.getElementById('err');
const $ok = document.getElementById('ok');
const $rolePill = document.getElementById('role-pill');
const $email = document.getElementById('email');
const $display = document.getElementById('display');
const $pkname = document.getElementById('pkname');
const $btnEnrol = document.getElementById('btn-enrol');
const $btnRetry = document.getElementById('btn-retry');
function showStep(id) {
for (const el of document.querySelectorAll('.step')) el.classList.remove('active');
document.getElementById(id).classList.add('active');
}
function showError(msg) {
$err.textContent = msg; $err.hidden = false;
$ok.hidden = true;
}
function showOk(msg) {
$ok.textContent = msg; $ok.hidden = false;
$err.hidden = true;
}
// Pull token from URL fragment (#token=...). Hide it from history immediately.
function tokenFromHash() {
const frag = window.location.hash.replace(/^#/, '');
const params = new URLSearchParams(frag);
return params.get('token');
}
function clearHash() {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
const token = tokenFromHash();
// Optional post-setup destination (`&next=…` in the fragment) — e.g. the
// second-brain view `xerj brain` printed. Restricted to an in-console hash
// route: anything with a scheme, `//`, or characters outside a route is
// discarded, so a magic link can never become an open redirect. Captured
// now because clearHash() wipes the fragment on redeem.
const nextRoute = (() => {
const frag = window.location.hash.replace(/^#/, '');
const n = new URLSearchParams(frag).get('next') || '';
if (!n || n.includes(':') || n.includes('//') || !/^[A-Za-z0-9/?&=_-]+$/.test(n)) return '';
return '#/' + n.replace(/^[#/]+/, '');
})();
if (!token) {
showError('No magic-link token in this URL. The node printed a one-time setup link when it started — open that URL (it contains #token=). A restart prints a new link while the owner account is still unclaimed. An existing owner signs in at /_xerj-console/login.');
$btnEnrol.disabled = true;
}
// Eagerly redeem the token on page load — it's single-use; we want
// to consume it before the user does anything else.
let enrollmentSession = null;
(async () => {
if (!token) return;
try {
enrollmentSession = await redeemMagic(token);
clearHash();
if (enrollmentSession.role) $rolePill.textContent = enrollmentSession.role;
if (enrollmentSession.email) $email.value = enrollmentSession.email;
} catch (e) {
const msg = String((e && e.message) || e);
// Links are single-use. The node's bootstrap link lasts 30 minutes and
// a restart prints a fresh one only while nobody has claimed the owner
// account. An admin's invite (also served by this page) lasts 72 hours
// and only that admin can issue a new one. An existing account signs in
// at the login page. Other redeem failures keep the raw reason.
if (/invalid or expired/i.test(msg)) {
showError(
'This link is invalid or expired. Setup links work once. ' +
'If it was an invite, ask the admin who sent it for a new one (invites last 72 hours). ' +
'If it was the setup link the node printed (it lasts 30 minutes) and nobody has claimed the owner account, ' +
'restart the node: a new link is printed on boot. ' +
'If you already have an account, sign in at /_xerj-console/login.'
);
} else {
showError('Could not redeem link: ' + msg);
}
$btnEnrol.disabled = true;
}
})();
async function enrol() {
$err.hidden = true;
if (!enrollmentSession) {
showError('Enrollment session missing — reload from the magic link.');
return;
}
const email = $email.value.trim();
const display = $display.value.trim() || email;
const pkname = $pkname.value.trim() || 'default';
if (!email) {
showError('Email is required.');
return;
}
showStep('step-touch');
try {
const begin = await beginEnrol(enrollmentSession.enrollment_session_id, email, display);
// Decode WebAuthn binary fields from base64url to ArrayBuffer.
const opts = JSON.parse(JSON.stringify(begin.creation_options.publicKey));
opts.challenge = b64uToBuf(opts.challenge);
opts.user.id = b64uToBuf(opts.user.id);
if (opts.excludeCredentials) {
for (const c of opts.excludeCredentials) c.id = b64uToBuf(c.id);
}
const cred = await navigator.credentials.create({ publicKey: opts });
if (!cred) throw new Error('Browser returned no credential.');
// Re-encode the credential response for transport.
const att = cred.response;
const credPayload = {
id: cred.id,
rawId: bufToB64u(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufToB64u(att.clientDataJSON),
attestationObject: bufToB64u(att.attestationObject),
},
extensions: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {},
};
await finishEnrol(
enrollmentSession.enrollment_session_id,
begin.challenge_id,
credPayload,
pkname, email, display
);
showStep('step-done');
showOk('Enrolled. Redirecting…');
setTimeout(() => { window.location.href = '/_xerj-console/' + nextRoute; }, 800);
} catch (e) {
showStep('step-info');
showError(e.message || String(e));
}
}
$btnEnrol.addEventListener('click', enrol);
$btnRetry.addEventListener('click', () => { showStep('step-info'); });
</script>
</body>
</html>