mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 02:40:39 +09:00
Add browser-based board CLI auth flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
1376fc8f44
commit
37c2c4acc4
31 changed files with 13299 additions and 19 deletions
|
|
@ -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 />}>
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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] ?? "";
|
||||
|
|
|
|||
|
|
@ -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
184
ui/src/pages/CliAuth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue