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