ai-generated junk tool for migrating atproto identities in-browser

[feat] turn actions list into actual pages that (right now) only tell what they do

Changed files
+397 -56
src
+49 -24
src/App.tsx
··· 1 1 import { useState, useEffect } from 'react' 2 2 import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' 3 3 import { AtpAgent } from '@atproto/api' 4 + import { AvatarProvider } from './contexts/AvatarContext' 4 5 import Login from './components/auth/Login' 5 6 import Actions from './components/common/Actions' 7 + import Migration from './components/common/Migration' 8 + import RecoveryKey from './components/common/RecoveryKey' 6 9 import './styles/App.css' 7 10 8 11 const SESSION_KEY = 'atproto_session'; ··· 49 52 }; 50 53 51 54 return ( 52 - <Router> 53 - <Routes> 54 - <Route 55 - path="/" 56 - element={ 57 - agent ? ( 58 - <Navigate to="/actions" replace /> 59 - ) : ( 60 - <Login onLogin={handleLogin} /> 61 - ) 62 - } 63 - /> 64 - <Route 65 - path="/actions" 66 - element={ 67 - agent ? ( 68 - <Actions agent={agent} onLogout={handleLogout} /> 69 - ) : ( 70 - <Navigate to="/" replace /> 71 - ) 72 - } 73 - /> 74 - </Routes> 75 - </Router> 55 + <AvatarProvider> 56 + <Router> 57 + <Routes> 58 + <Route 59 + path="/" 60 + element={ 61 + agent ? ( 62 + <Navigate to="/actions" replace /> 63 + ) : ( 64 + <Login onLogin={handleLogin} /> 65 + ) 66 + } 67 + /> 68 + <Route 69 + path="/actions" 70 + element={ 71 + agent ? ( 72 + <Actions agent={agent} onLogout={handleLogout} /> 73 + ) : ( 74 + <Navigate to="/" replace /> 75 + ) 76 + } 77 + /> 78 + <Route 79 + path="/migration" 80 + element={ 81 + agent ? ( 82 + <Migration agent={agent} onLogout={handleLogout} /> 83 + ) : ( 84 + <Navigate to="/" replace /> 85 + ) 86 + } 87 + /> 88 + <Route 89 + path="/recovery-key" 90 + element={ 91 + agent ? ( 92 + <RecoveryKey agent={agent} onLogout={handleLogout} /> 93 + ) : ( 94 + <Navigate to="/" replace /> 95 + ) 96 + } 97 + /> 98 + </Routes> 99 + </Router> 100 + </AvatarProvider> 76 101 ) 77 102 } 78 103
+15 -30
src/components/common/Actions.tsx
··· 2 2 import { AtpAgent } from '@atproto/api'; 3 3 import { useNavigate } from 'react-router-dom'; 4 4 import Footer from '../layout/Footer'; 5 + import Header from '../layout/Header'; 5 6 import '../../styles/App.css'; 6 7 7 8 interface ActionsProps { ··· 12 13 export default function Actions({ agent, onLogout }: ActionsProps) { 13 14 const [didDoc, setDidDoc] = useState<string>(''); 14 15 const [loading, setLoading] = useState(true); 15 - const [avatarUrl, setAvatarUrl] = useState<string>(''); 16 16 const navigate = useNavigate(); 17 17 18 18 const handleLogout = () => { ··· 28 28 throw new Error('No DID found in session'); 29 29 } 30 30 31 - // Fetch profile using authenticated agent 32 - const profile = await agent.getProfile({ actor: agent.session?.handle || '' }); 33 - if (profile.data.avatar) { 34 - setAvatarUrl(profile.data.avatar); 35 - } 36 - 37 31 let didDocResponse; 38 32 39 33 if (did.startsWith('did:plc:')) { ··· 51 45 52 46 setDidDoc(JSON.stringify(didDocResponse, null, 2)); 53 47 } catch (err) { 54 - console.error('Error fetching profile or DID document:', err); 48 + console.error('Error fetching DID document:', err); 55 49 setDidDoc(`Error fetching DID document: ${err instanceof Error ? err.message : 'Unknown error'}`); 56 50 } finally { 57 51 setLoading(false); ··· 71 65 72 66 return ( 73 67 <div className="actions-page"> 74 - <header className="app-header"> 75 - <h1 className="app-title">ATproto Migrator</h1> 76 - <div className="user-info"> 77 - {avatarUrl && ( 78 - <img 79 - src={avatarUrl} 80 - alt="Profile" 81 - className="user-avatar" 82 - /> 83 - )} 84 - <span className="user-handle">{agent.session?.handle}</span> 85 - <button className="logout-button" onClick={handleLogout}> 86 - Logout 87 - </button> 88 - </div> 89 - </header> 68 + <Header agent={agent} onLogout={handleLogout} /> 90 69 91 70 <div className="actions-container"> 92 71 <div className="actions-list"> 93 - <div className="action-item"> 72 + <button 73 + className="action-item" 74 + onClick={() => navigate('/migration')} 75 + > 94 76 <div className="action-icon"> 95 77 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 96 78 <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" /> ··· 98 80 </div> 99 81 <div className="action-content"> 100 82 <div className="action-title">Migrate account</div> 101 - <div className="action-subtitle">Move your account to a new host</div> 83 + <div className="action-subtitle">Move your account to a new data server</div> 102 84 </div> 103 - </div> 85 + </button> 104 86 105 - <div className="action-item"> 87 + <button 88 + className="action-item" 89 + onClick={() => navigate('/recovery-key')} 90 + > 106 91 <div className="action-icon"> 107 92 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 108 93 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> ··· 111 96 </div> 112 97 <div className="action-content"> 113 98 <div className="action-title">Add recovery key</div> 114 - <div className="action-subtitle">Create a new recovery method for your account</div> 99 + <div className="action-subtitle">Create a new recovery key for your account</div> 115 100 </div> 116 - </div> 101 + </button> 117 102 </div> 118 103 119 104 <details className="user-info-section">
+64
src/components/common/Migration.tsx
··· 1 + import { useNavigate } from 'react-router-dom'; 2 + import { AtpAgent } from '@atproto/api'; 3 + import Footer from '../layout/Footer'; 4 + import Header from '../layout/Header'; 5 + import '../../styles/App.css'; 6 + 7 + interface MigrationProps { 8 + agent: AtpAgent; 9 + onLogout: () => void; 10 + } 11 + 12 + export default function Migration({ agent, onLogout }: MigrationProps) { 13 + const navigate = useNavigate(); 14 + 15 + return ( 16 + <div className="actions-page"> 17 + <Header agent={agent} onLogout={onLogout} /> 18 + 19 + <div className="actions-container"> 20 + <div className="page-content"> 21 + <h2>Migrate your account</h2> 22 + <p>This tool allows you to migrate your account to a new Personal Data Server, a data storage service that hosts your account and all of its data.</p> 23 + <h3>What to expect</h3> 24 + <p>The migration process is <i>possible</i>, however it is not recommended if you are unsure about what you are doing, especially if you are migrating your primary account.</p> 25 + <p>You will need the following items to begin the migration process:</p> 26 + <ul> 27 + <li>A new PDS to migrate to</li> 28 + <li>An invite code from the new PDS (if required)</li> 29 + <li>A PLC operation token to confirm the migration</li> 30 + <li>A new password for your account <b>(Your password will not be stored by this tool.)</b></li> 31 + <li>If you are not using a custom domain, you will need a new handle as the default domain (such as alice.bsky.social or bob.example-pds.com) is non-transferable.</li> 32 + </ul> 33 + 34 + <div className="warning-section"> 35 + <h3>⚠️ Read Before Continuing ⚠️</h3> 36 + <ul> 37 + <li>If you are already on a third-party PDS, it must be able to send emails or you will not be able to get a PLC operation token without direct access to the server.</li> 38 + <li>Due to performance issues, the main Bluesky data servers do not allow for account data to be imported at this time. <b>You will not be able to migrate back to Bluesky servers.</b></li> 39 + <li>If your PDS goes down and you do not have access to a recovery key, you will be locked out of your account. <b>Bluesky developers will not be able to help you.</b></li> 40 + </ul> 41 + </div> 42 + 43 + <div className="docs-section"> 44 + <h3>Additional Resources</h3> 45 + <p>For the technically inclined, here are some additional resources for how the migration process works:</p> 46 + <ul> 47 + <li><a href="https://github.com/bluesky-social/pds/blob/main/ACCOUNT_MIGRATION.md" target="_blank" rel="noopener noreferrer">Detailed document on migration for PDS hosters</a></li> 48 + <li><a href="https://atproto.com/guides/account-migration" target="_blank" rel="noopener noreferrer">AT Protocol's developer documentation on account migration</a></li> 49 + <li><a href="https://whtwnd.com/bnewbold.net/3l5ii332pf32u">Guide to migrating an account using the command line</a></li> 50 + </ul> 51 + </div> 52 + 53 + <button 54 + className="back-button" 55 + onClick={() => navigate('/actions')} 56 + > 57 + ← Go back 58 + </button> 59 + </div> 60 + </div> 61 + <Footer /> 62 + </div> 63 + ); 64 + }
+59
src/components/common/RecoveryKey.tsx
··· 1 + import { useNavigate } from 'react-router-dom'; 2 + import { AtpAgent } from '@atproto/api'; 3 + import Footer from '../layout/Footer'; 4 + import Header from '../layout/Header'; 5 + import '../../styles/App.css'; 6 + 7 + interface RecoveryKeyProps { 8 + agent: AtpAgent; 9 + onLogout: () => void; 10 + } 11 + 12 + export default function RecoveryKey({ agent, onLogout }: RecoveryKeyProps) { 13 + const navigate = useNavigate(); 14 + 15 + return ( 16 + <div className="actions-page"> 17 + <Header agent={agent} onLogout={onLogout} /> 18 + 19 + <div className="actions-container"> 20 + <div className="page-content"> 21 + <h2>Add a recovery key</h2> 22 + <p>A recovery key (known as a <b>rotation key</b> in the AT Protocol) is a cryptographic key associated with your account that allows you to modify your account's core identity.</p> 23 + 24 + <h3>How rotation keys work</h3> 25 + <p>In the AT Protocol, your account is identified using a DID (<b>Decentralized Identifier</b>), with most accounts on the protocol using a variant of it developed specifically for the protocol. The account's core information (such as your handle and data server on the network) is stored in the account's DID document.</p> 26 + <p>To change this document, you use a rotation key to confirm that you are the owner of the account and that you are authorized to make the changes. For example, when changing your handle, your data server (also known as a PDS) will use its own rotation key to change it without asking you to manually sign the operation.</p> 27 + <h3>Why should I add another key?</h3> 28 + <p>Adding a rotation key allows you to regain control of your account if it is compromised. It also allows you to move your account to a new data server, even if the current server is down.</p> 29 + <div className="warning-section"> 30 + <h3>⚠️ Read Before Continuing ⚠️</h3> 31 + <ul> 32 + <li>You will need a PLC operation token to add a recovery key. Tokens are sent to the email address associated with your account.</li> 33 + <li>While we do generate a key for you, we will not store it. Please save it in a secure location.</li> 34 + <li>Keep your recovery key private. Anyone with access to it could potentially take control of your account.</li> 35 + <li>If you're using a third-party PDS, it must be able to send emails or you will not be able to use this tool to add a recovery key.</li> 36 + </ul> 37 + </div> 38 + <div className="docs-section"> 39 + <h3>Additional Resources</h3> 40 + <p>For the technically inclined, here are some additional resources for how rotation keys work:</p> 41 + <ul> 42 + <li><a href="https://atproto.com/guides/identity" target="_blank" rel="noopener noreferrer">AT Protocol's developer documentation on identity</a></li> 43 + <li><a href="https://whtwnd.com/did:plc:xz3euvkhf44iadavovbsmqoo/3laimapx6ks2b" target="_blank" rel="noopener noreferrer">Guide to adding a recovery key using the command line</a></li> 44 + <li><a href="https://whtwnd.com/did:plc:44ybard66vv44zksje25o7dz/3lj7jmt2ct72r" target="_blank" rel="noopener noreferrer">More in-depth guide to adding a recovery key</a></li> 45 + </ul> 46 + </div> 47 + 48 + <button 49 + className="back-button" 50 + onClick={() => navigate('/actions')} 51 + > 52 + ← Go back 53 + </button> 54 + </div> 55 + </div> 56 + <Footer /> 57 + </div> 58 + ); 59 + }
+50
src/components/layout/Header.tsx
··· 1 + import { useEffect } from 'react'; 2 + import { AtpAgent } from '@atproto/api'; 3 + import { useAvatar } from '../../contexts/AvatarContext'; 4 + import '../../styles/App.css'; 5 + 6 + interface HeaderProps { 7 + agent: AtpAgent; 8 + onLogout: () => void; 9 + } 10 + 11 + export default function Header({ agent, onLogout }: HeaderProps) { 12 + const { avatarUrl, setAvatarUrl } = useAvatar(); 13 + 14 + useEffect(() => { 15 + const fetchProfile = async () => { 16 + // Only fetch if we don't already have an avatar 17 + if (!avatarUrl) { 18 + try { 19 + const profile = await agent.getProfile({ actor: agent.session?.handle || '' }); 20 + if (profile.data.avatar) { 21 + setAvatarUrl(profile.data.avatar); 22 + } 23 + } catch (err) { 24 + console.error('Error fetching profile:', err); 25 + } 26 + } 27 + }; 28 + 29 + fetchProfile(); 30 + }, [agent, avatarUrl, setAvatarUrl]); 31 + 32 + return ( 33 + <header className="app-header"> 34 + <h1 className="app-title">ATproto Migrator</h1> 35 + <div className="user-info"> 36 + {avatarUrl && ( 37 + <img 38 + src={avatarUrl} 39 + alt="Profile" 40 + className="user-avatar" 41 + /> 42 + )} 43 + <span className="user-handle">{agent.session?.handle}</span> 44 + <button className="logout-button" onClick={onLogout}> 45 + Logout 46 + </button> 47 + </div> 48 + </header> 49 + ); 50 + }
+26
src/contexts/AvatarContext.tsx
··· 1 + import { createContext, useContext, useState, ReactNode } from 'react'; 2 + 3 + interface AvatarContextType { 4 + avatarUrl: string; 5 + setAvatarUrl: (url: string) => void; 6 + } 7 + 8 + const AvatarContext = createContext<AvatarContextType | undefined>(undefined); 9 + 10 + export function AvatarProvider({ children }: { children: ReactNode }) { 11 + const [avatarUrl, setAvatarUrl] = useState<string>(''); 12 + 13 + return ( 14 + <AvatarContext.Provider value={{ avatarUrl, setAvatarUrl }}> 15 + {children} 16 + </AvatarContext.Provider> 17 + ); 18 + } 19 + 20 + export function useAvatar() { 21 + const context = useContext(AvatarContext); 22 + if (context === undefined) { 23 + throw new Error('useAvatar must be used within an AvatarProvider'); 24 + } 25 + return context; 26 + }
+134 -2
src/styles/App.css
··· 48 48 max-width: 28rem; 49 49 width: 100%; 50 50 padding: 2rem; 51 + padding-top: 0; 51 52 background-color: var(--white); 52 53 border-radius: 0.5rem; 53 54 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); ··· 247 248 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 248 249 cursor: pointer; 249 250 transition: transform 0.2s, box-shadow 0.2s; 251 + border: none; 252 + width: 100%; 253 + text-align: left; 250 254 } 251 255 252 256 .action-item:hover { ··· 255 259 } 256 260 257 261 .action-icon { 258 - width: 2rem; 259 - height: 2rem; 262 + width: 2.5rem; 263 + height: 2.5rem; 260 264 display: flex; 261 265 align-items: center; 262 266 justify-content: center; 263 267 color: var(--primary-color); 268 + background-color: var(--bg-color); 269 + border-radius: 0.5rem; 270 + flex-shrink: 0; 264 271 } 265 272 266 273 .action-content { 267 274 flex: 1; 275 + text-align: left; 268 276 } 269 277 270 278 .action-title { ··· 272 280 font-weight: 600; 273 281 color: var(--text-color); 274 282 margin-bottom: 0.25rem; 283 + text-align: left; 275 284 } 276 285 277 286 .action-subtitle { 278 287 font-size: 0.875rem; 279 288 color: var(--text-light); 289 + text-align: left; 280 290 } 281 291 282 292 /* User info section */ ··· 445 455 background-color: var(--text-light); 446 456 cursor: not-allowed; 447 457 opacity: 0.8; 458 + } 459 + 460 + .page-content { 461 + background-color: var(--white); 462 + border-radius: 0.5rem; 463 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 464 + padding: 2rem; 465 + padding-top: 1rem; 466 + margin-top: 1rem; 467 + } 468 + 469 + .page-content h2 { 470 + font-size: 1.5rem; 471 + font-weight: 600; 472 + color: var(--text-color); 473 + margin-bottom: 1rem; 474 + } 475 + 476 + .page-content p { 477 + color: var(--text-color); 478 + margin-bottom: 1rem; 479 + } 480 + 481 + .page-content ul { 482 + list-style-type: disc; 483 + margin-left: 1.5rem; 484 + margin-bottom: 1.5rem; 485 + color: var(--text-color); 486 + } 487 + 488 + .page-content li { 489 + margin-bottom: 0.5rem; 490 + } 491 + 492 + .back-button { 493 + background-color: var(--primary-color); 494 + color: var(--white); 495 + border: none; 496 + padding: 0.75rem 1.5rem; 497 + border-radius: 0.375rem; 498 + font-size: 0.875rem; 499 + font-weight: 500; 500 + cursor: pointer; 501 + transition: background-color 0.2s; 502 + display: inline-flex; 503 + align-items: center; 504 + gap: 0.5rem; 505 + } 506 + 507 + .back-button:hover { 508 + background-color: var(--primary-hover); 509 + } 510 + 511 + .page-content h3 { 512 + font-size: 1.25rem; 513 + font-weight: 600; 514 + color: var(--text-color); 515 + margin: 1.5rem 0 1rem 0; 516 + } 517 + 518 + .warning-section { 519 + background-color: #fef3c7; 520 + border: 1px solid #fbbf24; 521 + border-radius: 0.5rem; 522 + padding: 1.5rem; 523 + margin: 1.5rem 0; 524 + } 525 + 526 + .warning-section h3 { 527 + color: #92400e; 528 + margin-top: 0; 529 + } 530 + 531 + .warning-section ul { 532 + margin-bottom: 0; 533 + padding-left: 0; 534 + } 535 + 536 + .warning-section li { 537 + color: #92400e; 538 + } 539 + 540 + .warning-section b { 541 + color: #78350f; 542 + } 543 + 544 + .docs-section { 545 + background-color: var(--bg-color); 546 + border-radius: 0.5rem; 547 + padding: 1.5rem; 548 + margin: 1.5rem 0; 549 + } 550 + 551 + .docs-section h3 { 552 + margin-top: 0; 553 + } 554 + 555 + .docs-section ul { 556 + margin-bottom: 0; 557 + padding-left: 0; 558 + } 559 + 560 + .docs-section a { 561 + color: var(--primary-color); 562 + text-decoration: none; 563 + transition: color 0.2s; 564 + display: inline-flex; 565 + align-items: center; 566 + gap: 0.5rem; 567 + } 568 + 569 + .docs-section a:hover { 570 + color: var(--primary-hover); 571 + } 572 + 573 + .docs-section a::after { 574 + content: "→"; 575 + transition: transform 0.2s; 576 + } 577 + 578 + .docs-section a:hover::after { 579 + transform: translateX(4px); 448 580 }