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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
285 lines · 9.1 KB Raw History
import { LoaderCircleIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { AppTile } from '#/components/atoms/app-tile.jsx';
import { Avatar } from '#/components/atoms/avatar.jsx';
import { Button } from '#/components/atoms/button.jsx';
import { Panel } from '#/components/atoms/panel.jsx';
import { ErrorNotice } from '#/components/molecules/error-notice.jsx';
import { ScopeSummary } from '#/components/molecules/scope-summary.jsx';
import { TextField } from '#/components/molecules/text-field.jsx';
import { b64, unb64 } from '#/lib/base64.js';
import { clientHost } from '#/lib/format.js';

export function ConsentPage({
  clientName,
  clientId,
  requestUri,
  loginHint = '',
  error = '',
  permissions = {},
  passkey,
}) {
  const [err, setErr] = useState(error);
  const [busy, setBusy] = useState(false);
  const [profile, setProfile] = useState(null);
  const [redirect, setRedirect] = useState(null);
  const approveButton = useRef(null);

  useEffect(() => {
    if (!loginHint) return;
    let live = true;
    fetch(
      `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(loginHint)}`,
    )
      .then((response) => (response.ok ? response.json() : null))
      .then((data) => {
        if (live && data) setProfile(data);
      })
      .catch(() => {});
    return () => {
      live = false;
    };
  }, [loginHint]);

  const identityOnly =
    !permissions.hasTransitionGeneric &&
    !permissions.hasEmail &&
    !permissions.repo?.length &&
    !permissions.rpc?.length &&
    !permissions.includes?.length &&
    !permissions.blobs?.length &&
    !permissions.spaces?.length;

  const handle = loginHint.startsWith('did:') ? loginHint : `@${loginHint}`;
  const initial = loginHint.startsWith('did:')
    ? '?'
    : (loginHint[0] || '?').toUpperCase();

  // The client's page can take seconds to load. Until it does, the redirect
  // screen shows the user that the request already has its answer.
  const returnToClient = (url, action) => {
    setRedirect({ url, action });
    window.location.href = url;
  };

  const submit = async (event) => {
    event.preventDefault();
    const form = event.currentTarget;
    const action =
      event.nativeEvent.submitter?.value === 'deny' ? 'deny' : 'approve';
    const body = new URLSearchParams(new FormData(form));
    body.set('action', action);
    setErr('');
    setBusy(true);
    try {
      const response = await fetch('/oauth/authorize', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Accept: 'application/json',
        },
        body,
      });
      const result = await response.json().catch(() => ({}));
      if (!response.ok || !result.redirect)
        throw new Error(result.error || 'That authorization failed.');
      returnToClient(result.redirect, action);
    } catch (caught) {
      setErr(String(caught?.message || caught));
      setBusy(false);
    }
  };

  const passkeyApprove = async () => {
    setErr('');
    setBusy(true);
    try {
      if (!window.PublicKeyCredential) {
        throw new Error('This browser has no passkey support.');
      }
      const assertion = await navigator.credentials.get({
        publicKey: {
          challenge: unb64(passkey.challenge),
          rpId: passkey.rpId,
          userVerification: 'preferred',
          timeout: 120000,
        },
      });
      const response = await fetch('/oauth/authorize/passkey', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          request_uri: requestUri,
          client_id: clientId,
          credentialId: b64(assertion.rawId),
          authenticatorData: b64(assertion.response.authenticatorData),
          clientDataJSON: b64(assertion.response.clientDataJSON),
          signature: b64(assertion.response.signature),
        }),
      });
      const result = await response.json();
      if (!response.ok)
        throw new Error(result.error || 'That passkey was refused.');
      returnToClient(result.redirect, 'approve');
    } catch (caught) {
      setErr(
        caught?.name === 'NotAllowedError'
          ? 'Cancelled.'
          : String(caught?.message || caught),
      );
      setBusy(false);
    }
  };

  if (redirect) {
    return (
      <RedirectScreen
        clientName={clientName}
        clientId={clientId}
        url={redirect.url}
        approved={redirect.action === 'approve'}
      />
    );
  }

  return (
    <div className="mx-auto flex min-h-svh max-w-md flex-col justify-center px-4 py-10">
      <Panel>
        {loginHint && (
          <div className="mb-5 flex items-center gap-3 rounded-2xl border border-border bg-muted p-3.5">
            <Avatar
              className="size-11"
              src={profile?.avatar}
              fallback={<span className="text-faint text-sm">{initial}</span>}
            />
            <div className="min-w-0">
              <div className="truncate font-extrabold text-[15px] text-foreground">
                {profile?.displayName || profile?.handle || 'Loading…'}
              </div>
              <div className="truncate text-[13px] text-faint">
                {profile?.handle ? `@${profile.handle}` : handle}
              </div>
            </div>
          </div>
        )}

        <div className="flex items-center gap-3">
          <AppTile domain={clientHost(clientId)} label={clientName} size={44} />
          <div className="min-w-0">
            <div className="truncate font-extrabold text-[18px] text-foreground tracking-tight">
              {clientName}
            </div>
            <div className="truncate text-[12.5px] text-faint">
              {clientHost(clientId)}
            </div>
          </div>
        </div>
        <p className="mt-3 text-[13px] text-muted-foreground leading-relaxed">
          {identityOnly
            ? 'This app wants to uniquely identify you through your account.'
            : 'This app wants to access your account.'}
        </p>

        <ScopeSummary perms={permissions} />

        <ErrorNotice error={err} className="mt-4" />

        <form
          method="POST"
          action="/oauth/authorize"
          onSubmit={submit}
          className="mt-4"
        >
          <input type="hidden" name="request_uri" value={requestUri} />
          <input type="hidden" name="client_id" value={clientId} />
          <TextField
            name="password"
            label="Password"
            type="password"
            required
            // A form submits through its first submit button, which is Deny.
            // Enter in this field names Authorize instead.
            onKeyDown={(event) => {
              if (event.key !== 'Enter') return;
              event.preventDefault();
              event.currentTarget.form?.requestSubmit(approveButton.current);
            }}
            // biome-ignore lint/a11y/noAutofocus: the only field on a dedicated authorize screen
            autoFocus
          />
          <div className="mt-4 grid grid-cols-2 gap-3">
            <Button
              type="submit"
              name="action"
              value="deny"
              variant="outline"
              size="block"
              disabled={busy}
              formNoValidate
            >
              Deny
            </Button>
            <Button
              ref={approveButton}
              type="submit"
              name="action"
              value="approve"
              size="block"
              disabled={busy}
            >
              Authorize
            </Button>
          </div>
        </form>

        {passkey && (
          <Button
            variant="outline"
            size="block"
            className="mt-3"
            disabled={busy}
            onClick={passkeyApprove}
          >
            Authorize with a passkey
          </Button>
        )}
      </Panel>
    </div>
  );
}

/**
 * The screen between an answered request and the client's own page. The
 * browser is already navigating to `url`. The link is for a navigation that
 * does not arrive.
 */
function RedirectScreen({ clientName, clientId, url, approved }) {
  return (
    <div className="mx-auto flex min-h-svh max-w-md flex-col justify-center px-4 py-10">
      <Panel className="text-center">
        <div
          className="flex flex-col items-center gap-4"
          role="status"
          aria-live="polite"
        >
          <AppTile domain={clientHost(clientId)} label={clientName} size={56} />
          <div>
            <div className="font-extrabold text-[18px] text-foreground tracking-tight">
              {approved ? 'Authorized' : 'Not authorized'}
            </div>
            <p className="mt-1.5 text-[13px] text-muted-foreground leading-relaxed">
              Taking you back to {clientName}.
            </p>
          </div>
          <LoaderCircleIcon className="size-5 animate-spin text-faint" />
        </div>
        <p className="mt-6 text-[12.5px] text-faint">
          Still here?{' '}
          <a href={url} className="font-bold text-key hover:text-foreground">
            Continue to {clientHost(clientId)}
          </a>
        </p>
      </Panel>
    </div>
  );
}