Add browser-based board CLI auth flow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-23 07:48:03 -05:00
parent 1376fc8f44
commit 37c2c4acc4
31 changed files with 13299 additions and 19 deletions

View file

@ -39,6 +39,7 @@ import { OrgChart } from "./pages/OrgChart";
import { NewAgent } from "./pages/NewAgent";
import { AuthPage } from "./pages/Auth";
import { BoardClaimPage } from "./pages/BoardClaim";
import { CliAuthPage } from "./pages/CliAuth";
import { InviteLandingPage } from "./pages/InviteLanding";
import { NotFoundPage } from "./pages/NotFound";
import { queryKeys } from "./lib/queryKeys";
@ -302,6 +303,7 @@ export function App() {
<Routes>
<Route path="auth" element={<AuthPage />} />
<Route path="board-claim/:token" element={<BoardClaimPage />} />
<Route path="cli-auth/:id" element={<CliAuthPage />} />
<Route path="invite/:token" element={<InviteLandingPage />} />
<Route element={<CloudAccessGate />}>

View file

@ -64,6 +64,23 @@ type BoardClaimStatus = {
claimedByUserId: string | null;
};
type CliAuthChallengeStatus = {
id: string;
status: "pending" | "approved" | "cancelled" | "expired";
command: string;
clientName: string | null;
requestedAccess: "board" | "instance_admin_required";
requestedCompanyId: string | null;
requestedCompanyName: string | null;
approvedAt: string | null;
cancelledAt: string | null;
expiresAt: string;
approvedByUser: { id: string; name: string; email: string } | null;
requiresSignIn: boolean;
canApprove: boolean;
currentUserId: string | null;
};
type CompanyInviteCreated = {
id: string;
token: string;
@ -127,4 +144,16 @@ export const accessApi = {
claimBoard: (token: string, code: string) =>
api.post<{ claimed: true; userId: string }>(`/board-claim/${token}/claim`, { code }),
getCliAuthChallenge: (id: string, token: string) =>
api.get<CliAuthChallengeStatus>(`/cli-auth/challenges/${id}?token=${encodeURIComponent(token)}`),
approveCliAuthChallenge: (id: string, token: string) =>
api.post<{ approved: boolean; status: string; userId: string; keyId: string | null; expiresAt: string }>(
`/cli-auth/challenges/${id}/approve`,
{ token },
),
cancelCliAuthChallenge: (id: string, token: string) =>
api.post<{ cancelled: boolean; status: string }>(`/cli-auth/challenges/${id}/cancel`, { token }),
};

View file

@ -4,7 +4,7 @@ import {
toCompanyRelativePath,
} from "./company-routes";
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "cli-auth", "docs"]);
export function isRememberableCompanyPath(path: string): boolean {
const pathname = path.split("?")[0] ?? "";

View file

@ -17,7 +17,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"design-guide",
]);
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "docs", "instance"]);
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "cli-auth", "docs", "instance"]);
export function normalizeCompanyPrefix(prefix: string): string {
return prefix.trim().toUpperCase();

184
ui/src/pages/CliAuth.tsx Normal file
View file

@ -0,0 +1,184 @@
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 { accessApi } from "../api/access";
import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys";
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 challengeQuery = useQuery({
queryKey: ["cli-auth-challenge", challengeId, token],
queryFn: () => accessApi.getCliAuthChallenge(challengeId, token),
enabled: challengeId.length > 0 && token.length > 0,
retry: false,
});
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>;
}
if (sessionQuery.isLoading || challengeQuery.isLoading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading CLI auth challenge...</div>;
}
if (challengeQuery.error) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card 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>
</div>
</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">
<div className="rounded-lg border border-border bg-card 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>
</div>
</div>
);
}
if (challenge.status === "cancelled" || challenge.status === "expired") {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card 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>
</div>
</div>
);
}
if (challenge.requiresSignIn || !sessionQuery.data) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card 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>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">Approve Paperclip CLI access</h1>
<p className="mt-2 text-sm text-muted-foreground">
A local Paperclip CLI process is requesting board access to this instance.
</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">Requested company</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">
This challenge requires instance-admin access. Sign in with an instance admin account to approve it.
</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>
</div>
</div>
);
}