+6
-6
package.json
+6
-6
package.json
···
8
8
},
9
9
"dependencies": {
10
10
"@atcute/atproto": "^3.1.9",
11
-
"@atcute/bluesky": "^3.2.12",
11
+
"@atcute/bluesky": "^3.2.13",
12
12
"@atcute/car": "^5.0.0",
13
-
"@atcute/repo": "^0.1.0",
14
13
"@atcute/cbor": "^2.2.8",
15
14
"@atcute/cid": "^2.2.6",
16
-
"@atcute/client": "^4.1.0",
17
-
"@atcute/crypto": "^2.2.6",
15
+
"@atcute/client": "^4.1.1",
16
+
"@atcute/crypto": "^2.3.0",
18
17
"@atcute/did-plc": "^0.2.0",
19
18
"@atcute/identity": "^1.1.3",
20
19
"@atcute/identity-resolver": "^1.2.0",
21
20
"@atcute/lexicons": "^1.2.5",
22
21
"@atcute/multibase": "^1.1.6",
22
+
"@atcute/repo": "^0.1.0",
23
23
"@atcute/tid": "^1.0.3",
24
24
"@badrap/valita": "^0.4.6",
25
25
"@mary/array-fns": "jsr:^0.1.5",
···
33
33
},
34
34
"devDependencies": {
35
35
"@tailwindcss/forms": "^0.5.10",
36
-
"@types/node": "^22.19.1",
36
+
"@types/node": "^22.19.2",
37
37
"autoprefixer": "^10.4.22",
38
38
"prettier": "^3.7.4",
39
39
"prettier-plugin-tailwindcss": "^0.6.14",
40
40
"tailwindcss": "^3.4.18",
41
41
"terser": "^5.44.1",
42
42
"typescript": "~5.9.3",
43
-
"vite": "^7.2.6",
43
+
"vite": "^7.2.7",
44
44
"vite-plugin-solid": "^2.11.10",
45
45
"wrangler": "^4.53.0"
46
46
}
+47
-47
pnpm-lock.yaml
+47
-47
pnpm-lock.yaml
···
12
12
specifier: ^3.1.9
13
13
version: 3.1.9
14
14
'@atcute/bluesky':
15
-
specifier: ^3.2.12
16
-
version: 3.2.12
15
+
specifier: ^3.2.13
16
+
version: 3.2.13
17
17
'@atcute/car':
18
18
specifier: ^5.0.0
19
19
version: 5.0.0
···
24
24
specifier: ^2.2.6
25
25
version: 2.2.6
26
26
'@atcute/client':
27
-
specifier: ^4.1.0
28
-
version: 4.1.0
27
+
specifier: ^4.1.1
28
+
version: 4.1.1
29
29
'@atcute/crypto':
30
-
specifier: ^2.2.6
31
-
version: 2.2.6
30
+
specifier: ^2.3.0
31
+
version: 2.3.0
32
32
'@atcute/did-plc':
33
33
specifier: ^0.2.0
34
34
version: 0.2.0
···
82
82
specifier: ^0.5.10
83
83
version: 0.5.10(tailwindcss@3.4.18)
84
84
'@types/node':
85
-
specifier: ^22.19.1
86
-
version: 22.19.1
85
+
specifier: ^22.19.2
86
+
version: 22.19.2
87
87
autoprefixer:
88
88
specifier: ^10.4.22
89
89
version: 10.4.22(postcss@8.5.6)
···
103
103
specifier: ~5.9.3
104
104
version: 5.9.3
105
105
vite:
106
-
specifier: ^7.2.6
107
-
version: 7.2.6(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1)
106
+
specifier: ^7.2.7
107
+
version: 7.2.7(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)
108
108
vite-plugin-solid:
109
109
specifier: ^2.11.10
110
-
version: 2.11.10(solid-js@1.9.10)(vite@7.2.6(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1))
110
+
version: 2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1))
111
111
wrangler:
112
112
specifier: ^4.53.0
113
113
version: 4.53.0
···
121
121
'@atcute/atproto@3.1.9':
122
122
resolution: {integrity: sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==}
123
123
124
-
'@atcute/bluesky@3.2.12':
125
-
resolution: {integrity: sha512-hVhAO7b4bxu9iwl/UdqugWDvUtSrf0VDN+dTalKxpJrJ3RrZb+jL1CB1AmdWOCZgHrOxXsgAJF4mpnzqd2D3oA==}
124
+
'@atcute/bluesky@3.2.13':
125
+
resolution: {integrity: sha512-ZG/mqsCjVU6zvH6XsRw+oQglrsdu5R7mnncMO+Ux0KWbX2xJw4ZMFHfs7ZTC69dVPK9r/yle7YbpygZTOWDM9A==}
126
126
127
127
'@atcute/car@5.0.0':
128
128
resolution: {integrity: sha512-OIY2xTXv8lSpZsDSn/UYQtJSMvDw5Hi4Q+uyvmiqSM+fht08QRAEq/nxa5YFciPZ3nfDFnZ3//EgJw7QhkSXLQ==}
···
133
133
'@atcute/cid@2.2.6':
134
134
resolution: {integrity: sha512-bTAHHbJ24p+E//V4KCS4xdmd39o211jJswvqQOevj7vk+5IYcgDLx1ryZWZ1sEPOo9x875li/kj5gpKL14RDwQ==}
135
135
136
-
'@atcute/client@4.1.0':
137
-
resolution: {integrity: sha512-AYhSu3RSDA2VDkVGOmad320NRbUUUf5pCFWJcOzlk25YC/4kyzmMFfpzhf1jjjEcY+anNBXGGhav/kKB1evggQ==}
136
+
'@atcute/client@4.1.1':
137
+
resolution: {integrity: sha512-FROCbTTCeL5u4tO/n72jDEKyKqjdlXMB56Ehve3W/gnnLGCYWvN42sS7tvL1Mgu6sbO3yZwsXKDrmM2No4XpjA==}
138
138
139
-
'@atcute/crypto@2.2.6':
140
-
resolution: {integrity: sha512-vkuexF+kmrKE1/Uqzub99Qi4QpnxA2jbu60E6PTgL4XypELQ6rb59MB/J1VbY2gs0kd3ET7+L3+NWpKD5nXyfA==}
139
+
'@atcute/crypto@2.3.0':
140
+
resolution: {integrity: sha512-w5pkJKCjbNMQu+F4JRHbR3ROQyhi1wbn+GSC6WDQamcYHkZmEZk1/eoI354bIQOOfkEM6aFLv718iskrkon4GQ==}
141
141
142
142
'@atcute/did-plc@0.2.0':
143
143
resolution: {integrity: sha512-1sGek8GRM/Ph7nLVRREm8FqM7g4shGckItvdVwJcRbUa8Rh0zOsXQa0QyYWAC0k40BhkqO9FwKXhJEaXCmF5oQ==}
···
929
929
'@types/estree@1.0.8':
930
930
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
931
931
932
-
'@types/node@22.19.1':
933
-
resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==}
932
+
'@types/node@22.19.2':
933
+
resolution: {integrity: sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==}
934
934
935
935
acorn-walk@8.3.2:
936
936
resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
···
977
977
solid-js:
978
978
optional: true
979
979
980
-
baseline-browser-mapping@2.9.4:
981
-
resolution: {integrity: sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==}
980
+
baseline-browser-mapping@2.9.5:
981
+
resolution: {integrity: sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==}
982
982
hasBin: true
983
983
984
984
binary-extensions@2.3.0:
···
1004
1004
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
1005
1005
engines: {node: '>= 6'}
1006
1006
1007
-
caniuse-lite@1.0.30001759:
1008
-
resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==}
1007
+
caniuse-lite@1.0.30001760:
1008
+
resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==}
1009
1009
1010
1010
chokidar@3.6.0:
1011
1011
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
···
1066
1066
dlv@1.1.3:
1067
1067
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
1068
1068
1069
-
electron-to-chromium@1.5.266:
1070
-
resolution: {integrity: sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==}
1069
+
electron-to-chromium@1.5.267:
1070
+
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
1071
1071
1072
1072
entities@6.0.1:
1073
1073
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
···
1580
1580
'@testing-library/jest-dom':
1581
1581
optional: true
1582
1582
1583
-
vite@7.2.6:
1584
-
resolution: {integrity: sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==}
1583
+
vite@7.2.7:
1584
+
resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==}
1585
1585
engines: {node: ^20.19.0 || >=22.12.0}
1586
1586
hasBin: true
1587
1587
peerDependencies:
···
1679
1679
dependencies:
1680
1680
'@atcute/lexicons': 1.2.5
1681
1681
1682
-
'@atcute/bluesky@3.2.12':
1682
+
'@atcute/bluesky@3.2.13':
1683
1683
dependencies:
1684
1684
'@atcute/atproto': 3.1.9
1685
1685
'@atcute/lexicons': 1.2.5
···
1702
1702
'@atcute/multibase': 1.1.6
1703
1703
'@atcute/uint8array': 1.0.6
1704
1704
1705
-
'@atcute/client@4.1.0':
1705
+
'@atcute/client@4.1.1':
1706
1706
dependencies:
1707
1707
'@atcute/identity': 1.1.3
1708
1708
'@atcute/lexicons': 1.2.5
1709
1709
1710
-
'@atcute/crypto@2.2.6':
1710
+
'@atcute/crypto@2.3.0':
1711
1711
dependencies:
1712
1712
'@atcute/multibase': 1.1.6
1713
1713
'@atcute/uint8array': 1.0.6
···
1717
1717
dependencies:
1718
1718
'@atcute/cbor': 2.2.8
1719
1719
'@atcute/cid': 2.2.6
1720
-
'@atcute/crypto': 2.2.6
1720
+
'@atcute/crypto': 2.3.0
1721
1721
'@atcute/identity': 1.1.3
1722
1722
'@atcute/lexicons': 1.2.5
1723
1723
'@atcute/multibase': 1.1.6
···
1756
1756
'@atcute/car': 5.0.0
1757
1757
'@atcute/cbor': 2.2.8
1758
1758
'@atcute/cid': 2.2.6
1759
-
'@atcute/crypto': 2.2.6
1759
+
'@atcute/crypto': 2.3.0
1760
1760
'@atcute/lexicons': 1.2.5
1761
1761
'@atcute/mst': 0.1.0
1762
1762
'@atcute/uint8array': 1.0.6
···
2316
2316
2317
2317
'@types/estree@1.0.8': {}
2318
2318
2319
-
'@types/node@22.19.1':
2319
+
'@types/node@22.19.2':
2320
2320
dependencies:
2321
2321
undici-types: 6.21.0
2322
2322
···
2338
2338
autoprefixer@10.4.22(postcss@8.5.6):
2339
2339
dependencies:
2340
2340
browserslist: 4.28.1
2341
-
caniuse-lite: 1.0.30001759
2341
+
caniuse-lite: 1.0.30001760
2342
2342
fraction.js: 5.3.4
2343
2343
normalize-range: 0.1.2
2344
2344
picocolors: 1.1.1
···
2361
2361
optionalDependencies:
2362
2362
solid-js: 1.9.10
2363
2363
2364
-
baseline-browser-mapping@2.9.4: {}
2364
+
baseline-browser-mapping@2.9.5: {}
2365
2365
2366
2366
binary-extensions@2.3.0: {}
2367
2367
···
2373
2373
2374
2374
browserslist@4.28.1:
2375
2375
dependencies:
2376
-
baseline-browser-mapping: 2.9.4
2377
-
caniuse-lite: 1.0.30001759
2378
-
electron-to-chromium: 1.5.266
2376
+
baseline-browser-mapping: 2.9.5
2377
+
caniuse-lite: 1.0.30001760
2378
+
electron-to-chromium: 1.5.267
2379
2379
node-releases: 2.0.27
2380
2380
update-browserslist-db: 1.2.2(browserslist@4.28.1)
2381
2381
···
2383
2383
2384
2384
camelcase-css@2.0.1: {}
2385
2385
2386
-
caniuse-lite@1.0.30001759: {}
2386
+
caniuse-lite@1.0.30001760: {}
2387
2387
2388
2388
chokidar@3.6.0:
2389
2389
dependencies:
···
2435
2435
2436
2436
dlv@1.1.3: {}
2437
2437
2438
-
electron-to-chromium@1.5.266: {}
2438
+
electron-to-chromium@1.5.267: {}
2439
2439
2440
2440
entities@6.0.1: {}
2441
2441
···
2924
2924
2925
2925
util-deprecate@1.0.2: {}
2926
2926
2927
-
vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.6(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1)):
2927
+
vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)):
2928
2928
dependencies:
2929
2929
'@babel/core': 7.28.5
2930
2930
'@types/babel__core': 7.20.5
···
2932
2932
merge-anything: 5.1.7
2933
2933
solid-js: 1.9.10
2934
2934
solid-refresh: 0.6.3(solid-js@1.9.10)
2935
-
vite: 7.2.6(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1)
2936
-
vitefu: 1.1.1(vite@7.2.6(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1))
2935
+
vite: 7.2.7(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)
2936
+
vitefu: 1.1.1(vite@7.2.7(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1))
2937
2937
transitivePeerDependencies:
2938
2938
- supports-color
2939
2939
2940
-
vite@7.2.6(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1):
2940
+
vite@7.2.7(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1):
2941
2941
dependencies:
2942
2942
esbuild: 0.25.12
2943
2943
fdir: 6.5.0(picomatch@4.0.3)
···
2946
2946
rollup: 4.53.3
2947
2947
tinyglobby: 0.2.15
2948
2948
optionalDependencies:
2949
-
'@types/node': 22.19.1
2949
+
'@types/node': 22.19.2
2950
2950
fsevents: 2.3.3
2951
2951
jiti: 1.21.7
2952
2952
terser: 5.44.1
2953
2953
2954
-
vitefu@1.1.1(vite@7.2.6(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1)):
2954
+
vitefu@1.1.1(vite@7.2.7(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)):
2955
2955
optionalDependencies:
2956
-
vite: 7.2.6(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1)
2956
+
vite: 7.2.7(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)
2957
2957
2958
2958
web-streams-polyfill@3.3.3:
2959
2959
optional: true
+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
+
};
+65
src/components/file-drop-zone.tsx
+65
src/components/file-drop-zone.tsx
···
1
+
import type { JSX } from 'solid-js';
2
+
3
+
import { createDropZone, type CreateDropZoneOptions } from '~/lib/hooks/dropzone';
4
+
5
+
import Button from './inputs/button';
6
+
7
+
interface FileDropZoneProps {
8
+
accept?: string;
9
+
disabled?: boolean;
10
+
onFiles: (files: File[]) => void;
11
+
dataTypes?: CreateDropZoneOptions['dataTypes'];
12
+
multiple?: boolean;
13
+
children?: JSX.Element;
14
+
}
15
+
16
+
const FileDropZone = (props: FileDropZoneProps) => {
17
+
const { ref: dropRef, isDropping } = createDropZone({
18
+
dataTypes: props.dataTypes,
19
+
multiple: props.multiple ?? false,
20
+
onDrop(files) {
21
+
if (files) {
22
+
props.onFiles(files);
23
+
}
24
+
},
25
+
});
26
+
27
+
const handleBrowse = () => {
28
+
const input = document.createElement('input');
29
+
input.type = 'file';
30
+
if (props.accept) {
31
+
input.accept = props.accept;
32
+
}
33
+
if (props.multiple) {
34
+
input.multiple = true;
35
+
}
36
+
input.oninput = () => {
37
+
const files = Array.from(input.files!);
38
+
if (files.length > 0) {
39
+
props.onFiles(files);
40
+
}
41
+
};
42
+
input.click();
43
+
};
44
+
45
+
return (
46
+
<fieldset
47
+
ref={dropRef}
48
+
disabled={props.disabled}
49
+
class={
50
+
`relative grid place-items-center rounded border border-gray-300 px-6 py-12 disabled:opacity-50` +
51
+
(props.disabled || !isDropping() ? ` bg-gray-100` : ` bg-green-100`)
52
+
}
53
+
>
54
+
<div class="flex flex-col items-center gap-4">
55
+
<Button variant="outline" onClick={handleBrowse}>
56
+
Browse files
57
+
</Button>
58
+
<p class="select-none font-medium text-gray-600">or drop your file here</p>
59
+
</div>
60
+
{props.children}
61
+
</fieldset>
62
+
);
63
+
};
64
+
65
+
export default FileDropZone;
+3
-1
src/components/inputs/text-input.tsx
+3
-1
src/components/inputs/text-input.tsx
···
4
4
5
5
import type { BoundInputEvent } from './_types';
6
6
7
-
interface TextInputProps {
7
+
export interface TextInputProps {
8
8
label: JSX.Element;
9
9
blurb?: JSX.Element;
10
10
monospace?: boolean;
11
11
type?: 'text' | 'password' | 'url' | 'email';
12
12
name?: string;
13
13
required?: boolean;
14
+
disabled?: boolean;
14
15
autocomplete?: 'off' | 'on' | 'one-time-code' | 'username';
15
16
autocorrect?: 'off' | 'on';
16
17
pattern?: string;
···
55
56
id={fieldId}
56
57
name={props.name}
57
58
required={props.required}
59
+
disabled={props.disabled}
58
60
autocomplete={props.autocomplete}
59
61
pattern={props.pattern}
60
62
placeholder={props.placeholder}
+22
src/components/page-header.tsx
+22
src/components/page-header.tsx
···
1
+
import type { JSX } from 'solid-js';
2
+
3
+
interface PageHeaderProps {
4
+
title: string;
5
+
subtitle?: string;
6
+
children?: JSX.Element;
7
+
}
8
+
9
+
const PageHeader = (props: PageHeaderProps) => {
10
+
return (
11
+
<>
12
+
<div class="p-4">
13
+
<h1 class="text-lg font-bold text-purple-800">{props.title}</h1>
14
+
{props.subtitle && <p class="text-gray-600">{props.subtitle}</p>}
15
+
{props.children}
16
+
</div>
17
+
<hr class="mx-4 border-gray-300" />
18
+
</>
19
+
);
20
+
};
21
+
22
+
export default PageHeader;
+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
+
}
+9
src/routes.ts
+9
src/routes.ts
···
22
22
path: '/crypto-generate',
23
23
component: lazy(() => import('./views/crypto/crypto-generate')),
24
24
},
25
+
{
26
+
path: '/crypto-info',
27
+
component: lazy(() => import('./views/crypto/crypto-info')),
28
+
},
25
29
26
30
{
27
31
path: '/did-lookup',
···
47
51
{
48
52
path: '/repo-archive-explore',
49
53
component: lazy(() => import('./views/repository/repo-archive-explore/page')),
54
+
},
55
+
56
+
{
57
+
path: '/account-migrate',
58
+
component: lazy(() => import('./views/account/account-migrate/page')),
50
59
},
51
60
52
61
{
+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;
+455
src/views/account/account-migrate/sections/blobs.tsx
+455
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
+
input: data,
176
+
headers: {
177
+
'content-type': 'application/octet-stream',
178
+
},
179
+
});
180
+
uploaded++;
181
+
} catch (err) {
182
+
console.error(`Failed to upload blob ${cid}:`, err);
183
+
failed++;
184
+
}
185
+
}
186
+
187
+
return { uploaded, failed, cancelled: false };
188
+
},
189
+
onError(err) {
190
+
console.error(err);
191
+
},
192
+
onSettled() {
193
+
setImportProgress();
194
+
},
195
+
});
196
+
197
+
const importFromSourceMutation = createMutation({
198
+
async mutationFn({ source, destManager }: { source: SourceAccount; destManager: CredentialManager }) {
199
+
setImportProgress('Checking for missing blobs...');
200
+
201
+
const sourceClient = new Client({ handler: simpleFetchHandler({ service: source.pdsUrl }) });
202
+
const destClient = new Client({ handler: destManager });
203
+
204
+
let uploaded = 0;
205
+
let failed = 0;
206
+
let cursor: string | undefined;
207
+
208
+
do {
209
+
const data = await ok(
210
+
destClient.get('com.atproto.repo.listMissingBlobs', {
211
+
params: { cursor, limit: 100 },
212
+
}),
213
+
);
214
+
cursor = data.cursor;
215
+
216
+
for (const blob of data.blobs) {
217
+
setImportProgress(`Uploading missing blobs (${uploaded} uploaded, ${failed} failed)`);
218
+
219
+
try {
220
+
const response = await sourceClient.get('com.atproto.sync.getBlob', {
221
+
as: 'stream',
222
+
params: { did: source.did, cid: blob.cid },
223
+
});
224
+
225
+
if (!response.ok) {
226
+
failed++;
227
+
continue;
228
+
}
229
+
230
+
const contentType = response.headers.get('content-type') || 'application/octet-stream';
231
+
232
+
await destClient.post('com.atproto.repo.uploadBlob', {
233
+
input: response.data,
234
+
headers: {
235
+
'content-type': contentType,
236
+
},
237
+
});
238
+
239
+
uploaded++;
240
+
} catch (err) {
241
+
console.error(`Failed to transfer blob ${blob.cid}:`, err);
242
+
failed++;
243
+
}
244
+
}
245
+
} while (cursor !== undefined);
246
+
247
+
return { uploaded, failed };
248
+
},
249
+
onError(err) {
250
+
console.error(err);
251
+
},
252
+
onSettled() {
253
+
setImportProgress();
254
+
},
255
+
});
256
+
257
+
const checkStatusMutation = createMutation({
258
+
async mutationFn({ destManager }: { destManager: CredentialManager }) {
259
+
const destClient = new Client({ handler: destManager });
260
+
const status = await ok(destClient.get('com.atproto.server.checkAccountStatus'));
261
+
262
+
let missingBlobs: string[] = [];
263
+
264
+
// Get list of missing blobs if any
265
+
if (status.expectedBlobs > status.importedBlobs) {
266
+
let cursor: string | undefined;
267
+
do {
268
+
const data = await ok(
269
+
destClient.get('com.atproto.repo.listMissingBlobs', {
270
+
params: { cursor, limit: 100 },
271
+
}),
272
+
);
273
+
cursor = data.cursor;
274
+
missingBlobs.push(...data.blobs.map((b) => b.cid));
275
+
} while (cursor !== undefined);
276
+
}
277
+
278
+
return {
279
+
expected: status.expectedBlobs,
280
+
imported: status.importedBlobs,
281
+
missingBlobs,
282
+
};
283
+
},
284
+
onError(err) {
285
+
console.error(err);
286
+
},
287
+
});
288
+
289
+
const isImporting = () => importFromFileMutation.isPending || importFromSourceMutation.isPending;
290
+
291
+
const getExportStatusText = () => {
292
+
const data = exportMutation.data;
293
+
if (data?.cancelled) return undefined;
294
+
if (data?.count === 0) return 'No blobs to export';
295
+
if (data) return `Exported ${data.count} blobs`;
296
+
return exportProgress();
297
+
};
298
+
299
+
const getImportStatusText = () => {
300
+
const fileData = importFromFileMutation.data;
301
+
const sourceData = importFromSourceMutation.data;
302
+
303
+
if (fileData && !fileData.cancelled) {
304
+
return (
305
+
`Uploaded ${fileData.uploaded} blobs` + (fileData.failed > 0 ? ` (${fileData.failed} failed)` : '')
306
+
);
307
+
}
308
+
if (sourceData) {
309
+
if (sourceData.uploaded === 0 && sourceData.failed === 0) return 'No missing blobs';
310
+
return (
311
+
`Uploaded ${sourceData.uploaded} blobs` +
312
+
(sourceData.failed > 0 ? ` (${sourceData.failed} failed)` : '')
313
+
);
314
+
}
315
+
return importProgress();
316
+
};
317
+
318
+
const getImportError = () => importFromFileMutation.error || importFromSourceMutation.error;
319
+
320
+
return (
321
+
<Accordion title="Blobs">
322
+
<Subsection title="Export from source">
323
+
<p class="text-sm text-gray-600">Download all blobs as a tarball for backup or manual import.</p>
324
+
325
+
<Show when={source()} fallback={<p class="text-sm text-gray-500">Resolve source account first.</p>}>
326
+
{(src) => (
327
+
<>
328
+
<div class="flex items-center gap-3">
329
+
<Button
330
+
onClick={() => exportMutation.mutate({ source: src() })}
331
+
disabled={exportMutation.isPending}
332
+
>
333
+
{exportMutation.isPending ? 'Exporting...' : 'Export to file'}
334
+
</Button>
335
+
<Show when={getExportStatusText()}>
336
+
{(text) => <span class="text-sm text-gray-600">{text()}</span>}
337
+
</Show>
338
+
</div>
339
+
340
+
<Show when={exportMutation.error}>
341
+
{(err) => <p class="text-sm text-red-600">{`${err()}`}</p>}
342
+
</Show>
343
+
</>
344
+
)}
345
+
</Show>
346
+
</Subsection>
347
+
348
+
<Subsection title="Import to destination">
349
+
<p class="text-sm text-gray-600">Upload blobs from a tarball or transfer directly from source.</p>
350
+
351
+
<Show
352
+
when={destination()?.manager}
353
+
fallback={<p class="text-sm text-gray-500">Sign in to destination account first.</p>}
354
+
>
355
+
{(destManager) => (
356
+
<>
357
+
<div class="flex flex-wrap items-center gap-3">
358
+
<Button
359
+
onClick={() => importFromFileMutation.mutate({ destManager: destManager() })}
360
+
disabled={isImporting()}
361
+
>
362
+
{isImporting() ? 'Importing...' : 'Import from file'}
363
+
</Button>
364
+
365
+
<Show when={source()}>
366
+
{(src) => (
367
+
<Button
368
+
variant="secondary"
369
+
onClick={() =>
370
+
importFromSourceMutation.mutate({ source: src(), destManager: destManager() })
371
+
}
372
+
disabled={isImporting()}
373
+
>
374
+
Transfer from source
375
+
</Button>
376
+
)}
377
+
</Show>
378
+
</div>
379
+
380
+
<Show when={getImportStatusText()}>
381
+
{(text) => <span class="text-sm text-gray-600">{text()}</span>}
382
+
</Show>
383
+
384
+
<Show when={getImportError()}>{(err) => <p class="text-sm text-red-600">{`${err()}`}</p>}</Show>
385
+
</>
386
+
)}
387
+
</Show>
388
+
</Subsection>
389
+
390
+
<Subsection title="Status">
391
+
<Show
392
+
when={destination()?.manager}
393
+
fallback={<p class="text-sm text-gray-500">Sign in to destination account first.</p>}
394
+
>
395
+
{(destManager) => (
396
+
<>
397
+
<div class="flex items-center gap-3">
398
+
<Button
399
+
variant="outline"
400
+
onClick={() => checkStatusMutation.mutate({ destManager: destManager() })}
401
+
disabled={checkStatusMutation.isPending}
402
+
>
403
+
{checkStatusMutation.isPending ? 'Checking...' : 'Check status'}
404
+
</Button>
405
+
406
+
<Show when={checkStatusMutation.data}>
407
+
{(status) => (
408
+
<span class="text-sm">
409
+
<StatusBadge variant={status().imported === status().expected ? 'success' : 'pending'}>
410
+
{status().imported}/{status().expected} blobs
411
+
</StatusBadge>
412
+
</span>
413
+
)}
414
+
</Show>
415
+
</div>
416
+
417
+
<Show when={checkStatusMutation.data?.missingBlobs.length}>
418
+
{(count) => (
419
+
<div class="mt-2 rounded border border-yellow-300 bg-yellow-50 p-3">
420
+
<p class="mb-2 text-sm font-medium text-yellow-800">{count()} missing blobs</p>
421
+
422
+
<Show when={source()}>
423
+
{(src) => (
424
+
<Button
425
+
variant="secondary"
426
+
onClick={() =>
427
+
importFromSourceMutation.mutate({ source: src(), destManager: destManager() })
428
+
}
429
+
disabled={isImporting()}
430
+
>
431
+
Transfer missing from source
432
+
</Button>
433
+
)}
434
+
</Show>
435
+
436
+
<details class="mt-2">
437
+
<summary class="cursor-pointer text-sm text-yellow-700">Show CIDs</summary>
438
+
<div class="mt-1 max-h-32 overflow-auto font-mono text-xs">
439
+
<For each={checkStatusMutation.data?.missingBlobs}>
440
+
{(cid) => <div class="truncate">{cid}</div>}
441
+
</For>
442
+
</div>
443
+
</details>
444
+
</div>
445
+
)}
446
+
</Show>
447
+
</>
448
+
)}
449
+
</Show>
450
+
</Subsection>
451
+
</Accordion>
452
+
);
453
+
};
454
+
455
+
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;
+545
src/views/account/account-migrate/sections/identity.tsx
+545
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
+
import type { Did } from '@atcute/lexicons/syntax';
6
+
7
+
import { getPlcAuditLogs } from '~/api/queries/plc';
8
+
import { formatTotpCode, TOTP_RE } from '~/api/utils/auth';
9
+
10
+
import { createMutation } from '~/lib/utils/mutation';
11
+
12
+
import { Accordion, StatusBadge, Subsection } from '~/components/accordion';
13
+
import Button from '~/components/inputs/button';
14
+
import TextInput from '~/components/inputs/text-input';
15
+
import ToggleInput from '~/components/inputs/toggle-input';
16
+
17
+
import { getPlcPayload } from '~/views/identity/plc-applicator/plc-utils';
18
+
19
+
import { useMigration } from '../context';
20
+
21
+
interface RecommendedCredentials {
22
+
alsoKnownAs?: string[];
23
+
rotationKeys?: string[];
24
+
verificationMethods?: Record<string, unknown>;
25
+
services?: Record<string, unknown>;
26
+
}
27
+
28
+
interface GeneratedKeypair {
29
+
publicDidKey: DidKeyString;
30
+
privateHex: string;
31
+
privateMultikey: string;
32
+
}
33
+
34
+
const IdentitySection = () => {
35
+
const { source, destination } = useMigration();
36
+
37
+
// Rotation key state
38
+
const [useGeneratedKey, setUseGeneratedKey] = createSignal(false);
39
+
const [customKeys, setCustomKeys] = createSignal<string[]>([]);
40
+
const [plcToken, setPlcToken] = createSignal('');
41
+
42
+
const requestTokenMutation = createMutation({
43
+
async mutationFn({ manager }: { manager: CredentialManager }) {
44
+
const client = new Client({ handler: manager });
45
+
await ok(client.post('com.atproto.identity.requestPlcOperationSignature', { as: null }));
46
+
},
47
+
onError(err) {
48
+
console.error(err);
49
+
},
50
+
});
51
+
52
+
const loadCredentialsMutation = createMutation({
53
+
async mutationFn({ manager }: { manager: CredentialManager }) {
54
+
const client = new Client({ handler: manager });
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
+
}
94
+
},
95
+
onError(err) {
96
+
console.error(err);
97
+
},
98
+
});
99
+
100
+
const generateKeyMutation = createMutation({
101
+
async mutationFn() {
102
+
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
103
+
const [publicDidKey, privateHex, privateMultikey] = await Promise.all([
104
+
keypair.exportPublicKey('did'),
105
+
keypair.exportPrivateKey('rawHex'),
106
+
keypair.exportPrivateKey('multikey'),
107
+
]);
108
+
return { publicDidKey, privateHex, privateMultikey } as GeneratedKeypair;
109
+
},
110
+
onError(err) {
111
+
console.error(err);
112
+
},
113
+
});
114
+
115
+
const signAndSubmitMutation = createMutation({
116
+
async mutationFn({
117
+
sourceManager,
118
+
destManager,
119
+
token,
120
+
credentials,
121
+
generatedKey,
122
+
customKeys,
123
+
}: {
124
+
sourceManager: CredentialManager;
125
+
destManager: CredentialManager;
126
+
token: string;
127
+
credentials: RecommendedCredentials;
128
+
generatedKey?: GeneratedKeypair;
129
+
customKeys: string[];
130
+
}) {
131
+
const sourceClient = new Client({ handler: sourceManager });
132
+
const destClient = new Client({ handler: destManager });
133
+
134
+
// Prepend user keys to PDS-provided keys (so user keys appear first for recovery)
135
+
const pdsRotationKeys = credentials.rotationKeys ?? [];
136
+
const userKeys: string[] = [];
137
+
if (generatedKey) {
138
+
userKeys.push(generatedKey.publicDidKey);
139
+
}
140
+
userKeys.push(...customKeys.filter((k) => k.trim()));
141
+
const rotationKeys = [...userKeys, ...pdsRotationKeys];
142
+
143
+
// Sign the PLC operation on the source PDS
144
+
const signage = await ok(
145
+
sourceClient.post('com.atproto.identity.signPlcOperation', {
146
+
input: {
147
+
token: formatTotpCode(token),
148
+
alsoKnownAs: credentials.alsoKnownAs,
149
+
rotationKeys: rotationKeys,
150
+
services: credentials.services,
151
+
verificationMethods: credentials.verificationMethods,
152
+
},
153
+
}),
154
+
);
155
+
156
+
// Submit via the destination PDS
157
+
await ok(
158
+
destClient.post('com.atproto.identity.submitPlcOperation', {
159
+
as: null,
160
+
input: {
161
+
operation: signage.operation,
162
+
},
163
+
}),
164
+
);
165
+
},
166
+
onSuccess() {
167
+
setPlcToken('');
168
+
},
169
+
onError(err) {
170
+
console.error(err);
171
+
},
172
+
});
173
+
174
+
// Calculate rotation key counts
175
+
const pdsKeyCount = () => loadCredentialsMutation.data?.rotationKeys?.length ?? 0;
176
+
const totalKeyCount = () => {
177
+
const custom = customKeys().filter((k) => k.trim()).length;
178
+
const generated = useGeneratedKey() && generateKeyMutation.data ? 1 : 0;
179
+
return pdsKeyCount() + custom + generated;
180
+
};
181
+
const canAddCustomKey = () => totalKeyCount() < 5;
182
+
const isOverLimit = () => totalKeyCount() > 5;
183
+
184
+
const addCustomKey = () => {
185
+
if (canAddCustomKey()) {
186
+
setCustomKeys([...customKeys(), '']);
187
+
}
188
+
};
189
+
190
+
const removeCustomKey = (index: number) => {
191
+
setCustomKeys(customKeys().filter((_, i) => i !== index));
192
+
};
193
+
194
+
const updateCustomKey = (index: number, value: string) => {
195
+
setCustomKeys(customKeys().map((k, i) => (i === index ? value : k)));
196
+
};
197
+
198
+
const canSignAndSubmit = () => {
199
+
const src = source();
200
+
const dest = destination();
201
+
const creds = loadCredentialsMutation.data;
202
+
const token = plcToken().trim();
203
+
204
+
return !!(src?.manager && dest?.manager && creds && token && !isOverLimit());
205
+
};
206
+
207
+
const handleSignAndSubmit = () => {
208
+
const src = source();
209
+
const dest = destination();
210
+
const creds = loadCredentialsMutation.data;
211
+
const token = plcToken().trim();
212
+
213
+
if (!src?.manager || !dest?.manager || !creds || !token || isOverLimit()) return;
214
+
215
+
signAndSubmitMutation.mutate({
216
+
sourceManager: src.manager,
217
+
destManager: dest.manager,
218
+
token,
219
+
credentials: creds,
220
+
generatedKey: useGeneratedKey() ? generateKeyMutation.data : undefined,
221
+
customKeys: customKeys(),
222
+
});
223
+
};
224
+
225
+
const getSubmitErrorMessage = () => {
226
+
const err = signAndSubmitMutation.error;
227
+
if (err instanceof ClientResponseError) {
228
+
if (err.error === 'InvalidToken' || err.error === 'ExpiredToken') {
229
+
return 'Confirmation code has expired or is invalid';
230
+
}
231
+
}
232
+
return `${err}`;
233
+
};
234
+
235
+
return (
236
+
<Accordion title="Identity (PLC)">
237
+
<div class="mb-4 rounded border border-yellow-300 bg-yellow-50 p-3">
238
+
<p class="text-sm font-medium text-yellow-800">
239
+
This updates your DID document to point to the new PDS. This is the critical step that makes the
240
+
migration official.
241
+
</p>
242
+
</div>
243
+
244
+
<Subsection title="1. Preview new credentials">
245
+
<p class="text-sm text-gray-600">View what your DID document will look like after the migration.</p>
246
+
247
+
<Show
248
+
when={destination()?.manager}
249
+
fallback={<p class="text-sm text-gray-500">Sign in to destination account first.</p>}
250
+
>
251
+
{(manager) => (
252
+
<>
253
+
<div class="flex items-center gap-3">
254
+
<Button
255
+
variant="outline"
256
+
onClick={() => loadCredentialsMutation.mutate({ manager: manager() })}
257
+
disabled={loadCredentialsMutation.isPending}
258
+
>
259
+
{loadCredentialsMutation.isPending ? 'Loading...' : 'Load credentials'}
260
+
</Button>
261
+
262
+
<Show when={loadCredentialsMutation.isSuccess}>
263
+
<StatusBadge variant="success">Loaded</StatusBadge>
264
+
</Show>
265
+
</div>
266
+
267
+
<Show when={loadCredentialsMutation.isError}>
268
+
<p class="text-sm text-red-600">{`${loadCredentialsMutation.error}`}</p>
269
+
</Show>
270
+
271
+
<Show when={loadCredentialsMutation.data}>
272
+
{(creds) => (
273
+
<>
274
+
<div class="mt-2 text-sm">
275
+
<p class="text-gray-500">
276
+
Destination PDS rotation keys ({creds().rotationKeys?.length ?? 0}/5):
277
+
</p>
278
+
<div class="mt-1 flex flex-col gap-1">
279
+
<For each={creds().rotationKeys ?? []}>
280
+
{(key) => <code class="block truncate text-xs text-gray-700">{key}</code>}
281
+
</For>
282
+
</div>
283
+
</div>
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
+
346
+
<details class="mt-2">
347
+
<summary class="cursor-pointer text-sm text-gray-600">View full credentials</summary>
348
+
<pre class="mt-2 max-h-48 overflow-auto rounded border border-gray-200 bg-gray-50 p-2 font-mono text-xs">
349
+
{JSON.stringify(creds(), null, 2)}
350
+
</pre>
351
+
</details>
352
+
</>
353
+
)}
354
+
</Show>
355
+
</>
356
+
)}
357
+
</Show>
358
+
</Subsection>
359
+
360
+
<Subsection title="2. Rotation keys (optional)">
361
+
<p class="text-sm text-gray-600">
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.
364
+
</p>
365
+
366
+
<ToggleInput
367
+
label="Generate a new rotation key"
368
+
checked={useGeneratedKey()}
369
+
onChange={(checked) => {
370
+
setUseGeneratedKey(checked);
371
+
// Auto-generate if checked and no key exists yet
372
+
if (checked && !generateKeyMutation.data && !generateKeyMutation.isPending) {
373
+
generateKeyMutation.mutate();
374
+
}
375
+
}}
376
+
/>
377
+
378
+
<Show when={useGeneratedKey() && generateKeyMutation.isPending}>
379
+
<p class="mt-2 text-sm text-gray-500">Generating key...</p>
380
+
</Show>
381
+
382
+
<Show when={useGeneratedKey() && generateKeyMutation.isError}>
383
+
<p class="mt-2 text-sm text-red-600">{`${generateKeyMutation.error}`}</p>
384
+
</Show>
385
+
386
+
<Show when={useGeneratedKey() && generateKeyMutation.data}>
387
+
{(keypair) => (
388
+
<div class="rounded border border-green-300 bg-green-50 p-3">
389
+
<p class="mb-2 text-sm font-semibold text-green-800">Save your rotation key private key!</p>
390
+
<p class="mb-3 text-xs text-green-700">
391
+
Store this securely. You'll need it to recover your account if your PDS becomes unavailable or
392
+
malicious.
393
+
</p>
394
+
395
+
<div class="flex flex-col gap-2 text-sm">
396
+
<div>
397
+
<p class="font-medium text-gray-600">Public key (did:key)</p>
398
+
<p class="break-all font-mono text-xs">{keypair().publicDidKey}</p>
399
+
</div>
400
+
<div>
401
+
<p class="font-medium text-gray-600">Private key (hex)</p>
402
+
<p class="break-all font-mono text-xs">{keypair().privateHex}</p>
403
+
</div>
404
+
<div>
405
+
<p class="font-medium text-gray-600">Private key (multikey)</p>
406
+
<p class="break-all font-mono text-xs">{keypair().privateMultikey}</p>
407
+
</div>
408
+
</div>
409
+
</div>
410
+
)}
411
+
</Show>
412
+
413
+
<div class="rounded border border-gray-200 bg-gray-50 p-3">
414
+
<p class="mb-2 text-sm font-medium text-gray-700">Custom rotation keys</p>
415
+
<p class="mb-3 text-xs text-gray-500">
416
+
Add existing rotation keys (did:key format) you already control.
417
+
</p>
418
+
419
+
<Index each={customKeys()}>
420
+
{(key, index) => (
421
+
<div class="mb-2 flex items-center gap-2">
422
+
<TextInput
423
+
label=""
424
+
placeholder="did:key:z..."
425
+
monospace
426
+
autocomplete="off"
427
+
value={key()}
428
+
onChange={(value) => updateCustomKey(index, value)}
429
+
/>
430
+
<button
431
+
type="button"
432
+
class="shrink-0 rounded px-2 py-1 text-sm text-red-600 hover:bg-red-50"
433
+
onClick={() => removeCustomKey(index)}
434
+
>
435
+
Remove
436
+
</button>
437
+
</div>
438
+
)}
439
+
</Index>
440
+
441
+
<Button variant="outline" onClick={addCustomKey} disabled={!canAddCustomKey()}>
442
+
Add rotation key
443
+
</Button>
444
+
445
+
<Show when={isOverLimit()}>
446
+
<p class="mt-2 text-sm text-red-600">
447
+
Too many rotation keys. PLC documents can only have up to 5 rotation keys total.
448
+
</p>
449
+
</Show>
450
+
451
+
<p class="mt-2 text-xs text-gray-500">
452
+
Total keys: {totalKeyCount()}/5 (PDS: {pdsKeyCount()}
453
+
{useGeneratedKey() && generateKeyMutation.data ? ', generated: 1' : ''}
454
+
{customKeys().filter((k) => k.trim()).length > 0
455
+
? `, custom: ${customKeys().filter((k) => k.trim()).length}`
456
+
: ''}
457
+
)
458
+
</p>
459
+
</div>
460
+
</Subsection>
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
+
496
+
<Subsection title="4. Sign and submit">
497
+
<p class="text-sm text-gray-600">Enter the confirmation code and submit the PLC operation.</p>
498
+
499
+
<Show when={!source()?.manager || !destination()?.manager}>
500
+
<p class="text-sm text-gray-500">Sign in to both source and destination accounts first.</p>
501
+
</Show>
502
+
503
+
<Show when={!loadCredentialsMutation.data}>
504
+
<p class="text-sm text-gray-500">Load credentials first.</p>
505
+
</Show>
506
+
507
+
<Show when={useGeneratedKey() && !generateKeyMutation.data}>
508
+
<p class="text-sm text-gray-500">Generate your rotation key first.</p>
509
+
</Show>
510
+
511
+
<Show when={source()?.manager && destination()?.manager && loadCredentialsMutation.data}>
512
+
<TextInput
513
+
label="Confirmation code from email"
514
+
type="text"
515
+
autocomplete="one-time-code"
516
+
pattern={TOTP_RE.source}
517
+
placeholder="AAAAA-BBBBB"
518
+
value={plcToken()}
519
+
onChange={setPlcToken}
520
+
monospace
521
+
/>
522
+
523
+
<div class="flex items-center gap-3">
524
+
<Button
525
+
onClick={handleSignAndSubmit}
526
+
disabled={signAndSubmitMutation.isPending || !canSignAndSubmit()}
527
+
>
528
+
{signAndSubmitMutation.isPending ? 'Submitting...' : 'Sign and submit'}
529
+
</Button>
530
+
531
+
<Show when={signAndSubmitMutation.isSuccess}>
532
+
<StatusBadge variant="success">Identity updated successfully</StatusBadge>
533
+
</Show>
534
+
</div>
535
+
536
+
<Show when={signAndSubmitMutation.isError}>
537
+
<p class="text-sm text-red-600">{getSubmitErrorMessage()}</p>
538
+
</Show>
539
+
</Show>
540
+
</Subsection>
541
+
</Accordion>
542
+
);
543
+
};
544
+
545
+
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
+
const file = await fd.getFile();
113
+
114
+
setImportStatus(`Uploading repository (${formatBytes(file.size)})...`);
115
+
116
+
const destClient = new Client({ handler: manager });
117
+
const importResp = await destClient.post('com.atproto.repo.importRepo', {
118
+
as: null,
119
+
input: file,
120
+
headers: {
121
+
'content-type': 'application/vnd.ipld.car',
122
+
},
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
+
input: response.data,
174
+
headers: {
175
+
'content-type': 'application/vnd.ipld.car',
176
+
},
177
+
});
178
+
179
+
if (!importResp.ok) {
180
+
throw new Error(`Failed to import repository: ${importResp.status}`);
181
+
}
182
+
183
+
// Check account status to get record count
184
+
const status = await ok(destClient.get('com.atproto.server.checkAccountStatus', {}));
185
+
setImportedRecords(status.indexedRecords);
186
+
187
+
setImportStatus(`Imported successfully`);
188
+
return status.indexedRecords;
189
+
},
190
+
onMutate() {
191
+
setImportStatus();
192
+
setImportedRecords();
193
+
},
194
+
onError(err) {
195
+
console.error(err);
196
+
setImportStatus();
197
+
},
198
+
});
199
+
200
+
const isExporting = () => exportMutation.isPending;
201
+
const isImporting = () => importFromFileMutation.isPending || importFromSourceMutation.isPending;
202
+
203
+
return (
204
+
<Accordion title="Repository">
205
+
<Subsection title="Export from source">
206
+
<p class="text-sm text-gray-600">
207
+
Download the repository as a CAR file for backup or manual import.
208
+
</p>
209
+
210
+
<Show when={source()} fallback={<p class="text-sm text-gray-500">Resolve source account first.</p>}>
211
+
{(src) => (
212
+
<>
213
+
<div class="flex items-center gap-3">
214
+
<Button
215
+
onClick={() => exportMutation.mutate({ pdsUrl: src().pdsUrl, did: src().did })}
216
+
disabled={isExporting()}
217
+
>
218
+
{isExporting() ? 'Exporting...' : 'Export to file'}
219
+
</Button>
220
+
<Show when={exportStatus()}>
221
+
<span class="text-sm text-gray-600">{exportStatus()}</span>
222
+
</Show>
223
+
</div>
224
+
225
+
<Show when={exportMutation.isError}>
226
+
<p class="text-sm text-red-600">{`${exportMutation.error}`}</p>
227
+
</Show>
228
+
</>
229
+
)}
230
+
</Show>
231
+
</Subsection>
232
+
233
+
<Subsection title="Import to destination">
234
+
<p class="text-sm text-gray-600">Upload a repository CAR file or transfer directly from source.</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;
+2
-5
src/views/blob/blob-export.tsx
+2
-5
src/views/blob/blob-export.tsx
···
17
17
import Button from '~/components/inputs/button';
18
18
import TextInput from '~/components/inputs/text-input';
19
19
import Logger, { createLogger } from '~/components/logger';
20
+
import PageHeader from '~/components/page-header';
20
21
21
22
const BlobExportPage = () => {
22
23
const logger = createLogger();
···
229
230
230
231
return (
231
232
<>
232
-
<div class="p-4">
233
-
<h1 class="text-lg font-bold text-purple-800">Export blobs</h1>
234
-
<p class="text-gray-600">Download all blobs from an account into a tarball</p>
235
-
</div>
236
-
<hr class="mx-4 border-gray-300" />
233
+
<PageHeader title="Export blobs" subtitle="Download all blobs from an account into a tarball" />
237
234
238
235
<form
239
236
onSubmit={(ev) => {
+2
-5
src/views/bluesky/threadgate-applicator/page.tsx
+2
-5
src/views/bluesky/threadgate-applicator/page.tsx
···
10
10
11
11
import { useTitle } from '~/lib/navigation/router';
12
12
13
+
import PageHeader from '~/components/page-header';
13
14
import { Wizard } from '~/components/wizard';
14
15
15
16
import Step1_HandleInput from './steps/step1_handle-input';
···
80
81
81
82
return (
82
83
<>
83
-
<div class="p-4">
84
-
<h1 class="text-lg font-bold text-purple-800">Retroactive thread gating</h1>
85
-
<p class="text-gray-600">Set reply permissions on all of your past Bluesky posts</p>
86
-
</div>
87
-
<hr class="mx-4 border-gray-300" />
84
+
<PageHeader title="Retroactive thread gating" subtitle="Set reply permissions on all of your past Bluesky posts" />
88
85
89
86
<Wizard<ThreadgateApplicatorConstraints>
90
87
initialStep="Step1_HandleInput"
+3
-6
src/views/crypto/crypto-generate.tsx
+3
-6
src/views/crypto/crypto-generate.tsx
···
6
6
7
7
import Button from '~/components/inputs/button';
8
8
import RadioInput from '~/components/inputs/radio-input';
9
+
import PageHeader from '~/components/page-header';
9
10
10
11
type KeyType = 'p256' | 'secp256k1';
11
12
···
26
27
27
28
return (
28
29
<>
29
-
<div class="p-4">
30
-
<h1 class="text-lg font-bold text-purple-800">Generate secret keys</h1>
31
-
<p class="text-gray-600">Create a new secp256k1/nistp256 keypair</p>
32
-
</div>
33
-
<hr class="mx-4 border-gray-300" />
30
+
<PageHeader title="Generate secret keys" subtitle="Create a new secp256k1/nistp256 keypair" />
34
31
35
32
<form
36
33
onSubmit={async (ev) => {
···
54
51
]);
55
52
56
53
const result: KeypairResult = {
57
-
type: keypair.type,
54
+
type: keypair.type as KeyType,
58
55
publicDidKey,
59
56
privateHex,
60
57
privateMultikey,
+255
src/views/crypto/crypto-info.tsx
+255
src/views/crypto/crypto-info.tsx
···
1
+
import { createMemo, createSignal, Match, Show, Switch } from 'solid-js';
2
+
3
+
import {
4
+
type DidKeyString,
5
+
P256PrivateKeyExportable,
6
+
P256PublicKey,
7
+
parseDidKey,
8
+
parsePrivateMultikey,
9
+
parsePublicMultikey,
10
+
Secp256k1PrivateKeyExportable,
11
+
Secp256k1PublicKey,
12
+
} from '@atcute/crypto';
13
+
import { fromBase16 } from '@atcute/multibase';
14
+
15
+
import { useTitle } from '~/lib/navigation/router';
16
+
17
+
import Button from '~/components/inputs/button';
18
+
import RadioInput from '~/components/inputs/radio-input';
19
+
import TextInput from '~/components/inputs/text-input';
20
+
import PageHeader from '~/components/page-header';
21
+
22
+
type KeyType = 'p256' | 'secp256k1';
23
+
type KeyFormat = 'did:key' | 'multikey' | 'hex';
24
+
25
+
interface KeyInfo {
26
+
keyType: KeyType;
27
+
isPrivate: boolean;
28
+
inputFormat: KeyFormat;
29
+
publicDidKey: DidKeyString;
30
+
publicMultikey: string;
31
+
privateHex?: string;
32
+
privateMultikey?: string;
33
+
}
34
+
35
+
const DID_KEY_REGEX = /^did:key:z[a-km-zA-HJ-NP-Z1-9]+$/;
36
+
const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/;
37
+
const HEX_REGEX = /^[0-9a-f]+$/;
38
+
39
+
const CryptoInfoPage = () => {
40
+
const [input, setInput] = createSignal('');
41
+
const [hexKeyType, setHexKeyType] = createSignal<KeyType>();
42
+
const [result, setResult] = createSignal<KeyInfo>();
43
+
const [error, setError] = createSignal<string>();
44
+
45
+
const detectedFormat = createMemo((): KeyFormat | undefined => {
46
+
const $input = input().trim();
47
+
48
+
if (DID_KEY_REGEX.test($input)) {
49
+
return 'did:key';
50
+
}
51
+
if (MULTIKEY_REGEX.test($input)) {
52
+
return 'multikey';
53
+
}
54
+
if (HEX_REGEX.test($input)) {
55
+
return 'hex';
56
+
}
57
+
});
58
+
59
+
const canSubmit = createMemo(() => {
60
+
const format = detectedFormat();
61
+
if (!format) {
62
+
return false;
63
+
}
64
+
if (format === 'hex' && !hexKeyType()) {
65
+
return false;
66
+
}
67
+
return true;
68
+
});
69
+
70
+
useTitle(() => `View crypto key info โ boat`);
71
+
72
+
return (
73
+
<>
74
+
<PageHeader title="View crypto key info" subtitle="Show basic metadata about a public or private key" />
75
+
76
+
<form
77
+
onSubmit={async (ev) => {
78
+
ev.preventDefault();
79
+
80
+
const $input = input().trim();
81
+
const format = detectedFormat();
82
+
83
+
setResult();
84
+
setError();
85
+
86
+
try {
87
+
let info: KeyInfo;
88
+
89
+
if (format === 'did:key') {
90
+
const parsed = parseDidKey($input);
91
+
const pubKey =
92
+
parsed.type === 'p256'
93
+
? await P256PublicKey.importRaw(parsed.publicKeyBytes)
94
+
: await Secp256k1PublicKey.importRaw(parsed.publicKeyBytes);
95
+
96
+
info = {
97
+
keyType: parsed.type,
98
+
isPrivate: false,
99
+
inputFormat: 'did:key',
100
+
publicDidKey: await pubKey.exportPublicKey('did'),
101
+
publicMultikey: await pubKey.exportPublicKey('multikey'),
102
+
};
103
+
} else if (format === 'multikey') {
104
+
// try parsing as private key first
105
+
try {
106
+
const parsed = parsePrivateMultikey($input);
107
+
const privKey =
108
+
parsed.type === 'p256'
109
+
? await P256PrivateKeyExportable.importRaw(parsed.privateKeyBytes)
110
+
: await Secp256k1PrivateKeyExportable.importRaw(parsed.privateKeyBytes);
111
+
112
+
info = {
113
+
keyType: parsed.type,
114
+
isPrivate: true,
115
+
inputFormat: 'multikey',
116
+
publicDidKey: await privKey.exportPublicKey('did'),
117
+
publicMultikey: await privKey.exportPublicKey('multikey'),
118
+
privateHex: await privKey.exportPrivateKey('rawHex'),
119
+
privateMultikey: await privKey.exportPrivateKey('multikey'),
120
+
};
121
+
} catch {
122
+
// try parsing as public key
123
+
const parsed = parsePublicMultikey($input);
124
+
const pubKey =
125
+
parsed.type === 'p256'
126
+
? await P256PublicKey.importRaw(parsed.publicKeyBytes)
127
+
: await Secp256k1PublicKey.importRaw(parsed.publicKeyBytes);
128
+
129
+
info = {
130
+
keyType: parsed.type,
131
+
isPrivate: false,
132
+
inputFormat: 'multikey',
133
+
publicDidKey: await pubKey.exportPublicKey('did'),
134
+
publicMultikey: await pubKey.exportPublicKey('multikey'),
135
+
};
136
+
}
137
+
} else if (format === 'hex') {
138
+
const keyType = hexKeyType()!;
139
+
const privateKeyBytes = fromBase16($input);
140
+
141
+
const privKey =
142
+
keyType === 'p256'
143
+
? await P256PrivateKeyExportable.importRaw(privateKeyBytes)
144
+
: await Secp256k1PrivateKeyExportable.importRaw(privateKeyBytes);
145
+
146
+
info = {
147
+
keyType: keyType,
148
+
isPrivate: true,
149
+
inputFormat: 'hex',
150
+
publicDidKey: await privKey.exportPublicKey('did'),
151
+
publicMultikey: await privKey.exportPublicKey('multikey'),
152
+
privateHex: await privKey.exportPrivateKey('rawHex'),
153
+
privateMultikey: await privKey.exportPrivateKey('multikey'),
154
+
};
155
+
} else {
156
+
throw new Error('Unknown key format');
157
+
}
158
+
159
+
setResult(info);
160
+
} catch (err) {
161
+
console.error(err);
162
+
setError(`Failed to parse key: ${err}`);
163
+
}
164
+
}}
165
+
class="flex flex-col gap-4 p-4"
166
+
>
167
+
<TextInput
168
+
label="Public or private key"
169
+
blurb="Accepts did:key, multikey, or hex format"
170
+
monospace
171
+
autocomplete="off"
172
+
autocorrect="off"
173
+
placeholder="did:key:z... or z... or a5973930f9d348..."
174
+
value={input()}
175
+
required
176
+
onChange={setInput}
177
+
/>
178
+
179
+
<Show when={detectedFormat() === 'hex'}>
180
+
<RadioInput
181
+
label="This is a..."
182
+
value={hexKeyType()}
183
+
required
184
+
options={[
185
+
{ value: 'secp256k1', label: `ES256K (secp256k1) private key` },
186
+
{ value: 'p256', label: `ES256 (p256) private key` },
187
+
]}
188
+
onChange={setHexKeyType}
189
+
/>
190
+
</Show>
191
+
192
+
<div>
193
+
<Button type="submit" disabled={!canSubmit()}>
194
+
Inspect
195
+
</Button>
196
+
</div>
197
+
</form>
198
+
199
+
<hr class="mx-4 border-gray-300" />
200
+
201
+
<Switch>
202
+
<Match when={error()}>
203
+
<div class="p-4 text-red-600">{error()}</div>
204
+
</Match>
205
+
206
+
<Match when={result()} keyed>
207
+
{(info) => (
208
+
<div class="flex flex-col gap-6 break-words p-4 text-gray-900">
209
+
<div>
210
+
<p class="font-semibold text-gray-600">Key type</p>
211
+
<span>
212
+
{/* @once */ info.keyType === 'p256'
213
+
? 'ES256 (p256)'
214
+
: 'ES256K (secp256k1)'}{' '}
215
+
{/* @once */ info.isPrivate ? 'private' : 'public'} key
216
+
</span>
217
+
</div>
218
+
219
+
<div>
220
+
<p class="font-semibold text-gray-600">Input encoding</p>
221
+
<span>{/* @once */ info.inputFormat}</span>
222
+
</div>
223
+
224
+
<div>
225
+
<p class="font-semibold text-gray-600">Public key (did:key)</p>
226
+
<span class="font-mono">{/* @once */ info.publicDidKey}</span>
227
+
</div>
228
+
229
+
<div>
230
+
<p class="font-semibold text-gray-600">Public key (multikey)</p>
231
+
<span class="font-mono">{/* @once */ info.publicMultikey}</span>
232
+
</div>
233
+
234
+
<Show when={info.privateHex}>
235
+
<div>
236
+
<p class="font-semibold text-gray-600">Private key (hex)</p>
237
+
<span class="font-mono">{/* @once */ info.privateHex}</span>
238
+
</div>
239
+
</Show>
240
+
241
+
<Show when={info.privateMultikey}>
242
+
<div>
243
+
<p class="font-semibold text-gray-600">Private key (multikey)</p>
244
+
<span class="font-mono">{/* @once */ info.privateMultikey}</span>
245
+
</div>
246
+
</Show>
247
+
</div>
248
+
)}
249
+
</Match>
250
+
</Switch>
251
+
</>
252
+
);
253
+
};
254
+
255
+
export default CryptoInfoPage;
+5
-7
src/views/frontpage.tsx
+5
-7
src/views/frontpage.tsx
···
2
2
3
3
import { useTitle } from '~/lib/navigation/router';
4
4
5
+
import PageHeader from '~/components/page-header';
6
+
5
7
import HistoryIcon from '~/components/ic-icons/baseline-history';
6
8
import KeyIcon from '~/components/ic-icons/baseline-key';
7
9
import KeyVisualizerIcon from '~/components/ic-icons/baseline-key-visualizer';
···
102
104
{
103
105
name: `Migrate account`,
104
106
description: `Move your account data to another server`,
105
-
href: null,
107
+
href: '/account-migrate',
106
108
icon: MoveUpOutlinedIcon,
107
109
},
108
110
],
···
119
121
{
120
122
name: `View crypto key info`,
121
123
description: `Show basic metadata about a public or private key`,
122
-
href: null,
124
+
href: `/crypto-info`,
123
125
icon: KeyVisualizerIcon,
124
126
},
125
127
],
···
170
172
171
173
return (
172
174
<>
173
-
<div class="p-4">
174
-
<h1 class="text-lg font-bold text-purple-800">boat</h1>
175
-
<p class="text-gray-600">handy online tools for AT Protocol</p>
176
-
</div>
177
-
<hr class="mx-4 border-gray-300" />
175
+
<PageHeader title="boat" subtitle="handy online tools for AT Protocol" />
178
176
179
177
<div class="flex grow flex-col pb-2">{nodes}</div>
180
178
+13
-28
src/views/identity/did-lookup.tsx
+13
-28
src/views/identity/did-lookup.tsx
···
15
15
import ErrorView from '~/components/error-view';
16
16
import Button from '~/components/inputs/button';
17
17
import TextInput from '~/components/inputs/text-input';
18
+
import PageHeader from '~/components/page-header';
18
19
19
20
const DidLookupPage = () => {
20
21
const [params, setParams] = useSearchParams({
···
46
47
47
48
return (
48
49
<>
49
-
<div class="p-4">
50
-
<h1 class="text-lg font-bold text-purple-800">View identity info</h1>
51
-
<p class="text-gray-600">Look up an account's DID document</p>
52
-
</div>
53
-
<hr class="mx-4 border-gray-300" />
50
+
<PageHeader title="View identity info" subtitle="Look up an account's DID document" />
54
51
55
52
<form
56
53
onSubmit={(ev) => {
···
133
130
134
131
<div class="mt-2 flex flex-wrap gap-2 empty:hidden">
135
132
{isPDS && isServiceUrl && (
136
-
<button
137
-
disabled
138
-
class="flex h-9 select-none items-center rounded border border-gray-300 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-100 disabled:pointer-events-none disabled:opacity-50"
139
-
>
133
+
<Button variant="outline" disabled>
140
134
View PDS info
141
-
</button>
135
+
</Button>
142
136
)}
143
137
144
138
{isPDS && isServiceUrl && (
145
-
<button
146
-
disabled
147
-
class="flex h-9 select-none items-center rounded border border-gray-300 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-100 disabled:pointer-events-none disabled:opacity-50"
148
-
>
139
+
<Button variant="outline" disabled>
149
140
Explore account repository
150
-
</button>
141
+
</Button>
151
142
)}
152
143
153
144
{isLabeler && isServiceUrl && (
154
-
<button
155
-
disabled
156
-
class="flex h-9 select-none items-center rounded border border-gray-300 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-100 disabled:pointer-events-none disabled:opacity-50"
157
-
>
145
+
<Button variant="outline" disabled>
158
146
View emitted labels
159
-
</button>
147
+
</Button>
160
148
)}
161
149
</div>
162
150
</li>
···
185
173
</div>
186
174
187
175
<div class="flex flex-wrap gap-4 p-4 pt-2">
188
-
<button
176
+
<Button
177
+
variant="outline"
189
178
onClick={() => {
190
179
navigator.clipboard.writeText(JSON.stringify(doc, null, 2));
191
180
}}
192
-
class="flex h-9 select-none items-center rounded border border-gray-300 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-100"
193
181
>
194
182
Copy DID document
195
-
</button>
183
+
</Button>
196
184
197
185
{isDidPlc && (
198
-
<a
199
-
href={`/plc-oplogs?q=${params.q!}`}
200
-
class="flex h-9 select-none items-center rounded border border-gray-300 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-100"
201
-
>
186
+
<Button variant="outline" href={`/plc-oplogs?q=${params.q!}`}>
202
187
View PLC operation logs
203
-
</a>
188
+
</Button>
204
189
)}
205
190
</div>
206
191
</>
+2
-5
src/views/identity/plc-applicator/page.tsx
+2
-5
src/views/identity/plc-applicator/page.tsx
···
13
13
14
14
import { useTitle } from '~/lib/navigation/router';
15
15
16
+
import PageHeader from '~/components/page-header';
16
17
import { Wizard } from '~/components/wizard';
17
18
18
19
import Step1_HandleInput from './steps/step1_handle-input';
···
101
102
102
103
return (
103
104
<>
104
-
<div class="p-4">
105
-
<h1 class="text-lg font-bold text-purple-800">Apply PLC operations</h1>
106
-
<p class="text-gray-600">Submit operations to your did:plc identity</p>
107
-
</div>
108
-
<hr class="mx-4 border-gray-300" />
105
+
<PageHeader title="Apply PLC operations" subtitle="Submit operations to your did:plc identity" />
109
106
110
107
<Wizard<PlcApplicatorConstraints>
111
108
initialStep="Step1_HandleInput"
+2
-5
src/views/identity/plc-oplogs.tsx
+2
-5
src/views/identity/plc-oplogs.tsx
···
20
20
import ContentCopyIcon from '~/components/ic-icons/baseline-content-copy';
21
21
import Button from '~/components/inputs/button';
22
22
import TextInput from '~/components/inputs/text-input';
23
+
import PageHeader from '~/components/page-header';
23
24
24
25
const PlcOperationLogPage = () => {
25
26
const [params, setParams] = useSearchParams({
···
55
56
56
57
return (
57
58
<>
58
-
<div class="p-4">
59
-
<h1 class="text-lg font-bold text-purple-800">View PLC operation logs</h1>
60
-
<p class="text-gray-600">Show history of a did:plc identity</p>
61
-
</div>
62
-
<hr class="mx-4 border-gray-300" />
59
+
<PageHeader title="View PLC operation logs" subtitle="Show history of a did:plc identity" />
63
60
64
61
<form
65
62
onSubmit={(ev) => {
+8
-41
src/views/repository/repo-archive-explore/views/welcome.tsx
+8
-41
src/views/repository/repo-archive-explore/views/welcome.tsx
···
3
3
import type { MutationReturn } from '~/lib/utils/mutation';
4
4
5
5
import CircularProgress from '~/components/circular-progress';
6
-
import { createDropZone } from '~/lib/hooks/dropzone';
6
+
import FileDropZone from '~/components/file-drop-zone';
7
+
import PageHeader from '~/components/page-header';
7
8
8
9
import type { Archive } from '../types';
9
10
···
12
13
}
13
14
14
15
const WelcomeView = ({ mutation }: WelcomeViewProps) => {
15
-
const { ref: dropRef, isDropping } = createDropZone({
16
-
// Checked, the mime type for CAR files is blank.
17
-
dataTypes: [''],
18
-
multiple: false,
19
-
onDrop(files) {
20
-
if (files) {
21
-
mutation.mutate({ file: files[0] });
22
-
}
23
-
},
24
-
});
25
-
26
16
return (
27
17
<>
28
-
<div class="p-4">
29
-
<h1 class="text-lg font-bold text-purple-800">Explore archive</h1>
30
-
<p class="text-gray-600">Explore a repository archive</p>
31
-
</div>
32
-
<hr class="mx-4 border-gray-300" />
18
+
<PageHeader title="Explore archive" subtitle="Explore a repository archive" />
33
19
34
20
<div class="flex flex-col gap-4 p-4">
35
-
<fieldset
36
-
ref={dropRef}
37
-
class={
38
-
`grid place-items-center rounded border border-gray-300 px-6 py-12 disabled:opacity-50` +
39
-
(!isDropping() ? ` bg-gray-100` : ` bg-green-100`)
40
-
}
21
+
<FileDropZone
22
+
accept=".car,application/vnd.ipld.car"
23
+
dataTypes={['']}
24
+
onFiles={(files) => mutation.mutate({ file: files[0] })}
41
25
>
42
-
<div class="flex flex-col items-center gap-4">
43
-
<button
44
-
onClick={() => {
45
-
const input = document.createElement('input');
46
-
input.type = 'file';
47
-
input.accept = '.car,application/vnd.ipld.car';
48
-
input.oninput = () => mutation.mutate({ file: input.files![0] });
49
-
50
-
input.click();
51
-
}}
52
-
class="flex h-9 select-none items-center rounded border border-gray-400 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-200 active:bg-gray-200 disabled:pointer-events-none"
53
-
>
54
-
Browse files
55
-
</button>
56
-
<p class="select-none font-medium text-gray-600">or drop your file here</p>
57
-
</div>
58
-
59
26
<div
60
27
hidden={!mutation.isPending}
61
28
class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-gray-50"
···
63
30
<CircularProgress />
64
31
<span class="font-medium">Reading CAR file</span>
65
32
</div>
66
-
</fieldset>
33
+
</FileDropZone>
67
34
68
35
<Show when={mutation.error}>
69
36
<p class="whitespace-pre-wrap text-[0.8125rem] font-medium leading-5 text-red-800">
+8
-41
src/views/repository/repo-archive-unpack.tsx
+8
-41
src/views/repository/repo-archive-unpack.tsx
···
4
4
import { fromStream } from '@atcute/repo';
5
5
import { writeTarEntry } from '@mary/tar';
6
6
7
-
import { createDropZone } from '~/lib/hooks/dropzone';
8
7
import { useTitle } from '~/lib/navigation/router';
9
8
import { makeAbortable } from '~/lib/utils/abortable';
10
9
10
+
import FileDropZone from '~/components/file-drop-zone';
11
11
import Logger, { createLogger } from '~/components/logger';
12
+
import PageHeader from '~/components/page-header';
12
13
13
14
// @ts-expect-error: new API
14
15
const yieldToScheduler: () => Promise<void> = window?.scheduler?.yield
···
21
22
22
23
const [getSignal, cleanup] = makeAbortable();
23
24
const [pending, setPending] = createSignal(false);
24
-
25
-
const { ref: dropRef, isDropping } = createDropZone({
26
-
// Checked, the mime type for CAR files is blank.
27
-
dataTypes: [''],
28
-
multiple: false,
29
-
onDrop(files) {
30
-
if (files) {
31
-
onFileDrop(files);
32
-
}
33
-
},
34
-
});
35
25
36
26
const mutate = async (file: File, signal: AbortSignal) => {
37
27
logger.log(`Starting extraction`);
···
155
145
156
146
return (
157
147
<>
158
-
<div class="p-4">
159
-
<h1 class="text-lg font-bold text-purple-800">Unpack archive</h1>
160
-
<p class="text-gray-600">Extract a repository archive into a tarball</p>
161
-
</div>
162
-
<hr class="mx-4 border-gray-300" />
148
+
<PageHeader title="Unpack archive" subtitle="Extract a repository archive into a tarball" />
163
149
164
150
<div class="p-4">
165
-
<fieldset
166
-
ref={dropRef}
151
+
<FileDropZone
152
+
accept=".car,application/vnd.ipld.car"
153
+
dataTypes={['']}
167
154
disabled={pending()}
168
-
class={
169
-
`grid place-items-center rounded border border-gray-300 px-6 py-12 disabled:opacity-50` +
170
-
(pending() || !isDropping() ? ` bg-gray-100` : ` bg-green-100`)
171
-
}
172
-
>
173
-
<div class="flex flex-col items-center gap-4">
174
-
<button
175
-
onClick={() => {
176
-
const input = document.createElement('input');
177
-
input.type = 'file';
178
-
input.accept = '.car,application/vnd.ipld.car';
179
-
input.oninput = () => onFileDrop(Array.from(input.files!));
180
-
181
-
input.click();
182
-
}}
183
-
class="flex h-9 select-none items-center rounded border border-gray-400 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-200 active:bg-gray-200 disabled:pointer-events-none"
184
-
>
185
-
Browse files
186
-
</button>
187
-
<p class="select-none font-medium text-gray-600">or drop your file here</p>
188
-
</div>
189
-
</fieldset>
155
+
onFiles={onFileDrop}
156
+
/>
190
157
</div>
191
158
<hr class="mx-4 border-gray-300" />
192
159
+3
-24
src/views/repository/repo-export.tsx
+3
-24
src/views/repository/repo-export.tsx
···
11
11
import { useTitle } from '~/lib/navigation/router';
12
12
import { makeAbortable } from '~/lib/utils/abortable';
13
13
import { formatBytes } from '~/lib/utils/intl/bytes';
14
+
import { iterateStream } from '~/lib/utils/stream';
14
15
15
16
import Button from '~/components/inputs/button';
16
17
import TextInput from '~/components/inputs/text-input';
17
18
import Logger, { createLogger } from '~/components/logger';
19
+
import PageHeader from '~/components/page-header';
18
20
19
21
const RepoExportPage = () => {
20
22
const logger = createLogger();
···
135
137
136
138
return (
137
139
<>
138
-
<div class="p-4">
139
-
<h1 class="text-lg font-bold text-purple-800">Export repository</h1>
140
-
<p class="text-gray-600">Download an archive of an account's repository</p>
141
-
</div>
142
-
<hr class="mx-4 border-gray-300" />
140
+
<PageHeader title="Export repository" subtitle="Download an archive of an account's repository" />
143
141
144
142
<form
145
143
onSubmit={(ev) => {
···
222
220
};
223
221
224
222
export default RepoExportPage;
225
-
226
-
export async function* iterateStream<T>(stream: ReadableStream<T>) {
227
-
// Get a lock on the stream
228
-
const reader = stream.getReader();
229
-
230
-
try {
231
-
while (true) {
232
-
const { done, value } = await reader.read();
233
-
234
-
if (done) {
235
-
return;
236
-
}
237
-
238
-
yield value;
239
-
}
240
-
} finally {
241
-
reader.releaseLock();
242
-
}
243
-
}