+431
-480
Diff
round #2
+61
-20
frontend/src/components/migration/ChooseHandleStep.svelte
+61
-20
frontend/src/components/migration/ChooseHandleStep.svelte
···
1
1
<script lang="ts">
2
-
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
2
+
import type { AuthMethod, HandlePreservation, ServerDescription, VerificationChannel } from '../../lib/migration/types'
3
+
import type { VerificationChannel as ApiVerificationChannel } from '../../lib/types/api'
3
4
import { _ } from '../../lib/i18n'
4
5
import HandleInput from '../HandleInput.svelte'
6
+
import CommsChannelPicker from '../CommsChannelPicker.svelte'
5
7
6
8
interface Props {
7
9
handleInput: string
8
10
selectedDomain: string
9
-
handleAvailable: boolean | null
10
-
checkingHandle: boolean
11
11
email: string
12
12
password: string
13
13
authMethod: AuthMethod
14
14
inviteCode: string
15
15
serverInfo: ServerDescription | null
16
+
availableCommsChannels: ApiVerificationChannel[]
17
+
verificationChannel: VerificationChannel
18
+
discordUsername: string
19
+
telegramUsername: string
20
+
signalUsername: string
16
21
migratingFromLabel: string
17
22
migratingFromValue: string
18
23
loading?: boolean
19
24
sourceHandle: string
20
25
sourceDid: string
26
+
sourcePdsDomains?: string[]
21
27
handlePreservation: HandlePreservation
22
28
existingHandleVerified: boolean
23
29
verifyingExistingHandle?: boolean
24
30
existingHandleError?: string | null
31
+
checkAvailability: (fullHandle: string) => Promise<boolean>
25
32
onHandleChange: (handle: string) => void
26
33
onDomainChange: (domain: string) => void
27
-
onCheckHandle: () => void
28
34
onEmailChange: (email: string) => void
29
35
onPasswordChange: (password: string) => void
30
36
onAuthMethodChange: (method: AuthMethod) => void
31
37
onInviteCodeChange: (code: string) => void
38
+
onVerificationChannelChange: (channel: VerificationChannel) => void
39
+
onDiscordChange: (value: string) => void
40
+
onTelegramChange: (value: string) => void
41
+
onSignalChange: (value: string) => void
32
42
onHandlePreservationChange?: (preservation: HandlePreservation) => void
33
43
onVerifyExistingHandle?: () => void
34
44
onBack: () => void
···
38
48
let {
39
49
handleInput,
40
50
selectedDomain,
41
-
handleAvailable,
42
-
checkingHandle,
43
51
email,
44
52
password,
45
53
authMethod,
46
54
inviteCode,
47
55
serverInfo,
56
+
availableCommsChannels,
57
+
verificationChannel,
58
+
discordUsername,
59
+
telegramUsername,
60
+
signalUsername,
48
61
migratingFromLabel,
49
62
migratingFromValue,
50
63
loading = false,
51
64
sourceHandle,
52
65
sourceDid,
66
+
sourcePdsDomains = [],
53
67
handlePreservation,
54
68
existingHandleVerified,
55
69
verifyingExistingHandle = false,
56
70
existingHandleError = null,
71
+
checkAvailability,
57
72
onHandleChange,
58
73
onDomainChange,
59
-
onCheckHandle,
60
74
onEmailChange,
61
75
onPasswordChange,
62
76
onAuthMethodChange,
63
77
onInviteCodeChange,
78
+
onVerificationChannelChange,
79
+
onDiscordChange,
80
+
onTelegramChange,
81
+
onSignalChange,
64
82
onHandlePreservationChange,
65
83
onVerifyExistingHandle,
66
84
onBack,
67
85
onContinue,
68
86
}: Props = $props()
69
87
88
+
let handleAvailable = $state<boolean | null>(null)
89
+
let checkingHandle = $state(false)
90
+
70
91
const handleTooShort = $derived(handleInput.trim().length > 0 && handleInput.trim().length < 3)
71
92
93
+
const isSourcePdsManaged = $derived(
94
+
sourcePdsDomains.length > 0 &&
95
+
sourceHandle.includes('.') &&
96
+
sourcePdsDomains.some(d => sourceHandle.endsWith(`.${d}`))
97
+
)
98
+
72
99
const isExternalHandle = $derived(
73
-
serverInfo != null &&
74
100
sourceHandle.includes('.') &&
101
+
!isSourcePdsManaged &&
102
+
serverInfo != null &&
75
103
!serverInfo.availableUserDomains.some(d => sourceHandle.endsWith(`.${d}`))
76
104
)
77
105
106
+
const hasVerificationIdentifier = $derived(
107
+
(verificationChannel === 'email' && email.trim().length > 0) ||
108
+
(verificationChannel === 'discord' && discordUsername.trim().length > 0) ||
109
+
(verificationChannel === 'telegram' && telegramUsername.trim().length > 0) ||
110
+
(verificationChannel === 'signal' && signalUsername.trim().length > 0)
111
+
)
112
+
78
113
const canContinue = $derived(
79
-
email &&
114
+
hasVerificationIdentifier &&
80
115
(authMethod === 'passkey' || password) &&
81
116
(
82
117
(handlePreservation === 'existing' && existingHandleVerified) ||
···
178
213
domains={serverInfo?.availableUserDomains ?? []}
179
214
{selectedDomain}
180
215
placeholder="username"
216
+
{checkAvailability}
217
+
bind:available={handleAvailable}
218
+
bind:checking={checkingHandle}
181
219
onInput={onHandleChange}
182
220
onDomainChange={onDomainChange}
183
221
/>
···
196
234
</div>
197
235
{/if}
198
236
199
-
<div class="field">
200
-
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
201
-
<input
202
-
id="email"
203
-
type="email"
204
-
placeholder="you@example.com"
205
-
value={email}
206
-
oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)}
207
-
required
208
-
/>
209
-
</div>
237
+
<CommsChannelPicker
238
+
channel={verificationChannel}
239
+
{email}
240
+
{discordUsername}
241
+
{telegramUsername}
242
+
{signalUsername}
243
+
availableChannels={availableCommsChannels}
244
+
disabled={loading}
245
+
onChannelChange={onVerificationChannelChange}
246
+
onEmailChange={onEmailChange}
247
+
onDiscordChange={onDiscordChange}
248
+
onTelegramChange={onTelegramChange}
249
+
onSignalChange={onSignalChange}
250
+
/>
210
251
211
252
<div class="field">
212
253
<span class="field-label">{$_('migration.inbound.chooseHandle.authMethod')}</span>
+94
-34
frontend/src/components/migration/EmailVerifyStep.svelte
+94
-34
frontend/src/components/migration/EmailVerifyStep.svelte
···
1
1
<script lang="ts">
2
+
import { onDestroy, onMount } from 'svelte'
3
+
import type { VerificationChannel } from '../../lib/migration/types'
4
+
import { api } from '../../lib/api'
2
5
import { _ } from '../../lib/i18n'
3
6
4
7
interface Props {
5
-
email: string
8
+
channel: VerificationChannel
9
+
identifier: string
6
10
token: string
7
11
loading: boolean
8
12
error: string | null
13
+
handle?: string
9
14
onTokenChange: (token: string) => void
10
15
onSubmit: (e: Event) => void
11
16
onResend: () => void
17
+
onVerified?: () => void
12
18
}
13
19
14
20
let {
15
-
email,
21
+
channel,
22
+
identifier,
16
23
token,
17
24
loading,
18
25
error,
26
+
handle,
19
27
onTokenChange,
20
28
onSubmit,
21
29
onResend,
30
+
onVerified,
22
31
}: Props = $props()
32
+
33
+
let telegramBotUsername = $state<string | undefined>(undefined)
34
+
let discordBotUsername = $state<string | undefined>(undefined)
35
+
let discordAppId = $state<string | undefined>(undefined)
36
+
37
+
const isTelegram = $derived(channel === 'telegram')
38
+
const isDiscord = $derived(channel === 'discord')
39
+
const isBotChannel = $derived(isTelegram || isDiscord)
40
+
41
+
onMount(async () => {
42
+
if (isBotChannel) {
43
+
try {
44
+
const serverInfo = await api.describeServer()
45
+
telegramBotUsername = serverInfo.telegramBotUsername
46
+
discordBotUsername = serverInfo.discordBotUsername
47
+
discordAppId = serverInfo.discordAppId
48
+
} catch {}
49
+
}
50
+
})
51
+
52
+
function channelLabel(ch: string): string {
53
+
switch (ch) {
54
+
case 'email': return 'email'
55
+
case 'discord': return 'Discord'
56
+
case 'telegram': return 'Telegram'
57
+
case 'signal': return 'Signal'
58
+
default: return ch
59
+
}
60
+
}
23
61
</script>
24
62
25
63
<div class="step-content">
26
64
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
27
-
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${email}</strong>` } })}</p>
28
-
29
-
<div class="info-box">
30
-
<p>
31
-
{$_('migration.inbound.emailVerify.hint')}
32
-
</p>
33
-
</div>
34
65
35
-
{#if error}
36
-
<div class="message error">
37
-
{error}
66
+
{#if isTelegram && telegramBotUsername && handle}
67
+
{@const encodedHandle = handle.replaceAll('.', '_')}
68
+
<p>{$_('migration.inbound.emailVerify.telegramInstructions')}</p>
69
+
<div class="info-box">
70
+
<p>
71
+
<a href="https://t.me/{telegramBotUsername}?start={encodedHandle}" target="_blank" rel="noopener">{$_('migration.inbound.emailVerify.openTelegram')}</a>,
72
+
or send <code>/start {handle}</code> to <code>@{telegramBotUsername}</code>
73
+
</p>
38
74
</div>
39
-
{/if}
40
-
41
-
<form onsubmit={onSubmit}>
42
-
<div>
43
-
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
44
-
<input
45
-
id="email-verify-token"
46
-
type="text"
47
-
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
48
-
value={token}
49
-
oninput={(e) => onTokenChange((e.target as HTMLInputElement).value)}
50
-
disabled={loading}
51
-
required
52
-
/>
75
+
<p class="hint">{$_('migration.inbound.emailVerify.waitingForVerification')}</p>
76
+
{:else if isDiscord && discordAppId && handle}
77
+
<p>{$_('migration.inbound.emailVerify.discordInstructions')}</p>
78
+
<div class="info-box">
79
+
<p>
80
+
<a href="https://discord.com/users/{discordAppId}" target="_blank" rel="noopener">{$_('migration.inbound.emailVerify.openDiscord')}</a>,
81
+
or send <code>/start {handle}</code> to <strong>{discordBotUsername ?? 'the bot'}</strong>
82
+
</p>
53
83
</div>
84
+
<p class="hint">{$_('migration.inbound.emailVerify.waitingForVerification')}</p>
85
+
{:else}
86
+
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${identifier}</strong>`, channel: channelLabel(channel) } })}</p>
54
87
55
-
<div class="button-row">
56
-
<button type="button" class="ghost" onclick={onResend} disabled={loading}>
57
-
{$_('migration.inbound.emailVerify.resend')}
58
-
</button>
59
-
<button type="submit" disabled={loading || !token}>
60
-
{loading ? $_('common.verifying') : $_('common.verify')}
61
-
</button>
88
+
<div class="info-box">
89
+
<p>
90
+
{$_('migration.inbound.emailVerify.hint')}
91
+
</p>
62
92
</div>
63
-
</form>
93
+
94
+
{#if error}
95
+
<div class="message error">
96
+
{error}
97
+
</div>
98
+
{/if}
99
+
100
+
<form onsubmit={onSubmit}>
101
+
<div>
102
+
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
103
+
<input
104
+
id="email-verify-token"
105
+
type="text"
106
+
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
107
+
value={token}
108
+
oninput={(e) => onTokenChange((e.target as HTMLInputElement).value)}
109
+
disabled={loading}
110
+
required
111
+
/>
112
+
</div>
113
+
114
+
<div class="button-row">
115
+
<button type="button" class="ghost" onclick={onResend} disabled={loading}>
116
+
{$_('migration.inbound.emailVerify.resend')}
117
+
</button>
118
+
<button type="submit" disabled={loading || !token}>
119
+
{loading ? $_('common.verifying') : $_('common.verify')}
120
+
</button>
121
+
</div>
122
+
</form>
123
+
{/if}
64
124
</div>
+79
-174
frontend/src/components/migration/InboundWizard.svelte
+79
-174
frontend/src/components/migration/InboundWizard.svelte
···
1
1
<script lang="ts">
2
2
import type { InboundMigrationFlow } from '../../lib/migration'
3
3
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
4
+
import { resolveVerificationIdentifier } from '../../lib/flows/migration-shared'
4
5
import { getErrorMessage } from '../../lib/migration/types'
5
-
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
6
+
import { createPasskeyCredential, PasskeyCancelledError } from '../../lib/flows/perform-passkey-registration'
6
7
import { _ } from '../../lib/i18n'
7
8
import ErrorStep from './ErrorStep.svelte'
8
9
import SuccessStep from './SuccessStep.svelte'
···
10
11
import EmailVerifyStep from './EmailVerifyStep.svelte'
11
12
import PasskeySetupStep from './PasskeySetupStep.svelte'
12
13
import AppPasswordStep from './AppPasswordStep.svelte'
14
+
import StepIndicator from './StepIndicator.svelte'
15
+
import ProgressStep from './ProgressStep.svelte'
16
+
import ReviewStep from './ReviewStep.svelte'
13
17
14
18
interface ResumeInfo {
15
19
direction: 'inbound'
···
38
42
let localPasswordInput = $state('')
39
43
let understood = $state(false)
40
44
let selectedDomain = $state('')
41
-
let handleAvailable = $state<boolean | null>(null)
42
-
let checkingHandle = $state(false)
43
45
let selectedAuthMethod = $state<AuthMethod>('password')
44
46
let passkeyName = $state('')
45
47
let verifyingExistingHandle = $state(false)
46
48
let existingHandleError = $state<string | null>(null)
49
+
let sourcePdsDomains = $state<string[]>([])
47
50
48
51
const isResuming = $derived(flow.state.needsReauth === true)
49
52
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
50
53
54
+
function verificationIdentifier(): string {
55
+
return resolveVerificationIdentifier(
56
+
flow.state.verificationChannel,
57
+
flow.state.targetEmail,
58
+
flow.state.discordUsername,
59
+
flow.state.telegramUsername,
60
+
flow.state.signalUsername,
61
+
)
62
+
}
63
+
51
64
$effect(() => {
52
65
if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') {
53
66
loadServerInfo()
54
67
}
55
68
if (flow.state.step === 'choose-handle') {
56
69
handleInput = ''
57
-
handleAvailable = null
58
70
existingHandleError = null
59
71
flow.updateField('handlePreservation', 'new')
60
72
flow.updateField('existingHandleVerified', false)
73
+
flow.loadSourcePdsDomains().then((d) => { sourcePdsDomains = d })
61
74
}
62
75
if (flow.state.step === 'source-handle' && resumeInfo) {
63
76
handleInput = resumeInfo.sourceHandle
···
79
92
80
93
$effect(() => {
81
94
if (flow.state.step === 'email-verify') {
95
+
const isBotChannel = flow.state.verificationChannel === 'telegram' || flow.state.verificationChannel === 'discord'
82
96
const interval = setInterval(async () => {
83
-
if (flow.state.emailVerifyToken.trim()) return
97
+
if (!isBotChannel && flow.state.emailVerifyToken.trim()) return
84
98
await flow.checkEmailVerifiedAndProceed()
85
99
}, 3000)
86
100
return () => clearInterval(interval)
···
97
111
}
98
112
}
99
113
100
-
async function checkHandle() {
101
-
if (!handleInput.trim()) return
102
-
103
-
const fullHandle = handleInput.includes('.')
104
-
? handleInput
105
-
: `${handleInput}.${selectedDomain}`
106
-
107
-
checkingHandle = true
108
-
handleAvailable = null
109
-
110
-
try {
111
-
handleAvailable = await flow.checkHandleAvailability(fullHandle)
112
-
} catch {
113
-
handleAvailable = true
114
-
} finally {
115
-
checkingHandle = false
116
-
}
117
-
}
118
-
119
114
function handlePreservationChange(preservation: HandlePreservation) {
120
115
flow.updateField('handlePreservation', preservation)
121
116
existingHandleError = null
···
224
219
flow.setError(null)
225
220
226
221
try {
227
-
if (!window.PublicKeyCredential) {
228
-
throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
229
-
}
230
-
231
-
const { options } = await flow.startPasskeyRegistration()
232
-
233
-
const publicKeyOptions = prepareWebAuthnCreationOptions(
234
-
options as { publicKey: Record<string, unknown> }
222
+
const credential = await createPasskeyCredential(
223
+
() => flow.startPasskeyRegistration(),
235
224
)
236
-
const credential = await navigator.credentials.create({
237
-
publicKey: publicKeyOptions,
238
-
})
239
-
240
-
if (!credential) {
241
-
throw new Error('Passkey creation was cancelled')
242
-
}
243
-
244
-
const publicKeyCredential = credential as PublicKeyCredential
245
-
const response = publicKeyCredential.response as AuthenticatorAttestationResponse
246
-
247
-
const credentialData = {
248
-
id: publicKeyCredential.id,
249
-
rawId: base64UrlEncode(publicKeyCredential.rawId),
250
-
type: publicKeyCredential.type,
251
-
response: {
252
-
clientDataJSON: base64UrlEncode(response.clientDataJSON),
253
-
attestationObject: base64UrlEncode(response.attestationObject),
254
-
},
255
-
}
256
-
257
-
await flow.completePasskeyRegistration(credentialData, passkeyName || undefined)
225
+
await flow.completePasskeyRegistration(credential, passkeyName || undefined)
258
226
} catch (err) {
259
-
const message = getErrorMessage(err)
260
-
if (message.includes('cancelled') || message.includes('AbortError')) {
227
+
if (err instanceof PasskeyCancelledError || (err instanceof DOMException && err.name === 'NotAllowedError')) {
261
228
flow.setError('Passkey registration was cancelled. Please try again.')
262
229
} else {
263
-
flow.setError(message)
230
+
flow.setError(getErrorMessage(err))
264
231
}
265
232
} finally {
266
233
loading = false
···
334
301
</script>
335
302
336
303
<div class="migration-wizard">
337
-
<div class="step-indicator">
338
-
{#each steps as _, i}
339
-
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
340
-
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
341
-
</div>
342
-
{#if i < steps.length - 1}
343
-
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
344
-
{/if}
345
-
{/each}
346
-
</div>
347
-
<div class="current-step-label">
348
-
<strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
349
-
</div>
304
+
<StepIndicator steps={steps} currentIndex={getCurrentStepIndex()} />
350
305
351
306
{#if flow.state.error}
352
307
<div class="message error">{flow.state.error}</div>
···
443
398
<ChooseHandleStep
444
399
{handleInput}
445
400
{selectedDomain}
446
-
{handleAvailable}
447
-
{checkingHandle}
448
401
email={flow.state.targetEmail}
449
402
password={flow.state.targetPassword}
450
403
authMethod={selectedAuthMethod}
451
404
inviteCode={flow.state.inviteCode}
452
405
{serverInfo}
406
+
availableCommsChannels={serverInfo?.availableCommsChannels ?? ['email']}
407
+
verificationChannel={flow.state.verificationChannel}
408
+
discordUsername={flow.state.discordUsername}
409
+
telegramUsername={flow.state.telegramUsername}
410
+
signalUsername={flow.state.signalUsername}
453
411
migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')}
454
412
migratingFromValue={flow.state.sourceHandle}
455
413
{loading}
456
414
sourceHandle={flow.state.sourceHandle}
457
415
sourceDid={flow.state.sourceDid}
416
+
{sourcePdsDomains}
458
417
handlePreservation={flow.state.handlePreservation}
459
418
existingHandleVerified={flow.state.existingHandleVerified}
460
419
{verifyingExistingHandle}
461
420
{existingHandleError}
421
+
checkAvailability={(h) => flow.checkHandleAvailability(h)}
462
422
onHandleChange={(h) => handleInput = h}
463
423
onDomainChange={(d) => selectedDomain = d}
464
-
onCheckHandle={checkHandle}
465
424
onEmailChange={(e) => flow.updateField('targetEmail', e)}
466
425
onPasswordChange={(p) => flow.updateField('targetPassword', p)}
467
426
onAuthMethodChange={(m) => selectedAuthMethod = m}
468
427
onInviteCodeChange={(c) => flow.updateField('inviteCode', c)}
428
+
onVerificationChannelChange={(ch) => flow.updateField('verificationChannel', ch)}
429
+
onDiscordChange={(v) => flow.updateField('discordUsername', v)}
430
+
onTelegramChange={(v) => flow.updateField('telegramUsername', v)}
431
+
onSignalChange={(v) => flow.updateField('signalUsername', v)}
469
432
onHandlePreservationChange={handlePreservationChange}
470
433
onVerifyExistingHandle={verifyExistingHandle}
471
434
onBack={() => flow.setStep('source-handle')}
···
473
436
/>
474
437
475
438
{:else if flow.state.step === 'review'}
476
-
<div class="step-content">
477
-
<h2>{$_('migration.inbound.review.title')}</h2>
478
-
<p>{$_('migration.inbound.review.desc')}</p>
479
-
480
-
<div class="review-card">
481
-
<div class="review-row">
482
-
<span class="label">{$_('migration.inbound.review.currentHandle')}:</span>
483
-
<span class="value">{flow.state.sourceHandle}</span>
484
-
</div>
485
-
<div class="review-row">
486
-
<span class="label">{$_('migration.inbound.review.newHandle')}:</span>
487
-
<span class="value">{flow.state.targetHandle}</span>
488
-
</div>
489
-
<div class="review-row">
490
-
<span class="label">{$_('migration.inbound.review.did')}:</span>
491
-
<span class="value mono">{flow.state.sourceDid}</span>
492
-
</div>
493
-
<div class="review-row">
494
-
<span class="label">{$_('migration.inbound.review.sourcePds')}:</span>
495
-
<span class="value">{flow.state.sourcePdsUrl}</span>
496
-
</div>
497
-
<div class="review-row">
498
-
<span class="label">{$_('migration.inbound.review.targetPds')}:</span>
499
-
<span class="value">{window.location.origin}</span>
500
-
</div>
501
-
<div class="review-row">
502
-
<span class="label">{$_('migration.inbound.review.email')}:</span>
503
-
<span class="value">{flow.state.targetEmail}</span>
504
-
</div>
505
-
<div class="review-row">
506
-
<span class="label">{$_('migration.inbound.review.authentication')}:</span>
507
-
<span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
508
-
</div>
509
-
</div>
510
-
511
-
<div class="warning-box">
439
+
<ReviewStep
440
+
description={$_('migration.inbound.review.desc')}
441
+
rows={[
442
+
{ label: $_('migration.inbound.review.currentHandle'), value: flow.state.sourceHandle },
443
+
{ label: $_('migration.inbound.review.newHandle'), value: flow.state.targetHandle },
444
+
{ label: $_('migration.inbound.review.did'), value: flow.state.sourceDid, mono: true },
445
+
{ label: $_('migration.inbound.review.sourcePds'), value: flow.state.sourcePdsUrl },
446
+
{ label: $_('migration.inbound.review.targetPds'), value: window.location.origin },
447
+
{ label: $_(`register.${flow.state.verificationChannel}`), value: verificationIdentifier() },
448
+
{ label: $_('migration.inbound.review.authentication'), value: flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword') },
449
+
]}
450
+
{loading}
451
+
onBack={() => flow.setStep('choose-handle')}
452
+
onContinue={startMigration}
453
+
>
454
+
{#snippet warning()}
512
455
{$_('migration.inbound.review.warning')}
513
-
</div>
514
-
515
-
<div class="button-row">
516
-
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
517
-
<button onclick={startMigration} disabled={loading}>
518
-
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
519
-
</button>
520
-
</div>
521
-
</div>
456
+
{/snippet}
457
+
</ReviewStep>
522
458
523
459
{:else if flow.state.step === 'migrating'}
524
-
<div class="step-content">
525
-
<h2>{$_('migration.inbound.migrating.title')}</h2>
526
-
<p>{$_('migration.inbound.migrating.desc')}</p>
527
-
528
-
<div class="progress-section">
529
-
<div class="progress-item" class:completed={flow.state.progress.repoExported}>
530
-
<span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
531
-
<span>{$_('migration.inbound.migrating.exportRepo')}</span>
532
-
</div>
533
-
<div class="progress-item" class:completed={flow.state.progress.repoImported}>
534
-
<span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
535
-
<span>{$_('migration.inbound.migrating.importRepo')}</span>
536
-
</div>
537
-
<div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
538
-
<span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
539
-
<span>{$_('migration.inbound.migrating.migrateBlobs')} ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
540
-
</div>
541
-
<div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
542
-
<span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
543
-
<span>{$_('migration.inbound.migrating.migratePrefs')}</span>
544
-
</div>
545
-
</div>
546
-
547
-
{#if flow.state.progress.blobsTotal > 0}
548
-
<div class="progress-bar">
549
-
<div
550
-
class="progress-fill"
551
-
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
552
-
></div>
553
-
</div>
554
-
{/if}
555
-
556
-
<p class="status-text">{flow.state.progress.currentOperation}</p>
557
-
</div>
460
+
<ProgressStep
461
+
title={$_('migration.inbound.migrating.title')}
462
+
description={$_('migration.inbound.migrating.desc')}
463
+
items={[
464
+
{ label: $_('migration.inbound.migrating.exportRepo'), completed: flow.state.progress.repoExported },
465
+
{ label: $_('migration.inbound.migrating.importRepo'), completed: flow.state.progress.repoImported },
466
+
{ 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 },
467
+
{ label: $_('migration.inbound.migrating.migratePrefs'), completed: flow.state.progress.prefsMigrated },
468
+
]}
469
+
statusText={flow.state.progress.currentOperation}
470
+
progressBar={flow.state.progress.blobsTotal > 0 ? { current: flow.state.progress.blobsMigrated, total: flow.state.progress.blobsTotal } : undefined}
471
+
/>
558
472
559
473
{:else if flow.state.step === 'passkey-setup'}
560
474
<PasskeySetupStep
···
575
489
576
490
{:else if flow.state.step === 'email-verify'}
577
491
<EmailVerifyStep
578
-
email={flow.state.targetEmail}
492
+
channel={flow.state.verificationChannel}
493
+
identifier={verificationIdentifier()}
494
+
handle={flow.state.targetHandle}
579
495
token={flow.state.emailVerifyToken}
580
496
{loading}
581
497
error={flow.state.error}
···
675
591
</div>
676
592
677
593
{:else if flow.state.step === 'finalizing'}
678
-
<div class="step-content">
679
-
<h2>{$_('migration.inbound.finalizing.title')}</h2>
680
-
<p>{$_('migration.inbound.finalizing.desc')}</p>
681
-
682
-
<div class="progress-section">
683
-
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
684
-
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
685
-
<span>{$_('migration.inbound.finalizing.signingPlc')}</span>
686
-
</div>
687
-
<div class="progress-item" class:completed={flow.state.progress.activated}>
688
-
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
689
-
<span>{$_('migration.inbound.finalizing.activating')}</span>
690
-
</div>
691
-
<div class="progress-item" class:completed={flow.state.progress.deactivated}>
692
-
<span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
693
-
<span>{$_('migration.inbound.finalizing.deactivating')}</span>
694
-
</div>
695
-
</div>
696
-
697
-
<p class="status-text">{flow.state.progress.currentOperation}</p>
698
-
</div>
594
+
<ProgressStep
595
+
title={$_('migration.inbound.finalizing.title')}
596
+
description={$_('migration.inbound.finalizing.desc')}
597
+
items={[
598
+
{ label: $_('migration.inbound.finalizing.signingPlc'), completed: flow.state.progress.plcSigned },
599
+
{ label: $_('migration.inbound.finalizing.activating'), completed: flow.state.progress.activated },
600
+
{ label: $_('migration.inbound.finalizing.deactivating'), completed: flow.state.progress.deactivated },
601
+
]}
602
+
statusText={flow.state.progress.currentOperation}
603
+
/>
699
604
700
605
{:else if flow.state.step === 'success'}
701
606
<SuccessStep handle={flow.state.targetHandle} did={flow.state.sourceDid}>
+80
-159
frontend/src/components/migration/OfflineInboundWizard.svelte
+80
-159
frontend/src/components/migration/OfflineInboundWizard.svelte
···
1
1
<script lang="ts">
2
2
import type { OfflineInboundMigrationFlow } from '../../lib/migration'
3
3
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
4
+
import { resolveVerificationIdentifier } from '../../lib/flows/migration-shared'
4
5
import { getErrorMessage } from '../../lib/migration/types'
5
-
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
6
+
import { PasskeyCancelledError } from '../../lib/flows/perform-passkey-registration'
6
7
import { _ } from '../../lib/i18n'
7
8
import ErrorStep from './ErrorStep.svelte'
8
9
import SuccessStep from './SuccessStep.svelte'
···
10
11
import EmailVerifyStep from './EmailVerifyStep.svelte'
11
12
import PasskeySetupStep from './PasskeySetupStep.svelte'
12
13
import AppPasswordStep from './AppPasswordStep.svelte'
14
+
import StepIndicator from './StepIndicator.svelte'
15
+
import ProgressStep from './ProgressStep.svelte'
16
+
import ReviewStep from './ReviewStep.svelte'
13
17
14
18
interface Props {
15
19
flow: OfflineInboundMigrationFlow
···
24
28
let understood = $state(false)
25
29
let handleInput = $state('')
26
30
let selectedDomain = $state('')
27
-
let handleAvailable = $state<boolean | null>(null)
28
-
let checkingHandle = $state(false)
29
31
let validatingKey = $state(false)
30
32
let keyValid = $state<boolean | null>(null)
31
33
let fileInputRef = $state<HTMLInputElement | null>(null)
32
34
let selectedAuthMethod = $state<AuthMethod>('password')
33
35
let passkeyName = $state('')
34
36
37
+
function verificationIdentifier(): string {
38
+
return resolveVerificationIdentifier(
39
+
flow.state.verificationChannel,
40
+
flow.state.targetEmail,
41
+
flow.state.discordUsername,
42
+
flow.state.telegramUsername,
43
+
flow.state.signalUsername,
44
+
)
45
+
}
46
+
35
47
let redirectTriggered = $state(false)
36
48
37
49
$effect(() => {
···
40
52
}
41
53
if (flow.state.step === 'choose-handle') {
42
54
handleInput = ''
43
-
handleAvailable = null
44
55
}
45
56
})
46
57
···
55
66
56
67
$effect(() => {
57
68
if (flow.state.step === 'email-verify') {
69
+
const isBotChannel = flow.state.verificationChannel === 'telegram' || flow.state.verificationChannel === 'discord'
58
70
const interval = setInterval(async () => {
59
-
if (flow.state.emailVerifyToken.trim()) return
71
+
if (!isBotChannel && flow.state.emailVerifyToken.trim()) return
60
72
await flow.checkEmailVerifiedAndProceed()
61
73
}, 3000)
62
74
return () => clearInterval(interval)
···
145
157
}
146
158
}
147
159
148
-
async function checkHandle() {
149
-
if (!handleInput.trim()) return
150
-
151
-
const fullHandle = handleInput.includes('.')
152
-
? handleInput
153
-
: `${handleInput}.${selectedDomain}`
154
-
155
-
checkingHandle = true
156
-
handleAvailable = null
157
-
158
-
try {
159
-
handleAvailable = await flow.checkHandleAvailability(fullHandle)
160
-
} catch {
161
-
handleAvailable = true
162
-
} finally {
163
-
checkingHandle = false
164
-
}
165
-
}
166
-
167
160
function proceedToReview() {
168
161
const fullHandle = handleInput.includes('.')
169
162
? handleInput
···
203
196
flow.setError(null)
204
197
205
198
try {
206
-
if (!window.PublicKeyCredential) {
207
-
throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
208
-
}
209
-
210
199
await flow.registerPasskey(passkeyName || undefined)
211
200
} catch (err) {
212
-
const message = getErrorMessage(err)
213
-
if (message.includes('cancelled') || message.includes('AbortError')) {
201
+
if (err instanceof PasskeyCancelledError || (err instanceof DOMException && err.name === 'NotAllowedError')) {
214
202
flow.setError('Passkey registration was cancelled. Please try again.')
215
203
} else {
216
-
flow.setError(message)
204
+
flow.setError(getErrorMessage(err))
217
205
}
218
206
} finally {
219
207
loading = false
···
233
221
</script>
234
222
235
223
<div class="migration-wizard">
236
-
<div class="step-indicator">
237
-
{#each steps as _, i}
238
-
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
239
-
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
240
-
</div>
241
-
{#if i < steps.length - 1}
242
-
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
243
-
{/if}
244
-
{/each}
245
-
</div>
246
-
<div class="current-step-label">
247
-
<strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
248
-
</div>
224
+
<StepIndicator steps={steps} currentIndex={getCurrentStepIndex()} />
249
225
250
226
{#if flow.state.error}
251
227
<div class="message error">{flow.state.error}</div>
···
401
377
<ChooseHandleStep
402
378
{handleInput}
403
379
{selectedDomain}
404
-
{handleAvailable}
405
-
{checkingHandle}
406
380
email={flow.state.targetEmail}
407
381
password={flow.state.targetPassword}
408
382
authMethod={selectedAuthMethod}
409
383
inviteCode={flow.state.inviteCode}
410
384
{serverInfo}
385
+
availableCommsChannels={serverInfo?.availableCommsChannels ?? ['email']}
386
+
verificationChannel={flow.state.verificationChannel}
387
+
discordUsername={flow.state.discordUsername}
388
+
telegramUsername={flow.state.telegramUsername}
389
+
signalUsername={flow.state.signalUsername}
411
390
migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')}
412
391
migratingFromValue={flow.state.userDid}
413
392
{loading}
···
417
396
existingHandleVerified={false}
418
397
verifyingExistingHandle={false}
419
398
existingHandleError={null}
399
+
checkAvailability={(h) => flow.checkHandleAvailability(h)}
420
400
onHandleChange={(h) => handleInput = h}
421
401
onDomainChange={(d) => selectedDomain = d}
422
-
onCheckHandle={checkHandle}
423
402
onEmailChange={(e) => flow.setTargetEmail(e)}
424
403
onPasswordChange={(p) => flow.setTargetPassword(p)}
425
404
onAuthMethodChange={(m) => selectedAuthMethod = m}
426
405
onInviteCodeChange={(c) => flow.setInviteCode(c)}
406
+
onVerificationChannelChange={(ch) => flow.updateField('verificationChannel', ch)}
407
+
onDiscordChange={(v) => flow.updateField('discordUsername', v)}
408
+
onTelegramChange={(v) => flow.updateField('telegramUsername', v)}
409
+
onSignalChange={(v) => flow.updateField('signalUsername', v)}
427
410
onBack={() => flow.setStep('provide-rotation-key')}
428
411
onContinue={proceedToReview}
429
412
/>
430
413
431
414
{:else if flow.state.step === 'review'}
432
-
<div class="step-content">
433
-
<h2>{$_('migration.inbound.review.title')}</h2>
434
-
<p>{$_('migration.offline.review.desc')}</p>
435
-
436
-
<div class="review-card">
437
-
<div class="review-row">
438
-
<span class="label">{$_('migration.inbound.review.did')}:</span>
439
-
<span class="value mono">{flow.state.userDid}</span>
440
-
</div>
441
-
<div class="review-row">
442
-
<span class="label">{$_('migration.inbound.review.newHandle')}:</span>
443
-
<span class="value">{flow.state.targetHandle}</span>
444
-
</div>
445
-
<div class="review-row">
446
-
<span class="label">{$_('migration.offline.review.carFile')}:</span>
447
-
<span class="value">{flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span>
448
-
</div>
449
-
<div class="review-row">
450
-
<span class="label">{$_('migration.offline.review.rotationKey')}:</span>
451
-
<span class="value mono">{flow.state.rotationKeyDidKey}</span>
452
-
</div>
453
-
<div class="review-row">
454
-
<span class="label">{$_('migration.inbound.review.targetPds')}:</span>
455
-
<span class="value">{window.location.origin}</span>
456
-
</div>
457
-
<div class="review-row">
458
-
<span class="label">{$_('migration.inbound.review.email')}:</span>
459
-
<span class="value">{flow.state.targetEmail}</span>
460
-
</div>
461
-
<div class="review-row">
462
-
<span class="label">{$_('migration.inbound.review.authentication')}:</span>
463
-
<span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
464
-
</div>
465
-
</div>
466
-
467
-
<div class="warning-box">
415
+
<ReviewStep
416
+
description={$_('migration.offline.review.desc')}
417
+
rows={[
418
+
{ label: $_('migration.inbound.review.did'), value: flow.state.userDid, mono: true },
419
+
{ label: $_('migration.inbound.review.newHandle'), value: flow.state.targetHandle },
420
+
{ label: $_('migration.offline.review.carFile'), value: `${flow.state.carFileName} (${(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)` },
421
+
{ label: $_('migration.offline.review.rotationKey'), value: flow.state.rotationKeyDidKey, mono: true },
422
+
{ label: $_('migration.inbound.review.targetPds'), value: window.location.origin },
423
+
{ label: $_(`register.${flow.state.verificationChannel}`), value: verificationIdentifier() },
424
+
{ label: $_('migration.inbound.review.authentication'), value: flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword') },
425
+
]}
426
+
{loading}
427
+
onBack={() => flow.setStep('choose-handle')}
428
+
onContinue={startMigration}
429
+
>
430
+
{#snippet warning()}
468
431
<strong>{$_('migration.offline.review.plcWarningTitle')}</strong>
469
432
<p>{$_('migration.offline.review.plcWarning')}</p>
470
-
</div>
471
-
472
-
<div class="button-row">
473
-
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
474
-
<button onclick={startMigration} disabled={loading}>
475
-
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
476
-
</button>
477
-
</div>
478
-
</div>
433
+
{/snippet}
434
+
</ReviewStep>
479
435
480
436
{:else if flow.state.step === 'creating' || flow.state.step === 'importing'}
481
-
<div class="step-content">
482
-
<h2>{$_('migration.offline.migrating.title')}</h2>
483
-
<p>{$_('migration.offline.migrating.desc')}</p>
484
-
485
-
<div class="progress-section">
486
-
<div class="progress-item" class:completed={flow.state.step !== 'creating'} class:active={flow.state.step === 'creating'}>
487
-
<span class="icon">{flow.state.step !== 'creating' ? '✓' : '○'}</span>
488
-
<span>{$_('migration.offline.migrating.creating')}</span>
489
-
</div>
490
-
<div class="progress-item" class:active={flow.state.step === 'importing'}>
491
-
<span class="icon">○</span>
492
-
<span>{$_('migration.offline.migrating.importing')}</span>
493
-
</div>
494
-
</div>
495
-
496
-
<p class="status-text">{flow.state.progress.currentOperation}</p>
497
-
</div>
437
+
<ProgressStep
438
+
title={$_('migration.offline.migrating.title')}
439
+
description={$_('migration.offline.migrating.desc')}
440
+
items={[
441
+
{ label: $_('migration.offline.migrating.creating'), completed: flow.state.step !== 'creating', active: flow.state.step === 'creating' },
442
+
{ label: $_('migration.offline.migrating.importing'), completed: false, active: flow.state.step === 'importing' },
443
+
]}
444
+
statusText={flow.state.progress.currentOperation}
445
+
/>
498
446
499
447
{:else if flow.state.step === 'migrating-blobs'}
500
-
<div class="step-content">
501
-
<h2>{$_('migration.offline.blobs.title')}</h2>
502
-
<p>{$_('migration.offline.blobs.desc')}</p>
503
-
504
-
<div class="progress-section">
505
-
<div class="progress-item completed">
506
-
<span class="icon">✓</span>
507
-
<span>{$_('migration.offline.migrating.importing')}</span>
508
-
</div>
509
-
<div class="progress-item active">
510
-
<span class="icon">○</span>
511
-
<span>{$_('migration.offline.blobs.migrating')}</span>
512
-
</div>
513
-
</div>
514
-
515
-
{#if flow.state.progress.blobsTotal > 0}
516
-
<div class="blob-progress">
517
-
<div class="blob-progress-bar">
518
-
<div
519
-
class="blob-progress-fill"
520
-
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
521
-
></div>
522
-
</div>
523
-
<p class="blob-progress-text">
524
-
{flow.state.progress.blobsMigrated} / {flow.state.progress.blobsTotal} blobs
525
-
</p>
526
-
</div>
527
-
{/if}
528
-
529
-
<p class="status-text">{flow.state.progress.currentOperation}</p>
530
-
448
+
<ProgressStep
449
+
title={$_('migration.offline.blobs.title')}
450
+
description={$_('migration.offline.blobs.desc')}
451
+
items={[
452
+
{ label: $_('migration.offline.migrating.importing'), completed: true },
453
+
{ label: `${$_('migration.offline.blobs.migrating')} (${flow.state.progress.blobsMigrated}/${flow.state.progress.blobsTotal})`, completed: false, active: true },
454
+
]}
455
+
statusText={flow.state.progress.currentOperation}
456
+
progressBar={flow.state.progress.blobsTotal > 0 ? { current: flow.state.progress.blobsMigrated, total: flow.state.progress.blobsTotal } : undefined}
457
+
>
531
458
{#if flow.state.progress.blobsFailed.length > 0}
532
459
<div class="warning-box">
533
460
<strong>{$_('migration.offline.blobs.failedTitle')}</strong>
534
461
<p>{$_('migration.offline.blobs.failedDesc', { values: { count: flow.state.progress.blobsFailed.length } })}</p>
535
462
</div>
536
463
{/if}
537
-
</div>
464
+
</ProgressStep>
538
465
539
466
{:else if flow.state.step === 'email-verify'}
540
467
<EmailVerifyStep
541
-
email={flow.state.targetEmail}
468
+
channel={flow.state.verificationChannel}
469
+
identifier={verificationIdentifier()}
470
+
handle={flow.state.targetHandle}
542
471
token={flow.state.emailVerifyToken}
543
472
{loading}
544
473
error={flow.state.error}
···
565
494
/>
566
495
567
496
{:else if flow.state.step === 'plc-signing' || flow.state.step === 'finalizing'}
568
-
<div class="step-content">
569
-
<h2>{$_('migration.inbound.finalizing.title')}</h2>
570
-
<p>{$_('migration.inbound.finalizing.desc')}</p>
571
-
572
-
<div class="progress-section">
573
-
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
574
-
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
575
-
<span>{$_('migration.inbound.finalizing.signingPlc')}</span>
576
-
</div>
577
-
<div class="progress-item" class:completed={flow.state.progress.activated}>
578
-
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
579
-
<span>{$_('migration.inbound.finalizing.activating')}</span>
580
-
</div>
581
-
</div>
582
-
583
-
<p class="status-text">{flow.state.progress.currentOperation}</p>
584
-
</div>
497
+
<ProgressStep
498
+
title={$_('migration.inbound.finalizing.title')}
499
+
description={$_('migration.inbound.finalizing.desc')}
500
+
items={[
501
+
{ label: $_('migration.inbound.finalizing.signingPlc'), completed: flow.state.progress.plcSigned },
502
+
{ label: $_('migration.inbound.finalizing.activating'), completed: flow.state.progress.activated },
503
+
]}
504
+
statusText={flow.state.progress.currentOperation}
505
+
/>
585
506
586
507
{:else if flow.state.step === 'success'}
587
508
<SuccessStep
+46
frontend/src/components/migration/ProgressStep.svelte
+46
frontend/src/components/migration/ProgressStep.svelte
···
1
+
<script lang="ts">
2
+
import type { Snippet } from 'svelte'
3
+
4
+
interface ProgressItem {
5
+
label: string
6
+
completed: boolean
7
+
active?: boolean
8
+
}
9
+
10
+
interface Props {
11
+
title: string
12
+
description: string
13
+
items: ProgressItem[]
14
+
statusText: string
15
+
progressBar?: { current: number; total: number }
16
+
children?: Snippet
17
+
}
18
+
19
+
let { title, description, items, statusText, progressBar, children }: Props = $props()
20
+
</script>
21
+
22
+
<div class="step-content">
23
+
<h2>{title}</h2>
24
+
<p>{description}</p>
25
+
26
+
<div class="progress-section">
27
+
{#each items as item}
28
+
<div class="progress-item" class:completed={item.completed} class:active={item.active}>
29
+
<span class="icon">{item.completed ? '✓' : '○'}</span>
30
+
<span>{item.label}</span>
31
+
</div>
32
+
{/each}
33
+
</div>
34
+
35
+
{#if progressBar && progressBar.total > 0}
36
+
<div class="progress-bar">
37
+
<div class="progress-fill" style="width: {(progressBar.current / progressBar.total) * 100}%"></div>
38
+
</div>
39
+
{/if}
40
+
41
+
<p class="status-text">{statusText}</p>
42
+
43
+
{#if children}
44
+
{@render children()}
45
+
{/if}
46
+
</div>
+48
frontend/src/components/migration/ReviewStep.svelte
+48
frontend/src/components/migration/ReviewStep.svelte
···
1
+
<script lang="ts">
2
+
import type { Snippet } from 'svelte'
3
+
import { _ } from '../../lib/i18n'
4
+
5
+
interface ReviewRow {
6
+
label: string
7
+
value: string
8
+
mono?: boolean
9
+
}
10
+
11
+
interface Props {
12
+
description: string
13
+
rows: ReviewRow[]
14
+
loading: boolean
15
+
onBack: () => void
16
+
onContinue: () => void
17
+
warning?: Snippet
18
+
}
19
+
20
+
let { description, rows, loading, onBack, onContinue, warning }: Props = $props()
21
+
</script>
22
+
23
+
<div class="step-content">
24
+
<h2>{$_('migration.inbound.review.title')}</h2>
25
+
<p>{description}</p>
26
+
27
+
<div class="review-card">
28
+
{#each rows as row}
29
+
<div class="review-row">
30
+
<span class="label">{row.label}:</span>
31
+
<span class="value" class:mono={row.mono}>{row.value}</span>
32
+
</div>
33
+
{/each}
34
+
</div>
35
+
36
+
{#if warning}
37
+
<div class="warning-box">
38
+
{@render warning()}
39
+
</div>
40
+
{/if}
41
+
42
+
<div class="button-row">
43
+
<button class="ghost" onclick={onBack} disabled={loading}>{$_('migration.inbound.common.back')}</button>
44
+
<button onclick={onContinue} disabled={loading}>
45
+
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
46
+
</button>
47
+
</div>
48
+
</div>
+22
frontend/src/components/migration/StepIndicator.svelte
+22
frontend/src/components/migration/StepIndicator.svelte
···
1
+
<script lang="ts">
2
+
interface Props {
3
+
steps: string[]
4
+
currentIndex: number
5
+
}
6
+
7
+
let { steps, currentIndex }: Props = $props()
8
+
</script>
9
+
10
+
<div class="step-indicator">
11
+
{#each steps as _, i}
12
+
<div class="step" class:active={i === currentIndex} class:completed={i < currentIndex}>
13
+
<div class="step-dot">{i < currentIndex ? '✓' : i + 1}</div>
14
+
</div>
15
+
{#if i < steps.length - 1}
16
+
<div class="step-line" class:completed={i < currentIndex}></div>
17
+
{/if}
18
+
{/each}
19
+
</div>
20
+
<div class="current-step-label">
21
+
<strong>{steps[currentIndex]}</strong> · Step {currentIndex + 1} of {steps.length}
22
+
</div>
+1
-93
frontend/src/styles/migration.css
+1
-93
frontend/src/styles/migration.css
···
92
92
margin: 0 0 var(--space-5) 0;
93
93
}
94
94
95
-
.info-box {
96
-
background: var(--accent-muted);
97
-
padding: var(--space-5);
98
-
margin-bottom: var(--space-5);
99
-
}
100
-
101
95
.info-box h3 {
102
96
margin: 0 0 var(--space-3) 0;
103
97
font-size: var(--text-base);
···
119
113
color: var(--text-secondary);
120
114
}
121
115
122
-
.warning-box {
123
-
background: var(--warning-bg);
124
-
padding: var(--space-5);
125
-
margin-bottom: var(--space-5);
126
-
font-size: var(--text-sm);
127
-
}
128
-
129
116
.warning-box strong {
130
117
color: var(--warning-text);
131
118
}
···
158
145
159
146
.button-row {
160
147
display: flex;
148
+
flex-direction: row;
161
149
gap: var(--space-3);
162
150
justify-content: flex-end;
163
151
margin-top: var(--space-5);
···
260
248
font-size: var(--text-sm);
261
249
}
262
250
263
-
.blob-progress {
264
-
margin: var(--space-4) 0;
265
-
}
266
-
267
-
.blob-progress-bar {
268
-
height: 8px;
269
-
background: var(--bg-primary);
270
-
overflow: hidden;
271
-
margin-bottom: var(--space-2);
272
-
}
273
-
274
-
.blob-progress-fill {
275
-
height: 100%;
276
-
background: var(--accent);
277
-
}
278
-
279
-
.blob-progress-text {
280
-
text-align: center;
281
-
color: var(--text-secondary);
282
-
font-size: var(--text-sm);
283
-
margin: 0;
284
-
}
285
251
286
252
.success-content {
287
253
text-align: center;
···
411
377
color: var(--text-secondary);
412
378
}
413
379
414
-
.app-password-display {
415
-
background: var(--bg-primary);
416
-
padding: var(--space-5);
417
-
margin-bottom: var(--space-5);
418
-
text-align: center;
419
-
}
420
-
421
-
.app-password-label {
422
-
font-size: var(--text-sm);
423
-
color: var(--text-secondary);
424
-
margin-bottom: var(--space-3);
425
-
}
426
-
427
-
.app-password-code {
428
-
display: block;
429
-
font-family: var(--font-mono);
430
-
font-size: var(--text-lg);
431
-
letter-spacing: 0.1em;
432
-
padding: var(--space-4);
433
-
background: var(--bg-tertiary);
434
-
margin-bottom: var(--space-4);
435
-
user-select: all;
436
-
}
437
-
438
-
.copy-btn {
439
-
font-size: var(--text-sm);
440
-
}
441
-
442
-
.current-account {
443
-
background: var(--bg-primary);
444
-
padding: var(--space-4);
445
-
margin-bottom: var(--space-5);
446
-
display: flex;
447
-
justify-content: space-between;
448
-
align-items: center;
449
-
}
450
-
451
380
.current-account .label {
452
381
color: var(--text-secondary);
453
382
}
···
457
386
font-size: var(--text-lg);
458
387
}
459
388
460
-
.server-info {
461
-
background: var(--bg-primary);
462
-
padding: var(--space-4);
463
-
margin-top: var(--space-5);
464
-
}
465
-
466
389
.server-info h3 {
467
390
margin: 0 0 var(--space-3) 0;
468
391
font-size: var(--text-base);
···
488
411
font-size: var(--text-sm);
489
412
}
490
413
491
-
.final-warning {
492
-
background: var(--error-bg);
493
-
border-color: var(--error-border);
494
-
}
495
-
496
414
.final-warning strong {
497
415
color: var(--error-text);
498
416
}
···
596
514
margin-bottom: var(--space-4);
597
515
}
598
516
599
-
.message.success {
600
-
background: var(--success-bg);
601
-
color: var(--success-text);
602
-
}
603
-
604
-
.message.error {
605
-
background: var(--error-bg);
606
-
color: var(--error-text);
607
-
}
608
-
609
517
.handle-choice-options {
610
518
display: flex;
611
519
flex-direction: column;
History
3 rounds
0 comments
oyster.cafe
submitted
#2
1 commit
expand
collapse
refactor(frontend): refactor migration components
expand 0 comments
pull request successfully merged
oyster.cafe
submitted
#1
1 commit
expand
collapse
refactor(frontend): refactor migration components
expand 0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
refactor(frontend): refactor migration components