Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

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

Fixed up did:web account creation

+1534 -515
+10
frontend/deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 + "npm:@noble/secp256k1@^2.1.0": "2.3.0", 4 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", 5 6 "npm:@testing-library/jest-dom@^6.6.3": "6.9.1", 6 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", 7 8 "npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1", 8 9 "npm:jsdom@^25.0.1": "25.0.1", 10 + "npm:multiformats@^13.3.1": "13.4.2", 9 11 "npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0", 10 12 "npm:svelte@5": "5.45.10_acorn@8.15.0", 11 13 "npm:vite@*": "6.4.1_picomatch@4.0.3", ··· 491 493 "@jridgewell/resolve-uri", 492 494 "@jridgewell/sourcemap-codec" 493 495 ] 496 + }, 497 + "@noble/secp256k1@2.3.0": { 498 + "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==" 494 499 }, 495 500 "@rollup/rollup-android-arm-eabi@4.53.3": { 496 501 "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", ··· 1281 1286 "ms@2.1.3": { 1282 1287 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 1283 1288 }, 1289 + "multiformats@13.4.2": { 1290 + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==" 1291 + }, 1284 1292 "nanoid@3.3.11": { 1285 1293 "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1286 1294 "bin": true ··· 1636 1644 "workspace": { 1637 1645 "packageJson": { 1638 1646 "dependencies": [ 1647 + "npm:@noble/secp256k1@^2.1.0", 1639 1648 "npm:@sveltejs/vite-plugin-svelte@5", 1640 1649 "npm:@testing-library/jest-dom@^6.6.3", 1641 1650 "npm:@testing-library/svelte@^5.2.6", 1642 1651 "npm:@testing-library/user-event@^14.5.2", 1643 1652 "npm:jsdom@^25.0.1", 1653 + "npm:multiformats@^13.3.1", 1644 1654 "npm:svelte-i18n@^4.0.1", 1645 1655 "npm:svelte@5", 1646 1656 "npm:vite@6",
+2
frontend/package.json
··· 12 12 "test:coverage": "vitest run --coverage" 13 13 }, 14 14 "dependencies": { 15 + "@noble/secp256k1": "^2.1.0", 16 + "multiformats": "^13.3.1", 15 17 "svelte-i18n": "^4.0.1" 16 18 }, 17 19 "devDependencies": {
+56 -7
frontend/src/lib/api.ts
··· 95 95 inviteCode?: string 96 96 didType?: DidType 97 97 did?: string 98 + signingKey?: string 98 99 verificationChannel?: VerificationChannel 99 100 discordId?: string 100 101 telegramUsername?: string ··· 120 121 } 121 122 122 123 export const api = { 123 - async createAccount(params: CreateAccountParams): Promise<CreateAccountResult> { 124 - return xrpc('com.atproto.server.createAccount', { 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, { 125 131 method: 'POST', 126 - body: { 132 + headers, 133 + body: JSON.stringify({ 127 134 handle: params.handle, 128 135 email: params.email, 129 136 password: params.password, 130 137 inviteCode: params.inviteCode, 131 138 didType: params.didType, 132 139 did: params.did, 140 + signingKey: params.signingKey, 133 141 verificationChannel: params.verificationChannel, 134 142 discordId: params.discordId, 135 143 telegramUsername: params.telegramUsername, 136 144 signalNumber: params.signalNumber, 137 - }, 145 + }), 138 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 139 152 }, 140 153 141 154 async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> { ··· 750 763 }) 751 764 }, 752 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 + 753 789 async createPasskeyAccount(params: { 754 790 handle: string 755 791 email?: string ··· 761 797 discordId?: string 762 798 telegramUsername?: string 763 799 signalNumber?: string 764 - }): Promise<{ 800 + }, byodToken?: string): Promise<{ 765 801 did: string 766 802 handle: string 767 803 setupToken: string 768 804 setupExpiresAt: string 769 805 }> { 770 - return xrpc('com.tranquil.account.createPasskeyAccount', { 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, { 771 814 method: 'POST', 772 - body: params, 815 + headers, 816 + body: JSON.stringify(params), 773 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() 774 823 }, 775 824 776 825 async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> {
+12
frontend/src/lib/auth.svelte.ts
··· 265 265 } 266 266 } 267 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 + 268 280 export async function logout(): Promise<void> { 269 281 if (state.session) { 270 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 1 <script lang="ts"> 2 - import { register, getAuthState } from '../lib/auth.svelte' 3 2 import { navigate } from '../lib/router.svelte' 4 - import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api' 3 + import { api, ApiError } from '../lib/api' 5 4 import { _ } from '../lib/i18n' 5 + import { 6 + createRegistrationFlow, 7 + VerificationStep, 8 + KeyChoiceStep, 9 + DidDocStep, 10 + } from '../lib/registration' 6 11 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 12 let serverInfo = $state<{ 23 13 availableUserDomains: string[] 24 14 inviteCodeRequired: boolean ··· 27 17 let loadingServerInfo = $state(true) 28 18 let serverInfoLoaded = false 29 19 30 - const auth = getAuthState() 20 + let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 21 + let confirmPassword = $state('') 31 22 32 23 $effect(() => { 33 24 if (!serverInfoLoaded) { ··· 36 27 } 37 28 }) 38 29 30 + $effect(() => { 31 + if (flow?.state.step === 'redirect-to-dashboard') { 32 + navigate('/dashboard') 33 + } 34 + }) 35 + 39 36 async function loadServerInfo() { 40 37 try { 41 38 serverInfo = await api.describeServer() 39 + const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 40 + flow = createRegistrationFlow('password', hostname) 42 41 } catch (e) { 43 42 console.error('Failed to load server info:', e) 44 43 } finally { ··· 46 45 } 47 46 } 48 47 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()) { 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()) { 63 57 return $_('register.validation.inviteCodeRequired') 64 58 } 65 - if (didType === 'web-external') { 66 - if (!externalDid.trim()) return $_('register.validation.externalDidRequired') 67 - if (!externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat') 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') 68 62 } 69 - switch (verificationChannel) { 63 + switch (info.verificationChannel) { 70 64 case 'email': 71 - if (!email.trim()) return $_('register.validation.emailRequired') 65 + if (!info.email.trim()) return $_('register.validation.emailRequired') 72 66 break 73 67 case 'discord': 74 - if (!discordId.trim()) return $_('register.validation.discordIdRequired') 68 + if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired') 75 69 break 76 70 case 'telegram': 77 - if (!telegramUsername.trim()) return $_('register.validation.telegramRequired') 71 + if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired') 78 72 break 79 73 case 'signal': 80 - if (!signalNumber.trim()) return $_('register.validation.signalRequired') 74 + if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired') 81 75 break 82 76 } 83 77 return null 84 78 } 85 79 86 - async function handleSubmit(e: Event) { 80 + async function handleInfoSubmit(e: Event) { 87 81 e.preventDefault() 88 - const validationError = validateForm() 82 + if (!flow) return 83 + 84 + const validationError = validateInfoStep() 89 85 if (validationError) { 90 - error = validationError 86 + flow.setError(validationError) 91 87 return 92 88 } 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 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 128 118 } 129 119 } 130 120 131 121 let fullHandle = $derived(() => { 132 - if (!handle.trim()) return '' 133 - if (handle.includes('.')) return handle.trim() 122 + if (!flow?.info.handle.trim()) return '' 123 + if (flow.info.handle.includes('.')) return flow.info.handle.trim() 134 124 const domain = serverInfo?.availableUserDomains?.[0] 135 - if (domain) return `${handle.trim()}.${domain}` 136 - return handle.trim() 125 + if (domain) return `${flow.info.handle.trim()}.${domain}` 126 + return flow.info.handle.trim() 137 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 + } 138 147 </script> 139 148 140 149 <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> 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> 149 160 </div> 150 - </div> 151 - 152 - {#if error} 153 - <div class="message error">{error}</div> 154 161 {/if} 155 162 156 163 <h1>{$_('register.title')}</h1> 157 - <p class="subtitle">{$_('register.subtitle')}</p> 164 + <p class="subtitle">{getSubtitle()}</p> 165 + 166 + {#if flow?.state.error} 167 + <div class="message error">{flow.state.error}</div> 168 + {/if} 158 169 159 - {#if loadingServerInfo} 170 + {#if loadingServerInfo || !flow} 160 171 <p class="loading">{$_('common.loading')}</p> 161 - {:else} 162 - <form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}> 172 + 173 + {:else if flow.state.step === 'info'} 174 + <form onsubmit={handleInfoSubmit}> 163 175 <div class="field"> 164 176 <label for="handle">{$_('register.handle')}</label> 165 177 <input 166 178 id="handle" 167 179 type="text" 168 - bind:value={handle} 180 + bind:value={flow.info.handle} 169 181 placeholder={$_('register.handlePlaceholder')} 170 - disabled={submitting} 182 + disabled={flow.state.submitting} 171 183 required 172 184 /> 173 - {#if handleHasDot} 185 + {#if flow.info.handle.includes('.')} 174 186 <p class="hint warning">{$_('register.handleDotWarning')}</p> 175 187 {:else if fullHandle()} 176 188 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> ··· 182 194 <input 183 195 id="password" 184 196 type="password" 185 - bind:value={password} 197 + bind:value={flow.info.password} 186 198 placeholder={$_('register.passwordPlaceholder')} 187 - disabled={submitting} 199 + disabled={flow.state.submitting} 188 200 required 189 201 minlength="8" 190 202 /> ··· 197 209 type="password" 198 210 bind:value={confirmPassword} 199 211 placeholder={$_('register.confirmPasswordPlaceholder')} 200 - disabled={submitting} 212 + disabled={flow.state.submitting} 201 213 required 202 214 /> 203 215 </div> ··· 208 220 209 221 <div class="radio-group"> 210 222 <label class="radio-label"> 211 - <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} /> 223 + <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 212 224 <span class="radio-content"> 213 225 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 214 226 <span class="radio-hint">{$_('register.didPlcHint')}</span> ··· 216 228 </label> 217 229 218 230 <label class="radio-label"> 219 - <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} /> 231 + <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 220 232 <span class="radio-content"> 221 233 <strong>{$_('register.didWeb')}</strong> 222 234 <span class="radio-hint">{$_('register.didWebHint')}</span> ··· 224 236 </label> 225 237 226 238 <label class="radio-label"> 227 - <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} /> 239 + <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 228 240 <span class="radio-content"> 229 241 <strong>{$_('register.didWebBYOD')}</strong> 230 242 <span class="radio-hint">{$_('register.didWebBYODHint')}</span> ··· 232 244 </label> 233 245 </div> 234 246 235 - {#if didType === 'web'} 247 + {#if flow.info.didType === 'web'} 236 248 <div class="warning-box"> 237 249 <strong>{$_('register.didWebWarningTitle')}</strong> 238 250 <ul> ··· 244 256 </div> 245 257 {/if} 246 258 247 - {#if didType === 'web-external'} 259 + {#if flow.info.didType === 'web-external'} 248 260 <div class="field"> 249 261 <label for="external-did">{$_('register.externalDid')}</label> 250 262 <input 251 263 id="external-did" 252 264 type="text" 253 - bind:value={externalDid} 265 + bind:value={flow.info.externalDid} 254 266 placeholder={$_('register.externalDidPlaceholder')} 255 - disabled={submitting} 267 + disabled={flow.state.submitting} 256 268 required 257 269 /> 258 270 <p class="hint">{$_('register.externalDidHint')}</p> ··· 266 278 267 279 <div class="field"> 268 280 <label for="verification-channel">{$_('register.verificationMethod')}</label> 269 - <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 281 + <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 270 282 <option value="email">{$_('register.email')}</option> 271 283 <option value="discord" disabled={!isChannelAvailable('discord')}> 272 284 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} ··· 280 292 </select> 281 293 </div> 282 294 283 - {#if verificationChannel === 'email'} 295 + {#if flow.info.verificationChannel === 'email'} 284 296 <div class="field"> 285 297 <label for="email">{$_('register.emailAddress')}</label> 286 298 <input 287 299 id="email" 288 300 type="email" 289 - bind:value={email} 301 + bind:value={flow.info.email} 290 302 placeholder={$_('register.emailPlaceholder')} 291 - disabled={submitting} 303 + disabled={flow.state.submitting} 292 304 required 293 305 /> 294 306 </div> 295 - {:else if verificationChannel === 'discord'} 307 + {:else if flow.info.verificationChannel === 'discord'} 296 308 <div class="field"> 297 309 <label for="discord-id">{$_('register.discordId')}</label> 298 310 <input 299 311 id="discord-id" 300 312 type="text" 301 - bind:value={discordId} 313 + bind:value={flow.info.discordId} 302 314 placeholder={$_('register.discordIdPlaceholder')} 303 - disabled={submitting} 315 + disabled={flow.state.submitting} 304 316 required 305 317 /> 306 318 <p class="hint">{$_('register.discordIdHint')}</p> 307 319 </div> 308 - {:else if verificationChannel === 'telegram'} 320 + {:else if flow.info.verificationChannel === 'telegram'} 309 321 <div class="field"> 310 322 <label for="telegram-username">{$_('register.telegramUsername')}</label> 311 323 <input 312 324 id="telegram-username" 313 325 type="text" 314 - bind:value={telegramUsername} 326 + bind:value={flow.info.telegramUsername} 315 327 placeholder={$_('register.telegramUsernamePlaceholder')} 316 - disabled={submitting} 328 + disabled={flow.state.submitting} 317 329 required 318 330 /> 319 331 </div> 320 - {:else if verificationChannel === 'signal'} 332 + {:else if flow.info.verificationChannel === 'signal'} 321 333 <div class="field"> 322 334 <label for="signal-number">{$_('register.signalNumber')}</label> 323 335 <input 324 336 id="signal-number" 325 337 type="tel" 326 - bind:value={signalNumber} 338 + bind:value={flow.info.signalNumber} 327 339 placeholder={$_('register.signalNumberPlaceholder')} 328 - disabled={submitting} 340 + disabled={flow.state.submitting} 329 341 required 330 342 /> 331 343 <p class="hint">{$_('register.signalNumberHint')}</p> ··· 339 351 <input 340 352 id="invite-code" 341 353 type="text" 342 - bind:value={inviteCode} 354 + bind:value={flow.info.inviteCode} 343 355 placeholder={$_('register.inviteCodePlaceholder')} 344 - disabled={submitting} 356 + disabled={flow.state.submitting} 345 357 required 346 358 /> 347 359 </div> 348 360 {/if} 349 361 350 - <button type="submit" disabled={submitting}> 351 - {submitting ? $_('register.creating') : $_('register.createButton')} 362 + <button type="submit" disabled={flow.state.submitting}> 363 + {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 352 364 </button> 353 365 </form> 354 366 ··· 358 370 <p class="link-text"> 359 371 {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a> 360 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> 361 402 {/if} 362 403 </div> 363 404
+157 -328
frontend/src/routes/RegisterPasskey.svelte
··· 1 1 <script lang="ts"> 2 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' 3 + import { api, ApiError } from '../lib/api' 5 4 import { _ } from '../lib/i18n' 5 + import { 6 + createRegistrationFlow, 7 + VerificationStep, 8 + KeyChoiceStep, 9 + DidDocStep, 10 + AppPasswordStep, 11 + } from '../lib/registration' 6 12 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) 13 + let serverInfo = $state<{ 14 + availableUserDomains: string[] 15 + inviteCodeRequired: boolean 16 + availableCommsChannels?: string[] 17 + } | null>(null) 23 18 let loadingServerInfo = $state(true) 24 19 let serverInfoLoaded = false 25 20 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) 21 + let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 22 + let passkeyName = $state('') 33 23 34 24 $effect(() => { 35 25 if (!serverInfoLoaded) { 36 26 serverInfoLoaded = true 37 27 loadServerInfo() 28 + } 29 + }) 30 + 31 + $effect(() => { 32 + if (flow?.state.step === 'redirect-to-dashboard') { 33 + navigate('/dashboard') 38 34 } 39 35 }) 40 36 41 37 async function loadServerInfo() { 42 38 try { 43 39 serverInfo = await api.describeServer() 40 + const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 41 + flow = createRegistrationFlow('passkey', hostname) 44 42 } catch (e) { 45 43 console.error('Failed to load server info:', e) 46 44 } finally { ··· 49 47 } 50 48 51 49 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()) { 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 55 return 'Invite code is required' 56 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:' 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 60 } 61 - switch (verificationChannel) { 61 + switch (info.verificationChannel) { 62 62 case 'email': 63 - if (!email.trim()) return 'Email is required for email verification' 63 + if (!info.email.trim()) return 'Email is required for email verification' 64 64 break 65 65 case 'discord': 66 - if (!discordId.trim()) return 'Discord ID is required for Discord verification' 66 + if (!info.discordId?.trim()) return 'Discord ID is required for Discord verification' 67 67 break 68 68 case 'telegram': 69 - if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification' 69 + if (!info.telegramUsername?.trim()) return 'Telegram username is required for Telegram verification' 70 70 break 71 71 case 'signal': 72 - if (!signalNumber.trim()) return 'Phone number is required for Signal verification' 72 + if (!info.signalNumber?.trim()) return 'Phone number is required for Signal verification' 73 73 break 74 74 } 75 75 return null ··· 112 112 113 113 async function handleInfoSubmit(e: Event) { 114 114 e.preventDefault() 115 + if (!flow) return 116 + 115 117 const validationError = validateInfoStep() 116 118 if (validationError) { 117 - error = validationError 119 + flow.setError(validationError) 118 120 return 119 121 } 120 122 121 123 if (!window.PublicKeyCredential) { 122 - error = 'Passkeys are not supported in this browser. Please use a different browser or register with a password instead.' 124 + flow.setError('Passkeys are not supported in this browser. Please use a different browser or register with a password instead.') 123 125 return 124 126 } 125 127 126 - submitting = true 127 - error = null 128 + flow.clearError() 129 + flow.proceedFromInfo() 130 + } 128 131 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 - } 132 + async function handleCreateAccount() { 133 + if (!flow) return 134 + await flow.createPasskeyAccount() 160 135 } 161 136 162 137 async function handlePasskeyRegistration() { 163 - if (!setupData) return 138 + if (!flow || !flow.account) return 164 139 165 - submitting = true 166 - error = null 140 + flow.setSubmitting(true) 141 + flow.clearError() 167 142 168 143 try { 169 144 const { options } = await api.startPasskeyRegistrationForSetup( 170 - setupData.did, 171 - setupData.setupToken, 145 + flow.account.did, 146 + flow.account.setupToken!, 172 147 passkeyName || undefined 173 148 ) 174 149 ··· 178 153 }) 179 154 180 155 if (!credential) { 181 - error = 'Passkey creation was cancelled' 182 - submitting = false 156 + flow.setError('Passkey creation was cancelled') 157 + flow.setSubmitting(false) 183 158 return 184 159 } 185 160 ··· 196 171 } 197 172 198 173 const result = await api.completePasskeySetup( 199 - setupData.did, 200 - setupData.setupToken, 174 + flow.account.did, 175 + flow.account.setupToken!, 201 176 credentialResponse, 202 177 passkeyName || undefined 203 178 ) 204 179 205 - appPasswordResult = { 206 - appPassword: result.appPassword, 207 - appPasswordName: result.appPasswordName, 208 - } 209 - 210 - step = 'app-password' 180 + flow.setPasskeyComplete(result.appPassword, result.appPasswordName) 211 181 } catch (err) { 212 182 if (err instanceof DOMException && err.name === 'NotAllowedError') { 213 - error = 'Passkey creation was cancelled' 183 + flow.setError('Passkey creation was cancelled') 214 184 } else if (err instanceof ApiError) { 215 - error = err.message || 'Passkey registration failed' 185 + flow.setError(err.message || 'Passkey registration failed') 216 186 } else if (err instanceof Error) { 217 - error = err.message || 'Passkey registration failed' 187 + flow.setError(err.message || 'Passkey registration failed') 218 188 } else { 219 - error = 'Passkey registration failed' 189 + flow.setError('Passkey registration failed') 220 190 } 221 191 } finally { 222 - submitting = false 192 + flow.setSubmitting(false) 223 193 } 224 194 } 225 195 226 - function copyAppPassword() { 227 - if (appPasswordResult) { 228 - navigator.clipboard.writeText(appPasswordResult.appPassword) 229 - appPasswordCopied = true 196 + async function handleComplete() { 197 + if (flow) { 198 + await flow.finalizeSession() 230 199 } 200 + navigate('/dashboard') 231 201 } 232 202 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 - } 203 + function isChannelAvailable(ch: string): boolean { 204 + const available = serverInfo?.availableCommsChannels ?? ['email'] 205 + return available.includes(ch) 280 206 } 281 207 282 208 function channelLabel(ch: string): string { ··· 289 215 } 290 216 } 291 217 292 - function isChannelAvailable(ch: string): boolean { 293 - const available = serverInfo?.availableCommsChannels ?? ['email'] 294 - return available.includes(ch) 295 - } 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 + }) 296 225 297 - function goToLogin() { 298 - navigate('/login') 226 + function extractDomain(did: string): string { 227 + return did.replace('did:web:', '').replace(/%3A/g, ':') 299 228 } 300 229 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 - }) 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 + } 308 246 </script> 309 247 310 248 <div class="register-page"> 311 - {#if step === 'info'} 249 + {#if flow?.state.step === 'info'} 312 250 <div class="migrate-callout"> 313 251 <div class="migrate-icon">↗</div> 314 252 <div class="migrate-content"> ··· 322 260 {/if} 323 261 324 262 <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> 263 + <p class="subtitle">{getSubtitle()}</p> 338 264 339 - {#if error} 340 - <div class="message error">{error}</div> 265 + {#if flow?.state.error} 266 + <div class="message error">{flow.state.error}</div> 341 267 {/if} 342 268 343 - {#if loadingServerInfo} 269 + {#if loadingServerInfo || !flow} 344 270 <p class="loading">Loading...</p> 345 - {:else if step === 'info'} 271 + 272 + {:else if flow.state.step === 'info'} 346 273 <form onsubmit={handleInfoSubmit}> 347 274 <div class="field"> 348 275 <label for="handle">Handle</label> 349 276 <input 350 277 id="handle" 351 278 type="text" 352 - bind:value={handle} 279 + bind:value={flow.info.handle} 353 280 placeholder="yourname" 354 - disabled={submitting} 281 + disabled={flow.state.submitting} 355 282 required 356 283 /> 357 - {#if handle.includes('.')} 284 + {#if flow.info.handle.includes('.')} 358 285 <p class="hint warning">Custom domain handles can be set up after account creation.</p> 359 286 {:else if fullHandle()} 360 287 <p class="hint">Your full handle will be: @{fullHandle()}</p> ··· 366 293 <p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p> 367 294 <div class="field"> 368 295 <label for="verification-channel">Verification Method</label> 369 - <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 296 + <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 370 297 <option value="email">Email</option> 371 298 <option value="discord" disabled={!isChannelAvailable('discord')}> 372 299 Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} ··· 379 306 </option> 380 307 </select> 381 308 </div> 382 - {#if verificationChannel === 'email'} 309 + {#if flow.info.verificationChannel === 'email'} 383 310 <div class="field"> 384 311 <label for="email">Email Address</label> 385 - <input id="email" type="email" bind:value={email} placeholder="you@example.com" disabled={submitting} required /> 312 + <input id="email" type="email" bind:value={flow.info.email} placeholder="you@example.com" disabled={flow.state.submitting} required /> 386 313 </div> 387 - {:else if verificationChannel === 'discord'} 314 + {:else if flow.info.verificationChannel === 'discord'} 388 315 <div class="field"> 389 316 <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 /> 317 + <input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder="Your Discord user ID" disabled={flow.state.submitting} required /> 391 318 <p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p> 392 319 </div> 393 - {:else if verificationChannel === 'telegram'} 320 + {:else if flow.info.verificationChannel === 'telegram'} 394 321 <div class="field"> 395 322 <label for="telegram-username">Telegram Username</label> 396 - <input id="telegram-username" type="text" bind:value={telegramUsername} placeholder="@yourusername" disabled={submitting} required /> 323 + <input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder="@yourusername" disabled={flow.state.submitting} required /> 397 324 </div> 398 - {:else if verificationChannel === 'signal'} 325 + {:else if flow.info.verificationChannel === 'signal'} 399 326 <div class="field"> 400 327 <label for="signal-number">Signal Phone Number</label> 401 - <input id="signal-number" type="tel" bind:value={signalNumber} placeholder="+1234567890" disabled={submitting} required /> 328 + <input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder="+1234567890" disabled={flow.state.submitting} required /> 402 329 <p class="hint">Include country code (e.g., +1 for US)</p> 403 330 </div> 404 331 {/if} ··· 409 336 <p class="section-hint">Choose how your decentralized identity will be managed.</p> 410 337 <div class="radio-group"> 411 338 <label class="radio-label"> 412 - <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} /> 339 + <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 413 340 <span class="radio-content"> 414 341 <strong>did:plc</strong> (Recommended) 415 342 <span class="radio-hint">Portable identity managed by PLC Directory</span> 416 343 </span> 417 344 </label> 418 345 <label class="radio-label"> 419 - <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} /> 346 + <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 420 347 <span class="radio-content"> 421 348 <strong>did:web</strong> 422 349 <span class="radio-hint">Identity hosted on this PDS (read warning below)</span> 423 350 </span> 424 351 </label> 425 352 <label class="radio-label"> 426 - <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} /> 353 + <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 427 354 <span class="radio-content"> 428 355 <strong>did:web (BYOD)</strong> 429 356 <span class="radio-hint">Bring your own domain</span> 430 357 </span> 431 358 </label> 432 359 </div> 433 - {#if didType === 'web'} 360 + {#if flow.info.didType === 'web'} 434 361 <div class="warning-box"> 435 362 <strong>Important: Understand the trade-offs</strong> 436 363 <ul> ··· 441 368 </ul> 442 369 </div> 443 370 {/if} 444 - {#if didType === 'web-external'} 371 + {#if flow.info.didType === 'web-external'} 445 372 <div class="field"> 446 373 <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> 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> 449 376 </div> 450 377 {/if} 451 378 </fieldset> ··· 453 380 {#if serverInfo?.inviteCodeRequired} 454 381 <div class="field"> 455 382 <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 /> 383 + <input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder="Enter your invite code" disabled={flow.state.submitting} required /> 457 384 </div> 458 385 {/if} 459 386 ··· 467 394 </ul> 468 395 </div> 469 396 470 - <button type="submit" disabled={submitting}> 471 - {submitting ? 'Creating account...' : 'Continue'} 397 + <button type="submit" disabled={flow.state.submitting}> 398 + {flow.state.submitting ? 'Creating account...' : 'Continue'} 472 399 </button> 473 400 </form> 474 401 475 402 <p class="link-text"> 476 403 Want a traditional password? <a href="#/register">Register with password</a> 477 404 </p> 478 - {:else if step === 'passkey'} 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'} 479 423 <div class="step-content"> 480 424 <div class="field"> 481 425 <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} /> 426 + <input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={flow.state.submitting} /> 483 427 <p class="hint">A friendly name to identify this passkey</p> 484 428 </div> 485 429 ··· 492 436 </ul> 493 437 </div> 494 438 495 - <button onclick={handlePasskeyRegistration} disabled={submitting} class="passkey-btn"> 496 - {submitting ? 'Creating Passkey...' : 'Create Passkey'} 439 + <button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn"> 440 + {flow.state.submitting ? 'Creating Passkey...' : 'Create Passkey'} 497 441 </button> 498 442 499 - <button type="button" class="secondary" onclick={() => step = 'info'} disabled={submitting}> 443 + <button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}> 500 444 Back 501 445 </button> 502 446 </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 447 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> 448 + {:else if flow.state.step === 'app-password'} 449 + <AppPasswordStep {flow} /> 524 450 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> 451 + {:else if flow.state.step === 'verify'} 452 + <VerificationStep {flow} /> 530 453 531 - {#if resendMessage} 532 - <div class="message success">{resendMessage}</div> 533 - {/if} 454 + {:else if flow.state.step === 'updated-did-doc'} 455 + <DidDocStep 456 + {flow} 457 + type="updated" 458 + onConfirm={() => flow?.activateAccount()} 459 + /> 534 460 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> 461 + {:else if flow.state.step === 'redirect-to-dashboard'} 462 + <p class="loading">Redirecting to dashboard...</p> 558 463 {/if} 559 464 </div> 560 465 ··· 609 514 text-decoration: underline; 610 515 } 611 516 612 - h1, h2 { 517 + h1 { 613 518 margin: 0 0 var(--space-3) 0; 614 519 } 615 520 ··· 697 602 color: var(--warning-text); 698 603 } 699 604 700 - .warning-box p { 701 - margin: 0; 702 - color: var(--warning-text); 703 - } 704 - 705 605 .warning-box ul { 706 606 margin: var(--space-4) 0 0 0; 707 607 padding-left: var(--space-5); ··· 747 647 .passkey-btn { 748 648 padding: var(--space-5); 749 649 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 650 } 822 651 823 652 .link-text {
+14 -12
src/api/identity/account.rs
··· 118 118 None 119 119 }; 120 120 121 - let is_migration = migration_auth.is_some() 121 + let is_did_web_byod = migration_auth.is_some() 122 122 && input 123 123 .did 124 124 .as_ref() 125 - .map(|d| d.starts_with("did:plc:") || d.starts_with("did:web:")) 125 + .map(|d| d.starts_with("did:web:")) 126 126 .unwrap_or(false); 127 127 128 - let is_did_web_byod = migration_auth.is_some() 128 + let is_migration = migration_auth.is_some() 129 129 && input 130 130 .did 131 131 .as_ref() 132 - .map(|d| d.starts_with("did:web:")) 132 + .map(|d| d.starts_with("did:plc:")) 133 133 .unwrap_or(false); 134 134 135 - if is_migration { 136 - if let (Some(migration_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) 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 137 { 138 - if migration_did != auth_did { 138 + if provided_did != auth_did { 139 139 return ( 140 140 StatusCode::FORBIDDEN, 141 141 Json(json!({ 142 142 "error": "AuthorizationError", 143 - "message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did) 143 + "message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did) 144 144 })), 145 145 ) 146 146 .into_response(); 147 147 } 148 148 if is_did_web_byod { 149 - info!(did = %migration_did, "Processing did:web BYOD account creation"); 149 + info!(did = %provided_did, "Processing did:web BYOD account creation"); 150 150 } else { 151 - info!(did = %migration_did, "Processing account migration"); 151 + info!(did = %provided_did, "Processing account migration"); 152 152 } 153 153 } 154 154 } ··· 717 717 .await 718 718 .map(|c| c.unwrap_or(0) == 0) 719 719 .unwrap_or(false); 720 - let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration { 720 + let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration || is_did_web_byod { 721 721 Some(chrono::Utc::now()) 722 722 } else { 723 723 None ··· 946 946 ) 947 947 .into_response(); 948 948 } 949 - if !is_migration { 949 + if !is_migration && !is_did_web_byod { 950 950 if let Err(e) = 951 951 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 952 952 { ··· 972 972 { 973 973 warn!("Failed to create default profile for {}: {}", did, e); 974 974 } 975 + } 976 + if !is_migration { 975 977 if let Some(ref recipient) = verification_recipient 976 978 && let Err(e) = crate::comms::enqueue_signup_verification( 977 979 &state.db,
+105 -40
src/api/server/passkey_account.rs
··· 12 12 use serde::{Deserialize, Serialize}; 13 13 use serde_json::json; 14 14 use std::sync::Arc; 15 - use tracing::{error, info, warn}; 15 + use tracing::{debug, error, info, warn}; 16 16 use uuid::Uuid; 17 17 18 18 use crate::api::repo::record::utils::create_signed_commit; 19 + use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token}; 19 20 use crate::state::{AppState, RateLimitKind}; 20 21 use crate::validation::validate_password; 21 22 ··· 105 106 ) 106 107 .into_response(); 107 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); 108 148 109 149 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 110 150 let pds_suffix = format!(".{}", hostname); ··· 301 341 ) 302 342 .into_response(); 303 343 } 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})), 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(), 315 364 ) 316 - .into_response(); 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)"); 317 374 } 318 - info!(did = %d, "Creating external did:web passkey account"); 319 375 d.to_string() 320 376 } 321 377 _ => { ··· 398 454 .map(|c| c.unwrap_or(0) == 0) 399 455 .unwrap_or(false); 400 456 457 + let deactivated_at: Option<chrono::DateTime<Utc>> = if is_byod_did_web { 458 + Some(Utc::now()) 459 + } else { 460 + None 461 + }; 462 + 401 463 let user_insert: Result<(Uuid,), _> = sqlx::query_as( 402 464 r#"INSERT INTO users ( 403 465 handle, email, did, password_hash, password_required, 404 466 preferred_comms_channel, 405 467 discord_id, telegram_username, signal_number, 406 468 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"#, 469 + is_admin, deactivated_at 470 + ) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#, 409 471 ) 410 472 .bind(&handle) 411 473 .bind(&email) ··· 435 497 .bind(&setup_token_hash) 436 498 .bind(setup_expires_at) 437 499 .bind(is_first_user) 500 + .bind(deactivated_at) 438 501 .fetch_one(&mut *tx) 439 502 .await; 440 503 ··· 612 675 .into_response(); 613 676 } 614 677 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); 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 + } 639 704 } 640 705 641 706 if let Err(e) = crate::comms::enqueue_signup_verification(