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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
133 lines · 4.2 KB Raw History
import { useQuery } from '@tanstack/react-query';
import { KeyRoundIcon, LockIcon } from 'lucide-react';
import { Button } from '#/components/atoms/button.jsx';
import { Panel } from '#/components/atoms/panel.jsx';
import { RowIcon } from '#/components/atoms/row-icon.jsx';
import { ErrorNotice } from '#/components/molecules/error-notice.jsx';
import { ListRow } from '#/components/molecules/list-row.jsx';
import { Section } from '#/components/molecules/section.jsx';
import { get, post } from '#/lib/api.js';
import { b64, unb64 } from '#/lib/base64.js';
import { day } from '#/lib/format.js';
import { queryKeys, useApiMutation } from '#/lib/query.js';

export function SignInMethods({ did }) {
  const { data, error } = useQuery({
    queryKey: queryKeys.passkeys,
    queryFn: () => get('/passkeys'),
  });
  const action = useApiMutation([queryKeys.passkeys]);

  const add = (name) =>
    action.run(async () => {
      if (!window.PublicKeyCredential) {
        throw new Error('This browser has no passkey support.');
      }
      const credential = await navigator.credentials.create({
        publicKey: {
          challenge: unb64(data.challenge),
          rp: { id: data.rpId, name: window.location.host },
          user: {
            id: new TextEncoder().encode(did),
            name: data.accountName,
            displayName: data.accountName,
          },
          pubKeyCredParams: [
            { type: 'public-key', alg: -7 },
            { type: 'public-key', alg: -257 },
          ],
          authenticatorSelection: {
            residentKey: 'preferred',
            userVerification: 'preferred',
          },
          excludeCredentials: data.passkeys.map((passkey) => ({
            type: 'public-key',
            id: unb64(passkey.credentialId),
          })),
          timeout: 120000,
        },
      });
      const response = await fetch('/account/passkeys/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name,
          credentialId: b64(credential.rawId),
          publicKey: b64(credential.response.getPublicKey()),
          algorithm: credential.response.getPublicKeyAlgorithm(),
          clientDataJSON: b64(credential.response.clientDataJSON),
        }),
      });
      const body = await response.json();
      if (!response.ok) throw new Error(body.error);
    });

  const remove = (credentialId) =>
    action.run(async () => {
      await post('/passkeys/revoke', { credentialId });
    });

  return (
    <>
      <Panel>
        <Section title="Ways to sign in">
          <ErrorNotice
            error={error?.message || action.error}
            className="mb-4"
          />
          <div className="flex flex-col gap-1">
            {data?.passkeys?.map((passkey) => (
              <ListRow
                key={passkey.credentialId}
                variant="plain"
                media={
                  <RowIcon>
                    <KeyRoundIcon />
                  </RowIcon>
                }
                title={passkey.name}
                description={`Passkey · ${
                  passkey.lastUsedAt
                    ? `used ${day(passkey.lastUsedAt)}`
                    : `added ${day(passkey.createdAt)}`
                }`}
                actions={
                  <Button
                    variant="outline"
                    size="sm"
                    disabled={action.busy}
                    onClick={() => remove(passkey.credentialId)}
                  >
                    Remove
                  </Button>
                }
              />
            ))}
            <ListRow
              variant="plain"
              media={
                <RowIcon>
                  <LockIcon />
                </RowIcon>
              }
              title="Account password"
              description="Backup way to sign in"
            />
          </div>
        </Section>
      </Panel>

      <Button
        variant="dashed"
        size="surface"
        disabled={action.busy || !data}
        onClick={() => add('This device')}
        className="px-6 py-5 font-extrabold text-[14.5px]"
      >
        {action.busy
          ? 'Waiting for your device…'
          : '+ Add a passkey on this device'}
      </Button>
    </>
  );
}