Graphical PDS migrator for AT Protocol
1import { useEffect, useState } from "preact/hooks";
2
3/**
4 * The migration state info.
5 * @type {MigrationStateInfo}
6 */
7interface MigrationStateInfo {
8 state: "up" | "issue" | "maintenance";
9 message: string;
10 allowMigration: boolean;
11}
12
13/**
14 * The migration progress props.
15 * @type {MigrationProgressProps}
16 */
17interface MigrationProgressProps {
18 service: string;
19 handle: string;
20 email: string;
21 password: string;
22 invite?: string;
23}
24
25/**
26 * The migration step.
27 * @type {MigrationStep}
28 */
29interface MigrationStep {
30 name: string;
31 status: "pending" | "in-progress" | "verifying" | "completed" | "error";
32 error?: string;
33 isVerificationError?: boolean;
34}
35
36/**
37 * The migration progress component.
38 * @param props - The migration progress props
39 * @returns The migration progress component
40 * @component
41 */
42export default function MigrationProgress(props: MigrationProgressProps) {
43 const [token, setToken] = useState("");
44 const [migrationState, setMigrationState] = useState<
45 MigrationStateInfo | null
46 >(null);
47 const [retryAttempts, setRetryAttempts] = useState<Record<number, number>>(
48 {},
49 );
50 const [showContinueAnyway, setShowContinueAnyway] = useState<
51 Record<number, boolean>
52 >({});
53
54 const [steps, setSteps] = useState<MigrationStep[]>([
55 { name: "Create Account", status: "pending" },
56 { name: "Migrate Data", status: "pending" },
57 { name: "Migrate Identity", status: "pending" },
58 { name: "Finalize Migration", status: "pending" },
59 ]);
60
61 const updateStepStatus = (
62 index: number,
63 status: MigrationStep["status"],
64 error?: string,
65 isVerificationError?: boolean,
66 ) => {
67 console.log(
68 `Updating step ${index} to ${status}${
69 error ? ` with error: ${error}` : ""
70 }`,
71 );
72 setSteps((prevSteps) =>
73 prevSteps.map((step, i) =>
74 i === index
75 ? { ...step, status, error, isVerificationError }
76 : i > index
77 ? {
78 ...step,
79 status: "pending",
80 error: undefined,
81 isVerificationError: undefined,
82 }
83 : step
84 )
85 );
86 };
87
88 const validateParams = () => {
89 if (!props.service?.trim()) {
90 updateStepStatus(0, "error", "Missing service URL");
91 return false;
92 }
93 if (!props.handle?.trim()) {
94 updateStepStatus(0, "error", "Missing handle");
95 return false;
96 }
97 if (!props.email?.trim()) {
98 updateStepStatus(0, "error", "Missing email");
99 return false;
100 }
101 if (!props.password?.trim()) {
102 updateStepStatus(0, "error", "Missing password");
103 return false;
104 }
105 return true;
106 };
107
108 useEffect(() => {
109 console.log("Starting migration with props:", {
110 service: props.service,
111 handle: props.handle,
112 email: props.email,
113 hasPassword: !!props.password,
114 invite: props.invite,
115 });
116
117 // Check migration state first
118 const checkMigrationState = async () => {
119 try {
120 const migrationResponse = await fetch("/api/migration-state");
121 if (migrationResponse.ok) {
122 const migrationData = await migrationResponse.json();
123 setMigrationState(migrationData);
124
125 if (!migrationData.allowMigration) {
126 updateStepStatus(0, "error", migrationData.message);
127 return;
128 }
129 }
130 } catch (error) {
131 console.error("Failed to check migration state:", error);
132 updateStepStatus(0, "error", "Unable to verify migration availability");
133 return;
134 }
135
136 if (!validateParams()) {
137 console.log("Parameter validation failed");
138 return;
139 }
140
141 startMigration().catch((error) => {
142 console.error("Unhandled migration error:", error);
143 updateStepStatus(
144 0,
145 "error",
146 error.message || "Unknown error occurred",
147 );
148 });
149 };
150
151 checkMigrationState();
152 }, []);
153
154 const getStepDisplayName = (step: MigrationStep, index: number) => {
155 if (step.status === "completed") {
156 switch (index) {
157 case 0:
158 return "Account Created";
159 case 1:
160 return "Data Migrated";
161 case 2:
162 return "Identity Migrated";
163 case 3:
164 return "Migration Finalized";
165 }
166 }
167
168 if (step.status === "in-progress") {
169 switch (index) {
170 case 0:
171 return "Creating your new account...";
172 case 1:
173 return "Migrating your data...";
174 case 2:
175 return step.name ===
176 "Enter the token sent to your email to complete identity migration"
177 ? step.name
178 : "Migrating your identity...";
179 case 3:
180 return "Finalizing migration...";
181 }
182 }
183
184 if (step.status === "verifying") {
185 switch (index) {
186 case 0:
187 return "Verifying account creation...";
188 case 1:
189 return "Verifying data migration...";
190 case 2:
191 return "Verifying identity migration...";
192 case 3:
193 return "Verifying migration completion...";
194 }
195 }
196
197 return step.name;
198 };
199
200 const startMigration = async () => {
201 try {
202 // Step 1: Create Account
203 updateStepStatus(0, "in-progress");
204 console.log("Starting account creation...");
205
206 try {
207 const createRes = await fetch("/api/migrate/create", {
208 method: "POST",
209 headers: { "Content-Type": "application/json" },
210 body: JSON.stringify({
211 service: props.service,
212 handle: props.handle,
213 password: props.password,
214 email: props.email,
215 ...(props.invite ? { invite: props.invite } : {}),
216 }),
217 });
218
219 console.log("Create account response status:", createRes.status);
220 const responseText = await createRes.text();
221 console.log("Create account response:", responseText);
222
223 if (!createRes.ok) {
224 try {
225 const json = JSON.parse(responseText);
226 throw new Error(json.message || "Failed to create account");
227 } catch {
228 throw new Error(responseText || "Failed to create account");
229 }
230 }
231
232 try {
233 const jsonData = JSON.parse(responseText);
234 if (!jsonData.success) {
235 throw new Error(jsonData.message || "Account creation failed");
236 }
237 } catch (e) {
238 console.log("Response is not JSON or lacks success field:", e);
239 }
240
241 updateStepStatus(0, "verifying");
242 const verified = await verifyStep(0);
243 if (!verified) {
244 console.log(
245 "Account creation: Verification failed, waiting for user action",
246 );
247 return;
248 }
249
250 // If verification succeeds, continue to data migration
251 await startDataMigration();
252 } catch (error) {
253 updateStepStatus(
254 0,
255 "error",
256 error instanceof Error ? error.message : String(error),
257 );
258 throw error;
259 }
260 } catch (error) {
261 console.error("Migration error in try/catch:", error);
262 }
263 };
264
265 const handleIdentityMigration = async () => {
266 if (!token) return;
267
268 try {
269 const identityRes = await fetch(
270 `/api/migrate/identity/sign?token=${encodeURIComponent(token)}`,
271 {
272 method: "POST",
273 headers: { "Content-Type": "application/json" },
274 },
275 );
276
277 const identityData = await identityRes.text();
278 if (!identityRes.ok) {
279 try {
280 const json = JSON.parse(identityData);
281 throw new Error(
282 json.message || "Failed to complete identity migration",
283 );
284 } catch {
285 throw new Error(
286 identityData || "Failed to complete identity migration",
287 );
288 }
289 }
290
291 let data;
292 try {
293 data = JSON.parse(identityData);
294 if (!data.success) {
295 throw new Error(data.message || "Identity migration failed");
296 }
297 } catch {
298 throw new Error("Invalid response from server");
299 }
300
301 updateStepStatus(2, "verifying");
302 const verified = await verifyStep(2);
303 if (!verified) {
304 console.log(
305 "Identity migration: Verification failed, waiting for user action",
306 );
307 return;
308 }
309
310 // If verification succeeds, continue to finalization
311 await startFinalization();
312 } catch (error) {
313 console.error("Identity migration error:", error);
314 updateStepStatus(
315 2,
316 "error",
317 error instanceof Error ? error.message : String(error),
318 );
319 }
320 };
321
322 const getStepIcon = (status: MigrationStep["status"]) => {
323 switch (status) {
324 case "pending":
325 return (
326 <div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center">
327 <div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" />
328 </div>
329 );
330 case "in-progress":
331 return (
332 <div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center">
333 <div class="w-3 h-3 rounded-full bg-blue-500" />
334 </div>
335 );
336 case "verifying":
337 return (
338 <div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center">
339 <div class="w-3 h-3 rounded-full bg-yellow-500" />
340 </div>
341 );
342 case "completed":
343 return (
344 <div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center">
345 <svg
346 class="w-5 h-5 text-white"
347 fill="none"
348 stroke="currentColor"
349 viewBox="0 0 24 24"
350 >
351 <path
352 stroke-linecap="round"
353 stroke-linejoin="round"
354 stroke-width="2"
355 d="M5 13l4 4L19 7"
356 />
357 </svg>
358 </div>
359 );
360 case "error":
361 return (
362 <div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center">
363 <svg
364 class="w-5 h-5 text-white"
365 fill="none"
366 stroke="currentColor"
367 viewBox="0 0 24 24"
368 >
369 <path
370 stroke-linecap="round"
371 stroke-linejoin="round"
372 stroke-width="2"
373 d="M6 18L18 6M6 6l12 12"
374 />
375 </svg>
376 </div>
377 );
378 }
379 };
380
381 const getStepClasses = (status: MigrationStep["status"]) => {
382 const baseClasses =
383 "flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200";
384 switch (status) {
385 case "pending":
386 return `${baseClasses} bg-gray-50 dark:bg-gray-800`;
387 case "in-progress":
388 return `${baseClasses} bg-blue-50 dark:bg-blue-900`;
389 case "verifying":
390 return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`;
391 case "completed":
392 return `${baseClasses} bg-green-50 dark:bg-green-900`;
393 case "error":
394 return `${baseClasses} bg-red-50 dark:bg-red-900`;
395 }
396 };
397
398 // Helper to verify a step after completion
399 const verifyStep = async (stepNum: number) => {
400 console.log(`Verification: Starting step ${stepNum + 1}`);
401 updateStepStatus(stepNum, "verifying");
402 try {
403 console.log(`Verification: Fetching status for step ${stepNum + 1}`);
404 const res = await fetch(`/api/migrate/status?step=${stepNum + 1}`);
405 console.log(`Verification: Status response status:`, res.status);
406 const data = await res.json();
407 console.log(`Verification: Status data for step ${stepNum + 1}:`, data);
408
409 if (data.ready) {
410 console.log(`Verification: Step ${stepNum + 1} is ready`);
411 updateStepStatus(stepNum, "completed");
412 // Reset retry state on success
413 setRetryAttempts((prev) => ({ ...prev, [stepNum]: 0 }));
414 setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
415
416 // Continue to next step if not the last one
417 if (stepNum < 3) {
418 setTimeout(() => continueToNextStep(stepNum + 1), 500);
419 }
420
421 return true;
422 } else {
423 console.log(
424 `Verification: Step ${stepNum + 1} is not ready:`,
425 data.reason,
426 );
427 const statusDetails = {
428 activated: data.activated,
429 validDid: data.validDid,
430 repoCommit: data.repoCommit,
431 repoRev: data.repoRev,
432 repoBlocks: data.repoBlocks,
433 expectedRecords: data.expectedRecords,
434 indexedRecords: data.indexedRecords,
435 privateStateValues: data.privateStateValues,
436 expectedBlobs: data.expectedBlobs,
437 importedBlobs: data.importedBlobs,
438 };
439 console.log(
440 `Verification: Step ${stepNum + 1} status details:`,
441 statusDetails,
442 );
443 const errorMessage = `${
444 data.reason || "Verification failed"
445 }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
446
447 // Track retry attempts
448 const currentAttempts = retryAttempts[stepNum] || 0;
449 setRetryAttempts((prev) => ({
450 ...prev,
451 [stepNum]: currentAttempts + 1,
452 }));
453
454 // Show continue anyway option if this is the second failure
455 if (currentAttempts >= 1) {
456 setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
457 }
458
459 updateStepStatus(stepNum, "error", errorMessage, true);
460 return false;
461 }
462 } catch (e) {
463 console.error(`Verification: Error in step ${stepNum + 1}:`, e);
464 const currentAttempts = retryAttempts[stepNum] || 0;
465 setRetryAttempts((prev) => ({ ...prev, [stepNum]: currentAttempts + 1 }));
466
467 // Show continue anyway option if this is the second failure
468 if (currentAttempts >= 1) {
469 setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true }));
470 }
471
472 updateStepStatus(
473 stepNum,
474 "error",
475 e instanceof Error ? e.message : String(e),
476 true,
477 );
478 return false;
479 }
480 };
481
482 const retryVerification = async (stepNum: number) => {
483 console.log(`Retrying verification for step ${stepNum + 1}`);
484 await verifyStep(stepNum);
485 };
486
487 const continueAnyway = (stepNum: number) => {
488 console.log(`Continuing anyway for step ${stepNum + 1}`);
489 updateStepStatus(stepNum, "completed");
490 setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false }));
491
492 // Continue with next step if not the last one
493 if (stepNum < 3) {
494 continueToNextStep(stepNum + 1);
495 }
496 };
497
498 const continueToNextStep = async (stepNum: number) => {
499 switch (stepNum) {
500 case 1:
501 // Continue to data migration
502 await startDataMigration();
503 break;
504 case 2:
505 // Continue to identity migration
506 await startIdentityMigration();
507 break;
508 case 3:
509 // Continue to finalization
510 await startFinalization();
511 break;
512 }
513 };
514
515 const startDataMigration = async () => {
516 // Step 2: Migrate Data
517 updateStepStatus(1, "in-progress");
518 console.log("Starting data migration...");
519
520 try {
521 // Step 2.1: Migrate Repo
522 console.log("Data migration: Starting repo migration");
523 const repoRes = await fetch("/api/migrate/data/repo", {
524 method: "POST",
525 headers: { "Content-Type": "application/json" },
526 });
527
528 console.log("Repo migration: Response status:", repoRes.status);
529 const repoText = await repoRes.text();
530 console.log("Repo migration: Raw response:", repoText);
531
532 if (!repoRes.ok) {
533 try {
534 const json = JSON.parse(repoText);
535 console.error("Repo migration: Error response:", json);
536 throw new Error(json.message || "Failed to migrate repo");
537 } catch {
538 console.error("Repo migration: Non-JSON error response:", repoText);
539 throw new Error(repoText || "Failed to migrate repo");
540 }
541 }
542
543 // Step 2.2: Migrate Blobs
544 console.log("Data migration: Starting blob migration");
545 const blobsRes = await fetch("/api/migrate/data/blobs", {
546 method: "POST",
547 headers: { "Content-Type": "application/json" },
548 });
549
550 console.log("Blob migration: Response status:", blobsRes.status);
551 const blobsText = await blobsRes.text();
552 console.log("Blob migration: Raw response:", blobsText);
553
554 if (!blobsRes.ok) {
555 try {
556 const json = JSON.parse(blobsText);
557 console.error("Blob migration: Error response:", json);
558 throw new Error(json.message || "Failed to migrate blobs");
559 } catch {
560 console.error(
561 "Blob migration: Non-JSON error response:",
562 blobsText,
563 );
564 throw new Error(blobsText || "Failed to migrate blobs");
565 }
566 }
567
568 // Step 2.3: Migrate Preferences
569 console.log("Data migration: Starting preferences migration");
570 const prefsRes = await fetch("/api/migrate/data/prefs", {
571 method: "POST",
572 headers: { "Content-Type": "application/json" },
573 });
574
575 console.log("Preferences migration: Response status:", prefsRes.status);
576 const prefsText = await prefsRes.text();
577 console.log("Preferences migration: Raw response:", prefsText);
578
579 if (!prefsRes.ok) {
580 try {
581 const json = JSON.parse(prefsText);
582 console.error("Preferences migration: Error response:", json);
583 throw new Error(json.message || "Failed to migrate preferences");
584 } catch {
585 console.error(
586 "Preferences migration: Non-JSON error response:",
587 prefsText,
588 );
589 throw new Error(prefsText || "Failed to migrate preferences");
590 }
591 }
592
593 console.log("Data migration: Starting verification");
594 updateStepStatus(1, "verifying");
595 const verified = await verifyStep(1);
596 console.log("Data migration: Verification result:", verified);
597 if (!verified) {
598 console.log(
599 "Data migration: Verification failed, waiting for user action",
600 );
601 return;
602 }
603
604 // If verification succeeds, continue to next step
605 await startIdentityMigration();
606 } catch (error) {
607 console.error("Data migration: Error caught:", error);
608 updateStepStatus(
609 1,
610 "error",
611 error instanceof Error ? error.message : String(error),
612 );
613 throw error;
614 }
615 };
616
617 const startIdentityMigration = async () => {
618 // Step 3: Request Identity Migration
619 updateStepStatus(2, "in-progress");
620 console.log("Requesting identity migration...");
621
622 try {
623 const requestRes = await fetch("/api/migrate/identity/request", {
624 method: "POST",
625 headers: { "Content-Type": "application/json" },
626 });
627
628 console.log("Identity request response status:", requestRes.status);
629 const requestText = await requestRes.text();
630 console.log("Identity request response:", requestText);
631
632 if (!requestRes.ok) {
633 try {
634 const json = JSON.parse(requestText);
635 throw new Error(
636 json.message || "Failed to request identity migration",
637 );
638 } catch {
639 throw new Error(
640 requestText || "Failed to request identity migration",
641 );
642 }
643 }
644
645 try {
646 const jsonData = JSON.parse(requestText);
647 if (!jsonData.success) {
648 throw new Error(
649 jsonData.message || "Identity migration request failed",
650 );
651 }
652 console.log("Identity migration requested successfully");
653
654 // Update step name to prompt for token
655 setSteps((prevSteps) =>
656 prevSteps.map((step, i) =>
657 i === 2
658 ? {
659 ...step,
660 name:
661 "Enter the token sent to your email to complete identity migration",
662 }
663 : step
664 )
665 );
666 // Don't continue with migration - wait for token input
667 return;
668 } catch (e) {
669 console.error("Failed to parse identity request response:", e);
670 throw new Error(
671 "Invalid response from server during identity request",
672 );
673 }
674 } catch (error) {
675 updateStepStatus(
676 2,
677 "error",
678 error instanceof Error ? error.message : String(error),
679 );
680 throw error;
681 }
682 };
683
684 const startFinalization = async () => {
685 // Step 4: Finalize Migration
686 updateStepStatus(3, "in-progress");
687 try {
688 const finalizeRes = await fetch("/api/migrate/finalize", {
689 method: "POST",
690 headers: { "Content-Type": "application/json" },
691 });
692
693 const finalizeData = await finalizeRes.text();
694 if (!finalizeRes.ok) {
695 try {
696 const json = JSON.parse(finalizeData);
697 throw new Error(json.message || "Failed to finalize migration");
698 } catch {
699 throw new Error(finalizeData || "Failed to finalize migration");
700 }
701 }
702
703 try {
704 const jsonData = JSON.parse(finalizeData);
705 if (!jsonData.success) {
706 throw new Error(jsonData.message || "Finalization failed");
707 }
708 } catch {
709 throw new Error("Invalid response from server during finalization");
710 }
711
712 updateStepStatus(3, "verifying");
713 const verified = await verifyStep(3);
714 if (!verified) {
715 console.log(
716 "Finalization: Verification failed, waiting for user action",
717 );
718 return;
719 }
720 } catch (error) {
721 updateStepStatus(
722 3,
723 "error",
724 error instanceof Error ? error.message : String(error),
725 );
726 throw error;
727 }
728 };
729
730 return (
731 <div class="space-y-8">
732 {/* Migration state alert */}
733 {migrationState && !migrationState.allowMigration && (
734 <div
735 class={`p-4 rounded-lg border ${
736 migrationState.state === "maintenance"
737 ? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200"
738 : "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"
739 }`}
740 >
741 <div class="flex items-center">
742 <div
743 class={`mr-3 ${
744 migrationState.state === "maintenance"
745 ? "text-yellow-600 dark:text-yellow-400"
746 : "text-red-600 dark:text-red-400"
747 }`}
748 >
749 {migrationState.state === "maintenance" ? "⚠️" : "🚫"}
750 </div>
751 <div>
752 <h3 class="font-semibold mb-1">
753 {migrationState.state === "maintenance"
754 ? "Maintenance Mode"
755 : "Service Unavailable"}
756 </h3>
757 <p class="text-sm">{migrationState.message}</p>
758 </div>
759 </div>
760 </div>
761 )}
762
763 <div class="space-y-4">
764 {steps.map((step, index) => (
765 <div key={step.name} class={getStepClasses(step.status)}>
766 {getStepIcon(step.status)}
767 <div class="flex-1">
768 <p
769 class={`font-medium ${
770 step.status === "error"
771 ? "text-red-900 dark:text-red-200"
772 : step.status === "completed"
773 ? "text-green-900 dark:text-green-200"
774 : step.status === "in-progress"
775 ? "text-blue-900 dark:text-blue-200"
776 : "text-gray-900 dark:text-gray-200"
777 }`}
778 >
779 {getStepDisplayName(step, index)}
780 </p>
781 {step.error && (
782 <div class="mt-1">
783 <p class="text-sm text-red-600 dark:text-red-400">
784 {(() => {
785 try {
786 const err = JSON.parse(step.error);
787 return err.message || step.error;
788 } catch {
789 return step.error;
790 }
791 })()}
792 </p>
793 {step.isVerificationError && (
794 <div class="flex space-x-2 mt-2">
795 <button
796 type="button"
797 onClick={() => retryVerification(index)}
798 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"
799 >
800 Retry Verification
801 </button>
802 {showContinueAnyway[index] && (
803 <button
804 type="button"
805 onClick={() => continueAnyway(index)}
806 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
807 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
808 >
809 Continue Anyway
810 </button>
811 )}
812 </div>
813 )}
814 </div>
815 )}
816 {index === 2 && step.status === "in-progress" &&
817 step.name ===
818 "Enter the token sent to your email to complete identity migration" &&
819 (
820 <div class="mt-4 space-y-4">
821 <p class="text-sm text-blue-800 dark:text-blue-200">
822 Please check your email for the migration token and enter
823 it below:
824 </p>
825 <div class="flex space-x-2">
826 <input
827 type="text"
828 value={token}
829 onChange={(e) => setToken(e.currentTarget.value)}
830 placeholder="Enter token"
831 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"
832 />
833 <button
834 type="button"
835 onClick={handleIdentityMigration}
836 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"
837 >
838 Submit Token
839 </button>
840 </div>
841 </div>
842 )}
843 </div>
844 </div>
845 ))}
846 </div>
847
848 {steps[3].status === "completed" && (
849 <div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800">
850 <p class="text-sm text-green-800 dark:text-green-200 pb-2">
851 Migration completed successfully! Sign out to finish the process and
852 return home.<br />
853 Please consider donating to Airport to support server and
854 development costs.
855 </p>
856 <div class="flex space-x-4">
857 <button
858 type="button"
859 onClick={async () => {
860 try {
861 const response = await fetch("/api/logout", {
862 method: "POST",
863 credentials: "include",
864 });
865 if (!response.ok) {
866 throw new Error("Logout failed");
867 }
868 globalThis.location.href = "/";
869 } catch (error) {
870 console.error("Failed to logout:", error);
871 }
872 }}
873 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"
874 >
875 <svg
876 class="w-5 h-5"
877 fill="none"
878 stroke="currentColor"
879 viewBox="0 0 24 24"
880 >
881 <path
882 stroke-linecap="round"
883 stroke-linejoin="round"
884 stroke-width="2"
885 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"
886 />
887 </svg>
888 <span>Sign Out</span>
889 </button>
890 <a
891 href="https://ko-fi.com/knotbin"
892 target="_blank"
893 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"
894 >
895 <svg
896 class="w-5 h-5"
897 fill="none"
898 stroke="currentColor"
899 viewBox="0 0 24 24"
900 >
901 <path
902 stroke-linecap="round"
903 stroke-linejoin="round"
904 stroke-width="2"
905 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"
906 />
907 </svg>
908 <span>Support Us</span>
909 </a>
910 </div>
911 </div>
912 )}
913 </div>
914 );
915}