mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-05-25 09:20:21 +00:00
refactor(frontend): refactor migration and registration lib
This commit is contained in:
139
frontend/src/components/CommsChannelPicker.svelte
Normal file
139
frontend/src/components/CommsChannelPicker.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import type { VerificationChannel } from '../lib/types/api'
|
||||
import { _ } from '../lib/i18n'
|
||||
|
||||
interface Props {
|
||||
channel: VerificationChannel
|
||||
email: string
|
||||
discordUsername: string
|
||||
telegramUsername: string
|
||||
signalUsername: string
|
||||
availableChannels: VerificationChannel[]
|
||||
disabled?: boolean
|
||||
discordInUse?: boolean
|
||||
telegramInUse?: boolean
|
||||
signalInUse?: boolean
|
||||
onChannelChange: (channel: VerificationChannel) => void
|
||||
onEmailChange: (value: string) => void
|
||||
onDiscordChange: (value: string) => void
|
||||
onTelegramChange: (value: string) => void
|
||||
onSignalChange: (value: string) => void
|
||||
onCheckInUse?: (channel: 'discord' | 'telegram' | 'signal', identifier: string) => void
|
||||
}
|
||||
|
||||
let {
|
||||
channel,
|
||||
email,
|
||||
discordUsername,
|
||||
telegramUsername,
|
||||
signalUsername,
|
||||
availableChannels,
|
||||
disabled = false,
|
||||
discordInUse = false,
|
||||
telegramInUse = false,
|
||||
signalInUse = false,
|
||||
onChannelChange,
|
||||
onEmailChange,
|
||||
onDiscordChange,
|
||||
onTelegramChange,
|
||||
onSignalChange,
|
||||
onCheckInUse,
|
||||
}: Props = $props()
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
switch (ch) {
|
||||
case 'email': return $_('register.email')
|
||||
case 'discord': return $_('register.discord')
|
||||
case 'telegram': return $_('register.telegram')
|
||||
case 'signal': return $_('register.signal')
|
||||
default: return ch
|
||||
}
|
||||
}
|
||||
|
||||
function isAvailable(ch: VerificationChannel): boolean {
|
||||
return availableChannels.includes(ch)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<label for="verification-channel">{$_('register.verificationMethod')}</label>
|
||||
<select id="verification-channel" value={channel} onchange={(e) => onChannelChange((e.target as HTMLSelectElement).value as VerificationChannel)} {disabled}>
|
||||
<option value="email">{channelLabel('email')}</option>
|
||||
{#if isAvailable('discord')}
|
||||
<option value="discord">{channelLabel('discord')}</option>
|
||||
{/if}
|
||||
{#if isAvailable('telegram')}
|
||||
<option value="telegram">{channelLabel('telegram')}</option>
|
||||
{/if}
|
||||
{#if isAvailable('signal')}
|
||||
<option value="signal">{channelLabel('signal')}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if channel === 'email'}
|
||||
<div>
|
||||
<label for="comms-email">{$_('register.emailAddress')}</label>
|
||||
<input
|
||||
id="comms-email"
|
||||
type="email"
|
||||
value={email}
|
||||
oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)}
|
||||
placeholder={$_('register.emailPlaceholder')}
|
||||
{disabled}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if channel === 'discord'}
|
||||
<div>
|
||||
<label for="comms-discord">{$_('register.discordUsername')}</label>
|
||||
<input
|
||||
id="comms-discord"
|
||||
type="text"
|
||||
value={discordUsername}
|
||||
oninput={(e) => onDiscordChange((e.target as HTMLInputElement).value)}
|
||||
onblur={() => onCheckInUse?.('discord', discordUsername)}
|
||||
placeholder={$_('register.discordUsernamePlaceholder')}
|
||||
{disabled}
|
||||
required
|
||||
/>
|
||||
{#if discordInUse}
|
||||
<p class="hint warning">{$_('register.discordInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if channel === 'telegram'}
|
||||
<div>
|
||||
<label for="comms-telegram">{$_('register.telegramUsername')}</label>
|
||||
<input
|
||||
id="comms-telegram"
|
||||
type="text"
|
||||
value={telegramUsername}
|
||||
oninput={(e) => onTelegramChange((e.target as HTMLInputElement).value)}
|
||||
onblur={() => onCheckInUse?.('telegram', telegramUsername)}
|
||||
placeholder={$_('register.telegramUsernamePlaceholder')}
|
||||
{disabled}
|
||||
required
|
||||
/>
|
||||
{#if telegramInUse}
|
||||
<p class="hint warning">{$_('register.telegramInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if channel === 'signal'}
|
||||
<div>
|
||||
<label for="comms-signal">{$_('register.signalUsername')}</label>
|
||||
<input
|
||||
id="comms-signal"
|
||||
type="tel"
|
||||
value={signalUsername}
|
||||
oninput={(e) => onSignalChange((e.target as HTMLInputElement).value)}
|
||||
onblur={() => onCheckInUse?.('signal', signalUsername)}
|
||||
placeholder={$_('register.signalUsernamePlaceholder')}
|
||||
{disabled}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalUsernameHint')}</p>
|
||||
{#if signalInUse}
|
||||
<p class="hint warning">{$_('register.signalInUseWarning')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -7,6 +7,9 @@
|
||||
placeholder?: string
|
||||
id?: string
|
||||
autocomplete?: HTMLInputElement['autocomplete']
|
||||
checkAvailability?: (fullHandle: string) => Promise<boolean>
|
||||
available?: boolean | null
|
||||
checking?: boolean
|
||||
onInput: (value: string) => void
|
||||
onDomainChange: (domain: string) => void
|
||||
}
|
||||
@@ -19,11 +22,42 @@
|
||||
placeholder = 'username',
|
||||
id = 'handle',
|
||||
autocomplete = 'off',
|
||||
checkAvailability,
|
||||
available = $bindable<boolean | null>(null),
|
||||
checking = $bindable(false),
|
||||
onInput,
|
||||
onDomainChange,
|
||||
}: Props = $props()
|
||||
|
||||
const showDomainSelect = $derived(domains.length > 1 && !value.includes('.'))
|
||||
|
||||
let checkTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
$effect(() => {
|
||||
void value
|
||||
void selectedDomain
|
||||
if (!checkAvailability) return
|
||||
if (checkTimeout) clearTimeout(checkTimeout)
|
||||
available = null
|
||||
if (value.trim().length >= 3 && !value.includes('.')) {
|
||||
checkTimeout = setTimeout(() => runCheck(), 400)
|
||||
}
|
||||
})
|
||||
|
||||
async function runCheck() {
|
||||
if (!checkAvailability) return
|
||||
const fullHandle = value.includes('.')
|
||||
? value.trim()
|
||||
: `${value.trim()}.${selectedDomain}`
|
||||
checking = true
|
||||
try {
|
||||
available = await checkAvailability(fullHandle)
|
||||
} catch {
|
||||
available = null
|
||||
} finally {
|
||||
checking = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="handle-input-group">
|
||||
|
||||
78
frontend/src/components/IdentityTypeSection.svelte
Normal file
78
frontend/src/components/IdentityTypeSection.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { _ } from '../lib/i18n'
|
||||
|
||||
interface Props {
|
||||
didType: 'plc' | 'web' | 'web-external'
|
||||
externalDid: string
|
||||
disabled: boolean
|
||||
selfHostedDidWebEnabled: boolean
|
||||
defaultDomain: string
|
||||
onDidTypeChange: (value: 'plc' | 'web' | 'web-external') => void
|
||||
onExternalDidChange: (value: string) => void
|
||||
}
|
||||
|
||||
let {
|
||||
didType,
|
||||
externalDid,
|
||||
disabled,
|
||||
selfHostedDidWebEnabled,
|
||||
defaultDomain,
|
||||
onDidTypeChange,
|
||||
onExternalDidChange,
|
||||
}: Props = $props()
|
||||
|
||||
function extractDomain(did: string): string {
|
||||
return did.replace(/^did:web:/, '').split(':')[0] || 'yourdomain.com'
|
||||
}
|
||||
</script>
|
||||
|
||||
<fieldset class="identity-section">
|
||||
<legend>{$_('registerPasskey.identityType')}</legend>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="plc" checked={didType === 'plc'} onchange={() => onDidTypeChange('plc')} {disabled} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
|
||||
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label" class:disabled={!selfHostedDidWebEnabled}>
|
||||
<input type="radio" name="didType" value="web" checked={didType === 'web'} onchange={() => onDidTypeChange('web')} disabled={disabled || !selfHostedDidWebEnabled} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didWeb')}</strong>
|
||||
{#if !selfHostedDidWebEnabled}
|
||||
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
|
||||
{:else}
|
||||
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web-external" checked={didType === 'web-external'} onchange={() => onDidTypeChange('web-external')} {disabled} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
|
||||
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if didType === 'web'}
|
||||
<div class="warning-box">
|
||||
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
|
||||
<ul>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${defaultDomain}</code>` } })}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if didType === 'web-external'}
|
||||
<div>
|
||||
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
|
||||
<input id="external-did" type="text" value={externalDid} oninput={(e) => onExternalDidChange(e.currentTarget.value)} placeholder={$_('registerPasskey.externalDidPlaceholder')} {disabled} required />
|
||||
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { portal } from '../lib/portal'
|
||||
import { getAuthState, getValidToken } from '../lib/auth.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { _ } from '../lib/i18n'
|
||||
@@ -136,7 +137,7 @@
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
|
||||
<div class="modal-backdrop" use:portal onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
|
||||
<div class="modal-header">
|
||||
<h2>{$_('reauth.title')}</h2>
|
||||
@@ -181,7 +182,7 @@
|
||||
|
||||
<div class="modal-content">
|
||||
{#if activeMethod === 'password'}
|
||||
<form onsubmit={handlePasswordSubmit}>
|
||||
<form id="reauth-form" onsubmit={handlePasswordSubmit}>
|
||||
<div>
|
||||
<label for="reauth-password">{$_('reauth.password')}</label>
|
||||
<input
|
||||
@@ -192,12 +193,9 @@
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading || !password}>
|
||||
{loading ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
</form>
|
||||
{:else if activeMethod === 'totp'}
|
||||
<form onsubmit={handleTotpSubmit}>
|
||||
<form id="reauth-form" onsubmit={handleTotpSubmit}>
|
||||
<div>
|
||||
<label for="reauth-totp">{$_('reauth.authenticatorCode')}</label>
|
||||
<input
|
||||
@@ -211,15 +209,10 @@
|
||||
maxlength="6"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading || !totpCode}>
|
||||
{loading ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
</form>
|
||||
{:else if activeMethod === 'passkey'}
|
||||
<div class="passkey-auth">
|
||||
<button onclick={handlePasskeyAuth} disabled={loading}>
|
||||
{loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')}
|
||||
</button>
|
||||
<p>{$_('reauth.usePasskey')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -228,6 +221,15 @@
|
||||
<button class="secondary" onclick={handleClose} disabled={loading}>
|
||||
{$_('reauth.cancel')}
|
||||
</button>
|
||||
{#if activeMethod === 'passkey'}
|
||||
<button onclick={handlePasskeyAuth} disabled={loading}>
|
||||
{loading ? $_('reauth.authenticating') : $_('common.verify')}
|
||||
</button>
|
||||
{:else}
|
||||
<button type="submit" form="reauth-form" disabled={loading || (activeMethod === 'password' ? !password : !totpCode)}>
|
||||
{loading ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
29
frontend/src/lib/flows/email-verification.ts
Normal file
29
frontend/src/lib/flows/email-verification.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface EmailVerificationDeps {
|
||||
checkVerified: () => Promise<boolean>;
|
||||
onVerified: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createEmailVerificationPoller(
|
||||
deps: EmailVerificationDeps,
|
||||
): { checkAndAdvance: () => Promise<boolean> } {
|
||||
let checking = false;
|
||||
|
||||
return {
|
||||
async checkAndAdvance(): Promise<boolean> {
|
||||
if (checking) return false;
|
||||
|
||||
checking = true;
|
||||
try {
|
||||
const verified = await deps.checkVerified();
|
||||
if (!verified) return false;
|
||||
|
||||
await deps.onVerified();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
56
frontend/src/lib/flows/migration-shared.ts
Normal file
56
frontend/src/lib/flows/migration-shared.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
MigrationProgress,
|
||||
ServerDescription,
|
||||
VerificationChannel,
|
||||
} from "../migration/types.ts";
|
||||
import type { AtprotoClient } from "../migration/atproto-client.ts";
|
||||
|
||||
export function createInitialProgress(): MigrationProgress {
|
||||
return {
|
||||
repoExported: false,
|
||||
repoImported: false,
|
||||
blobsTotal: 0,
|
||||
blobsMigrated: 0,
|
||||
blobsFailed: [],
|
||||
prefsMigrated: false,
|
||||
plcSigned: false,
|
||||
activated: false,
|
||||
deactivated: false,
|
||||
currentOperation: "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkHandleAvailabilityViaClient(
|
||||
client: AtprotoClient,
|
||||
handle: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await client.resolveHandle(handle);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveVerificationIdentifier(
|
||||
channel: VerificationChannel,
|
||||
email: string,
|
||||
discordUsername: string,
|
||||
telegramUsername: string,
|
||||
signalUsername: string,
|
||||
): string {
|
||||
switch (channel) {
|
||||
case "email": return email;
|
||||
case "discord": return discordUsername;
|
||||
case "telegram": return telegramUsername;
|
||||
case "signal": return signalUsername;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadServerInfo(
|
||||
client: AtprotoClient,
|
||||
cached: ServerDescription | null,
|
||||
): Promise<ServerDescription> {
|
||||
if (cached) return cached;
|
||||
return client.describeServer();
|
||||
}
|
||||
54
frontend/src/lib/flows/perform-passkey-registration.ts
Normal file
54
frontend/src/lib/flows/perform-passkey-registration.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
type CredentialAttestationJSON,
|
||||
prepareCreationOptions,
|
||||
serializeAttestationResponse,
|
||||
type WebAuthnCreationOptionsResponse,
|
||||
} from "../webauthn.ts";
|
||||
|
||||
export class PasskeyCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Passkey creation was cancelled");
|
||||
this.name = "PasskeyCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPasskeyCredential(
|
||||
startRegistration: () => Promise<{ options: unknown }>,
|
||||
): Promise<CredentialAttestationJSON> {
|
||||
if (!globalThis.PublicKeyCredential) {
|
||||
throw new Error("Passkeys are not supported in this browser");
|
||||
}
|
||||
|
||||
const { options } = await startRegistration();
|
||||
|
||||
const publicKeyOptions = prepareCreationOptions(
|
||||
options as unknown as WebAuthnCreationOptionsResponse,
|
||||
);
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyOptions,
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new PasskeyCancelledError();
|
||||
}
|
||||
|
||||
return serializeAttestationResponse(credential as PublicKeyCredential);
|
||||
}
|
||||
|
||||
export interface PasskeyRegistrationApi {
|
||||
startRegistration(): Promise<{ options: unknown }>;
|
||||
completeSetup(
|
||||
credential: CredentialAttestationJSON,
|
||||
name?: string,
|
||||
): Promise<{ appPassword: string; appPasswordName: string }>;
|
||||
}
|
||||
|
||||
export async function performPasskeyRegistration(
|
||||
passkeyApi: PasskeyRegistrationApi,
|
||||
friendlyName?: string,
|
||||
): Promise<{ appPassword: string; appPasswordName: string }> {
|
||||
const serialized = await createPasskeyCredential(
|
||||
passkeyApi.startRegistration,
|
||||
);
|
||||
return passkeyApi.completeSetup(serialized, friendlyName);
|
||||
}
|
||||
@@ -603,6 +603,20 @@ export class AtprotoClient {
|
||||
return result.verified;
|
||||
}
|
||||
|
||||
async checkChannelVerified(
|
||||
did: string,
|
||||
channel: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.xrpc<{ verified: boolean }>(
|
||||
"_checkChannelVerified",
|
||||
{
|
||||
httpMethod: "POST",
|
||||
body: { did, channel },
|
||||
},
|
||||
);
|
||||
return result.verified;
|
||||
}
|
||||
|
||||
async verifyToken(
|
||||
token: string,
|
||||
identifier: string,
|
||||
@@ -625,9 +639,13 @@ export class AtprotoClient {
|
||||
});
|
||||
}
|
||||
|
||||
async resendMigrationVerification(): Promise<void> {
|
||||
async resendMigrationVerification(
|
||||
channel: string,
|
||||
identifier: string,
|
||||
): Promise<void> {
|
||||
await this.xrpc("com.atproto.server.resendMigrationVerification", {
|
||||
httpMethod: "POST",
|
||||
body: { channel, identifier },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -731,23 +749,7 @@ export async function getOAuthServerMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePKCE(): Promise<{
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
}> {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
const codeVerifier = base64UrlEncode(array);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(codeVerifier);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
const codeChallenge = base64UrlEncode(new Uint8Array(digest));
|
||||
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
|
||||
function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
|
||||
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
|
||||
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
|
||||
"",
|
||||
@@ -758,34 +760,6 @@ export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function base64UrlDecode(base64url: string): Uint8Array {
|
||||
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function prepareWebAuthnCreationOptions(
|
||||
options: { publicKey: Record<string, unknown> },
|
||||
): PublicKeyCredentialCreationOptions {
|
||||
const pk = options.publicKey;
|
||||
return {
|
||||
...pk,
|
||||
challenge: base64UrlDecode(pk.challenge as string),
|
||||
user: {
|
||||
...(pk.user as Record<string, unknown>),
|
||||
id: base64UrlDecode((pk.user as Record<string, unknown>).id as string),
|
||||
},
|
||||
excludeCredentials:
|
||||
((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map(
|
||||
(cred) => ({
|
||||
...cred,
|
||||
id: base64UrlDecode(cred.id as string),
|
||||
}),
|
||||
),
|
||||
} as unknown as PublicKeyCredentialCreationOptions;
|
||||
}
|
||||
|
||||
async function computeAccessTokenHash(accessToken: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(accessToken);
|
||||
@@ -793,12 +767,6 @@ async function computeAccessTokenHash(accessToken: string): Promise<string> {
|
||||
return base64UrlEncode(new Uint8Array(hash));
|
||||
}
|
||||
|
||||
export function generateOAuthState(): string {
|
||||
const array = new Uint8Array(16);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array);
|
||||
}
|
||||
|
||||
export function buildOAuthAuthorizationUrl(
|
||||
metadata: OAuthServerMetadata,
|
||||
params: {
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
createLocalClient,
|
||||
exchangeOAuthCode,
|
||||
generateDPoPKeyPair,
|
||||
generateOAuthState,
|
||||
generatePKCE,
|
||||
getMigrationOAuthClientId,
|
||||
getMigrationOAuthRedirectUri,
|
||||
getOAuthServerMetadata,
|
||||
@@ -22,6 +20,11 @@ import {
|
||||
resolvePdsUrl,
|
||||
saveDPoPKey,
|
||||
} from "./atproto-client.ts";
|
||||
import {
|
||||
generateCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
} from "../oauth.ts";
|
||||
import {
|
||||
clearMigrationState,
|
||||
saveMigrationState,
|
||||
@@ -40,20 +43,13 @@ function migrationLog(stage: string, data?: Record<string, unknown>) {
|
||||
}
|
||||
}
|
||||
|
||||
function createInitialProgress(): MigrationProgress {
|
||||
return {
|
||||
repoExported: false,
|
||||
repoImported: false,
|
||||
blobsTotal: 0,
|
||||
blobsMigrated: 0,
|
||||
blobsFailed: [],
|
||||
prefsMigrated: false,
|
||||
plcSigned: false,
|
||||
activated: false,
|
||||
deactivated: false,
|
||||
currentOperation: "",
|
||||
};
|
||||
}
|
||||
import {
|
||||
createInitialProgress,
|
||||
checkHandleAvailabilityViaClient,
|
||||
loadServerInfo,
|
||||
resolveVerificationIdentifier,
|
||||
} from "../flows/migration-shared.ts";
|
||||
import { createEmailVerificationPoller } from "../flows/email-verification.ts";
|
||||
|
||||
export function createInboundMigrationFlow() {
|
||||
let state = $state<InboundMigrationState>({
|
||||
@@ -82,11 +78,16 @@ export function createInboundMigrationFlow() {
|
||||
generatedAppPasswordName: null,
|
||||
handlePreservation: "new",
|
||||
existingHandleVerified: false,
|
||||
verificationChannel: "email",
|
||||
discordUsername: "",
|
||||
telegramUsername: "",
|
||||
signalUsername: "",
|
||||
});
|
||||
|
||||
let sourceClient: AtprotoClient | null = null;
|
||||
let localClient: AtprotoClient | null = null;
|
||||
let localServerInfo: ServerDescription | null = null;
|
||||
let sourcePdsDomains: string[] = [];
|
||||
|
||||
function setStep(step: InboundStep) {
|
||||
state.step = step;
|
||||
@@ -113,10 +114,9 @@ export function createInboundMigrationFlow() {
|
||||
if (!localClient) {
|
||||
localClient = createLocalClient();
|
||||
}
|
||||
if (!localServerInfo) {
|
||||
localServerInfo = await localClient.describeServer();
|
||||
}
|
||||
return localServerInfo;
|
||||
const info = await loadServerInfo(localClient, localServerInfo);
|
||||
localServerInfo = info;
|
||||
return info;
|
||||
}
|
||||
|
||||
async function resolveSourcePds(handle: string): Promise<void> {
|
||||
@@ -147,8 +147,9 @@ export function createInboundMigrationFlow() {
|
||||
);
|
||||
}
|
||||
|
||||
const { codeVerifier, codeChallenge } = await generatePKCE();
|
||||
const oauthState = generateOAuthState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
const oauthState = generateState();
|
||||
|
||||
const dpopKeyPair = await generateDPoPKeyPair();
|
||||
await saveDPoPKey(dpopKeyPair);
|
||||
@@ -314,16 +315,23 @@ export function createInboundMigrationFlow() {
|
||||
saveMigrationState(state);
|
||||
}
|
||||
|
||||
async function loadSourcePdsDomains(): Promise<string[]> {
|
||||
if (sourcePdsDomains.length > 0) return sourcePdsDomains;
|
||||
if (!sourceClient) return [];
|
||||
try {
|
||||
const info = await sourceClient.describeServer();
|
||||
sourcePdsDomains = info.availableUserDomains;
|
||||
} catch {
|
||||
sourcePdsDomains = [];
|
||||
}
|
||||
return sourcePdsDomains;
|
||||
}
|
||||
|
||||
async function checkHandleAvailability(handle: string): Promise<boolean> {
|
||||
if (!localClient) {
|
||||
localClient = createLocalClient();
|
||||
}
|
||||
try {
|
||||
await localClient.resolveHandle(handle);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return checkHandleAvailabilityViaClient(localClient, handle);
|
||||
}
|
||||
|
||||
async function verifyExistingHandle(): Promise<{
|
||||
@@ -401,8 +409,12 @@ export function createInboundMigrationFlow() {
|
||||
const passkeyParams = {
|
||||
did: state.sourceDid,
|
||||
handle: state.targetHandle,
|
||||
email: state.targetEmail,
|
||||
email: state.targetEmail || undefined,
|
||||
inviteCode: state.inviteCode || undefined,
|
||||
verificationChannel: state.verificationChannel,
|
||||
discordUsername: state.discordUsername || undefined,
|
||||
telegramUsername: state.telegramUsername || undefined,
|
||||
signalUsername: state.signalUsername || undefined,
|
||||
};
|
||||
|
||||
migrationLog("startMigration: Creating passkey account on NEW PDS", {
|
||||
@@ -428,9 +440,13 @@ export function createInboundMigrationFlow() {
|
||||
const accountParams = {
|
||||
did: state.sourceDid,
|
||||
handle: state.targetHandle,
|
||||
email: state.targetEmail,
|
||||
email: state.targetEmail || undefined,
|
||||
password: state.targetPassword,
|
||||
inviteCode: state.inviteCode || undefined,
|
||||
verificationChannel: state.verificationChannel,
|
||||
discordUsername: state.discordUsername || undefined,
|
||||
telegramUsername: state.telegramUsername || undefined,
|
||||
signalUsername: state.signalUsername || undefined,
|
||||
};
|
||||
|
||||
migrationLog("startMigration: Creating account on NEW PDS", {
|
||||
@@ -618,30 +634,40 @@ export function createInboundMigrationFlow() {
|
||||
if (!localClient) {
|
||||
localClient = createLocalClient();
|
||||
}
|
||||
await localClient.resendMigrationVerification();
|
||||
await localClient.resendMigrationVerification(
|
||||
state.verificationChannel,
|
||||
resolveVerificationIdentifier(
|
||||
state.verificationChannel,
|
||||
state.targetEmail,
|
||||
state.discordUsername,
|
||||
state.telegramUsername,
|
||||
state.signalUsername,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let checkingEmailVerification = false;
|
||||
|
||||
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
|
||||
if (checkingEmailVerification) return false;
|
||||
if (!localClient) return false;
|
||||
|
||||
checkingEmailVerification = true;
|
||||
try {
|
||||
const verified = await localClient.checkEmailVerified(state.targetEmail);
|
||||
if (!verified) return false;
|
||||
|
||||
const verificationPoller = createEmailVerificationPoller({
|
||||
async checkVerified() {
|
||||
if (!localClient) return false;
|
||||
if (state.verificationChannel === "email") {
|
||||
return localClient.checkEmailVerified(state.targetEmail);
|
||||
}
|
||||
return localClient.checkChannelVerified(
|
||||
state.sourceDid,
|
||||
state.verificationChannel,
|
||||
);
|
||||
},
|
||||
async onVerified() {
|
||||
if (state.authMethod === "passkey") {
|
||||
migrationLog(
|
||||
"checkEmailVerifiedAndProceed: Email verified, proceeding to passkey setup",
|
||||
);
|
||||
setStep("passkey-setup");
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!localClient.getAccessToken()) {
|
||||
await localClient.loginDeactivated(
|
||||
if (!localClient!.getAccessToken()) {
|
||||
await localClient!.loginDeactivated(
|
||||
state.targetEmail,
|
||||
state.targetPassword,
|
||||
);
|
||||
@@ -652,11 +678,11 @@ export function createInboundMigrationFlow() {
|
||||
setError(
|
||||
"Email verified! Please log in to your old account again to complete the migration.",
|
||||
);
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.sourceDid.startsWith("did:web:")) {
|
||||
const credentials = await localClient.getRecommendedDidCredentials();
|
||||
const credentials = await localClient!.getRecommendedDidCredentials();
|
||||
state.targetVerificationMethod =
|
||||
credentials.verificationMethods?.atproto || null;
|
||||
setStep("did-web-update");
|
||||
@@ -664,16 +690,11 @@ export function createInboundMigrationFlow() {
|
||||
await sourceClient.requestPlcOperationSignature();
|
||||
setStep("plc-token");
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
const err = e as Error & { error?: string };
|
||||
if (err.error === "AccountNotVerified") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
checkingEmailVerification = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function checkEmailVerifiedAndProceed(): Promise<boolean> {
|
||||
return verificationPoller.checkAndAdvance();
|
||||
}
|
||||
|
||||
async function submitPlcToken(token: string): Promise<void> {
|
||||
@@ -946,6 +967,10 @@ export function createInboundMigrationFlow() {
|
||||
generatedAppPasswordName: null,
|
||||
handlePreservation: "new",
|
||||
existingHandleVerified: false,
|
||||
verificationChannel: "email",
|
||||
discordUsername: "",
|
||||
telegramUsername: "",
|
||||
signalUsername: "",
|
||||
};
|
||||
sourceClient = null;
|
||||
passkeySetup = null;
|
||||
@@ -1025,6 +1050,7 @@ export function createInboundMigrationFlow() {
|
||||
setStep,
|
||||
setError,
|
||||
loadLocalServerInfo,
|
||||
loadSourcePdsDomains,
|
||||
resolveSourcePds,
|
||||
initiateOAuthLogin,
|
||||
handleOAuthCallback,
|
||||
|
||||
@@ -7,10 +7,9 @@ import type {
|
||||
} from "./types.ts";
|
||||
import {
|
||||
AtprotoClient,
|
||||
base64UrlEncode,
|
||||
createLocalClient,
|
||||
prepareWebAuthnCreationOptions,
|
||||
} from "./atproto-client.ts";
|
||||
import { createPasskeyCredential } from "../flows/perform-passkey-registration.ts";
|
||||
import { api } from "../api.ts";
|
||||
import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops.ts";
|
||||
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts";
|
||||
@@ -124,20 +123,13 @@ export function getOfflineResumeInfo(): {
|
||||
|
||||
export { clearOfflineState };
|
||||
|
||||
function createInitialProgress(): MigrationProgress {
|
||||
return {
|
||||
repoExported: false,
|
||||
repoImported: false,
|
||||
blobsTotal: 0,
|
||||
blobsMigrated: 0,
|
||||
blobsFailed: [],
|
||||
prefsMigrated: false,
|
||||
plcSigned: false,
|
||||
activated: false,
|
||||
deactivated: false,
|
||||
currentOperation: "",
|
||||
};
|
||||
}
|
||||
import {
|
||||
createInitialProgress,
|
||||
checkHandleAvailabilityViaClient,
|
||||
loadServerInfo,
|
||||
resolveVerificationIdentifier,
|
||||
} from "../flows/migration-shared.ts";
|
||||
import { createEmailVerificationPoller } from "../flows/email-verification.ts";
|
||||
|
||||
export type OfflineInboundMigrationFlow = ReturnType<
|
||||
typeof createOfflineInboundMigrationFlow
|
||||
@@ -171,6 +163,10 @@ export function createOfflineInboundMigrationFlow() {
|
||||
plcUpdatedTemporarily: false,
|
||||
handlePreservation: "new",
|
||||
existingHandleVerified: false,
|
||||
verificationChannel: "email",
|
||||
discordUsername: "",
|
||||
telegramUsername: "",
|
||||
signalUsername: "",
|
||||
});
|
||||
|
||||
let localServerInfo: ServerDescription | null = null;
|
||||
@@ -198,21 +194,13 @@ export function createOfflineInboundMigrationFlow() {
|
||||
}
|
||||
|
||||
async function loadLocalServerInfo(): Promise<ServerDescription> {
|
||||
if (!localServerInfo) {
|
||||
const client = createLocalClient();
|
||||
localServerInfo = await client.describeServer();
|
||||
}
|
||||
return localServerInfo;
|
||||
const info = await loadServerInfo(createLocalClient(), localServerInfo);
|
||||
localServerInfo = info;
|
||||
return info;
|
||||
}
|
||||
|
||||
async function checkHandleAvailability(handle: string): Promise<boolean> {
|
||||
const client = createLocalClient();
|
||||
try {
|
||||
await client.resolveHandle(handle);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return checkHandleAvailabilityViaClient(createLocalClient(), handle);
|
||||
}
|
||||
|
||||
async function validateRotationKey(): Promise<boolean> {
|
||||
@@ -235,18 +223,6 @@ export function createOfflineInboundMigrationFlow() {
|
||||
const pdsService = lastOperation.services?.atproto_pds;
|
||||
if (pdsService?.endpoint) {
|
||||
state.oldPdsUrl = pdsService.endpoint;
|
||||
console.log(
|
||||
"[offline-migration] Captured old PDS URL:",
|
||||
state.oldPdsUrl,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[offline-migration] No PDS service endpoint found in PLC document",
|
||||
);
|
||||
console.log(
|
||||
"[offline-migration] PLC services:",
|
||||
JSON.stringify(lastOperation.services),
|
||||
);
|
||||
}
|
||||
|
||||
saveOfflineState(state);
|
||||
@@ -315,9 +291,13 @@ export function createOfflineInboundMigrationFlow() {
|
||||
{
|
||||
did: unsafeAsDid(state.userDid),
|
||||
handle: unsafeAsHandle(fullHandle),
|
||||
email: unsafeAsEmail(state.targetEmail),
|
||||
email: state.targetEmail ? unsafeAsEmail(state.targetEmail) : undefined,
|
||||
password: state.targetPassword,
|
||||
inviteCode: state.inviteCode || undefined,
|
||||
verificationChannel: state.verificationChannel,
|
||||
discordUsername: state.discordUsername || undefined,
|
||||
telegramUsername: state.telegramUsername || undefined,
|
||||
signalUsername: state.signalUsername || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -338,8 +318,12 @@ export function createOfflineInboundMigrationFlow() {
|
||||
const createResult = await api.createPasskeyAccount({
|
||||
did: unsafeAsDid(state.userDid),
|
||||
handle: unsafeAsHandle(fullHandle),
|
||||
email: unsafeAsEmail(state.targetEmail),
|
||||
email: state.targetEmail ? unsafeAsEmail(state.targetEmail) : undefined,
|
||||
inviteCode: state.inviteCode || undefined,
|
||||
verificationChannel: state.verificationChannel,
|
||||
discordUsername: state.discordUsername || undefined,
|
||||
telegramUsername: state.telegramUsername || undefined,
|
||||
signalUsername: state.signalUsername || undefined,
|
||||
}, serviceAuthToken);
|
||||
|
||||
state.targetHandle = fullHandle;
|
||||
@@ -487,20 +471,32 @@ export function createOfflineInboundMigrationFlow() {
|
||||
}
|
||||
|
||||
async function resendEmailVerification(): Promise<void> {
|
||||
await api.resendMigrationVerification(unsafeAsEmail(state.targetEmail));
|
||||
await api.resendMigrationVerification(
|
||||
state.verificationChannel,
|
||||
resolveVerificationIdentifier(
|
||||
state.verificationChannel,
|
||||
state.targetEmail,
|
||||
state.discordUsername,
|
||||
state.telegramUsername,
|
||||
state.signalUsername,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let checkingEmailVerification = false;
|
||||
|
||||
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
|
||||
if (checkingEmailVerification) return false;
|
||||
if (state.authMethod === "passkey") return false;
|
||||
|
||||
checkingEmailVerification = true;
|
||||
try {
|
||||
const { verified } = await api.checkEmailVerified(state.targetEmail);
|
||||
if (!verified) return false;
|
||||
|
||||
const verificationPoller = createEmailVerificationPoller({
|
||||
async checkVerified() {
|
||||
if (state.authMethod === "passkey") return false;
|
||||
if (state.verificationChannel === "email") {
|
||||
const { verified } = await api.checkEmailVerified(state.targetEmail);
|
||||
return verified;
|
||||
}
|
||||
const { verified } = await api.checkChannelVerified(
|
||||
state.userDid,
|
||||
state.verificationChannel,
|
||||
);
|
||||
return verified;
|
||||
},
|
||||
async onVerified() {
|
||||
if (!state.localAccessToken) {
|
||||
const session = await api.createSession(
|
||||
state.targetEmail,
|
||||
@@ -519,12 +515,11 @@ export function createOfflineInboundMigrationFlow() {
|
||||
|
||||
cleanup();
|
||||
setStep("success");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
checkingEmailVerification = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function checkEmailVerifiedAndProceed(): Promise<boolean> {
|
||||
return verificationPoller.checkAndAdvance();
|
||||
}
|
||||
|
||||
async function startPasskeyRegistration(): Promise<{ options: unknown }> {
|
||||
@@ -543,41 +538,14 @@ export function createOfflineInboundMigrationFlow() {
|
||||
throw new Error("No passkey setup token");
|
||||
}
|
||||
|
||||
if (!globalThis.PublicKeyCredential) {
|
||||
throw new Error("Passkeys are not supported in this browser");
|
||||
}
|
||||
|
||||
const { options } = await startPasskeyRegistration();
|
||||
|
||||
const publicKeyOptions = prepareWebAuthnCreationOptions(
|
||||
options as { publicKey: Record<string, unknown> },
|
||||
const credential = await createPasskeyCredential(
|
||||
() => startPasskeyRegistration(),
|
||||
);
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyOptions,
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Passkey creation was cancelled");
|
||||
}
|
||||
|
||||
const publicKeyCredential = credential as PublicKeyCredential;
|
||||
const response = publicKeyCredential
|
||||
.response as AuthenticatorAttestationResponse;
|
||||
|
||||
const credentialData = {
|
||||
id: publicKeyCredential.id,
|
||||
rawId: base64UrlEncode(publicKeyCredential.rawId),
|
||||
type: publicKeyCredential.type,
|
||||
response: {
|
||||
clientDataJSON: base64UrlEncode(response.clientDataJSON),
|
||||
attestationObject: base64UrlEncode(response.attestationObject),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await api.completePasskeySetup(
|
||||
unsafeAsDid(state.userDid),
|
||||
state.passkeySetupToken,
|
||||
credentialData,
|
||||
credential,
|
||||
passkeyName,
|
||||
);
|
||||
|
||||
@@ -675,6 +643,10 @@ export function createOfflineInboundMigrationFlow() {
|
||||
plcUpdatedTemporarily: false,
|
||||
handlePreservation: "new",
|
||||
existingHandleVerified: false,
|
||||
verificationChannel: "email",
|
||||
discordUsername: "",
|
||||
telegramUsername: "",
|
||||
signalUsername: "",
|
||||
};
|
||||
localServerInfo = null;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface MigrationProgress {
|
||||
|
||||
export type HandlePreservation = "new" | "existing";
|
||||
|
||||
export type VerificationChannel = "email" | "discord" | "telegram" | "signal";
|
||||
|
||||
export interface InboundMigrationState {
|
||||
direction: "inbound";
|
||||
step: InboundStep;
|
||||
@@ -78,6 +80,10 @@ export interface InboundMigrationState {
|
||||
resumeToStep?: InboundStep;
|
||||
handlePreservation: HandlePreservation;
|
||||
existingHandleVerified: boolean;
|
||||
verificationChannel: VerificationChannel;
|
||||
discordUsername: string;
|
||||
telegramUsername: string;
|
||||
signalUsername: string;
|
||||
}
|
||||
|
||||
export interface OfflineInboundMigrationState {
|
||||
@@ -107,6 +113,10 @@ export interface OfflineInboundMigrationState {
|
||||
plcUpdatedTemporarily: boolean;
|
||||
handlePreservation: HandlePreservation;
|
||||
existingHandleVerified: boolean;
|
||||
verificationChannel: VerificationChannel;
|
||||
discordUsername: string;
|
||||
telegramUsername: string;
|
||||
signalUsername: string;
|
||||
}
|
||||
|
||||
export type MigrationState = InboundMigrationState;
|
||||
@@ -142,6 +152,7 @@ export interface ServerDescription {
|
||||
availableUserDomains: string[];
|
||||
inviteCodeRequired: boolean;
|
||||
phoneVerificationRequired?: boolean;
|
||||
availableCommsChannels?: VerificationChannel[];
|
||||
links?: {
|
||||
privacyPolicy?: string;
|
||||
termsOfService?: string;
|
||||
@@ -226,17 +237,25 @@ export interface BlobRef {
|
||||
export interface CreateAccountParams {
|
||||
did?: string;
|
||||
handle: string;
|
||||
email: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
inviteCode?: string;
|
||||
recoveryKey?: string;
|
||||
verificationChannel?: VerificationChannel;
|
||||
discordUsername?: string;
|
||||
telegramUsername?: string;
|
||||
signalUsername?: string;
|
||||
}
|
||||
|
||||
export interface CreatePasskeyAccountParams {
|
||||
did?: string;
|
||||
handle: string;
|
||||
email: string;
|
||||
email?: string;
|
||||
inviteCode?: string;
|
||||
verificationChannel?: VerificationChannel;
|
||||
discordUsername?: string;
|
||||
telegramUsername?: string;
|
||||
signalUsername?: string;
|
||||
}
|
||||
|
||||
export interface PasskeyAccountSetup {
|
||||
|
||||
11
frontend/src/lib/portal.ts
Normal file
11
frontend/src/lib/portal.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function portal(node: HTMLElement): { destroy: () => void } {
|
||||
const target = document.body;
|
||||
target.appendChild(node);
|
||||
return {
|
||||
destroy() {
|
||||
if (node.parentNode === target) {
|
||||
target.removeChild(node);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { RegistrationFlow } from './flow.svelte'
|
||||
|
||||
interface Props {
|
||||
flow: RegistrationFlow
|
||||
}
|
||||
|
||||
let { flow }: Props = $props()
|
||||
|
||||
let copied = $state(false)
|
||||
let acknowledged = $state(false)
|
||||
|
||||
function copyToClipboard() {
|
||||
if (flow.account?.appPassword) {
|
||||
navigator.clipboard.writeText(flow.account.appPassword)
|
||||
copied = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app-password-step">
|
||||
<div class="warning-box">
|
||||
<strong>Important: Save this app password!</strong>
|
||||
<p>
|
||||
This app password is required to sign into apps that don't support passkeys yet (like bsky.app).
|
||||
You will only see this password once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="app-password-display">
|
||||
<div class="app-password-label">
|
||||
App Password for: <strong>{flow.account?.appPasswordName}</strong>
|
||||
</div>
|
||||
<code class="app-password-code">{flow.account?.appPassword}</code>
|
||||
<button type="button" class="copy-btn" onclick={copyToClipboard}>
|
||||
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={acknowledged} />
|
||||
<span>I have saved my app password in a secure location</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button onclick={() => flow.proceedFromAppPassword()} disabled={!acknowledged}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api, ApiError } from "../api.ts";
|
||||
import { createEmailVerificationPoller } from "../flows/email-verification.ts";
|
||||
import { setSession } from "../auth.svelte.ts";
|
||||
import {
|
||||
createServiceJwt,
|
||||
@@ -223,43 +224,51 @@ export function createRegistrationFlow(
|
||||
state.step = "creating";
|
||||
}
|
||||
|
||||
async function generateByodToken(): Promise<string | undefined> {
|
||||
if (
|
||||
state.info.didType !== "web-external" ||
|
||||
state.externalDidWeb.keyMode !== "byod" ||
|
||||
!state.externalDidWeb.byodPrivateKey
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return createServiceJwt(
|
||||
state.externalDidWeb.byodPrivateKey,
|
||||
state.info.externalDid!.trim(),
|
||||
getPdsDid(),
|
||||
"com.atproto.server.createAccount",
|
||||
);
|
||||
}
|
||||
|
||||
function commonAccountParams() {
|
||||
return {
|
||||
didType: state.info.didType,
|
||||
did: state.info.didType === "web-external"
|
||||
? unsafeAsDid(state.info.externalDid!.trim())
|
||||
: undefined,
|
||||
signingKey: state.info.didType === "web-external" &&
|
||||
state.externalDidWeb.keyMode === "reserved"
|
||||
? state.externalDidWeb.reservedSigningKey
|
||||
: undefined,
|
||||
inviteCode: state.info.inviteCode?.trim() || undefined,
|
||||
verificationChannel: state.info.verificationChannel,
|
||||
discordUsername: state.info.discordUsername?.trim() || undefined,
|
||||
telegramUsername: state.info.telegramUsername?.trim() || undefined,
|
||||
signalUsername: state.info.signalUsername?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function createPasswordAccount() {
|
||||
state.submitting = true;
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
let byodToken: string | undefined;
|
||||
|
||||
if (
|
||||
state.info.didType === "web-external" &&
|
||||
state.externalDidWeb.keyMode === "byod" &&
|
||||
state.externalDidWeb.byodPrivateKey
|
||||
) {
|
||||
byodToken = await createServiceJwt(
|
||||
state.externalDidWeb.byodPrivateKey,
|
||||
state.info.externalDid!.trim(),
|
||||
getPdsDid(),
|
||||
"com.atproto.server.createAccount",
|
||||
);
|
||||
}
|
||||
|
||||
const byodToken = await generateByodToken();
|
||||
const result = await api.createAccount({
|
||||
handle: getFullHandle(),
|
||||
email: state.info.email.trim(),
|
||||
password: state.info.password!,
|
||||
inviteCode: state.info.inviteCode?.trim() || undefined,
|
||||
didType: state.info.didType,
|
||||
did: state.info.didType === "web-external"
|
||||
? state.info.externalDid!.trim()
|
||||
: undefined,
|
||||
signingKey: state.info.didType === "web-external" &&
|
||||
state.externalDidWeb.keyMode === "reserved"
|
||||
? state.externalDidWeb.reservedSigningKey
|
||||
: undefined,
|
||||
verificationChannel: state.info.verificationChannel,
|
||||
discordUsername: state.info.discordUsername?.trim() || undefined,
|
||||
telegramUsername: state.info.telegramUsername?.trim() || undefined,
|
||||
signalUsername: state.info.signalUsername?.trim() || undefined,
|
||||
...commonAccountParams(),
|
||||
}, byodToken);
|
||||
|
||||
state.account = {
|
||||
@@ -280,39 +289,13 @@ export function createRegistrationFlow(
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
let byodToken: string | undefined;
|
||||
|
||||
if (
|
||||
state.info.didType === "web-external" &&
|
||||
state.externalDidWeb.keyMode === "byod" &&
|
||||
state.externalDidWeb.byodPrivateKey
|
||||
) {
|
||||
byodToken = await createServiceJwt(
|
||||
state.externalDidWeb.byodPrivateKey,
|
||||
state.info.externalDid!.trim(),
|
||||
getPdsDid(),
|
||||
"com.atproto.server.createAccount",
|
||||
);
|
||||
}
|
||||
|
||||
const byodToken = await generateByodToken();
|
||||
const result = await api.createPasskeyAccount({
|
||||
handle: unsafeAsHandle(getFullHandle()),
|
||||
email: state.info.email?.trim()
|
||||
? unsafeAsEmail(state.info.email.trim())
|
||||
: undefined,
|
||||
inviteCode: state.info.inviteCode?.trim() || undefined,
|
||||
didType: state.info.didType,
|
||||
did: state.info.didType === "web-external"
|
||||
? unsafeAsDid(state.info.externalDid!.trim())
|
||||
: undefined,
|
||||
signingKey: state.info.didType === "web-external" &&
|
||||
state.externalDidWeb.keyMode === "reserved"
|
||||
? state.externalDidWeb.reservedSigningKey
|
||||
: undefined,
|
||||
verificationChannel: state.info.verificationChannel,
|
||||
discordUsername: state.info.discordUsername?.trim() || undefined,
|
||||
telegramUsername: state.info.telegramUsername?.trim() || undefined,
|
||||
signalUsername: state.info.signalUsername?.trim() || undefined,
|
||||
...commonAccountParams(),
|
||||
}, byodToken);
|
||||
|
||||
state.account = {
|
||||
@@ -343,6 +326,50 @@ export function createRegistrationFlow(
|
||||
persistState();
|
||||
}
|
||||
|
||||
function getAccountPassword(): string {
|
||||
return state.mode === "passkey"
|
||||
? state.account!.appPassword!
|
||||
: state.info.password!;
|
||||
}
|
||||
|
||||
async function handlePostVerification(
|
||||
session: SessionState,
|
||||
): Promise<void> {
|
||||
state.session = session;
|
||||
|
||||
if (
|
||||
state.info.didType === "web-external" &&
|
||||
state.externalDidWeb.keyMode === "byod"
|
||||
) {
|
||||
const credentials = await api.getRecommendedDidCredentials(
|
||||
session.accessJwt,
|
||||
);
|
||||
const newPublicKeyMultibase =
|
||||
credentials.verificationMethods?.atproto?.replace("did:key:", "") || "";
|
||||
|
||||
const didDoc = generateDidDocument(
|
||||
state.info.externalDid!.trim(),
|
||||
newPublicKeyMultibase,
|
||||
state.account!.handle,
|
||||
getPdsEndpoint(),
|
||||
);
|
||||
state.externalDidWeb.updatedDidDocument = JSON.stringify(
|
||||
didDoc,
|
||||
null,
|
||||
"\t",
|
||||
);
|
||||
state.step = "updated-did-doc";
|
||||
persistState();
|
||||
} else if (state.info.didType === "web-external") {
|
||||
await api.activateAccount(session.accessJwt);
|
||||
await finalizeSession();
|
||||
state.step = "redirect-to-dashboard";
|
||||
} else {
|
||||
await finalizeSession();
|
||||
state.step = "redirect-to-dashboard";
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAccount(code: string) {
|
||||
state.submitting = true;
|
||||
state.error = null;
|
||||
@@ -354,48 +381,13 @@ export function createRegistrationFlow(
|
||||
);
|
||||
|
||||
if (state.info.didType === "web-external") {
|
||||
const password = state.mode === "passkey"
|
||||
? state.account!.appPassword!
|
||||
: state.info.password!;
|
||||
const session = await api.createSession(state.account!.did, password);
|
||||
state.session = {
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
};
|
||||
|
||||
if (state.externalDidWeb.keyMode === "byod") {
|
||||
const credentials = await api.getRecommendedDidCredentials(
|
||||
session.accessJwt,
|
||||
);
|
||||
const newPublicKeyMultibase =
|
||||
credentials.verificationMethods?.atproto?.replace("did:key:", "") ||
|
||||
"";
|
||||
|
||||
const didDoc = generateDidDocument(
|
||||
state.info.externalDid!.trim(),
|
||||
newPublicKeyMultibase,
|
||||
state.account!.handle,
|
||||
getPdsEndpoint(),
|
||||
);
|
||||
state.externalDidWeb.updatedDidDocument = JSON.stringify(
|
||||
didDoc,
|
||||
null,
|
||||
"\t",
|
||||
);
|
||||
state.step = "updated-did-doc";
|
||||
persistState();
|
||||
} else {
|
||||
await api.activateAccount(session.accessJwt);
|
||||
await finalizeSession();
|
||||
state.step = "redirect-to-dashboard";
|
||||
}
|
||||
const session = await api.createSession(
|
||||
state.account!.did,
|
||||
getAccountPassword(),
|
||||
);
|
||||
await handlePostVerification(session);
|
||||
} else {
|
||||
state.session = {
|
||||
accessJwt: confirmResult.accessJwt,
|
||||
refreshJwt: confirmResult.refreshJwt,
|
||||
};
|
||||
await finalizeSession();
|
||||
state.step = "redirect-to-dashboard";
|
||||
await handlePostVerification(confirmResult);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
@@ -419,74 +411,26 @@ export function createRegistrationFlow(
|
||||
}
|
||||
}
|
||||
|
||||
let checkingVerification = false;
|
||||
|
||||
async function checkAndAdvanceIfVerified(): Promise<boolean> {
|
||||
if (checkingVerification || !state.account) return false;
|
||||
|
||||
checkingVerification = true;
|
||||
try {
|
||||
const verificationPoller = createEmailVerificationPoller({
|
||||
async checkVerified() {
|
||||
if (!state.account) return false;
|
||||
const result = await api.checkChannelVerified(
|
||||
state.account.did,
|
||||
state.info.verificationChannel,
|
||||
);
|
||||
if (!result.verified) return false;
|
||||
return result.verified;
|
||||
},
|
||||
async onVerified() {
|
||||
const session = await api.createSession(
|
||||
state.account!.did,
|
||||
getAccountPassword(),
|
||||
);
|
||||
await handlePostVerification(session);
|
||||
},
|
||||
});
|
||||
|
||||
if (state.info.didType === "web-external") {
|
||||
const password = state.mode === "passkey"
|
||||
? state.account.appPassword!
|
||||
: state.info.password!;
|
||||
const session = await api.createSession(state.account.did, password);
|
||||
state.session = {
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
};
|
||||
|
||||
if (state.externalDidWeb.keyMode === "byod") {
|
||||
const credentials = await api.getRecommendedDidCredentials(
|
||||
session.accessJwt,
|
||||
);
|
||||
const newPublicKeyMultibase =
|
||||
credentials.verificationMethods?.atproto?.replace("did:key:", "") ||
|
||||
"";
|
||||
|
||||
const didDoc = generateDidDocument(
|
||||
state.info.externalDid!.trim(),
|
||||
newPublicKeyMultibase,
|
||||
state.account.handle,
|
||||
getPdsEndpoint(),
|
||||
);
|
||||
state.externalDidWeb.updatedDidDocument = JSON.stringify(
|
||||
didDoc,
|
||||
null,
|
||||
"\t",
|
||||
);
|
||||
state.step = "updated-did-doc";
|
||||
persistState();
|
||||
} else {
|
||||
await api.activateAccount(session.accessJwt);
|
||||
await finalizeSession();
|
||||
state.step = "redirect-to-dashboard";
|
||||
}
|
||||
} else {
|
||||
const password = state.mode === "passkey"
|
||||
? state.account.appPassword!
|
||||
: state.info.password!;
|
||||
const session = await api.createSession(state.account.did, password);
|
||||
state.session = {
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
};
|
||||
await finalizeSession();
|
||||
state.step = "redirect-to-dashboard";
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
checkingVerification = false;
|
||||
}
|
||||
function checkAndAdvanceIfVerified(): Promise<boolean> {
|
||||
return verificationPoller.checkAndAdvance();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
|
||||
@@ -3,4 +3,4 @@ export * from "./flow.svelte.ts";
|
||||
export { default as VerificationStep } from "./VerificationStep.svelte";
|
||||
export { default as KeyChoiceStep } from "./KeyChoiceStep.svelte";
|
||||
export { default as DidDocStep } from "./DidDocStep.svelte";
|
||||
export { default as AppPasswordStep } from "./AppPasswordStep.svelte";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user