<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — apm</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 400px; margin: 80px auto; padding: 0 16px; }
h1 { font-size: 1.5rem; }
label { display: block; margin-top: 16px; font-weight: 500; }
input { width: 100%; box-sizing: border-box; padding: 8px; margin-top: 4px; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; }
button { margin-top: 24px; width: 100%; padding: 10px; font-size: 1rem; background: #1a1a1a; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
#status { margin-top: 16px; padding: 10px; border-radius: 4px; display: none; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<h1>Sign in with a passkey</h1>
<form id="form">
<label for="username">Username</label>
<input id="username" type="text" autocomplete="username" required>
<button id="btn" type="submit">Sign in with passkey</button>
</form>
<div id="status"></div>
<script>
function b64uToArr(b64u) {
const b64 = b64u.replace(/-/g, '+').replace(/_/g, '/');
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
function arrToB64u(arr) {
let bin = '';
new Uint8Array(arr).forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function setStatus(msg, ok) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = ok ? 'success' : 'error';
el.style.display = 'block';
}
document.getElementById('form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('btn');
btn.disabled = true;
const username = document.getElementById('username').value.trim();
try {
const challengeRes = await fetch('/api/auth/login/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
if (!challengeRes.ok) {
const msg = await challengeRes.text();
setStatus('Error: ' + msg, false);
btn.disabled = false;
return;
}
const { login_id, publicKey } = await challengeRes.json();
publicKey.challenge = b64uToArr(publicKey.challenge);
if (publicKey.allowCredentials) {
publicKey.allowCredentials = publicKey.allowCredentials.map(c => ({
...c, id: b64uToArr(c.id),
}));
}
const cred = await navigator.credentials.get({ publicKey });
const response = {
id: cred.id,
rawId: arrToB64u(cred.rawId),
type: cred.type,
response: {
clientDataJSON: arrToB64u(cred.response.clientDataJSON),
authenticatorData: arrToB64u(cred.response.authenticatorData),
signature: arrToB64u(cred.response.signature),
userHandle: cred.response.userHandle ? arrToB64u(cred.response.userHandle) : null,
},
};
const completeRes = await fetch('/api/auth/login/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login_id, response }),
});
if (!completeRes.ok) {
const msg = await completeRes.text();
setStatus('Error: ' + msg, false);
btn.disabled = false;
return;
}
setStatus('Signed in! Redirecting…', true);
setTimeout(() => { window.location.href = '/'; }, 2000);
} catch (err) {
setStatus('Error: ' + err.message, false);
btn.disabled = false;
}
});
</script>
</body>
</html>