Files
PaperClipAI/ui/src/pages/CliAuth.tsx
T
DottaandPaperclip 2befcd9a0a Harden Board CLI approval and native verification fixtures (#16482)
Preserve governed Board CLI authority and strict verification fixtures on the Muse stack.

Keep approval/cancel ownership, current membership/key authority and durable first-change audit atomic. Bound Retry-After scheduling within the original challenge; never automatically resubmit consent. Retain descriptor ownership until identity-based cleanup and close it on success and error. Keep strict Git-origin and synchronized provider-entry fixture assertions.

Related public work: LukeAbell's existing PR #13919 proposes the same production descriptor-lifetime fix. This independently measured local integration retains the original tests and adds real-descriptor coverage. Do not treat this local integration as ownership of that contribution.

Carry actual merged UI source 896f3c7f3b through a normal forward merge, preserving canonical workspace recovery, Muse authority and existing migration bytes. Current-head CI and substantive review remain merge gates. Complete qualification of the final immutable canonical source and actual provider acceptance remain separate.

Refs #16482, #13919.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-17 23:22:57 -05:00

214 lines
8.7 KiB
TypeScript

import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useParams, useSearchParams } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { accessApi } from "../api/access";
import { ApiError } from "../api/client";
import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys";
function cliAuthRetryDelay(error: unknown): number {
const body = error instanceof ApiError && error.body && typeof error.body === "object"
? error.body as { details?: unknown } : null;
const details = body?.details && typeof body.details === "object"
? body.details as { retryAfterSeconds?: unknown } : null;
const milliseconds = typeof details?.retryAfterSeconds === "number" ? details.retryAfterSeconds * 1000 : Number.NaN;
return Number.isFinite(milliseconds) && milliseconds > 0 ? Math.max(1000, milliseconds) : 60_000;
}
function isCliAuthRateLimit(error: unknown): error is ApiError {
return error instanceof ApiError && error.status === 429;
}
export function CliAuthPage() {
const queryClient = useQueryClient();
const params = useParams();
const [searchParams] = useSearchParams();
const challengeId = (params.id ?? "").trim();
const token = (searchParams.get("token") ?? "").trim();
const currentPath = useMemo(
() => `/cli-auth/${encodeURIComponent(challengeId)}${token ? `?token=${encodeURIComponent(token)}` : ""}`,
[challengeId, token],
);
const sessionQuery = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
const challengeQueryKey = useMemo(() => ["cli-auth-challenge", challengeId, token] as const, [challengeId, token]);
const retryWindowEndsAt = useMemo(() => Date.now() + 10 * 60_000, [challengeId, token]);
const challengeQuery = useQuery({
queryKey: challengeQueryKey,
queryFn: () => accessApi.getCliAuthChallenge(challengeId, token),
enabled: challengeId.length > 0 && token.length > 0,
retry: (failureCount, error) => {
if (!isCliAuthRateLimit(error) || failureCount >= 3) return false;
const knownChallenge = queryClient.getQueryData<{ expiresAt: string }>(challengeQueryKey);
const knownExpiry = Date.parse(knownChallenge?.expiresAt ?? "");
const deadline = Number.isFinite(knownExpiry) ? Math.min(knownExpiry, retryWindowEndsAt) : retryWindowEndsAt;
return Date.now() + cliAuthRetryDelay(error) < deadline;
},
retryDelay: (_failureCount, error) => cliAuthRetryDelay(error),
});
const approveMutation = useMutation({
mutationFn: () => accessApi.approveCliAuthChallenge(challengeId, token),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await challengeQuery.refetch();
},
});
const cancelMutation = useMutation({
mutationFn: () => accessApi.cancelCliAuthChallenge(challengeId, token),
onSuccess: async () => {
await challengeQuery.refetch();
},
});
if (!challengeId || !token) {
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid CLI auth URL.</div>;
}
const waitingForRateLimit = isCliAuthRateLimit(challengeQuery.failureReason) && challengeQuery.isFetching;
if (sessionQuery.isLoading || challengeQuery.isLoading || waitingForRateLimit) {
return <div role="status" className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">
{waitingForRateLimit ? "Too many CLI approval requests. Retrying shortly..." : "Loading CLI auth challenge..."}
</div>;
}
if (challengeQuery.error) {
return (
<div className="mx-auto max-w-xl py-10">
<Card className="block p-6">
<h1 className="text-lg font-semibold">CLI auth challenge unavailable</h1>
<p className="mt-2 text-sm text-muted-foreground">
{challengeQuery.error instanceof Error ? challengeQuery.error.message : "Challenge is invalid or expired."}
</p>
</Card>
</div>
);
}
const challenge = challengeQuery.data;
if (!challenge) {
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">CLI auth challenge unavailable.</div>;
}
if (challenge.status === "approved") {
return (
<div className="mx-auto max-w-xl py-10">
<Card className="block p-6">
<h1 className="text-xl font-semibold">CLI access approved</h1>
<p className="mt-2 text-sm text-muted-foreground">
The Paperclip CLI can now finish authentication on the requesting machine.
</p>
<p className="mt-4 text-sm text-muted-foreground">
Command: <span className="font-mono text-foreground">{challenge.command}</span>
</p>
</Card>
</div>
);
}
if (challenge.status === "cancelled" || challenge.status === "expired") {
return (
<div className="mx-auto max-w-xl py-10">
<Card className="block p-6">
<h1 className="text-xl font-semibold">
{challenge.status === "expired" ? "CLI auth challenge expired" : "CLI auth challenge cancelled"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Start the CLI auth flow again from your terminal to generate a new approval request.
</p>
</Card>
</div>
);
}
if (challenge.requiresSignIn || !sessionQuery.data) {
return (
<div className="mx-auto max-w-xl py-10">
<Card className="block p-6">
<h1 className="text-xl font-semibold">Sign in required</h1>
<p className="mt-2 text-sm text-muted-foreground">
Sign in or create an account, then return to this page to approve the CLI access request.
</p>
<Button asChild className="mt-4">
<Link to={`/auth?next=${encodeURIComponent(currentPath)}`}>Sign in / Create account</Link>
</Button>
</Card>
</div>
);
}
return (
<div className="mx-auto max-w-xl py-10">
<Card className="block p-6">
<h1 className="text-xl font-semibold">Approve Paperclip CLI access</h1>
<p className="mt-2 text-sm text-muted-foreground">
A CLI client is requesting full board access to the organizations you can access on this instance. Approve only if you started this request in your own terminal.
</p>
<div className="mt-5 space-y-3 text-sm">
<div>
<div className="text-muted-foreground">Command</div>
<div className="font-mono text-foreground">{challenge.command}</div>
</div>
<div>
<div className="text-muted-foreground">Client</div>
<div className="text-foreground">{challenge.clientName ?? "paperclipai cli"}</div>
</div>
<div>
<div className="text-muted-foreground">Requested access</div>
<div className="text-foreground">
{challenge.requestedAccess === "instance_admin_required" ? "Instance admin" : "Board"}
</div>
</div>
{challenge.requestedCompanyName && (
<div>
<div className="text-muted-foreground">Organization for audit context</div>
<div className="text-foreground">{challenge.requestedCompanyName}</div>
</div>
)}
</div>
{(approveMutation.error || cancelMutation.error) && (
<p className="mt-4 text-sm text-destructive">
{(approveMutation.error ?? cancelMutation.error) instanceof Error
? ((approveMutation.error ?? cancelMutation.error) as Error).message
: "Failed to update CLI auth challenge"}
</p>
)}
{!challenge.canApprove && (
<p className="mt-4 text-sm text-destructive">
{challenge.requestedAccess === "instance_admin_required"
? "This challenge requires instance-admin access. Sign in with an instance admin account to approve it."
: "You do not have access to the requested organization."}
</p>
)}
<div className="mt-5 flex gap-3">
<Button
onClick={() => approveMutation.mutate()}
disabled={!challenge.canApprove || approveMutation.isPending || cancelMutation.isPending}
>
{approveMutation.isPending ? "Approving..." : "Approve CLI access"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => cancelMutation.mutate()}
disabled={approveMutation.isPending || cancelMutation.isPending}
>
{cancelMutation.isPending ? "Cancelling..." : "Cancel"}
</Button>
</div>
</Card>
</div>
);
}