Graphical PDS migrator for AT Protocol
1import { useEffect, useState } from "preact/hooks";
2import {
3 MigrationClient,
4 MigrationError,
5 MigrationErrorType,
6 MigrationProgressProps,
7 MigrationStep,
8} from "../lib/client.ts";
9/**
10 * The migration progress component.
11 * @param props - The migration progress props
12 * @returns The migration progress component
13 * @component
14 */
15export default function MigrationProgress(props: MigrationProgressProps) {
16 const [token, setToken] = useState("");
17 const [showContinueAnyway, setShowContinueAnyway] = useState<
18 Record<number, boolean>
19 >({});
20
21 const [steps, setSteps] = useState<MigrationStep[]>([
22 { name: "Create Account", status: "pending" },
23 { name: "Migrate Data", status: "pending" },
24 { name: "Migrate Identity", status: "pending" },
25 { name: "Finalize Migration", status: "pending" },
26 ]);
27
28 const updateStepStatus = (
29 index: number,
30 status: MigrationStep["status"],
31 error?: string,
32 isVerificationError?: boolean,
33 ) => {
34 console.log(
35 `Updating step ${index} to ${status}${
36 error ? ` with error: ${error}` : ""
37 }`,
38 );
39 setSteps((prevSteps) =>
40 prevSteps.map((step, i) => {
41 if (i === index) {
42 // Update the current step
43 return { ...step, status, error, isVerificationError };
44 } else if (i > index) {
45 // Reset future steps to pending only if current step is erroring
46 if (status === "error") {
47 return {
48 ...step,
49 status: "pending",
50 error: undefined,
51 isVerificationError: undefined,
52 };
53 }
54 // Otherwise keep future steps as they are
55 return step;
56 } else {
57 // Keep previous steps as they are (preserve completed status)
58 return step;
59 }
60 })
61 );
62 };
63
64 const validateParams = () => {
65 if (!props.service?.trim()) {
66 updateStepStatus(0, "error", "Missing service URL");
67 return false;
68 }
69 if (!props.handle?.trim()) {
70 updateStepStatus(0, "error", "Missing handle");
71 return false;
72 }
73 if (!props.email?.trim()) {
74 updateStepStatus(0, "error", "Missing email");
75 return false;
76 }
77 if (!props.password?.trim()) {
78 updateStepStatus(0, "error", "Missing password");
79 return false;
80 }
81 return true;
82 };
83
84 const client = new MigrationClient(
85 {
86 updateStepStatus,
87 nextStepHook(stepNum) {
88 if (stepNum === 2) {
89 // Update step name to prompt for token
90 setSteps((prevSteps) =>
91 prevSteps.map((step, i) =>
92 i === 2
93 ? {
94 ...step,
95 name:
96 "Enter the token sent to your email to complete identity migration",
97 }
98 : step
99 )
100 );
101 }
102 },
103 setShowContinueAnyway,
104 },
105 );
106
107 const continueAnyway = (stepNum: number) => {
108 console.log(`Continuing anyway for step ${stepNum + 1}`);
109 updateStepStatus(stepNum, "completed");
110 setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
111
112 // Continue with next step if not the last one
113 if (stepNum < 3) {
114 client.continueToNextStep(stepNum + 1);
115 }
116 };
117
118 const handleIdentityMigration = async () => {
119 if (!token.trim()) {
120 updateStepStatus(2, "error", "Please enter a valid token");
121 return;
122 }
123
124 try {
125 await client.handleIdentityMigration(token);
126 // If successful, continue to next step
127 client.continueToNextStep(3);
128 } catch (error) {
129 console.error("Identity migration error:", error);
130 updateStepStatus(
131 2,
132 "error",
133 error instanceof Error ? error.message : String(error),
134 );
135 }
136 };
137
138 useEffect(() => {
139 (async () => {
140 if (!validateParams()) {
141 console.log("Parameter validation failed");
142 return;
143 }
144
145 try {
146 await client.checkState();
147 } catch (error) {
148 console.error("Failed to check migration state:", error);
149 if (
150 error instanceof MigrationError &&
151 error.type === MigrationErrorType.NOT_ALLOWED
152 ) {
153 updateStepStatus(0, "error", error.message);
154 } else {
155 updateStepStatus(
156 0,
157 "error",
158 "Unable to verify migration availability",
159 );
160 }
161 return;
162 }
163
164 await client.startMigration(props);
165 })();
166 }, []);
167
168 const getStepDisplayName = (step: MigrationStep, index: number) => {
169 if (step.status === "completed") {
170 switch (index) {
171 case 0:
172 return "Account Created";
173 case 1:
174 return "Data Migrated";
175 case 2:
176 return "Identity Migrated";
177 case 3:
178 return "Migration Finalized";
179 }
180 }
181
182 if (step.status === "in-progress") {
183 switch (index) {
184 case 0:
185 return "Creating your new account...";
186 case 1:
187 return "Migrating your data...";
188 case 2:
189 return step.name ===
190 "Enter the token sent to your email to complete identity migration"
191 ? step.name
192 : "Migrating your identity...";
193 case 3:
194 return "Finalizing migration...";
195 }
196 }
197
198 if (step.status === "verifying") {
199 switch (index) {
200 case 0:
201 return "Verifying account creation...";
202 case 1:
203 return "Verifying data migration...";
204 case 2:
205 return "Verifying identity migration...";
206 case 3:
207 return "Verifying migration completion...";
208 }
209 }
210
211 return step.name;
212 };
213
214 const getStepIcon = (status: MigrationStep["status"]) => {
215 switch (status) {
216 case "pending":
217 return (
218 <div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center">
219 <div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" />
220 </div>
221 );
222 case "in-progress":
223 return (
224 <div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center">
225 <div class="w-3 h-3 rounded-full bg-blue-500" />
226 </div>
227 );
228 case "verifying":
229 return (
230 <div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center">
231 <div class="w-3 h-3 rounded-full bg-yellow-500" />
232 </div>
233 );
234 case "completed":
235 return (
236 <div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center">
237 <svg
238 class="w-5 h-5 text-white"
239 fill="none"
240 stroke="currentColor"
241 viewBox="0 0 24 24"
242 >
243 <path
244 stroke-linecap="round"
245 stroke-linejoin="round"
246 stroke-width="2"
247 d="M5 13l4 4L19 7"
248 />
249 </svg>
250 </div>
251 );
252 case "error":
253 return (
254 <div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center">
255 <svg
256 class="w-5 h-5 text-white"
257 fill="none"
258 stroke="currentColor"
259 viewBox="0 0 24 24"
260 >
261 <path
262 stroke-linecap="round"
263 stroke-linejoin="round"
264 stroke-width="2"
265 d="M6 18L18 6M6 6l12 12"
266 />
267 </svg>
268 </div>
269 );
270 }
271 };
272
273 const getStepClasses = (status: MigrationStep["status"]) => {
274 const baseClasses =
275 "flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200";
276 switch (status) {
277 case "pending":
278 return `${baseClasses} bg-gray-50 dark:bg-gray-800`;
279 case "in-progress":
280 return `${baseClasses} bg-blue-50 dark:bg-blue-900`;
281 case "verifying":
282 return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`;
283 case "completed":
284 return `${baseClasses} bg-green-50 dark:bg-green-900`;
285 case "error":
286 return `${baseClasses} bg-red-50 dark:bg-red-900`;
287 }
288 };
289
290 return (
291 <div class="space-y-8">
292 <div class="space-y-4">
293 {steps.map((step, index) => (
294 <div key={step.name} class={getStepClasses(step.status)}>
295 {getStepIcon(step.status)}
296 <div class="flex-1">
297 <p
298 class={`font-medium ${
299 step.status === "error"
300 ? "text-red-900 dark:text-red-200"
301 : step.status === "completed"
302 ? "text-green-900 dark:text-green-200"
303 : step.status === "in-progress"
304 ? "text-blue-900 dark:text-blue-200"
305 : "text-gray-900 dark:text-gray-200"
306 }`}
307 >
308 {getStepDisplayName(step, index)}
309 </p>
310 {step.error && (
311 <div class="mt-1">
312 <p class="text-sm text-red-600 dark:text-red-400">
313 {(() => {
314 try {
315 const err = JSON.parse(step.error);
316 return err.message || step.error;
317 } catch {
318 return step.error;
319 }
320 })()}
321 </p>
322 {step.isVerificationError && (
323 <div class="flex space-x-2 mt-2">
324 <button
325 type="button"
326 onClick={() => client.retryVerification(index, steps)}
327 class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors duration-200 dark:bg-blue-500 dark:hover:bg-blue-400"
328 >
329 Retry Verification
330 </button>
331 {showContinueAnyway[index] && (
332 <button
333 type="button"
334 onClick={() => continueAnyway(index)}
335 class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200
336 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
337 >
338 Continue Anyway
339 </button>
340 )}
341 </div>
342 )}
343 </div>
344 )}
345 {index === 2 && step.status === "in-progress" &&
346 step.name ===
347 "Enter the token sent to your email to complete identity migration" &&
348 (
349 <div class="mt-4 space-y-4">
350 <p class="text-sm text-blue-800 dark:text-blue-200">
351 Please check your email for the migration token and enter
352 it below:
353 </p>
354 <div class="flex space-x-2">
355 <input
356 type="text"
357 value={token}
358 onChange={(e) => setToken(e.currentTarget.value)}
359 placeholder="Enter token"
360 class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400"
361 />
362 <button
363 type="button"
364 onClick={handleIdentityMigration}
365 class="px-4 py-2 border border-transparent 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-blue-500 transition-colors duration-200"
366 >
367 Submit Token
368 </button>
369 </div>
370 </div>
371 )}
372 </div>
373 </div>
374 ))}
375 </div>
376
377 {steps[3].status === "completed" && (
378 <div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800">
379 <p class="text-sm text-green-800 dark:text-green-200 pb-2">
380 Migration completed successfully! Sign out to finish the process and
381 return home.<br />
382 Please consider donating to Airport to support server and
383 development costs.
384 </p>
385 <div class="flex space-x-4">
386 <button
387 type="button"
388 onClick={async () => {
389 try {
390 const response = await fetch("/api/logout", {
391 method: "POST",
392 credentials: "include",
393 });
394 if (!response.ok) {
395 throw new Error("Logout failed");
396 }
397 globalThis.location.href = "/";
398 } catch (error) {
399 console.error("Failed to logout:", error);
400 }
401 }}
402 class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
403 >
404 <svg
405 class="w-5 h-5"
406 fill="none"
407 stroke="currentColor"
408 viewBox="0 0 24 24"
409 >
410 <path
411 stroke-linecap="round"
412 stroke-linejoin="round"
413 stroke-width="2"
414 d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
415 />
416 </svg>
417 <span>Sign Out</span>
418 </button>
419 <a
420 href="https://ko-fi.com/knotbin"
421 target="_blank"
422 class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2"
423 >
424 <svg
425 class="w-5 h-5"
426 fill="none"
427 stroke="currentColor"
428 viewBox="0 0 24 24"
429 >
430 <path
431 stroke-linecap="round"
432 stroke-linejoin="round"
433 stroke-width="2"
434 d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
435 />
436 </svg>
437 <span>Support Us</span>
438 </a>
439 </div>
440 </div>
441 )}
442 </div>
443 );
444}