From 386490b334c03a1aea1a944ae5e2a0b413cfa021 Mon Sep 17 00:00:00 2001 From: Paperclip CTO Date: Wed, 24 Jun 2026 08:34:44 +0000 Subject: [PATCH] feat(ui): add subscription credential settings --- ui/src/api/index.ts | 1 + ui/src/api/subscriptionCredentials.ts | 50 +++ .../SubscriptionCredentialsPanel.test.tsx | 121 +++++ .../SubscriptionCredentialsPanel.tsx | 422 ++++++++++++++++++ ui/src/lib/queryKeys.ts | 3 + ui/src/pages/ProfileSettings.test.tsx | 18 +- ui/src/pages/ProfileSettings.tsx | 12 + 7 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 ui/src/api/subscriptionCredentials.ts create mode 100644 ui/src/components/SubscriptionCredentialsPanel.test.tsx create mode 100644 ui/src/components/SubscriptionCredentialsPanel.tsx diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 1bee6e75897..ae930554e2d 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -20,3 +20,4 @@ export { sidebarPreferencesApi } from "./sidebarPreferences"; export { resourceMembershipsApi } from "./resourceMemberships"; export { inboxDismissalsApi } from "./inboxDismissals"; export { companySkillsApi } from "./companySkills"; +export { subscriptionCredentialsApi } from "./subscriptionCredentials"; diff --git a/ui/src/api/subscriptionCredentials.ts b/ui/src/api/subscriptionCredentials.ts new file mode 100644 index 00000000000..f44b53be422 --- /dev/null +++ b/ui/src/api/subscriptionCredentials.ts @@ -0,0 +1,50 @@ +import type { + SubscriptionCredentialKind, + SubscriptionCredentialProvider, + SubscriptionCredentialReadModel, + SubscriptionCredentialStatus, + SubscriptionCredentialTestStatus, +} from "@paperclipai/shared"; +import { api } from "./client"; + +// Payload for linking (creating) or rotating (updating) a credential. The +// server keys the upsert on (company, user, provider), so a rotate is just an +// upsert with fresh `material` for the same provider. `material` is the raw +// pasted token / JSON document; it is sent once and never returned by the API. +export interface UpsertSubscriptionCredentialInput { + provider: SubscriptionCredentialProvider; + credentialKind: SubscriptionCredentialKind; + material: string; + status?: SubscriptionCredentialStatus; +} + +export interface RecordTestResultInput { + testStatus: SubscriptionCredentialTestStatus; +} + +// All routes are company-scoped and resolve the acting board user server-side; +// the client never passes a userId. Responses are always the redacted read +// model; credential material is never echoed back. +export const subscriptionCredentialsApi = { + list: (companyId: string) => + api.get( + `/companies/${companyId}/subscription-credentials`, + ), + get: (companyId: string, credentialId: string) => + api.get( + `/companies/${companyId}/subscription-credentials/${credentialId}`, + ), + // Link or rotate: upsert keyed by provider for the acting user. + upsert: (companyId: string, data: UpsertSubscriptionCredentialInput) => + api.put( + `/companies/${companyId}/subscription-credentials`, + data, + ), + recordTestResult: (companyId: string, credentialId: string, data: RecordTestResultInput) => + api.post( + `/companies/${companyId}/subscription-credentials/${credentialId}/test-result`, + data, + ), + remove: (companyId: string, credentialId: string) => + api.delete(`/companies/${companyId}/subscription-credentials/${credentialId}`), +}; diff --git a/ui/src/components/SubscriptionCredentialsPanel.test.tsx b/ui/src/components/SubscriptionCredentialsPanel.test.tsx new file mode 100644 index 00000000000..45f6d0f1bf3 --- /dev/null +++ b/ui/src/components/SubscriptionCredentialsPanel.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SubscriptionCredentialsPanel } from "./SubscriptionCredentialsPanel"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +const mockSubscriptionCredentialsApi = vi.hoisted(() => ({ + list: vi.fn(), + get: vi.fn(), + upsert: vi.fn(), + recordTestResult: vi.fn(), + remove: vi.fn(), +})); + +vi.mock("@/api/subscriptionCredentials", () => ({ + subscriptionCredentialsApi: mockSubscriptionCredentialsApi, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("SubscriptionCredentialsPanel", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + + mockSubscriptionCredentialsApi.list.mockResolvedValue([]); + mockSubscriptionCredentialsApi.upsert.mockResolvedValue({ + id: "credential-1", + companyId: "company-1", + userId: "user-1", + provider: "claude", + credentialKind: "claude_oauth_token", + status: "active", + testStatus: "untested", + redactedMetadata: { kind: "claude_oauth_token", materialFormat: "token" }, + lastTestedAt: null, + lastResolvedAt: null, + createdAt: "2026-06-24T00:00:00.000Z", + updatedAt: "2026-06-24T00:00:00.000Z", + }); + mockSubscriptionCredentialsApi.remove.mockResolvedValue(undefined); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("links a credential without echoing the submitted secret back into the form", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("No Claude credential is linked for this user yet."); + expect(container.textContent).toContain("No Codex credential is linked for this user yet."); + + const textareas = container.querySelectorAll("textarea"); + expect(textareas.length).toBeGreaterThan(0); + const claudeTextarea = textareas[0] as HTMLTextAreaElement; + + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + setter?.call(claudeTextarea, "secret-token"); + claudeTextarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flushReact(); + + expect(claudeTextarea.value).toBe("secret-token"); + + const linkButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Link credential"), + ) as HTMLButtonElement | undefined; + expect(linkButton).toBeDefined(); + + await act(async () => { + linkButton?.click(); + }); + await flushReact(); + await flushReact(); + + expect(mockSubscriptionCredentialsApi.upsert).toHaveBeenCalledWith("company-1", { + provider: "claude", + credentialKind: "claude_oauth_token", + material: "secret-token", + status: "active", + }); + expect(claudeTextarea.value).toBe(""); + expect(container.textContent).not.toContain("secret-token"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/SubscriptionCredentialsPanel.tsx b/ui/src/components/SubscriptionCredentialsPanel.tsx new file mode 100644 index 00000000000..b96bd4fc7c6 --- /dev/null +++ b/ui/src/components/SubscriptionCredentialsPanel.tsx @@ -0,0 +1,422 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Cloud, KeyRound, Loader2, ShieldCheck, Trash2 } from "lucide-react"; +import type { + SubscriptionCredentialKind, + SubscriptionCredentialProvider, + SubscriptionCredentialReadModel, + SubscriptionCredentialStatus, +} from "@paperclipai/shared"; +import { subscriptionCredentialsApi } from "@/api/subscriptionCredentials"; +import { queryKeys } from "@/lib/queryKeys"; +import { formatDateTime, relativeTime } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Field } from "./agent-config-primitives"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +type ProviderConfig = { + provider: SubscriptionCredentialProvider; + label: string; + icon: typeof Cloud; + accentClassName: string; + description: string; + guidance: string; + emptyState: string; + kinds: Array<{ + value: SubscriptionCredentialKind; + label: string; + note: string; + }>; +}; + +const PROVIDER_CONFIGS: ProviderConfig[] = [ + { + provider: "claude", + label: "Claude", + icon: Cloud, + accentClassName: "from-sky-500/20 via-cyan-500/10 to-transparent", + description: "Use the official Claude Code subscription login or a pasted credentials JSON export.", + guidance: + "Paste either the Claude OAuth token or the contents of ~/.claude/.credentials.json from the official CLI.", + emptyState: "No Claude credential is linked for this user yet.", + kinds: [ + { + value: "claude_oauth_token", + label: "Claude OAuth token", + note: "Paste the token string from Claude Code setup.", + }, + { + value: "claude_credentials_json", + label: "Claude credentials JSON", + note: "Paste the ~/.claude/.credentials.json document.", + }, + ], + }, + { + provider: "codex", + label: "Codex", + icon: KeyRound, + accentClassName: "from-emerald-500/20 via-teal-500/10 to-transparent", + description: "Use the official Codex CLI subscription auth JSON for the signed-in employee.", + guidance: + "Paste the contents of ~/.codex/auth.json from the official Codex app or CLI.", + emptyState: "No Codex credential is linked for this user yet.", + kinds: [ + { + value: "codex_auth_json", + label: "Codex auth JSON", + note: "Paste the ~/.codex/auth.json document.", + }, + ], + }, +]; + +function kindLabel(kind: SubscriptionCredentialKind): string { + switch (kind) { + case "claude_oauth_token": + return "Claude OAuth token"; + case "claude_credentials_json": + return "Claude credentials JSON"; + case "codex_auth_json": + return "Codex auth JSON"; + } +} + +function statusTone(status: SubscriptionCredentialStatus): "secondary" | "outline" | "destructive" { + return status === "active" ? "secondary" : "outline"; +} + +function testStatusTone(testStatus: SubscriptionCredentialReadModel["testStatus"]): "secondary" | "outline" | "destructive" { + if (testStatus === "ready") return "secondary"; + if (testStatus === "failed") return "destructive"; + return "outline"; +} + +function timestampLabel(value: string | null) { + if (!value) return "-"; + return `${formatDateTime(value)} (${relativeTime(value)})`; +} + +function describeMetadata(metadata: Record | null) { + if (!metadata) return "-"; + const entries = Object.entries(metadata).filter(([, value]) => value !== undefined && value !== null); + if (entries.length === 0) return "-"; + return entries + .map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`) + .join(" | "); +} + +interface SubscriptionCredentialsPanelProps { + companyId: string; + companyName: string; +} + +export function SubscriptionCredentialsPanel({ companyId, companyName }: SubscriptionCredentialsPanelProps) { + const queryClient = useQueryClient(); + const credentialsQuery = useQuery({ + queryKey: queryKeys.subscriptionCredentials.list(companyId), + queryFn: () => subscriptionCredentialsApi.list(companyId), + }); + + const credentialsByProvider = useMemo(() => { + const map = new Map(); + for (const credential of credentialsQuery.data ?? []) { + map.set(credential.provider, credential); + } + return map; + }, [credentialsQuery.data]); + + return ( +
+
+
+
+ +
+
+

Subscription credentials

+

+ Link a per-user Claude or Codex subscription credential for {companyName}. Paperclip encrypts the + stored material at rest and only shows redacted metadata after submission. +

+

+ Use the official CLI or app for the signed-in employee. Do not paste another user's credentials + or a company-shared secret. +

+
+
+ +
+
+

How it works

+
    +
  • Link once, then rotate by submitting fresh material for the same provider.
  • +
  • Paperclip never re-displays the token or JSON body after save.
  • +
  • Agents that prefer subscription mode look for the matching provider record here.
  • +
+
+
+

Status signals

+
    +
  • Show the provider, credential kind, updated time, last test, and last use time.
  • +
  • Missing records stay visible so it is obvious why subscription mode is unavailable.
  • +
  • Validation errors are rendered inline below the form that triggered them.
  • +
+
+
+
+ + {credentialsQuery.isError ? ( +
+ {credentialsQuery.error instanceof Error + ? credentialsQuery.error.message + : "Failed to load subscription credentials."} +
+ ) : null} + + {credentialsQuery.isLoading ? ( +
Loading subscription credentials...
+ ) : ( +
+ {PROVIDER_CONFIGS.map((config) => ( + { + await queryClient.invalidateQueries({ queryKey: queryKeys.subscriptionCredentials.list(companyId) }); + }} + /> + ))} +
+ )} +
+ ); +} + +interface SubscriptionCredentialCardProps { + companyId: string; + config: ProviderConfig; + credential: SubscriptionCredentialReadModel | undefined; + onSaved: () => Promise; +} + +function SubscriptionCredentialCard({ companyId, config, credential, onSaved }: SubscriptionCredentialCardProps) { + const [credentialKind, setCredentialKind] = useState( + credential?.credentialKind ?? config.kinds[0]?.value ?? "claude_oauth_token", + ); + const [status, setStatus] = useState(credential?.status ?? "active"); + const [material, setMaterial] = useState(""); + const [formError, setFormError] = useState(null); + const isLinked = credential !== undefined; + const Icon = config.icon; + + useEffect(() => { + setCredentialKind(credential?.credentialKind ?? config.kinds[0]?.value ?? "claude_oauth_token"); + setStatus(credential?.status ?? "active"); + setMaterial(""); + setFormError(null); + }, [credential?.credentialKind, credential?.status, credential?.updatedAt, config.kinds]); + + const saveMutation = useMutation({ + mutationFn: async () => { + const trimmedMaterial = material.trim(); + if (!trimmedMaterial) { + throw new Error("Paste the credential material before saving."); + } + return subscriptionCredentialsApi.upsert(companyId, { + provider: config.provider, + credentialKind, + material: trimmedMaterial, + status, + }); + }, + onSuccess: async () => { + setFormError(null); + setMaterial(""); + await onSaved(); + }, + onError: (error) => { + setFormError(error instanceof Error ? error.message : "Failed to save subscription credential."); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + if (!credential) { + throw new Error("No credential is linked for this provider."); + } + return subscriptionCredentialsApi.remove(companyId, credential.id); + }, + onSuccess: async () => { + setFormError(null); + setMaterial(""); + await onSaved(); + }, + onError: (error) => { + setFormError(error instanceof Error ? error.message : "Failed to unlink subscription credential."); + }, + }); + + return ( + +
+ +
+
+
+ + + + {config.label} +
+ {config.description} +
+
+ + {credential?.status ?? "missing"} + + + {credential?.testStatus ?? "untested"} + +
+
+ +

{config.guidance}

+
+ + +
+
+

Credential kind

+

{credential ? kindLabel(credential.credentialKind) : "Not linked"}

+
+
+

Last updated

+

{timestampLabel(credential?.updatedAt ?? null)}

+
+
+

Last tested

+

{timestampLabel(credential?.lastTestedAt ?? null)}

+
+
+

Last used

+

{timestampLabel(credential?.lastResolvedAt ?? null)}

+
+
+ +
+ {credential ? ( + <> + Redacted metadata:{" "} + {describeMetadata(credential.redactedMetadata)} + + ) : ( + config.emptyState + )} +
+ + + + {formError ? ( +
+ {formError} +
+ ) : null} + +
+ +
+ {config.kinds.map((kind) => ( + + ))} +
+
+ + +