handy online tools for AT Protocol boat.kelinci.net
atproto bluesky atcute typescript solidjs

Compare changes

Choose any two refs to compare.

+1
.gitignore
··· 2 2 dist/ 3 3 4 4 /.wrangler/ 5 + /.research/ 5 6 6 7 *.local 7 8 *.local.ts
+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
··· 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
··· 1 + import { createSignal, type JSX, Show } from 'solid-js'; 2 + 3 + import ChevronRightIcon from '~/components/ic-icons/baseline-chevron-right'; 4 + 5 + export interface AccordionProps { 6 + title: string; 7 + children: JSX.Element; 8 + defaultOpen?: boolean; 9 + } 10 + 11 + export const Accordion = (props: AccordionProps) => { 12 + const [isOpen, setIsOpen] = createSignal(props.defaultOpen ?? false); 13 + 14 + return ( 15 + <div class="border-b border-gray-200"> 16 + <button 17 + type="button" 18 + onClick={() => setIsOpen(!isOpen())} 19 + class="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-gray-50" 20 + > 21 + <ChevronRightIcon 22 + class={`h-5 w-5 text-gray-500 transition-transform` + (isOpen() ? ` rotate-90` : ``)} 23 + /> 24 + <span class="font-semibold">{props.title}</span> 25 + </button> 26 + 27 + <Show when={isOpen()}> 28 + <div class="pb-4 pl-12 pr-4">{props.children}</div> 29 + </Show> 30 + </div> 31 + ); 32 + }; 33 + 34 + export interface SubsectionProps { 35 + title: string; 36 + children: JSX.Element; 37 + } 38 + 39 + export const Subsection = (props: SubsectionProps) => { 40 + return ( 41 + <div class="mb-4 last:mb-0"> 42 + <h4 class="mb-3 text-sm font-semibold text-gray-600">{props.title}</h4> 43 + <div class="flex flex-col gap-3">{props.children}</div> 44 + </div> 45 + ); 46 + }; 47 + 48 + export interface StatusBadgeProps { 49 + variant: 'idle' | 'pending' | 'success' | 'error'; 50 + children: JSX.Element; 51 + } 52 + 53 + export const StatusBadge = (props: StatusBadgeProps) => { 54 + const variantStyles = () => { 55 + switch (props.variant) { 56 + case 'idle': 57 + return 'bg-gray-100 text-gray-600'; 58 + case 'pending': 59 + return 'bg-yellow-100 text-yellow-800'; 60 + case 'success': 61 + return 'bg-green-100 text-green-800'; 62 + case 'error': 63 + return 'bg-red-100 text-red-800'; 64 + } 65 + }; 66 + 67 + return ( 68 + <span class={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${variantStyles()}`}> 69 + {props.children} 70 + </span> 71 + ); 72 + };
+3 -1
src/components/inputs/text-input.tsx
··· 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}
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + import { createSignal, Show } from 'solid-js'; 2 + 3 + import { type AtpAccessJwt, ClientResponseError, CredentialManager } from '@atcute/client'; 4 + import { getPdsEndpoint, isAtprotoDid } from '@atcute/identity'; 5 + import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax'; 6 + 7 + import { getDidDocument } from '~/api/queries/did-doc'; 8 + import { resolveHandleViaAppView } from '~/api/queries/handle'; 9 + import { formatTotpCode, TOTP_RE } from '~/api/utils/auth'; 10 + import { decodeJwt } from '~/api/utils/jwt'; 11 + 12 + import { createMutation } from '~/lib/utils/mutation'; 13 + 14 + import { Accordion, StatusBadge, Subsection } from '~/components/accordion'; 15 + import Button from '~/components/inputs/button'; 16 + import TextInput from '~/components/inputs/text-input'; 17 + 18 + import { useMigration } from '../context'; 19 + 20 + interface SourceAccountSectionProps { 21 + onStarted?: () => void; 22 + } 23 + 24 + class InsufficientLoginError extends Error {} 25 + 26 + const SourceAccountSection = (props: SourceAccountSectionProps) => { 27 + const { source, setSource } = useMigration(); 28 + 29 + // Resolve state 30 + const [identifier, setIdentifier] = createSignal(''); 31 + const [resolveError, setResolveError] = createSignal<string>(); 32 + 33 + // Auth state 34 + const [password, setPassword] = createSignal(''); 35 + const [otp, setOtp] = createSignal(''); 36 + const [isTotpRequired, setIsTotpRequired] = createSignal(false); 37 + const [authError, setAuthError] = createSignal<string>(); 38 + 39 + const resolveMutation = createMutation({ 40 + async mutationFn({ identifier }: { identifier: string }) { 41 + let did: AtprotoDid; 42 + if (isAtprotoDid(identifier)) { 43 + did = identifier; 44 + } else if (isHandle(identifier)) { 45 + did = await resolveHandleViaAppView({ handle: identifier }); 46 + } else { 47 + throw new Error(`${identifier} is not a valid DID or handle`); 48 + } 49 + 50 + const didDoc = await getDidDocument({ did }); 51 + const pdsUrl = getPdsEndpoint(didDoc); 52 + 53 + if (!pdsUrl) { 54 + throw new Error(`No PDS endpoint found in DID document`); 55 + } 56 + 57 + return { did, didDoc, pdsUrl }; 58 + }, 59 + onMutate() { 60 + setResolveError(); 61 + }, 62 + onSuccess({ did, didDoc, pdsUrl }) { 63 + setSource({ did, didDoc, pdsUrl, manager: null }); 64 + props.onStarted?.(); 65 + }, 66 + onError(err) { 67 + if (err instanceof ClientResponseError) { 68 + if (err.error === 'InvalidRequest' && err.description?.includes('resolve handle')) { 69 + setResolveError(`Can't resolve handle, is it typed correctly?`); 70 + return; 71 + } 72 + } 73 + console.error(err); 74 + setResolveError(`${err}`); 75 + }, 76 + }); 77 + 78 + const authMutation = createMutation({ 79 + async mutationFn({ pdsUrl, did, password, otp }: { pdsUrl: string; did: string; password: string; otp: string }) { 80 + const manager = new CredentialManager({ service: pdsUrl }); 81 + const session = await manager.login({ 82 + identifier: did, 83 + password: password, 84 + code: formatTotpCode(otp), 85 + }); 86 + 87 + const decoded = decodeJwt(session.accessJwt) as AtpAccessJwt; 88 + if (decoded.scope !== 'com.atproto.access') { 89 + throw new InsufficientLoginError(`You need to sign in with a main password, not an app password`); 90 + } 91 + 92 + return manager; 93 + }, 94 + onMutate() { 95 + setAuthError(); 96 + }, 97 + onSuccess(manager) { 98 + setSource({ ...source()!, manager }); 99 + setPassword(''); 100 + setOtp(''); 101 + setIsTotpRequired(false); 102 + }, 103 + onError(err) { 104 + if (err instanceof ClientResponseError) { 105 + if (err.error === 'AuthFactorTokenRequired') { 106 + setOtp(''); 107 + setIsTotpRequired(true); 108 + return; 109 + } 110 + if (err.error === 'AuthenticationRequired') { 111 + setAuthError(`Invalid identifier or password`); 112 + return; 113 + } 114 + if (err.error === 'AccountTakedown') { 115 + setAuthError(`Account has been taken down`); 116 + return; 117 + } 118 + if (err.description?.includes('Token is invalid')) { 119 + setAuthError(`Invalid one-time confirmation code`); 120 + setIsTotpRequired(true); 121 + return; 122 + } 123 + } 124 + if (err instanceof InsufficientLoginError) { 125 + setAuthError(err.message); 126 + return; 127 + } 128 + console.error(err); 129 + setAuthError(`${err}`); 130 + }, 131 + }); 132 + 133 + const isResolved = () => source() !== null; 134 + const isAuthenticated = () => source()?.manager != null; 135 + 136 + return ( 137 + <Accordion title="Source Account" defaultOpen> 138 + <Subsection title="Resolve identity"> 139 + <Show when={!isResolved()}> 140 + <form 141 + onSubmit={(ev) => { 142 + ev.preventDefault(); 143 + resolveMutation.mutate({ identifier: identifier() }); 144 + }} 145 + class="flex flex-col gap-3" 146 + > 147 + <TextInput 148 + label="Handle or DID" 149 + placeholder="alice.bsky.social" 150 + value={identifier()} 151 + required 152 + autofocus 153 + onChange={setIdentifier} 154 + /> 155 + 156 + <Show when={resolveError()}> 157 + <p class="text-sm text-red-600">{resolveError()}</p> 158 + </Show> 159 + 160 + <div> 161 + <Button type="submit" disabled={resolveMutation.isPending}> 162 + {resolveMutation.isPending ? 'Resolving...' : 'Resolve'} 163 + </Button> 164 + </div> 165 + </form> 166 + </Show> 167 + 168 + <Show when={isResolved()}> 169 + <div class="flex flex-col gap-2 text-sm"> 170 + <p> 171 + <span class="text-gray-500">DID:</span>{' '} 172 + <span class="font-mono">{source()!.did}</span> 173 + </p> 174 + <p> 175 + <span class="text-gray-500">PDS:</span>{' '} 176 + <span class="font-mono">{source()!.pdsUrl}</span> 177 + </p> 178 + <div class="mt-1"> 179 + <button 180 + type="button" 181 + onClick={() => setSource(null)} 182 + class="text-sm text-purple-800 hover:underline" 183 + > 184 + Change account 185 + </button> 186 + </div> 187 + </div> 188 + </Show> 189 + </Subsection> 190 + 191 + <Show when={isResolved()}> 192 + <Subsection title="Authenticate"> 193 + <p class="text-sm text-gray-600"> 194 + Authentication is required for some operations like exporting preferences or signing PLC operations. 195 + </p> 196 + 197 + <Show when={!isAuthenticated()}> 198 + <form 199 + onSubmit={(ev) => { 200 + ev.preventDefault(); 201 + const src = source()!; 202 + authMutation.mutate({ 203 + pdsUrl: src.pdsUrl, 204 + did: src.did, 205 + password: password(), 206 + otp: otp(), 207 + }); 208 + }} 209 + class="flex flex-col gap-3" 210 + > 211 + <TextInput 212 + label="Main password" 213 + blurb="Your credentials stay entirely within your browser." 214 + type="password" 215 + value={password()} 216 + required 217 + onChange={setPassword} 218 + /> 219 + 220 + <Show when={isTotpRequired()}> 221 + <TextInput 222 + label="One-time confirmation code" 223 + blurb="A code has been sent to your email address." 224 + type="text" 225 + autocomplete="one-time-code" 226 + pattern={TOTP_RE.source} 227 + placeholder="AAAAA-BBBBB" 228 + value={otp()} 229 + required 230 + onChange={setOtp} 231 + monospace 232 + /> 233 + </Show> 234 + 235 + <Show when={authError()}> 236 + <p class="text-sm text-red-600">{authError()}</p> 237 + </Show> 238 + 239 + <div> 240 + <Button type="submit" disabled={authMutation.isPending}> 241 + {authMutation.isPending ? 'Signing in...' : 'Sign in'} 242 + </Button> 243 + </div> 244 + </form> 245 + </Show> 246 + 247 + <Show when={isAuthenticated()}> 248 + <div class="flex items-center gap-2"> 249 + <StatusBadge variant="success">Signed in</StatusBadge> 250 + <button 251 + type="button" 252 + onClick={() => setSource({ ...source()!, manager: null })} 253 + class="text-sm text-purple-800 hover:underline" 254 + > 255 + Sign out 256 + </button> 257 + </div> 258 + </Show> 259 + </Subsection> 260 + </Show> 261 + </Accordion> 262 + ); 263 + }; 264 + 265 + export default SourceAccountSection;
+1 -1
src/views/crypto/crypto-generate.tsx
··· 51 51 ]); 52 52 53 53 const result: KeypairResult = { 54 - type: keypair.type, 54 + type: keypair.type as KeyType, 55 55 publicDidKey, 56 56 privateHex, 57 57 privateMultikey,
+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;
+2 -2
src/views/frontpage.tsx
··· 104 104 { 105 105 name: `Migrate account`, 106 106 description: `Move your account data to another server`, 107 - href: null, 107 + href: '/account-migrate', 108 108 icon: MoveUpOutlinedIcon, 109 109 }, 110 110 ], ··· 121 121 { 122 122 name: `View crypto key info`, 123 123 description: `Show basic metadata about a public or private key`, 124 - href: null, 124 + href: `/crypto-info`, 125 125 icon: KeyVisualizerIcon, 126 126 }, 127 127 ],
+1 -19
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'; ··· 219 220 }; 220 221 221 222 export default RepoExportPage; 222 - 223 - export async function* iterateStream<T>(stream: ReadableStream<T>) { 224 - // Get a lock on the stream 225 - const reader = stream.getReader(); 226 - 227 - try { 228 - while (true) { 229 - const { done, value } = await reader.read(); 230 - 231 - if (done) { 232 - return; 233 - } 234 - 235 - yield value; 236 - } 237 - } finally { 238 - reader.releaseLock(); 239 - } 240 - }