add passkey support
ci/woodpecker/push/e2e Pipeline failed
ci/woodpecker/push/fmt Pipeline was successful
ci/woodpecker/push/test Pipeline was successful

This commit is contained in:
2026-08-02 16:46:52 -04:00
parent 04c9724c76
commit fdd3e8c5da
18 changed files with 1073 additions and 28 deletions
+38
View File
@@ -0,0 +1,38 @@
function b64ToBytes(b64) {
const bin = atob(b64.replace(/-/g, "+").replace(/_/g, "/"));
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
document.getElementById("passkey-login").addEventListener("click", async () => {
const email = document.getElementById("email").value;
if (!email) {
alert("Enter your email first.");
return;
}
const start = await fetch("/auth/passkey/login/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!start.ok) {
alert("No passkey found for that email.");
return;
}
const options = await start.json();
const pk = options.publicKey;
pk.challenge = b64ToBytes(pk.challenge);
if (pk.allowCredentials) {
pk.allowCredentials.forEach((c) => (c.id = b64ToBytes(c.id)));
}
const credential = await navigator.credentials.get(options);
const finish = await fetch("/auth/passkey/login/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response: credential }),
});
if (finish.ok) {
window.location.href = "/lists";
}
});
+32
View File
@@ -0,0 +1,32 @@
function b64ToBytes(b64) {
const bin = atob(b64.replace(/-/g, "+").replace(/_/g, "/"));
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
document.getElementById("add-passkey").addEventListener("click", async () => {
const csrf = document.getElementById("add-passkey").dataset.csrf;
const start = await fetch("/auth/passkey/register/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ csrf }),
});
const options = await start.json();
const pk = options.publicKey;
pk.challenge = b64ToBytes(pk.challenge);
pk.user.id = b64ToBytes(pk.user.id);
if (pk.excludeCredentials) {
pk.excludeCredentials.forEach((c) => (c.id = b64ToBytes(c.id)));
}
const credential = await navigator.credentials.create(options);
const response = { csrf, response: credential };
const finish = await fetch("/auth/passkey/register/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(response),
});
if (finish.ok) {
window.location.href = "/account";
}
});
+8 -1
View File
@@ -51,7 +51,8 @@ a { color: inherit; }
}
.site-nav a:hover { color: var(--ink); background: #eef2ea; }
.account-nav { display: flex; align-items: center; gap: 16px; color: var(--muted); font-size: .9rem; }
.user-name { color: var(--ink); font-weight: 700; }
.user-name { color: var(--ink); font-weight: 700; text-decoration: none; }
.user-name:hover { color: var(--deep-sage); }
.text-button { border: 0; padding: 0; color: var(--deep-sage); background: transparent; cursor: pointer; font-weight: 700; }
.site-main { width: min(1120px, calc(100% - 40px)); margin: 30px auto 80px; }
@@ -96,6 +97,12 @@ textarea:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85,
.auth-card { width: min(100%, 480px); margin: 7vh auto 0; padding: clamp(27px, 6vw, 54px); border: 1px solid var(--line); border-radius: 28px; background: rgba(255, 253, 248, .9); box-shadow: var(--shadow); }
.auth-card .button { margin-top: 11px; }
.auth-divider { display: flex; align-items: center; gap: 12px; margin: 20px 0 4px; color: var(--muted); font-size: .8rem; }
.auth-divider::before, .auth-divider::after { content: ""; flex: 1; height: 1px; background: var(--line); }
.passkey-list { display: grid; gap: 8px; margin-bottom: 16px; }
.passkey-row { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 14px; background: #fff; }
.passkey-row .item-copy { flex: 1; }
.passkey-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-switch { margin: 25px 0 0; color: var(--muted); font-size: .9rem; text-align: center; }
.auth-switch a { color: var(--deep-sage); font-weight: 800; }
.alert { margin-bottom: 18px; padding: 12px 14px; border-radius: 12px; font-size: .9rem; }