diff options
Diffstat (limited to 'web/templates/login.html')
| -rw-r--r-- | web/templates/login.html | 90 |
1 files changed, 90 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> |
