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