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}