diff options
Diffstat (limited to 'web/templates/settings.html')
| -rw-r--r-- | web/templates/settings.html | 114 |
1 files changed, 114 insertions, 0 deletions
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"> |
