mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-15 13:56:05 +00:00
refactor(frontend): refactor migration components
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user