refactor(frontend): refactor migration components

This commit is contained in:
Lewis
2026-03-19 14:26:26 +00:00
committed by Tangled
parent cf77354cde
commit f3f55e239f
8 changed files with 434 additions and 483 deletions
@@ -1,34 +1,44 @@
<script lang="ts">
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
import type { AuthMethod, HandlePreservation, ServerDescription, VerificationChannel } from '../../lib/migration/types'
import type { VerificationChannel as ApiVerificationChannel } from '../../lib/types/api'
import { _ } from '../../lib/i18n'
import HandleInput from '../HandleInput.svelte'
import CommsChannelPicker from '../CommsChannelPicker.svelte'
interface Props {
handleInput: string
selectedDomain: string
handleAvailable: boolean | null
checkingHandle: boolean
email: string
password: string
authMethod: AuthMethod
inviteCode: string
serverInfo: ServerDescription | null
availableCommsChannels: ApiVerificationChannel[]
verificationChannel: VerificationChannel
discordUsername: string
telegramUsername: string
signalUsername: string
migratingFromLabel: string
migratingFromValue: string
loading?: boolean
sourceHandle: string
sourceDid: string
sourcePdsDomains?: string[]
handlePreservation: HandlePreservation
existingHandleVerified: boolean
verifyingExistingHandle?: boolean
existingHandleError?: string | null
checkAvailability: (fullHandle: string) => Promise<boolean>
onHandleChange: (handle: string) => void
onDomainChange: (domain: string) => void
onCheckHandle: () => void
onEmailChange: (email: string) => void
onPasswordChange: (password: string) => void
onAuthMethodChange: (method: AuthMethod) => void
onInviteCodeChange: (code: string) => void
onVerificationChannelChange: (channel: VerificationChannel) => void
onDiscordChange: (value: string) => void
onTelegramChange: (value: string) => void
onSignalChange: (value: string) => void
onHandlePreservationChange?: (preservation: HandlePreservation) => void
onVerifyExistingHandle?: () => void
onBack: () => void
@@ -38,45 +48,70 @@
let {
handleInput,
selectedDomain,
handleAvailable,
checkingHandle,
email,
password,
authMethod,
inviteCode,
serverInfo,
availableCommsChannels,
verificationChannel,
discordUsername,
telegramUsername,
signalUsername,
migratingFromLabel,
migratingFromValue,
loading = false,
sourceHandle,
sourceDid,
sourcePdsDomains = [],
handlePreservation,
existingHandleVerified,
verifyingExistingHandle = false,
existingHandleError = null,
checkAvailability,
onHandleChange,
onDomainChange,
onCheckHandle,
onEmailChange,
onPasswordChange,
onAuthMethodChange,
onInviteCodeChange,
onVerificationChannelChange,
onDiscordChange,
onTelegramChange,
onSignalChange,
onHandlePreservationChange,
onVerifyExistingHandle,
onBack,
onContinue,
}: Props = $props()
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
const handleTooShort = $derived(handleInput.trim().length > 0 && handleInput.trim().length < 3)
const isExternalHandle = $derived(
serverInfo != null &&
const isSourcePdsManaged = $derived(
sourcePdsDomains.length > 0 &&
sourceHandle.includes('.') &&
sourcePdsDomains.some(d => sourceHandle.endsWith(`.${d}`))
)
const isExternalHandle = $derived(
sourceHandle.includes('.') &&
!isSourcePdsManaged &&
serverInfo != null &&
!serverInfo.availableUserDomains.some(d => sourceHandle.endsWith(`.${d}`))
)
const hasVerificationIdentifier = $derived(
(verificationChannel === 'email' && email.trim().length > 0) ||
(verificationChannel === 'discord' && discordUsername.trim().length > 0) ||
(verificationChannel === 'telegram' && telegramUsername.trim().length > 0) ||
(verificationChannel === 'signal' && signalUsername.trim().length > 0)
)
const canContinue = $derived(
email &&
hasVerificationIdentifier &&
(authMethod === 'passkey' || password) &&
(
(handlePreservation === 'existing' && existingHandleVerified) ||
@@ -178,6 +213,9 @@
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder="username"
{checkAvailability}
bind:available={handleAvailable}
bind:checking={checkingHandle}
onInput={onHandleChange}
onDomainChange={onDomainChange}
/>
@@ -196,17 +234,20 @@
</div>
{/if}
<div class="field">
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
<input
id="email"
type="email"
placeholder="you@example.com"
value={email}
oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)}
required
/>
</div>
<CommsChannelPicker
channel={verificationChannel}
{email}
{discordUsername}
{telegramUsername}
{signalUsername}
availableChannels={availableCommsChannels}
disabled={loading}
onChannelChange={onVerificationChannelChange}
onEmailChange={onEmailChange}
onDiscordChange={onDiscordChange}
onTelegramChange={onTelegramChange}
onSignalChange={onSignalChange}
/>
<div class="field">
<span class="field-label">{$_('migration.inbound.chooseHandle.authMethod')}</span>
@@ -1,64 +1,124 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import type { VerificationChannel } from '../../lib/migration/types'
import { api } from '../../lib/api'
import { _ } from '../../lib/i18n'
interface Props {
email: string
channel: VerificationChannel
identifier: string
token: string
loading: boolean
error: string | null
handle?: string
onTokenChange: (token: string) => void
onSubmit: (e: Event) => void
onResend: () => void
onVerified?: () => void
}
let {
email,
channel,
identifier,
token,
loading,
error,
handle,
onTokenChange,
onSubmit,
onResend,
onVerified,
}: Props = $props()
let telegramBotUsername = $state<string | undefined>(undefined)
let discordBotUsername = $state<string | undefined>(undefined)
let discordAppId = $state<string | undefined>(undefined)
const isTelegram = $derived(channel === 'telegram')
const isDiscord = $derived(channel === 'discord')
const isBotChannel = $derived(isTelegram || isDiscord)
onMount(async () => {
if (isBotChannel) {
try {
const serverInfo = await api.describeServer()
telegramBotUsername = serverInfo.telegramBotUsername
discordBotUsername = serverInfo.discordBotUsername
discordAppId = serverInfo.discordAppId
} catch {}
}
})
function channelLabel(ch: string): string {
switch (ch) {
case 'email': return 'email'
case 'discord': return 'Discord'
case 'telegram': return 'Telegram'
case 'signal': return 'Signal'
default: return ch
}
}
</script>
<div class="step-content">
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${email}</strong>` } })}</p>
<div class="info-box">
<p>
{$_('migration.inbound.emailVerify.hint')}
</p>
</div>
{#if error}
<div class="message error">
{error}
{#if isTelegram && telegramBotUsername && handle}
{@const encodedHandle = handle.replaceAll('.', '_')}
<p>{$_('migration.inbound.emailVerify.telegramInstructions')}</p>
<div class="info-box">
<p>
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('migration.inbound.emailVerify.openTelegram')}</a>,
or send <code>/start {handle}</code> to <code>@{telegramBotUsername}</code>
</p>
</div>
<p class="hint">{$_('migration.inbound.emailVerify.waitingForVerification')}</p>
{:else if isDiscord && discordAppId && handle}
<p>{$_('migration.inbound.emailVerify.discordInstructions')}</p>
<div class="info-box">
<p>
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">{$_('migration.inbound.emailVerify.openDiscord')}</a>,
or send <code>/start {handle}</code> to <strong>{discordBotUsername ?? 'the bot'}</strong>
</p>
</div>
<p class="hint">{$_('migration.inbound.emailVerify.waitingForVerification')}</p>
{:else}
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${identifier}</strong>`, channel: channelLabel(channel) } })}</p>
<div class="info-box">
<p>
{$_('migration.inbound.emailVerify.hint')}
</p>
</div>
{#if error}
<div class="message error">
{error}
</div>
{/if}
<form onsubmit={onSubmit}>
<div>
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
<input
id="email-verify-token"
type="text"
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
value={token}
oninput={(e) => onTokenChange((e.target as HTMLInputElement).value)}
disabled={loading}
required
/>
</div>
<div class="button-row">
<button type="button" class="ghost" onclick={onResend} disabled={loading}>
{$_('migration.inbound.emailVerify.resend')}
</button>
<button type="submit" disabled={loading || !token}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
</div>
</form>
{/if}
<form onsubmit={onSubmit}>
<div>
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
<input
id="email-verify-token"
type="text"
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
value={token}
oninput={(e) => onTokenChange((e.target as HTMLInputElement).value)}
disabled={loading}
required
/>
</div>
<div class="button-row">
<button type="button" class="ghost" onclick={onResend} disabled={loading}>
{$_('migration.inbound.emailVerify.resend')}
</button>
<button type="submit" disabled={loading || !token}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
</div>
</form>
</div>
@@ -1,8 +1,9 @@
<script lang="ts">
import type { InboundMigrationFlow } from '../../lib/migration'
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
import { resolveVerificationIdentifier } from '../../lib/flows/migration-shared'
import { getErrorMessage } from '../../lib/migration/types'
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
import { createPasskeyCredential, PasskeyCancelledError } from '../../lib/flows/perform-passkey-registration'
import { _ } from '../../lib/i18n'
import ErrorStep from './ErrorStep.svelte'
import SuccessStep from './SuccessStep.svelte'
@@ -10,6 +11,9 @@
import EmailVerifyStep from './EmailVerifyStep.svelte'
import PasskeySetupStep from './PasskeySetupStep.svelte'
import AppPasswordStep from './AppPasswordStep.svelte'
import StepIndicator from './StepIndicator.svelte'
import ProgressStep from './ProgressStep.svelte'
import ReviewStep from './ReviewStep.svelte'
interface ResumeInfo {
direction: 'inbound'
@@ -38,26 +42,35 @@
let localPasswordInput = $state('')
let understood = $state(false)
let selectedDomain = $state('')
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
let selectedAuthMethod = $state<AuthMethod>('password')
let passkeyName = $state('')
let verifyingExistingHandle = $state(false)
let existingHandleError = $state<string | null>(null)
let sourcePdsDomains = $state<string[]>([])
const isResuming = $derived(flow.state.needsReauth === true)
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
function verificationIdentifier(): string {
return resolveVerificationIdentifier(
flow.state.verificationChannel,
flow.state.targetEmail,
flow.state.discordUsername,
flow.state.telegramUsername,
flow.state.signalUsername,
)
}
$effect(() => {
if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') {
loadServerInfo()
}
if (flow.state.step === 'choose-handle') {
handleInput = ''
handleAvailable = null
existingHandleError = null
flow.updateField('handlePreservation', 'new')
flow.updateField('existingHandleVerified', false)
flow.loadSourcePdsDomains().then((d) => { sourcePdsDomains = d })
}
if (flow.state.step === 'source-handle' && resumeInfo) {
handleInput = resumeInfo.sourceHandle
@@ -79,8 +92,9 @@
$effect(() => {
if (flow.state.step === 'email-verify') {
const isBotChannel = flow.state.verificationChannel === 'telegram' || flow.state.verificationChannel === 'discord'
const interval = setInterval(async () => {
if (flow.state.emailVerifyToken.trim()) return
if (!isBotChannel && flow.state.emailVerifyToken.trim()) return
await flow.checkEmailVerifiedAndProceed()
}, 3000)
return () => clearInterval(interval)
@@ -97,25 +111,6 @@
}
}
async function checkHandle() {
if (!handleInput.trim()) return
const fullHandle = handleInput.includes('.')
? handleInput
: `${handleInput}.${selectedDomain}`
checkingHandle = true
handleAvailable = null
try {
handleAvailable = await flow.checkHandleAvailability(fullHandle)
} catch {
handleAvailable = true
} finally {
checkingHandle = false
}
}
function handlePreservationChange(preservation: HandlePreservation) {
flow.updateField('handlePreservation', preservation)
existingHandleError = null
@@ -224,43 +219,15 @@
flow.setError(null)
try {
if (!window.PublicKeyCredential) {
throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
}
const { options } = await flow.startPasskeyRegistration()
const publicKeyOptions = prepareWebAuthnCreationOptions(
options as { publicKey: Record<string, unknown> }
const credential = await createPasskeyCredential(
() => flow.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),
},
}
await flow.completePasskeyRegistration(credentialData, passkeyName || undefined)
await flow.completePasskeyRegistration(credential, passkeyName || undefined)
} catch (err) {
const message = getErrorMessage(err)
if (message.includes('cancelled') || message.includes('AbortError')) {
if (err instanceof PasskeyCancelledError || (err instanceof DOMException && err.name === 'NotAllowedError')) {
flow.setError('Passkey registration was cancelled. Please try again.')
} else {
flow.setError(message)
flow.setError(getErrorMessage(err))
}
} finally {
loading = false
@@ -334,19 +301,7 @@
</script>
<div class="migration-wizard">
<div class="step-indicator">
{#each steps as _, i}
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
</div>
{#if i < steps.length - 1}
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
{/if}
{/each}
</div>
<div class="current-step-label">
<strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
</div>
<StepIndicator steps={steps} currentIndex={getCurrentStepIndex()} />
{#if flow.state.error}
<div class="message error">{flow.state.error}</div>
@@ -443,29 +398,37 @@
<ChooseHandleStep
{handleInput}
{selectedDomain}
{handleAvailable}
{checkingHandle}
email={flow.state.targetEmail}
password={flow.state.targetPassword}
authMethod={selectedAuthMethod}
inviteCode={flow.state.inviteCode}
{serverInfo}
availableCommsChannels={serverInfo?.availableCommsChannels ?? ['email']}
verificationChannel={flow.state.verificationChannel}
discordUsername={flow.state.discordUsername}
telegramUsername={flow.state.telegramUsername}
signalUsername={flow.state.signalUsername}
migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')}
migratingFromValue={flow.state.sourceHandle}
{loading}
sourceHandle={flow.state.sourceHandle}
sourceDid={flow.state.sourceDid}
{sourcePdsDomains}
handlePreservation={flow.state.handlePreservation}
existingHandleVerified={flow.state.existingHandleVerified}
{verifyingExistingHandle}
{existingHandleError}
checkAvailability={(h) => flow.checkHandleAvailability(h)}
onHandleChange={(h) => handleInput = h}
onDomainChange={(d) => selectedDomain = d}
onCheckHandle={checkHandle}
onEmailChange={(e) => flow.updateField('targetEmail', e)}
onPasswordChange={(p) => flow.updateField('targetPassword', p)}
onAuthMethodChange={(m) => selectedAuthMethod = m}
onInviteCodeChange={(c) => flow.updateField('inviteCode', c)}
onVerificationChannelChange={(ch) => flow.updateField('verificationChannel', ch)}
onDiscordChange={(v) => flow.updateField('discordUsername', v)}
onTelegramChange={(v) => flow.updateField('telegramUsername', v)}
onSignalChange={(v) => flow.updateField('signalUsername', v)}
onHandlePreservationChange={handlePreservationChange}
onVerifyExistingHandle={verifyExistingHandle}
onBack={() => flow.setStep('source-handle')}
@@ -473,88 +436,39 @@
/>
{:else if flow.state.step === 'review'}
<div class="step-content">
<h2>{$_('migration.inbound.review.title')}</h2>
<p>{$_('migration.inbound.review.desc')}</p>
<div class="review-card">
<div class="review-row">
<span class="label">{$_('migration.inbound.review.currentHandle')}:</span>
<span class="value">{flow.state.sourceHandle}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.newHandle')}:</span>
<span class="value">{flow.state.targetHandle}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.did')}:</span>
<span class="value mono">{flow.state.sourceDid}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.sourcePds')}:</span>
<span class="value">{flow.state.sourcePdsUrl}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.targetPds')}:</span>
<span class="value">{window.location.origin}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.email')}:</span>
<span class="value">{flow.state.targetEmail}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.authentication')}:</span>
<span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
</div>
</div>
<div class="warning-box">
<ReviewStep
description={$_('migration.inbound.review.desc')}
rows={[
{ label: $_('migration.inbound.review.currentHandle'), value: flow.state.sourceHandle },
{ label: $_('migration.inbound.review.newHandle'), value: flow.state.targetHandle },
{ label: $_('migration.inbound.review.did'), value: flow.state.sourceDid, mono: true },
{ label: $_('migration.inbound.review.sourcePds'), value: flow.state.sourcePdsUrl },
{ label: $_('migration.inbound.review.targetPds'), value: window.location.origin },
{ label: $_(`register.${flow.state.verificationChannel}`), value: verificationIdentifier() },
{ label: $_('migration.inbound.review.authentication'), value: flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword') },
]}
{loading}
onBack={() => flow.setStep('choose-handle')}
onContinue={startMigration}
>
{#snippet warning()}
{$_('migration.inbound.review.warning')}
</div>
<div class="button-row">
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
<button onclick={startMigration} disabled={loading}>
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
</button>
</div>
</div>
{/snippet}
</ReviewStep>
{:else if flow.state.step === 'migrating'}
<div class="step-content">
<h2>{$_('migration.inbound.migrating.title')}</h2>
<p>{$_('migration.inbound.migrating.desc')}</p>
<div class="progress-section">
<div class="progress-item" class:completed={flow.state.progress.repoExported}>
<span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
<span>{$_('migration.inbound.migrating.exportRepo')}</span>
</div>
<div class="progress-item" class:completed={flow.state.progress.repoImported}>
<span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
<span>{$_('migration.inbound.migrating.importRepo')}</span>
</div>
<div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
<span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
<span>{$_('migration.inbound.migrating.migrateBlobs')} ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
</div>
<div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
<span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
<span>{$_('migration.inbound.migrating.migratePrefs')}</span>
</div>
</div>
{#if flow.state.progress.blobsTotal > 0}
<div class="progress-bar">
<div
class="progress-fill"
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
></div>
</div>
{/if}
<p class="status-text">{flow.state.progress.currentOperation}</p>
</div>
<ProgressStep
title={$_('migration.inbound.migrating.title')}
description={$_('migration.inbound.migrating.desc')}
items={[
{ label: $_('migration.inbound.migrating.exportRepo'), completed: flow.state.progress.repoExported },
{ label: $_('migration.inbound.migrating.importRepo'), completed: flow.state.progress.repoImported },
{ label: `${$_('migration.inbound.migrating.migrateBlobs')} (${flow.state.progress.blobsMigrated}/${flow.state.progress.blobsTotal})`, completed: flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0, active: flow.state.progress.repoImported && !flow.state.progress.prefsMigrated },
{ label: $_('migration.inbound.migrating.migratePrefs'), completed: flow.state.progress.prefsMigrated },
]}
statusText={flow.state.progress.currentOperation}
progressBar={flow.state.progress.blobsTotal > 0 ? { current: flow.state.progress.blobsMigrated, total: flow.state.progress.blobsTotal } : undefined}
/>
{:else if flow.state.step === 'passkey-setup'}
<PasskeySetupStep
@@ -575,7 +489,9 @@
{:else if flow.state.step === 'email-verify'}
<EmailVerifyStep
email={flow.state.targetEmail}
channel={flow.state.verificationChannel}
identifier={verificationIdentifier()}
handle={flow.state.targetHandle}
token={flow.state.emailVerifyToken}
{loading}
error={flow.state.error}
@@ -675,27 +591,16 @@
</div>
{:else if flow.state.step === 'finalizing'}
<div class="step-content">
<h2>{$_('migration.inbound.finalizing.title')}</h2>
<p>{$_('migration.inbound.finalizing.desc')}</p>
<div class="progress-section">
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
<span>{$_('migration.inbound.finalizing.signingPlc')}</span>
</div>
<div class="progress-item" class:completed={flow.state.progress.activated}>
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
<span>{$_('migration.inbound.finalizing.activating')}</span>
</div>
<div class="progress-item" class:completed={flow.state.progress.deactivated}>
<span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
<span>{$_('migration.inbound.finalizing.deactivating')}</span>
</div>
</div>
<p class="status-text">{flow.state.progress.currentOperation}</p>
</div>
<ProgressStep
title={$_('migration.inbound.finalizing.title')}
description={$_('migration.inbound.finalizing.desc')}
items={[
{ label: $_('migration.inbound.finalizing.signingPlc'), completed: flow.state.progress.plcSigned },
{ label: $_('migration.inbound.finalizing.activating'), completed: flow.state.progress.activated },
{ label: $_('migration.inbound.finalizing.deactivating'), completed: flow.state.progress.deactivated },
]}
statusText={flow.state.progress.currentOperation}
/>
{:else if flow.state.step === 'success'}
<SuccessStep handle={flow.state.targetHandle} did={flow.state.sourceDid}>
@@ -1,8 +1,9 @@
<script lang="ts">
import type { OfflineInboundMigrationFlow } from '../../lib/migration'
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
import { resolveVerificationIdentifier } from '../../lib/flows/migration-shared'
import { getErrorMessage } from '../../lib/migration/types'
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
import { PasskeyCancelledError } from '../../lib/flows/perform-passkey-registration'
import { _ } from '../../lib/i18n'
import ErrorStep from './ErrorStep.svelte'
import SuccessStep from './SuccessStep.svelte'
@@ -10,6 +11,9 @@
import EmailVerifyStep from './EmailVerifyStep.svelte'
import PasskeySetupStep from './PasskeySetupStep.svelte'
import AppPasswordStep from './AppPasswordStep.svelte'
import StepIndicator from './StepIndicator.svelte'
import ProgressStep from './ProgressStep.svelte'
import ReviewStep from './ReviewStep.svelte'
interface Props {
flow: OfflineInboundMigrationFlow
@@ -24,14 +28,22 @@
let understood = $state(false)
let handleInput = $state('')
let selectedDomain = $state('')
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
let validatingKey = $state(false)
let keyValid = $state<boolean | null>(null)
let fileInputRef = $state<HTMLInputElement | null>(null)
let selectedAuthMethod = $state<AuthMethod>('password')
let passkeyName = $state('')
function verificationIdentifier(): string {
return resolveVerificationIdentifier(
flow.state.verificationChannel,
flow.state.targetEmail,
flow.state.discordUsername,
flow.state.telegramUsername,
flow.state.signalUsername,
)
}
let redirectTriggered = $state(false)
$effect(() => {
@@ -40,7 +52,6 @@
}
if (flow.state.step === 'choose-handle') {
handleInput = ''
handleAvailable = null
}
})
@@ -55,8 +66,9 @@
$effect(() => {
if (flow.state.step === 'email-verify') {
const isBotChannel = flow.state.verificationChannel === 'telegram' || flow.state.verificationChannel === 'discord'
const interval = setInterval(async () => {
if (flow.state.emailVerifyToken.trim()) return
if (!isBotChannel && flow.state.emailVerifyToken.trim()) return
await flow.checkEmailVerifiedAndProceed()
}, 3000)
return () => clearInterval(interval)
@@ -145,25 +157,6 @@
}
}
async function checkHandle() {
if (!handleInput.trim()) return
const fullHandle = handleInput.includes('.')
? handleInput
: `${handleInput}.${selectedDomain}`
checkingHandle = true
handleAvailable = null
try {
handleAvailable = await flow.checkHandleAvailability(fullHandle)
} catch {
handleAvailable = true
} finally {
checkingHandle = false
}
}
function proceedToReview() {
const fullHandle = handleInput.includes('.')
? handleInput
@@ -203,17 +196,12 @@
flow.setError(null)
try {
if (!window.PublicKeyCredential) {
throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
}
await flow.registerPasskey(passkeyName || undefined)
} catch (err) {
const message = getErrorMessage(err)
if (message.includes('cancelled') || message.includes('AbortError')) {
if (err instanceof PasskeyCancelledError || (err instanceof DOMException && err.name === 'NotAllowedError')) {
flow.setError('Passkey registration was cancelled. Please try again.')
} else {
flow.setError(message)
flow.setError(getErrorMessage(err))
}
} finally {
loading = false
@@ -233,19 +221,7 @@
</script>
<div class="migration-wizard">
<div class="step-indicator">
{#each steps as _, i}
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
</div>
{#if i < steps.length - 1}
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
{/if}
{/each}
</div>
<div class="current-step-label">
<strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
</div>
<StepIndicator steps={steps} currentIndex={getCurrentStepIndex()} />
{#if flow.state.error}
<div class="message error">{flow.state.error}</div>
@@ -401,13 +377,16 @@
<ChooseHandleStep
{handleInput}
{selectedDomain}
{handleAvailable}
{checkingHandle}
email={flow.state.targetEmail}
password={flow.state.targetPassword}
authMethod={selectedAuthMethod}
inviteCode={flow.state.inviteCode}
{serverInfo}
availableCommsChannels={serverInfo?.availableCommsChannels ?? ['email']}
verificationChannel={flow.state.verificationChannel}
discordUsername={flow.state.discordUsername}
telegramUsername={flow.state.telegramUsername}
signalUsername={flow.state.signalUsername}
migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')}
migratingFromValue={flow.state.userDid}
{loading}
@@ -417,128 +396,78 @@
existingHandleVerified={false}
verifyingExistingHandle={false}
existingHandleError={null}
checkAvailability={(h) => flow.checkHandleAvailability(h)}
onHandleChange={(h) => handleInput = h}
onDomainChange={(d) => selectedDomain = d}
onCheckHandle={checkHandle}
onEmailChange={(e) => flow.setTargetEmail(e)}
onPasswordChange={(p) => flow.setTargetPassword(p)}
onAuthMethodChange={(m) => selectedAuthMethod = m}
onInviteCodeChange={(c) => flow.setInviteCode(c)}
onVerificationChannelChange={(ch) => flow.updateField('verificationChannel', ch)}
onDiscordChange={(v) => flow.updateField('discordUsername', v)}
onTelegramChange={(v) => flow.updateField('telegramUsername', v)}
onSignalChange={(v) => flow.updateField('signalUsername', v)}
onBack={() => flow.setStep('provide-rotation-key')}
onContinue={proceedToReview}
/>
{:else if flow.state.step === 'review'}
<div class="step-content">
<h2>{$_('migration.inbound.review.title')}</h2>
<p>{$_('migration.offline.review.desc')}</p>
<div class="review-card">
<div class="review-row">
<span class="label">{$_('migration.inbound.review.did')}:</span>
<span class="value mono">{flow.state.userDid}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.newHandle')}:</span>
<span class="value">{flow.state.targetHandle}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.offline.review.carFile')}:</span>
<span class="value">{flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.offline.review.rotationKey')}:</span>
<span class="value mono">{flow.state.rotationKeyDidKey}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.targetPds')}:</span>
<span class="value">{window.location.origin}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.email')}:</span>
<span class="value">{flow.state.targetEmail}</span>
</div>
<div class="review-row">
<span class="label">{$_('migration.inbound.review.authentication')}:</span>
<span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
</div>
</div>
<div class="warning-box">
<ReviewStep
description={$_('migration.offline.review.desc')}
rows={[
{ label: $_('migration.inbound.review.did'), value: flow.state.userDid, mono: true },
{ label: $_('migration.inbound.review.newHandle'), value: flow.state.targetHandle },
{ label: $_('migration.offline.review.carFile'), value: `${flow.state.carFileName} (${(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)` },
{ label: $_('migration.offline.review.rotationKey'), value: flow.state.rotationKeyDidKey, mono: true },
{ label: $_('migration.inbound.review.targetPds'), value: window.location.origin },
{ label: $_(`register.${flow.state.verificationChannel}`), value: verificationIdentifier() },
{ label: $_('migration.inbound.review.authentication'), value: flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword') },
]}
{loading}
onBack={() => flow.setStep('choose-handle')}
onContinue={startMigration}
>
{#snippet warning()}
<strong>{$_('migration.offline.review.plcWarningTitle')}</strong>
<p>{$_('migration.offline.review.plcWarning')}</p>
</div>
<div class="button-row">
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
<button onclick={startMigration} disabled={loading}>
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
</button>
</div>
</div>
{/snippet}
</ReviewStep>
{:else if flow.state.step === 'creating' || flow.state.step === 'importing'}
<div class="step-content">
<h2>{$_('migration.offline.migrating.title')}</h2>
<p>{$_('migration.offline.migrating.desc')}</p>
<div class="progress-section">
<div class="progress-item" class:completed={flow.state.step !== 'creating'} class:active={flow.state.step === 'creating'}>
<span class="icon">{flow.state.step !== 'creating' ? '✓' : '○'}</span>
<span>{$_('migration.offline.migrating.creating')}</span>
</div>
<div class="progress-item" class:active={flow.state.step === 'importing'}>
<span class="icon"></span>
<span>{$_('migration.offline.migrating.importing')}</span>
</div>
</div>
<p class="status-text">{flow.state.progress.currentOperation}</p>
</div>
<ProgressStep
title={$_('migration.offline.migrating.title')}
description={$_('migration.offline.migrating.desc')}
items={[
{ label: $_('migration.offline.migrating.creating'), completed: flow.state.step !== 'creating', active: flow.state.step === 'creating' },
{ label: $_('migration.offline.migrating.importing'), completed: false, active: flow.state.step === 'importing' },
]}
statusText={flow.state.progress.currentOperation}
/>
{:else if flow.state.step === 'migrating-blobs'}
<div class="step-content">
<h2>{$_('migration.offline.blobs.title')}</h2>
<p>{$_('migration.offline.blobs.desc')}</p>
<div class="progress-section">
<div class="progress-item completed">
<span class="icon"></span>
<span>{$_('migration.offline.migrating.importing')}</span>
</div>
<div class="progress-item active">
<span class="icon"></span>
<span>{$_('migration.offline.blobs.migrating')}</span>
</div>
</div>
{#if flow.state.progress.blobsTotal > 0}
<div class="blob-progress">
<div class="blob-progress-bar">
<div
class="blob-progress-fill"
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
></div>
</div>
<p class="blob-progress-text">
{flow.state.progress.blobsMigrated} / {flow.state.progress.blobsTotal} blobs
</p>
</div>
{/if}
<p class="status-text">{flow.state.progress.currentOperation}</p>
<ProgressStep
title={$_('migration.offline.blobs.title')}
description={$_('migration.offline.blobs.desc')}
items={[
{ label: $_('migration.offline.migrating.importing'), completed: true },
{ label: `${$_('migration.offline.blobs.migrating')} (${flow.state.progress.blobsMigrated}/${flow.state.progress.blobsTotal})`, completed: false, active: true },
]}
statusText={flow.state.progress.currentOperation}
progressBar={flow.state.progress.blobsTotal > 0 ? { current: flow.state.progress.blobsMigrated, total: flow.state.progress.blobsTotal } : undefined}
>
{#if flow.state.progress.blobsFailed.length > 0}
<div class="warning-box">
<strong>{$_('migration.offline.blobs.failedTitle')}</strong>
<p>{$_('migration.offline.blobs.failedDesc', { values: { count: flow.state.progress.blobsFailed.length } })}</p>
</div>
{/if}
</div>
</ProgressStep>
{:else if flow.state.step === 'email-verify'}
<EmailVerifyStep
email={flow.state.targetEmail}
channel={flow.state.verificationChannel}
identifier={verificationIdentifier()}
handle={flow.state.targetHandle}
token={flow.state.emailVerifyToken}
{loading}
error={flow.state.error}
@@ -565,23 +494,15 @@
/>
{:else if flow.state.step === 'plc-signing' || flow.state.step === 'finalizing'}
<div class="step-content">
<h2>{$_('migration.inbound.finalizing.title')}</h2>
<p>{$_('migration.inbound.finalizing.desc')}</p>
<div class="progress-section">
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
<span>{$_('migration.inbound.finalizing.signingPlc')}</span>
</div>
<div class="progress-item" class:completed={flow.state.progress.activated}>
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
<span>{$_('migration.inbound.finalizing.activating')}</span>
</div>
</div>
<p class="status-text">{flow.state.progress.currentOperation}</p>
</div>
<ProgressStep
title={$_('migration.inbound.finalizing.title')}
description={$_('migration.inbound.finalizing.desc')}
items={[
{ label: $_('migration.inbound.finalizing.signingPlc'), completed: flow.state.progress.plcSigned },
{ label: $_('migration.inbound.finalizing.activating'), completed: flow.state.progress.activated },
]}
statusText={flow.state.progress.currentOperation}
/>
{:else if flow.state.step === 'success'}
<SuccessStep
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte'
interface ProgressItem {
label: string
completed: boolean
active?: boolean
}
interface Props {
title: string
description: string
items: ProgressItem[]
statusText: string
progressBar?: { current: number; total: number }
children?: Snippet
}
let { title, description, items, statusText, progressBar, children }: Props = $props()
</script>
<div class="step-content">
<h2>{title}</h2>
<p>{description}</p>
<div class="progress-section">
{#each items as item}
<div class="progress-item" class:completed={item.completed} class:active={item.active}>
<span class="icon">{item.completed ? '✓' : '○'}</span>
<span>{item.label}</span>
</div>
{/each}
</div>
{#if progressBar && progressBar.total > 0}
<div class="progress-bar">
<div class="progress-fill" style="width: {(progressBar.current / progressBar.total) * 100}%"></div>
</div>
{/if}
<p class="status-text">{statusText}</p>
{#if children}
{@render children()}
{/if}
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { _ } from '../../lib/i18n'
interface ReviewRow {
label: string
value: string
mono?: boolean
}
interface Props {
description: string
rows: ReviewRow[]
loading: boolean
onBack: () => void
onContinue: () => void
warning?: Snippet
}
let { description, rows, loading, onBack, onContinue, warning }: Props = $props()
</script>
<div class="step-content">
<h2>{$_('migration.inbound.review.title')}</h2>
<p>{description}</p>
<div class="review-card">
{#each rows as row}
<div class="review-row">
<span class="label">{row.label}:</span>
<span class="value" class:mono={row.mono}>{row.value}</span>
</div>
{/each}
</div>
{#if warning}
<div class="warning-box">
{@render warning()}
</div>
{/if}
<div class="button-row">
<button class="ghost" onclick={onBack} disabled={loading}>{$_('migration.inbound.common.back')}</button>
<button onclick={onContinue} disabled={loading}>
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
</button>
</div>
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
interface Props {
steps: string[]
currentIndex: number
}
let { steps, currentIndex }: Props = $props()
</script>
<div class="step-indicator">
{#each steps as _, i}
<div class="step" class:active={i === currentIndex} class:completed={i < currentIndex}>
<div class="step-dot">{i < currentIndex ? '✓' : i + 1}</div>
</div>
{#if i < steps.length - 1}
<div class="step-line" class:completed={i < currentIndex}></div>
{/if}
{/each}
</div>
<div class="current-step-label">
<strong>{steps[currentIndex]}</strong> · Step {currentIndex + 1} of {steps.length}
</div>
+1 -93
View File
@@ -92,12 +92,6 @@
margin: 0 0 var(--space-5) 0;
}
.info-box {
background: var(--accent-muted);
padding: var(--space-5);
margin-bottom: var(--space-5);
}
.info-box h3 {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-base);
@@ -119,13 +113,6 @@
color: var(--text-secondary);
}
.warning-box {
background: var(--warning-bg);
padding: var(--space-5);
margin-bottom: var(--space-5);
font-size: var(--text-sm);
}
.warning-box strong {
color: var(--warning-text);
}
@@ -158,6 +145,7 @@
.button-row {
display: flex;
flex-direction: row;
gap: var(--space-3);
justify-content: flex-end;
margin-top: var(--space-5);
@@ -260,28 +248,6 @@
font-size: var(--text-sm);
}
.blob-progress {
margin: var(--space-4) 0;
}
.blob-progress-bar {
height: 8px;
background: var(--bg-primary);
overflow: hidden;
margin-bottom: var(--space-2);
}
.blob-progress-fill {
height: 100%;
background: var(--accent);
}
.blob-progress-text {
text-align: center;
color: var(--text-secondary);
font-size: var(--text-sm);
margin: 0;
}
.success-content {
text-align: center;
@@ -411,43 +377,6 @@ label.auth-option {
color: var(--text-secondary);
}
.app-password-display {
background: var(--bg-primary);
padding: var(--space-5);
margin-bottom: var(--space-5);
text-align: center;
}
.app-password-label {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-3);
}
.app-password-code {
display: block;
font-family: var(--font-mono);
font-size: var(--text-lg);
letter-spacing: 0.1em;
padding: var(--space-4);
background: var(--bg-tertiary);
margin-bottom: var(--space-4);
user-select: all;
}
.copy-btn {
font-size: var(--text-sm);
}
.current-account {
background: var(--bg-primary);
padding: var(--space-4);
margin-bottom: var(--space-5);
display: flex;
justify-content: space-between;
align-items: center;
}
.current-account .label {
color: var(--text-secondary);
}
@@ -457,12 +386,6 @@ label.auth-option {
font-size: var(--text-lg);
}
.server-info {
background: var(--bg-primary);
padding: var(--space-4);
margin-top: var(--space-5);
}
.server-info h3 {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-base);
@@ -488,11 +411,6 @@ label.auth-option {
font-size: var(--text-sm);
}
.final-warning {
background: var(--error-bg);
border-color: var(--error-border);
}
.final-warning strong {
color: var(--error-text);
}
@@ -596,16 +514,6 @@ label.auth-option {
margin-bottom: var(--space-4);
}
.message.success {
background: var(--success-bg);
color: var(--success-text);
}
.message.error {
background: var(--error-bg);
color: var(--error-text);
}
.handle-choice-options {
display: flex;
flex-direction: column;