summaryrefslogtreecommitdiff
path: root/web/templates
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-02-05 15:35:01 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-02-05 15:35:01 -1000
commit0a1001eb0bd2d1f7c0624ae1ef8ae7ccdb3447d4 (patch)
treecaf04d4f505bb12751579e2f0f1730ead7a9e2e2 /web/templates
parent1eab4d59454fa5999675d51b99e77ac6580aba95 (diff)
Add passkey (WebAuthn) authentication support
Enable passwordless login via passkeys as an alternative to password auth. Users register passkeys from Settings; the login page offers both options. WebAuthn is optional — only active when WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN env vars are set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web/templates')
-rw-r--r--web/templates/login.html90
-rw-r--r--web/templates/passkeys_list.html24
-rw-r--r--web/templates/settings.html114
3 files changed, 228 insertions, 0 deletions
diff --git a/web/templates/login.html b/web/templates/login.html
index bda6364..7d40a6b 100644
--- a/web/templates/login.html
+++ b/web/templates/login.html
@@ -58,6 +58,96 @@
Sign In
</button>
</form>
+
+ {{if .WebAuthnEnabled}}
+ <div id="passkey-section" class="mt-6 pt-6 border-t border-white/20" style="display: none;">
+ <button
+ id="passkey-login-btn"
+ type="button"
+ class="w-full bg-white/10 hover:bg-white/20 text-white font-medium py-3 px-4 rounded-lg transition-colors border border-white/20">
+ Sign in with Passkey
+ </button>
+ <p id="passkey-error" class="mt-2 text-red-300 text-sm" style="display: none;"></p>
+ </div>
+ <script>
+ (function() {
+ if (!window.PublicKeyCredential) return;
+ document.getElementById('passkey-section').style.display = 'block';
+
+ document.getElementById('passkey-login-btn').addEventListener('click', async function() {
+ const btn = this;
+ const errEl = document.getElementById('passkey-error');
+ errEl.style.display = 'none';
+ btn.disabled = true;
+ btn.textContent = 'Waiting for passkey...';
+
+ try {
+ const csrfToken = document.querySelector('input[name="csrf_token"]').value;
+ const beginResp = await fetch('/passkeys/login/begin', {
+ method: 'POST',
+ headers: {'X-CSRF-Token': csrfToken}
+ });
+ if (!beginResp.ok) throw new Error('Failed to start login');
+ const options = await beginResp.json();
+
+ options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
+ if (options.publicKey.allowCredentials) {
+ options.publicKey.allowCredentials = options.publicKey.allowCredentials.map(c => ({
+ ...c, id: base64urlToBuffer(c.id)
+ }));
+ }
+
+ const assertion = await navigator.credentials.get(options);
+
+ const body = JSON.stringify({
+ id: assertion.id,
+ rawId: bufferToBase64url(assertion.rawId),
+ type: assertion.type,
+ response: {
+ authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
+ clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
+ signature: bufferToBase64url(assertion.response.signature),
+ userHandle: assertion.response.userHandle ? bufferToBase64url(assertion.response.userHandle) : ''
+ }
+ });
+
+ const finishResp = await fetch('/passkeys/login/finish', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken},
+ body: body
+ });
+ const result = await finishResp.json();
+ if (!finishResp.ok) throw new Error(result.error || 'Login failed');
+
+ window.location.href = result.redirect || '/';
+ } catch (e) {
+ if (e.name === 'NotAllowedError') {
+ errEl.textContent = 'Passkey request was cancelled.';
+ } else {
+ errEl.textContent = e.message || 'Passkey login failed.';
+ }
+ errEl.style.display = 'block';
+ btn.disabled = false;
+ btn.textContent = 'Sign in with Passkey';
+ }
+ });
+
+ function base64urlToBuffer(base64url) {
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
+ const binary = atob(base64 + pad);
+ return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
+ }
+
+ function bufferToBase64url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+ }
+ })();
+ </script>
+ {{end}}
</div>
</div>
</body>
diff --git a/web/templates/passkeys_list.html b/web/templates/passkeys_list.html
new file mode 100644
index 0000000..4e05461
--- /dev/null
+++ b/web/templates/passkeys_list.html
@@ -0,0 +1,24 @@
+{{define "passkeys_list.html"}}
+<div class="items-list">
+ {{if .Passkeys}}
+ {{range .Passkeys}}
+ <div class="item-row" id="passkey-{{.ID}}">
+ <div class="item-name">
+ <strong>{{if .Name}}{{.Name}}{{else}}Passkey{{end}}</strong>
+ <div class="item-desc">Added {{.CreatedAt.Format "Jan 2, 2006"}}</div>
+ </div>
+ <button class="btn btn-danger btn-sm"
+ hx-delete="/passkeys/{{.ID}}"
+ hx-target="#passkey-{{.ID}}"
+ hx-swap="outerHTML"
+ hx-headers='{"X-CSRF-Token": "{{$.CSRFToken}}"}'
+ hx-confirm="Delete this passkey?">
+ Delete
+ </button>
+ </div>
+ {{end}}
+ {{else}}
+ <div class="empty-state">No passkeys registered. Register one to enable passwordless login.</div>
+ {{end}}
+</div>
+{{end}}
diff --git a/web/templates/settings.html b/web/templates/settings.html
index db84860..50569e4 100644
--- a/web/templates/settings.html
+++ b/web/templates/settings.html
@@ -204,6 +204,120 @@
</form>
</div>
+ <!-- Passkeys Section -->
+ <div class="card" id="passkeys-card">
+ <div class="card-header">
+ <div class="card-title">Passkeys</div>
+ </div>
+ <div id="passkeys-list" hx-get="/settings/passkeys" hx-trigger="load" hx-swap="innerHTML">
+ <div class="empty-state">Loading passkeys...</div>
+ </div>
+ <div class="add-form" id="passkey-register-form">
+ <input type="text" id="passkey-name" placeholder="Passkey name (e.g., MacBook Touch ID)" maxlength="100">
+ <button type="button" class="btn" id="register-passkey-btn">Register New Passkey</button>
+ </div>
+ <p id="passkey-status" style="color: var(--text-secondary); font-size: 0.85em; margin-top: 8px; display: none;"></p>
+ </div>
+ <script>
+ (function() {
+ if (!window.PublicKeyCredential) {
+ document.getElementById('passkeys-card').style.display = 'none';
+ return;
+ }
+
+ document.getElementById('register-passkey-btn').addEventListener('click', async function() {
+ const btn = this;
+ const statusEl = document.getElementById('passkey-status');
+ const nameInput = document.getElementById('passkey-name');
+ const name = nameInput.value.trim() || 'Passkey';
+
+ btn.disabled = true;
+ statusEl.style.display = 'block';
+ statusEl.style.color = 'var(--text-secondary)';
+ statusEl.textContent = 'Starting registration...';
+
+ try {
+ const csrfMeta = document.querySelector('input[name="csrf_token"]') || document.querySelector('[name="csrf_token"]');
+ let csrfToken = '';
+ // Get CSRF token from the feature toggle form's hidden fields or cookie
+ const forms = document.querySelectorAll('form');
+ for (const f of forms) {
+ const input = f.querySelector('input[name="csrf_token"]');
+ if (input) { csrfToken = input.value; break; }
+ }
+ // Fallback: fetch from HTMX headers
+ if (!csrfToken) {
+ const resp = await fetch('/settings/passkeys');
+ // Try to extract from response
+ }
+
+ const beginResp = await fetch('/passkeys/register/begin', {
+ method: 'POST',
+ headers: {'X-CSRF-Token': csrfToken}
+ });
+ if (!beginResp.ok) throw new Error('Failed to start registration');
+ const options = await beginResp.json();
+
+ options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
+ options.publicKey.user.id = base64urlToBuffer(options.publicKey.user.id);
+ if (options.publicKey.excludeCredentials) {
+ options.publicKey.excludeCredentials = options.publicKey.excludeCredentials.map(c => ({
+ ...c, id: base64urlToBuffer(c.id)
+ }));
+ }
+
+ statusEl.textContent = 'Waiting for passkey...';
+ const credential = await navigator.credentials.create(options);
+
+ const body = JSON.stringify({
+ id: credential.id,
+ rawId: bufferToBase64url(credential.rawId),
+ type: credential.type,
+ response: {
+ attestationObject: bufferToBase64url(credential.response.attestationObject),
+ clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
+ }
+ });
+
+ const finishResp = await fetch('/passkeys/register/finish?name=' + encodeURIComponent(name), {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken},
+ body: body
+ });
+ if (!finishResp.ok) throw new Error('Registration failed');
+
+ statusEl.style.color = 'var(--success)';
+ statusEl.textContent = 'Passkey registered successfully!';
+ nameInput.value = '';
+ // Reload passkeys list
+ htmx.trigger('#passkeys-list', 'load');
+ } catch (e) {
+ statusEl.style.color = 'var(--danger)';
+ if (e.name === 'NotAllowedError') {
+ statusEl.textContent = 'Registration was cancelled.';
+ } else {
+ statusEl.textContent = e.message || 'Registration failed.';
+ }
+ }
+ btn.disabled = false;
+ });
+
+ function base64urlToBuffer(base64url) {
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
+ const binary = atob(base64 + pad);
+ return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
+ }
+
+ function bufferToBase64url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+ }
+ })();
+ </script>
+
<!-- Source Configuration Section -->
<h2>Data Sources</h2>
<form hx-post="/settings/sync" hx-swap="outerHTML" hx-target="#sources-container" hx-indicator=".sync-indicator">