Skip to content

Commit f2a8495

Browse files
khalidsheetclaude
andcommitted
feat(auth): v0.11.4 — built-in HTML pages for /auth/reset, /auth/verify, /auth/otp
The email-link auth flows (password reset, email verification and OTP / magic-link sign-in) build links of the form `{app.url}/auth/{kind}?token=...&collection=...`. Until now, vaultbase did not serve anything at those paths, so when `app.url` pointed at a vaultbase host without a separate frontend, the link landed on a 404. Ship a small auth-pages plugin that mounts at the root and serves a self-contained HTML page for each of the three flows. Pages are inlined (no external assets, no JS framework) and POST to the existing JSON API: - GET /auth/reset → form, POSTs /api/v1/auth/:collection/confirm-password-reset - GET /auth/verify → auto-POSTs /api/v1/auth/:collection/verify-email - GET /auth/otp → auto-POSTs /api/v1/auth/:collection/otp/auth, displays the JWT and stores it under localStorage["vaultbase_user_token"] Each page includes a password-reveal toggle, password-match / length validation, error messages from the API and a noindex meta tag. Cache disabled to avoid stale pages between issues. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7339b23 commit f2a8495

5 files changed

Lines changed: 361 additions & 3 deletions

File tree

admin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "vaultbase-admin",
33
"private": true,
4-
"version": "0.11.3",
4+
"version": "0.11.4",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vaultbase",
3-
"version": "0.11.3",
3+
"version": "0.11.4",
44
"type": "module",
55
"scripts": {
66
"dev": "bun --watch src/index.ts",

src/admin/auth-pages.ts

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
import Elysia from "elysia";
2+
3+
/**
4+
* Built-in HTML pages for the email-link auth flows: password reset, email
5+
* verification and OTP / magic-link login. The link emails point at
6+
* `{app.url}/auth/{kind}?token=...&collection=...`. Without a frontend at
7+
* `app.url`, these tokens would have nowhere to land — so vaultbase ships
8+
* minimal self-contained pages that POST to the existing JSON API.
9+
*
10+
* Each page is a single inlined HTML document (no external assets, no JS
11+
* framework) so it works whether vaultbase is the host or behind a custom
12+
* domain.
13+
*/
14+
15+
const COMMON_HEAD = /* html */ `
16+
<meta charset="utf-8" />
17+
<meta name="viewport" content="width=device-width, initial-scale=1" />
18+
<meta name="robots" content="noindex" />
19+
<style>
20+
:root {
21+
--bg: #0b0d12;
22+
--card: #14171f;
23+
--fg: #e7eaf0;
24+
--muted: #8b93a7;
25+
--border: #232735;
26+
--accent: #a3e635;
27+
--danger: #f87171;
28+
--success: #34d399;
29+
}
30+
* { box-sizing: border-box; }
31+
html, body { height: 100%; }
32+
body {
33+
margin: 0;
34+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
35+
background: var(--bg);
36+
color: var(--fg);
37+
display: grid;
38+
place-items: center;
39+
padding: 24px;
40+
}
41+
.card {
42+
width: 100%;
43+
max-width: 400px;
44+
background: var(--card);
45+
border: 1px solid var(--border);
46+
border-radius: 12px;
47+
padding: 28px;
48+
box-shadow: 0 1px 0 rgba(255,255,255,0.03), 0 12px 40px rgba(0,0,0,0.4);
49+
}
50+
.brand {
51+
display: flex; align-items: center; gap: 8px;
52+
font-family: ui-monospace, "SF Mono", Menlo, monospace;
53+
font-size: 13px; color: var(--muted);
54+
margin-bottom: 18px;
55+
}
56+
.brand .dot {
57+
width: 8px; height: 8px; border-radius: 50%;
58+
background: var(--accent);
59+
}
60+
h1 {
61+
font-size: 18px; font-weight: 600; margin: 0 0 6px;
62+
}
63+
p.sub {
64+
margin: 0 0 18px; color: var(--muted); font-size: 13px; line-height: 1.5;
65+
}
66+
label {
67+
display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px;
68+
text-transform: uppercase; letter-spacing: 0.04em;
69+
}
70+
.field { margin-bottom: 14px; position: relative; }
71+
input[type="password"], input[type="text"] {
72+
width: 100%; padding: 9px 36px 9px 11px;
73+
background: rgba(255,255,255,0.03);
74+
color: var(--fg);
75+
border: 1px solid var(--border);
76+
border-radius: 6px; font-size: 13px;
77+
font-family: inherit;
78+
outline: none;
79+
transition: border-color 120ms;
80+
}
81+
input:focus { border-color: var(--accent); }
82+
.reveal {
83+
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
84+
background: transparent; border: 0; color: var(--muted); cursor: pointer;
85+
padding: 6px; display: flex; align-items: center;
86+
}
87+
.reveal:hover { color: var(--fg); }
88+
button.primary {
89+
width: 100%; padding: 10px 14px;
90+
background: var(--accent); color: #000;
91+
border: 0; border-radius: 6px; font-weight: 600; font-size: 13px;
92+
cursor: pointer;
93+
display: flex; align-items: center; justify-content: center; gap: 6px;
94+
}
95+
button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
96+
button.primary:hover:not(:disabled) { filter: brightness(1.05); }
97+
.msg { font-size: 12px; padding: 9px 11px; border-radius: 6px; margin-bottom: 14px; line-height: 1.4; }
98+
.msg.error { background: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.3); color: var(--danger); }
99+
.msg.success { background: rgba(52,211,153,0.1); border: 1px solid rgba(52,211,153,0.3); color: var(--success); }
100+
.center { text-align: center; }
101+
.spinner {
102+
width: 14px; height: 14px;
103+
border: 2px solid rgba(0,0,0,0.2); border-top-color: #000;
104+
border-radius: 50%; animation: spin 600ms linear infinite;
105+
}
106+
@keyframes spin { to { transform: rotate(360deg); } }
107+
.footer { font-size: 11px; color: var(--muted); margin-top: 18px; text-align: center; }
108+
</style>
109+
`;
110+
111+
const REVEAL_SVG_EYE =
112+
`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>`;
113+
const REVEAL_SVG_EYE_OFF =
114+
`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19c-7 0-10-7-10-7a19.77 19.77 0 0 1 4.22-5.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 10 7 10 7a19.86 19.86 0 0 1-3.17 4.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`;
115+
116+
function escapeHtml(s: string): string {
117+
return s
118+
.replace(/&/g, "&amp;")
119+
.replace(/</g, "&lt;")
120+
.replace(/>/g, "&gt;")
121+
.replace(/"/g, "&quot;")
122+
.replace(/'/g, "&#39;");
123+
}
124+
125+
function pageShell(title: string, body: string): string {
126+
return `<!doctype html>
127+
<html lang="en">
128+
<head>
129+
${COMMON_HEAD}
130+
<title>${escapeHtml(title)}</title>
131+
</head>
132+
<body>
133+
<div class="card">
134+
<div class="brand"><span class="dot"></span><span>vaultbase</span></div>
135+
${body}
136+
<div class="footer">vaultbase &middot; self-hosted backend</div>
137+
</div>
138+
</body>
139+
</html>`;
140+
}
141+
142+
function resetPage(token: string, collection: string): string {
143+
const body = /* html */ `
144+
<h1>Reset your password</h1>
145+
<p class="sub">Choose a new password for your account. Both fields must match.</p>
146+
<div id="msg"></div>
147+
<form id="form" autocomplete="off">
148+
<div class="field">
149+
<label for="pw">New password</label>
150+
<input id="pw" type="password" autocomplete="new-password" required minlength="8" />
151+
<button type="button" class="reveal" data-target="pw" aria-label="Show password">${REVEAL_SVG_EYE}</button>
152+
</div>
153+
<div class="field">
154+
<label for="pw2">Confirm password</label>
155+
<input id="pw2" type="password" autocomplete="new-password" required minlength="8" />
156+
<button type="button" class="reveal" data-target="pw2" aria-label="Show password">${REVEAL_SVG_EYE}</button>
157+
</div>
158+
<button type="submit" id="submit" class="primary">Reset password</button>
159+
</form>
160+
<script>
161+
(function () {
162+
var token = ${JSON.stringify(token)};
163+
var collection = ${JSON.stringify(collection)};
164+
var form = document.getElementById('form');
165+
var msg = document.getElementById('msg');
166+
var btn = document.getElementById('submit');
167+
168+
document.querySelectorAll('.reveal').forEach(function (b) {
169+
b.addEventListener('click', function () {
170+
var input = document.getElementById(b.dataset.target);
171+
if (!input) return;
172+
var showing = input.type === 'text';
173+
input.type = showing ? 'password' : 'text';
174+
b.innerHTML = showing ? ${JSON.stringify(REVEAL_SVG_EYE)} : ${JSON.stringify(REVEAL_SVG_EYE_OFF)};
175+
});
176+
});
177+
178+
function show(kind, text) {
179+
msg.className = 'msg ' + kind;
180+
msg.textContent = text;
181+
}
182+
183+
if (!token || !collection) {
184+
show('error', 'Missing token or collection. Use the link from the email.');
185+
btn.disabled = true;
186+
return;
187+
}
188+
189+
form.addEventListener('submit', async function (e) {
190+
e.preventDefault();
191+
var pw = document.getElementById('pw').value;
192+
var pw2 = document.getElementById('pw2').value;
193+
if (pw !== pw2) { show('error', 'Passwords do not match.'); return; }
194+
if (pw.length < 8) { show('error', 'Password must be at least 8 characters.'); return; }
195+
btn.disabled = true;
196+
btn.innerHTML = '<span class="spinner"></span> Resetting...';
197+
try {
198+
var res = await fetch('/api/v1/auth/' + encodeURIComponent(collection) + '/confirm-password-reset', {
199+
method: 'POST',
200+
headers: { 'content-type': 'application/json' },
201+
body: JSON.stringify({ token: token, password: pw }),
202+
});
203+
var json = await res.json().catch(function () { return {}; });
204+
if (res.ok && json && json.data && json.data.reset) {
205+
show('success', 'Password reset. You can now sign in with your new password.');
206+
form.style.display = 'none';
207+
} else {
208+
show('error', (json && json.error) || ('Request failed (' + res.status + ').'));
209+
btn.disabled = false;
210+
btn.textContent = 'Reset password';
211+
}
212+
} catch (err) {
213+
show('error', 'Network error. Please try again.');
214+
btn.disabled = false;
215+
btn.textContent = 'Reset password';
216+
}
217+
});
218+
})();
219+
</script>
220+
`;
221+
return pageShell("Reset your password", body);
222+
}
223+
224+
function verifyPage(token: string, collection: string): string {
225+
const body = /* html */ `
226+
<h1>Verify your email</h1>
227+
<p class="sub">Confirming your email address...</p>
228+
<div id="msg" class="msg" style="display:none"></div>
229+
<div id="loading" class="center" style="margin: 18px 0;">
230+
<div class="spinner" style="border-color: rgba(255,255,255,0.1); border-top-color: var(--accent); margin: 0 auto;"></div>
231+
</div>
232+
<script>
233+
(function () {
234+
var token = ${JSON.stringify(token)};
235+
var collection = ${JSON.stringify(collection)};
236+
var msg = document.getElementById('msg');
237+
var loading = document.getElementById('loading');
238+
239+
function show(kind, text) {
240+
loading.style.display = 'none';
241+
msg.style.display = 'block';
242+
msg.className = 'msg ' + kind;
243+
msg.textContent = text;
244+
}
245+
246+
if (!token || !collection) { show('error', 'Missing token or collection. Use the link from the email.'); return; }
247+
248+
fetch('/api/v1/auth/' + encodeURIComponent(collection) + '/verify-email', {
249+
method: 'POST',
250+
headers: { 'content-type': 'application/json' },
251+
body: JSON.stringify({ token: token }),
252+
}).then(async function (res) {
253+
var json = await res.json().catch(function () { return {}; });
254+
if (res.ok && json && json.data && json.data.verified) {
255+
show('success', 'Email verified. You can close this tab and continue in the app.');
256+
} else {
257+
show('error', (json && json.error) || 'Invalid or expired link.');
258+
}
259+
}).catch(function () {
260+
show('error', 'Network error. Please try again.');
261+
});
262+
})();
263+
</script>
264+
`;
265+
return pageShell("Verify your email", body);
266+
}
267+
268+
function otpPage(token: string, collection: string): string {
269+
const body = /* html */ `
270+
<h1>Sign in</h1>
271+
<p class="sub">Authenticating with your magic link...</p>
272+
<div id="msg" class="msg" style="display:none"></div>
273+
<div id="loading" class="center" style="margin: 18px 0;">
274+
<div class="spinner" style="border-color: rgba(255,255,255,0.1); border-top-color: var(--accent); margin: 0 auto;"></div>
275+
</div>
276+
<div id="result" style="display:none">
277+
<p class="sub">You're signed in. The token below is valid for the next hour.</p>
278+
<div class="field">
279+
<label>JWT</label>
280+
<textarea id="jwt" readonly style="width:100%;min-height:96px;padding:9px 11px;background:rgba(255,255,255,0.03);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:11px;font-family:ui-monospace,monospace;resize:vertical"></textarea>
281+
</div>
282+
<button type="button" id="copy" class="primary">Copy token</button>
283+
</div>
284+
<script>
285+
(function () {
286+
var token = ${JSON.stringify(token)};
287+
var collection = ${JSON.stringify(collection)};
288+
var msg = document.getElementById('msg');
289+
var loading = document.getElementById('loading');
290+
var result = document.getElementById('result');
291+
292+
function show(kind, text) {
293+
loading.style.display = 'none';
294+
msg.style.display = 'block';
295+
msg.className = 'msg ' + kind;
296+
msg.textContent = text;
297+
}
298+
299+
if (!token || !collection) { show('error', 'Missing token or collection. Use the link from the email.'); return; }
300+
301+
fetch('/api/v1/auth/' + encodeURIComponent(collection) + '/otp/auth', {
302+
method: 'POST',
303+
headers: { 'content-type': 'application/json' },
304+
body: JSON.stringify({ token: token }),
305+
}).then(async function (res) {
306+
var json = await res.json().catch(function () { return {}; });
307+
if (res.ok && json && json.data && json.data.token) {
308+
loading.style.display = 'none';
309+
result.style.display = 'block';
310+
var ta = document.getElementById('jwt');
311+
ta.value = json.data.token;
312+
try { localStorage.setItem('vaultbase_user_token', json.data.token); } catch (_) {}
313+
document.getElementById('copy').addEventListener('click', function () {
314+
ta.select();
315+
navigator.clipboard.writeText(ta.value);
316+
var b = document.getElementById('copy');
317+
var prev = b.textContent;
318+
b.textContent = 'Copied';
319+
setTimeout(function () { b.textContent = prev; }, 1200);
320+
});
321+
} else {
322+
show('error', (json && json.error) || 'Invalid or expired link.');
323+
}
324+
}).catch(function () {
325+
show('error', 'Network error. Please try again.');
326+
});
327+
})();
328+
</script>
329+
`;
330+
return pageShell("Sign in", body);
331+
}
332+
333+
export function makeAuthPagesPlugin() {
334+
return new Elysia({ name: "auth-pages" })
335+
.get("/auth/reset", ({ query, set }) => {
336+
const token = String(query.token ?? "");
337+
const collection = String(query.collection ?? "users");
338+
set.headers["content-type"] = "text/html; charset=utf-8";
339+
set.headers["cache-control"] = "no-store";
340+
return resetPage(token, collection);
341+
})
342+
.get("/auth/verify", ({ query, set }) => {
343+
const token = String(query.token ?? "");
344+
const collection = String(query.collection ?? "users");
345+
set.headers["content-type"] = "text/html; charset=utf-8";
346+
set.headers["cache-control"] = "no-store";
347+
return verifyPage(token, collection);
348+
})
349+
.get("/auth/otp", ({ query, set }) => {
350+
const token = String(query.token ?? "");
351+
const collection = String(query.collection ?? "users");
352+
set.headers["content-type"] = "text/html; charset=utf-8";
353+
set.headers["cache-control"] = "no-store";
354+
return otpPage(token, collection);
355+
});
356+
}

src/core/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
*
55
* Bump in lockstep with `package.json` version + the git tag.
66
*/
7-
export const VAULTBASE_VERSION = "0.11.3";
7+
export const VAULTBASE_VERSION = "0.11.4";

src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { makeCollectionsPlugin } from "./api/collections.ts";
1111
import { makeRecordsPlugin } from "./api/records.ts";
1212
import { makeFilesPlugin, pruneFileTokenUses } from "./api/files.ts";
1313
import { makeAdminPlugin } from "./admin/index.ts";
14+
import { makeAuthPagesPlugin } from "./admin/auth-pages.ts";
1415
import { makeLogsPlugin } from "./api/logs.ts";
1516
import { makeAdminsPlugin } from "./api/admins.ts";
1617
import { makeBackupPlugin } from "./api/backup.ts";
@@ -223,6 +224,7 @@ export function createServer(config: Config) {
223224
.use(makeRecordsPlugin(config.jwtSecret))
224225
)
225226
.use(makeAdminPlugin())
227+
.use(makeAuthPagesPlugin())
226228
.get("/api/health", () => ({ data: { status: "ok" } }))
227229
// Cluster health probe — admin proxies / load-balancers hit this. Worker
228230
// id (if running under cluster mode) helps debug which worker answered.

0 commit comments

Comments
 (0)