this repo has no description

Fixed up did:web account creation

lewis 68e7f4ee d8ac571e

+10
frontend/deno.lock
··· 1 { 2 "version": "5", 3 "specifiers": { 4 "npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3", 5 "npm:@testing-library/jest-dom@^6.6.3": "6.9.1", 6 "npm:@testing-library/svelte@^5.2.6": "5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1", 7 "npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1", 8 "npm:jsdom@^25.0.1": "25.0.1", 9 "npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0", 10 "npm:svelte@5": "5.45.10_acorn@8.15.0", 11 "npm:vite@*": "6.4.1_picomatch@4.0.3", ··· 491 "@jridgewell/resolve-uri", 492 "@jridgewell/sourcemap-codec" 493 ] 494 }, 495 "@rollup/rollup-android-arm-eabi@4.53.3": { 496 "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", ··· 1281 "ms@2.1.3": { 1282 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 1283 }, 1284 "nanoid@3.3.11": { 1285 "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1286 "bin": true ··· 1636 "workspace": { 1637 "packageJson": { 1638 "dependencies": [ 1639 "npm:@sveltejs/vite-plugin-svelte@5", 1640 "npm:@testing-library/jest-dom@^6.6.3", 1641 "npm:@testing-library/svelte@^5.2.6", 1642 "npm:@testing-library/user-event@^14.5.2", 1643 "npm:jsdom@^25.0.1", 1644 "npm:svelte-i18n@^4.0.1", 1645 "npm:svelte@5", 1646 "npm:vite@6",
··· 1 { 2 "version": "5", 3 "specifiers": { 4 + "npm:@noble/secp256k1@^2.1.0": "2.3.0", 5 "npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3", 6 "npm:@testing-library/jest-dom@^6.6.3": "6.9.1", 7 "npm:@testing-library/svelte@^5.2.6": "5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1", 8 "npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1", 9 "npm:jsdom@^25.0.1": "25.0.1", 10 + "npm:multiformats@^13.3.1": "13.4.2", 11 "npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0", 12 "npm:svelte@5": "5.45.10_acorn@8.15.0", 13 "npm:vite@*": "6.4.1_picomatch@4.0.3", ··· 493 "@jridgewell/resolve-uri", 494 "@jridgewell/sourcemap-codec" 495 ] 496 + }, 497 + "@noble/secp256k1@2.3.0": { 498 + "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==" 499 }, 500 "@rollup/rollup-android-arm-eabi@4.53.3": { 501 "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", ··· 1286 "ms@2.1.3": { 1287 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 1288 }, 1289 + "multiformats@13.4.2": { 1290 + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==" 1291 + }, 1292 "nanoid@3.3.11": { 1293 "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1294 "bin": true ··· 1644 "workspace": { 1645 "packageJson": { 1646 "dependencies": [ 1647 + "npm:@noble/secp256k1@^2.1.0", 1648 "npm:@sveltejs/vite-plugin-svelte@5", 1649 "npm:@testing-library/jest-dom@^6.6.3", 1650 "npm:@testing-library/svelte@^5.2.6", 1651 "npm:@testing-library/user-event@^14.5.2", 1652 "npm:jsdom@^25.0.1", 1653 + "npm:multiformats@^13.3.1", 1654 "npm:svelte-i18n@^4.0.1", 1655 "npm:svelte@5", 1656 "npm:vite@6",
+2
frontend/package.json
··· 12 "test:coverage": "vitest run --coverage" 13 }, 14 "dependencies": { 15 "svelte-i18n": "^4.0.1" 16 }, 17 "devDependencies": {
··· 12 "test:coverage": "vitest run --coverage" 13 }, 14 "dependencies": { 15 + "@noble/secp256k1": "^2.1.0", 16 + "multiformats": "^13.3.1", 17 "svelte-i18n": "^4.0.1" 18 }, 19 "devDependencies": {
+56 -7
frontend/src/lib/api.ts
··· 95 inviteCode?: string 96 didType?: DidType 97 did?: string 98 verificationChannel?: VerificationChannel 99 discordId?: string 100 telegramUsername?: string ··· 120 } 121 122 export const api = { 123 - async createAccount(params: CreateAccountParams): Promise<CreateAccountResult> { 124 - return xrpc('com.atproto.server.createAccount', { 125 method: 'POST', 126 - body: { 127 handle: params.handle, 128 email: params.email, 129 password: params.password, 130 inviteCode: params.inviteCode, 131 didType: params.didType, 132 did: params.did, 133 verificationChannel: params.verificationChannel, 134 discordId: params.discordId, 135 telegramUsername: params.telegramUsername, 136 signalNumber: params.signalNumber, 137 - }, 138 }) 139 }, 140 141 async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> { ··· 750 }) 751 }, 752 753 async createPasskeyAccount(params: { 754 handle: string 755 email?: string ··· 761 discordId?: string 762 telegramUsername?: string 763 signalNumber?: string 764 - }): Promise<{ 765 did: string 766 handle: string 767 setupToken: string 768 setupExpiresAt: string 769 }> { 770 - return xrpc('com.tranquil.account.createPasskeyAccount', { 771 method: 'POST', 772 - body: params, 773 }) 774 }, 775 776 async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> {
··· 95 inviteCode?: string 96 didType?: DidType 97 did?: string 98 + signingKey?: string 99 verificationChannel?: VerificationChannel 100 discordId?: string 101 telegramUsername?: string ··· 121 } 122 123 export const api = { 124 + async createAccount(params: CreateAccountParams, byodToken?: string): Promise<CreateAccountResult> { 125 + const url = `${API_BASE}/com.atproto.server.createAccount` 126 + const headers: Record<string, string> = { 'Content-Type': 'application/json' } 127 + if (byodToken) { 128 + headers['Authorization'] = `Bearer ${byodToken}` 129 + } 130 + const response = await fetch(url, { 131 method: 'POST', 132 + headers, 133 + body: JSON.stringify({ 134 handle: params.handle, 135 email: params.email, 136 password: params.password, 137 inviteCode: params.inviteCode, 138 didType: params.didType, 139 did: params.did, 140 + signingKey: params.signingKey, 141 verificationChannel: params.verificationChannel, 142 discordId: params.discordId, 143 telegramUsername: params.telegramUsername, 144 signalNumber: params.signalNumber, 145 + }), 146 }) 147 + const data = await response.json() 148 + if (!response.ok) { 149 + throw new ApiError(data.error, data.message, response.status) 150 + } 151 + return data 152 }, 153 154 async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> { ··· 763 }) 764 }, 765 766 + async reserveSigningKey(did?: string): Promise<{ signingKey: string }> { 767 + return xrpc('com.atproto.server.reserveSigningKey', { 768 + method: 'POST', 769 + body: { did }, 770 + }) 771 + }, 772 + 773 + async getRecommendedDidCredentials(token: string): Promise<{ 774 + rotationKeys?: string[] 775 + alsoKnownAs?: string[] 776 + verificationMethods?: { atproto?: string } 777 + services?: { atproto_pds?: { type: string; endpoint: string } } 778 + }> { 779 + return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token }) 780 + }, 781 + 782 + async activateAccount(token: string): Promise<void> { 783 + await xrpc('com.atproto.server.activateAccount', { 784 + method: 'POST', 785 + token, 786 + }) 787 + }, 788 + 789 async createPasskeyAccount(params: { 790 handle: string 791 email?: string ··· 797 discordId?: string 798 telegramUsername?: string 799 signalNumber?: string 800 + }, byodToken?: string): Promise<{ 801 did: string 802 handle: string 803 setupToken: string 804 setupExpiresAt: string 805 }> { 806 + const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount` 807 + const headers: Record<string, string> = { 808 + 'Content-Type': 'application/json' 809 + } 810 + if (byodToken) { 811 + headers['Authorization'] = `Bearer ${byodToken}` 812 + } 813 + const res = await fetch(url, { 814 method: 'POST', 815 + headers, 816 + body: JSON.stringify(params), 817 }) 818 + if (!res.ok) { 819 + const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) 820 + throw new ApiError(res.status, err.error, err.message) 821 + } 822 + return res.json() 823 }, 824 825 async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> {
+12
frontend/src/lib/auth.svelte.ts
··· 265 } 266 } 267 268 export async function logout(): Promise<void> { 269 if (state.session) { 270 try {
··· 265 } 266 } 267 268 + export function setSession(session: { did: string; handle: string; accessJwt: string; refreshJwt: string }): void { 269 + const newSession: Session = { 270 + did: session.did, 271 + handle: session.handle, 272 + accessJwt: session.accessJwt, 273 + refreshJwt: session.refreshJwt, 274 + } 275 + state.session = newSession 276 + saveSession(newSession) 277 + addOrUpdateSavedAccount(newSession) 278 + } 279 + 280 export async function logout(): Promise<void> { 281 if (state.session) { 282 try {
+106
frontend/src/lib/crypto.ts
···
··· 1 + import * as secp from '@noble/secp256k1' 2 + import { base58btc } from 'multiformats/bases/base58' 3 + 4 + const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]) 5 + 6 + export interface Keypair { 7 + privateKey: Uint8Array 8 + publicKey: Uint8Array 9 + publicKeyMultibase: string 10 + publicKeyDidKey: string 11 + } 12 + 13 + export async function generateKeypair(): Promise<Keypair> { 14 + const privateKey = secp.utils.randomPrivateKey() 15 + const publicKey = secp.getPublicKey(privateKey, true) 16 + 17 + const multicodecKey = new Uint8Array(SECP256K1_MULTICODEC_PREFIX.length + publicKey.length) 18 + multicodecKey.set(SECP256K1_MULTICODEC_PREFIX, 0) 19 + multicodecKey.set(publicKey, SECP256K1_MULTICODEC_PREFIX.length) 20 + 21 + const publicKeyMultibase = base58btc.encode(multicodecKey) 22 + const publicKeyDidKey = `did:key:${publicKeyMultibase}` 23 + 24 + return { 25 + privateKey, 26 + publicKey, 27 + publicKeyMultibase, 28 + publicKeyDidKey, 29 + } 30 + } 31 + 32 + function base64UrlEncode(data: Uint8Array | string): string { 33 + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data 34 + let binary = '' 35 + for (let i = 0; i < bytes.length; i++) { 36 + binary += String.fromCharCode(bytes[i]) 37 + } 38 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 39 + } 40 + 41 + export async function createServiceJwt( 42 + privateKey: Uint8Array, 43 + issuerDid: string, 44 + audienceDid: string, 45 + lxm: string 46 + ): Promise<string> { 47 + const header = { 48 + alg: 'ES256K', 49 + typ: 'JWT', 50 + } 51 + 52 + const now = Math.floor(Date.now() / 1000) 53 + const payload = { 54 + iss: issuerDid, 55 + sub: issuerDid, 56 + aud: audienceDid, 57 + exp: now + 180, 58 + iat: now, 59 + lxm: lxm, 60 + } 61 + 62 + const headerEncoded = base64UrlEncode(JSON.stringify(header)) 63 + const payloadEncoded = base64UrlEncode(JSON.stringify(payload)) 64 + const message = `${headerEncoded}.${payloadEncoded}` 65 + 66 + const msgBytes = new TextEncoder().encode(message) 67 + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBytes) 68 + const msgHash = new Uint8Array(hashBuffer) 69 + const signature = await secp.signAsync(msgHash, privateKey) 70 + const sigBytes = signature.toCompactRawBytes() 71 + const signatureEncoded = base64UrlEncode(sigBytes) 72 + 73 + return `${message}.${signatureEncoded}` 74 + } 75 + 76 + export function generateDidDocument( 77 + did: string, 78 + publicKeyMultibase: string, 79 + handle: string, 80 + pdsEndpoint: string 81 + ): object { 82 + return { 83 + '@context': [ 84 + 'https://www.w3.org/ns/did/v1', 85 + 'https://w3id.org/security/multikey/v1', 86 + 'https://w3id.org/security/suites/secp256k1-2019/v1', 87 + ], 88 + id: did, 89 + alsoKnownAs: [`at://${handle}`], 90 + verificationMethod: [ 91 + { 92 + id: `${did}#atproto`, 93 + type: 'Multikey', 94 + controller: did, 95 + publicKeyMultibase: publicKeyMultibase, 96 + }, 97 + ], 98 + service: [ 99 + { 100 + id: '#atproto_pds', 101 + type: 'AtprotoPersonalDataServer', 102 + serviceEndpoint: pdsEndpoint, 103 + }, 104 + ], 105 + } 106 + }
+121
frontend/src/lib/registration/AppPasswordStep.svelte
···
··· 1 + <script lang="ts"> 2 + import type { RegistrationFlow } from './flow.svelte' 3 + 4 + interface Props { 5 + flow: RegistrationFlow 6 + } 7 + 8 + let { flow }: Props = $props() 9 + 10 + let copied = $state(false) 11 + let acknowledged = $state(false) 12 + 13 + function copyToClipboard() { 14 + if (flow.account?.appPassword) { 15 + navigator.clipboard.writeText(flow.account.appPassword) 16 + copied = true 17 + } 18 + } 19 + </script> 20 + 21 + <div class="app-password-step"> 22 + <div class="warning-box"> 23 + <strong>Important: Save this app password!</strong> 24 + <p> 25 + This app password is required to sign into apps that don't support passkeys yet (like bsky.app). 26 + You will only see this password once. 27 + </p> 28 + </div> 29 + 30 + <div class="app-password-display"> 31 + <div class="app-password-label"> 32 + App Password for: <strong>{flow.account?.appPasswordName}</strong> 33 + </div> 34 + <code class="app-password-code">{flow.account?.appPassword}</code> 35 + <button type="button" class="copy-btn" onclick={copyToClipboard}> 36 + {copied ? 'Copied!' : 'Copy to Clipboard'} 37 + </button> 38 + </div> 39 + 40 + <div class="field"> 41 + <label class="checkbox-label"> 42 + <input type="checkbox" bind:checked={acknowledged} /> 43 + <span>I have saved my app password in a secure location</span> 44 + </label> 45 + </div> 46 + 47 + <button onclick={() => flow.proceedFromAppPassword()} disabled={!acknowledged}> 48 + Continue 49 + </button> 50 + </div> 51 + 52 + <style> 53 + .app-password-step { 54 + display: flex; 55 + flex-direction: column; 56 + gap: var(--space-4); 57 + } 58 + 59 + .warning-box { 60 + padding: var(--space-5); 61 + background: var(--warning-bg); 62 + border: 1px solid var(--warning-border); 63 + border-radius: var(--radius-lg); 64 + font-size: var(--text-sm); 65 + } 66 + 67 + .warning-box strong { 68 + display: block; 69 + margin-bottom: var(--space-3); 70 + color: var(--warning-text); 71 + } 72 + 73 + .warning-box p { 74 + margin: 0; 75 + color: var(--warning-text); 76 + } 77 + 78 + .app-password-display { 79 + background: var(--bg-card); 80 + border: 2px solid var(--accent); 81 + border-radius: var(--radius-xl); 82 + padding: var(--space-6); 83 + text-align: center; 84 + } 85 + 86 + .app-password-label { 87 + font-size: var(--text-sm); 88 + color: var(--text-secondary); 89 + margin-bottom: var(--space-4); 90 + } 91 + 92 + .app-password-code { 93 + display: block; 94 + font-size: var(--text-xl); 95 + font-family: ui-monospace, monospace; 96 + letter-spacing: 0.1em; 97 + padding: var(--space-5); 98 + background: var(--bg-input); 99 + border-radius: var(--radius-md); 100 + margin-bottom: var(--space-4); 101 + user-select: all; 102 + } 103 + 104 + .copy-btn { 105 + padding: var(--space-3) var(--space-5); 106 + font-size: var(--text-sm); 107 + } 108 + 109 + .checkbox-label { 110 + display: flex; 111 + align-items: center; 112 + gap: var(--space-3); 113 + cursor: pointer; 114 + font-weight: var(--font-normal); 115 + } 116 + 117 + .checkbox-label input[type="checkbox"] { 118 + width: auto; 119 + padding: 0; 120 + } 121 + </style>
+166
frontend/src/lib/registration/DidDocStep.svelte
···
··· 1 + <script lang="ts"> 2 + import type { RegistrationFlow } from './flow.svelte' 3 + 4 + interface Props { 5 + flow: RegistrationFlow 6 + type: 'initial' | 'updated' 7 + onConfirm: () => void 8 + onBack?: () => void 9 + } 10 + 11 + let { flow, type, onConfirm, onBack }: Props = $props() 12 + 13 + let copied = $state(false) 14 + let confirmed = $state(false) 15 + 16 + const didDocument = $derived( 17 + type === 'initial' 18 + ? flow.externalDidWeb.initialDidDocument 19 + : flow.externalDidWeb.updatedDidDocument 20 + ) 21 + 22 + const title = $derived( 23 + type === 'initial' 24 + ? 'Step 1: Upload your DID document' 25 + : 'Step 2: Update your DID document' 26 + ) 27 + 28 + const description = $derived( 29 + type === 'initial' 30 + ? 'Copy the JSON below and save it at:' 31 + : 'The PDS has assigned a new signing key for your account. Update your DID document with this new key:' 32 + ) 33 + 34 + const confirmLabel = $derived( 35 + type === 'initial' 36 + ? 'I have uploaded the DID document to my domain' 37 + : 'I have updated the DID document on my domain' 38 + ) 39 + 40 + const buttonLabel = $derived( 41 + type === 'initial' ? 'Continue' : 'Activate Account' 42 + ) 43 + 44 + function copyToClipboard() { 45 + if (didDocument) { 46 + navigator.clipboard.writeText(didDocument) 47 + copied = true 48 + } 49 + } 50 + 51 + function handleConfirm() { 52 + if (!confirmed) { 53 + flow.setError(`Please confirm you have ${type === 'initial' ? 'uploaded' : 'updated'} the DID document`) 54 + return 55 + } 56 + onConfirm() 57 + } 58 + </script> 59 + 60 + <div class="did-doc-step"> 61 + <div class="warning-box"> 62 + <strong>{title}</strong> 63 + <p>{description}</p> 64 + <code class="did-url">https://{flow.extractDomain(flow.info.externalDid || '')}/.well-known/did.json</code> 65 + </div> 66 + 67 + <div class="did-doc-display"> 68 + <pre class="did-doc-code">{didDocument}</pre> 69 + <button type="button" class="copy-btn" onclick={copyToClipboard}> 70 + {copied ? 'Copied!' : 'Copy to Clipboard'} 71 + </button> 72 + </div> 73 + 74 + <div class="field"> 75 + <label class="checkbox-label"> 76 + <input type="checkbox" bind:checked={confirmed} /> 77 + <span>{confirmLabel}</span> 78 + </label> 79 + </div> 80 + 81 + <button onclick={handleConfirm} disabled={flow.state.submitting || !confirmed}> 82 + {flow.state.submitting ? (type === 'initial' ? 'Creating account...' : 'Activating...') : buttonLabel} 83 + </button> 84 + 85 + {#if onBack} 86 + <button type="button" class="secondary" onclick={onBack} disabled={flow.state.submitting}> 87 + Back 88 + </button> 89 + {/if} 90 + </div> 91 + 92 + <style> 93 + .did-doc-step { 94 + display: flex; 95 + flex-direction: column; 96 + gap: var(--space-4); 97 + } 98 + 99 + .warning-box { 100 + padding: var(--space-5); 101 + background: var(--warning-bg); 102 + border: 1px solid var(--warning-border); 103 + border-radius: var(--radius-lg); 104 + font-size: var(--text-sm); 105 + } 106 + 107 + .warning-box strong { 108 + display: block; 109 + margin-bottom: var(--space-3); 110 + color: var(--warning-text); 111 + } 112 + 113 + .warning-box p { 114 + margin: 0; 115 + color: var(--warning-text); 116 + } 117 + 118 + .did-url { 119 + display: block; 120 + margin-top: var(--space-3); 121 + padding: var(--space-3); 122 + background: var(--bg-input); 123 + border-radius: var(--radius-md); 124 + font-size: var(--text-sm); 125 + word-break: break-all; 126 + } 127 + 128 + .did-doc-display { 129 + background: var(--bg-card); 130 + border: 1px solid var(--border-color); 131 + border-radius: var(--radius-lg); 132 + overflow: hidden; 133 + } 134 + 135 + .did-doc-code { 136 + margin: 0; 137 + padding: var(--space-4); 138 + background: var(--bg-input); 139 + font-size: var(--text-xs); 140 + overflow-x: auto; 141 + white-space: pre; 142 + max-height: 300px; 143 + overflow-y: auto; 144 + } 145 + 146 + .copy-btn { 147 + width: 100%; 148 + border-radius: 0; 149 + margin: 0; 150 + padding: var(--space-3) var(--space-5); 151 + font-size: var(--text-sm); 152 + } 153 + 154 + .checkbox-label { 155 + display: flex; 156 + align-items: center; 157 + gap: var(--space-3); 158 + cursor: pointer; 159 + font-weight: var(--font-normal); 160 + } 161 + 162 + .checkbox-label input[type="checkbox"] { 163 + width: auto; 164 + padding: 0; 165 + } 166 + </style>
+117
frontend/src/lib/registration/KeyChoiceStep.svelte
···
··· 1 + <script lang="ts"> 2 + import type { RegistrationFlow } from './flow.svelte' 3 + 4 + interface Props { 5 + flow: RegistrationFlow 6 + } 7 + 8 + let { flow }: Props = $props() 9 + </script> 10 + 11 + <div class="key-choice-step"> 12 + <div class="info-box"> 13 + <strong>External did:web Setup</strong> 14 + <p> 15 + To use your own domain ({flow.extractDomain(flow.info.externalDid || '')}) as your identity, 16 + you'll need to host a DID document. Choose how you'd like to set up the signing key: 17 + </p> 18 + </div> 19 + 20 + <div class="key-choice-options"> 21 + <button 22 + class="key-choice-btn" 23 + onclick={() => flow.selectKeyMode('reserved')} 24 + disabled={flow.state.submitting} 25 + > 26 + <span class="key-choice-title">Let the PDS generate a key</span> 27 + <span class="key-choice-desc">Simpler setup - we'll provide the public key for your DID document</span> 28 + </button> 29 + 30 + <button 31 + class="key-choice-btn" 32 + onclick={() => flow.selectKeyMode('byod')} 33 + disabled={flow.state.submitting} 34 + > 35 + <span class="key-choice-title">I'll provide my own key</span> 36 + <span class="key-choice-desc">Advanced - generate a key in your browser for initial authentication</span> 37 + </button> 38 + </div> 39 + 40 + {#if flow.state.submitting} 41 + <p class="loading">Generating key...</p> 42 + {/if} 43 + 44 + <button type="button" class="secondary" onclick={() => flow.goBack()} disabled={flow.state.submitting}> 45 + Back 46 + </button> 47 + </div> 48 + 49 + <style> 50 + .key-choice-step { 51 + display: flex; 52 + flex-direction: column; 53 + gap: var(--space-4); 54 + } 55 + 56 + .info-box { 57 + background: var(--bg-secondary); 58 + border: 1px solid var(--border-color); 59 + border-radius: var(--radius-lg); 60 + padding: var(--space-5); 61 + font-size: var(--text-sm); 62 + } 63 + 64 + .info-box strong { 65 + display: block; 66 + margin-bottom: var(--space-3); 67 + } 68 + 69 + .info-box p { 70 + margin: 0; 71 + color: var(--text-secondary); 72 + } 73 + 74 + .key-choice-options { 75 + display: flex; 76 + flex-direction: column; 77 + gap: var(--space-3); 78 + } 79 + 80 + .key-choice-btn { 81 + display: flex; 82 + flex-direction: column; 83 + align-items: flex-start; 84 + gap: var(--space-2); 85 + padding: var(--space-5); 86 + background: var(--bg-card); 87 + border: 2px solid var(--border-color); 88 + border-radius: var(--radius-lg); 89 + text-align: left; 90 + cursor: pointer; 91 + transition: border-color 0.2s; 92 + } 93 + 94 + .key-choice-btn:hover:not(:disabled) { 95 + border-color: var(--accent); 96 + } 97 + 98 + .key-choice-btn:disabled { 99 + opacity: 0.6; 100 + cursor: not-allowed; 101 + } 102 + 103 + .key-choice-title { 104 + font-weight: var(--font-semibold); 105 + color: var(--text-primary); 106 + } 107 + 108 + .key-choice-desc { 109 + font-size: var(--text-sm); 110 + color: var(--text-secondary); 111 + } 112 + 113 + .loading { 114 + text-align: center; 115 + color: var(--text-secondary); 116 + } 117 + </style>
+103
frontend/src/lib/registration/VerificationStep.svelte
···
··· 1 + <script lang="ts"> 2 + import { api, ApiError } from '../api' 3 + import type { RegistrationFlow } from './flow.svelte' 4 + 5 + interface Props { 6 + flow: RegistrationFlow 7 + } 8 + 9 + let { flow }: Props = $props() 10 + 11 + let verificationCode = $state('') 12 + let resending = $state(false) 13 + let resendMessage = $state<string | null>(null) 14 + 15 + function channelLabel(ch: string): string { 16 + switch (ch) { 17 + case 'email': return 'email' 18 + case 'discord': return 'Discord' 19 + case 'telegram': return 'Telegram' 20 + case 'signal': return 'Signal' 21 + default: return ch 22 + } 23 + } 24 + 25 + async function handleSubmit(e: Event) { 26 + e.preventDefault() 27 + if (!verificationCode.trim()) return 28 + resendMessage = null 29 + await flow.verifyAccount(verificationCode) 30 + } 31 + 32 + async function handleResend() { 33 + if (resending || !flow.account) return 34 + resending = true 35 + resendMessage = null 36 + flow.clearError() 37 + 38 + try { 39 + const { resendVerification } = await import('../auth.svelte') 40 + await resendVerification(flow.account.did) 41 + resendMessage = 'Verification code resent!' 42 + } catch (err) { 43 + if (err instanceof ApiError) { 44 + flow.setError(err.message || 'Failed to resend code') 45 + } else if (err instanceof Error) { 46 + flow.setError(err.message || 'Failed to resend code') 47 + } else { 48 + flow.setError('Failed to resend code') 49 + } 50 + } finally { 51 + resending = false 52 + } 53 + } 54 + </script> 55 + 56 + <div class="verification-step"> 57 + <p class="info-text"> 58 + We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}. 59 + Enter it below to continue. 60 + </p> 61 + 62 + {#if resendMessage} 63 + <div class="message success">{resendMessage}</div> 64 + {/if} 65 + 66 + <form onsubmit={handleSubmit}> 67 + <div class="field"> 68 + <label for="verification-code">Verification Code</label> 69 + <input 70 + id="verification-code" 71 + type="text" 72 + bind:value={verificationCode} 73 + placeholder="Enter 6-digit code" 74 + disabled={flow.state.submitting} 75 + required 76 + maxlength="6" 77 + inputmode="numeric" 78 + autocomplete="one-time-code" 79 + /> 80 + </div> 81 + 82 + <button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}> 83 + {flow.state.submitting ? 'Verifying...' : 'Verify'} 84 + </button> 85 + 86 + <button type="button" class="secondary" onclick={handleResend} disabled={resending}> 87 + {resending ? 'Resending...' : 'Resend Code'} 88 + </button> 89 + </form> 90 + </div> 91 + 92 + <style> 93 + .verification-step { 94 + display: flex; 95 + flex-direction: column; 96 + gap: var(--space-4); 97 + } 98 + 99 + .info-text { 100 + color: var(--text-secondary); 101 + margin: 0; 102 + } 103 + </style>
+340
frontend/src/lib/registration/flow.svelte.ts
···
··· 1 + import { api, ApiError } from '../api' 2 + import { generateKeypair, createServiceJwt, generateDidDocument } from '../crypto' 3 + import type { 4 + RegistrationMode, 5 + RegistrationStep, 6 + RegistrationInfo, 7 + ExternalDidWebState, 8 + AccountResult, 9 + SessionState, 10 + } from './types' 11 + 12 + export interface RegistrationFlowState { 13 + mode: RegistrationMode 14 + step: RegistrationStep 15 + info: RegistrationInfo 16 + externalDidWeb: ExternalDidWebState 17 + account: AccountResult | null 18 + session: SessionState | null 19 + error: string | null 20 + submitting: boolean 21 + pdsHostname: string 22 + } 23 + 24 + export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: string) { 25 + let state = $state<RegistrationFlowState>({ 26 + mode, 27 + step: 'info', 28 + info: { 29 + handle: '', 30 + email: '', 31 + password: '', 32 + inviteCode: '', 33 + didType: 'plc', 34 + externalDid: '', 35 + verificationChannel: 'email', 36 + discordId: '', 37 + telegramUsername: '', 38 + signalNumber: '', 39 + }, 40 + externalDidWeb: { 41 + keyMode: 'reserved', 42 + }, 43 + account: null, 44 + session: null, 45 + error: null, 46 + submitting: false, 47 + pdsHostname, 48 + }) 49 + 50 + function getPdsEndpoint(): string { 51 + return `https://${state.pdsHostname}` 52 + } 53 + 54 + function getPdsDid(): string { 55 + return `did:web:${state.pdsHostname}` 56 + } 57 + 58 + function getFullHandle(): string { 59 + return `${state.info.handle.trim()}.${state.pdsHostname}` 60 + } 61 + 62 + function extractDomain(did: string): string { 63 + return did.replace('did:web:', '').replace(/%3A/g, ':') 64 + } 65 + 66 + function setError(err: unknown) { 67 + if (err instanceof ApiError) { 68 + state.error = err.message || 'An error occurred' 69 + } else if (err instanceof Error) { 70 + state.error = err.message || 'An error occurred' 71 + } else { 72 + state.error = 'An error occurred' 73 + } 74 + } 75 + 76 + async function proceedFromInfo() { 77 + state.error = null 78 + if (state.info.didType === 'web-external') { 79 + state.step = 'key-choice' 80 + } else { 81 + state.step = 'creating' 82 + } 83 + } 84 + 85 + async function selectKeyMode(keyMode: 'reserved' | 'byod') { 86 + state.submitting = true 87 + state.error = null 88 + state.externalDidWeb.keyMode = keyMode 89 + 90 + try { 91 + let publicKeyMultibase: string 92 + 93 + if (keyMode === 'reserved') { 94 + const result = await api.reserveSigningKey(state.info.externalDid!.trim()) 95 + state.externalDidWeb.reservedSigningKey = result.signingKey 96 + publicKeyMultibase = result.signingKey.replace('did:key:', '') 97 + } else { 98 + const keypair = await generateKeypair() 99 + state.externalDidWeb.byodPrivateKey = keypair.privateKey 100 + state.externalDidWeb.byodPublicKeyMultibase = keypair.publicKeyMultibase 101 + publicKeyMultibase = keypair.publicKeyMultibase 102 + } 103 + 104 + const didDoc = generateDidDocument( 105 + state.info.externalDid!.trim(), 106 + publicKeyMultibase, 107 + getFullHandle(), 108 + getPdsEndpoint() 109 + ) 110 + state.externalDidWeb.initialDidDocument = JSON.stringify(didDoc, null, '\t') 111 + state.step = 'initial-did-doc' 112 + } catch (err) { 113 + setError(err) 114 + } finally { 115 + state.submitting = false 116 + } 117 + } 118 + 119 + async function confirmInitialDidDoc() { 120 + state.step = 'creating' 121 + } 122 + 123 + async function createPasswordAccount() { 124 + state.submitting = true 125 + state.error = null 126 + 127 + try { 128 + let byodToken: string | undefined 129 + 130 + if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) { 131 + byodToken = await createServiceJwt( 132 + state.externalDidWeb.byodPrivateKey, 133 + state.info.externalDid!.trim(), 134 + getPdsDid(), 135 + 'com.atproto.server.createAccount' 136 + ) 137 + } 138 + 139 + const result = await api.createAccount({ 140 + handle: state.info.handle.trim(), 141 + email: state.info.email.trim(), 142 + password: state.info.password!, 143 + inviteCode: state.info.inviteCode?.trim() || undefined, 144 + didType: state.info.didType, 145 + did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined, 146 + signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved' 147 + ? state.externalDidWeb.reservedSigningKey 148 + : undefined, 149 + verificationChannel: state.info.verificationChannel, 150 + discordId: state.info.discordId?.trim() || undefined, 151 + telegramUsername: state.info.telegramUsername?.trim() || undefined, 152 + signalNumber: state.info.signalNumber?.trim() || undefined, 153 + }, byodToken) 154 + 155 + state.account = { 156 + did: result.did, 157 + handle: result.handle, 158 + } 159 + state.step = 'verify' 160 + } catch (err) { 161 + setError(err) 162 + } finally { 163 + state.submitting = false 164 + } 165 + } 166 + 167 + async function createPasskeyAccount() { 168 + state.submitting = true 169 + state.error = null 170 + 171 + try { 172 + let byodToken: string | undefined 173 + 174 + if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) { 175 + byodToken = await createServiceJwt( 176 + state.externalDidWeb.byodPrivateKey, 177 + state.info.externalDid!.trim(), 178 + getPdsDid(), 179 + 'com.atproto.server.createAccount' 180 + ) 181 + } 182 + 183 + const result = await api.createPasskeyAccount({ 184 + handle: state.info.handle.trim(), 185 + email: state.info.email?.trim() || undefined, 186 + inviteCode: state.info.inviteCode?.trim() || undefined, 187 + didType: state.info.didType, 188 + did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined, 189 + signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved' 190 + ? state.externalDidWeb.reservedSigningKey 191 + : undefined, 192 + verificationChannel: state.info.verificationChannel, 193 + discordId: state.info.discordId?.trim() || undefined, 194 + telegramUsername: state.info.telegramUsername?.trim() || undefined, 195 + signalNumber: state.info.signalNumber?.trim() || undefined, 196 + }, byodToken) 197 + 198 + state.account = { 199 + did: result.did, 200 + handle: result.handle, 201 + setupToken: result.setupToken, 202 + } 203 + state.step = 'passkey' 204 + } catch (err) { 205 + setError(err) 206 + } finally { 207 + state.submitting = false 208 + } 209 + } 210 + 211 + function setPasskeyComplete(appPassword: string, appPasswordName: string) { 212 + if (state.account) { 213 + state.account.appPassword = appPassword 214 + state.account.appPasswordName = appPasswordName 215 + } 216 + state.step = 'app-password' 217 + } 218 + 219 + function proceedFromAppPassword() { 220 + state.step = 'verify' 221 + } 222 + 223 + async function verifyAccount(code: string) { 224 + state.submitting = true 225 + state.error = null 226 + 227 + try { 228 + const confirmResult = await api.confirmSignup(state.account!.did, code.trim()) 229 + 230 + if (state.info.didType === 'web-external') { 231 + const password = state.mode === 'passkey' ? state.account!.appPassword! : state.info.password! 232 + const session = await api.createSession(state.account!.did, password) 233 + state.session = { 234 + accessJwt: session.accessJwt, 235 + refreshJwt: session.refreshJwt, 236 + } 237 + 238 + if (state.externalDidWeb.keyMode === 'byod') { 239 + const credentials = await api.getRecommendedDidCredentials(session.accessJwt) 240 + const newPublicKeyMultibase = credentials.verificationMethods?.atproto?.replace('did:key:', '') || '' 241 + 242 + const didDoc = generateDidDocument( 243 + state.info.externalDid!.trim(), 244 + newPublicKeyMultibase, 245 + state.account!.handle, 246 + getPdsEndpoint() 247 + ) 248 + state.externalDidWeb.updatedDidDocument = JSON.stringify(didDoc, null, '\t') 249 + state.step = 'updated-did-doc' 250 + } else { 251 + await api.activateAccount(session.accessJwt) 252 + await finalizeSession() 253 + state.step = 'redirect-to-dashboard' 254 + } 255 + } else { 256 + state.session = { 257 + accessJwt: confirmResult.accessJwt, 258 + refreshJwt: confirmResult.refreshJwt, 259 + } 260 + await finalizeSession() 261 + state.step = 'redirect-to-dashboard' 262 + } 263 + } catch (err) { 264 + setError(err) 265 + } finally { 266 + state.submitting = false 267 + } 268 + } 269 + 270 + async function activateAccount() { 271 + state.submitting = true 272 + state.error = null 273 + 274 + try { 275 + await api.activateAccount(state.session!.accessJwt) 276 + await finalizeSession() 277 + state.step = 'redirect-to-dashboard' 278 + } catch (err) { 279 + setError(err) 280 + } finally { 281 + state.submitting = false 282 + } 283 + } 284 + 285 + function goBack() { 286 + switch (state.step) { 287 + case 'key-choice': 288 + state.step = 'info' 289 + break 290 + case 'initial-did-doc': 291 + state.step = 'key-choice' 292 + break 293 + case 'passkey': 294 + state.step = state.info.didType === 'web-external' ? 'initial-did-doc' : 'info' 295 + break 296 + } 297 + } 298 + 299 + async function finalizeSession() { 300 + if (!state.session || !state.account) return 301 + const { setSession } = await import('../auth.svelte') 302 + setSession({ 303 + did: state.account.did, 304 + handle: state.account.handle, 305 + accessJwt: state.session.accessJwt, 306 + refreshJwt: state.session.refreshJwt, 307 + }) 308 + } 309 + 310 + return { 311 + get state() { return state }, 312 + get info() { return state.info }, 313 + get externalDidWeb() { return state.externalDidWeb }, 314 + get account() { return state.account }, 315 + get session() { return state.session }, 316 + 317 + getPdsEndpoint, 318 + getPdsDid, 319 + getFullHandle, 320 + extractDomain, 321 + 322 + proceedFromInfo, 323 + selectKeyMode, 324 + confirmInitialDidDoc, 325 + createPasswordAccount, 326 + createPasskeyAccount, 327 + setPasskeyComplete, 328 + proceedFromAppPassword, 329 + verifyAccount, 330 + activateAccount, 331 + finalizeSession, 332 + goBack, 333 + 334 + setError(msg: string) { state.error = msg }, 335 + clearError() { state.error = null }, 336 + setSubmitting(val: boolean) { state.submitting = val }, 337 + } 338 + } 339 + 340 + export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>
+6
frontend/src/lib/registration/index.ts
···
··· 1 + export * from './types' 2 + export * from './flow.svelte' 3 + export { default as VerificationStep } from './VerificationStep.svelte' 4 + export { default as KeyChoiceStep } from './KeyChoiceStep.svelte' 5 + export { default as DidDocStep } from './DidDocStep.svelte' 6 + export { default as AppPasswordStep } from './AppPasswordStep.svelte'
+50
frontend/src/lib/registration/types.ts
···
··· 1 + import type { VerificationChannel, DidType } from '../api' 2 + 3 + export type RegistrationMode = 'password' | 'passkey' 4 + 5 + export type RegistrationStep = 6 + | 'info' 7 + | 'key-choice' 8 + | 'initial-did-doc' 9 + | 'creating' 10 + | 'passkey' 11 + | 'app-password' 12 + | 'verify' 13 + | 'updated-did-doc' 14 + | 'activating' 15 + | 'redirect-to-dashboard' 16 + 17 + export interface RegistrationInfo { 18 + handle: string 19 + email: string 20 + password?: string 21 + inviteCode?: string 22 + didType: DidType 23 + externalDid?: string 24 + verificationChannel: VerificationChannel 25 + discordId?: string 26 + telegramUsername?: string 27 + signalNumber?: string 28 + } 29 + 30 + export interface ExternalDidWebState { 31 + keyMode: 'reserved' | 'byod' 32 + reservedSigningKey?: string 33 + byodPrivateKey?: Uint8Array 34 + byodPublicKeyMultibase?: string 35 + initialDidDocument?: string 36 + updatedDidDocument?: string 37 + } 38 + 39 + export interface AccountResult { 40 + did: string 41 + handle: string 42 + setupToken?: string 43 + appPassword?: string 44 + appPasswordName?: string 45 + } 46 + 47 + export interface SessionState { 48 + accessJwt: string 49 + refreshJwt: string 50 + }
+169 -128
frontend/src/routes/Register.svelte
··· 1 <script lang="ts"> 2 - import { register, getAuthState } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 - import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api' 5 import { _ } from '../lib/i18n' 6 7 - const STORAGE_KEY = 'tranquil_pds_pending_verification' 8 - 9 - let handle = $state('') 10 - let email = $state('') 11 - let password = $state('') 12 - let confirmPassword = $state('') 13 - let inviteCode = $state('') 14 - let verificationChannel = $state<VerificationChannel>('email') 15 - let discordId = $state('') 16 - let telegramUsername = $state('') 17 - let signalNumber = $state('') 18 - let didType = $state<DidType>('plc') 19 - let externalDid = $state('') 20 - let submitting = $state(false) 21 - let error = $state<string | null>(null) 22 let serverInfo = $state<{ 23 availableUserDomains: string[] 24 inviteCodeRequired: boolean ··· 27 let loadingServerInfo = $state(true) 28 let serverInfoLoaded = false 29 30 - const auth = getAuthState() 31 32 $effect(() => { 33 if (!serverInfoLoaded) { ··· 36 } 37 }) 38 39 async function loadServerInfo() { 40 try { 41 serverInfo = await api.describeServer() 42 } catch (e) { 43 console.error('Failed to load server info:', e) 44 } finally { ··· 46 } 47 } 48 49 - let handleHasDot = $derived(handle.includes('.')) 50 - 51 - function isChannelAvailable(channel: string): boolean { 52 - const available = serverInfo?.availableCommsChannels ?? ['email'] 53 - return available.includes(channel) 54 - } 55 - 56 - function validateForm(): string | null { 57 - if (!handle.trim()) return $_('register.validation.handleRequired') 58 - if (handle.includes('.')) return $_('register.validation.handleNoDots') 59 - if (!password) return $_('register.validation.passwordRequired') 60 - if (password.length < 8) return $_('register.validation.passwordLength') 61 - if (password !== confirmPassword) return $_('register.validation.passwordsMismatch') 62 - if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) { 63 return $_('register.validation.inviteCodeRequired') 64 } 65 - if (didType === 'web-external') { 66 - if (!externalDid.trim()) return $_('register.validation.externalDidRequired') 67 - if (!externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat') 68 } 69 - switch (verificationChannel) { 70 case 'email': 71 - if (!email.trim()) return $_('register.validation.emailRequired') 72 break 73 case 'discord': 74 - if (!discordId.trim()) return $_('register.validation.discordIdRequired') 75 break 76 case 'telegram': 77 - if (!telegramUsername.trim()) return $_('register.validation.telegramRequired') 78 break 79 case 'signal': 80 - if (!signalNumber.trim()) return $_('register.validation.signalRequired') 81 break 82 } 83 return null 84 } 85 86 - async function handleSubmit(e: Event) { 87 e.preventDefault() 88 - const validationError = validateForm() 89 if (validationError) { 90 - error = validationError 91 return 92 } 93 - submitting = true 94 - error = null 95 - try { 96 - const result = await register({ 97 - handle: handle.trim(), 98 - email: email.trim(), 99 - password, 100 - inviteCode: inviteCode.trim() || undefined, 101 - didType, 102 - did: didType === 'web-external' ? externalDid.trim() : undefined, 103 - verificationChannel, 104 - discordId: discordId.trim() || undefined, 105 - telegramUsername: telegramUsername.trim() || undefined, 106 - signalNumber: signalNumber.trim() || undefined, 107 - }) 108 - if (result.verificationRequired) { 109 - localStorage.setItem(STORAGE_KEY, JSON.stringify({ 110 - did: result.did, 111 - handle: result.handle, 112 - channel: result.verificationChannel, 113 - })) 114 - navigate('/verify') 115 - } else { 116 - navigate('/dashboard') 117 - } 118 - } catch (err: any) { 119 - if (err instanceof ApiError) { 120 - error = err.message || 'Registration failed' 121 - } else if (err instanceof Error) { 122 - error = err.message || 'Registration failed' 123 - } else { 124 - error = 'Registration failed' 125 - } 126 - } finally { 127 - submitting = false 128 } 129 } 130 131 let fullHandle = $derived(() => { 132 - if (!handle.trim()) return '' 133 - if (handle.includes('.')) return handle.trim() 134 const domain = serverInfo?.availableUserDomains?.[0] 135 - if (domain) return `${handle.trim()}.${domain}` 136 - return handle.trim() 137 }) 138 </script> 139 140 <div class="register-page"> 141 - <div class="migrate-callout"> 142 - <div class="migrate-icon">↗</div> 143 - <div class="migrate-content"> 144 - <strong>{$_('register.migrateTitle')}</strong> 145 - <p>{$_('register.migrateDescription')}</p> 146 - <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link"> 147 - {$_('register.migrateLink')} → 148 - </a> 149 </div> 150 - </div> 151 - 152 - {#if error} 153 - <div class="message error">{error}</div> 154 {/if} 155 156 <h1>{$_('register.title')}</h1> 157 - <p class="subtitle">{$_('register.subtitle')}</p> 158 159 - {#if loadingServerInfo} 160 <p class="loading">{$_('common.loading')}</p> 161 - {:else} 162 - <form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}> 163 <div class="field"> 164 <label for="handle">{$_('register.handle')}</label> 165 <input 166 id="handle" 167 type="text" 168 - bind:value={handle} 169 placeholder={$_('register.handlePlaceholder')} 170 - disabled={submitting} 171 required 172 /> 173 - {#if handleHasDot} 174 <p class="hint warning">{$_('register.handleDotWarning')}</p> 175 {:else if fullHandle()} 176 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> ··· 182 <input 183 id="password" 184 type="password" 185 - bind:value={password} 186 placeholder={$_('register.passwordPlaceholder')} 187 - disabled={submitting} 188 required 189 minlength="8" 190 /> ··· 197 type="password" 198 bind:value={confirmPassword} 199 placeholder={$_('register.confirmPasswordPlaceholder')} 200 - disabled={submitting} 201 required 202 /> 203 </div> ··· 208 209 <div class="radio-group"> 210 <label class="radio-label"> 211 - <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} /> 212 <span class="radio-content"> 213 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 214 <span class="radio-hint">{$_('register.didPlcHint')}</span> ··· 216 </label> 217 218 <label class="radio-label"> 219 - <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} /> 220 <span class="radio-content"> 221 <strong>{$_('register.didWeb')}</strong> 222 <span class="radio-hint">{$_('register.didWebHint')}</span> ··· 224 </label> 225 226 <label class="radio-label"> 227 - <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} /> 228 <span class="radio-content"> 229 <strong>{$_('register.didWebBYOD')}</strong> 230 <span class="radio-hint">{$_('register.didWebBYODHint')}</span> ··· 232 </label> 233 </div> 234 235 - {#if didType === 'web'} 236 <div class="warning-box"> 237 <strong>{$_('register.didWebWarningTitle')}</strong> 238 <ul> ··· 244 </div> 245 {/if} 246 247 - {#if didType === 'web-external'} 248 <div class="field"> 249 <label for="external-did">{$_('register.externalDid')}</label> 250 <input 251 id="external-did" 252 type="text" 253 - bind:value={externalDid} 254 placeholder={$_('register.externalDidPlaceholder')} 255 - disabled={submitting} 256 required 257 /> 258 <p class="hint">{$_('register.externalDidHint')}</p> ··· 266 267 <div class="field"> 268 <label for="verification-channel">{$_('register.verificationMethod')}</label> 269 - <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 270 <option value="email">{$_('register.email')}</option> 271 <option value="discord" disabled={!isChannelAvailable('discord')}> 272 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} ··· 280 </select> 281 </div> 282 283 - {#if verificationChannel === 'email'} 284 <div class="field"> 285 <label for="email">{$_('register.emailAddress')}</label> 286 <input 287 id="email" 288 type="email" 289 - bind:value={email} 290 placeholder={$_('register.emailPlaceholder')} 291 - disabled={submitting} 292 required 293 /> 294 </div> 295 - {:else if verificationChannel === 'discord'} 296 <div class="field"> 297 <label for="discord-id">{$_('register.discordId')}</label> 298 <input 299 id="discord-id" 300 type="text" 301 - bind:value={discordId} 302 placeholder={$_('register.discordIdPlaceholder')} 303 - disabled={submitting} 304 required 305 /> 306 <p class="hint">{$_('register.discordIdHint')}</p> 307 </div> 308 - {:else if verificationChannel === 'telegram'} 309 <div class="field"> 310 <label for="telegram-username">{$_('register.telegramUsername')}</label> 311 <input 312 id="telegram-username" 313 type="text" 314 - bind:value={telegramUsername} 315 placeholder={$_('register.telegramUsernamePlaceholder')} 316 - disabled={submitting} 317 required 318 /> 319 </div> 320 - {:else if verificationChannel === 'signal'} 321 <div class="field"> 322 <label for="signal-number">{$_('register.signalNumber')}</label> 323 <input 324 id="signal-number" 325 type="tel" 326 - bind:value={signalNumber} 327 placeholder={$_('register.signalNumberPlaceholder')} 328 - disabled={submitting} 329 required 330 /> 331 <p class="hint">{$_('register.signalNumberHint')}</p> ··· 339 <input 340 id="invite-code" 341 type="text" 342 - bind:value={inviteCode} 343 placeholder={$_('register.inviteCodePlaceholder')} 344 - disabled={submitting} 345 required 346 /> 347 </div> 348 {/if} 349 350 - <button type="submit" disabled={submitting}> 351 - {submitting ? $_('register.creating') : $_('register.createButton')} 352 </button> 353 </form> 354 ··· 358 <p class="link-text"> 359 {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a> 360 </p> 361 {/if} 362 </div> 363
··· 1 <script lang="ts"> 2 import { navigate } from '../lib/router.svelte' 3 + import { api, ApiError } from '../lib/api' 4 import { _ } from '../lib/i18n' 5 + import { 6 + createRegistrationFlow, 7 + VerificationStep, 8 + KeyChoiceStep, 9 + DidDocStep, 10 + } from '../lib/registration' 11 12 let serverInfo = $state<{ 13 availableUserDomains: string[] 14 inviteCodeRequired: boolean ··· 17 let loadingServerInfo = $state(true) 18 let serverInfoLoaded = false 19 20 + let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 21 + let confirmPassword = $state('') 22 23 $effect(() => { 24 if (!serverInfoLoaded) { ··· 27 } 28 }) 29 30 + $effect(() => { 31 + if (flow?.state.step === 'redirect-to-dashboard') { 32 + navigate('/dashboard') 33 + } 34 + }) 35 + 36 async function loadServerInfo() { 37 try { 38 serverInfo = await api.describeServer() 39 + const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 40 + flow = createRegistrationFlow('password', hostname) 41 } catch (e) { 42 console.error('Failed to load server info:', e) 43 } finally { ··· 45 } 46 } 47 48 + function validateInfoStep(): string | null { 49 + if (!flow) return 'Flow not initialized' 50 + const info = flow.info 51 + if (!info.handle.trim()) return $_('register.validation.handleRequired') 52 + if (info.handle.includes('.')) return $_('register.validation.handleNoDots') 53 + if (!info.password) return $_('register.validation.passwordRequired') 54 + if (info.password.length < 8) return $_('register.validation.passwordLength') 55 + if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch') 56 + if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { 57 return $_('register.validation.inviteCodeRequired') 58 } 59 + if (info.didType === 'web-external') { 60 + if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired') 61 + if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat') 62 } 63 + switch (info.verificationChannel) { 64 case 'email': 65 + if (!info.email.trim()) return $_('register.validation.emailRequired') 66 break 67 case 'discord': 68 + if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired') 69 break 70 case 'telegram': 71 + if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired') 72 break 73 case 'signal': 74 + if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired') 75 break 76 } 77 return null 78 } 79 80 + async function handleInfoSubmit(e: Event) { 81 e.preventDefault() 82 + if (!flow) return 83 + 84 + const validationError = validateInfoStep() 85 if (validationError) { 86 + flow.setError(validationError) 87 return 88 } 89 + 90 + flow.clearError() 91 + flow.proceedFromInfo() 92 + } 93 + 94 + async function handleCreateAccount() { 95 + if (!flow) return 96 + await flow.createPasswordAccount() 97 + } 98 + 99 + async function handleComplete() { 100 + if (flow) { 101 + await flow.finalizeSession() 102 + } 103 + navigate('/dashboard') 104 + } 105 + 106 + function isChannelAvailable(ch: string): boolean { 107 + const available = serverInfo?.availableCommsChannels ?? ['email'] 108 + return available.includes(ch) 109 + } 110 + 111 + function channelLabel(ch: string): string { 112 + switch (ch) { 113 + case 'email': return $_('register.email') 114 + case 'discord': return $_('register.discord') 115 + case 'telegram': return $_('register.telegram') 116 + case 'signal': return $_('register.signal') 117 + default: return ch 118 } 119 } 120 121 let fullHandle = $derived(() => { 122 + if (!flow?.info.handle.trim()) return '' 123 + if (flow.info.handle.includes('.')) return flow.info.handle.trim() 124 const domain = serverInfo?.availableUserDomains?.[0] 125 + if (domain) return `${flow.info.handle.trim()}.${domain}` 126 + return flow.info.handle.trim() 127 }) 128 + 129 + function extractDomain(did: string): string { 130 + return did.replace('did:web:', '').replace(/%3A/g, ':') 131 + } 132 + 133 + function getSubtitle(): string { 134 + if (!flow) return '' 135 + switch (flow.state.step) { 136 + case 'info': return $_('register.subtitle') 137 + case 'key-choice': return 'Choose how to set up your external did:web identity.' 138 + case 'initial-did-doc': return 'Upload your DID document to continue.' 139 + case 'creating': return $_('register.creating') 140 + case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.` 141 + case 'updated-did-doc': return 'Update your DID document with the PDS signing key.' 142 + case 'activating': return 'Activating your account...' 143 + case 'complete': return 'Your account has been created successfully!' 144 + default: return '' 145 + } 146 + } 147 </script> 148 149 <div class="register-page"> 150 + {#if flow?.state.step === 'info'} 151 + <div class="migrate-callout"> 152 + <div class="migrate-icon">↗</div> 153 + <div class="migrate-content"> 154 + <strong>{$_('register.migrateTitle')}</strong> 155 + <p>{$_('register.migrateDescription')}</p> 156 + <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link"> 157 + {$_('register.migrateLink')} → 158 + </a> 159 + </div> 160 </div> 161 {/if} 162 163 <h1>{$_('register.title')}</h1> 164 + <p class="subtitle">{getSubtitle()}</p> 165 + 166 + {#if flow?.state.error} 167 + <div class="message error">{flow.state.error}</div> 168 + {/if} 169 170 + {#if loadingServerInfo || !flow} 171 <p class="loading">{$_('common.loading')}</p> 172 + 173 + {:else if flow.state.step === 'info'} 174 + <form onsubmit={handleInfoSubmit}> 175 <div class="field"> 176 <label for="handle">{$_('register.handle')}</label> 177 <input 178 id="handle" 179 type="text" 180 + bind:value={flow.info.handle} 181 placeholder={$_('register.handlePlaceholder')} 182 + disabled={flow.state.submitting} 183 required 184 /> 185 + {#if flow.info.handle.includes('.')} 186 <p class="hint warning">{$_('register.handleDotWarning')}</p> 187 {:else if fullHandle()} 188 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> ··· 194 <input 195 id="password" 196 type="password" 197 + bind:value={flow.info.password} 198 placeholder={$_('register.passwordPlaceholder')} 199 + disabled={flow.state.submitting} 200 required 201 minlength="8" 202 /> ··· 209 type="password" 210 bind:value={confirmPassword} 211 placeholder={$_('register.confirmPasswordPlaceholder')} 212 + disabled={flow.state.submitting} 213 required 214 /> 215 </div> ··· 220 221 <div class="radio-group"> 222 <label class="radio-label"> 223 + <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 224 <span class="radio-content"> 225 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 226 <span class="radio-hint">{$_('register.didPlcHint')}</span> ··· 228 </label> 229 230 <label class="radio-label"> 231 + <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 232 <span class="radio-content"> 233 <strong>{$_('register.didWeb')}</strong> 234 <span class="radio-hint">{$_('register.didWebHint')}</span> ··· 236 </label> 237 238 <label class="radio-label"> 239 + <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 240 <span class="radio-content"> 241 <strong>{$_('register.didWebBYOD')}</strong> 242 <span class="radio-hint">{$_('register.didWebBYODHint')}</span> ··· 244 </label> 245 </div> 246 247 + {#if flow.info.didType === 'web'} 248 <div class="warning-box"> 249 <strong>{$_('register.didWebWarningTitle')}</strong> 250 <ul> ··· 256 </div> 257 {/if} 258 259 + {#if flow.info.didType === 'web-external'} 260 <div class="field"> 261 <label for="external-did">{$_('register.externalDid')}</label> 262 <input 263 id="external-did" 264 type="text" 265 + bind:value={flow.info.externalDid} 266 placeholder={$_('register.externalDidPlaceholder')} 267 + disabled={flow.state.submitting} 268 required 269 /> 270 <p class="hint">{$_('register.externalDidHint')}</p> ··· 278 279 <div class="field"> 280 <label for="verification-channel">{$_('register.verificationMethod')}</label> 281 + <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 282 <option value="email">{$_('register.email')}</option> 283 <option value="discord" disabled={!isChannelAvailable('discord')}> 284 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} ··· 292 </select> 293 </div> 294 295 + {#if flow.info.verificationChannel === 'email'} 296 <div class="field"> 297 <label for="email">{$_('register.emailAddress')}</label> 298 <input 299 id="email" 300 type="email" 301 + bind:value={flow.info.email} 302 placeholder={$_('register.emailPlaceholder')} 303 + disabled={flow.state.submitting} 304 required 305 /> 306 </div> 307 + {:else if flow.info.verificationChannel === 'discord'} 308 <div class="field"> 309 <label for="discord-id">{$_('register.discordId')}</label> 310 <input 311 id="discord-id" 312 type="text" 313 + bind:value={flow.info.discordId} 314 placeholder={$_('register.discordIdPlaceholder')} 315 + disabled={flow.state.submitting} 316 required 317 /> 318 <p class="hint">{$_('register.discordIdHint')}</p> 319 </div> 320 + {:else if flow.info.verificationChannel === 'telegram'} 321 <div class="field"> 322 <label for="telegram-username">{$_('register.telegramUsername')}</label> 323 <input 324 id="telegram-username" 325 type="text" 326 + bind:value={flow.info.telegramUsername} 327 placeholder={$_('register.telegramUsernamePlaceholder')} 328 + disabled={flow.state.submitting} 329 required 330 /> 331 </div> 332 + {:else if flow.info.verificationChannel === 'signal'} 333 <div class="field"> 334 <label for="signal-number">{$_('register.signalNumber')}</label> 335 <input 336 id="signal-number" 337 type="tel" 338 + bind:value={flow.info.signalNumber} 339 placeholder={$_('register.signalNumberPlaceholder')} 340 + disabled={flow.state.submitting} 341 required 342 /> 343 <p class="hint">{$_('register.signalNumberHint')}</p> ··· 351 <input 352 id="invite-code" 353 type="text" 354 + bind:value={flow.info.inviteCode} 355 placeholder={$_('register.inviteCodePlaceholder')} 356 + disabled={flow.state.submitting} 357 required 358 /> 359 </div> 360 {/if} 361 362 + <button type="submit" disabled={flow.state.submitting}> 363 + {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 364 </button> 365 </form> 366 ··· 370 <p class="link-text"> 371 {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a> 372 </p> 373 + 374 + {:else if flow.state.step === 'key-choice'} 375 + <KeyChoiceStep {flow} /> 376 + 377 + {:else if flow.state.step === 'initial-did-doc'} 378 + <DidDocStep 379 + {flow} 380 + type="initial" 381 + onConfirm={handleCreateAccount} 382 + onBack={() => flow?.goBack()} 383 + /> 384 + 385 + {:else if flow.state.step === 'creating'} 386 + {#await flow.createPasswordAccount()} 387 + <p class="loading">{$_('register.creating')}</p> 388 + {/await} 389 + 390 + {:else if flow.state.step === 'verify'} 391 + <VerificationStep {flow} /> 392 + 393 + {:else if flow.state.step === 'updated-did-doc'} 394 + <DidDocStep 395 + {flow} 396 + type="updated" 397 + onConfirm={() => flow?.activateAccount()} 398 + /> 399 + 400 + {:else if flow.state.step === 'redirect-to-dashboard'} 401 + <p class="loading">Redirecting to dashboard...</p> 402 {/if} 403 </div> 404
+157 -328
frontend/src/routes/RegisterPasskey.svelte
··· 1 <script lang="ts"> 2 import { navigate } from '../lib/router.svelte' 3 - import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api' 4 - import { getAuthState, confirmSignup, resendVerification } from '../lib/auth.svelte' 5 import { _ } from '../lib/i18n' 6 7 - const auth = getAuthState() 8 - 9 - let step = $state<'info' | 'passkey' | 'app-password' | 'verify' | 'success'>('info') 10 - let handle = $state('') 11 - let email = $state('') 12 - let inviteCode = $state('') 13 - let didType = $state<DidType>('plc') 14 - let externalDid = $state('') 15 - let verificationChannel = $state<VerificationChannel>('email') 16 - let discordId = $state('') 17 - let telegramUsername = $state('') 18 - let signalNumber = $state('') 19 - let passkeyName = $state('') 20 - let submitting = $state(false) 21 - let error = $state<string | null>(null) 22 - let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean; availableCommsChannels?: string[] } | null>(null) 23 let loadingServerInfo = $state(true) 24 let serverInfoLoaded = false 25 26 - let setupData = $state<{ did: string; handle: string; setupToken: string } | null>(null) 27 - let appPasswordResult = $state<{ appPassword: string; appPasswordName: string } | null>(null) 28 - let appPasswordAcknowledged = $state(false) 29 - let appPasswordCopied = $state(false) 30 - let verificationCode = $state('') 31 - let resendingCode = $state(false) 32 - let resendMessage = $state<string | null>(null) 33 34 $effect(() => { 35 if (!serverInfoLoaded) { 36 serverInfoLoaded = true 37 loadServerInfo() 38 } 39 }) 40 41 async function loadServerInfo() { 42 try { 43 serverInfo = await api.describeServer() 44 } catch (e) { 45 console.error('Failed to load server info:', e) 46 } finally { ··· 49 } 50 51 function validateInfoStep(): string | null { 52 - if (!handle.trim()) return 'Handle is required' 53 - if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.' 54 - if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) { 55 return 'Invite code is required' 56 } 57 - if (didType === 'web-external') { 58 - if (!externalDid.trim()) return 'External did:web is required' 59 - if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:' 60 } 61 - switch (verificationChannel) { 62 case 'email': 63 - if (!email.trim()) return 'Email is required for email verification' 64 break 65 case 'discord': 66 - if (!discordId.trim()) return 'Discord ID is required for Discord verification' 67 break 68 case 'telegram': 69 - if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification' 70 break 71 case 'signal': 72 - if (!signalNumber.trim()) return 'Phone number is required for Signal verification' 73 break 74 } 75 return null ··· 112 113 async function handleInfoSubmit(e: Event) { 114 e.preventDefault() 115 const validationError = validateInfoStep() 116 if (validationError) { 117 - error = validationError 118 return 119 } 120 121 if (!window.PublicKeyCredential) { 122 - error = 'Passkeys are not supported in this browser. Please use a different browser or register with a password instead.' 123 return 124 } 125 126 - submitting = true 127 - error = null 128 129 - try { 130 - const result = await api.createPasskeyAccount({ 131 - handle: handle.trim(), 132 - email: email.trim() || undefined, 133 - inviteCode: inviteCode.trim() || undefined, 134 - didType, 135 - did: didType === 'web-external' ? externalDid.trim() : undefined, 136 - verificationChannel, 137 - discordId: discordId.trim() || undefined, 138 - telegramUsername: telegramUsername.trim() || undefined, 139 - signalNumber: signalNumber.trim() || undefined, 140 - }) 141 - 142 - setupData = { 143 - did: result.did, 144 - handle: result.handle, 145 - setupToken: result.setupToken, 146 - } 147 - 148 - step = 'passkey' 149 - } catch (err) { 150 - if (err instanceof ApiError) { 151 - error = err.message || 'Registration failed' 152 - } else if (err instanceof Error) { 153 - error = err.message || 'Registration failed' 154 - } else { 155 - error = 'Registration failed' 156 - } 157 - } finally { 158 - submitting = false 159 - } 160 } 161 162 async function handlePasskeyRegistration() { 163 - if (!setupData) return 164 165 - submitting = true 166 - error = null 167 168 try { 169 const { options } = await api.startPasskeyRegistrationForSetup( 170 - setupData.did, 171 - setupData.setupToken, 172 passkeyName || undefined 173 ) 174 ··· 178 }) 179 180 if (!credential) { 181 - error = 'Passkey creation was cancelled' 182 - submitting = false 183 return 184 } 185 ··· 196 } 197 198 const result = await api.completePasskeySetup( 199 - setupData.did, 200 - setupData.setupToken, 201 credentialResponse, 202 passkeyName || undefined 203 ) 204 205 - appPasswordResult = { 206 - appPassword: result.appPassword, 207 - appPasswordName: result.appPasswordName, 208 - } 209 - 210 - step = 'app-password' 211 } catch (err) { 212 if (err instanceof DOMException && err.name === 'NotAllowedError') { 213 - error = 'Passkey creation was cancelled' 214 } else if (err instanceof ApiError) { 215 - error = err.message || 'Passkey registration failed' 216 } else if (err instanceof Error) { 217 - error = err.message || 'Passkey registration failed' 218 } else { 219 - error = 'Passkey registration failed' 220 } 221 } finally { 222 - submitting = false 223 } 224 } 225 226 - function copyAppPassword() { 227 - if (appPasswordResult) { 228 - navigator.clipboard.writeText(appPasswordResult.appPassword) 229 - appPasswordCopied = true 230 } 231 } 232 233 - function handleFinish() { 234 - step = 'verify' 235 - } 236 - 237 - async function handleVerification() { 238 - if (!setupData || !verificationCode.trim()) return 239 - 240 - submitting = true 241 - error = null 242 - 243 - try { 244 - await confirmSignup(setupData.did, verificationCode.trim()) 245 - navigate('/dashboard') 246 - } catch (err) { 247 - if (err instanceof ApiError) { 248 - error = err.message || 'Verification failed' 249 - } else if (err instanceof Error) { 250 - error = err.message || 'Verification failed' 251 - } else { 252 - error = 'Verification failed' 253 - } 254 - } finally { 255 - submitting = false 256 - } 257 - } 258 - 259 - async function handleResendCode() { 260 - if (!setupData || resendingCode) return 261 - 262 - resendingCode = true 263 - resendMessage = null 264 - error = null 265 - 266 - try { 267 - await resendVerification(setupData.did) 268 - resendMessage = 'Verification code resent!' 269 - } catch (err) { 270 - if (err instanceof ApiError) { 271 - error = err.message || 'Failed to resend code' 272 - } else if (err instanceof Error) { 273 - error = err.message || 'Failed to resend code' 274 - } else { 275 - error = 'Failed to resend code' 276 - } 277 - } finally { 278 - resendingCode = false 279 - } 280 } 281 282 function channelLabel(ch: string): string { ··· 289 } 290 } 291 292 - function isChannelAvailable(ch: string): boolean { 293 - const available = serverInfo?.availableCommsChannels ?? ['email'] 294 - return available.includes(ch) 295 - } 296 297 - function goToLogin() { 298 - navigate('/login') 299 } 300 301 - let fullHandle = $derived(() => { 302 - if (!handle.trim()) return '' 303 - if (handle.includes('.')) return handle.trim() 304 - const domain = serverInfo?.availableUserDomains?.[0] 305 - if (domain) return `${handle.trim()}.${domain}` 306 - return handle.trim() 307 - }) 308 </script> 309 310 <div class="register-page"> 311 - {#if step === 'info'} 312 <div class="migrate-callout"> 313 <div class="migrate-icon">↗</div> 314 <div class="migrate-content"> ··· 322 {/if} 323 324 <h1>Create Passkey Account</h1> 325 - <p class="subtitle"> 326 - {#if step === 'info'} 327 - Create an ultra-secure account using a passkey instead of a password. 328 - {:else if step === 'passkey'} 329 - Register your passkey to secure your account. 330 - {:else if step === 'app-password'} 331 - Save your app password for third-party apps. 332 - {:else if step === 'verify'} 333 - Verify your {channelLabel(verificationChannel)} to complete registration. 334 - {:else} 335 - Your account has been created successfully! 336 - {/if} 337 - </p> 338 339 - {#if error} 340 - <div class="message error">{error}</div> 341 {/if} 342 343 - {#if loadingServerInfo} 344 <p class="loading">Loading...</p> 345 - {:else if step === 'info'} 346 <form onsubmit={handleInfoSubmit}> 347 <div class="field"> 348 <label for="handle">Handle</label> 349 <input 350 id="handle" 351 type="text" 352 - bind:value={handle} 353 placeholder="yourname" 354 - disabled={submitting} 355 required 356 /> 357 - {#if handle.includes('.')} 358 <p class="hint warning">Custom domain handles can be set up after account creation.</p> 359 {:else if fullHandle()} 360 <p class="hint">Your full handle will be: @{fullHandle()}</p> ··· 366 <p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p> 367 <div class="field"> 368 <label for="verification-channel">Verification Method</label> 369 - <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 370 <option value="email">Email</option> 371 <option value="discord" disabled={!isChannelAvailable('discord')}> 372 Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} ··· 379 </option> 380 </select> 381 </div> 382 - {#if verificationChannel === 'email'} 383 <div class="field"> 384 <label for="email">Email Address</label> 385 - <input id="email" type="email" bind:value={email} placeholder="you@example.com" disabled={submitting} required /> 386 </div> 387 - {:else if verificationChannel === 'discord'} 388 <div class="field"> 389 <label for="discord-id">Discord User ID</label> 390 - <input id="discord-id" type="text" bind:value={discordId} placeholder="Your Discord user ID" disabled={submitting} required /> 391 <p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p> 392 </div> 393 - {:else if verificationChannel === 'telegram'} 394 <div class="field"> 395 <label for="telegram-username">Telegram Username</label> 396 - <input id="telegram-username" type="text" bind:value={telegramUsername} placeholder="@yourusername" disabled={submitting} required /> 397 </div> 398 - {:else if verificationChannel === 'signal'} 399 <div class="field"> 400 <label for="signal-number">Signal Phone Number</label> 401 - <input id="signal-number" type="tel" bind:value={signalNumber} placeholder="+1234567890" disabled={submitting} required /> 402 <p class="hint">Include country code (e.g., +1 for US)</p> 403 </div> 404 {/if} ··· 409 <p class="section-hint">Choose how your decentralized identity will be managed.</p> 410 <div class="radio-group"> 411 <label class="radio-label"> 412 - <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} /> 413 <span class="radio-content"> 414 <strong>did:plc</strong> (Recommended) 415 <span class="radio-hint">Portable identity managed by PLC Directory</span> 416 </span> 417 </label> 418 <label class="radio-label"> 419 - <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} /> 420 <span class="radio-content"> 421 <strong>did:web</strong> 422 <span class="radio-hint">Identity hosted on this PDS (read warning below)</span> 423 </span> 424 </label> 425 <label class="radio-label"> 426 - <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} /> 427 <span class="radio-content"> 428 <strong>did:web (BYOD)</strong> 429 <span class="radio-hint">Bring your own domain</span> 430 </span> 431 </label> 432 </div> 433 - {#if didType === 'web'} 434 <div class="warning-box"> 435 <strong>Important: Understand the trade-offs</strong> 436 <ul> ··· 441 </ul> 442 </div> 443 {/if} 444 - {#if didType === 'web-external'} 445 <div class="field"> 446 <label for="external-did">Your did:web</label> 447 - <input id="external-did" type="text" bind:value={externalDid} placeholder="did:web:yourdomain.com" disabled={submitting} required /> 448 - <p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p> 449 </div> 450 {/if} 451 </fieldset> ··· 453 {#if serverInfo?.inviteCodeRequired} 454 <div class="field"> 455 <label for="invite-code">Invite Code <span class="required">*</span></label> 456 - <input id="invite-code" type="text" bind:value={inviteCode} placeholder="Enter your invite code" disabled={submitting} required /> 457 </div> 458 {/if} 459 ··· 467 </ul> 468 </div> 469 470 - <button type="submit" disabled={submitting}> 471 - {submitting ? 'Creating account...' : 'Continue'} 472 </button> 473 </form> 474 475 <p class="link-text"> 476 Want a traditional password? <a href="#/register">Register with password</a> 477 </p> 478 - {:else if step === 'passkey'} 479 <div class="step-content"> 480 <div class="field"> 481 <label for="passkey-name">Passkey Name (optional)</label> 482 - <input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={submitting} /> 483 <p class="hint">A friendly name to identify this passkey</p> 484 </div> 485 ··· 492 </ul> 493 </div> 494 495 - <button onclick={handlePasskeyRegistration} disabled={submitting} class="passkey-btn"> 496 - {submitting ? 'Creating Passkey...' : 'Create Passkey'} 497 </button> 498 499 - <button type="button" class="secondary" onclick={() => step = 'info'} disabled={submitting}> 500 Back 501 </button> 502 </div> 503 - {:else if step === 'app-password'} 504 - <div class="step-content"> 505 - <div class="warning-box"> 506 - <strong>Important: Save this app password!</strong> 507 - <p>This app password is required to sign into apps that don't support passkeys yet (like bsky.app). You will only see this password once.</p> 508 - </div> 509 510 - <div class="app-password-display"> 511 - <div class="app-password-label">App Password for: <strong>{appPasswordResult?.appPasswordName}</strong></div> 512 - <code class="app-password-code">{appPasswordResult?.appPassword}</code> 513 - <button type="button" class="copy-btn" onclick={copyAppPassword}> 514 - {appPasswordCopied ? 'Copied!' : 'Copy to Clipboard'} 515 - </button> 516 - </div> 517 - 518 - <div class="field"> 519 - <label class="checkbox-label"> 520 - <input type="checkbox" bind:checked={appPasswordAcknowledged} /> 521 - <span>I have saved my app password in a secure location</span> 522 - </label> 523 - </div> 524 525 - <button onclick={handleFinish} disabled={!appPasswordAcknowledged}>Continue</button> 526 - </div> 527 - {:else if step === 'verify'} 528 - <div class="step-content"> 529 - <p class="info-text">We've sent a verification code to your {channelLabel(verificationChannel)}. Enter it below to complete your account setup.</p> 530 531 - {#if resendMessage} 532 - <div class="message success">{resendMessage}</div> 533 - {/if} 534 535 - <form onsubmit={(e) => { e.preventDefault(); handleVerification(); }}> 536 - <div class="field"> 537 - <label for="verification-code">Verification Code</label> 538 - <input id="verification-code" type="text" bind:value={verificationCode} placeholder="Enter 6-digit code" disabled={submitting} required maxlength="6" inputmode="numeric" autocomplete="one-time-code" /> 539 - </div> 540 - 541 - <button type="submit" disabled={submitting || !verificationCode.trim()}> 542 - {submitting ? 'Verifying...' : 'Verify Account'} 543 - </button> 544 - 545 - <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 546 - {resendingCode ? 'Resending...' : 'Resend Code'} 547 - </button> 548 - </form> 549 - </div> 550 - {:else if step === 'success'} 551 - <div class="success-content"> 552 - <div class="success-icon">&#x2714;</div> 553 - <h2>Account Created!</h2> 554 - <p>Your passkey-only account has been created successfully.</p> 555 - <p class="handle-display">@{setupData?.handle}</p> 556 - <button onclick={goToLogin}>Sign In</button> 557 - </div> 558 {/if} 559 </div> 560 ··· 609 text-decoration: underline; 610 } 611 612 - h1, h2 { 613 margin: 0 0 var(--space-3) 0; 614 } 615 ··· 697 color: var(--warning-text); 698 } 699 700 - .warning-box p { 701 - margin: 0; 702 - color: var(--warning-text); 703 - } 704 - 705 .warning-box ul { 706 margin: var(--space-4) 0 0 0; 707 padding-left: var(--space-5); ··· 747 .passkey-btn { 748 padding: var(--space-5); 749 font-size: var(--text-lg); 750 - } 751 - 752 - .app-password-display { 753 - background: var(--bg-card); 754 - border: 2px solid var(--accent); 755 - border-radius: var(--radius-xl); 756 - padding: var(--space-6); 757 - text-align: center; 758 - } 759 - 760 - .app-password-label { 761 - font-size: var(--text-sm); 762 - color: var(--text-secondary); 763 - margin-bottom: var(--space-4); 764 - } 765 - 766 - .app-password-code { 767 - display: block; 768 - font-size: var(--text-xl); 769 - font-family: ui-monospace, monospace; 770 - letter-spacing: 0.1em; 771 - padding: var(--space-5); 772 - background: var(--bg-input); 773 - border-radius: var(--radius-md); 774 - margin-bottom: var(--space-4); 775 - user-select: all; 776 - } 777 - 778 - .copy-btn { 779 - margin-top: 0; 780 - padding: var(--space-3) var(--space-5); 781 - font-size: var(--text-sm); 782 - } 783 - 784 - .checkbox-label { 785 - display: flex; 786 - align-items: center; 787 - gap: var(--space-3); 788 - cursor: pointer; 789 - font-weight: var(--font-normal); 790 - } 791 - 792 - .checkbox-label input[type="checkbox"] { 793 - width: auto; 794 - padding: 0; 795 - } 796 - 797 - .success-content { 798 - text-align: center; 799 - } 800 - 801 - .success-icon { 802 - font-size: var(--text-4xl); 803 - color: var(--success-text); 804 - margin-bottom: var(--space-4); 805 - } 806 - 807 - .success-content p { 808 - color: var(--text-secondary); 809 - } 810 - 811 - .handle-display { 812 - font-size: var(--text-xl); 813 - font-weight: var(--font-semibold); 814 - color: var(--text-primary); 815 - margin: var(--space-4) 0; 816 - } 817 - 818 - .info-text { 819 - color: var(--text-secondary); 820 - margin: 0; 821 } 822 823 .link-text {
··· 1 <script lang="ts"> 2 import { navigate } from '../lib/router.svelte' 3 + import { api, ApiError } from '../lib/api' 4 import { _ } from '../lib/i18n' 5 + import { 6 + createRegistrationFlow, 7 + VerificationStep, 8 + KeyChoiceStep, 9 + DidDocStep, 10 + AppPasswordStep, 11 + } from '../lib/registration' 12 13 + let serverInfo = $state<{ 14 + availableUserDomains: string[] 15 + inviteCodeRequired: boolean 16 + availableCommsChannels?: string[] 17 + } | null>(null) 18 let loadingServerInfo = $state(true) 19 let serverInfoLoaded = false 20 21 + let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 22 + let passkeyName = $state('') 23 24 $effect(() => { 25 if (!serverInfoLoaded) { 26 serverInfoLoaded = true 27 loadServerInfo() 28 + } 29 + }) 30 + 31 + $effect(() => { 32 + if (flow?.state.step === 'redirect-to-dashboard') { 33 + navigate('/dashboard') 34 } 35 }) 36 37 async function loadServerInfo() { 38 try { 39 serverInfo = await api.describeServer() 40 + const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 41 + flow = createRegistrationFlow('passkey', hostname) 42 } catch (e) { 43 console.error('Failed to load server info:', e) 44 } finally { ··· 47 } 48 49 function validateInfoStep(): string | null { 50 + if (!flow) return 'Flow not initialized' 51 + const info = flow.info 52 + if (!info.handle.trim()) return 'Handle is required' 53 + if (info.handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.' 54 + if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { 55 return 'Invite code is required' 56 } 57 + if (info.didType === 'web-external') { 58 + if (!info.externalDid?.trim()) return 'External did:web is required' 59 + if (!info.externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:' 60 } 61 + switch (info.verificationChannel) { 62 case 'email': 63 + if (!info.email.trim()) return 'Email is required for email verification' 64 break 65 case 'discord': 66 + if (!info.discordId?.trim()) return 'Discord ID is required for Discord verification' 67 break 68 case 'telegram': 69 + if (!info.telegramUsername?.trim()) return 'Telegram username is required for Telegram verification' 70 break 71 case 'signal': 72 + if (!info.signalNumber?.trim()) return 'Phone number is required for Signal verification' 73 break 74 } 75 return null ··· 112 113 async function handleInfoSubmit(e: Event) { 114 e.preventDefault() 115 + if (!flow) return 116 + 117 const validationError = validateInfoStep() 118 if (validationError) { 119 + flow.setError(validationError) 120 return 121 } 122 123 if (!window.PublicKeyCredential) { 124 + flow.setError('Passkeys are not supported in this browser. Please use a different browser or register with a password instead.') 125 return 126 } 127 128 + flow.clearError() 129 + flow.proceedFromInfo() 130 + } 131 132 + async function handleCreateAccount() { 133 + if (!flow) return 134 + await flow.createPasskeyAccount() 135 } 136 137 async function handlePasskeyRegistration() { 138 + if (!flow || !flow.account) return 139 140 + flow.setSubmitting(true) 141 + flow.clearError() 142 143 try { 144 const { options } = await api.startPasskeyRegistrationForSetup( 145 + flow.account.did, 146 + flow.account.setupToken!, 147 passkeyName || undefined 148 ) 149 ··· 153 }) 154 155 if (!credential) { 156 + flow.setError('Passkey creation was cancelled') 157 + flow.setSubmitting(false) 158 return 159 } 160 ··· 171 } 172 173 const result = await api.completePasskeySetup( 174 + flow.account.did, 175 + flow.account.setupToken!, 176 credentialResponse, 177 passkeyName || undefined 178 ) 179 180 + flow.setPasskeyComplete(result.appPassword, result.appPasswordName) 181 } catch (err) { 182 if (err instanceof DOMException && err.name === 'NotAllowedError') { 183 + flow.setError('Passkey creation was cancelled') 184 } else if (err instanceof ApiError) { 185 + flow.setError(err.message || 'Passkey registration failed') 186 } else if (err instanceof Error) { 187 + flow.setError(err.message || 'Passkey registration failed') 188 } else { 189 + flow.setError('Passkey registration failed') 190 } 191 } finally { 192 + flow.setSubmitting(false) 193 } 194 } 195 196 + async function handleComplete() { 197 + if (flow) { 198 + await flow.finalizeSession() 199 } 200 + navigate('/dashboard') 201 } 202 203 + function isChannelAvailable(ch: string): boolean { 204 + const available = serverInfo?.availableCommsChannels ?? ['email'] 205 + return available.includes(ch) 206 } 207 208 function channelLabel(ch: string): string { ··· 215 } 216 } 217 218 + let fullHandle = $derived(() => { 219 + if (!flow?.info.handle.trim()) return '' 220 + if (flow.info.handle.includes('.')) return flow.info.handle.trim() 221 + const domain = serverInfo?.availableUserDomains?.[0] 222 + if (domain) return `${flow.info.handle.trim()}.${domain}` 223 + return flow.info.handle.trim() 224 + }) 225 226 + function extractDomain(did: string): string { 227 + return did.replace('did:web:', '').replace(/%3A/g, ':') 228 } 229 230 + function getSubtitle(): string { 231 + if (!flow) return '' 232 + switch (flow.state.step) { 233 + case 'info': return 'Create an ultra-secure account using a passkey instead of a password.' 234 + case 'key-choice': return 'Choose how to set up your external did:web identity.' 235 + case 'initial-did-doc': return 'Upload your DID document to continue.' 236 + case 'creating': return 'Creating your account...' 237 + case 'passkey': return 'Register your passkey to secure your account.' 238 + case 'app-password': return 'Save your app password for third-party apps.' 239 + case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.` 240 + case 'updated-did-doc': return 'Update your DID document with the PDS signing key.' 241 + case 'activating': return 'Activating your account...' 242 + case 'complete': return 'Your account has been created successfully!' 243 + default: return '' 244 + } 245 + } 246 </script> 247 248 <div class="register-page"> 249 + {#if flow?.state.step === 'info'} 250 <div class="migrate-callout"> 251 <div class="migrate-icon">↗</div> 252 <div class="migrate-content"> ··· 260 {/if} 261 262 <h1>Create Passkey Account</h1> 263 + <p class="subtitle">{getSubtitle()}</p> 264 265 + {#if flow?.state.error} 266 + <div class="message error">{flow.state.error}</div> 267 {/if} 268 269 + {#if loadingServerInfo || !flow} 270 <p class="loading">Loading...</p> 271 + 272 + {:else if flow.state.step === 'info'} 273 <form onsubmit={handleInfoSubmit}> 274 <div class="field"> 275 <label for="handle">Handle</label> 276 <input 277 id="handle" 278 type="text" 279 + bind:value={flow.info.handle} 280 placeholder="yourname" 281 + disabled={flow.state.submitting} 282 required 283 /> 284 + {#if flow.info.handle.includes('.')} 285 <p class="hint warning">Custom domain handles can be set up after account creation.</p> 286 {:else if fullHandle()} 287 <p class="hint">Your full handle will be: @{fullHandle()}</p> ··· 293 <p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p> 294 <div class="field"> 295 <label for="verification-channel">Verification Method</label> 296 + <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 297 <option value="email">Email</option> 298 <option value="discord" disabled={!isChannelAvailable('discord')}> 299 Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} ··· 306 </option> 307 </select> 308 </div> 309 + {#if flow.info.verificationChannel === 'email'} 310 <div class="field"> 311 <label for="email">Email Address</label> 312 + <input id="email" type="email" bind:value={flow.info.email} placeholder="you@example.com" disabled={flow.state.submitting} required /> 313 </div> 314 + {:else if flow.info.verificationChannel === 'discord'} 315 <div class="field"> 316 <label for="discord-id">Discord User ID</label> 317 + <input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder="Your Discord user ID" disabled={flow.state.submitting} required /> 318 <p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p> 319 </div> 320 + {:else if flow.info.verificationChannel === 'telegram'} 321 <div class="field"> 322 <label for="telegram-username">Telegram Username</label> 323 + <input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder="@yourusername" disabled={flow.state.submitting} required /> 324 </div> 325 + {:else if flow.info.verificationChannel === 'signal'} 326 <div class="field"> 327 <label for="signal-number">Signal Phone Number</label> 328 + <input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder="+1234567890" disabled={flow.state.submitting} required /> 329 <p class="hint">Include country code (e.g., +1 for US)</p> 330 </div> 331 {/if} ··· 336 <p class="section-hint">Choose how your decentralized identity will be managed.</p> 337 <div class="radio-group"> 338 <label class="radio-label"> 339 + <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 340 <span class="radio-content"> 341 <strong>did:plc</strong> (Recommended) 342 <span class="radio-hint">Portable identity managed by PLC Directory</span> 343 </span> 344 </label> 345 <label class="radio-label"> 346 + <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 347 <span class="radio-content"> 348 <strong>did:web</strong> 349 <span class="radio-hint">Identity hosted on this PDS (read warning below)</span> 350 </span> 351 </label> 352 <label class="radio-label"> 353 + <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 354 <span class="radio-content"> 355 <strong>did:web (BYOD)</strong> 356 <span class="radio-hint">Bring your own domain</span> 357 </span> 358 </label> 359 </div> 360 + {#if flow.info.didType === 'web'} 361 <div class="warning-box"> 362 <strong>Important: Understand the trade-offs</strong> 363 <ul> ··· 368 </ul> 369 </div> 370 {/if} 371 + {#if flow.info.didType === 'web-external'} 372 <div class="field"> 373 <label for="external-did">Your did:web</label> 374 + <input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder="did:web:yourdomain.com" disabled={flow.state.submitting} required /> 375 + <p class="hint">You'll need to serve a DID document at <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p> 376 </div> 377 {/if} 378 </fieldset> ··· 380 {#if serverInfo?.inviteCodeRequired} 381 <div class="field"> 382 <label for="invite-code">Invite Code <span class="required">*</span></label> 383 + <input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder="Enter your invite code" disabled={flow.state.submitting} required /> 384 </div> 385 {/if} 386 ··· 394 </ul> 395 </div> 396 397 + <button type="submit" disabled={flow.state.submitting}> 398 + {flow.state.submitting ? 'Creating account...' : 'Continue'} 399 </button> 400 </form> 401 402 <p class="link-text"> 403 Want a traditional password? <a href="#/register">Register with password</a> 404 </p> 405 + 406 + {:else if flow.state.step === 'key-choice'} 407 + <KeyChoiceStep {flow} /> 408 + 409 + {:else if flow.state.step === 'initial-did-doc'} 410 + <DidDocStep 411 + {flow} 412 + type="initial" 413 + onConfirm={handleCreateAccount} 414 + onBack={() => flow?.goBack()} 415 + /> 416 + 417 + {:else if flow.state.step === 'creating'} 418 + {#await flow.createPasskeyAccount()} 419 + <p class="loading">Creating your account...</p> 420 + {/await} 421 + 422 + {:else if flow.state.step === 'passkey'} 423 <div class="step-content"> 424 <div class="field"> 425 <label for="passkey-name">Passkey Name (optional)</label> 426 + <input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={flow.state.submitting} /> 427 <p class="hint">A friendly name to identify this passkey</p> 428 </div> 429 ··· 436 </ul> 437 </div> 438 439 + <button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn"> 440 + {flow.state.submitting ? 'Creating Passkey...' : 'Create Passkey'} 441 </button> 442 443 + <button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}> 444 Back 445 </button> 446 </div> 447 448 + {:else if flow.state.step === 'app-password'} 449 + <AppPasswordStep {flow} /> 450 451 + {:else if flow.state.step === 'verify'} 452 + <VerificationStep {flow} /> 453 454 + {:else if flow.state.step === 'updated-did-doc'} 455 + <DidDocStep 456 + {flow} 457 + type="updated" 458 + onConfirm={() => flow?.activateAccount()} 459 + /> 460 461 + {:else if flow.state.step === 'redirect-to-dashboard'} 462 + <p class="loading">Redirecting to dashboard...</p> 463 {/if} 464 </div> 465 ··· 514 text-decoration: underline; 515 } 516 517 + h1 { 518 margin: 0 0 var(--space-3) 0; 519 } 520 ··· 602 color: var(--warning-text); 603 } 604 605 .warning-box ul { 606 margin: var(--space-4) 0 0 0; 607 padding-left: var(--space-5); ··· 647 .passkey-btn { 648 padding: var(--space-5); 649 font-size: var(--text-lg); 650 } 651 652 .link-text {
+14 -12
src/api/identity/account.rs
··· 118 None 119 }; 120 121 - let is_migration = migration_auth.is_some() 122 && input 123 .did 124 .as_ref() 125 - .map(|d| d.starts_with("did:plc:") || d.starts_with("did:web:")) 126 .unwrap_or(false); 127 128 - let is_did_web_byod = migration_auth.is_some() 129 && input 130 .did 131 .as_ref() 132 - .map(|d| d.starts_with("did:web:")) 133 .unwrap_or(false); 134 135 - if is_migration { 136 - if let (Some(migration_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) 137 { 138 - if migration_did != auth_did { 139 return ( 140 StatusCode::FORBIDDEN, 141 Json(json!({ 142 "error": "AuthorizationError", 143 - "message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did) 144 })), 145 ) 146 .into_response(); 147 } 148 if is_did_web_byod { 149 - info!(did = %migration_did, "Processing did:web BYOD account creation"); 150 } else { 151 - info!(did = %migration_did, "Processing account migration"); 152 } 153 } 154 } ··· 717 .await 718 .map(|c| c.unwrap_or(0) == 0) 719 .unwrap_or(false); 720 - let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration { 721 Some(chrono::Utc::now()) 722 } else { 723 None ··· 946 ) 947 .into_response(); 948 } 949 - if !is_migration { 950 if let Err(e) = 951 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 952 { ··· 972 { 973 warn!("Failed to create default profile for {}: {}", did, e); 974 } 975 if let Some(ref recipient) = verification_recipient 976 && let Err(e) = crate::comms::enqueue_signup_verification( 977 &state.db,
··· 118 None 119 }; 120 121 + let is_did_web_byod = migration_auth.is_some() 122 && input 123 .did 124 .as_ref() 125 + .map(|d| d.starts_with("did:web:")) 126 .unwrap_or(false); 127 128 + let is_migration = migration_auth.is_some() 129 && input 130 .did 131 .as_ref() 132 + .map(|d| d.starts_with("did:plc:")) 133 .unwrap_or(false); 134 135 + if is_migration || is_did_web_byod { 136 + if let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) 137 { 138 + if provided_did != auth_did { 139 return ( 140 StatusCode::FORBIDDEN, 141 Json(json!({ 142 "error": "AuthorizationError", 143 + "message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did) 144 })), 145 ) 146 .into_response(); 147 } 148 if is_did_web_byod { 149 + info!(did = %provided_did, "Processing did:web BYOD account creation"); 150 } else { 151 + info!(did = %provided_did, "Processing account migration"); 152 } 153 } 154 } ··· 717 .await 718 .map(|c| c.unwrap_or(0) == 0) 719 .unwrap_or(false); 720 + let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration || is_did_web_byod { 721 Some(chrono::Utc::now()) 722 } else { 723 None ··· 946 ) 947 .into_response(); 948 } 949 + if !is_migration && !is_did_web_byod { 950 if let Err(e) = 951 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 952 { ··· 972 { 973 warn!("Failed to create default profile for {}: {}", did, e); 974 } 975 + } 976 + if !is_migration { 977 if let Some(ref recipient) = verification_recipient 978 && let Err(e) = crate::comms::enqueue_signup_verification( 979 &state.db,
+105 -40
src/api/server/passkey_account.rs
··· 12 use serde::{Deserialize, Serialize}; 13 use serde_json::json; 14 use std::sync::Arc; 15 - use tracing::{error, info, warn}; 16 use uuid::Uuid; 17 18 use crate::api::repo::record::utils::create_signed_commit; 19 use crate::state::{AppState, RateLimitKind}; 20 use crate::validation::validate_password; 21 ··· 105 ) 106 .into_response(); 107 } 108 109 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 110 let pds_suffix = format!(".{}", hostname); ··· 301 ) 302 .into_response(); 303 } 304 - if let Err(e) = crate::api::identity::did::verify_did_web( 305 - d, 306 - &hostname, 307 - &input.handle, 308 - input.signing_key.as_deref(), 309 - ) 310 - .await 311 - { 312 - return ( 313 - StatusCode::BAD_REQUEST, 314 - Json(json!({"error": "InvalidDid", "message": e})), 315 ) 316 - .into_response(); 317 } 318 - info!(did = %d, "Creating external did:web passkey account"); 319 d.to_string() 320 } 321 _ => { ··· 398 .map(|c| c.unwrap_or(0) == 0) 399 .unwrap_or(false); 400 401 let user_insert: Result<(Uuid,), _> = sqlx::query_as( 402 r#"INSERT INTO users ( 403 handle, email, did, password_hash, password_required, 404 preferred_comms_channel, 405 discord_id, telegram_username, signal_number, 406 recovery_token, recovery_token_expires_at, 407 - is_admin 408 - ) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10) RETURNING id"#, 409 ) 410 .bind(&handle) 411 .bind(&email) ··· 435 .bind(&setup_token_hash) 436 .bind(setup_expires_at) 437 .bind(is_first_user) 438 .fetch_one(&mut *tx) 439 .await; 440 ··· 612 .into_response(); 613 } 614 615 - if let Err(e) = 616 - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 617 - { 618 - warn!("Failed to sequence identity event for {}: {}", did, e); 619 - } 620 - if let Err(e) = 621 - crate::api::repo::record::sequence_account_event(&state, &did, true, None).await 622 - { 623 - warn!("Failed to sequence account event for {}: {}", did, e); 624 - } 625 - let profile_record = serde_json::json!({ 626 - "$type": "app.bsky.actor.profile", 627 - "displayName": handle 628 - }); 629 - if let Err(e) = crate::api::repo::record::create_record_internal( 630 - &state, 631 - &did, 632 - "app.bsky.actor.profile", 633 - "self", 634 - &profile_record, 635 - ) 636 - .await 637 - { 638 - warn!("Failed to create default profile for {}: {}", did, e); 639 } 640 641 if let Err(e) = crate::comms::enqueue_signup_verification(
··· 12 use serde::{Deserialize, Serialize}; 13 use serde_json::json; 14 use std::sync::Arc; 15 + use tracing::{debug, error, info, warn}; 16 use uuid::Uuid; 17 18 use crate::api::repo::record::utils::create_signed_commit; 19 + use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token}; 20 use crate::state::{AppState, RateLimitKind}; 21 use crate::validation::validate_password; 22 ··· 106 ) 107 .into_response(); 108 } 109 + 110 + let byod_auth = if let Some(token) = 111 + extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok())) 112 + { 113 + if is_service_token(&token) { 114 + let verifier = ServiceTokenVerifier::new(); 115 + match verifier 116 + .verify_service_token(&token, Some("com.atproto.server.createAccount")) 117 + .await 118 + { 119 + Ok(claims) => { 120 + debug!("Service token verified for BYOD did:web: iss={}", claims.iss); 121 + Some(claims.iss) 122 + } 123 + Err(e) => { 124 + error!("Service token verification failed: {:?}", e); 125 + return ( 126 + StatusCode::UNAUTHORIZED, 127 + Json(json!({ 128 + "error": "AuthenticationFailed", 129 + "message": format!("Service token verification failed: {}", e) 130 + })), 131 + ) 132 + .into_response(); 133 + } 134 + } 135 + } else { 136 + None 137 + } 138 + } else { 139 + None 140 + }; 141 + 142 + let is_byod_did_web = byod_auth.is_some() 143 + && input 144 + .did 145 + .as_ref() 146 + .map(|d| d.starts_with("did:web:")) 147 + .unwrap_or(false); 148 149 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 150 let pds_suffix = format!(".{}", hostname); ··· 341 ) 342 .into_response(); 343 } 344 + if is_byod_did_web { 345 + if let Some(ref auth_did) = byod_auth { 346 + if d != auth_did { 347 + return ( 348 + StatusCode::FORBIDDEN, 349 + Json(json!({ 350 + "error": "AuthorizationError", 351 + "message": format!("Service token issuer {} does not match DID {}", auth_did, d) 352 + })), 353 + ) 354 + .into_response(); 355 + } 356 + } 357 + info!(did = %d, "Creating external did:web passkey account (BYOD key)"); 358 + } else { 359 + if let Err(e) = crate::api::identity::did::verify_did_web( 360 + d, 361 + &hostname, 362 + &input.handle, 363 + input.signing_key.as_deref(), 364 ) 365 + .await 366 + { 367 + return ( 368 + StatusCode::BAD_REQUEST, 369 + Json(json!({"error": "InvalidDid", "message": e})), 370 + ) 371 + .into_response(); 372 + } 373 + info!(did = %d, "Creating external did:web passkey account (reserved key)"); 374 } 375 d.to_string() 376 } 377 _ => { ··· 454 .map(|c| c.unwrap_or(0) == 0) 455 .unwrap_or(false); 456 457 + let deactivated_at: Option<chrono::DateTime<Utc>> = if is_byod_did_web { 458 + Some(Utc::now()) 459 + } else { 460 + None 461 + }; 462 + 463 let user_insert: Result<(Uuid,), _> = sqlx::query_as( 464 r#"INSERT INTO users ( 465 handle, email, did, password_hash, password_required, 466 preferred_comms_channel, 467 discord_id, telegram_username, signal_number, 468 recovery_token, recovery_token_expires_at, 469 + is_admin, deactivated_at 470 + ) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#, 471 ) 472 .bind(&handle) 473 .bind(&email) ··· 497 .bind(&setup_token_hash) 498 .bind(setup_expires_at) 499 .bind(is_first_user) 500 + .bind(deactivated_at) 501 .fetch_one(&mut *tx) 502 .await; 503 ··· 675 .into_response(); 676 } 677 678 + if !is_byod_did_web { 679 + if let Err(e) = 680 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 681 + { 682 + warn!("Failed to sequence identity event for {}: {}", did, e); 683 + } 684 + if let Err(e) = 685 + crate::api::repo::record::sequence_account_event(&state, &did, true, None).await 686 + { 687 + warn!("Failed to sequence account event for {}: {}", did, e); 688 + } 689 + let profile_record = serde_json::json!({ 690 + "$type": "app.bsky.actor.profile", 691 + "displayName": handle 692 + }); 693 + if let Err(e) = crate::api::repo::record::create_record_internal( 694 + &state, 695 + &did, 696 + "app.bsky.actor.profile", 697 + "self", 698 + &profile_record, 699 + ) 700 + .await 701 + { 702 + warn!("Failed to create default profile for {}: {}", did, e); 703 + } 704 } 705 706 if let Err(e) = crate::comms::enqueue_signup_verification(