Files
tranquil-pds/frontend/src/components/migration/ChooseHandleStep.svelte
T

325 lines
11 KiB
Svelte

<script lang="ts">
import type { AuthMethod, HandlePreservation, ServerDescription, VerificationChannel } from '../../lib/migration/types'
import type { VerificationChannel as ApiVerificationChannel } from '../../lib/types/api'
import { _ } from '../../lib/i18n'
import HandleInput from '../HandleInput.svelte'
import CommsChannelPicker from '../CommsChannelPicker.svelte'
interface Props {
handleInput: string
selectedDomain: string
email: string
password: string
authMethod: AuthMethod
inviteCode: string
serverInfo: ServerDescription | null
availableCommsChannels: ApiVerificationChannel[]
verificationChannel: VerificationChannel
discordUsername: string
telegramUsername: string
signalUsername: string
migratingFromLabel: string
migratingFromValue: string
loading?: boolean
sourceHandle: string
sourceDid: string
sourcePdsDomains?: string[]
handlePreservation: HandlePreservation
existingHandleVerified: boolean
verifyingExistingHandle?: boolean
existingHandleError?: string | null
checkAvailability: (fullHandle: string) => Promise<boolean>
onHandleChange: (handle: string) => void
onDomainChange: (domain: string) => void
onEmailChange: (email: string) => void
onPasswordChange: (password: string) => void
onAuthMethodChange: (method: AuthMethod) => void
onInviteCodeChange: (code: string) => void
onVerificationChannelChange: (channel: VerificationChannel) => void
onDiscordChange: (value: string) => void
onTelegramChange: (value: string) => void
onSignalChange: (value: string) => void
onHandlePreservationChange?: (preservation: HandlePreservation) => void
onVerifyExistingHandle?: () => void
onBack: () => void
onContinue: () => void
}
let {
handleInput,
selectedDomain,
email,
password,
authMethod,
inviteCode,
serverInfo,
availableCommsChannels,
verificationChannel,
discordUsername,
telegramUsername,
signalUsername,
migratingFromLabel,
migratingFromValue,
loading = false,
sourceHandle,
sourceDid,
sourcePdsDomains = [],
handlePreservation,
existingHandleVerified,
verifyingExistingHandle = false,
existingHandleError = null,
checkAvailability,
onHandleChange,
onDomainChange,
onEmailChange,
onPasswordChange,
onAuthMethodChange,
onInviteCodeChange,
onVerificationChannelChange,
onDiscordChange,
onTelegramChange,
onSignalChange,
onHandlePreservationChange,
onVerifyExistingHandle,
onBack,
onContinue,
}: Props = $props()
let handleAvailable = $state<boolean | null>(null)
let checkingHandle = $state(false)
const handleTooShort = $derived(handleInput.trim().length > 0 && handleInput.trim().length < 3)
const isSourcePdsManaged = $derived(
sourcePdsDomains.length > 0 &&
sourceHandle.includes('.') &&
sourcePdsDomains.some(d => sourceHandle.endsWith(`.${d}`))
)
const isExternalHandle = $derived(
sourceHandle.includes('.') &&
!isSourcePdsManaged &&
serverInfo != null &&
!serverInfo.availableUserDomains.some(d => sourceHandle.endsWith(`.${d}`))
)
const hasVerificationIdentifier = $derived(
(verificationChannel === 'email' && email.trim().length > 0) ||
(verificationChannel === 'discord' && discordUsername.trim().length > 0) ||
(verificationChannel === 'telegram' && telegramUsername.trim().length > 0) ||
(verificationChannel === 'signal' && signalUsername.trim().length > 0)
)
const canContinue = $derived(
hasVerificationIdentifier &&
(authMethod === 'passkey' || password) &&
(
(handlePreservation === 'existing' && existingHandleVerified) ||
(handlePreservation === 'new' && handleInput.trim().length >= 3 && handleAvailable !== false)
)
)
</script>
<div class="step-content">
<h2>{$_('migration.inbound.chooseHandle.title')}</h2>
<p>{$_('migration.inbound.chooseHandle.desc')}</p>
<div class="current-info">
<span class="label">{migratingFromLabel}:</span>
<span class="value">{migratingFromValue}</span>
</div>
{#if isExternalHandle}
<div class="field">
<span class="field-label">{$_('migration.inbound.chooseHandle.handleChoice')}</span>
<div class="handle-choice-options">
<label class="handle-choice-option" class:selected={handlePreservation === 'existing'}>
<input
type="radio"
name="handle-preservation"
value="existing"
checked={handlePreservation === 'existing'}
onchange={() => onHandlePreservationChange?.('existing')}
/>
<div class="handle-choice-content">
<strong>{$_('migration.inbound.chooseHandle.keepExisting')}</strong>
<span class="handle-preview">@{sourceHandle}</span>
</div>
</label>
<label class="handle-choice-option" class:selected={handlePreservation === 'new'}>
<input
type="radio"
name="handle-preservation"
value="new"
checked={handlePreservation === 'new'}
onchange={() => onHandlePreservationChange?.('new')}
/>
<div class="handle-choice-content">
<strong>{$_('migration.inbound.chooseHandle.createNew')}</strong>
</div>
</label>
</div>
</div>
{/if}
{#if handlePreservation === 'existing' && isExternalHandle}
<div class="field">
<span class="field-label">{$_('migration.inbound.chooseHandle.existingHandle')}</span>
<div class="existing-handle-display">
<span class="handle-value">@{sourceHandle}</span>
{#if existingHandleVerified}
<span class="verified-badge">{$_('migration.inbound.chooseHandle.verified')}</span>
{/if}
</div>
{#if !existingHandleVerified}
<div class="verification-instructions">
<p class="instruction-header">{$_('migration.inbound.chooseHandle.verifyInstructions')}</p>
<div class="verification-record">
<code>_atproto.{sourceHandle} TXT "did={sourceDid}"</code>
</div>
<p class="instruction-or">{$_('migration.inbound.chooseHandle.or')}</p>
<div class="verification-record">
<code>https://{sourceHandle}/.well-known/atproto-did</code>
<span class="record-content">{$_('migration.inbound.chooseHandle.returning')} <code>{sourceDid}</code></span>
</div>
</div>
<button
class="verify-btn"
onclick={() => onVerifyExistingHandle?.()}
disabled={verifyingExistingHandle}
>
{#if verifyingExistingHandle}
{$_('migration.inbound.chooseHandle.verifying')}
{:else if existingHandleError}
{$_('migration.inbound.chooseHandle.checkAgain')}
{:else}
{$_('migration.inbound.chooseHandle.verifyOwnership')}
{/if}
</button>
{#if existingHandleError}
<p class="hint error">{existingHandleError}</p>
{/if}
{/if}
</div>
{:else}
<div class="field">
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
<HandleInput
id="new-handle"
value={handleInput}
domains={serverInfo?.availableUserDomains ?? []}
{selectedDomain}
placeholder="username"
{checkAvailability}
bind:available={handleAvailable}
bind:checking={checkingHandle}
onInput={onHandleChange}
onDomainChange={onDomainChange}
/>
{#if handleTooShort}
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
{:else if checkingHandle}
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
{:else if handleAvailable === true}
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
{:else if handleAvailable === false}
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
{:else}
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
{/if}
</div>
{/if}
<CommsChannelPicker
channel={verificationChannel}
{email}
{discordUsername}
{telegramUsername}
{signalUsername}
availableChannels={availableCommsChannels}
disabled={loading}
onChannelChange={onVerificationChannelChange}
onEmailChange={onEmailChange}
onDiscordChange={onDiscordChange}
onTelegramChange={onTelegramChange}
onSignalChange={onSignalChange}
/>
<div class="field">
<span class="field-label">{$_('migration.inbound.chooseHandle.authMethod')}</span>
<div class="auth-method-options">
<label class="auth-option" class:selected={authMethod === 'password'}>
<input
type="radio"
name="auth-method"
value="password"
checked={authMethod === 'password'}
onchange={() => onAuthMethodChange('password')}
/>
<div class="auth-option-content">
<strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong>
<span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span>
</div>
</label>
<label class="auth-option" class:selected={authMethod === 'passkey'}>
<input
type="radio"
name="auth-method"
value="passkey"
checked={authMethod === 'passkey'}
onchange={() => onAuthMethodChange('passkey')}
/>
<div class="auth-option-content">
<strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong>
<span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span>
</div>
</label>
</div>
</div>
{#if authMethod === 'password'}
<div class="field">
<label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label>
<input
id="new-password"
type="password"
placeholder="Password for your new account"
value={password}
oninput={(e) => onPasswordChange((e.target as HTMLInputElement).value)}
required
minlength={8}
/>
<p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p>
</div>
{:else}
<div class="info-box">
<p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p>
</div>
{/if}
{#if serverInfo?.inviteCodeRequired}
<div class="field">
<label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label>
<input
id="invite"
type="text"
placeholder="Enter invite code"
value={inviteCode}
oninput={(e) => onInviteCodeChange((e.target as HTMLInputElement).value)}
required
/>
</div>
{/if}
<div class="button-row">
<button class="ghost" onclick={onBack} disabled={loading}>{$_('migration.inbound.common.back')}</button>
<button disabled={!canContinue || loading} onclick={onContinue}>
{$_('migration.inbound.common.continue')}
</button>
</div>
</div>