🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add passkey support

+1001 -13
+17 -4
.env.example
··· 3 3 # See README for setup instructions 4 4 WHISPER_SERVICE_URL=http://localhost:8000 5 5 6 - # Gemini API Key (optional) 7 - # For cleaning transcripts - removes tags and improves grammar 8 - # Get your key from: https://aistudio.google.com/app/apikey 9 - # GEMINI_API_KEY=your_api_key_here 6 + # LLM API Configuration (Required for VTT cleaning) 7 + # Configure your LLM service endpoint and credentials 8 + LLM_API_KEY=your_api_key_here 9 + LLM_API_BASE_URL=https://api.openai.com/v1 10 + LLM_MODEL=gpt-4o-mini 11 + 12 + # WebAuthn/Passkey Configuration (Production Only) 13 + # In development, these default to localhost values 14 + # Only needed when deploying to production 15 + 16 + # Relying Party ID - your domain name 17 + # Must match the domain where your app is hosted 18 + # RP_ID=thistle.app 19 + 20 + # Origin - full URL of your app 21 + # Must match exactly where users access your app 22 + # ORIGIN=https://thistle.app
+52
bun.lock
··· 1 1 { 2 2 "lockfileVersion": 1, 3 + "configVersion": 0, 3 4 "workspaces": { 4 5 "": { 5 6 "name": "inky", 6 7 "dependencies": { 8 + "@simplewebauthn/browser": "^13.2.2", 9 + "@simplewebauthn/server": "^13.2.2", 7 10 "eventsource-client": "^1.2.0", 8 11 "lit": "^3.3.1", 9 12 "ua-parser-js": "^2.0.6", 10 13 }, 11 14 "devDependencies": { 12 15 "@biomejs/biome": "^2.3.2", 16 + "@simplewebauthn/types": "^12.0.0", 13 17 "@types/bun": "latest", 14 18 }, 15 19 "peerDependencies": { ··· 36 40 37 41 "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-6Ee9P26DTb4D8sN9nXxgbi9Dw5vSOfH98M7UlmkjKB2vtUbrRqCbZiNfryGiwnPIpd6YUoTl7rLVD2/x1CyEHQ=="], 38 42 43 + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], 44 + 45 + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], 46 + 39 47 "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.4.0", "", {}, "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw=="], 40 48 41 49 "@lit/reactive-element": ["@lit/reactive-element@2.1.1", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0" } }, "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg=="], 42 50 51 + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A=="], 52 + 53 + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "@peculiar/asn1-x509-attr": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A=="], 54 + 55 + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ=="], 56 + 57 + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg=="], 58 + 59 + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.5.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.5.0", "@peculiar/asn1-pkcs8": "^2.5.0", "@peculiar/asn1-rsa": "^2.5.0", "@peculiar/asn1-schema": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug=="], 60 + 61 + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw=="], 62 + 63 + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.5.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.5.0", "@peculiar/asn1-pfx": "^2.5.0", "@peculiar/asn1-pkcs8": "^2.5.0", "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "@peculiar/asn1-x509-attr": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A=="], 64 + 65 + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q=="], 66 + 67 + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.5.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ=="], 68 + 69 + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ=="], 70 + 71 + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A=="], 72 + 73 + "@peculiar/x509": ["@peculiar/x509@1.14.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.5.0", "@peculiar/asn1-csr": "^2.5.0", "@peculiar/asn1-ecc": "^2.5.0", "@peculiar/asn1-pkcs9": "^2.5.0", "@peculiar/asn1-rsa": "^2.5.0", "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg=="], 74 + 75 + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="], 76 + 77 + "@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="], 78 + 79 + "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], 80 + 43 81 "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], 44 82 45 83 "@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="], ··· 48 86 49 87 "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], 50 88 89 + "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], 90 + 51 91 "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], 52 92 53 93 "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], ··· 66 106 67 107 "lit-html": ["lit-html@3.3.1", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA=="], 68 108 109 + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], 110 + 111 + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], 112 + 113 + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], 114 + 115 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 116 + 117 + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], 118 + 69 119 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 70 120 71 121 "ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="], ··· 73 123 "ua-parser-js": ["ua-parser-js@2.0.6", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg=="], 74 124 75 125 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 126 + 127 + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], 76 128 } 77 129 }
+3
package.json
··· 9 9 }, 10 10 "devDependencies": { 11 11 "@biomejs/biome": "^2.3.2", 12 + "@simplewebauthn/types": "^12.0.0", 12 13 "@types/bun": "latest" 13 14 }, 14 15 "peerDependencies": { 15 16 "typescript": "^5" 16 17 }, 17 18 "dependencies": { 19 + "@simplewebauthn/browser": "^13.2.2", 20 + "@simplewebauthn/server": "^13.2.2", 18 21 "eventsource-client": "^1.2.0", 19 22 "lit": "^3.3.1", 20 23 "ua-parser-js": "^2.0.6"
+80
src/components/auth.ts
··· 1 1 import { css, html, LitElement } from "lit"; 2 2 import { customElement, state } from "lit/decorators.js"; 3 3 import { hashPasswordClient } from "../lib/client-auth"; 4 + import { 5 + authenticateWithPasskey, 6 + isPasskeySupported, 7 + } from "../lib/client-passkey"; 4 8 import type { PasswordStrength } from "./password-strength"; 5 9 import "./password-strength"; 6 10 import type { PasswordStrengthResult } from "./password-strength"; ··· 24 28 @state() isSubmitting = false; 25 29 @state() needsRegistration = false; 26 30 @state() passwordStrength: PasswordStrengthResult | null = null; 31 + @state() passkeySupported = false; 27 32 28 33 static override styles = css` 29 34 :host { ··· 264 269 font-size: 0.875rem; 265 270 margin: 0; 266 271 } 272 + 273 + .divider { 274 + display: flex; 275 + align-items: center; 276 + text-align: center; 277 + margin: 1.5rem 0; 278 + color: var(--secondary); 279 + font-size: 0.875rem; 280 + } 281 + 282 + .divider::before, 283 + .divider::after { 284 + content: ""; 285 + flex: 1; 286 + border-bottom: 1px solid var(--secondary); 287 + } 288 + 289 + .divider::before { 290 + margin-right: 0.5rem; 291 + } 292 + 293 + .divider::after { 294 + margin-left: 0.5rem; 295 + } 296 + 297 + .btn-passkey { 298 + background: transparent; 299 + color: var(--primary); 300 + border-color: var(--primary); 301 + width: 100%; 302 + margin-bottom: 0; 303 + } 304 + 305 + .btn-passkey:hover:not(:disabled) { 306 + background: var(--primary); 307 + color: white; 308 + } 267 309 `; 268 310 269 311 override async connectedCallback() { 270 312 super.connectedCallback(); 313 + this.passkeySupported = isPasskeySupported(); 271 314 await this.checkAuth(); 272 315 } 273 316 ··· 418 461 this.passwordStrength = e.detail; 419 462 } 420 463 464 + private async handlePasskeyLogin() { 465 + this.error = ""; 466 + this.isSubmitting = true; 467 + 468 + try { 469 + const result = await authenticateWithPasskey(this.email || undefined); 470 + 471 + if (!result.success) { 472 + this.error = result.error || "Passkey authentication failed"; 473 + return; 474 + } 475 + 476 + // Success - reload to get user info 477 + await this.checkAuth(); 478 + this.closeModal(); 479 + window.dispatchEvent(new CustomEvent("auth-changed")); 480 + } finally { 481 + this.isSubmitting = false; 482 + } 483 + } 484 + 421 485 override render() { 422 486 if (this.loading) { 423 487 return html`<div class="loading">Loading...</div>`; ··· 483 547 ` 484 548 : "" 485 549 } 550 + 551 + ${ 552 + !this.needsRegistration && this.passkeySupported 553 + ? html` 554 + <button 555 + type="button" 556 + class="btn-passkey" 557 + @click=${this.handlePasskeyLogin} 558 + ?disabled=${this.isSubmitting} 559 + > 560 + 🔑 ${this.isSubmitting ? "Loading..." : "Sign in with Passkey"} 561 + </button> 562 + <div class="divider">or sign in with password</div> 563 + ` 564 + : "" 565 + } 486 566 487 567 <form @submit=${this.handleSubmit}> 488 568 <div class="form-group">
+151 -2
src/components/user-settings.ts
··· 2 2 import { customElement, state } from "lit/decorators.js"; 3 3 import { UAParser } from "ua-parser-js"; 4 4 import { hashPasswordClient } from "../lib/client-auth"; 5 + import { 6 + isPasskeySupported, 7 + registerPasskey, 8 + } from "../lib/client-passkey"; 5 9 6 10 interface User { 7 11 email: string; ··· 19 23 is_current: boolean; 20 24 } 21 25 22 - type SettingsPage = "account" | "sessions" | "danger"; 26 + interface Passkey { 27 + id: string; 28 + name: string | null; 29 + created_at: number; 30 + last_used_at: number | null; 31 + } 32 + 33 + type SettingsPage = "account" | "sessions" | "passkeys" | "danger"; 23 34 24 35 @customElement("user-settings") 25 36 export class UserSettings extends LitElement { 26 37 @state() user: User | null = null; 27 38 @state() sessions: Session[] = []; 39 + @state() passkeys: Passkey[] = []; 28 40 @state() loading = true; 29 41 @state() loadingSessions = true; 42 + @state() loadingPasskeys = true; 30 43 @state() error = ""; 31 44 @state() showDeleteConfirm = false; 32 45 @state() currentPage: SettingsPage = "account"; ··· 36 49 @state() newPassword = ""; 37 50 @state() newName = ""; 38 51 @state() newAvatar = ""; 52 + @state() passkeySupported = false; 53 + @state() addingPasskey = false; 39 54 40 55 static override styles = css` 41 56 :host { ··· 217 232 position: relative; 218 233 } 219 234 220 - 235 + .field-description { 236 + font-size: 0.875rem; 237 + color: var(--secondary); 238 + margin: 0.5rem 0; 239 + } 221 240 222 241 .danger-section { 223 242 border-color: var(--accent); ··· 403 422 404 423 override async connectedCallback() { 405 424 super.connectedCallback(); 425 + this.passkeySupported = isPasskeySupported(); 406 426 await this.loadUser(); 407 427 await this.loadSessions(); 428 + if (this.passkeySupported) { 429 + await this.loadPasskeys(); 430 + } 408 431 } 409 432 410 433 async loadUser() { ··· 435 458 } 436 459 } 437 460 461 + async loadPasskeys() { 462 + try { 463 + const response = await fetch("/api/passkeys"); 464 + 465 + if (response.ok) { 466 + const data = await response.json(); 467 + this.passkeys = data.passkeys; 468 + } 469 + } finally { 470 + this.loadingPasskeys = false; 471 + } 472 + } 473 + 474 + async handleAddPasskey() { 475 + this.addingPasskey = true; 476 + this.error = ""; 477 + 478 + try { 479 + const name = prompt("Name this passkey (optional):"); 480 + if (name === null) { 481 + // User cancelled 482 + return; 483 + } 484 + 485 + const result = await registerPasskey(name || undefined); 486 + 487 + if (!result.success) { 488 + this.error = result.error || "Failed to register passkey"; 489 + return; 490 + } 491 + 492 + // Reload passkeys 493 + await this.loadPasskeys(); 494 + } finally { 495 + this.addingPasskey = false; 496 + } 497 + } 498 + 499 + async handleDeletePasskey(passkeyId: string) { 500 + if (!confirm("Are you sure you want to delete this passkey?")) { 501 + return; 502 + } 503 + 504 + try { 505 + const response = await fetch(`/api/passkeys/${passkeyId}`, { 506 + method: "DELETE", 507 + }); 508 + 509 + if (!response.ok) { 510 + const error = await response.json(); 511 + this.error = error.error || "Failed to delete passkey"; 512 + return; 513 + } 514 + 515 + // Reload passkeys 516 + await this.loadPasskeys(); 517 + } catch { 518 + this.error = "Failed to delete passkey"; 519 + } 520 + } 521 + 438 522 async handleLogout() { 439 523 try { 440 524 await fetch("/api/auth/logout", { method: "POST" }); ··· 832 916 ` 833 917 } 834 918 </div> 919 + 920 + ${ 921 + this.passkeySupported 922 + ? html` 923 + <div class="field-group"> 924 + <label class="field-label">Passkeys</label> 925 + <p class="field-description"> 926 + Passkeys provide a more secure and convenient way to sign in without passwords. 927 + They use biometric authentication or your device's security features. 928 + </p> 929 + ${ 930 + this.loadingPasskeys 931 + ? html`<div class="field-value">Loading passkeys...</div>` 932 + : this.passkeys.length === 0 933 + ? html`<div class="field-value" style="color: var(--secondary);">No passkeys registered yet</div>` 934 + : html` 935 + <div style="display: flex; flex-direction: column; gap: 1rem; margin-top: 1rem;"> 936 + ${this.passkeys.map( 937 + (passkey) => html` 938 + <div class="session-card"> 939 + <div class="session-details"> 940 + <div class="session-row"> 941 + <span class="session-label">Name</span> 942 + <span class="session-value">${passkey.name || "Unnamed passkey"}</span> 943 + </div> 944 + <div class="session-row"> 945 + <span class="session-label">Created</span> 946 + <span class="session-value">${new Date(passkey.created_at * 1000).toLocaleDateString()}</span> 947 + </div> 948 + ${ 949 + passkey.last_used_at 950 + ? html` 951 + <div class="session-row"> 952 + <span class="session-label">Last used</span> 953 + <span class="session-value">${new Date(passkey.last_used_at * 1000).toLocaleDateString()}</span> 954 + </div> 955 + ` 956 + : "" 957 + } 958 + </div> 959 + <button 960 + class="btn btn-rejection btn-small" 961 + @click=${() => this.handleDeletePasskey(passkey.id)} 962 + style="margin-top: 0.75rem;" 963 + > 964 + Delete 965 + </button> 966 + </div> 967 + `, 968 + )} 969 + </div> 970 + ` 971 + } 972 + <button 973 + class="btn btn-affirmative" 974 + style="margin-top: 1rem;" 975 + @click=${this.handleAddPasskey} 976 + ?disabled=${this.addingPasskey} 977 + > 978 + ${this.addingPasskey ? "Adding..." : "Add Passkey"} 979 + </button> 980 + </div> 981 + ` 982 + : "" 983 + } 835 984 836 985 <div class="field-group"> 837 986 <label class="field-label">Member Since</label>
+38
src/db/schema.ts
··· 100 100 CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); 101 101 `, 102 102 }, 103 + { 104 + version: 7, 105 + name: "Add WebAuthn passkey support", 106 + sql: ` 107 + CREATE TABLE IF NOT EXISTS passkeys ( 108 + id TEXT PRIMARY KEY, 109 + user_id INTEGER NOT NULL, 110 + credential_id TEXT NOT NULL UNIQUE, 111 + public_key TEXT NOT NULL, 112 + counter INTEGER NOT NULL DEFAULT 0, 113 + transports TEXT, 114 + name TEXT, 115 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 116 + last_used_at INTEGER, 117 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 118 + ); 119 + 120 + CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id); 121 + CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id); 122 + 123 + -- Make password optional for users who only use passkeys 124 + CREATE TABLE users_new ( 125 + id INTEGER PRIMARY KEY AUTOINCREMENT, 126 + email TEXT UNIQUE NOT NULL, 127 + password_hash TEXT, 128 + name TEXT, 129 + avatar TEXT DEFAULT 'd', 130 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 131 + role TEXT NOT NULL DEFAULT 'user' 132 + ); 133 + 134 + INSERT INTO users_new SELECT * FROM users; 135 + DROP TABLE users; 136 + ALTER TABLE users_new RENAME TO users; 137 + 138 + CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); 139 + `, 140 + }, 103 141 ]; 104 142 105 143 function getCurrentVersion(): number {
+144
src/index.ts
··· 20 20 updateUserRole, 21 21 type UserRole, 22 22 } from "./lib/auth"; 23 + import { 24 + createAuthenticationOptions, 25 + createRegistrationOptions, 26 + deletePasskey, 27 + getPasskeysForUser, 28 + updatePasskeyName, 29 + verifyAndAuthenticatePasskey, 30 + verifyAndCreatePasskey, 31 + } from "./lib/passkey"; 23 32 import { handleError, ValidationErrors } from "./lib/errors"; 24 33 import { requireAdmin, requireAuth } from "./lib/middleware"; 25 34 import { enforceRateLimit } from "./lib/rate-limit"; ··· 231 240 created_at: user.created_at, 232 241 role: user.role, 233 242 }); 243 + }, 244 + }, 245 + "/api/passkeys/register/options": { 246 + POST: async (req) => { 247 + try { 248 + const user = requireAuth(req); 249 + const options = await createRegistrationOptions(user); 250 + return Response.json(options); 251 + } catch (err) { 252 + return handleError(err); 253 + } 254 + }, 255 + }, 256 + "/api/passkeys/register/verify": { 257 + POST: async (req) => { 258 + try { 259 + const user = requireAuth(req); 260 + const body = await req.json(); 261 + const { response: credentialResponse, challenge, name } = body; 262 + 263 + const passkey = await verifyAndCreatePasskey( 264 + credentialResponse, 265 + challenge, 266 + name, 267 + ); 268 + 269 + return Response.json({ 270 + success: true, 271 + passkey: { 272 + id: passkey.id, 273 + name: passkey.name, 274 + created_at: passkey.created_at, 275 + }, 276 + }); 277 + } catch (err) { 278 + return handleError(err); 279 + } 280 + }, 281 + }, 282 + "/api/passkeys/authenticate/options": { 283 + POST: async (req) => { 284 + try { 285 + const body = await req.json(); 286 + const { email } = body; 287 + 288 + const options = await createAuthenticationOptions(email); 289 + return Response.json(options); 290 + } catch (err) { 291 + return handleError(err); 292 + } 293 + }, 294 + }, 295 + "/api/passkeys/authenticate/verify": { 296 + POST: async (req) => { 297 + try { 298 + const body = await req.json(); 299 + const { response: credentialResponse, challenge } = body; 300 + 301 + const { user } = await verifyAndAuthenticatePasskey( 302 + credentialResponse, 303 + challenge, 304 + ); 305 + 306 + // Create session 307 + const ipAddress = 308 + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || 309 + req.headers.get("x-real-ip") || 310 + "unknown"; 311 + const userAgent = req.headers.get("user-agent") || "unknown"; 312 + const sessionId = createSession(user.id, ipAddress, userAgent); 313 + 314 + return Response.json( 315 + { 316 + email: user.email, 317 + name: user.name, 318 + avatar: user.avatar, 319 + created_at: user.created_at, 320 + role: user.role, 321 + }, 322 + { 323 + headers: { 324 + "Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 325 + }, 326 + }, 327 + ); 328 + } catch (err) { 329 + return handleError(err); 330 + } 331 + }, 332 + }, 333 + "/api/passkeys": { 334 + GET: async (req) => { 335 + try { 336 + const user = requireAuth(req); 337 + const passkeys = getPasskeysForUser(user.id); 338 + return Response.json({ 339 + passkeys: passkeys.map((p) => ({ 340 + id: p.id, 341 + name: p.name, 342 + created_at: p.created_at, 343 + last_used_at: p.last_used_at, 344 + })), 345 + }); 346 + } catch (err) { 347 + return handleError(err); 348 + } 349 + }, 350 + }, 351 + "/api/passkeys/:id": { 352 + PUT: async (req) => { 353 + try { 354 + const user = requireAuth(req); 355 + const body = await req.json(); 356 + const { name } = body; 357 + const passkeyId = req.params.id; 358 + 359 + if (!name) { 360 + return Response.json({ error: "Name required" }, { status: 400 }); 361 + } 362 + 363 + updatePasskeyName(passkeyId, user.id, name); 364 + return Response.json({ success: true }); 365 + } catch (err) { 366 + return handleError(err); 367 + } 368 + }, 369 + DELETE: async (req) => { 370 + try { 371 + const user = requireAuth(req); 372 + const passkeyId = req.params.id; 373 + deletePasskey(passkeyId, user.id); 374 + return Response.json({ success: true }); 375 + } catch (err) { 376 + return handleError(err); 377 + } 234 378 }, 235 379 }, 236 380 "/api/sessions": {
+153
src/lib/client-passkey.ts
··· 1 + import { 2 + startAuthentication, 3 + startRegistration, 4 + } from "@simplewebauthn/browser"; 5 + import type { 6 + PublicKeyCredentialCreationOptionsJSON, 7 + PublicKeyCredentialRequestOptionsJSON, 8 + } from "@simplewebauthn/types"; 9 + 10 + /** 11 + * Register a new passkey for the current user 12 + */ 13 + export async function registerPasskey( 14 + name?: string, 15 + ): Promise<{ success: boolean; error?: string }> { 16 + try { 17 + // Get registration options from server 18 + const optionsResponse = await fetch("/api/passkeys/register/options", { 19 + method: "POST", 20 + }); 21 + 22 + if (!optionsResponse.ok) { 23 + const error = await optionsResponse.json(); 24 + return { 25 + success: false, 26 + error: error.error || "Failed to get registration options", 27 + }; 28 + } 29 + 30 + const options: PublicKeyCredentialCreationOptionsJSON = 31 + await optionsResponse.json(); 32 + 33 + // Start browser passkey creation 34 + let credential: Awaited<ReturnType<typeof startRegistration>>; 35 + try { 36 + credential = await startRegistration({ optionsJSON: options }); 37 + } catch (err) { 38 + // User cancelled or browser doesn't support passkeys 39 + return { 40 + success: false, 41 + error: 42 + err instanceof Error 43 + ? err.message 44 + : "Passkey registration was cancelled", 45 + }; 46 + } 47 + 48 + // Verify with server 49 + const verifyResponse = await fetch("/api/passkeys/register/verify", { 50 + method: "POST", 51 + headers: { "Content-Type": "application/json" }, 52 + body: JSON.stringify({ 53 + response: credential, 54 + challenge: options.challenge, 55 + name, 56 + }), 57 + }); 58 + 59 + if (!verifyResponse.ok) { 60 + const error = await verifyResponse.json(); 61 + return { 62 + success: false, 63 + error: error.error || "Failed to verify passkey", 64 + }; 65 + } 66 + 67 + return { success: true }; 68 + } catch (err) { 69 + return { 70 + success: false, 71 + error: 72 + err instanceof Error ? err.message : "Failed to register passkey", 73 + }; 74 + } 75 + } 76 + 77 + /** 78 + * Authenticate with a passkey 79 + */ 80 + export async function authenticateWithPasskey( 81 + email?: string, 82 + ): Promise<{ success: boolean; error?: string }> { 83 + try { 84 + // Get authentication options from server 85 + const optionsResponse = await fetch("/api/passkeys/authenticate/options", { 86 + method: "POST", 87 + headers: { "Content-Type": "application/json" }, 88 + body: JSON.stringify({ email }), 89 + }); 90 + 91 + if (!optionsResponse.ok) { 92 + const error = await optionsResponse.json(); 93 + return { 94 + success: false, 95 + error: error.error || "Failed to get authentication options", 96 + }; 97 + } 98 + 99 + const options: PublicKeyCredentialRequestOptionsJSON = 100 + await optionsResponse.json(); 101 + 102 + // Start browser passkey authentication 103 + let credential: Awaited<ReturnType<typeof startAuthentication>>; 104 + try { 105 + credential = await startAuthentication({ optionsJSON: options }); 106 + } catch (err) { 107 + // User cancelled or no passkey available 108 + return { 109 + success: false, 110 + error: 111 + err instanceof Error 112 + ? err.message 113 + : "Passkey authentication was cancelled", 114 + }; 115 + } 116 + 117 + // Verify with server 118 + const verifyResponse = await fetch("/api/passkeys/authenticate/verify", { 119 + method: "POST", 120 + headers: { "Content-Type": "application/json" }, 121 + body: JSON.stringify({ 122 + response: credential, 123 + challenge: options.challenge, 124 + }), 125 + }); 126 + 127 + if (!verifyResponse.ok) { 128 + const error = await verifyResponse.json(); 129 + return { 130 + success: false, 131 + error: error.error || "Failed to verify passkey", 132 + }; 133 + } 134 + 135 + return { success: true }; 136 + } catch (err) { 137 + return { 138 + success: false, 139 + error: 140 + err instanceof Error ? err.message : "Failed to authenticate with passkey", 141 + }; 142 + } 143 + } 144 + 145 + /** 146 + * Check if passkeys are supported in this browser 147 + */ 148 + export function isPasskeySupported(): boolean { 149 + return ( 150 + window.PublicKeyCredential !== undefined && 151 + typeof window.PublicKeyCredential === "function" 152 + ); 153 + }
+356
src/lib/passkey.ts
··· 1 + import { 2 + generateAuthenticationOptions, 3 + generateRegistrationOptions, 4 + verifyAuthenticationResponse, 5 + verifyRegistrationResponse, 6 + type VerifiedAuthenticationResponse, 7 + type VerifiedRegistrationResponse, 8 + } from "@simplewebauthn/server"; 9 + import type { 10 + AuthenticationResponseJSON, 11 + RegistrationResponseJSON, 12 + } from "@simplewebauthn/types"; 13 + import db from "../db/schema"; 14 + import type { User } from "./auth"; 15 + 16 + export interface Passkey { 17 + id: string; 18 + user_id: number; 19 + credential_id: string; 20 + public_key: string; 21 + counter: number; 22 + transports: string | null; 23 + name: string | null; 24 + created_at: number; 25 + last_used_at: number | null; 26 + } 27 + 28 + export interface RegistrationChallenge { 29 + challenge: string; 30 + user_id: number; 31 + expires_at: number; 32 + } 33 + 34 + export interface AuthenticationChallenge { 35 + challenge: string; 36 + expires_at: number; 37 + } 38 + 39 + // In-memory challenge storage 40 + const registrationChallenges = new Map<string, RegistrationChallenge>(); 41 + const authenticationChallenges = new Map<string, AuthenticationChallenge>(); 42 + 43 + // Challenge TTL: 5 minutes 44 + const CHALLENGE_TTL = 5 * 60 * 1000; 45 + 46 + // Cleanup expired challenges every minute 47 + setInterval(() => { 48 + const now = Date.now(); 49 + for (const [challenge, data] of registrationChallenges.entries()) { 50 + if (data.expires_at < now) { 51 + registrationChallenges.delete(challenge); 52 + } 53 + } 54 + for (const [challenge, data] of authenticationChallenges.entries()) { 55 + if (data.expires_at < now) { 56 + authenticationChallenges.delete(challenge); 57 + } 58 + } 59 + }, 60 * 1000); 60 + 61 + /** 62 + * Get RP ID and origin based on environment 63 + */ 64 + function getRPConfig(): { rpID: string; rpName: string; origin: string } { 65 + if (process.env.NODE_ENV === "production") { 66 + return { 67 + rpID: process.env.RP_ID || "thistle.app", 68 + rpName: "Thistle", 69 + origin: process.env.ORIGIN || "https://thistle.app", 70 + }; 71 + } 72 + return { 73 + rpID: "localhost", 74 + rpName: "Thistle (Dev)", 75 + origin: "http://localhost:3000", 76 + }; 77 + } 78 + 79 + /** 80 + * Generate registration options for a user 81 + */ 82 + export async function createRegistrationOptions(user: User) { 83 + const { rpID, rpName } = getRPConfig(); 84 + 85 + // Get existing credentials to exclude 86 + const existingCredentials = getPasskeysForUser(user.id); 87 + 88 + const options = await generateRegistrationOptions({ 89 + rpName, 90 + rpID, 91 + userName: user.email, 92 + userDisplayName: user.name || user.email, 93 + attestationType: "none", 94 + excludeCredentials: existingCredentials.map((cred) => ({ 95 + id: cred.credential_id, 96 + transports: cred.transports?.split(",") as 97 + | ("usb" | "nfc" | "ble" | "internal" | "hybrid")[] 98 + | undefined, 99 + })), 100 + authenticatorSelection: { 101 + residentKey: "preferred", 102 + userVerification: "preferred", 103 + }, 104 + }); 105 + 106 + // Store challenge 107 + registrationChallenges.set(options.challenge, { 108 + challenge: options.challenge, 109 + user_id: user.id, 110 + expires_at: Date.now() + CHALLENGE_TTL, 111 + }); 112 + 113 + return options; 114 + } 115 + 116 + /** 117 + * Verify registration response and create passkey 118 + */ 119 + export async function verifyAndCreatePasskey( 120 + response: RegistrationResponseJSON, 121 + expectedChallenge: string, 122 + name?: string, 123 + ): Promise<Passkey> { 124 + // Validate challenge exists 125 + const challengeData = registrationChallenges.get(expectedChallenge); 126 + if (!challengeData) { 127 + throw new Error("Invalid or expired challenge"); 128 + } 129 + 130 + if (challengeData.expires_at < Date.now()) { 131 + registrationChallenges.delete(expectedChallenge); 132 + throw new Error("Challenge expired"); 133 + } 134 + 135 + const { origin, rpID } = getRPConfig(); 136 + 137 + // Verify the registration 138 + let verification: VerifiedRegistrationResponse; 139 + try { 140 + verification = await verifyRegistrationResponse({ 141 + response, 142 + expectedChallenge, 143 + expectedOrigin: origin, 144 + expectedRPID: rpID, 145 + }); 146 + } catch (error) { 147 + throw new Error( 148 + `Registration verification failed: ${error instanceof Error ? error.message : "Unknown error"}`, 149 + ); 150 + } 151 + 152 + if (!verification.verified || !verification.registrationInfo) { 153 + throw new Error("Registration verification failed"); 154 + } 155 + 156 + // Remove used challenge 157 + registrationChallenges.delete(expectedChallenge); 158 + 159 + const { credential } = verification.registrationInfo; 160 + 161 + // Create passkey 162 + // credential.id is a base64url string in SimpleWebAuthn v13 163 + // credential.publicKey is a Uint8Array that needs conversion 164 + const passkeyId = crypto.randomUUID(); 165 + const credentialIdBase64 = credential.id; 166 + const publicKeyBase64 = Buffer.from(credential.publicKey).toString("base64url"); 167 + const transports = response.response.transports?.join(",") || null; 168 + 169 + db.run( 170 + `INSERT INTO passkeys (id, user_id, credential_id, public_key, counter, transports, name) 171 + VALUES (?, ?, ?, ?, ?, ?, ?)`, 172 + [ 173 + passkeyId, 174 + challengeData.user_id, 175 + credentialIdBase64, 176 + publicKeyBase64, 177 + credential.counter, 178 + transports, 179 + name || null, 180 + ], 181 + ); 182 + 183 + const passkey = db 184 + .query<Passkey, [string]>( 185 + `SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at 186 + FROM passkeys WHERE id = ?`, 187 + ) 188 + .get(passkeyId); 189 + 190 + if (!passkey) { 191 + throw new Error("Failed to create passkey"); 192 + } 193 + 194 + return passkey; 195 + } 196 + 197 + /** 198 + * Generate authentication options 199 + */ 200 + export async function createAuthenticationOptions(email?: string) { 201 + const { rpID } = getRPConfig(); 202 + 203 + let allowCredentials: Array<{ 204 + id: string; 205 + transports?: ("usb" | "nfc" | "ble" | "internal" | "hybrid")[]; 206 + }> = []; 207 + 208 + // If email provided, only allow that user's credentials 209 + if (email) { 210 + const user = db 211 + .query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?") 212 + .get(email); 213 + 214 + if (user) { 215 + const credentials = getPasskeysForUser(user.id); 216 + allowCredentials = credentials.map((cred) => ({ 217 + id: cred.credential_id, 218 + transports: cred.transports?.split(",") as 219 + | ("usb" | "nfc" | "ble" | "internal" | "hybrid")[] 220 + | undefined, 221 + })); 222 + } 223 + } 224 + 225 + const options = await generateAuthenticationOptions({ 226 + rpID, 227 + allowCredentials: allowCredentials.length > 0 ? allowCredentials : undefined, 228 + userVerification: "preferred", 229 + }); 230 + 231 + // Store challenge 232 + authenticationChallenges.set(options.challenge, { 233 + challenge: options.challenge, 234 + expires_at: Date.now() + CHALLENGE_TTL, 235 + }); 236 + 237 + return options; 238 + } 239 + 240 + /** 241 + * Verify authentication response 242 + */ 243 + export async function verifyAndAuthenticatePasskey( 244 + response: AuthenticationResponseJSON, 245 + expectedChallenge: string, 246 + ): Promise<{ passkey: Passkey; user: User }> { 247 + // Validate challenge 248 + const challengeData = authenticationChallenges.get(expectedChallenge); 249 + if (!challengeData) { 250 + throw new Error("Invalid or expired challenge"); 251 + } 252 + 253 + if (challengeData.expires_at < Date.now()) { 254 + authenticationChallenges.delete(expectedChallenge); 255 + throw new Error("Challenge expired"); 256 + } 257 + 258 + // Get passkey by credential ID 259 + // response.id is already base64url encoded string from SimpleWebAuthn 260 + const passkey = db 261 + .query<Passkey, [string]>( 262 + `SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at 263 + FROM passkeys WHERE credential_id = ?`, 264 + ) 265 + .get(response.id); 266 + 267 + if (!passkey) { 268 + throw new Error("Passkey not found"); 269 + } 270 + 271 + const { origin, rpID } = getRPConfig(); 272 + 273 + // Verify the authentication 274 + let verification: VerifiedAuthenticationResponse; 275 + try { 276 + verification = await verifyAuthenticationResponse({ 277 + response, 278 + expectedChallenge, 279 + expectedOrigin: origin, 280 + expectedRPID: rpID, 281 + credential: { 282 + id: passkey.credential_id, 283 + publicKey: Buffer.from(passkey.public_key, "base64url"), 284 + counter: passkey.counter, 285 + }, 286 + }); 287 + } catch (error) { 288 + throw new Error( 289 + `Authentication verification failed: ${error instanceof Error ? error.message : "Unknown error"}`, 290 + ); 291 + } 292 + 293 + if (!verification.verified) { 294 + throw new Error("Authentication verification failed"); 295 + } 296 + 297 + // Remove used challenge 298 + authenticationChallenges.delete(expectedChallenge); 299 + 300 + // Update last used timestamp and counter 301 + const now = Math.floor(Date.now() / 1000); 302 + db.run( 303 + "UPDATE passkeys SET last_used_at = ?, counter = ? WHERE id = ?", 304 + [now, verification.authenticationInfo.newCounter, passkey.id], 305 + ); 306 + 307 + // Get user 308 + const user = db 309 + .query<User, [number]>( 310 + "SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?", 311 + ) 312 + .get(passkey.user_id); 313 + 314 + if (!user) { 315 + throw new Error("User not found"); 316 + } 317 + 318 + return { passkey, user }; 319 + } 320 + 321 + /** 322 + * Get all passkeys for a user 323 + */ 324 + export function getPasskeysForUser(userId: number): Passkey[] { 325 + return db 326 + .query<Passkey, [number]>( 327 + `SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at 328 + FROM passkeys WHERE user_id = ? ORDER BY created_at DESC`, 329 + ) 330 + .all(userId); 331 + } 332 + 333 + /** 334 + * Delete a passkey 335 + */ 336 + export function deletePasskey(passkeyId: string, userId: number): void { 337 + db.run("DELETE FROM passkeys WHERE id = ? AND user_id = ?", [ 338 + passkeyId, 339 + userId, 340 + ]); 341 + } 342 + 343 + /** 344 + * Update passkey name 345 + */ 346 + export function updatePasskeyName( 347 + passkeyId: string, 348 + userId: number, 349 + name: string, 350 + ): void { 351 + db.run("UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?", [ 352 + name, 353 + passkeyId, 354 + userId, 355 + ]); 356 + }
+7 -7
src/pages/admin.html
··· 10 10 <link rel="stylesheet" href="../styles/main.css"> 11 11 <style> 12 12 main { 13 - max-width: 80rem; 13 + max-width: 80rem !important; 14 14 margin: 0 auto; 15 15 padding: 2rem; 16 16 } ··· 418 418 btn.addEventListener('click', async (e) => { 419 419 const button = e.target; 420 420 const id = button.dataset.id; 421 - 421 + 422 422 if (!confirm('Are you sure you want to delete this transcription? This cannot be undone.')) { 423 423 return; 424 424 } ··· 530 530 try { 531 531 const res = await fetch(`/api/admin/users/${userId}/role`, { 532 532 method: 'PUT', 533 - headers: { 'Content-Type': 'application/json' }, 534 - body: JSON.stringify({ role: newRole }) 533 + headers: {'Content-Type': 'application/json'}, 534 + body: JSON.stringify({role: newRole}) 535 535 }); 536 536 537 537 if (!res.ok) { ··· 621 621 document.querySelectorAll('.tab').forEach(tab => { 622 622 tab.addEventListener('click', () => { 623 623 const tabName = tab.dataset.tab; 624 - 624 + 625 625 document.querySelectorAll('.tab').forEach(t => { 626 626 t.classList.remove('active'); 627 627 }); 628 628 document.querySelectorAll('.tab-content').forEach(c => { 629 629 c.classList.remove('active'); 630 630 }); 631 - 631 + 632 632 tab.classList.add('active'); 633 633 document.getElementById(`${tabName}-tab`).classList.add('active'); 634 634 }); ··· 639 639 </script> 640 640 </body> 641 641 642 - </html> 642 + </html>