Graphical PDS migrator for AT Protocol
1import { useEffect, useState } 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<
68 MigrationStateInfo | null
69 >(null);
70
71 const ensureServiceUrl = (url: string): string => {
72 if (!url) return url;
73 try {
74 // If it already has a protocol, return as is
75 new URL(url);
76 return url;
77 } catch {
78 // If no protocol, add https://
79 return `https://${url}`;
80 }
81 };
82
83 useEffect(() => {
84 if (!IS_BROWSER) return;
85
86 const fetchInitialData = async () => {
87 try {
88 // Check migration state first
89 const migrationResponse = await fetch("/api/migration-state");
90 if (migrationResponse.ok) {
91 const migrationData = await migrationResponse.json();
92 setMigrationState(migrationData);
93 }
94
95 // Fetch user passport
96 const response = await fetch("/api/me", {
97 credentials: "include",
98 });
99 if (!response.ok) {
100 throw new Error("Failed to fetch user profile");
101 }
102 const userData = await response.json();
103 if (userData) {
104 // Get PDS URL from the current service
105 const pdsResponse = await fetch(
106 `/api/resolve-pds?did=${userData.did}`,
107 );
108 const pdsData = await pdsResponse.json();
109
110 setPassport({
111 did: userData.did,
112 handle: userData.handle,
113 pds: pdsData.pds || "Unknown",
114 createdAt: new Date().toISOString(), // TODO: Get actual creation date from API
115 });
116 }
117 } catch (error) {
118 console.error("Failed to fetch initial data:", error);
119 }
120 };
121
122 fetchInitialData();
123 }, []);
124
125 const checkServerDescription = async (serviceUrl: string) => {
126 try {
127 setIsLoading(true);
128 const response = await fetch(
129 `${serviceUrl}/xrpc/com.atproto.server.describeServer`,
130 );
131 if (!response.ok) {
132 throw new Error("Failed to fetch server description");
133 }
134 const data: ServerDescription = await response.json();
135 setInviteRequired(data.inviteCodeRequired ?? false);
136 const domains = data.availableUserDomains || [];
137 setAvailableDomains(domains);
138 if (domains.length === 1) {
139 setSelectedDomain(domains[0]);
140 } else if (domains.length > 1) {
141 setSelectedDomain(domains[0]);
142 }
143 } catch (err) {
144 console.error("Failed to check server description:", err);
145 setError(
146 "Failed to connect to server. Please check the URL and try again.",
147 );
148 setInviteRequired(false);
149 setAvailableDomains([]);
150 } finally {
151 setIsLoading(false);
152 }
153 };
154
155 const handleServiceChange = (value: string) => {
156 const urlWithProtocol = ensureServiceUrl(value);
157 setService(urlWithProtocol);
158 setError("");
159 if (urlWithProtocol) {
160 checkServerDescription(urlWithProtocol);
161 } else {
162 setAvailableDomains([]);
163 setSelectedDomain("");
164 }
165 };
166
167 const handleSubmit = (e: Event) => {
168 e.preventDefault();
169
170 // Check migration state first
171 if (migrationState && !migrationState.allowMigration) {
172 setError(migrationState.message);
173 return;
174 }
175
176 if (!service || !handlePrefix || !email || !password) {
177 setError("Please fill in all required fields");
178 return;
179 }
180
181 if (inviteRequired && !invite) {
182 setError("Invite code is required for this server");
183 return;
184 }
185
186 setShowConfirmation(true);
187 };
188
189 const handleConfirmation = () => {
190 // Double-check migration state before proceeding
191 if (migrationState && !migrationState.allowMigration) {
192 setError(migrationState.message);
193 return;
194 }
195
196 if (confirmationText !== "MIGRATE") {
197 setError("Please type 'MIGRATE' to confirm");
198 return;
199 }
200
201 const fullHandle = `${handlePrefix}${
202 availableDomains.length === 1
203 ? availableDomains[0]
204 : availableDomains.length > 1
205 ? selectedDomain
206 : ".example.com"
207 }`;
208
209 // Redirect to progress page with parameters
210 const params = new URLSearchParams({
211 service,
212 handle: fullHandle,
213 email,
214 password,
215 ...(invite ? { invite } : {}),
216 });
217 globalThis.location.href = `/migrate/progress?${params.toString()}`;
218 };
219
220 return (
221 <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">
222 {/* Decorative airport elements */}
223 <div class="absolute top-0 left-0 w-full h-1 bg-blue-500"></div>
224 <div class="absolute top-2 left-4 text-blue-500 text-sm font-mono">
225 TERMINAL 1
226 </div>
227 <div class="absolute top-2 right-4 text-blue-500 text-sm font-mono">
228 GATE M1
229 </div>
230
231 {/* Migration state alert */}
232 {migrationState && !migrationState.allowMigration && (
233 <div
234 class={`mb-6 mt-4 p-4 rounded-lg border ${
235 migrationState.state === "maintenance"
236 ? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200"
237 : "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200"
238 }`}
239 >
240 <div class="flex items-center">
241 <div
242 class={`mr-3 ${
243 migrationState.state === "maintenance"
244 ? "text-yellow-600 dark:text-yellow-400"
245 : "text-red-600 dark:text-red-400"
246 }`}
247 >
248 {migrationState.state === "maintenance" ? "⚠️" : "🚫"}
249 </div>
250 <div>
251 <h3 class="font-semibold mb-1">
252 {migrationState.state === "maintenance"
253 ? "Maintenance Mode"
254 : "Service Unavailable"}
255 </h3>
256 <p class="text-sm">{migrationState.message}</p>
257 </div>
258 </div>
259 </div>
260 )}
261
262 <div class="text-center mb-8 relative">
263 <p class="text-gray-600 dark:text-gray-400 mt-4">
264 Please complete your migration check-in
265 </p>
266 <div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono">
267 FLIGHT: MIG-2024
268 </div>
269 </div>
270
271 {/* Passport Section */}
272 {passport && (
273 <div class="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border border-gray-200 dark:border-gray-700">
274 <div class="flex items-center justify-between mb-4">
275 <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
276 Current Passport
277 </h3>
278 <div class="text-xs text-gray-500 dark:text-gray-400 font-mono">
279 ISSUED: {new Date().toLocaleDateString()}
280 </div>
281 </div>
282 <div class="grid grid-cols-2 gap-4 text-sm">
283 <div>
284 <div class="text-gray-500 dark:text-gray-400 mb-1">Handle</div>
285 <div class="font-mono text-gray-900 dark:text-white">
286 {passport.handle}
287 </div>
288 </div>
289 <div>
290 <div class="text-gray-500 dark:text-gray-400 mb-1">DID</div>
291 <div class="font-mono text-gray-900 dark:text-white break-all">
292 {passport.did}
293 </div>
294 </div>
295 <div>
296 <div class="text-gray-500 dark:text-gray-400 mb-1">
297 Citizen of PDS
298 </div>
299 <div class="font-mono text-gray-900 dark:text-white break-all">
300 {passport.pds}
301 </div>
302 </div>
303 <div>
304 <div class="text-gray-500 dark:text-gray-400 mb-1">
305 Account Age
306 </div>
307 <div class="font-mono text-gray-900 dark:text-white">
308 {passport.createdAt
309 ? new Date(passport.createdAt).toLocaleDateString()
310 : "Unknown"}
311 </div>
312 </div>
313 </div>
314 </div>
315 )}
316
317 <form onSubmit={handleSubmit} class="space-y-6">
318 {error && (
319 <div class="bg-red-50 dark:bg-red-900 rounded-lg ">
320 <p class="text-red-800 dark:text-red-200 flex items-center">
321 <svg
322 class="w-5 h-5 mr-2"
323 fill="none"
324 stroke="currentColor"
325 viewBox="0 0 24 24"
326 >
327 <path
328 stroke-linecap="round"
329 stroke-linejoin="round"
330 stroke-width="2"
331 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"
332 >
333 </path>
334 </svg>
335 {error}
336 </p>
337 </div>
338 )}
339
340 <div class="space-y-4">
341 <div>
342 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
343 Destination Server
344 <span class="text-xs text-gray-500 ml-1">
345 (Final Destination)
346 </span>
347 </label>
348 <div class="relative">
349 <input
350 type="url"
351 value={service}
352 onChange={(e) => handleServiceChange(e.currentTarget.value)}
353 placeholder="https://example.com"
354 required
355 disabled={isLoading}
356 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"
357 />
358 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
359 <svg
360 class="h-5 w-5 text-gray-400"
361 fill="none"
362 stroke="currentColor"
363 viewBox="0 0 24 24"
364 >
365 <path
366 stroke-linecap="round"
367 stroke-linejoin="round"
368 stroke-width="2"
369 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"
370 >
371 </path>
372 </svg>
373 </div>
374 </div>
375 {isLoading && (
376 <p class="mt-1 text-sm text-gray-500 dark:text-gray-400 flex items-center">
377 <svg
378 class="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-500"
379 fill="none"
380 viewBox="0 0 24 24"
381 >
382 <circle
383 class="opacity-25"
384 cx="12"
385 cy="12"
386 r="10"
387 stroke="currentColor"
388 stroke-width="4"
389 >
390 </circle>
391 <path
392 class="opacity-75"
393 fill="currentColor"
394 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"
395 >
396 </path>
397 </svg>
398 Verifying destination server...
399 </p>
400 )}
401 </div>
402
403 <div>
404 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
405 New Account Handle
406 <span class="text-xs text-gray-500 ml-1">(Passport ID)</span>
407 <div class="inline-block relative group ml-2">
408 <svg
409 class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-help"
410 fill="currentColor"
411 viewBox="0 0 20 20"
412 >
413 <path
414 fill-rule="evenodd"
415 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"
416 clip-rule="evenodd"
417 />
418 </svg>
419 <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">
420 You can change your handle to a custom domain later
421 <div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900">
422 </div>
423 </div>
424 </div>
425 </label>
426 <div class="mt-1 relative w-full">
427 <div class="flex rounded-md shadow-sm w-full">
428 <div class="relative flex-1">
429 <input
430 type="text"
431 value={handlePrefix}
432 onChange={(e) => setHandlePrefix(e.currentTarget.value)}
433 placeholder="username"
434 required
435 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"
436 style={{ fontFamily: "inherit" }}
437 />
438 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
439 <svg
440 class="h-5 w-5 text-gray-400"
441 fill="none"
442 stroke="currentColor"
443 viewBox="0 0 24 24"
444 >
445 <path
446 stroke-linecap="round"
447 stroke-linejoin="round"
448 stroke-width="2"
449 d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
450 >
451 </path>
452 </svg>
453 </div>
454 {/* Suffix for domain ending */}
455 {availableDomains.length > 0
456 ? (
457 availableDomains.length === 1
458 ? (
459 <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">
460 {availableDomains[0]}
461 </span>
462 )
463 : (
464 <span class="absolute inset-y-0 right-0 flex items-center pr-1">
465 <select
466 value={selectedDomain}
467 onChange={(e) =>
468 setSelectedDomain(e.currentTarget.value)}
469 class="bg-transparent text-gray-400 font-mono text-base focus:outline-none focus:ring-0 border-0 pr-2"
470 style={{ appearance: "none" }}
471 >
472 {availableDomains.map((domain) => (
473 <option key={domain} value={domain}>
474 {domain}
475 </option>
476 ))}
477 </select>
478 </span>
479 )
480 )
481 : (
482 <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">
483 .example.com
484 </span>
485 )}
486 </div>
487 </div>
488 </div>
489 </div>
490
491 <div>
492 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
493 Email
494 <span class="text-xs text-gray-500 ml-1">
495 (Emergency Contact)
496 </span>
497 </label>
498 <div class="relative">
499 <input
500 type="email"
501 value={email}
502 onChange={(e) => setEmail(e.currentTarget.value)}
503 required
504 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"
505 />
506 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
507 <svg
508 class="h-5 w-5 text-gray-400"
509 fill="none"
510 stroke="currentColor"
511 viewBox="0 0 24 24"
512 >
513 <path
514 stroke-linecap="round"
515 stroke-linejoin="round"
516 stroke-width="2"
517 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"
518 >
519 </path>
520 </svg>
521 </div>
522 </div>
523 </div>
524
525 <div>
526 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
527 New Account Password
528 <span class="text-xs text-gray-500 ml-1">
529 (Security Clearance)
530 </span>
531 </label>
532 <div class="relative">
533 <input
534 type="password"
535 value={password}
536 onChange={(e) => setPassword(e.currentTarget.value)}
537 required
538 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"
539 />
540 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
541 <svg
542 class="h-5 w-5 text-gray-400"
543 fill="none"
544 stroke="currentColor"
545 viewBox="0 0 24 24"
546 >
547 <path
548 stroke-linecap="round"
549 stroke-linejoin="round"
550 stroke-width="2"
551 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"
552 >
553 </path>
554 </svg>
555 </div>
556 </div>
557 </div>
558
559 {inviteRequired && (
560 <div>
561 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
562 Invitation Code
563 <span class="text-xs text-gray-500 ml-1">(Boarding Pass)</span>
564 </label>
565 <div class="relative">
566 <input
567 type="text"
568 value={invite}
569 onChange={(e) => setInvite(e.currentTarget.value)}
570 required
571 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"
572 />
573 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
574 <svg
575 class="h-5 w-5 text-gray-400"
576 fill="none"
577 stroke="currentColor"
578 viewBox="0 0 24 24"
579 >
580 <path
581 stroke-linecap="round"
582 stroke-linejoin="round"
583 stroke-width="2"
584 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"
585 >
586 </path>
587 </svg>
588 </div>
589 </div>
590 </div>
591 )}
592 </div>
593
594 <button
595 type="submit"
596 disabled={isLoading ||
597 Boolean(migrationState && !migrationState.allowMigration)}
598 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"
599 >
600 <svg
601 class="w-5 h-5 mr-2"
602 fill="none"
603 stroke="currentColor"
604 viewBox="0 0 24 24"
605 >
606 <path
607 stroke-linecap="round"
608 stroke-linejoin="round"
609 stroke-width="2"
610 d="M5 13l4 4L19 7"
611 >
612 </path>
613 </svg>
614 Proceed to Check-in
615 </button>
616 </form>
617
618 {showConfirmation && (
619 <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
620 <div
621 class="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-md w-full shadow-2xl border-0 relative animate-popin"
622 style={{
623 boxShadow:
624 "0 8px 32px 0 rgba(255, 0, 0, 0.15), 0 1.5px 8px 0 rgba(0,0,0,0.10)",
625 }}
626 >
627 <div class="absolute -top-8 left-1/2 -translate-x-1/2">
628 <div class="bg-red-500 rounded-full p-3 shadow-lg animate-bounce-short">
629 <svg
630 class="w-8 h-8 text-white"
631 fill="none"
632 stroke="currentColor"
633 viewBox="0 0 24 24"
634 >
635 <path
636 stroke-linecap="round"
637 stroke-linejoin="round"
638 stroke-width="2"
639 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"
640 />
641 </svg>
642 </div>
643 </div>
644 <div class="text-center mb-4 mt-6">
645 <h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide">
646 Final Boarding Call
647 </h3>
648 <p class="text-gray-700 dark:text-gray-300 mb-2 text-base">
649 <span class="font-semibold text-red-500">Warning:</span>{" "}
650 This migration is <strong>irreversible</strong>{" "}
651 if coming from Bluesky servers.<br />Bluesky does not recommend
652 it for main accounts. Migrate at your own risk. We reccomend
653 backing up your data before proceeding.
654 </p>
655 <p class="text-gray-700 dark:text-gray-300 mb-4 text-base">
656 Please type{" "}
657 <span class="font-mono font-bold text-blue-600">MIGRATE</span>
658 {" "}
659 below to confirm and proceed.
660 </p>
661 </div>
662 <div class="relative">
663 <input
664 type="text"
665 value={confirmationText}
666 onInput={(e) => setConfirmationText(e.currentTarget.value)}
667 placeholder="Type MIGRATE to confirm"
668 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"
669 autoFocus
670 />
671 </div>
672 <div class="flex justify-end space-x-4 mt-6">
673 <button
674 onClick={() => setShowConfirmation(false)}
675 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"
676 type="button"
677 >
678 <svg
679 class="w-5 h-5 mr-2"
680 fill="none"
681 stroke="currentColor"
682 viewBox="0 0 24 24"
683 >
684 <path
685 stroke-linecap="round"
686 stroke-linejoin="round"
687 stroke-width="2"
688 d="M6 18L18 6M6 6l12 12"
689 >
690 </path>
691 </svg>
692 Cancel
693 </button>
694 <button
695 onClick={handleConfirmation}
696 class={`px-4 py-2 rounded-md flex items-center transition font-semibold ${
697 confirmationText.trim().toLowerCase() === "migrate"
698 ? "bg-red-600 text-white hover:bg-red-700 cursor-pointer"
699 : "bg-red-300 text-white cursor-not-allowed"
700 }`}
701 type="button"
702 disabled={confirmationText.trim().toLowerCase() !== "migrate"}
703 >
704 <svg
705 class="w-5 h-5 mr-2"
706 fill="none"
707 stroke="currentColor"
708 viewBox="0 0 24 24"
709 >
710 <path
711 stroke-linecap="round"
712 stroke-linejoin="round"
713 stroke-width="2"
714 d="M5 13l4 4L19 7"
715 >
716 </path>
717 </svg>
718 Confirm Migration
719 </button>
720 </div>
721 </div>
722 </div>
723 )}
724 </div>
725 );
726}