Our Personal Data Server from scratch! tranquil.farm
atproto pds rust postgresql fun oauth

refactor(frontend): refactor migration and registration lib #76

merged opened by oyster.cafe targeting main from refactor/frontend-deletions
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mhg53l5vo322
+706 -425
Diff #2
+139
frontend/src/components/CommsChannelPicker.svelte
··· 1 + <script lang="ts"> 2 + import type { VerificationChannel } from '../lib/types/api' 3 + import { _ } from '../lib/i18n' 4 + 5 + interface Props { 6 + channel: VerificationChannel 7 + email: string 8 + discordUsername: string 9 + telegramUsername: string 10 + signalUsername: string 11 + availableChannels: VerificationChannel[] 12 + disabled?: boolean 13 + discordInUse?: boolean 14 + telegramInUse?: boolean 15 + signalInUse?: boolean 16 + onChannelChange: (channel: VerificationChannel) => void 17 + onEmailChange: (value: string) => void 18 + onDiscordChange: (value: string) => void 19 + onTelegramChange: (value: string) => void 20 + onSignalChange: (value: string) => void 21 + onCheckInUse?: (channel: 'discord' | 'telegram' | 'signal', identifier: string) => void 22 + } 23 + 24 + let { 25 + channel, 26 + email, 27 + discordUsername, 28 + telegramUsername, 29 + signalUsername, 30 + availableChannels, 31 + disabled = false, 32 + discordInUse = false, 33 + telegramInUse = false, 34 + signalInUse = false, 35 + onChannelChange, 36 + onEmailChange, 37 + onDiscordChange, 38 + onTelegramChange, 39 + onSignalChange, 40 + onCheckInUse, 41 + }: Props = $props() 42 + 43 + function channelLabel(ch: string): string { 44 + switch (ch) { 45 + case 'email': return $_('register.email') 46 + case 'discord': return $_('register.discord') 47 + case 'telegram': return $_('register.telegram') 48 + case 'signal': return $_('register.signal') 49 + default: return ch 50 + } 51 + } 52 + 53 + function isAvailable(ch: VerificationChannel): boolean { 54 + return availableChannels.includes(ch) 55 + } 56 + </script> 57 + 58 + <div> 59 + <label for="verification-channel">{$_('register.verificationMethod')}</label> 60 + <select id="verification-channel" value={channel} onchange={(e) => onChannelChange((e.target as HTMLSelectElement).value as VerificationChannel)} {disabled}> 61 + <option value="email">{channelLabel('email')}</option> 62 + {#if isAvailable('discord')} 63 + <option value="discord">{channelLabel('discord')}</option> 64 + {/if} 65 + {#if isAvailable('telegram')} 66 + <option value="telegram">{channelLabel('telegram')}</option> 67 + {/if} 68 + {#if isAvailable('signal')} 69 + <option value="signal">{channelLabel('signal')}</option> 70 + {/if} 71 + </select> 72 + </div> 73 + 74 + {#if channel === 'email'} 75 + <div> 76 + <label for="comms-email">{$_('register.emailAddress')}</label> 77 + <input 78 + id="comms-email" 79 + type="email" 80 + value={email} 81 + oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)} 82 + placeholder={$_('register.emailPlaceholder')} 83 + {disabled} 84 + required 85 + /> 86 + </div> 87 + {:else if channel === 'discord'} 88 + <div> 89 + <label for="comms-discord">{$_('register.discordUsername')}</label> 90 + <input 91 + id="comms-discord" 92 + type="text" 93 + value={discordUsername} 94 + oninput={(e) => onDiscordChange((e.target as HTMLInputElement).value)} 95 + onblur={() => onCheckInUse?.('discord', discordUsername)} 96 + placeholder={$_('register.discordUsernamePlaceholder')} 97 + {disabled} 98 + required 99 + /> 100 + {#if discordInUse} 101 + <p class="hint warning">{$_('register.discordInUseWarning')}</p> 102 + {/if} 103 + </div> 104 + {:else if channel === 'telegram'} 105 + <div> 106 + <label for="comms-telegram">{$_('register.telegramUsername')}</label> 107 + <input 108 + id="comms-telegram" 109 + type="text" 110 + value={telegramUsername} 111 + oninput={(e) => onTelegramChange((e.target as HTMLInputElement).value)} 112 + onblur={() => onCheckInUse?.('telegram', telegramUsername)} 113 + placeholder={$_('register.telegramUsernamePlaceholder')} 114 + {disabled} 115 + required 116 + /> 117 + {#if telegramInUse} 118 + <p class="hint warning">{$_('register.telegramInUseWarning')}</p> 119 + {/if} 120 + </div> 121 + {:else if channel === 'signal'} 122 + <div> 123 + <label for="comms-signal">{$_('register.signalUsername')}</label> 124 + <input 125 + id="comms-signal" 126 + type="tel" 127 + value={signalUsername} 128 + oninput={(e) => onSignalChange((e.target as HTMLInputElement).value)} 129 + onblur={() => onCheckInUse?.('signal', signalUsername)} 130 + placeholder={$_('register.signalUsernamePlaceholder')} 131 + {disabled} 132 + required 133 + /> 134 + <p class="hint">{$_('register.signalUsernameHint')}</p> 135 + {#if signalInUse} 136 + <p class="hint warning">{$_('register.signalInUseWarning')}</p> 137 + {/if} 138 + </div> 139 + {/if}
+34
frontend/src/components/HandleInput.svelte
··· 7 7 placeholder?: string 8 8 id?: string 9 9 autocomplete?: HTMLInputElement['autocomplete'] 10 + checkAvailability?: (fullHandle: string) => Promise<boolean> 11 + available?: boolean | null 12 + checking?: boolean 10 13 onInput: (value: string) => void 11 14 onDomainChange: (domain: string) => void 12 15 } ··· 19 22 placeholder = 'username', 20 23 id = 'handle', 21 24 autocomplete = 'off', 25 + checkAvailability, 26 + available = $bindable<boolean | null>(null), 27 + checking = $bindable(false), 22 28 onInput, 23 29 onDomainChange, 24 30 }: Props = $props() 25 31 26 32 const showDomainSelect = $derived(domains.length > 1 && !value.includes('.')) 33 + 34 + let checkTimeout: ReturnType<typeof setTimeout> | null = null 35 + 36 + $effect(() => { 37 + void value 38 + void selectedDomain 39 + if (!checkAvailability) return 40 + if (checkTimeout) clearTimeout(checkTimeout) 41 + available = null 42 + if (value.trim().length >= 3 && !value.includes('.')) { 43 + checkTimeout = setTimeout(() => runCheck(), 400) 44 + } 45 + }) 46 + 47 + async function runCheck() { 48 + if (!checkAvailability) return 49 + const fullHandle = value.includes('.') 50 + ? value.trim() 51 + : `${value.trim()}.${selectedDomain}` 52 + checking = true 53 + try { 54 + available = await checkAvailability(fullHandle) 55 + } catch { 56 + available = null 57 + } finally { 58 + checking = false 59 + } 60 + } 27 61 </script> 28 62 29 63 <div class="handle-input-group">
+78
frontend/src/components/IdentityTypeSection.svelte
··· 1 + <script lang="ts"> 2 + import { _ } from '../lib/i18n' 3 + 4 + interface Props { 5 + didType: 'plc' | 'web' | 'web-external' 6 + externalDid: string 7 + disabled: boolean 8 + selfHostedDidWebEnabled: boolean 9 + defaultDomain: string 10 + onDidTypeChange: (value: 'plc' | 'web' | 'web-external') => void 11 + onExternalDidChange: (value: string) => void 12 + } 13 + 14 + let { 15 + didType, 16 + externalDid, 17 + disabled, 18 + selfHostedDidWebEnabled, 19 + defaultDomain, 20 + onDidTypeChange, 21 + onExternalDidChange, 22 + }: Props = $props() 23 + 24 + function extractDomain(did: string): string { 25 + return did.replace(/^did:web:/, '').split(':')[0] || 'yourdomain.com' 26 + } 27 + </script> 28 + 29 + <fieldset class="identity-section"> 30 + <legend>{$_('registerPasskey.identityType')}</legend> 31 + <div class="radio-group"> 32 + <label class="radio-label"> 33 + <input type="radio" name="didType" value="plc" checked={didType === 'plc'} onchange={() => onDidTypeChange('plc')} {disabled} /> 34 + <span class="radio-content"> 35 + <strong>{$_('registerPasskey.didPlcRecommended')}</strong> 36 + <span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span> 37 + </span> 38 + </label> 39 + <label class="radio-label" class:disabled={!selfHostedDidWebEnabled}> 40 + <input type="radio" name="didType" value="web" checked={didType === 'web'} onchange={() => onDidTypeChange('web')} disabled={disabled || !selfHostedDidWebEnabled} /> 41 + <span class="radio-content"> 42 + <strong>{$_('registerPasskey.didWeb')}</strong> 43 + {#if !selfHostedDidWebEnabled} 44 + <span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span> 45 + {:else} 46 + <span class="radio-hint">{$_('registerPasskey.didWebHint')}</span> 47 + {/if} 48 + </span> 49 + </label> 50 + <label class="radio-label"> 51 + <input type="radio" name="didType" value="web-external" checked={didType === 'web-external'} onchange={() => onDidTypeChange('web-external')} {disabled} /> 52 + <span class="radio-content"> 53 + <strong>{$_('registerPasskey.didWebBYOD')}</strong> 54 + <span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span> 55 + </span> 56 + </label> 57 + </div> 58 + </fieldset> 59 + 60 + {#if didType === 'web'} 61 + <div class="warning-box"> 62 + <strong>{$_('registerPasskey.didWebWarningTitle')}</strong> 63 + <ul> 64 + <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${defaultDomain}</code>` } })}</li> 65 + <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li> 66 + <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li> 67 + <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li> 68 + </ul> 69 + </div> 70 + {/if} 71 + 72 + {#if didType === 'web-external'} 73 + <div> 74 + <label for="external-did">{$_('registerPasskey.externalDid')}</label> 75 + <input id="external-did" type="text" value={externalDid} oninput={(e) => onExternalDidChange(e.currentTarget.value)} placeholder={$_('registerPasskey.externalDidPlaceholder')} {disabled} required /> 76 + <p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p> 77 + </div> 78 + {/if}
+14 -12
frontend/src/components/ReauthModal.svelte
··· 1 1 <script lang="ts"> 2 + import { portal } from '../lib/portal' 2 3 import { getAuthState, getValidToken } from '../lib/auth.svelte' 3 4 import { api, ApiError } from '../lib/api' 4 5 import { _ } from '../lib/i18n' ··· 136 137 </script> 137 138 138 139 {#if show} 139 - <div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation"> 140 + <div class="modal-backdrop" use:portal onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation"> 140 141 <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1"> 141 142 <div class="modal-header"> 142 143 <h2>{$_('reauth.title')}</h2> ··· 181 182 182 183 <div class="modal-content"> 183 184 {#if activeMethod === 'password'} 184 - <form onsubmit={handlePasswordSubmit}> 185 + <form id="reauth-form" onsubmit={handlePasswordSubmit}> 185 186 <div> 186 187 <label for="reauth-password">{$_('reauth.password')}</label> 187 188 <input ··· 192 193 autocomplete="current-password" 193 194 /> 194 195 </div> 195 - <button type="submit" disabled={loading || !password}> 196 - {loading ? $_('common.verifying') : $_('common.verify')} 197 - </button> 198 196 </form> 199 197 {:else if activeMethod === 'totp'} 200 - <form onsubmit={handleTotpSubmit}> 198 + <form id="reauth-form" onsubmit={handleTotpSubmit}> 201 199 <div> 202 200 <label for="reauth-totp">{$_('reauth.authenticatorCode')}</label> 203 201 <input ··· 211 209 maxlength="6" 212 210 /> 213 211 </div> 214 - <button type="submit" disabled={loading || !totpCode}> 215 - {loading ? $_('common.verifying') : $_('common.verify')} 216 - </button> 217 212 </form> 218 213 {:else if activeMethod === 'passkey'} 219 214 <div class="passkey-auth"> 220 - <button onclick={handlePasskeyAuth} disabled={loading}> 221 - {loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')} 222 - </button> 215 + <p>{$_('reauth.usePasskey')}</p> 223 216 </div> 224 217 {/if} 225 218 </div> ··· 228 221 <button class="secondary" onclick={handleClose} disabled={loading}> 229 222 {$_('reauth.cancel')} 230 223 </button> 224 + {#if activeMethod === 'passkey'} 225 + <button onclick={handlePasskeyAuth} disabled={loading}> 226 + {loading ? $_('reauth.authenticating') : $_('common.verify')} 227 + </button> 228 + {:else} 229 + <button type="submit" form="reauth-form" disabled={loading || (activeMethod === 'password' ? !password : !totpCode)}> 230 + {loading ? $_('common.verifying') : $_('common.verify')} 231 + </button> 232 + {/if} 231 233 </div> 232 234 </div> 233 235 </div>
+29
frontend/src/lib/flows/email-verification.ts
··· 1 + export interface EmailVerificationDeps { 2 + checkVerified: () => Promise<boolean>; 3 + onVerified: () => Promise<void>; 4 + } 5 + 6 + export function createEmailVerificationPoller( 7 + deps: EmailVerificationDeps, 8 + ): { checkAndAdvance: () => Promise<boolean> } { 9 + let checking = false; 10 + 11 + return { 12 + async checkAndAdvance(): Promise<boolean> { 13 + if (checking) return false; 14 + 15 + checking = true; 16 + try { 17 + const verified = await deps.checkVerified(); 18 + if (!verified) return false; 19 + 20 + await deps.onVerified(); 21 + return true; 22 + } catch { 23 + return false; 24 + } finally { 25 + checking = false; 26 + } 27 + }, 28 + }; 29 + }
+56
frontend/src/lib/flows/migration-shared.ts
··· 1 + import type { 2 + MigrationProgress, 3 + ServerDescription, 4 + VerificationChannel, 5 + } from "../migration/types.ts"; 6 + import type { AtprotoClient } from "../migration/atproto-client.ts"; 7 + 8 + export function createInitialProgress(): MigrationProgress { 9 + return { 10 + repoExported: false, 11 + repoImported: false, 12 + blobsTotal: 0, 13 + blobsMigrated: 0, 14 + blobsFailed: [], 15 + prefsMigrated: false, 16 + plcSigned: false, 17 + activated: false, 18 + deactivated: false, 19 + currentOperation: "", 20 + }; 21 + } 22 + 23 + export async function checkHandleAvailabilityViaClient( 24 + client: AtprotoClient, 25 + handle: string, 26 + ): Promise<boolean> { 27 + try { 28 + await client.resolveHandle(handle); 29 + return false; 30 + } catch { 31 + return true; 32 + } 33 + } 34 + 35 + export function resolveVerificationIdentifier( 36 + channel: VerificationChannel, 37 + email: string, 38 + discordUsername: string, 39 + telegramUsername: string, 40 + signalUsername: string, 41 + ): string { 42 + switch (channel) { 43 + case "email": return email; 44 + case "discord": return discordUsername; 45 + case "telegram": return telegramUsername; 46 + case "signal": return signalUsername; 47 + } 48 + } 49 + 50 + export async function loadServerInfo( 51 + client: AtprotoClient, 52 + cached: ServerDescription | null, 53 + ): Promise<ServerDescription> { 54 + if (cached) return cached; 55 + return client.describeServer(); 56 + }
+54
frontend/src/lib/flows/perform-passkey-registration.ts
··· 1 + import { 2 + type CredentialAttestationJSON, 3 + prepareCreationOptions, 4 + serializeAttestationResponse, 5 + type WebAuthnCreationOptionsResponse, 6 + } from "../webauthn.ts"; 7 + 8 + export class PasskeyCancelledError extends Error { 9 + constructor() { 10 + super("Passkey creation was cancelled"); 11 + this.name = "PasskeyCancelledError"; 12 + } 13 + } 14 + 15 + export async function createPasskeyCredential( 16 + startRegistration: () => Promise<{ options: unknown }>, 17 + ): Promise<CredentialAttestationJSON> { 18 + if (!globalThis.PublicKeyCredential) { 19 + throw new Error("Passkeys are not supported in this browser"); 20 + } 21 + 22 + const { options } = await startRegistration(); 23 + 24 + const publicKeyOptions = prepareCreationOptions( 25 + options as unknown as WebAuthnCreationOptionsResponse, 26 + ); 27 + const credential = await navigator.credentials.create({ 28 + publicKey: publicKeyOptions, 29 + }); 30 + 31 + if (!credential) { 32 + throw new PasskeyCancelledError(); 33 + } 34 + 35 + return serializeAttestationResponse(credential as PublicKeyCredential); 36 + } 37 + 38 + export interface PasskeyRegistrationApi { 39 + startRegistration(): Promise<{ options: unknown }>; 40 + completeSetup( 41 + credential: CredentialAttestationJSON, 42 + name?: string, 43 + ): Promise<{ appPassword: string; appPasswordName: string }>; 44 + } 45 + 46 + export async function performPasskeyRegistration( 47 + passkeyApi: PasskeyRegistrationApi, 48 + friendlyName?: string, 49 + ): Promise<{ appPassword: string; appPasswordName: string }> { 50 + const serialized = await createPasskeyCredential( 51 + passkeyApi.startRegistration, 52 + ); 53 + return passkeyApi.completeSetup(serialized, friendlyName); 54 + }
+20 -52
frontend/src/lib/migration/atproto-client.ts
··· 603 603 return result.verified; 604 604 } 605 605 606 + async checkChannelVerified( 607 + did: string, 608 + channel: string, 609 + ): Promise<boolean> { 610 + const result = await this.xrpc<{ verified: boolean }>( 611 + "_checkChannelVerified", 612 + { 613 + httpMethod: "POST", 614 + body: { did, channel }, 615 + }, 616 + ); 617 + return result.verified; 618 + } 619 + 606 620 async verifyToken( 607 621 token: string, 608 622 identifier: string, ··· 625 639 }); 626 640 } 627 641 628 - async resendMigrationVerification(): Promise<void> { 642 + async resendMigrationVerification( 643 + channel: string, 644 + identifier: string, 645 + ): Promise<void> { 629 646 await this.xrpc("com.atproto.server.resendMigrationVerification", { 630 647 httpMethod: "POST", 648 + body: { channel, identifier }, 631 649 }); 632 650 } 633 651 ··· 731 749 } 732 750 } 733 751 734 - export async function generatePKCE(): Promise<{ 735 - codeVerifier: string; 736 - codeChallenge: string; 737 - }> { 738 - const array = new Uint8Array(32); 739 - crypto.getRandomValues(array); 740 - const codeVerifier = base64UrlEncode(array); 741 - 742 - const encoder = new TextEncoder(); 743 - const data = encoder.encode(codeVerifier); 744 - const digest = await crypto.subtle.digest("SHA-256", data); 745 - const codeChallenge = base64UrlEncode(new Uint8Array(digest)); 746 - 747 - return { codeVerifier, codeChallenge }; 748 - } 749 - 750 - export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string { 752 + function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string { 751 753 const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer; 752 754 const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( 753 755 "", ··· 758 760 ); 759 761 } 760 762 761 - export function base64UrlDecode(base64url: string): Uint8Array { 762 - const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); 763 - const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); 764 - const binary = atob(padded); 765 - return Uint8Array.from(binary, (char) => char.charCodeAt(0)); 766 - } 767 - 768 - export function prepareWebAuthnCreationOptions( 769 - options: { publicKey: Record<string, unknown> }, 770 - ): PublicKeyCredentialCreationOptions { 771 - const pk = options.publicKey; 772 - return { 773 - ...pk, 774 - challenge: base64UrlDecode(pk.challenge as string), 775 - user: { 776 - ...(pk.user as Record<string, unknown>), 777 - id: base64UrlDecode((pk.user as Record<string, unknown>).id as string), 778 - }, 779 - excludeCredentials: 780 - ((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map( 781 - (cred) => ({ 782 - ...cred, 783 - id: base64UrlDecode(cred.id as string), 784 - }), 785 - ), 786 - } as unknown as PublicKeyCredentialCreationOptions; 787 - } 788 - 789 763 async function computeAccessTokenHash(accessToken: string): Promise<string> { 790 764 const encoder = new TextEncoder(); 791 765 const data = encoder.encode(accessToken); ··· 793 767 return base64UrlEncode(new Uint8Array(hash)); 794 768 } 795 769 796 - export function generateOAuthState(): string { 797 - const array = new Uint8Array(16); 798 - crypto.getRandomValues(array); 799 - return base64UrlEncode(array); 800 - } 801 - 802 770 export function buildOAuthAuthorizationUrl( 803 771 metadata: OAuthServerMetadata, 804 772 params: {
+83 -57
frontend/src/lib/migration/flow.svelte.ts
··· 12 12 createLocalClient, 13 13 exchangeOAuthCode, 14 14 generateDPoPKeyPair, 15 - generateOAuthState, 16 - generatePKCE, 17 15 getMigrationOAuthClientId, 18 16 getMigrationOAuthRedirectUri, 19 17 getOAuthServerMetadata, ··· 22 20 resolvePdsUrl, 23 21 saveDPoPKey, 24 22 } from "./atproto-client.ts"; 23 + import { 24 + generateCodeChallenge, 25 + generateCodeVerifier, 26 + generateState, 27 + } from "../oauth.ts"; 25 28 import { 26 29 clearMigrationState, 27 30 saveMigrationState, ··· 40 43 } 41 44 } 42 45 43 - function createInitialProgress(): MigrationProgress { 44 - return { 45 - repoExported: false, 46 - repoImported: false, 47 - blobsTotal: 0, 48 - blobsMigrated: 0, 49 - blobsFailed: [], 50 - prefsMigrated: false, 51 - plcSigned: false, 52 - activated: false, 53 - deactivated: false, 54 - currentOperation: "", 55 - }; 56 - } 46 + import { 47 + createInitialProgress, 48 + checkHandleAvailabilityViaClient, 49 + loadServerInfo, 50 + resolveVerificationIdentifier, 51 + } from "../flows/migration-shared.ts"; 52 + import { createEmailVerificationPoller } from "../flows/email-verification.ts"; 57 53 58 54 export function createInboundMigrationFlow() { 59 55 let state = $state<InboundMigrationState>({ ··· 82 78 generatedAppPasswordName: null, 83 79 handlePreservation: "new", 84 80 existingHandleVerified: false, 81 + verificationChannel: "email", 82 + discordUsername: "", 83 + telegramUsername: "", 84 + signalUsername: "", 85 85 }); 86 86 87 87 let sourceClient: AtprotoClient | null = null; 88 88 let localClient: AtprotoClient | null = null; 89 89 let localServerInfo: ServerDescription | null = null; 90 + let sourcePdsDomains: string[] = []; 90 91 91 92 function setStep(step: InboundStep) { 92 93 state.step = step; ··· 113 114 if (!localClient) { 114 115 localClient = createLocalClient(); 115 116 } 116 - if (!localServerInfo) { 117 - localServerInfo = await localClient.describeServer(); 118 - } 119 - return localServerInfo; 117 + const info = await loadServerInfo(localClient, localServerInfo); 118 + localServerInfo = info; 119 + return info; 120 120 } 121 121 122 122 async function resolveSourcePds(handle: string): Promise<void> { ··· 147 147 ); 148 148 } 149 149 150 - const { codeVerifier, codeChallenge } = await generatePKCE(); 151 - const oauthState = generateOAuthState(); 150 + const codeVerifier = generateCodeVerifier(); 151 + const codeChallenge = await generateCodeChallenge(codeVerifier); 152 + const oauthState = generateState(); 152 153 153 154 const dpopKeyPair = await generateDPoPKeyPair(); 154 155 await saveDPoPKey(dpopKeyPair); ··· 314 315 saveMigrationState(state); 315 316 } 316 317 318 + async function loadSourcePdsDomains(): Promise<string[]> { 319 + if (sourcePdsDomains.length > 0) return sourcePdsDomains; 320 + if (!sourceClient) return []; 321 + try { 322 + const info = await sourceClient.describeServer(); 323 + sourcePdsDomains = info.availableUserDomains; 324 + } catch { 325 + sourcePdsDomains = []; 326 + } 327 + return sourcePdsDomains; 328 + } 329 + 317 330 async function checkHandleAvailability(handle: string): Promise<boolean> { 318 331 if (!localClient) { 319 332 localClient = createLocalClient(); 320 333 } 321 - try { 322 - await localClient.resolveHandle(handle); 323 - return false; 324 - } catch { 325 - return true; 326 - } 334 + return checkHandleAvailabilityViaClient(localClient, handle); 327 335 } 328 336 329 337 async function verifyExistingHandle(): Promise<{ ··· 401 409 const passkeyParams = { 402 410 did: state.sourceDid, 403 411 handle: state.targetHandle, 404 - email: state.targetEmail, 412 + email: state.targetEmail || undefined, 405 413 inviteCode: state.inviteCode || undefined, 414 + verificationChannel: state.verificationChannel, 415 + discordUsername: state.discordUsername || undefined, 416 + telegramUsername: state.telegramUsername || undefined, 417 + signalUsername: state.signalUsername || undefined, 406 418 }; 407 419 408 420 migrationLog("startMigration: Creating passkey account on NEW PDS", { ··· 428 440 const accountParams = { 429 441 did: state.sourceDid, 430 442 handle: state.targetHandle, 431 - email: state.targetEmail, 443 + email: state.targetEmail || undefined, 432 444 password: state.targetPassword, 433 445 inviteCode: state.inviteCode || undefined, 446 + verificationChannel: state.verificationChannel, 447 + discordUsername: state.discordUsername || undefined, 448 + telegramUsername: state.telegramUsername || undefined, 449 + signalUsername: state.signalUsername || undefined, 434 450 }; 435 451 436 452 migrationLog("startMigration: Creating account on NEW PDS", { ··· 618 634 if (!localClient) { 619 635 localClient = createLocalClient(); 620 636 } 621 - await localClient.resendMigrationVerification(); 637 + await localClient.resendMigrationVerification( 638 + state.verificationChannel, 639 + resolveVerificationIdentifier( 640 + state.verificationChannel, 641 + state.targetEmail, 642 + state.discordUsername, 643 + state.telegramUsername, 644 + state.signalUsername, 645 + ), 646 + ); 622 647 } 623 648 624 - let checkingEmailVerification = false; 625 - 626 - async function checkEmailVerifiedAndProceed(): Promise<boolean> { 627 - if (checkingEmailVerification) return false; 628 - if (!localClient) return false; 629 - 630 - checkingEmailVerification = true; 631 - try { 632 - const verified = await localClient.checkEmailVerified(state.targetEmail); 633 - if (!verified) return false; 634 - 649 + const verificationPoller = createEmailVerificationPoller({ 650 + async checkVerified() { 651 + if (!localClient) return false; 652 + if (state.verificationChannel === "email") { 653 + return localClient.checkEmailVerified(state.targetEmail); 654 + } 655 + return localClient.checkChannelVerified( 656 + state.sourceDid, 657 + state.verificationChannel, 658 + ); 659 + }, 660 + async onVerified() { 635 661 if (state.authMethod === "passkey") { 636 662 migrationLog( 637 663 "checkEmailVerifiedAndProceed: Email verified, proceeding to passkey setup", 638 664 ); 639 665 setStep("passkey-setup"); 640 - return true; 666 + return; 641 667 } 642 668 643 - if (!localClient.getAccessToken()) { 644 - await localClient.loginDeactivated( 669 + if (!localClient!.getAccessToken()) { 670 + await localClient!.loginDeactivated( 645 671 state.targetEmail, 646 672 state.targetPassword, 647 673 ); ··· 652 678 setError( 653 679 "Email verified! Please log in to your old account again to complete the migration.", 654 680 ); 655 - return true; 681 + return; 656 682 } 657 683 658 684 if (state.sourceDid.startsWith("did:web:")) { 659 - const credentials = await localClient.getRecommendedDidCredentials(); 685 + const credentials = await localClient!.getRecommendedDidCredentials(); 660 686 state.targetVerificationMethod = 661 687 credentials.verificationMethods?.atproto || null; 662 688 setStep("did-web-update"); ··· 664 690 await sourceClient.requestPlcOperationSignature(); 665 691 setStep("plc-token"); 666 692 } 667 - return true; 668 - } catch (e) { 669 - const err = e as Error & { error?: string }; 670 - if (err.error === "AccountNotVerified") { 671 - return false; 672 - } 673 - return false; 674 - } finally { 675 - checkingEmailVerification = false; 676 - } 693 + }, 694 + }); 695 + 696 + function checkEmailVerifiedAndProceed(): Promise<boolean> { 697 + return verificationPoller.checkAndAdvance(); 677 698 } 678 699 679 700 async function submitPlcToken(token: string): Promise<void> { ··· 946 967 generatedAppPasswordName: null, 947 968 handlePreservation: "new", 948 969 existingHandleVerified: false, 970 + verificationChannel: "email", 971 + discordUsername: "", 972 + telegramUsername: "", 973 + signalUsername: "", 949 974 }; 950 975 sourceClient = null; 951 976 passkeySetup = null; ··· 1025 1050 setStep, 1026 1051 setError, 1027 1052 loadLocalServerInfo, 1053 + loadSourcePdsDomains, 1028 1054 resolveSourcePds, 1029 1055 initiateOAuthLogin, 1030 1056 handleOAuthCallback,
+62 -90
frontend/src/lib/migration/offline-flow.svelte.ts
··· 7 7 } from "./types.ts"; 8 8 import { 9 9 AtprotoClient, 10 - base64UrlEncode, 11 10 createLocalClient, 12 - prepareWebAuthnCreationOptions, 13 11 } from "./atproto-client.ts"; 12 + import { createPasskeyCredential } from "../flows/perform-passkey-registration.ts"; 14 13 import { api } from "../api.ts"; 15 14 import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops.ts"; 16 15 import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts"; ··· 124 123 125 124 export { clearOfflineState }; 126 125 127 - function createInitialProgress(): MigrationProgress { 128 - return { 129 - repoExported: false, 130 - repoImported: false, 131 - blobsTotal: 0, 132 - blobsMigrated: 0, 133 - blobsFailed: [], 134 - prefsMigrated: false, 135 - plcSigned: false, 136 - activated: false, 137 - deactivated: false, 138 - currentOperation: "", 139 - }; 140 - } 126 + import { 127 + createInitialProgress, 128 + checkHandleAvailabilityViaClient, 129 + loadServerInfo, 130 + resolveVerificationIdentifier, 131 + } from "../flows/migration-shared.ts"; 132 + import { createEmailVerificationPoller } from "../flows/email-verification.ts"; 141 133 142 134 export type OfflineInboundMigrationFlow = ReturnType< 143 135 typeof createOfflineInboundMigrationFlow ··· 171 163 plcUpdatedTemporarily: false, 172 164 handlePreservation: "new", 173 165 existingHandleVerified: false, 166 + verificationChannel: "email", 167 + discordUsername: "", 168 + telegramUsername: "", 169 + signalUsername: "", 174 170 }); 175 171 176 172 let localServerInfo: ServerDescription | null = null; ··· 198 194 } 199 195 200 196 async function loadLocalServerInfo(): Promise<ServerDescription> { 201 - if (!localServerInfo) { 202 - const client = createLocalClient(); 203 - localServerInfo = await client.describeServer(); 204 - } 205 - return localServerInfo; 197 + const info = await loadServerInfo(createLocalClient(), localServerInfo); 198 + localServerInfo = info; 199 + return info; 206 200 } 207 201 208 202 async function checkHandleAvailability(handle: string): Promise<boolean> { 209 - const client = createLocalClient(); 210 - try { 211 - await client.resolveHandle(handle); 212 - return false; 213 - } catch { 214 - return true; 215 - } 203 + return checkHandleAvailabilityViaClient(createLocalClient(), handle); 216 204 } 217 205 218 206 async function validateRotationKey(): Promise<boolean> { ··· 235 223 const pdsService = lastOperation.services?.atproto_pds; 236 224 if (pdsService?.endpoint) { 237 225 state.oldPdsUrl = pdsService.endpoint; 238 - console.log( 239 - "[offline-migration] Captured old PDS URL:", 240 - state.oldPdsUrl, 241 - ); 242 - } else { 243 - console.warn( 244 - "[offline-migration] No PDS service endpoint found in PLC document", 245 - ); 246 - console.log( 247 - "[offline-migration] PLC services:", 248 - JSON.stringify(lastOperation.services), 249 - ); 250 226 } 251 227 252 228 saveOfflineState(state); ··· 315 291 { 316 292 did: unsafeAsDid(state.userDid), 317 293 handle: unsafeAsHandle(fullHandle), 318 - email: unsafeAsEmail(state.targetEmail), 294 + email: state.targetEmail ? unsafeAsEmail(state.targetEmail) : undefined, 319 295 password: state.targetPassword, 320 296 inviteCode: state.inviteCode || undefined, 297 + verificationChannel: state.verificationChannel, 298 + discordUsername: state.discordUsername || undefined, 299 + telegramUsername: state.telegramUsername || undefined, 300 + signalUsername: state.signalUsername || undefined, 321 301 }, 322 302 ); 323 303 ··· 338 318 const createResult = await api.createPasskeyAccount({ 339 319 did: unsafeAsDid(state.userDid), 340 320 handle: unsafeAsHandle(fullHandle), 341 - email: unsafeAsEmail(state.targetEmail), 321 + email: state.targetEmail ? unsafeAsEmail(state.targetEmail) : undefined, 342 322 inviteCode: state.inviteCode || undefined, 323 + verificationChannel: state.verificationChannel, 324 + discordUsername: state.discordUsername || undefined, 325 + telegramUsername: state.telegramUsername || undefined, 326 + signalUsername: state.signalUsername || undefined, 343 327 }, serviceAuthToken); 344 328 345 329 state.targetHandle = fullHandle; ··· 487 471 } 488 472 489 473 async function resendEmailVerification(): Promise<void> { 490 - await api.resendMigrationVerification(unsafeAsEmail(state.targetEmail)); 474 + await api.resendMigrationVerification( 475 + state.verificationChannel, 476 + resolveVerificationIdentifier( 477 + state.verificationChannel, 478 + state.targetEmail, 479 + state.discordUsername, 480 + state.telegramUsername, 481 + state.signalUsername, 482 + ), 483 + ); 491 484 } 492 485 493 - let checkingEmailVerification = false; 494 - 495 - async function checkEmailVerifiedAndProceed(): Promise<boolean> { 496 - if (checkingEmailVerification) return false; 497 - if (state.authMethod === "passkey") return false; 498 - 499 - checkingEmailVerification = true; 500 - try { 501 - const { verified } = await api.checkEmailVerified(state.targetEmail); 502 - if (!verified) return false; 503 - 486 + const verificationPoller = createEmailVerificationPoller({ 487 + async checkVerified() { 488 + if (state.authMethod === "passkey") return false; 489 + if (state.verificationChannel === "email") { 490 + const { verified } = await api.checkEmailVerified(state.targetEmail); 491 + return verified; 492 + } 493 + const { verified } = await api.checkChannelVerified( 494 + state.userDid, 495 + state.verificationChannel, 496 + ); 497 + return verified; 498 + }, 499 + async onVerified() { 504 500 if (!state.localAccessToken) { 505 501 const session = await api.createSession( 506 502 state.targetEmail, ··· 519 515 520 516 cleanup(); 521 517 setStep("success"); 522 - return true; 523 - } catch { 524 - return false; 525 - } finally { 526 - checkingEmailVerification = false; 527 - } 518 + }, 519 + }); 520 + 521 + function checkEmailVerifiedAndProceed(): Promise<boolean> { 522 + return verificationPoller.checkAndAdvance(); 528 523 } 529 524 530 525 async function startPasskeyRegistration(): Promise<{ options: unknown }> { ··· 543 538 throw new Error("No passkey setup token"); 544 539 } 545 540 546 - if (!globalThis.PublicKeyCredential) { 547 - throw new Error("Passkeys are not supported in this browser"); 548 - } 549 - 550 - const { options } = await startPasskeyRegistration(); 551 - 552 - const publicKeyOptions = prepareWebAuthnCreationOptions( 553 - options as { publicKey: Record<string, unknown> }, 541 + const credential = await createPasskeyCredential( 542 + () => startPasskeyRegistration(), 554 543 ); 555 - const credential = await navigator.credentials.create({ 556 - publicKey: publicKeyOptions, 557 - }); 558 - 559 - if (!credential) { 560 - throw new Error("Passkey creation was cancelled"); 561 - } 562 - 563 - const publicKeyCredential = credential as PublicKeyCredential; 564 - const response = publicKeyCredential 565 - .response as AuthenticatorAttestationResponse; 566 - 567 - const credentialData = { 568 - id: publicKeyCredential.id, 569 - rawId: base64UrlEncode(publicKeyCredential.rawId), 570 - type: publicKeyCredential.type, 571 - response: { 572 - clientDataJSON: base64UrlEncode(response.clientDataJSON), 573 - attestationObject: base64UrlEncode(response.attestationObject), 574 - }, 575 - }; 576 544 577 545 const result = await api.completePasskeySetup( 578 546 unsafeAsDid(state.userDid), 579 547 state.passkeySetupToken, 580 - credentialData, 548 + credential, 581 549 passkeyName, 582 550 ); 583 551 ··· 675 643 plcUpdatedTemporarily: false, 676 644 handlePreservation: "new", 677 645 existingHandleVerified: false, 646 + verificationChannel: "email", 647 + discordUsername: "", 648 + telegramUsername: "", 649 + signalUsername: "", 678 650 }; 679 651 localServerInfo = null; 680 652 }
+21 -2
frontend/src/lib/migration/types.ts
··· 50 50 51 51 export type HandlePreservation = "new" | "existing"; 52 52 53 + export type VerificationChannel = "email" | "discord" | "telegram" | "signal"; 54 + 53 55 export interface InboundMigrationState { 54 56 direction: "inbound"; 55 57 step: InboundStep; ··· 78 80 resumeToStep?: InboundStep; 79 81 handlePreservation: HandlePreservation; 80 82 existingHandleVerified: boolean; 83 + verificationChannel: VerificationChannel; 84 + discordUsername: string; 85 + telegramUsername: string; 86 + signalUsername: string; 81 87 } 82 88 83 89 export interface OfflineInboundMigrationState { ··· 107 113 plcUpdatedTemporarily: boolean; 108 114 handlePreservation: HandlePreservation; 109 115 existingHandleVerified: boolean; 116 + verificationChannel: VerificationChannel; 117 + discordUsername: string; 118 + telegramUsername: string; 119 + signalUsername: string; 110 120 } 111 121 112 122 export type MigrationState = InboundMigrationState; ··· 142 152 availableUserDomains: string[]; 143 153 inviteCodeRequired: boolean; 144 154 phoneVerificationRequired?: boolean; 155 + availableCommsChannels?: VerificationChannel[]; 145 156 links?: { 146 157 privacyPolicy?: string; 147 158 termsOfService?: string; ··· 226 237 export interface CreateAccountParams { 227 238 did?: string; 228 239 handle: string; 229 - email: string; 240 + email?: string; 230 241 password: string; 231 242 inviteCode?: string; 232 243 recoveryKey?: string; 244 + verificationChannel?: VerificationChannel; 245 + discordUsername?: string; 246 + telegramUsername?: string; 247 + signalUsername?: string; 233 248 } 234 249 235 250 export interface CreatePasskeyAccountParams { 236 251 did?: string; 237 252 handle: string; 238 - email: string; 253 + email?: string; 239 254 inviteCode?: string; 255 + verificationChannel?: VerificationChannel; 256 + discordUsername?: string; 257 + telegramUsername?: string; 258 + signalUsername?: string; 240 259 } 241 260 242 261 export interface PasskeyAccountSetup {
+11
frontend/src/lib/portal.ts
··· 1 + export function portal(node: HTMLElement): { destroy: () => void } { 2 + const target = document.body; 3 + target.appendChild(node); 4 + return { 5 + destroy() { 6 + if (node.parentNode === target) { 7 + target.removeChild(node); 8 + } 9 + }, 10 + }; 11 + }
-51
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 -
+104 -160
frontend/src/lib/registration/flow.svelte.ts
··· 1 1 import { api, ApiError } from "../api.ts"; 2 + import { createEmailVerificationPoller } from "../flows/email-verification.ts"; 2 3 import { setSession } from "../auth.svelte.ts"; 3 4 import { 4 5 createServiceJwt, ··· 223 224 state.step = "creating"; 224 225 } 225 226 227 + async function generateByodToken(): Promise<string | undefined> { 228 + if ( 229 + state.info.didType !== "web-external" || 230 + state.externalDidWeb.keyMode !== "byod" || 231 + !state.externalDidWeb.byodPrivateKey 232 + ) { 233 + return undefined; 234 + } 235 + return createServiceJwt( 236 + state.externalDidWeb.byodPrivateKey, 237 + state.info.externalDid!.trim(), 238 + getPdsDid(), 239 + "com.atproto.server.createAccount", 240 + ); 241 + } 242 + 243 + function commonAccountParams() { 244 + return { 245 + didType: state.info.didType, 246 + did: state.info.didType === "web-external" 247 + ? unsafeAsDid(state.info.externalDid!.trim()) 248 + : undefined, 249 + signingKey: state.info.didType === "web-external" && 250 + state.externalDidWeb.keyMode === "reserved" 251 + ? state.externalDidWeb.reservedSigningKey 252 + : undefined, 253 + inviteCode: state.info.inviteCode?.trim() || undefined, 254 + verificationChannel: state.info.verificationChannel, 255 + discordUsername: state.info.discordUsername?.trim() || undefined, 256 + telegramUsername: state.info.telegramUsername?.trim() || undefined, 257 + signalUsername: state.info.signalUsername?.trim() || undefined, 258 + }; 259 + } 260 + 226 261 async function createPasswordAccount() { 227 262 state.submitting = true; 228 263 state.error = null; 229 264 230 265 try { 231 - let byodToken: string | undefined; 232 - 233 - if ( 234 - state.info.didType === "web-external" && 235 - state.externalDidWeb.keyMode === "byod" && 236 - state.externalDidWeb.byodPrivateKey 237 - ) { 238 - byodToken = await createServiceJwt( 239 - state.externalDidWeb.byodPrivateKey, 240 - state.info.externalDid!.trim(), 241 - getPdsDid(), 242 - "com.atproto.server.createAccount", 243 - ); 244 - } 245 - 266 + const byodToken = await generateByodToken(); 246 267 const result = await api.createAccount({ 247 268 handle: getFullHandle(), 248 269 email: state.info.email.trim(), 249 270 password: state.info.password!, 250 - inviteCode: state.info.inviteCode?.trim() || undefined, 251 - didType: state.info.didType, 252 - did: state.info.didType === "web-external" 253 - ? state.info.externalDid!.trim() 254 - : undefined, 255 - signingKey: state.info.didType === "web-external" && 256 - state.externalDidWeb.keyMode === "reserved" 257 - ? state.externalDidWeb.reservedSigningKey 258 - : undefined, 259 - verificationChannel: state.info.verificationChannel, 260 - discordUsername: state.info.discordUsername?.trim() || undefined, 261 - telegramUsername: state.info.telegramUsername?.trim() || undefined, 262 - signalUsername: state.info.signalUsername?.trim() || undefined, 271 + ...commonAccountParams(), 263 272 }, byodToken); 264 273 265 274 state.account = { ··· 280 289 state.error = null; 281 290 282 291 try { 283 - let byodToken: string | undefined; 284 - 285 - if ( 286 - state.info.didType === "web-external" && 287 - state.externalDidWeb.keyMode === "byod" && 288 - state.externalDidWeb.byodPrivateKey 289 - ) { 290 - byodToken = await createServiceJwt( 291 - state.externalDidWeb.byodPrivateKey, 292 - state.info.externalDid!.trim(), 293 - getPdsDid(), 294 - "com.atproto.server.createAccount", 295 - ); 296 - } 297 - 292 + const byodToken = await generateByodToken(); 298 293 const result = await api.createPasskeyAccount({ 299 294 handle: unsafeAsHandle(getFullHandle()), 300 295 email: state.info.email?.trim() 301 296 ? unsafeAsEmail(state.info.email.trim()) 302 297 : undefined, 303 - inviteCode: state.info.inviteCode?.trim() || undefined, 304 - didType: state.info.didType, 305 - did: state.info.didType === "web-external" 306 - ? unsafeAsDid(state.info.externalDid!.trim()) 307 - : undefined, 308 - signingKey: state.info.didType === "web-external" && 309 - state.externalDidWeb.keyMode === "reserved" 310 - ? state.externalDidWeb.reservedSigningKey 311 - : undefined, 312 - verificationChannel: state.info.verificationChannel, 313 - discordUsername: state.info.discordUsername?.trim() || undefined, 314 - telegramUsername: state.info.telegramUsername?.trim() || undefined, 315 - signalUsername: state.info.signalUsername?.trim() || undefined, 298 + ...commonAccountParams(), 316 299 }, byodToken); 317 300 318 301 state.account = { ··· 343 326 persistState(); 344 327 } 345 328 329 + function getAccountPassword(): string { 330 + return state.mode === "passkey" 331 + ? state.account!.appPassword! 332 + : state.info.password!; 333 + } 334 + 335 + async function handlePostVerification( 336 + session: SessionState, 337 + ): Promise<void> { 338 + state.session = session; 339 + 340 + if ( 341 + state.info.didType === "web-external" && 342 + state.externalDidWeb.keyMode === "byod" 343 + ) { 344 + const credentials = await api.getRecommendedDidCredentials( 345 + session.accessJwt, 346 + ); 347 + const newPublicKeyMultibase = 348 + credentials.verificationMethods?.atproto?.replace("did:key:", "") || ""; 349 + 350 + const didDoc = generateDidDocument( 351 + state.info.externalDid!.trim(), 352 + newPublicKeyMultibase, 353 + state.account!.handle, 354 + getPdsEndpoint(), 355 + ); 356 + state.externalDidWeb.updatedDidDocument = JSON.stringify( 357 + didDoc, 358 + null, 359 + "\t", 360 + ); 361 + state.step = "updated-did-doc"; 362 + persistState(); 363 + } else if (state.info.didType === "web-external") { 364 + await api.activateAccount(session.accessJwt); 365 + await finalizeSession(); 366 + state.step = "redirect-to-dashboard"; 367 + } else { 368 + await finalizeSession(); 369 + state.step = "redirect-to-dashboard"; 370 + } 371 + } 372 + 346 373 async function verifyAccount(code: string) { 347 374 state.submitting = true; 348 375 state.error = null; ··· 354 381 ); 355 382 356 383 if (state.info.didType === "web-external") { 357 - const password = state.mode === "passkey" 358 - ? state.account!.appPassword! 359 - : state.info.password!; 360 - const session = await api.createSession(state.account!.did, password); 361 - state.session = { 362 - accessJwt: session.accessJwt, 363 - refreshJwt: session.refreshJwt, 364 - }; 365 - 366 - if (state.externalDidWeb.keyMode === "byod") { 367 - const credentials = await api.getRecommendedDidCredentials( 368 - session.accessJwt, 369 - ); 370 - const newPublicKeyMultibase = 371 - credentials.verificationMethods?.atproto?.replace("did:key:", "") || 372 - ""; 373 - 374 - const didDoc = generateDidDocument( 375 - state.info.externalDid!.trim(), 376 - newPublicKeyMultibase, 377 - state.account!.handle, 378 - getPdsEndpoint(), 379 - ); 380 - state.externalDidWeb.updatedDidDocument = JSON.stringify( 381 - didDoc, 382 - null, 383 - "\t", 384 - ); 385 - state.step = "updated-did-doc"; 386 - persistState(); 387 - } else { 388 - await api.activateAccount(session.accessJwt); 389 - await finalizeSession(); 390 - state.step = "redirect-to-dashboard"; 391 - } 384 + const session = await api.createSession( 385 + state.account!.did, 386 + getAccountPassword(), 387 + ); 388 + await handlePostVerification(session); 392 389 } else { 393 - state.session = { 394 - accessJwt: confirmResult.accessJwt, 395 - refreshJwt: confirmResult.refreshJwt, 396 - }; 397 - await finalizeSession(); 398 - state.step = "redirect-to-dashboard"; 390 + await handlePostVerification(confirmResult); 399 391 } 400 392 } catch (err) { 401 393 setError(err); ··· 419 411 } 420 412 } 421 413 422 - let checkingVerification = false; 423 - 424 - async function checkAndAdvanceIfVerified(): Promise<boolean> { 425 - if (checkingVerification || !state.account) return false; 426 - 427 - checkingVerification = true; 428 - try { 414 + const verificationPoller = createEmailVerificationPoller({ 415 + async checkVerified() { 416 + if (!state.account) return false; 429 417 const result = await api.checkChannelVerified( 430 418 state.account.did, 431 419 state.info.verificationChannel, 432 420 ); 433 - if (!result.verified) return false; 434 - 435 - if (state.info.didType === "web-external") { 436 - const password = state.mode === "passkey" 437 - ? state.account.appPassword! 438 - : state.info.password!; 439 - const session = await api.createSession(state.account.did, password); 440 - state.session = { 441 - accessJwt: session.accessJwt, 442 - refreshJwt: session.refreshJwt, 443 - }; 444 - 445 - if (state.externalDidWeb.keyMode === "byod") { 446 - const credentials = await api.getRecommendedDidCredentials( 447 - session.accessJwt, 448 - ); 449 - const newPublicKeyMultibase = 450 - credentials.verificationMethods?.atproto?.replace("did:key:", "") || 451 - ""; 452 - 453 - const didDoc = generateDidDocument( 454 - state.info.externalDid!.trim(), 455 - newPublicKeyMultibase, 456 - state.account.handle, 457 - getPdsEndpoint(), 458 - ); 459 - state.externalDidWeb.updatedDidDocument = JSON.stringify( 460 - didDoc, 461 - null, 462 - "\t", 463 - ); 464 - state.step = "updated-did-doc"; 465 - persistState(); 466 - } else { 467 - await api.activateAccount(session.accessJwt); 468 - await finalizeSession(); 469 - state.step = "redirect-to-dashboard"; 470 - } 471 - } else { 472 - const password = state.mode === "passkey" 473 - ? state.account.appPassword! 474 - : state.info.password!; 475 - const session = await api.createSession(state.account.did, password); 476 - state.session = { 477 - accessJwt: session.accessJwt, 478 - refreshJwt: session.refreshJwt, 479 - }; 480 - await finalizeSession(); 481 - state.step = "redirect-to-dashboard"; 482 - } 421 + return result.verified; 422 + }, 423 + async onVerified() { 424 + const session = await api.createSession( 425 + state.account!.did, 426 + getAccountPassword(), 427 + ); 428 + await handlePostVerification(session); 429 + }, 430 + }); 483 431 484 - return true; 485 - } catch { 486 - return false; 487 - } finally { 488 - checkingVerification = false; 489 - } 432 + function checkAndAdvanceIfVerified(): Promise<boolean> { 433 + return verificationPoller.checkAndAdvance(); 490 434 } 491 435 492 436 function goBack() {
+1 -1
frontend/src/lib/registration/index.ts
··· 3 3 export { default as VerificationStep } from "./VerificationStep.svelte"; 4 4 export { default as KeyChoiceStep } from "./KeyChoiceStep.svelte"; 5 5 export { default as DidDocStep } from "./DidDocStep.svelte"; 6 - export { default as AppPasswordStep } from "./AppPasswordStep.svelte"; 6 +

History

3 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
refactor(frontend): refactor migration and registration lib
expand 0 comments
pull request successfully merged
1 commit
expand
refactor(frontend): refactor migration and registration lib
expand 0 comments
1 commit
expand
refactor(frontend): refactor migration and registration lib
expand 0 comments