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

feat: migration tool first pass

mary.my.id c7e2b3c9 d35afbe1

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