Our Personal Data Server from scratch! tranquil.farm
atproto pds rust postgresql fun oauth

refactor(frontend): refactor migration components #75

merged opened by oyster.cafe targeting main from refactor/frontend-deletions
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mhg53l5vkn22
+431 -480
Diff #2
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
sign up or login to add to the discussion
1 commit
expand
refactor(frontend): refactor migration components
expand 0 comments
pull request successfully merged
1 commit
expand
refactor(frontend): refactor migration components
expand 0 comments
1 commit
expand
refactor(frontend): refactor migration components
expand 0 comments