Our Personal Data Server from scratch!
tranquil.farm
oauth
atproto
pds
rust
postgresql
objectstorage
fun
1<script lang="ts">
2 import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
3 import { _ } from '../../lib/i18n'
4 import HandleInput from '../HandleInput.svelte'
5
6 interface Props {
7 handleInput: string
8 selectedDomain: string
9 handleAvailable: boolean | null
10 checkingHandle: boolean
11 email: string
12 password: string
13 authMethod: AuthMethod
14 inviteCode: string
15 serverInfo: ServerDescription | null
16 migratingFromLabel: string
17 migratingFromValue: string
18 loading?: boolean
19 sourceHandle: string
20 sourceDid: string
21 handlePreservation: HandlePreservation
22 existingHandleVerified: boolean
23 verifyingExistingHandle?: boolean
24 existingHandleError?: string | null
25 onHandleChange: (handle: string) => void
26 onDomainChange: (domain: string) => void
27 onCheckHandle: () => void
28 onEmailChange: (email: string) => void
29 onPasswordChange: (password: string) => void
30 onAuthMethodChange: (method: AuthMethod) => void
31 onInviteCodeChange: (code: string) => void
32 onHandlePreservationChange?: (preservation: HandlePreservation) => void
33 onVerifyExistingHandle?: () => void
34 onBack: () => void
35 onContinue: () => void
36 }
37
38 let {
39 handleInput,
40 selectedDomain,
41 handleAvailable,
42 checkingHandle,
43 email,
44 password,
45 authMethod,
46 inviteCode,
47 serverInfo,
48 migratingFromLabel,
49 migratingFromValue,
50 loading = false,
51 sourceHandle,
52 sourceDid,
53 handlePreservation,
54 existingHandleVerified,
55 verifyingExistingHandle = false,
56 existingHandleError = null,
57 onHandleChange,
58 onDomainChange,
59 onCheckHandle,
60 onEmailChange,
61 onPasswordChange,
62 onAuthMethodChange,
63 onInviteCodeChange,
64 onHandlePreservationChange,
65 onVerifyExistingHandle,
66 onBack,
67 onContinue,
68 }: Props = $props()
69
70 const handleTooShort = $derived(handleInput.trim().length > 0 && handleInput.trim().length < 3)
71
72 const isExternalHandle = $derived(
73 serverInfo != null &&
74 sourceHandle.includes('.') &&
75 !serverInfo.availableUserDomains.some(d => sourceHandle.endsWith(`.${d}`))
76 )
77
78 const canContinue = $derived(
79 email &&
80 (authMethod === 'passkey' || password) &&
81 (
82 (handlePreservation === 'existing' && existingHandleVerified) ||
83 (handlePreservation === 'new' && handleInput.trim().length >= 3 && handleAvailable !== false)
84 )
85 )
86</script>
87
88<div class="step-content">
89 <h2>{$_('migration.inbound.chooseHandle.title')}</h2>
90 <p>{$_('migration.inbound.chooseHandle.desc')}</p>
91
92 <div class="current-info">
93 <span class="label">{migratingFromLabel}:</span>
94 <span class="value">{migratingFromValue}</span>
95 </div>
96
97 {#if isExternalHandle}
98 <div class="field">
99 <span class="field-label">{$_('migration.inbound.chooseHandle.handleChoice')}</span>
100 <div class="handle-choice-options">
101 <label class="handle-choice-option" class:selected={handlePreservation === 'existing'}>
102 <input
103 type="radio"
104 name="handle-preservation"
105 value="existing"
106 checked={handlePreservation === 'existing'}
107 onchange={() => onHandlePreservationChange?.('existing')}
108 />
109 <div class="handle-choice-content">
110 <strong>{$_('migration.inbound.chooseHandle.keepExisting')}</strong>
111 <span class="handle-preview">@{sourceHandle}</span>
112 </div>
113 </label>
114 <label class="handle-choice-option" class:selected={handlePreservation === 'new'}>
115 <input
116 type="radio"
117 name="handle-preservation"
118 value="new"
119 checked={handlePreservation === 'new'}
120 onchange={() => onHandlePreservationChange?.('new')}
121 />
122 <div class="handle-choice-content">
123 <strong>{$_('migration.inbound.chooseHandle.createNew')}</strong>
124 </div>
125 </label>
126 </div>
127 </div>
128 {/if}
129
130 {#if handlePreservation === 'existing' && isExternalHandle}
131 <div class="field">
132 <span class="field-label">{$_('migration.inbound.chooseHandle.existingHandle')}</span>
133 <div class="existing-handle-display">
134 <span class="handle-value">@{sourceHandle}</span>
135 {#if existingHandleVerified}
136 <span class="verified-badge">{$_('migration.inbound.chooseHandle.verified')}</span>
137 {/if}
138 </div>
139
140 {#if !existingHandleVerified}
141 <div class="verification-instructions">
142 <p class="instruction-header">{$_('migration.inbound.chooseHandle.verifyInstructions')}</p>
143 <div class="verification-record">
144 <code>_atproto.{sourceHandle} TXT "did={sourceDid}"</code>
145 </div>
146 <p class="instruction-or">{$_('migration.inbound.chooseHandle.or')}</p>
147 <div class="verification-record">
148 <code>https://{sourceHandle}/.well-known/atproto-did</code>
149 <span class="record-content">{$_('migration.inbound.chooseHandle.returning')} <code>{sourceDid}</code></span>
150 </div>
151 </div>
152
153 <button
154 class="verify-btn"
155 onclick={() => onVerifyExistingHandle?.()}
156 disabled={verifyingExistingHandle}
157 >
158 {#if verifyingExistingHandle}
159 {$_('migration.inbound.chooseHandle.verifying')}
160 {:else if existingHandleError}
161 {$_('migration.inbound.chooseHandle.checkAgain')}
162 {:else}
163 {$_('migration.inbound.chooseHandle.verifyOwnership')}
164 {/if}
165 </button>
166
167 {#if existingHandleError}
168 <p class="hint error">{existingHandleError}</p>
169 {/if}
170 {/if}
171 </div>
172 {:else}
173 <div class="field">
174 <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
175 <HandleInput
176 id="new-handle"
177 value={handleInput}
178 domains={serverInfo?.availableUserDomains ?? []}
179 {selectedDomain}
180 placeholder="username"
181 onInput={onHandleChange}
182 onDomainChange={onDomainChange}
183 />
184
185 {#if handleTooShort}
186 <p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
187 {:else if checkingHandle}
188 <p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
189 {:else if handleAvailable === true}
190 <p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
191 {:else if handleAvailable === false}
192 <p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
193 {:else}
194 <p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
195 {/if}
196 </div>
197 {/if}
198
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>
210
211 <div class="field">
212 <span class="field-label">{$_('migration.inbound.chooseHandle.authMethod')}</span>
213 <div class="auth-method-options">
214 <label class="auth-option" class:selected={authMethod === 'password'}>
215 <input
216 type="radio"
217 name="auth-method"
218 value="password"
219 checked={authMethod === 'password'}
220 onchange={() => onAuthMethodChange('password')}
221 />
222 <div class="auth-option-content">
223 <strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong>
224 <span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span>
225 </div>
226 </label>
227 <label class="auth-option" class:selected={authMethod === 'passkey'}>
228 <input
229 type="radio"
230 name="auth-method"
231 value="passkey"
232 checked={authMethod === 'passkey'}
233 onchange={() => onAuthMethodChange('passkey')}
234 />
235 <div class="auth-option-content">
236 <strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong>
237 <span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span>
238 </div>
239 </label>
240 </div>
241 </div>
242
243 {#if authMethod === 'password'}
244 <div class="field">
245 <label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label>
246 <input
247 id="new-password"
248 type="password"
249 placeholder="Password for your new account"
250 value={password}
251 oninput={(e) => onPasswordChange((e.target as HTMLInputElement).value)}
252 required
253 minlength={8}
254 />
255 <p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p>
256 </div>
257 {:else}
258 <div class="info-box">
259 <p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p>
260 </div>
261 {/if}
262
263 {#if serverInfo?.inviteCodeRequired}
264 <div class="field">
265 <label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label>
266 <input
267 id="invite"
268 type="text"
269 placeholder="Enter invite code"
270 value={inviteCode}
271 oninput={(e) => onInviteCodeChange((e.target as HTMLInputElement).value)}
272 required
273 />
274 </div>
275 {/if}
276
277 <div class="button-row">
278 <button class="ghost" onclick={onBack} disabled={loading}>{$_('migration.inbound.common.back')}</button>
279 <button disabled={!canContinue || loading} onclick={onContinue}>
280 {$_('migration.inbound.common.continue')}
281 </button>
282 </div>
283</div>
284
285<style>
286 .handle-choice-options {
287 display: flex;
288 flex-direction: column;
289 gap: var(--space-3);
290 }
291
292 .handle-choice-option {
293 display: flex;
294 align-items: center;
295 gap: var(--space-3);
296 padding: var(--space-4);
297 border: 1px solid var(--border-color);
298 border-radius: var(--radius-lg);
299 cursor: pointer;
300 transition: border-color var(--transition-normal), background var(--transition-normal);
301 }
302
303 .handle-choice-option:hover {
304 border-color: var(--accent);
305 }
306
307 .handle-choice-option.selected {
308 border-color: var(--accent);
309 background: var(--accent-muted);
310 }
311
312 .handle-choice-option input[type="radio"] {
313 flex-shrink: 0;
314 width: 18px;
315 height: 18px;
316 margin: 0;
317 }
318
319 .handle-choice-content {
320 display: flex;
321 flex-direction: column;
322 gap: var(--space-1);
323 }
324
325 .handle-preview {
326 font-family: var(--font-mono);
327 font-size: var(--text-sm);
328 color: var(--text-secondary);
329 }
330
331 .existing-handle-display {
332 display: flex;
333 align-items: center;
334 gap: var(--space-4);
335 padding: var(--space-4);
336 background: var(--bg-secondary);
337 border-radius: var(--radius-lg);
338 margin-bottom: var(--space-4);
339 }
340
341 .handle-value {
342 font-family: var(--font-mono);
343 font-size: var(--text-base);
344 }
345
346 .verified-badge {
347 font-size: var(--text-xs);
348 padding: var(--space-1) var(--space-3);
349 background: var(--success-bg);
350 color: var(--success-text);
351 border-radius: var(--radius-md);
352 }
353
354 .verification-instructions {
355 background: var(--bg-secondary);
356 padding: var(--space-5);
357 border-radius: var(--radius-lg);
358 margin-bottom: var(--space-4);
359 }
360
361 .instruction-header {
362 margin: 0 0 var(--space-4) 0;
363 font-size: var(--text-sm);
364 color: var(--text-secondary);
365 }
366
367 .instruction-or {
368 margin: var(--space-3) 0;
369 font-size: var(--text-xs);
370 color: var(--text-muted);
371 text-align: center;
372 }
373
374 .verification-record {
375 display: flex;
376 flex-direction: column;
377 gap: var(--space-2);
378 }
379
380 .verification-record code {
381 font-size: var(--text-sm);
382 padding: var(--space-3);
383 background: var(--bg-tertiary);
384 border-radius: var(--radius-md);
385 overflow-x: auto;
386 word-break: break-all;
387 }
388
389 .record-content {
390 font-size: var(--text-xs);
391 color: var(--text-secondary);
392 padding-left: var(--space-3);
393 }
394
395 .record-content code {
396 padding: var(--space-1) var(--space-2);
397 font-size: var(--text-xs);
398 }
399
400 .verify-btn {
401 width: 100%;
402 }
403</style>