+72
src/components/accordion.tsx
+72
src/components/accordion.tsx
···
···
1
+
import { createSignal, type JSX, Show } from 'solid-js';
2
+
3
+
import ChevronRightIcon from '~/components/ic-icons/baseline-chevron-right';
4
+
5
+
export interface AccordionProps {
6
+
title: string;
7
+
children: JSX.Element;
8
+
defaultOpen?: boolean;
9
+
}
10
+
11
+
export const Accordion = (props: AccordionProps) => {
12
+
const [isOpen, setIsOpen] = createSignal(props.defaultOpen ?? false);
13
+
14
+
return (
15
+
<div class="border-b border-gray-200">
16
+
<button
17
+
type="button"
18
+
onClick={() => setIsOpen(!isOpen())}
19
+
class="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-gray-50"
20
+
>
21
+
<ChevronRightIcon
22
+
class={`h-5 w-5 text-gray-500 transition-transform` + (isOpen() ? ` rotate-90` : ``)}
23
+
/>
24
+
<span class="font-semibold">{props.title}</span>
25
+
</button>
26
+
27
+
<Show when={isOpen()}>
28
+
<div class="pb-4 pl-12 pr-4">{props.children}</div>
29
+
</Show>
30
+
</div>
31
+
);
32
+
};
33
+
34
+
export interface SubsectionProps {
35
+
title: string;
36
+
children: JSX.Element;
37
+
}
38
+
39
+
export const Subsection = (props: SubsectionProps) => {
40
+
return (
41
+
<div class="mb-4 last:mb-0">
42
+
<h4 class="mb-3 text-sm font-semibold text-gray-600">{props.title}</h4>
43
+
<div class="flex flex-col gap-3">{props.children}</div>
44
+
</div>
45
+
);
46
+
};
47
+
48
+
export interface StatusBadgeProps {
49
+
variant: 'idle' | 'pending' | 'success' | 'error';
50
+
children: JSX.Element;
51
+
}
52
+
53
+
export const StatusBadge = (props: StatusBadgeProps) => {
54
+
const variantStyles = () => {
55
+
switch (props.variant) {
56
+
case 'idle':
57
+
return 'bg-gray-100 text-gray-600';
58
+
case 'pending':
59
+
return 'bg-yellow-100 text-yellow-800';
60
+
case 'success':
61
+
return 'bg-green-100 text-green-800';
62
+
case 'error':
63
+
return 'bg-red-100 text-red-800';
64
+
}
65
+
};
66
+
67
+
return (
68
+
<span class={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${variantStyles()}`}>
69
+
{props.children}
70
+
</span>
71
+
);
72
+
};
+3
-1
src/components/inputs/text-input.tsx
+3
-1
src/components/inputs/text-input.tsx
···
4
5
import type { BoundInputEvent } from './_types';
6
7
-
interface TextInputProps {
8
label: JSX.Element;
9
blurb?: JSX.Element;
10
monospace?: boolean;
11
type?: 'text' | 'password' | 'url' | 'email';
12
name?: string;
13
required?: boolean;
14
autocomplete?: 'off' | 'on' | 'one-time-code' | 'username';
15
autocorrect?: 'off' | 'on';
16
pattern?: string;
···
55
id={fieldId}
56
name={props.name}
57
required={props.required}
58
autocomplete={props.autocomplete}
59
pattern={props.pattern}
60
placeholder={props.placeholder}
···
4
5
import type { BoundInputEvent } from './_types';
6
7
+
export interface TextInputProps {
8
label: JSX.Element;
9
blurb?: JSX.Element;
10
monospace?: boolean;
11
type?: 'text' | 'password' | 'url' | 'email';
12
name?: string;
13
required?: boolean;
14
+
disabled?: boolean;
15
autocomplete?: 'off' | 'on' | 'one-time-code' | 'username';
16
autocorrect?: 'off' | 'on';
17
pattern?: string;
···
56
id={fieldId}
57
name={props.name}
58
required={props.required}
59
+
disabled={props.disabled}
60
autocomplete={props.autocomplete}
61
pattern={props.pattern}
62
placeholder={props.placeholder}
+17
src/lib/utils/stream.ts
+17
src/lib/utils/stream.ts
···
···
1
+
export async function* iterateStream<T>(stream: ReadableStream<T>) {
2
+
const reader = stream.getReader();
3
+
4
+
try {
5
+
while (true) {
6
+
const { done, value } = await reader.read();
7
+
8
+
if (done) {
9
+
return;
10
+
}
11
+
12
+
yield value;
13
+
}
14
+
} finally {
15
+
reader.releaseLock();
16
+
}
17
+
}
+5
src/routes.ts
+5
src/routes.ts
+49
src/views/account/account-migrate/context.tsx
+49
src/views/account/account-migrate/context.tsx
···
···
1
+
import { createContext, createSignal, useContext, type JSX } from 'solid-js';
2
+
3
+
import type { CredentialManager } from '@atcute/client';
4
+
import type { DidDocument } from '@atcute/identity';
5
+
import type { AtprotoDid, Did } from '@atcute/lexicons/syntax';
6
+
7
+
export interface SourceAccount {
8
+
did: AtprotoDid;
9
+
didDoc: DidDocument;
10
+
pdsUrl: string;
11
+
manager: CredentialManager | null;
12
+
}
13
+
14
+
export interface DestinationAccount {
15
+
pdsUrl: string;
16
+
serviceDid: Did;
17
+
manager: CredentialManager | null;
18
+
}
19
+
20
+
export interface MigrationContextValue {
21
+
source: () => SourceAccount | null;
22
+
setSource: (account: SourceAccount | null) => void;
23
+
destination: () => DestinationAccount | null;
24
+
setDestination: (account: DestinationAccount | null) => void;
25
+
}
26
+
27
+
const MigrationContext = createContext<MigrationContextValue>();
28
+
29
+
export const MigrationProvider = (props: { children: JSX.Element }) => {
30
+
const [source, setSource] = createSignal<SourceAccount | null>(null);
31
+
const [destination, setDestination] = createSignal<DestinationAccount | null>(null);
32
+
33
+
const value: MigrationContextValue = {
34
+
source,
35
+
setSource,
36
+
destination,
37
+
setDestination,
38
+
};
39
+
40
+
return <MigrationContext.Provider value={value}>{props.children}</MigrationContext.Provider>;
41
+
};
42
+
43
+
export const useMigration = (): MigrationContextValue => {
44
+
const context = useContext(MigrationContext);
45
+
if (!context) {
46
+
throw new Error('useMigration must be used within a MigrationProvider');
47
+
}
48
+
return context;
49
+
};
+54
src/views/account/account-migrate/page.tsx
+54
src/views/account/account-migrate/page.tsx
···
···
1
+
import { createEffect, createSignal, onCleanup } from 'solid-js';
2
+
3
+
import { history } from '~/globals/navigation';
4
+
5
+
import { useTitle } from '~/lib/navigation/router';
6
+
7
+
import PageHeader from '~/components/page-header';
8
+
9
+
import { MigrationProvider } from './context';
10
+
11
+
import SourceAccountSection from './sections/source-account';
12
+
import DestinationAccountSection from './sections/destination-account';
13
+
import RepositorySection from './sections/repository';
14
+
import BlobsSection from './sections/blobs';
15
+
import PreferencesSection from './sections/preferences';
16
+
import IdentitySection from './sections/identity';
17
+
import AccountStatusSection from './sections/account-status';
18
+
19
+
const AccountMigratePage = () => {
20
+
const [hasStarted, setHasStarted] = createSignal(false);
21
+
22
+
createEffect(() => {
23
+
if (hasStarted()) {
24
+
const cleanup = history.block((tx) => {
25
+
if (window.confirm(`You have a migration in progress. Leave this page?`)) {
26
+
cleanup();
27
+
tx.retry();
28
+
}
29
+
});
30
+
31
+
onCleanup(cleanup);
32
+
}
33
+
});
34
+
35
+
useTitle(() => `Migrate account — boat`);
36
+
37
+
return (
38
+
<MigrationProvider>
39
+
<PageHeader title="Migrate account" subtitle="Move your account data to another server" />
40
+
41
+
<div class="flex flex-col">
42
+
<SourceAccountSection onStarted={() => setHasStarted(true)} />
43
+
<DestinationAccountSection />
44
+
<RepositorySection />
45
+
<BlobsSection />
46
+
<PreferencesSection />
47
+
<IdentitySection />
48
+
<AccountStatusSection />
49
+
</div>
50
+
</MigrationProvider>
51
+
);
52
+
};
53
+
54
+
export default AccountMigratePage;
+207
src/views/account/account-migrate/sections/account-status.tsx
+207
src/views/account/account-migrate/sections/account-status.tsx
···
···
1
+
import { Show } from 'solid-js';
2
+
3
+
import { Client, type CredentialManager, ok } from '@atcute/client';
4
+
5
+
import { createMutation } from '~/lib/utils/mutation';
6
+
7
+
import { Accordion, StatusBadge, Subsection } from '~/components/accordion';
8
+
import Button from '~/components/inputs/button';
9
+
10
+
import { useMigration } from '../context';
11
+
12
+
interface AccountStatus {
13
+
activated: boolean;
14
+
validDid: boolean;
15
+
repoCommit: string;
16
+
repoRev: string;
17
+
repoBlocks: number;
18
+
indexedRecords: number;
19
+
privateStateValues: number;
20
+
expectedBlobs: number;
21
+
importedBlobs: number;
22
+
}
23
+
24
+
const AccountStatusSection = () => {
25
+
const { source, destination } = useMigration();
26
+
27
+
const checkSourceMutation = createMutation({
28
+
async mutationFn({ manager }: { manager: CredentialManager }) {
29
+
const sourceClient = new Client({ handler: manager });
30
+
return await ok(sourceClient.get('com.atproto.server.checkAccountStatus')) as AccountStatus;
31
+
},
32
+
onError(err) {
33
+
console.error(err);
34
+
},
35
+
});
36
+
37
+
const checkDestMutation = createMutation({
38
+
async mutationFn({ manager }: { manager: CredentialManager }) {
39
+
const destClient = new Client({ handler: manager });
40
+
return await ok(destClient.get('com.atproto.server.checkAccountStatus')) as AccountStatus;
41
+
},
42
+
onError(err) {
43
+
console.error(err);
44
+
},
45
+
});
46
+
47
+
const activateMutation = createMutation({
48
+
async mutationFn({ manager }: { manager: CredentialManager }) {
49
+
const destClient = new Client({ handler: manager });
50
+
await ok(destClient.post('com.atproto.server.activateAccount', { as: null }));
51
+
},
52
+
onSuccess() {
53
+
const dest = destination();
54
+
if (dest?.manager) {
55
+
checkDestMutation.mutate({ manager: dest.manager });
56
+
}
57
+
},
58
+
onError(err) {
59
+
console.error(err);
60
+
},
61
+
});
62
+
63
+
const deactivateMutation = createMutation({
64
+
async mutationFn({ manager }: { manager: CredentialManager }) {
65
+
if (!confirm('Are you sure you want to deactivate your source account? This will prevent the old PDS from serving your data.')) {
66
+
throw new Error('Cancelled');
67
+
}
68
+
const sourceClient = new Client({ handler: manager });
69
+
await ok(sourceClient.post('com.atproto.server.deactivateAccount', { as: null, input: {} }));
70
+
},
71
+
onSuccess() {
72
+
const src = source();
73
+
if (src?.manager) {
74
+
checkSourceMutation.mutate({ manager: src.manager });
75
+
}
76
+
},
77
+
onError(err) {
78
+
if (err instanceof Error && err.message === 'Cancelled') return;
79
+
console.error(err);
80
+
},
81
+
});
82
+
83
+
const renderStatus = (status: AccountStatus) => (
84
+
<div class="space-y-1 text-sm">
85
+
<p>
86
+
<span class="text-gray-500">Status:</span>{' '}
87
+
<StatusBadge variant={status.activated ? 'success' : 'idle'}>
88
+
{status.activated ? 'Active' : 'Deactivated'}
89
+
</StatusBadge>
90
+
</p>
91
+
<p>
92
+
<span class="text-gray-500">Records:</span>{' '}
93
+
<span class="font-mono">{status.indexedRecords}</span>
94
+
</p>
95
+
<p>
96
+
<span class="text-gray-500">Blobs:</span>{' '}
97
+
<span class="font-mono">{status.importedBlobs}/{status.expectedBlobs}</span>
98
+
</p>
99
+
<p>
100
+
<span class="text-gray-500">Repo blocks:</span>{' '}
101
+
<span class="font-mono">{status.repoBlocks}</span>
102
+
</p>
103
+
</div>
104
+
);
105
+
106
+
return (
107
+
<Accordion title="Account Status">
108
+
<Subsection title="Source account">
109
+
<Show
110
+
when={source()?.manager}
111
+
fallback={<p class="text-sm text-gray-500">Sign in to source account first.</p>}
112
+
>
113
+
{(manager) => (
114
+
<>
115
+
<div class="flex items-center gap-3">
116
+
<Button
117
+
variant="outline"
118
+
onClick={() => checkSourceMutation.mutate({ manager: manager() })}
119
+
disabled={checkSourceMutation.isPending}
120
+
>
121
+
{checkSourceMutation.isPending ? 'Checking...' : 'Check status'}
122
+
</Button>
123
+
</div>
124
+
125
+
<Show when={checkSourceMutation.isError}>
126
+
<p class="text-sm text-red-600">{`${checkSourceMutation.error}`}</p>
127
+
</Show>
128
+
129
+
<Show when={checkSourceMutation.data}>
130
+
{(status) => (
131
+
<>
132
+
{renderStatus(status())}
133
+
134
+
<Show when={status().activated}>
135
+
<div class="mt-3">
136
+
<Button
137
+
variant="secondary"
138
+
onClick={() => deactivateMutation.mutate({ manager: manager() })}
139
+
disabled={deactivateMutation.isPending}
140
+
>
141
+
{deactivateMutation.isPending ? 'Deactivating...' : 'Deactivate source account'}
142
+
</Button>
143
+
</div>
144
+
</Show>
145
+
</>
146
+
)}
147
+
</Show>
148
+
</>
149
+
)}
150
+
</Show>
151
+
</Subsection>
152
+
153
+
<Subsection title="Destination account">
154
+
<Show
155
+
when={destination()?.manager}
156
+
fallback={<p class="text-sm text-gray-500">Sign in to destination account first.</p>}
157
+
>
158
+
{(manager) => (
159
+
<>
160
+
<div class="flex items-center gap-3">
161
+
<Button
162
+
variant="outline"
163
+
onClick={() => checkDestMutation.mutate({ manager: manager() })}
164
+
disabled={checkDestMutation.isPending}
165
+
>
166
+
{checkDestMutation.isPending ? 'Checking...' : 'Check status'}
167
+
</Button>
168
+
</div>
169
+
170
+
<Show when={checkDestMutation.isError}>
171
+
<p class="text-sm text-red-600">{`${checkDestMutation.error}`}</p>
172
+
</Show>
173
+
174
+
<Show when={checkDestMutation.data}>
175
+
{(status) => (
176
+
<>
177
+
{renderStatus(status())}
178
+
179
+
<Show when={!status().activated}>
180
+
<div class="mt-3">
181
+
<Button
182
+
onClick={() => activateMutation.mutate({ manager: manager() })}
183
+
disabled={activateMutation.isPending}
184
+
>
185
+
{activateMutation.isPending ? 'Activating...' : 'Activate destination account'}
186
+
</Button>
187
+
</div>
188
+
</Show>
189
+
</>
190
+
)}
191
+
</Show>
192
+
</>
193
+
)}
194
+
</Show>
195
+
</Subsection>
196
+
197
+
<Show when={activateMutation.isError || deactivateMutation.isError}>
198
+
<p class="text-sm text-red-600">
199
+
{activateMutation.isError ? `Failed to activate: ${activateMutation.error}` : ''}
200
+
{deactivateMutation.isError ? `Failed to deactivate: ${deactivateMutation.error}` : ''}
201
+
</p>
202
+
</Show>
203
+
</Accordion>
204
+
);
205
+
};
206
+
207
+
export default AccountStatusSection;
+454
src/views/account/account-migrate/sections/blobs.tsx
+454
src/views/account/account-migrate/sections/blobs.tsx
···
···
1
+
import { showOpenFilePicker, showSaveFilePicker } from 'native-file-system-adapter';
2
+
import { createSignal, For, Show } from 'solid-js';
3
+
4
+
import { Client, ClientResponseError, type CredentialManager, ok, simpleFetchHandler } from '@atcute/client';
5
+
import { untar, writeTarEntry } from '@mary/tar';
6
+
7
+
import { createMutation } from '~/lib/utils/mutation';
8
+
9
+
import { Accordion, StatusBadge, Subsection } from '~/components/accordion';
10
+
import Button from '~/components/inputs/button';
11
+
12
+
import { useMigration, type SourceAccount } from '../context';
13
+
14
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
15
+
16
+
const BlobsSection = () => {
17
+
const { source, destination } = useMigration();
18
+
19
+
// Progress state (kept separate since mutations don't handle incremental updates)
20
+
const [exportProgress, setExportProgress] = createSignal<string>();
21
+
const [importProgress, setImportProgress] = createSignal<string>();
22
+
23
+
const exportMutation = createMutation({
24
+
async mutationFn({ source }: { source: SourceAccount }) {
25
+
const sourceClient = new Client({ handler: simpleFetchHandler({ service: source.pdsUrl }) });
26
+
27
+
setExportProgress('Retrieving list of blobs...');
28
+
29
+
// Get list of all blobs
30
+
let blobs: string[] = [];
31
+
let cursor: string | undefined;
32
+
do {
33
+
const data = await ok(
34
+
sourceClient.get('com.atproto.sync.listBlobs', {
35
+
params: { did: source.did, cursor, limit: 1_000 },
36
+
}),
37
+
);
38
+
cursor = data.cursor;
39
+
blobs = blobs.concat(data.cids);
40
+
setExportProgress(`Retrieving list of blobs (found ${blobs.length})`);
41
+
} while (cursor !== undefined);
42
+
43
+
if (blobs.length === 0) {
44
+
return { count: 0, cancelled: false };
45
+
}
46
+
47
+
setExportProgress('Waiting for file picker...');
48
+
49
+
const fd = await showSaveFilePicker({
50
+
suggestedName: `blobs-${source.did}-${new Date().toISOString()}.tar`,
51
+
// @ts-expect-error: ponyfill doesn't have the full typings
52
+
id: 'blob-export',
53
+
startIn: 'downloads',
54
+
types: [
55
+
{
56
+
description: 'Tarball archive',
57
+
accept: { 'application/tar': ['.tar'] },
58
+
},
59
+
],
60
+
}).catch((err) => {
61
+
if (err instanceof DOMException && err.name === 'AbortError') {
62
+
return undefined;
63
+
}
64
+
throw err;
65
+
});
66
+
67
+
if (!fd) {
68
+
return { count: 0, cancelled: true };
69
+
}
70
+
71
+
const writable = await fd.createWritable();
72
+
73
+
let downloaded = 0;
74
+
for (const cid of blobs) {
75
+
setExportProgress(`Downloading blobs (${downloaded}/${blobs.length})`);
76
+
77
+
const downloadBlob = async (): Promise<Uint8Array | undefined> => {
78
+
let attempts = 0;
79
+
while (true) {
80
+
if (attempts > 0) await sleep(2_000);
81
+
attempts++;
82
+
83
+
try {
84
+
const response = await sourceClient.get('com.atproto.sync.getBlob', {
85
+
as: 'bytes',
86
+
params: { did: source.did, cid },
87
+
});
88
+
89
+
if (response.ok) {
90
+
return response.data;
91
+
}
92
+
93
+
if (response.status === 400 && response.data.message === 'Blob not found') {
94
+
return undefined;
95
+
}
96
+
97
+
if (response.status === 429) {
98
+
await sleep(10_000);
99
+
}
100
+
101
+
if (attempts < 3) continue;
102
+
throw new ClientResponseError(response);
103
+
} catch (err) {
104
+
if (attempts < 3) continue;
105
+
throw err;
106
+
}
107
+
}
108
+
};
109
+
110
+
const data = await downloadBlob();
111
+
if (data !== undefined) {
112
+
const entry = writeTarEntry({ filename: `blobs/${cid}`, data });
113
+
await writable.write(entry);
114
+
}
115
+
116
+
downloaded++;
117
+
}
118
+
119
+
await writable.close();
120
+
return { count: blobs.length, cancelled: false };
121
+
},
122
+
onError(err) {
123
+
console.error(err);
124
+
},
125
+
onSettled() {
126
+
setExportProgress();
127
+
},
128
+
});
129
+
130
+
const importFromFileMutation = createMutation({
131
+
async mutationFn({ destManager }: { destManager: CredentialManager }) {
132
+
setImportProgress('Waiting for file picker...');
133
+
134
+
const [fd] = await showOpenFilePicker({
135
+
// @ts-expect-error: ponyfill doesn't have the full typings
136
+
id: 'blob-import',
137
+
types: [
138
+
{
139
+
description: 'Tarball archive',
140
+
accept: { 'application/tar': ['.tar'] },
141
+
},
142
+
],
143
+
}).catch((err) => {
144
+
if (err instanceof DOMException && err.name === 'AbortError') {
145
+
return [undefined];
146
+
}
147
+
throw err;
148
+
});
149
+
150
+
if (!fd) {
151
+
return { uploaded: 0, failed: 0, cancelled: true };
152
+
}
153
+
154
+
setImportProgress('Reading archive...');
155
+
const file = await fd.getFile();
156
+
157
+
const destClient = new Client({ handler: destManager });
158
+
159
+
let uploaded = 0;
160
+
let failed = 0;
161
+
162
+
for await (const entry of untar(file.stream())) {
163
+
if (entry.type !== 'file') continue;
164
+
165
+
const filename = entry.name;
166
+
// Extract CID from path like "blobs/bafk..."
167
+
const cid = filename.split('/').pop();
168
+
if (!cid) continue;
169
+
170
+
setImportProgress(`Uploading blobs (${uploaded} uploaded, ${failed} failed)`);
171
+
172
+
try {
173
+
const data = await entry.bytes();
174
+
await destClient.post('com.atproto.repo.uploadBlob', {
175
+
encoding: 'application/octet-stream',
176
+
input: data,
177
+
});
178
+
uploaded++;
179
+
} catch (err) {
180
+
console.error(`Failed to upload blob ${cid}:`, err);
181
+
failed++;
182
+
}
183
+
}
184
+
185
+
return { uploaded, failed, cancelled: false };
186
+
},
187
+
onError(err) {
188
+
console.error(err);
189
+
},
190
+
onSettled() {
191
+
setImportProgress();
192
+
},
193
+
});
194
+
195
+
const importFromSourceMutation = createMutation({
196
+
async mutationFn({ source, destManager }: { source: SourceAccount; destManager: CredentialManager }) {
197
+
setImportProgress('Checking for missing blobs...');
198
+
199
+
const sourceClient = new Client({ handler: simpleFetchHandler({ service: source.pdsUrl }) });
200
+
const destClient = new Client({ handler: destManager });
201
+
202
+
let uploaded = 0;
203
+
let failed = 0;
204
+
let cursor: string | undefined;
205
+
206
+
do {
207
+
const data = await ok(
208
+
destClient.get('com.atproto.repo.listMissingBlobs', {
209
+
params: { cursor, limit: 100 },
210
+
}),
211
+
);
212
+
cursor = data.cursor;
213
+
214
+
for (const blob of data.blobs) {
215
+
setImportProgress(`Uploading missing blobs (${uploaded} uploaded, ${failed} failed)`);
216
+
217
+
try {
218
+
const response = await sourceClient.get('com.atproto.sync.getBlob', {
219
+
as: 'stream',
220
+
params: { did: source.did, cid: blob.cid },
221
+
});
222
+
223
+
if (!response.ok) {
224
+
failed++;
225
+
continue;
226
+
}
227
+
228
+
const contentType = response.headers.get('content-type') || 'application/octet-stream';
229
+
230
+
await destClient.post('com.atproto.repo.uploadBlob', {
231
+
encoding: contentType,
232
+
input: response.data,
233
+
});
234
+
235
+
uploaded++;
236
+
} catch (err) {
237
+
console.error(`Failed to transfer blob ${blob.cid}:`, err);
238
+
failed++;
239
+
}
240
+
}
241
+
} while (cursor !== undefined);
242
+
243
+
return { uploaded, failed };
244
+
},
245
+
onError(err) {
246
+
console.error(err);
247
+
},
248
+
onSettled() {
249
+
setImportProgress();
250
+
},
251
+
});
252
+
253
+
const checkStatusMutation = createMutation({
254
+
async mutationFn({ destManager }: { destManager: CredentialManager }) {
255
+
const destClient = new Client({ handler: destManager });
256
+
const status = await ok(destClient.get('com.atproto.server.checkAccountStatus'));
257
+
258
+
let missingBlobs: string[] = [];
259
+
260
+
// Get list of missing blobs if any
261
+
if (status.expectedBlobs > status.importedBlobs) {
262
+
let cursor: string | undefined;
263
+
do {
264
+
const data = await ok(
265
+
destClient.get('com.atproto.repo.listMissingBlobs', {
266
+
params: { cursor, limit: 100 },
267
+
}),
268
+
);
269
+
cursor = data.cursor;
270
+
missingBlobs.push(...data.blobs.map((b) => b.cid));
271
+
} while (cursor !== undefined);
272
+
}
273
+
274
+
return {
275
+
expected: status.expectedBlobs,
276
+
imported: status.importedBlobs,
277
+
missingBlobs,
278
+
};
279
+
},
280
+
onError(err) {
281
+
console.error(err);
282
+
},
283
+
});
284
+
285
+
const isImporting = () => importFromFileMutation.isPending || importFromSourceMutation.isPending;
286
+
287
+
const getExportStatusText = () => {
288
+
const data = exportMutation.data;
289
+
if (data?.cancelled) return undefined;
290
+
if (data?.count === 0) return 'No blobs to export';
291
+
if (data) return `Exported ${data.count} blobs`;
292
+
return exportProgress();
293
+
};
294
+
295
+
const getImportStatusText = () => {
296
+
const fileData = importFromFileMutation.data;
297
+
const sourceData = importFromSourceMutation.data;
298
+
299
+
if (fileData && !fileData.cancelled) {
300
+
return `Uploaded ${fileData.uploaded} blobs` + (fileData.failed > 0 ? ` (${fileData.failed} failed)` : '');
301
+
}
302
+
if (sourceData) {
303
+
if (sourceData.uploaded === 0 && sourceData.failed === 0) return 'No missing blobs';
304
+
return `Uploaded ${sourceData.uploaded} blobs` + (sourceData.failed > 0 ? ` (${sourceData.failed} failed)` : '');
305
+
}
306
+
return importProgress();
307
+
};
308
+
309
+
const getImportError = () => importFromFileMutation.error || importFromSourceMutation.error;
310
+
311
+
return (
312
+
<Accordion title="Blobs">
313
+
<Subsection title="Export from source">
314
+
<p class="text-sm text-gray-600">
315
+
Download all blobs as a tarball for backup or manual import.
316
+
</p>
317
+
318
+
<Show when={source()} fallback={<p class="text-sm text-gray-500">Resolve source account first.</p>}>
319
+
{(src) => (
320
+
<>
321
+
<div class="flex items-center gap-3">
322
+
<Button
323
+
onClick={() => exportMutation.mutate({ source: src() })}
324
+
disabled={exportMutation.isPending}
325
+
>
326
+
{exportMutation.isPending ? 'Exporting...' : 'Export to file'}
327
+
</Button>
328
+
<Show when={getExportStatusText()}>
329
+
{(text) => <span class="text-sm text-gray-600">{text()}</span>}
330
+
</Show>
331
+
</div>
332
+
333
+
<Show when={exportMutation.error}>
334
+
{(err) => <p class="text-sm text-red-600">{`${err()}`}</p>}
335
+
</Show>
336
+
</>
337
+
)}
338
+
</Show>
339
+
</Subsection>
340
+
341
+
<Subsection title="Import to destination">
342
+
<p class="text-sm text-gray-600">
343
+
Upload blobs from a tarball or transfer directly from source.
344
+
</p>
345
+
346
+
<Show
347
+
when={destination()?.manager}
348
+
fallback={<p class="text-sm text-gray-500">Sign in to destination account first.</p>}
349
+
>
350
+
{(destManager) => (
351
+
<>
352
+
<div class="flex flex-wrap items-center gap-3">
353
+
<Button
354
+
onClick={() => importFromFileMutation.mutate({ destManager: destManager() })}
355
+
disabled={isImporting()}
356
+
>
357
+
{isImporting() ? 'Importing...' : 'Import from file'}
358
+
</Button>
359
+
360
+
<Show when={source()}>
361
+
{(src) => (
362
+
<Button
363
+
variant="secondary"
364
+
onClick={() =>
365
+
importFromSourceMutation.mutate({ source: src(), destManager: destManager() })
366
+
}
367
+
disabled={isImporting()}
368
+
>
369
+
Transfer from source
370
+
</Button>
371
+
)}
372
+
</Show>
373
+
</div>
374
+
375
+
<Show when={getImportStatusText()}>
376
+
{(text) => <span class="text-sm text-gray-600">{text()}</span>}
377
+
</Show>
378
+
379
+
<Show when={getImportError()}>
380
+
{(err) => <p class="text-sm text-red-600">{`${err()}`}</p>}
381
+
</Show>
382
+
</>
383
+
)}
384
+
</Show>
385
+
</Subsection>
386
+
387
+
<Subsection title="Status">
388
+
<Show
389
+
when={destination()?.manager}
390
+
fallback={<p class="text-sm text-gray-500">Sign in to destination account first.</p>}
391
+
>
392
+
{(destManager) => (
393
+
<>
394
+
<div class="flex items-center gap-3">
395
+
<Button
396
+
variant="outline"
397
+
onClick={() => checkStatusMutation.mutate({ destManager: destManager() })}
398
+
disabled={checkStatusMutation.isPending}
399
+
>
400
+
{checkStatusMutation.isPending ? 'Checking...' : 'Check status'}
401
+
</Button>
402
+
403
+
<Show when={checkStatusMutation.data}>
404
+
{(status) => (
405
+
<span class="text-sm">
406
+
<StatusBadge
407
+
variant={status().imported === status().expected ? 'success' : 'pending'}
408
+
>
409
+
{status().imported}/{status().expected} blobs
410
+
</StatusBadge>
411
+
</span>
412
+
)}
413
+
</Show>
414
+
</div>
415
+
416
+
<Show when={checkStatusMutation.data?.missingBlobs.length}>
417
+
{(count) => (
418
+
<div class="mt-2 rounded border border-yellow-300 bg-yellow-50 p-3">
419
+
<p class="mb-2 text-sm font-medium text-yellow-800">{count()} missing blobs</p>
420
+
421
+
<Show when={source()}>
422
+
{(src) => (
423
+
<Button
424
+
variant="secondary"
425
+
onClick={() =>
426
+
importFromSourceMutation.mutate({ source: src(), destManager: destManager() })
427
+
}
428
+
disabled={isImporting()}
429
+
>
430
+
Transfer missing from source
431
+
</Button>
432
+
)}
433
+
</Show>
434
+
435
+
<details class="mt-2">
436
+
<summary class="cursor-pointer text-sm text-yellow-700">Show CIDs</summary>
437
+
<div class="mt-1 max-h-32 overflow-auto font-mono text-xs">
438
+
<For each={checkStatusMutation.data?.missingBlobs}>
439
+
{(cid) => <div class="truncate">{cid}</div>}
440
+
</For>
441
+
</div>
442
+
</details>
443
+
</div>
444
+
)}
445
+
</Show>
446
+
</>
447
+
)}
448
+
</Show>
449
+
</Subsection>
450
+
</Accordion>
451
+
);
452
+
};
453
+
454
+
export default BlobsSection;
+437
src/views/account/account-migrate/sections/destination-account.tsx
+437
src/views/account/account-migrate/sections/destination-account.tsx
···
···
1
+
import { createSignal, Show } from 'solid-js';
2
+
3
+
import {
4
+
type AtpAccessJwt,
5
+
Client,
6
+
ClientResponseError,
7
+
CredentialManager,
8
+
ok,
9
+
simpleFetchHandler,
10
+
} from '@atcute/client';
11
+
import type { Did, Handle } from '@atcute/lexicons/syntax';
12
+
13
+
import { formatTotpCode, TOTP_RE } from '~/api/utils/auth';
14
+
import { decodeJwt } from '~/api/utils/jwt';
15
+
import { isServiceUrlString } from '~/api/types/strings';
16
+
17
+
import { createMutation } from '~/lib/utils/mutation';
18
+
19
+
import { Accordion, StatusBadge, Subsection } from '~/components/accordion';
20
+
import Button from '~/components/inputs/button';
21
+
import TextInput from '~/components/inputs/text-input';
22
+
23
+
import { useMigration } from '../context';
24
+
25
+
class InsufficientLoginError extends Error {}
26
+
27
+
const DestinationAccountSection = () => {
28
+
const { source, destination, setDestination } = useMigration();
29
+
30
+
// Connect state
31
+
const [pdsUrl, setPdsUrl] = createSignal('');
32
+
const [connectError, setConnectError] = createSignal<string>();
33
+
34
+
// Create account state
35
+
const [newHandle, setNewHandle] = createSignal('');
36
+
const [newEmail, setNewEmail] = createSignal('');
37
+
const [newPassword, setNewPassword] = createSignal('');
38
+
const [inviteCode, setInviteCode] = createSignal('');
39
+
const [createError, setCreateError] = createSignal<string>();
40
+
41
+
// Login state
42
+
const [loginPassword, setLoginPassword] = createSignal('');
43
+
const [loginOtp, setLoginOtp] = createSignal('');
44
+
const [isLoginTotpRequired, setIsLoginTotpRequired] = createSignal(false);
45
+
const [loginError, setLoginError] = createSignal<string>();
46
+
47
+
const connectMutation = createMutation({
48
+
async mutationFn({ pdsUrl }: { pdsUrl: string }) {
49
+
const destClient = new Client({ handler: simpleFetchHandler({ service: pdsUrl }) });
50
+
const desc = await ok(destClient.get('com.atproto.server.describeServer'));
51
+
52
+
return { serviceDid: desc.did };
53
+
},
54
+
onMutate() {
55
+
setConnectError();
56
+
},
57
+
onSuccess({ serviceDid }) {
58
+
setDestination({ pdsUrl: pdsUrl(), serviceDid, manager: null });
59
+
},
60
+
onError(err) {
61
+
console.error(err);
62
+
setConnectError(`Failed to connect: ${err}`);
63
+
},
64
+
});
65
+
66
+
const createAccountMutation = createMutation({
67
+
async mutationFn({
68
+
sourceDid,
69
+
sourceManager,
70
+
destPdsUrl,
71
+
destServiceDid,
72
+
handle,
73
+
email,
74
+
password,
75
+
inviteCode,
76
+
}: {
77
+
sourceDid: Did;
78
+
sourceManager: CredentialManager;
79
+
destPdsUrl: string;
80
+
destServiceDid: string;
81
+
handle: Handle;
82
+
email: string;
83
+
password: string;
84
+
inviteCode: string;
85
+
}) {
86
+
// Get service auth token from old PDS
87
+
const sourceClient = new Client({ handler: sourceManager });
88
+
const authResp = await ok(
89
+
sourceClient.get('com.atproto.server.getServiceAuth', {
90
+
params: {
91
+
aud: destServiceDid as Did,
92
+
lxm: 'com.atproto.server.createAccount',
93
+
},
94
+
}),
95
+
);
96
+
const serviceJwt = authResp.token;
97
+
98
+
// Create account on new PDS with service auth
99
+
const destClient = new Client({ handler: simpleFetchHandler({ service: destPdsUrl }) });
100
+
const createResp = await destClient.post('com.atproto.server.createAccount', {
101
+
headers: { Authorization: `Bearer ${serviceJwt}` },
102
+
input: {
103
+
did: sourceDid,
104
+
handle: handle,
105
+
email: email,
106
+
password: password,
107
+
inviteCode: inviteCode || undefined,
108
+
},
109
+
});
110
+
111
+
if (!createResp.ok) {
112
+
throw new ClientResponseError(createResp);
113
+
}
114
+
115
+
if (createResp.data.did !== sourceDid) {
116
+
throw new Error(`Created account has different DID: ${createResp.data.did}`);
117
+
}
118
+
119
+
// Login to the new account
120
+
const manager = new CredentialManager({ service: destPdsUrl });
121
+
await manager.login({
122
+
identifier: sourceDid,
123
+
password: password,
124
+
});
125
+
126
+
return manager;
127
+
},
128
+
onMutate() {
129
+
setCreateError();
130
+
},
131
+
onSuccess(manager) {
132
+
setDestination({ ...destination()!, manager });
133
+
setNewPassword('');
134
+
},
135
+
onError(err) {
136
+
if (err instanceof ClientResponseError) {
137
+
if (err.error === 'InvalidInviteCode') {
138
+
setCreateError(`Invalid invite code`);
139
+
return;
140
+
}
141
+
if (err.error === 'HandleNotAvailable') {
142
+
setCreateError(`Handle is not available`);
143
+
return;
144
+
}
145
+
if (err.description) {
146
+
setCreateError(err.description);
147
+
return;
148
+
}
149
+
}
150
+
console.error(err);
151
+
setCreateError(`${err}`);
152
+
},
153
+
});
154
+
155
+
const loginMutation = createMutation({
156
+
async mutationFn({
157
+
pdsUrl,
158
+
did,
159
+
password,
160
+
otp,
161
+
}: {
162
+
pdsUrl: string;
163
+
did: string;
164
+
password: string;
165
+
otp: string;
166
+
}) {
167
+
const manager = new CredentialManager({ service: pdsUrl });
168
+
const session = await manager.login({
169
+
identifier: did,
170
+
password: password,
171
+
code: formatTotpCode(otp),
172
+
});
173
+
174
+
const decoded = decodeJwt(session.accessJwt) as AtpAccessJwt;
175
+
if (decoded.scope !== 'com.atproto.access') {
176
+
throw new InsufficientLoginError(`You need to sign in with a main password, not an app password`);
177
+
}
178
+
179
+
return manager;
180
+
},
181
+
onMutate() {
182
+
setLoginError();
183
+
},
184
+
onSuccess(manager) {
185
+
setDestination({ ...destination()!, manager });
186
+
setLoginPassword('');
187
+
setLoginOtp('');
188
+
setIsLoginTotpRequired(false);
189
+
},
190
+
onError(err) {
191
+
if (err instanceof ClientResponseError) {
192
+
if (err.error === 'AuthFactorTokenRequired') {
193
+
setLoginOtp('');
194
+
setIsLoginTotpRequired(true);
195
+
return;
196
+
}
197
+
if (err.error === 'AuthenticationRequired') {
198
+
setLoginError(`Invalid identifier or password`);
199
+
return;
200
+
}
201
+
if (err.description?.includes('Token is invalid')) {
202
+
setLoginError(`Invalid one-time confirmation code`);
203
+
setIsLoginTotpRequired(true);
204
+
return;
205
+
}
206
+
}
207
+
if (err instanceof InsufficientLoginError) {
208
+
setLoginError(err.message);
209
+
return;
210
+
}
211
+
console.error(err);
212
+
setLoginError(`${err}`);
213
+
},
214
+
});
215
+
216
+
const isConnected = () => destination() !== null;
217
+
const isAuthenticated = () => destination()?.manager != null;
218
+
const canCreateAccount = () => source()?.manager != null;
219
+
220
+
return (
221
+
<Accordion title="Destination Account">
222
+
<Subsection title="Connect to PDS">
223
+
<Show when={!isConnected()}>
224
+
<form
225
+
onSubmit={(ev) => {
226
+
ev.preventDefault();
227
+
connectMutation.mutate({ pdsUrl: pdsUrl() });
228
+
}}
229
+
class="flex flex-col gap-3"
230
+
>
231
+
<TextInput
232
+
label="PDS URL"
233
+
type="url"
234
+
placeholder="https://pds.example.com"
235
+
value={pdsUrl()}
236
+
required
237
+
onChange={(text, event) => {
238
+
setPdsUrl(text);
239
+
const input = event.currentTarget;
240
+
if (text !== '' && !isServiceUrlString(text)) {
241
+
input.setCustomValidity('Must be a valid URL');
242
+
} else {
243
+
input.setCustomValidity('');
244
+
}
245
+
}}
246
+
/>
247
+
248
+
<Show when={connectError()}>
249
+
<p class="text-sm text-red-600">{connectError()}</p>
250
+
</Show>
251
+
252
+
<div>
253
+
<Button type="submit" disabled={connectMutation.isPending}>
254
+
{connectMutation.isPending ? 'Connecting...' : 'Connect'}
255
+
</Button>
256
+
</div>
257
+
</form>
258
+
</Show>
259
+
260
+
<Show when={isConnected()}>
261
+
<div class="flex flex-col gap-2 text-sm">
262
+
<p>
263
+
<span class="text-gray-500">URL:</span>{' '}
264
+
<span class="font-mono">{destination()!.pdsUrl}</span>
265
+
</p>
266
+
<p>
267
+
<span class="text-gray-500">Service DID:</span>{' '}
268
+
<span class="font-mono">{destination()!.serviceDid}</span>
269
+
</p>
270
+
<div class="mt-1">
271
+
<button
272
+
type="button"
273
+
onClick={() => setDestination(null)}
274
+
class="text-sm text-purple-800 hover:underline"
275
+
>
276
+
Change PDS
277
+
</button>
278
+
</div>
279
+
</div>
280
+
</Show>
281
+
</Subsection>
282
+
283
+
<Show when={isConnected() && !isAuthenticated()}>
284
+
<Subsection title="Create new account">
285
+
<Show when={!canCreateAccount()}>
286
+
<p class="text-sm text-gray-600">
287
+
You need to authenticate to your source account first to create an account on the
288
+
destination PDS.
289
+
</p>
290
+
</Show>
291
+
292
+
<Show when={canCreateAccount()}>
293
+
<form
294
+
onSubmit={(ev) => {
295
+
ev.preventDefault();
296
+
const src = source()!;
297
+
const dest = destination()!;
298
+
createAccountMutation.mutate({
299
+
sourceDid: src.did,
300
+
sourceManager: src.manager!,
301
+
destPdsUrl: dest.pdsUrl,
302
+
destServiceDid: dest.serviceDid,
303
+
handle: newHandle() as Handle,
304
+
email: newEmail(),
305
+
password: newPassword(),
306
+
inviteCode: inviteCode(),
307
+
});
308
+
}}
309
+
class="flex flex-col gap-3"
310
+
>
311
+
<TextInput
312
+
label="Handle"
313
+
placeholder="alice.pds.example.com"
314
+
value={newHandle()}
315
+
required
316
+
onChange={setNewHandle}
317
+
/>
318
+
319
+
<TextInput
320
+
label="Email"
321
+
type="email"
322
+
placeholder="alice@example.com"
323
+
value={newEmail()}
324
+
required
325
+
onChange={setNewEmail}
326
+
/>
327
+
328
+
<TextInput
329
+
label="Password"
330
+
type="password"
331
+
value={newPassword()}
332
+
required
333
+
onChange={setNewPassword}
334
+
/>
335
+
336
+
<TextInput
337
+
label="Invite code (if required)"
338
+
placeholder="pds-example-com-xxxxx"
339
+
value={inviteCode()}
340
+
onChange={setInviteCode}
341
+
/>
342
+
343
+
<Show when={createError()}>
344
+
<p class="text-sm text-red-600">{createError()}</p>
345
+
</Show>
346
+
347
+
<div>
348
+
<Button type="submit" disabled={createAccountMutation.isPending}>
349
+
{createAccountMutation.isPending ? 'Creating...' : 'Create account'}
350
+
</Button>
351
+
</div>
352
+
</form>
353
+
</Show>
354
+
</Subsection>
355
+
356
+
<Subsection title="Or login to existing account">
357
+
<p class="mb-2 text-sm text-gray-600">
358
+
If you already have a deactivated account on the destination PDS.
359
+
</p>
360
+
361
+
<Show when={!source()}>
362
+
<p class="text-sm text-gray-600">
363
+
Resolve your source account first so we know which DID to use.
364
+
</p>
365
+
</Show>
366
+
367
+
<Show when={source()}>
368
+
<form
369
+
onSubmit={(ev) => {
370
+
ev.preventDefault();
371
+
const src = source()!;
372
+
const dest = destination()!;
373
+
loginMutation.mutate({
374
+
pdsUrl: dest.pdsUrl,
375
+
did: src.did,
376
+
password: loginPassword(),
377
+
otp: loginOtp(),
378
+
});
379
+
}}
380
+
class="flex flex-col gap-3"
381
+
>
382
+
<TextInput
383
+
label="Password"
384
+
type="password"
385
+
value={loginPassword()}
386
+
required
387
+
onChange={setLoginPassword}
388
+
/>
389
+
390
+
<Show when={isLoginTotpRequired()}>
391
+
<TextInput
392
+
label="One-time confirmation code"
393
+
blurb="A code has been sent to your email address."
394
+
type="text"
395
+
autocomplete="one-time-code"
396
+
pattern={TOTP_RE.source}
397
+
placeholder="AAAAA-BBBBB"
398
+
value={loginOtp()}
399
+
required
400
+
onChange={setLoginOtp}
401
+
monospace
402
+
/>
403
+
</Show>
404
+
405
+
<Show when={loginError()}>
406
+
<p class="text-sm text-red-600">{loginError()}</p>
407
+
</Show>
408
+
409
+
<div>
410
+
<Button type="submit" disabled={loginMutation.isPending}>
411
+
{loginMutation.isPending ? 'Signing in...' : 'Sign in'}
412
+
</Button>
413
+
</div>
414
+
</form>
415
+
</Show>
416
+
</Subsection>
417
+
</Show>
418
+
419
+
<Show when={isAuthenticated()}>
420
+
<Subsection title="Account status">
421
+
<div class="flex items-center gap-2">
422
+
<StatusBadge variant="success">Signed in</StatusBadge>
423
+
<button
424
+
type="button"
425
+
onClick={() => setDestination({ ...destination()!, manager: null })}
426
+
class="text-sm text-purple-800 hover:underline"
427
+
>
428
+
Sign out
429
+
</button>
430
+
</div>
431
+
</Subsection>
432
+
</Show>
433
+
</Accordion>
434
+
);
435
+
};
436
+
437
+
export default DestinationAccountSection;
+443
src/views/account/account-migrate/sections/identity.tsx
+443
src/views/account/account-migrate/sections/identity.tsx
···
···
1
+
import { createSignal, For, Index, Show } from 'solid-js';
2
+
3
+
import { Client, ClientResponseError, type CredentialManager, ok } from '@atcute/client';
4
+
import { type DidKeyString, Secp256k1PrivateKeyExportable } from '@atcute/crypto';
5
+
6
+
import { formatTotpCode, TOTP_RE } from '~/api/utils/auth';
7
+
8
+
import { createMutation } from '~/lib/utils/mutation';
9
+
10
+
import { Accordion, StatusBadge, Subsection } from '~/components/accordion';
11
+
import Button from '~/components/inputs/button';
12
+
import TextInput from '~/components/inputs/text-input';
13
+
import ToggleInput from '~/components/inputs/toggle-input';
14
+
15
+
import { useMigration } from '../context';
16
+
17
+
interface RecommendedCredentials {
18
+
alsoKnownAs?: string[];
19
+
rotationKeys?: string[];
20
+
verificationMethods?: Record<string, unknown>;
21
+
services?: Record<string, unknown>;
22
+
}
23
+
24
+
interface GeneratedKeypair {
25
+
publicDidKey: DidKeyString;
26
+
privateHex: string;
27
+
privateMultikey: string;
28
+
}
29
+
30
+
const IdentitySection = () => {
31
+
const { source, destination } = useMigration();
32
+
33
+
// Rotation key state
34
+
const [useGeneratedKey, setUseGeneratedKey] = createSignal(false);
35
+
const [customKeys, setCustomKeys] = createSignal<string[]>([]);
36
+
const [plcToken, setPlcToken] = createSignal('');
37
+
38
+
const requestTokenMutation = createMutation({
39
+
async mutationFn({ manager }: { manager: CredentialManager }) {
40
+
const client = new Client({ handler: manager });
41
+
await ok(client.post('com.atproto.identity.requestPlcOperationSignature', { as: null }));
42
+
},
43
+
onError(err) {
44
+
console.error(err);
45
+
},
46
+
});
47
+
48
+
const loadCredentialsMutation = createMutation({
49
+
async mutationFn({ manager }: { manager: CredentialManager }) {
50
+
const client = new Client({ handler: manager });
51
+
return (await ok(client.get('com.atproto.identity.getRecommendedDidCredentials', {}))) as RecommendedCredentials;
52
+
},
53
+
onError(err) {
54
+
console.error(err);
55
+
},
56
+
});
57
+
58
+
const generateKeyMutation = createMutation({
59
+
async mutationFn() {
60
+
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
61
+
const [publicDidKey, privateHex, privateMultikey] = await Promise.all([
62
+
keypair.exportPublicKey('did'),
63
+
keypair.exportPrivateKey('rawHex'),
64
+
keypair.exportPrivateKey('multikey'),
65
+
]);
66
+
return { publicDidKey, privateHex, privateMultikey } as GeneratedKeypair;
67
+
},
68
+
onError(err) {
69
+
console.error(err);
70
+
},
71
+
});
72
+
73
+
const signAndSubmitMutation = createMutation({
74
+
async mutationFn({
75
+
sourceManager,
76
+
destManager,
77
+
token,
78
+
credentials,
79
+
generatedKey,
80
+
customKeys,
81
+
}: {
82
+
sourceManager: CredentialManager;
83
+
destManager: CredentialManager;
84
+
token: string;
85
+
credentials: RecommendedCredentials;
86
+
generatedKey?: GeneratedKeypair;
87
+
customKeys: string[];
88
+
}) {
89
+
const sourceClient = new Client({ handler: sourceManager });
90
+
const destClient = new Client({ handler: destManager });
91
+
92
+
// Prepend user keys to PDS-provided keys (so user keys appear first for recovery)
93
+
const pdsRotationKeys = credentials.rotationKeys ?? [];
94
+
const userKeys: string[] = [];
95
+
if (generatedKey) {
96
+
userKeys.push(generatedKey.publicDidKey);
97
+
}
98
+
userKeys.push(...customKeys.filter((k) => k.trim()));
99
+
const rotationKeys = [...userKeys, ...pdsRotationKeys];
100
+
101
+
// Sign the PLC operation on the source PDS
102
+
const signage = await ok(
103
+
sourceClient.post('com.atproto.identity.signPlcOperation', {
104
+
input: {
105
+
token: formatTotpCode(token),
106
+
alsoKnownAs: credentials.alsoKnownAs,
107
+
rotationKeys: rotationKeys,
108
+
services: credentials.services,
109
+
verificationMethods: credentials.verificationMethods,
110
+
},
111
+
}),
112
+
);
113
+
114
+
// Submit via the destination PDS
115
+
await ok(
116
+
destClient.post('com.atproto.identity.submitPlcOperation', {
117
+
as: null,
118
+
input: {
119
+
operation: signage.operation,
120
+
},
121
+
}),
122
+
);
123
+
},
124
+
onSuccess() {
125
+
setPlcToken('');
126
+
},
127
+
onError(err) {
128
+
console.error(err);
129
+
},
130
+
});
131
+
132
+
// Calculate rotation key counts
133
+
const pdsKeyCount = () => loadCredentialsMutation.data?.rotationKeys?.length ?? 0;
134
+
const totalKeyCount = () => {
135
+
const custom = customKeys().filter((k) => k.trim()).length;
136
+
const generated = useGeneratedKey() && generateKeyMutation.data ? 1 : 0;
137
+
return pdsKeyCount() + custom + generated;
138
+
};
139
+
const canAddCustomKey = () => totalKeyCount() < 5;
140
+
const isOverLimit = () => totalKeyCount() > 5;
141
+
142
+
const addCustomKey = () => {
143
+
if (canAddCustomKey()) {
144
+
setCustomKeys([...customKeys(), '']);
145
+
}
146
+
};
147
+
148
+
const removeCustomKey = (index: number) => {
149
+
setCustomKeys(customKeys().filter((_, i) => i !== index));
150
+
};
151
+
152
+
const updateCustomKey = (index: number, value: string) => {
153
+
setCustomKeys(customKeys().map((k, i) => (i === index ? value : k)));
154
+
};
155
+
156
+
const canSignAndSubmit = () => {
157
+
const src = source();
158
+
const dest = destination();
159
+
const creds = loadCredentialsMutation.data;
160
+
const token = plcToken().trim();
161
+
162
+
return !!(src?.manager && dest?.manager && creds && token && !isOverLimit());
163
+
};
164
+
165
+
const handleSignAndSubmit = () => {
166
+
const src = source();
167
+
const dest = destination();
168
+
const creds = loadCredentialsMutation.data;
169
+
const token = plcToken().trim();
170
+
171
+
if (!src?.manager || !dest?.manager || !creds || !token || isOverLimit()) return;
172
+
173
+
signAndSubmitMutation.mutate({
174
+
sourceManager: src.manager,
175
+
destManager: dest.manager,
176
+
token,
177
+
credentials: creds,
178
+
generatedKey: useGeneratedKey() ? generateKeyMutation.data : undefined,
179
+
customKeys: customKeys(),
180
+
});
181
+
};
182
+
183
+
const getSubmitErrorMessage = () => {
184
+
const err = signAndSubmitMutation.error;
185
+
if (err instanceof ClientResponseError) {
186
+
if (err.error === 'InvalidToken' || err.error === 'ExpiredToken') {
187
+
return 'Confirmation code has expired or is invalid';
188
+
}
189
+
}
190
+
return `${err}`;
191
+
};
192
+
193
+
return (
194
+
<Accordion title="Identity (PLC)">
195
+
<div class="mb-4 rounded border border-yellow-300 bg-yellow-50 p-3">
196
+
<p class="text-sm font-medium text-yellow-800">
197
+
This updates your DID document to point to the new PDS. This is the critical step that makes the
198
+
migration official.
199
+
</p>
200
+
</div>
201
+
202
+
<Subsection title="1. Request operation signature">
203
+
<p class="text-sm text-gray-600">Request a confirmation token via email from your source PDS.</p>
204
+
205
+
<Show
206
+
when={source()?.manager}
207
+
fallback={<p class="text-sm text-gray-500">Sign in to source account first.</p>}
208
+
>
209
+
{(manager) => (
210
+
<>
211
+
<div class="flex items-center gap-3">
212
+
<Button
213
+
onClick={() => requestTokenMutation.mutate({ manager: manager() })}
214
+
disabled={requestTokenMutation.isPending}
215
+
>
216
+
{requestTokenMutation.isPending ? 'Requesting...' : 'Request token'}
217
+
</Button>
218
+
219
+
<Show when={requestTokenMutation.isSuccess}>
220
+
<StatusBadge variant="success">Email sent</StatusBadge>
221
+
</Show>
222
+
</div>
223
+
224
+
<Show when={requestTokenMutation.isError}>
225
+
<p class="text-sm text-red-600">{`${requestTokenMutation.error}`}</p>
226
+
</Show>
227
+
228
+
<Show when={requestTokenMutation.isSuccess}>
229
+
<p class="text-sm text-gray-600">Check your email inbox for the confirmation code.</p>
230
+
</Show>
231
+
</>
232
+
)}
233
+
</Show>
234
+
</Subsection>
235
+
236
+
<Subsection title="2. Preview new credentials">
237
+
<p class="text-sm text-gray-600">View what your DID document will look like after the migration.</p>
238
+
239
+
<Show
240
+
when={destination()?.manager}
241
+
fallback={<p class="text-sm text-gray-500">Sign in to destination account first.</p>}
242
+
>
243
+
{(manager) => (
244
+
<>
245
+
<div class="flex items-center gap-3">
246
+
<Button
247
+
variant="outline"
248
+
onClick={() => loadCredentialsMutation.mutate({ manager: manager() })}
249
+
disabled={loadCredentialsMutation.isPending}
250
+
>
251
+
{loadCredentialsMutation.isPending ? 'Loading...' : 'Load credentials'}
252
+
</Button>
253
+
254
+
<Show when={loadCredentialsMutation.isSuccess}>
255
+
<StatusBadge variant="success">Loaded</StatusBadge>
256
+
</Show>
257
+
</div>
258
+
259
+
<Show when={loadCredentialsMutation.isError}>
260
+
<p class="text-sm text-red-600">{`${loadCredentialsMutation.error}`}</p>
261
+
</Show>
262
+
263
+
<Show when={loadCredentialsMutation.data}>
264
+
{(creds) => (
265
+
<>
266
+
<div class="mt-2 text-sm">
267
+
<p class="text-gray-500">
268
+
PDS rotation keys ({creds().rotationKeys?.length ?? 0}/5):
269
+
</p>
270
+
<div class="mt-1 flex flex-col gap-1">
271
+
<For each={creds().rotationKeys ?? []}>
272
+
{(key) => (
273
+
<code class="block truncate text-xs text-gray-700">{key}</code>
274
+
)}
275
+
</For>
276
+
</div>
277
+
</div>
278
+
279
+
<details class="mt-2">
280
+
<summary class="cursor-pointer text-sm text-gray-600">
281
+
View full credentials
282
+
</summary>
283
+
<pre class="mt-2 max-h-48 overflow-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs">
284
+
{JSON.stringify(creds(), null, 2)}
285
+
</pre>
286
+
</details>
287
+
</>
288
+
)}
289
+
</Show>
290
+
</>
291
+
)}
292
+
</Show>
293
+
</Subsection>
294
+
295
+
<Subsection title="3. Rotation keys (optional)">
296
+
<p class="text-sm text-gray-600">
297
+
Add a rotation key to recover your account if your new PDS goes rogue. This will be prepended to
298
+
the PDS rotation keys shown above.
299
+
</p>
300
+
301
+
<ToggleInput
302
+
label="Generate a new rotation key"
303
+
checked={useGeneratedKey()}
304
+
onChange={(checked) => {
305
+
setUseGeneratedKey(checked);
306
+
// Auto-generate if checked and no key exists yet
307
+
if (checked && !generateKeyMutation.data && !generateKeyMutation.isPending) {
308
+
generateKeyMutation.mutate();
309
+
}
310
+
}}
311
+
/>
312
+
313
+
<Show when={useGeneratedKey() && generateKeyMutation.isPending}>
314
+
<p class="mt-2 text-sm text-gray-500">Generating key...</p>
315
+
</Show>
316
+
317
+
<Show when={useGeneratedKey() && generateKeyMutation.isError}>
318
+
<p class="mt-2 text-sm text-red-600">{`${generateKeyMutation.error}`}</p>
319
+
</Show>
320
+
321
+
<Show when={useGeneratedKey() && generateKeyMutation.data}>
322
+
{(keypair) => (
323
+
<div class="rounded border border-green-300 bg-green-50 p-3">
324
+
<p class="mb-2 text-sm font-semibold text-green-800">Save your rotation key private key!</p>
325
+
<p class="mb-3 text-xs text-green-700">
326
+
Store this securely. You'll need it to recover your account if your PDS becomes
327
+
unavailable or malicious.
328
+
</p>
329
+
330
+
<div class="flex flex-col gap-2 text-sm">
331
+
<div>
332
+
<p class="font-medium text-gray-600">Public key (did:key)</p>
333
+
<p class="break-all font-mono text-xs">{keypair().publicDidKey}</p>
334
+
</div>
335
+
<div>
336
+
<p class="font-medium text-gray-600">Private key (hex)</p>
337
+
<p class="break-all font-mono text-xs">{keypair().privateHex}</p>
338
+
</div>
339
+
<div>
340
+
<p class="font-medium text-gray-600">Private key (multikey)</p>
341
+
<p class="break-all font-mono text-xs">{keypair().privateMultikey}</p>
342
+
</div>
343
+
</div>
344
+
</div>
345
+
)}
346
+
</Show>
347
+
348
+
<div class="mt-4 border-t border-gray-200 pt-4">
349
+
<p class="mb-2 text-sm font-medium text-gray-700">Custom rotation keys</p>
350
+
<p class="mb-3 text-xs text-gray-500">
351
+
Add existing rotation keys (did:key format) you already control.
352
+
</p>
353
+
354
+
<Index each={customKeys()}>
355
+
{(key, index) => (
356
+
<div class="mb-2 flex items-center gap-2">
357
+
<TextInput
358
+
label=""
359
+
placeholder="did:key:z..."
360
+
monospace
361
+
autocomplete="off"
362
+
value={key()}
363
+
onChange={(value) => updateCustomKey(index, value)}
364
+
/>
365
+
<button
366
+
type="button"
367
+
class="shrink-0 rounded px-2 py-1 text-sm text-red-600 hover:bg-red-50"
368
+
onClick={() => removeCustomKey(index)}
369
+
>
370
+
Remove
371
+
</button>
372
+
</div>
373
+
)}
374
+
</Index>
375
+
376
+
<Button variant="outline" onClick={addCustomKey} disabled={!canAddCustomKey()}>
377
+
Add rotation key
378
+
</Button>
379
+
380
+
<Show when={isOverLimit()}>
381
+
<p class="mt-2 text-sm text-red-600">
382
+
Too many rotation keys. PLC documents can only have up to 5 rotation keys total.
383
+
</p>
384
+
</Show>
385
+
386
+
<p class="mt-2 text-xs text-gray-500">
387
+
Total keys: {totalKeyCount()}/5 (PDS: {pdsKeyCount()}
388
+
{useGeneratedKey() && generateKeyMutation.data ? ', generated: 1' : ''}
389
+
{customKeys().filter((k) => k.trim()).length > 0
390
+
? `, custom: ${customKeys().filter((k) => k.trim()).length}`
391
+
: ''}
392
+
)
393
+
</p>
394
+
</div>
395
+
</Subsection>
396
+
397
+
<Subsection title="4. Sign and submit">
398
+
<p class="text-sm text-gray-600">Enter the confirmation code and submit the PLC operation.</p>
399
+
400
+
<Show when={!source()?.manager || !destination()?.manager}>
401
+
<p class="text-sm text-gray-500">Sign in to both source and destination accounts first.</p>
402
+
</Show>
403
+
404
+
<Show when={!loadCredentialsMutation.data}>
405
+
<p class="text-sm text-gray-500">Load credentials first.</p>
406
+
</Show>
407
+
408
+
<Show when={useGeneratedKey() && !generateKeyMutation.data}>
409
+
<p class="text-sm text-gray-500">Generate your rotation key first.</p>
410
+
</Show>
411
+
412
+
<Show when={source()?.manager && destination()?.manager && loadCredentialsMutation.data}>
413
+
<TextInput
414
+
label="Confirmation code from email"
415
+
type="text"
416
+
autocomplete="one-time-code"
417
+
pattern={TOTP_RE.source}
418
+
placeholder="AAAAA-BBBBB"
419
+
value={plcToken()}
420
+
onChange={setPlcToken}
421
+
monospace
422
+
/>
423
+
424
+
<div class="flex items-center gap-3">
425
+
<Button onClick={handleSignAndSubmit} disabled={signAndSubmitMutation.isPending || !canSignAndSubmit()}>
426
+
{signAndSubmitMutation.isPending ? 'Submitting...' : 'Sign and submit'}
427
+
</Button>
428
+
429
+
<Show when={signAndSubmitMutation.isSuccess}>
430
+
<StatusBadge variant="success">Identity updated successfully</StatusBadge>
431
+
</Show>
432
+
</div>
433
+
434
+
<Show when={signAndSubmitMutation.isError}>
435
+
<p class="text-sm text-red-600">{getSubmitErrorMessage()}</p>
436
+
</Show>
437
+
</Show>
438
+
</Subsection>
439
+
</Accordion>
440
+
);
441
+
};
442
+
443
+
export default IdentitySection;
+180
src/views/account/account-migrate/sections/preferences.tsx
+180
src/views/account/account-migrate/sections/preferences.tsx
···
···
1
+
import { showSaveFilePicker } from 'native-file-system-adapter';
2
+
import { createSignal, Show } from 'solid-js';
3
+
4
+
import { Client, type CredentialManager, ok } from '@atcute/client';
5
+
6
+
import { createMutation } from '~/lib/utils/mutation';
7
+
8
+
import { Accordion, StatusBadge, Subsection } from '~/components/accordion';
9
+
import Button from '~/components/inputs/button';
10
+
import MultilineInput from '~/components/inputs/multiline-input';
11
+
12
+
import { useMigration } from '../context';
13
+
14
+
const PreferencesSection = () => {
15
+
const { source, destination } = useMigration();
16
+
17
+
const [prefsInput, setPrefsInput] = createSignal('');
18
+
19
+
const exportMutation = createMutation({
20
+
async mutationFn({ sourceManager }: { sourceManager: CredentialManager }) {
21
+
const sourceClient = new Client({ handler: sourceManager });
22
+
const prefs = await ok(sourceClient.get('app.bsky.actor.getPreferences', { params: {} }));
23
+
return JSON.stringify(prefs, null, 2);
24
+
},
25
+
onSuccess(json) {
26
+
setPrefsInput(json);
27
+
},
28
+
onError(err) {
29
+
console.error(err);
30
+
},
31
+
});
32
+
33
+
const downloadPrefs = async () => {
34
+
const prefs = exportMutation.data;
35
+
if (!prefs) return;
36
+
37
+
try {
38
+
const fd = await showSaveFilePicker({
39
+
suggestedName: `preferences-${source()?.did}-${new Date().toISOString()}.json`,
40
+
// @ts-expect-error: ponyfill doesn't have the full typings
41
+
id: 'prefs-export',
42
+
startIn: 'downloads',
43
+
types: [
44
+
{
45
+
description: 'JSON file',
46
+
accept: { 'application/json': ['.json'] },
47
+
},
48
+
],
49
+
}).catch((err) => {
50
+
if (err instanceof DOMException && err.name === 'AbortError') {
51
+
return undefined;
52
+
}
53
+
throw err;
54
+
});
55
+
56
+
if (!fd) return;
57
+
58
+
const writable = await fd.createWritable();
59
+
await writable.write(prefs);
60
+
await writable.close();
61
+
} catch (err) {
62
+
console.error(err);
63
+
}
64
+
};
65
+
66
+
const importMutation = createMutation({
67
+
async mutationFn({ destManager, input }: { destManager: CredentialManager; input: string }) {
68
+
const prefs = JSON.parse(input);
69
+
70
+
// Validate that it has a preferences array
71
+
if (!prefs.preferences || !Array.isArray(prefs.preferences)) {
72
+
throw new Error('Invalid preferences format: missing preferences array');
73
+
}
74
+
75
+
const destClient = new Client({ handler: destManager });
76
+
await destClient.post('app.bsky.actor.putPreferences', {
77
+
as: null,
78
+
input: prefs,
79
+
});
80
+
},
81
+
onError(err) {
82
+
console.error(err);
83
+
},
84
+
});
85
+
86
+
const getImportErrorMessage = () => {
87
+
const err = importMutation.error;
88
+
if (err instanceof SyntaxError) {
89
+
return 'Invalid JSON format';
90
+
}
91
+
return `${err}`;
92
+
};
93
+
94
+
return (
95
+
<Accordion title="Preferences">
96
+
<Subsection title="Export from source">
97
+
<p class="text-sm text-gray-600">
98
+
Export your Bluesky preferences (muted words, content filters, saved feeds, etc).
99
+
</p>
100
+
101
+
<Show
102
+
when={source()?.manager}
103
+
fallback={<p class="text-sm text-gray-500">Sign in to source account first.</p>}
104
+
>
105
+
{(sourceManager) => (
106
+
<>
107
+
<div class="flex items-center gap-3">
108
+
<Button
109
+
onClick={() => exportMutation.mutate({ sourceManager: sourceManager() })}
110
+
disabled={exportMutation.isPending}
111
+
>
112
+
{exportMutation.isPending ? 'Exporting...' : 'Export preferences'}
113
+
</Button>
114
+
115
+
<Show when={exportMutation.data}>
116
+
<Button variant="secondary" onClick={downloadPrefs}>
117
+
Download as file
118
+
</Button>
119
+
</Show>
120
+
</div>
121
+
122
+
<Show when={exportMutation.error}>
123
+
{(err) => <p class="text-sm text-red-600">{`${err()}`}</p>}
124
+
</Show>
125
+
126
+
<Show when={exportMutation.data}>
127
+
{(prefs) => (
128
+
<details class="mt-2">
129
+
<summary class="cursor-pointer text-sm text-gray-600">
130
+
View exported preferences
131
+
</summary>
132
+
<pre class="mt-2 max-h-48 overflow-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs">
133
+
{prefs()}
134
+
</pre>
135
+
</details>
136
+
)}
137
+
</Show>
138
+
</>
139
+
)}
140
+
</Show>
141
+
</Subsection>
142
+
143
+
<Subsection title="Import to destination">
144
+
<p class="text-sm text-gray-600">Paste preferences JSON or use the exported data above.</p>
145
+
146
+
<Show
147
+
when={destination()?.manager}
148
+
fallback={<p class="text-sm text-gray-500">Sign in to destination account first.</p>}
149
+
>
150
+
{(destManager) => (
151
+
<>
152
+
<MultilineInput label="Preferences JSON" value={prefsInput()} onChange={setPrefsInput} />
153
+
154
+
<div class="flex items-center gap-3">
155
+
<Button
156
+
onClick={() =>
157
+
importMutation.mutate({ destManager: destManager(), input: prefsInput().trim() })
158
+
}
159
+
disabled={importMutation.isPending || !prefsInput().trim()}
160
+
>
161
+
{importMutation.isPending ? 'Importing...' : 'Import preferences'}
162
+
</Button>
163
+
164
+
<Show when={importMutation.isSuccess}>
165
+
<StatusBadge variant="success">Preferences imported successfully</StatusBadge>
166
+
</Show>
167
+
</div>
168
+
169
+
<Show when={importMutation.error}>
170
+
<p class="text-sm text-red-600">{getImportErrorMessage()}</p>
171
+
</Show>
172
+
</>
173
+
)}
174
+
</Show>
175
+
</Subsection>
176
+
</Accordion>
177
+
);
178
+
};
179
+
180
+
export default PreferencesSection;
+291
src/views/account/account-migrate/sections/repository.tsx
+291
src/views/account/account-migrate/sections/repository.tsx
···
···
1
+
import { showOpenFilePicker, showSaveFilePicker } from 'native-file-system-adapter';
2
+
import { createSignal, Show } from 'solid-js';
3
+
4
+
import { Client, type CredentialManager, ok, simpleFetchHandler } from '@atcute/client';
5
+
import type { Did } from '@atcute/lexicons/syntax';
6
+
7
+
import { formatBytes } from '~/lib/utils/intl/bytes';
8
+
import { createMutation } from '~/lib/utils/mutation';
9
+
import { iterateStream } from '~/lib/utils/stream';
10
+
11
+
import { Accordion, StatusBadge, Subsection } from '~/components/accordion';
12
+
import Button from '~/components/inputs/button';
13
+
14
+
import { useMigration } from '../context';
15
+
16
+
const RepositorySection = () => {
17
+
const { source, destination } = useMigration();
18
+
19
+
// Export state
20
+
const [exportStatus, setExportStatus] = createSignal<string>();
21
+
22
+
// Import state
23
+
const [importStatus, setImportStatus] = createSignal<string>();
24
+
const [importedRecords, setImportedRecords] = createSignal<number>();
25
+
26
+
const exportMutation = createMutation({
27
+
async mutationFn({ pdsUrl, did }: { pdsUrl: string; did: Did }) {
28
+
setExportStatus('Waiting for file picker...');
29
+
30
+
const fd = await showSaveFilePicker({
31
+
suggestedName: `repo-${did}-${new Date().toISOString()}.car`,
32
+
// @ts-expect-error: ponyfill doesn't have the full typings
33
+
id: 'repo-export',
34
+
startIn: 'downloads',
35
+
types: [
36
+
{
37
+
description: 'CAR archive file',
38
+
accept: { 'application/vnd.ipld.car': ['.car'] },
39
+
},
40
+
],
41
+
}).catch((err) => {
42
+
if (err instanceof DOMException && err.name === 'AbortError') {
43
+
return undefined;
44
+
}
45
+
throw err;
46
+
});
47
+
48
+
if (!fd) {
49
+
setExportStatus();
50
+
return null;
51
+
}
52
+
53
+
const writable = await fd.createWritable();
54
+
55
+
setExportStatus('Downloading repository...');
56
+
57
+
const sourceClient = new Client({ handler: simpleFetchHandler({ service: pdsUrl }) });
58
+
const response = await sourceClient.get('com.atproto.sync.getRepo', {
59
+
as: 'stream',
60
+
params: { did },
61
+
});
62
+
63
+
if (!response.ok) {
64
+
throw new Error(`Failed to download repository: ${response.status}`);
65
+
}
66
+
67
+
let size = 0;
68
+
for await (const chunk of iterateStream(response.data)) {
69
+
size += chunk.length;
70
+
await writable.write(chunk);
71
+
setExportStatus(`Downloading repository... (${formatBytes(size)})`);
72
+
}
73
+
74
+
await writable.close();
75
+
setExportStatus(`Exported ${formatBytes(size)}`);
76
+
return size;
77
+
},
78
+
onMutate() {
79
+
setExportStatus();
80
+
},
81
+
onError(err) {
82
+
console.error(err);
83
+
setExportStatus();
84
+
},
85
+
});
86
+
87
+
const importFromFileMutation = createMutation({
88
+
async mutationFn({ manager }: { manager: CredentialManager }) {
89
+
setImportStatus('Waiting for file picker...');
90
+
91
+
const [fd] = await showOpenFilePicker({
92
+
// @ts-expect-error: ponyfill doesn't have the full typings
93
+
id: 'repo-import',
94
+
types: [
95
+
{
96
+
description: 'CAR archive file',
97
+
accept: { 'application/vnd.ipld.car': ['.car'] },
98
+
},
99
+
],
100
+
}).catch((err) => {
101
+
if (err instanceof DOMException && err.name === 'AbortError') {
102
+
return [undefined];
103
+
}
104
+
throw err;
105
+
});
106
+
107
+
if (!fd) {
108
+
setImportStatus();
109
+
return null;
110
+
}
111
+
112
+
setImportStatus('Reading file...');
113
+
const file = await fd.getFile();
114
+
const data = new Uint8Array(await file.arrayBuffer());
115
+
116
+
setImportStatus(`Uploading repository (${formatBytes(data.length)})...`);
117
+
118
+
const destClient = new Client({ handler: manager });
119
+
const importResp = await destClient.post('com.atproto.repo.importRepo', {
120
+
as: null,
121
+
encoding: 'application/vnd.ipld.car',
122
+
input: data,
123
+
});
124
+
125
+
if (!importResp.ok) {
126
+
throw new Error(`Failed to import repository: ${importResp.status}`);
127
+
}
128
+
129
+
// Check account status to get record count
130
+
const status = await ok(destClient.get('com.atproto.server.checkAccountStatus', {}));
131
+
setImportedRecords(status.indexedRecords);
132
+
133
+
setImportStatus(`Imported successfully`);
134
+
return status.indexedRecords;
135
+
},
136
+
onMutate() {
137
+
setImportStatus();
138
+
setImportedRecords();
139
+
},
140
+
onError(err) {
141
+
console.error(err);
142
+
setImportStatus();
143
+
},
144
+
});
145
+
146
+
const importFromSourceMutation = createMutation({
147
+
async mutationFn({
148
+
sourcePdsUrl,
149
+
sourceDid,
150
+
destManager,
151
+
}: {
152
+
sourcePdsUrl: string;
153
+
sourceDid: Did;
154
+
destManager: CredentialManager;
155
+
}) {
156
+
setImportStatus('Downloading from source PDS...');
157
+
158
+
const sourceClient = new Client({ handler: simpleFetchHandler({ service: sourcePdsUrl }) });
159
+
const response = await sourceClient.get('com.atproto.sync.getRepo', {
160
+
as: 'bytes',
161
+
params: { did: sourceDid },
162
+
});
163
+
164
+
if (!response.ok) {
165
+
throw new Error(`Failed to download repository: ${response.status}`);
166
+
}
167
+
168
+
setImportStatus(`Uploading to destination (${formatBytes(response.data.length)})...`);
169
+
170
+
const destClient = new Client({ handler: destManager });
171
+
const importResp = await destClient.post('com.atproto.repo.importRepo', {
172
+
as: null,
173
+
encoding: 'application/vnd.ipld.car',
174
+
input: response.data,
175
+
});
176
+
177
+
if (!importResp.ok) {
178
+
throw new Error(`Failed to import repository: ${importResp.status}`);
179
+
}
180
+
181
+
// Check account status to get record count
182
+
const status = await ok(destClient.get('com.atproto.server.checkAccountStatus', {}));
183
+
setImportedRecords(status.indexedRecords);
184
+
185
+
setImportStatus(`Imported successfully`);
186
+
return status.indexedRecords;
187
+
},
188
+
onMutate() {
189
+
setImportStatus();
190
+
setImportedRecords();
191
+
},
192
+
onError(err) {
193
+
console.error(err);
194
+
setImportStatus();
195
+
},
196
+
});
197
+
198
+
const isExporting = () => exportMutation.isPending;
199
+
const isImporting = () => importFromFileMutation.isPending || importFromSourceMutation.isPending;
200
+
201
+
return (
202
+
<Accordion title="Repository">
203
+
<Subsection title="Export from source">
204
+
<p class="text-sm text-gray-600">
205
+
Download the repository as a CAR file for backup or manual import.
206
+
</p>
207
+
208
+
<Show when={source()} fallback={<p class="text-sm text-gray-500">Resolve source account first.</p>}>
209
+
{(src) => (
210
+
<>
211
+
<div class="flex items-center gap-3">
212
+
<Button
213
+
onClick={() => exportMutation.mutate({ pdsUrl: src().pdsUrl, did: src().did })}
214
+
disabled={isExporting()}
215
+
>
216
+
{isExporting() ? 'Exporting...' : 'Export to file'}
217
+
</Button>
218
+
<Show when={exportStatus()}>
219
+
<span class="text-sm text-gray-600">{exportStatus()}</span>
220
+
</Show>
221
+
</div>
222
+
223
+
<Show when={exportMutation.isError}>
224
+
<p class="text-sm text-red-600">{`${exportMutation.error}`}</p>
225
+
</Show>
226
+
</>
227
+
)}
228
+
</Show>
229
+
</Subsection>
230
+
231
+
<Subsection title="Import to destination">
232
+
<p class="text-sm text-gray-600">
233
+
Upload a repository CAR file or transfer directly from source.
234
+
</p>
235
+
236
+
<Show
237
+
when={destination()?.manager}
238
+
fallback={<p class="text-sm text-gray-500">Sign in to destination account first.</p>}
239
+
>
240
+
{(manager) => (
241
+
<>
242
+
<div class="flex flex-wrap items-center gap-3">
243
+
<Button
244
+
onClick={() => importFromFileMutation.mutate({ manager: manager() })}
245
+
disabled={isImporting()}
246
+
>
247
+
{isImporting() ? 'Importing...' : 'Import from file'}
248
+
</Button>
249
+
250
+
<Show when={source()}>
251
+
{(src) => (
252
+
<Button
253
+
variant="secondary"
254
+
onClick={() =>
255
+
importFromSourceMutation.mutate({
256
+
sourcePdsUrl: src().pdsUrl,
257
+
sourceDid: src().did,
258
+
destManager: manager(),
259
+
})
260
+
}
261
+
disabled={isImporting()}
262
+
>
263
+
Transfer from source
264
+
</Button>
265
+
)}
266
+
</Show>
267
+
</div>
268
+
269
+
<Show when={importStatus()}>
270
+
<div class="flex items-center gap-2">
271
+
<span class="text-sm text-gray-600">{importStatus()}</span>
272
+
<Show when={importedRecords() !== undefined}>
273
+
<StatusBadge variant="success">{importedRecords()} records</StatusBadge>
274
+
</Show>
275
+
</div>
276
+
</Show>
277
+
278
+
<Show when={importFromFileMutation.isError || importFromSourceMutation.isError}>
279
+
<p class="text-sm text-red-600">
280
+
{`${importFromFileMutation.error || importFromSourceMutation.error}`}
281
+
</p>
282
+
</Show>
283
+
</>
284
+
)}
285
+
</Show>
286
+
</Subsection>
287
+
</Accordion>
288
+
);
289
+
};
290
+
291
+
export default RepositorySection;
+265
src/views/account/account-migrate/sections/source-account.tsx
+265
src/views/account/account-migrate/sections/source-account.tsx
···
···
1
+
import { createSignal, Show } from 'solid-js';
2
+
3
+
import { type AtpAccessJwt, ClientResponseError, CredentialManager } from '@atcute/client';
4
+
import { getPdsEndpoint, isAtprotoDid } from '@atcute/identity';
5
+
import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax';
6
+
7
+
import { getDidDocument } from '~/api/queries/did-doc';
8
+
import { resolveHandleViaAppView } from '~/api/queries/handle';
9
+
import { formatTotpCode, TOTP_RE } from '~/api/utils/auth';
10
+
import { decodeJwt } from '~/api/utils/jwt';
11
+
12
+
import { createMutation } from '~/lib/utils/mutation';
13
+
14
+
import { Accordion, StatusBadge, Subsection } from '~/components/accordion';
15
+
import Button from '~/components/inputs/button';
16
+
import TextInput from '~/components/inputs/text-input';
17
+
18
+
import { useMigration } from '../context';
19
+
20
+
interface SourceAccountSectionProps {
21
+
onStarted?: () => void;
22
+
}
23
+
24
+
class InsufficientLoginError extends Error {}
25
+
26
+
const SourceAccountSection = (props: SourceAccountSectionProps) => {
27
+
const { source, setSource } = useMigration();
28
+
29
+
// Resolve state
30
+
const [identifier, setIdentifier] = createSignal('');
31
+
const [resolveError, setResolveError] = createSignal<string>();
32
+
33
+
// Auth state
34
+
const [password, setPassword] = createSignal('');
35
+
const [otp, setOtp] = createSignal('');
36
+
const [isTotpRequired, setIsTotpRequired] = createSignal(false);
37
+
const [authError, setAuthError] = createSignal<string>();
38
+
39
+
const resolveMutation = createMutation({
40
+
async mutationFn({ identifier }: { identifier: string }) {
41
+
let did: AtprotoDid;
42
+
if (isAtprotoDid(identifier)) {
43
+
did = identifier;
44
+
} else if (isHandle(identifier)) {
45
+
did = await resolveHandleViaAppView({ handle: identifier });
46
+
} else {
47
+
throw new Error(`${identifier} is not a valid DID or handle`);
48
+
}
49
+
50
+
const didDoc = await getDidDocument({ did });
51
+
const pdsUrl = getPdsEndpoint(didDoc);
52
+
53
+
if (!pdsUrl) {
54
+
throw new Error(`No PDS endpoint found in DID document`);
55
+
}
56
+
57
+
return { did, didDoc, pdsUrl };
58
+
},
59
+
onMutate() {
60
+
setResolveError();
61
+
},
62
+
onSuccess({ did, didDoc, pdsUrl }) {
63
+
setSource({ did, didDoc, pdsUrl, manager: null });
64
+
props.onStarted?.();
65
+
},
66
+
onError(err) {
67
+
if (err instanceof ClientResponseError) {
68
+
if (err.error === 'InvalidRequest' && err.description?.includes('resolve handle')) {
69
+
setResolveError(`Can't resolve handle, is it typed correctly?`);
70
+
return;
71
+
}
72
+
}
73
+
console.error(err);
74
+
setResolveError(`${err}`);
75
+
},
76
+
});
77
+
78
+
const authMutation = createMutation({
79
+
async mutationFn({ pdsUrl, did, password, otp }: { pdsUrl: string; did: string; password: string; otp: string }) {
80
+
const manager = new CredentialManager({ service: pdsUrl });
81
+
const session = await manager.login({
82
+
identifier: did,
83
+
password: password,
84
+
code: formatTotpCode(otp),
85
+
});
86
+
87
+
const decoded = decodeJwt(session.accessJwt) as AtpAccessJwt;
88
+
if (decoded.scope !== 'com.atproto.access') {
89
+
throw new InsufficientLoginError(`You need to sign in with a main password, not an app password`);
90
+
}
91
+
92
+
return manager;
93
+
},
94
+
onMutate() {
95
+
setAuthError();
96
+
},
97
+
onSuccess(manager) {
98
+
setSource({ ...source()!, manager });
99
+
setPassword('');
100
+
setOtp('');
101
+
setIsTotpRequired(false);
102
+
},
103
+
onError(err) {
104
+
if (err instanceof ClientResponseError) {
105
+
if (err.error === 'AuthFactorTokenRequired') {
106
+
setOtp('');
107
+
setIsTotpRequired(true);
108
+
return;
109
+
}
110
+
if (err.error === 'AuthenticationRequired') {
111
+
setAuthError(`Invalid identifier or password`);
112
+
return;
113
+
}
114
+
if (err.error === 'AccountTakedown') {
115
+
setAuthError(`Account has been taken down`);
116
+
return;
117
+
}
118
+
if (err.description?.includes('Token is invalid')) {
119
+
setAuthError(`Invalid one-time confirmation code`);
120
+
setIsTotpRequired(true);
121
+
return;
122
+
}
123
+
}
124
+
if (err instanceof InsufficientLoginError) {
125
+
setAuthError(err.message);
126
+
return;
127
+
}
128
+
console.error(err);
129
+
setAuthError(`${err}`);
130
+
},
131
+
});
132
+
133
+
const isResolved = () => source() !== null;
134
+
const isAuthenticated = () => source()?.manager != null;
135
+
136
+
return (
137
+
<Accordion title="Source Account" defaultOpen>
138
+
<Subsection title="Resolve identity">
139
+
<Show when={!isResolved()}>
140
+
<form
141
+
onSubmit={(ev) => {
142
+
ev.preventDefault();
143
+
resolveMutation.mutate({ identifier: identifier() });
144
+
}}
145
+
class="flex flex-col gap-3"
146
+
>
147
+
<TextInput
148
+
label="Handle or DID"
149
+
placeholder="alice.bsky.social"
150
+
value={identifier()}
151
+
required
152
+
autofocus
153
+
onChange={setIdentifier}
154
+
/>
155
+
156
+
<Show when={resolveError()}>
157
+
<p class="text-sm text-red-600">{resolveError()}</p>
158
+
</Show>
159
+
160
+
<div>
161
+
<Button type="submit" disabled={resolveMutation.isPending}>
162
+
{resolveMutation.isPending ? 'Resolving...' : 'Resolve'}
163
+
</Button>
164
+
</div>
165
+
</form>
166
+
</Show>
167
+
168
+
<Show when={isResolved()}>
169
+
<div class="flex flex-col gap-2 text-sm">
170
+
<p>
171
+
<span class="text-gray-500">DID:</span>{' '}
172
+
<span class="font-mono">{source()!.did}</span>
173
+
</p>
174
+
<p>
175
+
<span class="text-gray-500">PDS:</span>{' '}
176
+
<span class="font-mono">{source()!.pdsUrl}</span>
177
+
</p>
178
+
<div class="mt-1">
179
+
<button
180
+
type="button"
181
+
onClick={() => setSource(null)}
182
+
class="text-sm text-purple-800 hover:underline"
183
+
>
184
+
Change account
185
+
</button>
186
+
</div>
187
+
</div>
188
+
</Show>
189
+
</Subsection>
190
+
191
+
<Show when={isResolved()}>
192
+
<Subsection title="Authenticate">
193
+
<p class="text-sm text-gray-600">
194
+
Authentication is required for some operations like exporting preferences or signing PLC operations.
195
+
</p>
196
+
197
+
<Show when={!isAuthenticated()}>
198
+
<form
199
+
onSubmit={(ev) => {
200
+
ev.preventDefault();
201
+
const src = source()!;
202
+
authMutation.mutate({
203
+
pdsUrl: src.pdsUrl,
204
+
did: src.did,
205
+
password: password(),
206
+
otp: otp(),
207
+
});
208
+
}}
209
+
class="flex flex-col gap-3"
210
+
>
211
+
<TextInput
212
+
label="Main password"
213
+
blurb="Your credentials stay entirely within your browser."
214
+
type="password"
215
+
value={password()}
216
+
required
217
+
onChange={setPassword}
218
+
/>
219
+
220
+
<Show when={isTotpRequired()}>
221
+
<TextInput
222
+
label="One-time confirmation code"
223
+
blurb="A code has been sent to your email address."
224
+
type="text"
225
+
autocomplete="one-time-code"
226
+
pattern={TOTP_RE.source}
227
+
placeholder="AAAAA-BBBBB"
228
+
value={otp()}
229
+
required
230
+
onChange={setOtp}
231
+
monospace
232
+
/>
233
+
</Show>
234
+
235
+
<Show when={authError()}>
236
+
<p class="text-sm text-red-600">{authError()}</p>
237
+
</Show>
238
+
239
+
<div>
240
+
<Button type="submit" disabled={authMutation.isPending}>
241
+
{authMutation.isPending ? 'Signing in...' : 'Sign in'}
242
+
</Button>
243
+
</div>
244
+
</form>
245
+
</Show>
246
+
247
+
<Show when={isAuthenticated()}>
248
+
<div class="flex items-center gap-2">
249
+
<StatusBadge variant="success">Signed in</StatusBadge>
250
+
<button
251
+
type="button"
252
+
onClick={() => setSource({ ...source()!, manager: null })}
253
+
class="text-sm text-purple-800 hover:underline"
254
+
>
255
+
Sign out
256
+
</button>
257
+
</div>
258
+
</Show>
259
+
</Subsection>
260
+
</Show>
261
+
</Accordion>
262
+
);
263
+
};
264
+
265
+
export default SourceAccountSection;
+1
-1
src/views/frontpage.tsx
+1
-1
src/views/frontpage.tsx
+1
-19
src/views/repository/repo-export.tsx
+1
-19
src/views/repository/repo-export.tsx
···
11
import { useTitle } from '~/lib/navigation/router';
12
import { makeAbortable } from '~/lib/utils/abortable';
13
import { formatBytes } from '~/lib/utils/intl/bytes';
14
15
import Button from '~/components/inputs/button';
16
import TextInput from '~/components/inputs/text-input';
···
219
};
220
221
export default RepoExportPage;
222
-
223
-
export async function* iterateStream<T>(stream: ReadableStream<T>) {
224
-
// Get a lock on the stream
225
-
const reader = stream.getReader();
226
-
227
-
try {
228
-
while (true) {
229
-
const { done, value } = await reader.read();
230
-
231
-
if (done) {
232
-
return;
233
-
}
234
-
235
-
yield value;
236
-
}
237
-
} finally {
238
-
reader.releaseLock();
239
-
}
240
-
}
···
11
import { useTitle } from '~/lib/navigation/router';
12
import { makeAbortable } from '~/lib/utils/abortable';
13
import { formatBytes } from '~/lib/utils/intl/bytes';
14
+
import { iterateStream } from '~/lib/utils/stream';
15
16
import Button from '~/components/inputs/button';
17
import TextInput from '~/components/inputs/text-input';
···
220
};
221
222
export default RepoExportPage;