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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
215 lines · 7.1 KB Raw History
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { Panel } from '#/components/atoms/panel.jsx';
import { ErrorNotice } from '#/components/molecules/error-notice.jsx';
import { FormShell } from '#/components/molecules/form-shell.jsx';
import { Section } from '#/components/molecules/section.jsx';
import { TextField } from '#/components/molecules/text-field.jsx';
import { TextLink } from '#/components/molecules/text-link.jsx';
import { get, post } from '#/lib/api.js';
import { queryKeys, useApiMutation, useInvalidate } from '#/lib/query.js';

export function EmailCard() {
  const { data, error } = useQuery({
    queryKey: queryKeys.email,
    queryFn: () => get('/email'),
  });
  const action = useApiMutation([queryKeys.email]);
  const invalidate = useInvalidate();
  const [notice, setNotice] = useState('');
  const [now, setNow] = useState(() => Date.now());

  const pending = data?.pending || null;
  const resendAt = pending?.resendAt || 0;
  const secondsLeft = Math.max(0, Math.ceil((resendAt - now) / 1000));

  // Tick once a second while the resend is still cooling down.
  useEffect(() => {
    if (!pending || Date.now() >= resendAt) return;
    const id = setInterval(() => {
      setNow(Date.now());
      if (Date.now() >= resendAt) clearInterval(id);
    }, 1000);
    return () => clearInterval(id);
  }, [pending, resendAt]);

  if (data && !data.supported) return null;

  const run = (body, message) =>
    action.run(async () => {
      await post('/email/action', body);
      setNotice(message);
    });

  // The pending card is the feedback that a code went out, so sending one
  // clears any notice rather than repeating the address a third time.
  const resend = () => run({ action: 'resend' }, '');
  const cancel = () => run({ action: 'cancel' }, '');
  const sendToStored = () => run({ address: data.address }, '');

  return (
    <Panel>
      <Section title="Email for recovery">
        <ErrorNotice error={error?.message || action.error} className="mb-3" />
        {notice && !action.error && (
          <Notice variant="success" className="mb-3">
            {notice}
          </Notice>
        )}
        <p className="mb-4 text-[13px] text-muted-foreground leading-relaxed">
          An address to get back into your account. This server sends nothing
          else to it.
        </p>

        {pending ? (
          <>
            <AddressRow
              address={pending.address}
              status={
                <span className="inline-flex items-center gap-1.5 whitespace-nowrap text-[12px] text-muted-foreground">
                  <span className="size-1.5 rounded-full bg-key" />
                  Awaiting confirmation
                </span>
              }
              trailing={
                <Button
                  variant="outline"
                  size="sm"
                  className="shrink-0"
                  disabled={secondsLeft > 0 || action.busy}
                  onClick={resend}
                >
                  {secondsLeft > 0
                    ? `Resend in ${secondsLeft}s`
                    : 'Resend code'}
                </Button>
              }
            />
            {data.address && data.address !== pending.address && (
              <p className="-mt-1 mb-4 text-[12px] text-faint">
                {data.address} stays your address until you confirm this one.
              </p>
            )}
            <FormShell
              submitLabel="Confirm address"
              onSubmit={async (values) => {
                await post('/email/action', {
                  action: 'verify',
                  code: values.code,
                });
                setNotice('Address confirmed.');
                invalidate([queryKeys.email]);
              }}
            >
              <TextField
                name="code"
                label="Enter the confirmation code"
                required
                inputMode="numeric"
                pattern="[0-9]{6}"
                autoComplete="one-time-code"
              />
            </FormShell>
            <TextLink
              className="mt-3 text-[12.5px]"
              disabled={action.busy}
              onClick={cancel}
            >
              Use a different address
            </TextLink>
          </>
        ) : data?.address ? (
          <>
            <AddressRow
              address={data.address}
              status={
                <span className="text-[12px] text-faint">
                  {data.verified ? 'Confirmed' : 'Unconfirmed'}
                </span>
              }
              trailing={
                !data.verified &&
                data.canSend && (
                  <Button
                    variant="outline"
                    size="sm"
                    className="shrink-0"
                    disabled={action.busy}
                    onClick={sendToStored}
                  >
                    Send code
                  </Button>
                )
              }
            />
            <FormShell
              submitLabel="Change address"
              onSubmit={async (values) => {
                await post('/email/action', { address: values.address });
                setNotice(
                  data.canSend
                    ? `Sent a code to ${values.address}.`
                    : 'Address saved.',
                );
                invalidate([queryKeys.email]);
              }}
            >
              <TextField
                name="address"
                label="Change the address"
                type="email"
                required
                autoComplete="email"
                autoCapitalize="none"
                spellCheck="false"
              />
            </FormShell>
          </>
        ) : (
          <FormShell
            submitLabel={data?.canSend ? 'Send a code' : 'Save address'}
            onSubmit={async (values) => {
              await post('/email/action', { address: values.address });
              setNotice(
                data?.canSend
                  ? `Sent a code to ${values.address}.`
                  : 'Address saved.',
              );
              invalidate([queryKeys.email]);
            }}
          >
            <TextField
              name="address"
              label="Add an address"
              type="email"
              required
              autoComplete="email"
              autoCapitalize="none"
              spellCheck="false"
            />
          </FormShell>
        )}
      </Section>
    </Panel>
  );
}

/** One address on its own line, then its status and primary action below. */
function AddressRow({ address, status, trailing }) {
  return (
    <div className="mb-4 rounded-2xl border border-border bg-muted px-4 py-3">
      <div
        className="truncate font-mono text-[13px] text-foreground"
        title={address}
      >
        {address}
      </div>
      <div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-2">
        {status}
        {trailing && <div className="ml-auto">{trailing}</div>}
      </div>
    </div>
  );
}