import { ArrowLeftIcon, CheckIcon, CircleIcon, CopyIcon, DownloadIcon, KeyRoundIcon, Loader2Icon, TriangleAlertIcon, UserPlusIcon, XIcon, } from 'lucide-react'; import { useRef, useState } from 'react'; import { Button } from '#/components/atoms/button.jsx'; import { Heading } from '#/components/atoms/heading.jsx'; import { Notice } from '#/components/atoms/notice.jsx'; import { Panel } from '#/components/atoms/panel.jsx'; import { ErrorNotice } from '#/components/molecules/error-notice.jsx'; import { TextField } from '#/components/molecules/text-field.jsx'; import { isRateLimitError } from '#/lib/atproto.js'; import { MIGRATION_STEPS, runMigration } from '#/lib/migrate.js'; import { generateRecoveryKey } from '#/lib/rotationKey.js'; /** * First-run wizard, served by the server on an empty PDS. Two ways in: mint a * brand-new account, or migrate an existing one here from another PDS. */ export function OnboardPage({ hostname }) { const [choice, setChoice] = useState( /** @type {null|'create'|'migrate'} */ (null), ); return (
{choice === null && ( )} {choice === 'create' && ( setChoice(null)} /> )} {choice === 'migrate' && ( setChoice(null)} /> )}
); } function Chooser({ hostname, onChoose }) { return ( <> Set up this server

{hostname} is a fresh Personal Data Server with no account yet. Claim it below.

); } function BackButton({ onBack, disabled }) { return ( ); } function CreateAccount({ hostname, onBack }) { const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const submit = async (event) => { event.preventDefault(); setError(''); setBusy(true); const form = new FormData(event.currentTarget); try { const res = await fetch('/account/onboarding/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ handle: (form.get('handle') || '').toString().trim(), email: (form.get('email') || '').toString().trim(), password: (form.get('password') || '').toString(), }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.message || data.error || 'Failed.'); window.location.href = '/account'; } catch (caught) { setError(String(caught?.message || caught)); setBusy(false); } }; return ( <> Create a new account
); } function MigrateAccount({ hostname, onBack }) { const [phase, setPhase] = useState( /** @type {'form'|'running'|'error'|'done'} */ ('form'), ); const [error, setError] = useState(''); const [rateLimited, setRateLimited] = useState(false); const [steps, setSteps] = useState(() => Object.fromEntries(MIGRATION_STEPS.map((s) => [s.key, { status: 'idle' }])), ); // A pending code prompt: {kind:'2fa'|'plc', resolve}. const [prompt, setPrompt] = useState(null); const [result, setResult] = useState(/** @type {any} */ (null)); // The freshly minted recovery key ({didKey, privateKeyHex}) and whether the // user has confirmed they saved it — gates the irreversible identity move. const [recovery, setRecovery] = useState(/** @type {any} */ (null)); const [recoverySaved, setRecoverySaved] = useState(false); const activeStep = useRef(''); const lastInput = useRef(/** @type {any} */ (null)); const onStep = (key, patch) => { if (patch.status === 'active') activeStep.current = key; setSteps((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } })); }; const requestTwoFactor = () => new Promise((resolve) => setPrompt({ kind: '2fa', resolve })); const requestPlcCode = () => new Promise((resolve) => setPrompt({ kind: 'plc', resolve })); const run = async (input) => { setError(''); setRateLimited(false); setPhase('running'); setSteps( Object.fromEntries( MIGRATION_STEPS.map((s) => [s.key, { status: 'idle' }]), ), ); try { const outcome = await runMigration(input, { onStep, requestTwoFactor, requestPlcCode, }); setResult(outcome); setPhase('done'); } catch (caught) { onStep(activeStep.current, { status: 'error' }); setPrompt(null); setRateLimited(isRateLimitError(caught)); setError(String(caught?.message || caught)); setPhase('error'); } }; const submitForm = (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); // Mint the recovery key up front (default on) so its public half rides along // into the identity move and the user can be shown the private half to save. let minted = null; if (form.get('makeRecoveryKey')) { minted = generateRecoveryKey(); } setRecovery(minted); setRecoverySaved(false); const input = { identifier: (form.get('identifier') || '').toString().trim(), password: (form.get('password') || '').toString(), newHandle: (form.get('newHandle') || hostname).toString().trim(), email: (form.get('email') || '').toString().trim(), serverPassword: (form.get('serverPassword') || '').toString(), recoveryKey: minted?.didKey || null, }; lastInput.current = input; run(input); }; const submitCode = (event) => { event.preventDefault(); const value = new FormData(event.currentTarget) .get('code') .toString() .trim(); const pending = prompt; setPrompt(null); pending?.resolve(value); }; if (phase === 'done') { return ( <> You're all set Your account now lives on {hostname}. Your identity points here and your old server has been retired. {result?.skippedBlobs?.length > 0 && ( {result.skippedBlobs.length}{' '} {result.skippedBlobs.length === 1 ? 'attachment' : 'attachments'}{' '} could not be read from your old server and were left behind — the posts came over, but those images or videos will be missing. )} {recovery && ( )} ); } if (phase === 'running' || phase === 'error') { return ( <> {phase === 'error' ? 'Migration paused' : 'Migrating…'} {phase === 'error' && ( {error} {rateLimited && ( The other server is rate-limiting the transfer. This usually resets after 24 hours — retry then; already-copied data is kept. )} )} {recovery && ( )} {prompt && ( )} {phase === 'error' && ( <> )} ); } // phase === 'form' return ( <> Migrate an existing account

Move your account here from another PDS. You'll need your{' '} current login — your real password, not an app password.


); } function StepList({ steps }) { return (
    {MIGRATION_STEPS.map(({ key, label }) => { const { status, detail } = steps[key]; return (
  • {label} {detail && ( {detail} )}
  • ); })}
); } function StepIcon({ status }) { if (status === 'done') return ; if (status === 'warn') return ; if (status === 'error') return ; if (status === 'active') return ; return ; } function CodePrompt({ kind, onSubmit, blocked }) { return (
{kind === '2fa' ? 'Your old server emailed a sign-in code. Enter it to continue.' : 'Your old server emailed a confirmation code to move your identity. Enter it to finish.'} {blocked && (

Save your recovery key and check the box above before finishing.

)} ); } /** * Shows a freshly minted recovery key's private half for the user to save. In * `reminder` mode (post-migration) it's informational; otherwise it carries the * "I've saved this" checkbox that unblocks the identity move. */ function RecoveryKeyCard({ privateKeyHex, saved, onSavedChange, reminder }) { const [copied, setCopied] = useState(false); const copy = async () => { try { await navigator.clipboard.writeText(privateKeyHex); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { // Clipboard can be unavailable (permissions/insecure context); the key is // still selectable in the box. } }; return (
Your recovery key

Save this private key somewhere safe (a password manager). It's the only way to recover your identity if this server is ever lost, and it{' '} won't be shown again.

{privateKeyHex}
{!reminder && ( )}
); }