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 (
{identityOnly ? 'This app wants to uniquely identify you through your account.' : 'This app wants to access your account.'}
Taking you back to {clientName}.
Still here?{' '} Continue to {clientHost(clientId)}