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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
183 lines · 6.4 KB Raw History
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { TextField } from '#/components/molecules/text-field.jsx';
import { documentHandle, resolveDid } from '#/lib/identity.js';
import { beginSignIn, clientId } from '#/lib/oauth.js';
import { SCOPES } from '#/lib/scopes.js';

/**
 * The account whose site is serving this page, or null where the page is served
 * from anywhere else. The field is not filled from it: a reader signs in as
 * whoever they are, which on a shared deployment is rarely the host. It is kept
 * for the fallback below, where a handle that resolves nowhere is the host's
 * own and its DID does resolve.
 */
function useHostAccount() {
  return useQuery({
    queryKey: ['host-account'],
    queryFn: async () => {
      const response = await fetch('/.well-known/atproto-did');
      if (!response.ok) return null;
      const did = (await response.text()).trim();
      if (!did.startsWith('did:')) return null;
      return { did, handle: documentHandle(await resolveDid(did)) };
    },
    retry: false,
    staleTime: Number.POSITIVE_INFINITY,
  });
}

/**
 * Whether this copy of the app is registered for the origin it is served from.
 *
 * An OAuth client is named by the URL its registration is served from, so a
 * copy deployed for one origin cannot sign in from another. The check is
 * skipped on loopback, where the client_id carries its own registration.
 */
function useRegistration() {
  const expected = clientId(SCOPES.read);
  return useQuery({
    queryKey: ['registration', expected],
    queryFn: async () => {
      if (!expected.startsWith('https://')) return { ok: true };
      const response = await fetch('/client-metadata.json');
      if (!response.ok) return { ok: false, found: null };
      const found = (await response.json()).client_id;
      return { ok: found === expected, found };
    },
    retry: false,
    staleTime: Number.POSITIVE_INFINITY,
  });
}

export function SignInPage() {
  const { data: host } = useHostAccount();
  const { data: registration } = useRegistration();
  const [identifier, setIdentifier] = useState('');
  const [access, setAccess] = useState('read');
  const [error, setError] = useState(null);
  const [busy, setBusy] = useState(false);

  async function submit(event) {
    event.preventDefault();
    setError(null);
    setBusy(true);
    try {
      await beginSignIn(identifier, { scope: SCOPES[access] });
    } catch (err) {
      // The origin publishes its own account's DID, which resolves wherever the
      // handle beside it does not. A handle on a domain with no public DNS is
      // the case: a local stack, or an account mid-handle-change.
      if (host && identifier.trim().toLowerCase() === host.handle) {
        try {
          await beginSignIn(host.did, { scope: SCOPES[access] });
          return;
        } catch {
          // The DID failed too, so the first message is the useful one.
        }
      }
      setError(err.message);
      setBusy(false);
    }
  }

  return (
    <div className="mx-auto max-w-md pt-16">
      <h1 className="font-semibold text-[22px] text-foreground tracking-tight">
        Spaces
      </h1>
      <p className="mt-2 text-muted-foreground text-sm leading-relaxed">
        Sign in to browse the permissioned spaces your account holds: the
        records in each one, who writes to them, and what the authority says
        about them.
      </p>

      <form onSubmit={submit} className="mt-8 flex flex-col gap-4">
        <TextField
          name="identifier"
          label="Handle or DID"
          placeholder="alice.example.com"
          autoCapitalize="none"
          autoCorrect="off"
          spellCheck="false"
          autoComplete="username"
          required
          value={identifier}
          onChange={(event) => setIdentifier(event.target.value)}
        />

        <fieldset className="flex flex-col gap-2">
          <legend className="text-[13px] text-muted-foreground">Access</legend>
          {[
            {
              key: 'read',
              title: 'Read only',
              detail: 'List and open records. Nothing on the server changes.',
            },
            {
              key: 'write',
              title: 'Read and write',
              detail:
                'Also create, edit and delete records, and manage spaces and their members.',
            },
          ].map((option) => (
            <label
              key={option.key}
              className={`flex cursor-pointer gap-3 rounded-xl border p-3 ${
                access === option.key
                  ? 'border-key/40 bg-key/5'
                  : 'border-border hover:border-input'
              }`}
            >
              <input
                type="radio"
                name="access"
                value={option.key}
                checked={access === option.key}
                onChange={() => setAccess(option.key)}
                className="mt-1 accent-[var(--key)]"
              />
              <span className="min-w-0">
                <span className="block font-medium text-[13px] text-foreground">
                  {option.title}
                </span>
                <span className="block text-muted-foreground text-xs leading-relaxed">
                  {option.detail}
                </span>
              </span>
            </label>
          ))}
        </fieldset>

        {registration && !registration.ok && (
          <Notice variant="warning">
            This copy is registered for{' '}
            {registration.found
              ? new URL(registration.found).origin
              : 'nowhere'}
            , not for {window.location.origin}. Deploy it again with
            SPACES_ORIGIN set to this origin before signing in.
          </Notice>
        )}

        {error && <Notice variant="error">{error}</Notice>}

        <Button
          type="submit"
          size="block"
          disabled={busy || !identifier.trim()}
        >
          {busy ? 'Redirecting…' : 'Continue'}
        </Button>
      </form>

      <p className="mt-6 text-faint text-xs leading-relaxed">
        Your PDS asks you to approve this app before it sees anything. The
        access token stays in this browser and is bound to a key this page
        cannot read, so a copy of it is no use anywhere else.
      </p>
    </div>
  );
}