Graphical PDS migrator for AT Protocol
1import { useState, useEffect } from "preact/hooks";
2import { IS_BROWSER } from "fresh/runtime";
3
4/**
5 * The migration setup props.
6 * @type {MigrationSetupProps}
7 */
8interface MigrationSetupProps {
9 service?: string | null;
10 handle?: string | null;
11 email?: string | null;
12 invite?: string | null;
13}
14
15/**
16 * The server description.
17 * @type {ServerDescription}
18 */
19interface ServerDescription {
20 inviteCodeRequired: boolean;
21 availableUserDomains: string[];
22}
23
24/**
25 * The user passport.
26 * @type {UserPassport}
27 */
28interface UserPassport {
29 did: string;
30 handle: string;
31 pds: string;
32 createdAt?: string;
33}
34
35/**
36 * The migration state info.
37 * @type {MigrationStateInfo}
38 */
39interface MigrationStateInfo {
40 state: "up" | "issue" | "maintenance";
41 message: string;
42 allowMigration: boolean;
43}
44
45/**
46 * The migration setup component.
47 * @param props - The migration setup props
48 * @returns The migration setup component
49 * @component
50 */
51export default function MigrationSetup(props: MigrationSetupProps) {
52 const [service, setService] = useState(props.service || "");
53 const [handlePrefix, setHandlePrefix] = useState(
54 props.handle?.split(".")[0] || "",
55 );
56 const [selectedDomain, setSelectedDomain] = useState("");
57 const [email, setEmail] = useState(props.email || "");
58 const [password, setPassword] = useState("");
59 const [invite, setInvite] = useState(props.invite || "");
60 const [inviteRequired, setInviteRequired] = useState<boolean | null>(null);
61 const [availableDomains, setAvailableDomains] = useState<string[]>([]);
62 const [error, setError] = useState("");
63 const [isLoading, setIsLoading] = useState(false);
64 const [showConfirmation, setShowConfirmation] = useState(false);
65 const [confirmationText, setConfirmationText] = useState("");
66 const [passport, setPassport] = useState<UserPassport | null>(null);
67 const [migrationState, setMigrationState] = useState<MigrationStateInfo | null>(null);
68
69 const ensureServiceUrl = (url: string): string => {
70 if (!url) return url;
71 try {
72 // If it already has a protocol, return as is
73 new URL(url);
74 return url;
75 } catch {
76 // If no protocol, add https://
77 return `https://${url}`;
78 }
79 };
80
81 useEffect(() => {
82 if (!IS_BROWSER) return;
83
84 const fetchInitialData = async () => {
85 try {
86 // Check migration state first
87 const migrationResponse = await fetch("/api/migration-state");
88 if (migrationResponse.ok) {
89 const migrationData = await migrationResponse.json();
90 setMigrationState(migrationData);
91 }
92
93 // Fetch user passport
94 const response = await fetch("/api/me", {
95 credentials: "include",
96 });
97 if (!response.ok) {
98 throw new Error("Failed to fetch user profile");
99 }
100 const userData = await response.json();
101 if (userData) {
102 // Get PDS URL from the current service
103 const pdsResponse = await fetch(`/api/resolve-pds?did=${userData.did}`);
104 const pdsData = await pdsResponse.json();
105
106 setPassport({
107 did: userData.did,
108 handle: userData.handle,
109 pds: pdsData.pds || "Unknown",
110 createdAt: new Date().toISOString() // TODO: Get actual creation date from API
111 });
112 }
113 } catch (error) {
114 console.error("Failed to fetch initial data:", error);
115 }
116 };
117
118 fetchInitialData();
119 }, []);
120
121 const checkServerDescription = async (serviceUrl: string) => {
122 try {
123 setIsLoading(true);
124 const response = await fetch(
125 `${serviceUrl}/xrpc/com.atproto.server.describeServer`,
126 );
127 if (!response.ok) {
128 throw new Error("Failed to fetch server description");
129 }
130 const data: ServerDescription = await response.json();
131 setInviteRequired(data.inviteCodeRequired ?? false);
132 const domains = data.availableUserDomains || [];
133 setAvailableDomains(domains);
134 if (domains.length === 1) {
135 setSelectedDomain(domains[0]);
136 } else if (domains.length > 1) {
137 setSelectedDomain(domains[0]);
138 }
139 } catch (err) {
140 console.error("Failed to check server description:", err);
141 setError(
142 "Failed to connect to server. Please check the URL and try again.",
143 );
144 setInviteRequired(false);
145 setAvailableDomains([]);
146 } finally {
147 setIsLoading(false);
148 }
149 };
150
151 const handleServiceChange = (value: string) => {
152 const urlWithProtocol = ensureServiceUrl(value);
153 setService(urlWithProtocol);
154 setError("");
155 if (urlWithProtocol) {
156 checkServerDescription(urlWithProtocol);
157 } else {
158 setAvailableDomains([]);
159 setSelectedDomain("");
160 }
161 };
162
163 const handleSubmit = (e: Event) => {
164 e.preventDefault();
165
166 // Check migration state first
167 if (migrationState && !migrationState.allowMigration) {
168 setError(migrationState.message);
169 return;
170 }
171
172 if (!service || !handlePrefix || !email || !password) {
173 setError("Please fill in all required fields");
174 return;
175 }
176
177 if (inviteRequired && !invite) {
178 setError("Invite code is required for this server");
179 return;
180 }
181
182 setShowConfirmation(true);
183 };
184
185 const handleConfirmation = () => {
186 // Double-check migration state before proceeding
187 if (migrationState && !migrationState.allowMigration) {
188 setError(migrationState.message);
189 return;
190 }
191
192 if (confirmationText !== "MIGRATE") {
193 setError("Please type 'MIGRATE' to confirm");
194 return;
195 }
196
197 const fullHandle = `${handlePrefix}${
198 availableDomains.length === 1
199 ? availableDomains[0]
200 : availableDomains.length > 1
201 ? selectedDomain
202 : ".example.com"
203 }`;
204
205 // Redirect to progress page with parameters
206 const params = new URLSearchParams({
207 service,
208 handle: fullHandle,
209 email,
210 password,
211 ...(invite ? { invite } : {}),
212 });
213 globalThis.location.href = `/migrate/progress?${params.toString()}`;
214 };
215
216 return (
217 <div class="max-w-2xl mx-auto p-6 bg-gradient-to-b from-blue-50 to-white dark:from-gray-800 dark:to-gray-900 rounded-lg shadow-xl relative overflow-hidden">
218 {/* Decorative airport elements */}
219 <div class="absolute top-0 left-0 w-full h-1 bg-blue-500"></div>
220 <div class="absolute top-2 left-4 text-blue-500 text-sm font-mono">TERMINAL 1</div>
221 <div class="absolute top-2 right-4 text-blue-500 text-sm font-mono">GATE M1</div>
222
223 {/* Migration state alert */}
224 {migrationState && !migrationState.allowMigration && (
225 <div class={`mb-6 mt-4 p-4 rounded-lg border ${
226 migrationState.state === "maintenance"
227 ? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200"
228 : "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"
229 }`}>
230 <div class="flex items-center">
231 <div class={`mr-3 ${
232 migrationState.state === "maintenance" ? "text-yellow-600 dark:text-yellow-400" : "text-red-600 dark:text-red-400"
233 }`}>
234 {migrationState.state === "maintenance" ? "⚠️" : "🚫"}
235 </div>
236 <div>
237 <h3 class="font-semibold mb-1">
238 {migrationState.state === "maintenance" ? "Maintenance Mode" : "Service Unavailable"}
239 </h3>
240 <p class="text-sm">{migrationState.message}</p>
241 </div>
242 </div>
243 </div>
244 )}
245
246 <div class="text-center mb-8 relative">
247 <p class="text-gray-600 dark:text-gray-400 mt-4">Please complete your migration check-in</p>
248 <div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono">FLIGHT: MIG-2024</div>
249 </div>
250
251 {/* Passport Section */}
252 {passport && (
253 <div class="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border border-gray-200 dark:border-gray-700">
254 <div class="flex items-center justify-between mb-4">
255 <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Current Passport</h3>
256 <div class="text-xs text-gray-500 dark:text-gray-400 font-mono">ISSUED: {new Date().toLocaleDateString()}</div>
257 </div>
258 <div class="grid grid-cols-2 gap-4 text-sm">
259 <div>
260 <div class="text-gray-500 dark:text-gray-400 mb-1">Handle</div>
261 <div class="font-mono text-gray-900 dark:text-white">{passport.handle}</div>
262 </div>
263 <div>
264 <div class="text-gray-500 dark:text-gray-400 mb-1">DID</div>
265 <div class="font-mono text-gray-900 dark:text-white break-all">{passport.did}</div>
266 </div>
267 <div>
268 <div class="text-gray-500 dark:text-gray-400 mb-1">Citizen of PDS</div>
269 <div class="font-mono text-gray-900 dark:text-white break-all">{passport.pds}</div>
270 </div>
271 <div>
272 <div class="text-gray-500 dark:text-gray-400 mb-1">Account Age</div>
273 <div class="font-mono text-gray-900 dark:text-white">
274 {passport.createdAt ? new Date(passport.createdAt).toLocaleDateString() : "Unknown"}
275 </div>
276 </div>
277 </div>
278 </div>
279 )}
280
281 <form onSubmit={handleSubmit} class="space-y-6">
282 {error && (
283 <div class="bg-red-50 dark:bg-red-900 rounded-lg ">
284 <p class="text-red-800 dark:text-red-200 flex items-center">
285 <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
286 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
287 </svg>
288 {error}
289 </p>
290 </div>
291 )}
292
293 <div class="space-y-4">
294 <div>
295 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
296 Destination Server
297 <span class="text-xs text-gray-500 ml-1">(Final Destination)</span>
298 </label>
299 <div class="relative">
300 <input
301 type="url"
302 value={service}
303 onChange={(e) => handleServiceChange(e.currentTarget.value)}
304 placeholder="https://example.com"
305 required
306 disabled={isLoading}
307 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed pl-10"
308 />
309 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
310 <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
311 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
312 </svg>
313 </div>
314 </div>
315 {isLoading && (
316 <p class="mt-1 text-sm text-gray-500 dark:text-gray-400 flex items-center">
317 <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24">
318 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
319 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
320 </svg>
321 Verifying destination server...
322 </p>
323 )}
324 </div>
325
326 <div>
327 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
328 New Account Handle
329 <span class="text-xs text-gray-500 ml-1">(Passport ID)</span>
330 <div class="inline-block relative group ml-2">
331 <svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-help" fill="currentColor" viewBox="0 0 20 20">
332 <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
333 </svg>
334 <div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10">
335 You can change your handle to a custom domain later
336 <div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
337 </div>
338 </div>
339 </label>
340 <div class="mt-1 relative w-full">
341 <div class="flex rounded-md shadow-sm w-full">
342 <div class="relative flex-1">
343 <input
344 type="text"
345 value={handlePrefix}
346 onChange={(e) => setHandlePrefix(e.currentTarget.value)}
347 placeholder="username"
348 required
349 class="w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10 pr-32"
350 style={{ fontFamily: 'inherit' }}
351 />
352 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
353 <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
354 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
355 </svg>
356 </div>
357 {/* Suffix for domain ending */}
358 {availableDomains.length > 0 ? (
359 availableDomains.length === 1 ? (
360 <span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base">
361 {availableDomains[0]}
362 </span>
363 ) : (
364 <span class="absolute inset-y-0 right-0 flex items-center pr-1">
365 <select
366 value={selectedDomain}
367 onChange={(e) => setSelectedDomain(e.currentTarget.value)}
368 class="bg-transparent text-gray-400 font-mono text-base focus:outline-none focus:ring-0 border-0 pr-2"
369 style={{ appearance: 'none' }}
370 >
371 {availableDomains.map((domain) => (
372 <option key={domain} value={domain}>{domain}</option>
373 ))}
374 </select>
375 </span>
376 )
377 ) : (
378 <span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base">
379 .example.com
380 </span>
381 )}
382 </div>
383 </div>
384 </div>
385 </div>
386
387 <div>
388 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
389 Email
390 <span class="text-xs text-gray-500 ml-1">(Emergency Contact)</span>
391 </label>
392 <div class="relative">
393 <input
394 type="email"
395 value={email}
396 onChange={(e) => setEmail(e.currentTarget.value)}
397 required
398 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10"
399 />
400 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
401 <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
402 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
403 </svg>
404 </div>
405 </div>
406 </div>
407
408 <div>
409 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
410 New Account Password
411 <span class="text-xs text-gray-500 ml-1">(Security Clearance)</span>
412 </label>
413 <div class="relative">
414 <input
415 type="password"
416 value={password}
417 onChange={(e) => setPassword(e.currentTarget.value)}
418 required
419 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10"
420 />
421 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
422 <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
423 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
424 </svg>
425 </div>
426 </div>
427 </div>
428
429 {inviteRequired && (
430 <div>
431 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
432 Invitation Code
433 <span class="text-xs text-gray-500 ml-1">(Boarding Pass)</span>
434 </label>
435 <div class="relative">
436 <input
437 type="text"
438 value={invite}
439 onChange={(e) => setInvite(e.currentTarget.value)}
440 required
441 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10"
442 />
443 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
444 <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
445 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
446 </svg>
447 </div>
448 </div>
449 </div>
450 )}
451 </div>
452
453 <button
454 type="submit"
455 disabled={isLoading || Boolean(migrationState && !migrationState.allowMigration)}
456 class="w-full flex justify-center items-center py-3 px-4 rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
457 >
458 <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
459 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
460 </svg>
461 Proceed to Check-in
462 </button>
463 </form>
464
465 {showConfirmation && (
466 <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
467 <div
468 class="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-md w-full shadow-2xl border-0 relative animate-popin"
469 style={{ boxShadow: '0 8px 32px 0 rgba(255, 0, 0, 0.15), 0 1.5px 8px 0 rgba(0,0,0,0.10)' }}
470 >
471 <div class="absolute -top-8 left-1/2 -translate-x-1/2">
472 <div class="bg-red-500 rounded-full p-3 shadow-lg animate-bounce-short">
473 <svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
474 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
475 </svg>
476 </div>
477 </div>
478 <div class="text-center mb-4 mt-6">
479 <h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide">Final Boarding Call</h3>
480 <p class="text-gray-700 dark:text-gray-300 mb-2 text-base">
481 <span class="font-semibold text-red-500">Warning:</span> This migration is <strong>irreversible</strong> if coming from Bluesky servers.<br />Bluesky does not recommend it for main accounts. Migrate at your own risk. We reccomend backing up your data before proceeding.
482 </p>
483 <p class="text-gray-700 dark:text-gray-300 mb-4 text-base">
484 Please type <span class="font-mono font-bold text-blue-600">MIGRATE</span> below to confirm and proceed.
485 </p>
486 </div>
487 <div class="relative">
488 <input
489 type="text"
490 value={confirmationText}
491 onInput={(e) => setConfirmationText(e.currentTarget.value)}
492 placeholder="Type MIGRATE to confirm"
493 class="w-full p-3 rounded-md bg-white dark:bg-gray-700 shadow focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 dark:text-white text-center font-mono text-lg border border-red-200 dark:border-red-700 transition"
494 autoFocus
495 />
496 </div>
497 <div class="flex justify-end space-x-4 mt-6">
498 <button
499 onClick={() => setShowConfirmation(false)}
500 class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md flex items-center transition"
501 type="button"
502 >
503 <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
504 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
505 </svg>
506 Cancel
507 </button>
508 <button
509 onClick={handleConfirmation}
510 class={`px-4 py-2 rounded-md flex items-center transition font-semibold ${confirmationText.trim().toLowerCase() === 'migrate' ? 'bg-red-600 text-white hover:bg-red-700 cursor-pointer' : 'bg-red-300 text-white cursor-not-allowed'}`}
511 type="button"
512 disabled={confirmationText.trim().toLowerCase() !== 'migrate'}
513 >
514 <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
515 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
516 </svg>
517 Confirm Migration
518 </button>
519 </div>
520 </div>
521 </div>
522 )}
523 </div>
524 );
525}