+17
-16
src/views/account/account-migrate/sections/blobs.tsx
+17
-16
src/views/account/account-migrate/sections/blobs.tsx
···
172
172
try {
173
173
const data = await entry.bytes();
174
174
await destClient.post('com.atproto.repo.uploadBlob', {
175
-
encoding: 'application/octet-stream',
176
175
input: data,
176
+
headers: {
177
+
'content-type': 'application/octet-stream',
178
+
},
177
179
});
178
180
uploaded++;
179
181
} catch (err) {
···
228
230
const contentType = response.headers.get('content-type') || 'application/octet-stream';
229
231
230
232
await destClient.post('com.atproto.repo.uploadBlob', {
231
-
encoding: contentType,
232
233
input: response.data,
234
+
headers: {
235
+
'content-type': contentType,
236
+
},
233
237
});
234
238
235
239
uploaded++;
···
297
301
const sourceData = importFromSourceMutation.data;
298
302
299
303
if (fileData && !fileData.cancelled) {
300
-
return `Uploaded ${fileData.uploaded} blobs` + (fileData.failed > 0 ? ` (${fileData.failed} failed)` : '');
304
+
return (
305
+
`Uploaded ${fileData.uploaded} blobs` + (fileData.failed > 0 ? ` (${fileData.failed} failed)` : '')
306
+
);
301
307
}
302
308
if (sourceData) {
303
309
if (sourceData.uploaded === 0 && sourceData.failed === 0) return 'No missing blobs';
304
-
return `Uploaded ${sourceData.uploaded} blobs` + (sourceData.failed > 0 ? ` (${sourceData.failed} failed)` : '');
310
+
return (
311
+
`Uploaded ${sourceData.uploaded} blobs` +
312
+
(sourceData.failed > 0 ? ` (${sourceData.failed} failed)` : '')
313
+
);
305
314
}
306
315
return importProgress();
307
316
};
···
311
320
return (
312
321
<Accordion title="Blobs">
313
322
<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>
323
+
<p class="text-sm text-gray-600">Download all blobs as a tarball for backup or manual import.</p>
317
324
318
325
<Show when={source()} fallback={<p class="text-sm text-gray-500">Resolve source account first.</p>}>
319
326
{(src) => (
···
339
346
</Subsection>
340
347
341
348
<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>
349
+
<p class="text-sm text-gray-600">Upload blobs from a tarball or transfer directly from source.</p>
345
350
346
351
<Show
347
352
when={destination()?.manager}
···
376
381
{(text) => <span class="text-sm text-gray-600">{text()}</span>}
377
382
</Show>
378
383
379
-
<Show when={getImportError()}>
380
-
{(err) => <p class="text-sm text-red-600">{`${err()}`}</p>}
381
-
</Show>
384
+
<Show when={getImportError()}>{(err) => <p class="text-sm text-red-600">{`${err()}`}</p>}</Show>
382
385
</>
383
386
)}
384
387
</Show>
···
403
406
<Show when={checkStatusMutation.data}>
404
407
{(status) => (
405
408
<span class="text-sm">
406
-
<StatusBadge
407
-
variant={status().imported === status().expected ? 'success' : 'pending'}
408
-
>
409
+
<StatusBadge variant={status().imported === status().expected ? 'success' : 'pending'}>
409
410
{status().imported}/{status().expected} blobs
410
411
</StatusBadge>
411
412
</span>
+152
-50
src/views/account/account-migrate/sections/identity.tsx
+152
-50
src/views/account/account-migrate/sections/identity.tsx
···
2
2
3
3
import { Client, ClientResponseError, type CredentialManager, ok } from '@atcute/client';
4
4
import { type DidKeyString, Secp256k1PrivateKeyExportable } from '@atcute/crypto';
5
+
import type { Did } from '@atcute/lexicons/syntax';
5
6
7
+
import { getPlcAuditLogs } from '~/api/queries/plc';
6
8
import { formatTotpCode, TOTP_RE } from '~/api/utils/auth';
7
9
8
10
import { createMutation } from '~/lib/utils/mutation';
···
11
13
import Button from '~/components/inputs/button';
12
14
import TextInput from '~/components/inputs/text-input';
13
15
import ToggleInput from '~/components/inputs/toggle-input';
16
+
17
+
import { getPlcPayload } from '~/views/identity/plc-applicator/plc-utils';
14
18
15
19
import { useMigration } from '../context';
16
20
···
48
52
const loadCredentialsMutation = createMutation({
49
53
async mutationFn({ manager }: { manager: CredentialManager }) {
50
54
const client = new Client({ handler: manager });
51
-
return (await ok(client.get('com.atproto.identity.getRecommendedDidCredentials', {}))) as RecommendedCredentials;
55
+
return (await ok(
56
+
client.get('com.atproto.identity.getRecommendedDidCredentials', {}),
57
+
)) as RecommendedCredentials;
58
+
},
59
+
onError(err) {
60
+
console.error(err);
61
+
},
62
+
});
63
+
64
+
// Analyze current rotation keys to find user-controlled keys that should be preserved
65
+
const analyzeRotationKeysMutation = createMutation({
66
+
async mutationFn({ did, sourceManager }: { did: Did<'plc'>; sourceManager: CredentialManager }, signal) {
67
+
// Get current rotation keys from PLC audit log
68
+
const auditLogs = await getPlcAuditLogs({ did, signal });
69
+
const latestEntry = auditLogs[auditLogs.length - 1];
70
+
const currentPayload = getPlcPayload(latestEntry);
71
+
const currentRotationKeys = currentPayload.rotationKeys ?? [];
72
+
73
+
// Get source PDS's recommended credentials to identify PDS-controlled keys
74
+
const sourceClient = new Client({ handler: sourceManager });
75
+
const sourcePdsCredentials = (await ok(
76
+
sourceClient.get('com.atproto.identity.getRecommendedDidCredentials', {}),
77
+
)) as RecommendedCredentials;
78
+
const sourcePdsKeys = new Set(sourcePdsCredentials.rotationKeys ?? []);
79
+
80
+
// Keys in current doc that aren't from source PDS are user-controlled
81
+
const userControlledKeys = currentRotationKeys.filter((key) => !sourcePdsKeys.has(key));
82
+
83
+
return {
84
+
currentRotationKeys,
85
+
sourcePdsKeys: sourcePdsCredentials.rotationKeys ?? [],
86
+
userControlledKeys,
87
+
};
88
+
},
89
+
onSuccess(data) {
90
+
// Pre-populate custom keys with user-controlled keys
91
+
if (data.userControlledKeys.length > 0) {
92
+
setCustomKeys(data.userControlledKeys);
93
+
}
52
94
},
53
95
onError(err) {
54
96
console.error(err);
···
199
241
</p>
200
242
</div>
201
243
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">
244
+
<Subsection title="1. Preview new credentials">
237
245
<p class="text-sm text-gray-600">View what your DID document will look like after the migration.</p>
238
246
239
247
<Show
···
265
273
<>
266
274
<div class="mt-2 text-sm">
267
275
<p class="text-gray-500">
268
-
PDS rotation keys ({creds().rotationKeys?.length ?? 0}/5):
276
+
Destination PDS rotation keys ({creds().rotationKeys?.length ?? 0}/5):
269
277
</p>
270
278
<div class="mt-1 flex flex-col gap-1">
271
279
<For each={creds().rotationKeys ?? []}>
272
-
{(key) => (
273
-
<code class="block truncate text-xs text-gray-700">{key}</code>
274
-
)}
280
+
{(key) => <code class="block truncate text-xs text-gray-700">{key}</code>}
275
281
</For>
276
282
</div>
277
283
</div>
278
284
285
+
<Show when={source()?.manager && source()}>
286
+
{(src) => (
287
+
<div class="mt-3 rounded border border-blue-200 bg-blue-50 p-3">
288
+
<div class="flex items-center justify-between">
289
+
<p class="text-sm font-medium text-blue-800">Analyze existing rotation keys</p>
290
+
<Button
291
+
variant="outline"
292
+
onClick={() =>
293
+
analyzeRotationKeysMutation.mutate({
294
+
did: src().did as Did<'plc'>,
295
+
sourceManager: src().manager!,
296
+
})
297
+
}
298
+
disabled={analyzeRotationKeysMutation.isPending}
299
+
>
300
+
{analyzeRotationKeysMutation.isPending ? 'Analyzing...' : 'Analyze'}
301
+
</Button>
302
+
</div>
303
+
<p class="mt-1 text-xs text-blue-600">
304
+
Check if you have any user-controlled rotation keys that should be preserved
305
+
during migration.
306
+
</p>
307
+
308
+
<Show when={analyzeRotationKeysMutation.error}>
309
+
<p class="mt-2 text-sm text-red-600">{`${analyzeRotationKeysMutation.error}`}</p>
310
+
</Show>
311
+
312
+
<Show when={analyzeRotationKeysMutation.data}>
313
+
{(analysis) => (
314
+
<div class="mt-2 text-sm">
315
+
<Show
316
+
when={analysis().userControlledKeys.length > 0}
317
+
fallback={
318
+
<p class="text-blue-700">
319
+
No user-controlled rotation keys found. Your current keys are all
320
+
managed by your source PDS.
321
+
</p>
322
+
}
323
+
>
324
+
<p class="font-medium text-blue-800">
325
+
Found {analysis().userControlledKeys.length} user-controlled key(s) to
326
+
preserve:
327
+
</p>
328
+
<div class="mt-1 flex flex-col gap-1">
329
+
<For each={analysis().userControlledKeys}>
330
+
{(key) => (
331
+
<code class="block truncate text-xs text-blue-700">{key}</code>
332
+
)}
333
+
</For>
334
+
</div>
335
+
<p class="mt-2 text-xs text-blue-600">
336
+
These keys have been added to the custom keys section below.
337
+
</p>
338
+
</Show>
339
+
</div>
340
+
)}
341
+
</Show>
342
+
</div>
343
+
)}
344
+
</Show>
345
+
279
346
<details class="mt-2">
280
-
<summary class="cursor-pointer text-sm text-gray-600">
281
-
View full credentials
282
-
</summary>
347
+
<summary class="cursor-pointer text-sm text-gray-600">View full credentials</summary>
283
348
<pre class="mt-2 max-h-48 overflow-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs">
284
349
{JSON.stringify(creds(), null, 2)}
285
350
</pre>
···
292
357
</Show>
293
358
</Subsection>
294
359
295
-
<Subsection title="3. Rotation keys (optional)">
360
+
<Subsection title="2. Rotation keys (optional)">
296
361
<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.
362
+
Add a rotation key to recover your account if your new PDS goes rogue. This will be prepended to the
363
+
PDS rotation keys shown above.
299
364
</p>
300
365
301
366
<ToggleInput
···
323
388
<div class="rounded border border-green-300 bg-green-50 p-3">
324
389
<p class="mb-2 text-sm font-semibold text-green-800">Save your rotation key private key!</p>
325
390
<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.
391
+
Store this securely. You'll need it to recover your account if your PDS becomes unavailable or
392
+
malicious.
328
393
</p>
329
394
330
395
<div class="flex flex-col gap-2 text-sm">
···
345
410
)}
346
411
</Show>
347
412
348
-
<div class="mt-4 border-t border-gray-200 pt-4">
413
+
<div class="rounded border border-gray-200 bg-gray-50 p-3">
349
414
<p class="mb-2 text-sm font-medium text-gray-700">Custom rotation keys</p>
350
415
<p class="mb-3 text-xs text-gray-500">
351
416
Add existing rotation keys (did:key format) you already control.
···
394
459
</div>
395
460
</Subsection>
396
461
462
+
<Subsection title="3. Request operation signature">
463
+
<p class="text-sm text-gray-600">Request a confirmation token via email from your source PDS.</p>
464
+
465
+
<Show
466
+
when={source()?.manager}
467
+
fallback={<p class="text-sm text-gray-500">Sign in to source account first.</p>}
468
+
>
469
+
{(manager) => (
470
+
<>
471
+
<div class="flex items-center gap-3">
472
+
<Button
473
+
onClick={() => requestTokenMutation.mutate({ manager: manager() })}
474
+
disabled={requestTokenMutation.isPending}
475
+
>
476
+
{requestTokenMutation.isPending ? 'Requesting...' : 'Request token'}
477
+
</Button>
478
+
479
+
<Show when={requestTokenMutation.isSuccess}>
480
+
<StatusBadge variant="success">Email sent</StatusBadge>
481
+
</Show>
482
+
</div>
483
+
484
+
<Show when={requestTokenMutation.isError}>
485
+
<p class="text-sm text-red-600">{`${requestTokenMutation.error}`}</p>
486
+
</Show>
487
+
488
+
<Show when={requestTokenMutation.isSuccess}>
489
+
<p class="text-sm text-gray-600">Check your email inbox for the confirmation code.</p>
490
+
</Show>
491
+
</>
492
+
)}
493
+
</Show>
494
+
</Subsection>
495
+
397
496
<Subsection title="4. Sign and submit">
398
497
<p class="text-sm text-gray-600">Enter the confirmation code and submit the PLC operation.</p>
399
498
···
422
521
/>
423
522
424
523
<div class="flex items-center gap-3">
425
-
<Button onClick={handleSignAndSubmit} disabled={signAndSubmitMutation.isPending || !canSignAndSubmit()}>
524
+
<Button
525
+
onClick={handleSignAndSubmit}
526
+
disabled={signAndSubmitMutation.isPending || !canSignAndSubmit()}
527
+
>
426
528
{signAndSubmitMutation.isPending ? 'Submitting...' : 'Sign and submit'}
427
529
</Button>
428
530
+9
-9
src/views/account/account-migrate/sections/repository.tsx
+9
-9
src/views/account/account-migrate/sections/repository.tsx
···
109
109
return null;
110
110
}
111
111
112
-
setImportStatus('Reading file...');
113
112
const file = await fd.getFile();
114
-
const data = new Uint8Array(await file.arrayBuffer());
115
113
116
-
setImportStatus(`Uploading repository (${formatBytes(data.length)})...`);
114
+
setImportStatus(`Uploading repository (${formatBytes(file.size)})...`);
117
115
118
116
const destClient = new Client({ handler: manager });
119
117
const importResp = await destClient.post('com.atproto.repo.importRepo', {
120
118
as: null,
121
-
encoding: 'application/vnd.ipld.car',
122
-
input: data,
119
+
input: file,
120
+
headers: {
121
+
'content-type': 'application/vnd.ipld.car',
122
+
},
123
123
});
124
124
125
125
if (!importResp.ok) {
···
170
170
const destClient = new Client({ handler: destManager });
171
171
const importResp = await destClient.post('com.atproto.repo.importRepo', {
172
172
as: null,
173
-
encoding: 'application/vnd.ipld.car',
174
173
input: response.data,
174
+
headers: {
175
+
'content-type': 'application/vnd.ipld.car',
176
+
},
175
177
});
176
178
177
179
if (!importResp.ok) {
···
229
231
</Subsection>
230
232
231
233
<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>
234
+
<p class="text-sm text-gray-600">Upload a repository CAR file or transfer directly from source.</p>
235
235
236
236
<Show
237
237
when={destination()?.manager}