+49
-24
src/App.tsx
+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
+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
+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
+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
+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
+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
+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
}