+706
-425
Diff
round #2
+139
frontend/src/components/CommsChannelPicker.svelte
+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
+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
+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
+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
+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
+
}
+54
frontend/src/lib/flows/perform-passkey-registration.ts
+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
+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
+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
+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
+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
+11
frontend/src/lib/portal.ts
-51
frontend/src/lib/registration/AppPasswordStep.svelte
-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
+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
+1
-1
frontend/src/lib/registration/index.ts
History
3 rounds
0 comments
oyster.cafe
submitted
#2
1 commit
expand
collapse
refactor(frontend): refactor migration and registration lib
expand 0 comments
pull request successfully merged
oyster.cafe
submitted
#1
1 commit
expand
collapse
refactor(frontend): refactor migration and registration lib
expand 0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
refactor(frontend): refactor migration and registration lib