~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
550 lines · 18.1 KB Raw History
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 (
    <div className="mx-auto flex min-h-svh max-w-md flex-col justify-center px-4 py-10">
      <Panel>
        {choice === null && (
          <Chooser hostname={hostname} onChoose={setChoice} />
        )}
        {choice === 'create' && (
          <CreateAccount hostname={hostname} onBack={() => setChoice(null)} />
        )}
        {choice === 'migrate' && (
          <MigrateAccount hostname={hostname} onBack={() => setChoice(null)} />
        )}
      </Panel>
    </div>
  );
}

function Chooser({ hostname, onChoose }) {
  return (
    <>
      <Heading>Set up this server</Heading>
      <p className="mt-2 text-[13px] text-muted-foreground leading-relaxed">
        <span className="text-foreground">{hostname}</span> is a fresh Personal
        Data Server with no account yet. Claim it below.
      </p>
      <div className="mt-5 flex flex-col gap-3">
        <button
          type="button"
          onClick={() => onChoose('create')}
          className="flex items-start gap-3 rounded-2xl border border-border bg-muted px-4 py-3.5 text-left transition-colors hover:border-input hover:bg-accent"
        >
          <UserPlusIcon className="mt-0.5 size-5 shrink-0 text-key" />
          <span>
            <span className="block font-semibold text-sm">
              Create a new account
            </span>
            <span className="block text-[12.5px] text-muted-foreground leading-relaxed">
              Mint a brand-new identity hosted here.
            </span>
          </span>
        </button>
        <button
          type="button"
          onClick={() => onChoose('migrate')}
          className="flex items-start gap-3 rounded-2xl border border-border bg-muted px-4 py-3.5 text-left transition-colors hover:border-input hover:bg-accent"
        >
          <DownloadIcon className="mt-0.5 size-5 shrink-0 text-key" />
          <span>
            <span className="block font-semibold text-sm">
              Migrate an existing account
            </span>
            <span className="block text-[12.5px] text-muted-foreground leading-relaxed">
              Move your account here from another PDS, keeping your identity.
            </span>
          </span>
        </button>
      </div>
    </>
  );
}

function BackButton({ onBack, disabled }) {
  return (
    <button
      type="button"
      onClick={onBack}
      disabled={disabled}
      className="mb-3 inline-flex items-center gap-1 text-[13px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
    >
      <ArrowLeftIcon className="size-3.5" /> Back
    </button>
  );
}

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 (
    <>
      <BackButton onBack={onBack} disabled={busy} />
      <Heading>Create a new account</Heading>
      <ErrorNotice error={error} className="mt-4" />
      <form onSubmit={submit} className="mt-4 flex flex-col gap-4">
        <TextField
          name="handle"
          label="Handle"
          defaultValue={hostname}
          hint="Defaults to this server's address. Use a domain you own to have your own name."
          autoCapitalize="none"
          autoCorrect="off"
          spellCheck="false"
        />
        <TextField
          name="email"
          label="Email (optional)"
          type="email"
          autoComplete="email"
        />
        <TextField
          name="password"
          label="Server password"
          type="password"
          hint="The PDS_PASSWORD set when this server was deployed."
          autoComplete="off"
          required
        />
        <Button type="submit" size="block" disabled={busy}>
          {busy ? 'Creating…' : 'Create account'}
        </Button>
      </form>
    </>
  );
}

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 (
      <>
        <Heading>You're all set</Heading>
        <Notice variant="success" className="mt-4">
          Your account now lives on {hostname}. Your identity points here and
          your old server has been retired.
        </Notice>
        {result?.skippedBlobs?.length > 0 && (
          <Notice variant="warning" className="mt-3">
            {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.
          </Notice>
        )}
        {recovery && (
          <RecoveryKeyCard privateKeyHex={recovery.privateKeyHex} reminder />
        )}
        <Button
          size="block"
          className="mt-4"
          onClick={() => {
            window.location.href = '/account';
          }}
        >
          Go to your account
        </Button>
      </>
    );
  }

  if (phase === 'running' || phase === 'error') {
    return (
      <>
        <Heading>
          {phase === 'error' ? 'Migration paused' : 'Migrating…'}
        </Heading>
        {phase === 'error' && (
          <Notice variant="error" className="mt-4">
            {error}
            {rateLimited && (
              <span className="mt-1 block text-[12.5px]">
                The other server is rate-limiting the transfer. This usually
                resets after 24 hours — retry then; already-copied data is kept.
              </span>
            )}
          </Notice>
        )}
        <StepList steps={steps} />
        {recovery && (
          <RecoveryKeyCard
            privateKeyHex={recovery.privateKeyHex}
            saved={recoverySaved}
            onSavedChange={setRecoverySaved}
          />
        )}
        {prompt && (
          <CodePrompt
            kind={prompt.kind}
            onSubmit={submitCode}
            blocked={prompt.kind === 'plc' && !!recovery && !recoverySaved}
          />
        )}
        {phase === 'error' && (
          <>
            <Button
              size="block"
              className="mt-4"
              onClick={() => run(lastInput.current)}
            >
              Try again
            </Button>
            <button
              type="button"
              onClick={() => setPhase('form')}
              className="mt-2 w-full text-center text-[13px] text-muted-foreground transition-colors hover:text-foreground"
            >
              Edit details
            </button>
          </>
        )}
      </>
    );
  }

  // phase === 'form'
  return (
    <>
      <BackButton onBack={onBack} />
      <Heading>Migrate an existing account</Heading>
      <p className="mt-2 text-[13px] text-muted-foreground leading-relaxed">
        Move your account here from another PDS. You'll need your{' '}
        <span className="text-foreground">current</span> login — your real
        password, not an app password.
      </p>
      <form onSubmit={submitForm} className="mt-4 flex flex-col gap-4">
        <TextField
          name="identifier"
          label="Current handle or DID"
          placeholder="you.bsky.social"
          autoCapitalize="none"
          autoCorrect="off"
          spellCheck="false"
          required
        />
        <TextField
          name="password"
          label="Current password"
          type="password"
          autoComplete="off"
          required
        />
        <hr className="border-border" />
        <TextField
          name="newHandle"
          label="Handle here"
          defaultValue={hostname}
          hint="What you'll be known as on this server."
          autoCapitalize="none"
          autoCorrect="off"
          spellCheck="false"
        />
        <TextField name="email" label="Email (optional)" type="email" />
        <TextField
          name="serverPassword"
          label="This server's password"
          type="password"
          hint="The PDS_PASSWORD set when this server was deployed."
          autoComplete="off"
          required
        />
        <label className="flex items-start gap-2.5 rounded-xl border border-border bg-muted px-3.5 py-3 text-left">
          <input
            type="checkbox"
            name="makeRecoveryKey"
            defaultChecked
            className="mt-0.5 size-4 shrink-0 accent-key"
          />
          <span>
            <span className="block font-medium text-[13px]">
              Create a recovery key
            </span>
            <span className="block text-[12px] text-muted-foreground leading-relaxed">
              A key you alone hold, so you can recover your identity if this
              server is ever lost. You'll save it before the move finishes.
              Recommended.
            </span>
          </span>
        </label>
        <Button type="submit" size="block">
          Start migration
        </Button>
      </form>
    </>
  );
}

function StepList({ steps }) {
  return (
    <ul className="mt-4 flex flex-col gap-2.5">
      {MIGRATION_STEPS.map(({ key, label }) => {
        const { status, detail } = steps[key];
        return (
          <li key={key} className="flex items-center gap-2.5 text-sm">
            <StepIcon status={status} />
            <span
              className={
                status === 'idle'
                  ? 'text-faint'
                  : status === 'error'
                    ? 'text-destructive'
                    : status === 'warn'
                      ? 'text-warning'
                      : 'text-foreground'
              }
            >
              {label}
            </span>
            {detail && (
              <span className="ml-auto text-[12px] text-muted-foreground">
                {detail}
              </span>
            )}
          </li>
        );
      })}
    </ul>
  );
}

function StepIcon({ status }) {
  if (status === 'done')
    return <CheckIcon className="size-4 shrink-0 text-success" />;
  if (status === 'warn')
    return <TriangleAlertIcon className="size-4 shrink-0 text-warning" />;
  if (status === 'error')
    return <XIcon className="size-4 shrink-0 text-destructive" />;
  if (status === 'active')
    return <Loader2Icon className="size-4 shrink-0 animate-spin text-key" />;
  return <CircleIcon className="size-4 shrink-0 text-faint" />;
}

function CodePrompt({ kind, onSubmit, blocked }) {
  return (
    <form onSubmit={onSubmit} className="mt-4 flex flex-col gap-3">
      <Notice variant="info">
        {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.'}
      </Notice>
      <TextField
        name="code"
        label={kind === '2fa' ? 'Sign-in code' : 'Confirmation code'}
        autoComplete="one-time-code"
        autoCapitalize="none"
        spellCheck="false"
        required
        autoFocus
      />
      {blocked && (
        <p className="text-[12.5px] text-warning">
          Save your recovery key and check the box above before finishing.
        </p>
      )}
      <Button type="submit" size="block" disabled={blocked}>
        Continue
      </Button>
    </form>
  );
}

/**
 * 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 (
    <div className="mt-4 rounded-xl border border-warning/40 bg-warning-dim p-3.5">
      <div className="flex items-center gap-2 text-warning">
        <KeyRoundIcon className="size-4 shrink-0" />
        <span className="font-semibold text-[13px]">Your recovery key</span>
      </div>
      <p className="mt-1.5 text-[12.5px] text-muted-foreground leading-relaxed">
        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{' '}
        <span className="text-foreground">won't be shown again</span>.
      </p>
      <div className="mt-2.5 flex items-stretch gap-2">
        <code className="min-w-0 flex-1 select-all break-all rounded-lg bg-card px-2.5 py-2 font-mono text-[11px] text-foreground">
          {privateKeyHex}
        </code>
        <button
          type="button"
          onClick={copy}
          className="flex shrink-0 items-center gap-1 rounded-lg border border-border px-2.5 text-[12px] text-muted-foreground transition-colors hover:text-foreground"
        >
          {copied ? (
            <CheckIcon className="size-3.5" />
          ) : (
            <CopyIcon className="size-3.5" />
          )}
          {copied ? 'Copied' : 'Copy'}
        </button>
      </div>
      {!reminder && (
        <label className="mt-2.5 flex items-center gap-2 text-[13px] text-foreground">
          <input
            type="checkbox"
            checked={saved}
            onChange={(e) => onSavedChange(e.target.checked)}
            className="size-4 shrink-0 accent-key"
          />
          I've saved my recovery key
        </label>
      )}
    </div>
  );
}