chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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>
);
}