Files
PaperClipAI/ui/src/pages/Secrets.tsx
T
TonioandPaperclip 56517dda6c fix(ui): bundle connector, toolbar, empty-state and agent roster fixes (#18716)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use connector setup, collection headers, empty states, and
agent lists to manage that work.
> - Provider redirects replace the setup page, and collection controls
use different sizes.
> - Agent selection inserts a bar above the list. The row link
highlights only part of the row.
> - This pull request keeps sign-in in a separate window and applies the
Tasks control sizes to collection and audit headers.
> - It also reuses artifact artwork for empty collections and gives
agent selection a fixed action card.
> - These changes preserve the operator's place and keep controls and
rows stable.

## Linked Issues or Issue Description

**What happened?**

Connector sign-in replaced the current page. Inline collection controls
had different heights. Projects and Artifacts used small placeholder
empty states. Agent selection moved the list, hover covered only the
identity cell, and chat/status shared that cell without matching
headings.

**Expected behavior**

Provider sign-in opens in a new tab or window. Collection controls match
Tasks. Empty collections use the large artifact graphics. Agent actions
float above the page while rows remain aligned.

**Steps to reproduce**

Start connector sign-in from a page or dialog. Compare collection and
Audit headers. Open an empty Projects or Artifacts collection. Select an
agent, then hover across its row.

Related public work: #13584 covers an earlier connector new-tab
implementation, including same-page fallbacks that this bundle does not
use. #8528 covers Tasks toggle sizing. #17742 covers scoped agent Audit
sections. This bundle does not change server callback or audit
authorization contracts.

## What Changed

- Reserve a separate authorization window during the click. Use
validated sign-in URLs, retain a new-tab fallback, and close blank
windows after failed requests. Store Cloud reauthentication state in the
destination window.
- Use the Tasks 44px control foundation for shared collection headers
and Audit, Costs, Timeline, Secrets, Teams, Gateways, and agent skill
controls.
- Use a large artifact stack with an off-white Paperclip image with a
dark icon for Projects and artifact Tasks empty states. Use one matching
artifact for other types. Set 20px gaps and use the label “Create
project”. Action hover and keyboard focus animate the artwork.
- Portal agent bulk actions to a fixed card 48px above the bottom safe
area. Use quintic entry/exit motion and respect reduced motion. Retain
card contents during exit.
- Give chat and status separate cells, add a Status heading, use the
MessagesSquare chat action with a hover tooltip, and highlight the
entire row.
- Add linked agent chips and a manager picker to the agent profile.
Block self and descendant manager choices. Keep the name crown and
custom title inline during edits.
- Update design guidance, the connection maps, regression tests, and
empty-state Storybook fixtures.

## Verification

- Final commit `a41a6c6494a839deaaec1ef7cc50c90078ab6381`: all 55 checks
passed, three skipped. [Canonical CI
run](https://github.com/paperclipai/paperclip/actions/runs/41999792769)
includes all eight browser shards and all server/runner/workspace
groups. Greptile: 5/5, zero unresolved review findings.
- Workspace typecheck/build passed. Local UI suite passed 11,006 tests
in 880 files, plus 542 CLI tests. Subsequent connector callback changes
passed all 245 AppsConnect tests, UI typecheck, token gates, and
query-error policy checks. Manager cache/error regressions passed four
tests. Map checks passed.
- Local connector browser suite: 20 passed. All six chat integration
shards: 1,931 passed. Workspace B groups passed; database tests passed
313 cases across 72 files with one worker.
- Browser preview verified agent manager changes, overview links, inline
name/title geometry, empty-state colors, row actions and the chat
tooltip. Live provider consent and post-deployment feature verification
remain separate from these fixtures.
- Preserved failures: ten bulk-roster tests initially missed the
portaled action card; their selectors were fixed and all twelve roster
tests passed. A nested fixture selected pnpm 11 and removed local
dependencies; the local shim now pins pnpm 9. Embedded PostgreSQL native
links were restored. A Python 3.9 fixture failure passed all 19 tests
with the current runtime. Concurrent local database startup failures
passed on the one-worker rerun.
- The broad local serialized server run passed 3,791 tests with one
scheduling assertion failure, which passed in isolation. The local
general-server run reproduced a native recovery provider/model fixture
failure and has not completed. These server sources are unchanged by
this bundle; all final canonical CI server shards passed. This is not a
claim that every broad local run passed.
- [Earlier CI
run](https://github.com/paperclipai/paperclip/actions/runs/41995119880)
failed a browser test during cleanup when heartbeat-run cancellation hit
a PostgreSQL lock timeout. All final browser shards passed without
weakening that test.
- Review follow-ups cover Slack popup/fallback recovery, a single
artwork hover transform, shared manager error rendering, detached
standalone OAuth windows, and verified task-bound callbacks in both
hosts.

## Risks

- Browser popup policy and provider callback behavior can affect sign-in
completion. The original setup page stays open; blocked windows have an
explicit recovery path.
- Shared toolbar styles affect several surfaces. Menu content remains in
its existing portals; canonical label tabs and search clear buttons
retain their own geometry.
- No schema, server, API authorization, or migration change.
- Required CI and review are complete. Deployment provenance and hosted
feature verification are checked separately after merge.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, test
execution, and browser inspection. The exact model ID and context-window
size were not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run relevant tests locally and they pass (broader local
exceptions are recorded above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-05 01:23:53 -08:00

4856 lines
194 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
ArchiveRestore,
Archive,
Ban,
CheckCircle2,
ChevronLeft,
ChevronRight,
Cloud,
CornerLeftUp,
Copy,
Database,
Edit3,
ExternalLink,
Folder,
FolderOpen,
KeyRound,
Link2,
Lock,
Loader2,
MoreHorizontal,
Plus,
RefreshCw,
Search,
ShieldCheck,
Star,
Trash2,
X,
Filter,
Info,
Pencil,
UserRound,
Users,
} from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import type {
CompanySecret,
CompanySecretUsageBinding,
CompanySecretProviderConfig,
SecretProviderConfigDiscoveryCandidate,
SecretProviderConfigDiscoveryPreviewResult,
SecretAccessEvent,
SecretManagedMode,
SecretProvider,
SecretProviderConfigStatus,
SecretProviderDescriptor,
SecretStatus,
UserSecretCoverageSummary,
UserSecretDefinition,
} from "@paperclipai/shared";
import { hidesCompanySection } from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useHiddenSettings } from "../hooks/useHiddenSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import {
secretsApi,
type CreateSecretInput,
type CreateSecretProviderConfigInput,
type SecretProviderHealthResponse,
type UpdateSecretProviderConfigInput,
} from "../api/secrets";
import { ApiError } from "../api/client";
import { accessApi, type CompanyUserDirectoryEntry } from "../api/access";
import { agentsApi } from "../api/agents";
import { envKeyFromSecretName } from "../components/environment-variables-editor/model";
import {
AGENT_ACCESS_CONFIG_PATH_PREFIX,
aliasFromConfigPath,
consumerTypeLabel,
deliveryModeForConfigPath,
deliveryModeLabel,
} from "../lib/secret-delivery";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "../lib/utils";
import { copyTextToClipboard } from "../lib/clipboard";
import { useCopyAction } from "../lib/use-copy-action";
import { PageTabBar } from "../components/PageTabBar";
import { AgentSelect } from "../components/AgentMultiSelect";
import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog";
import { MyUserSecretsTab } from "./secrets/MyUserSecretsTab";
import { ProposalsTab } from "./secrets/ProposalsTab";
import { SecretPathName } from "./secrets/SecretPathName";
import {
buildSecretPathBreadcrumbs,
buildSecretPathListing,
getSecretPathRowName,
normalizeSecretPath,
validateSecretFolderSegment,
type SecretPathFolder,
} from "./secrets/secret-path";
import { SetMyUserSecretDialog } from "./secrets/SetMyUserSecretDialog";
import {
coverageSummaryLabel,
UserSecretChip,
} from "./secrets/user-secret-presentation";
import type { MyUserSecretEntry } from "../api/secrets";
import { describeError } from "@/api/errors";
type CreateMode = "managed" | "external";
// "value" writes a new secret value (for external references: through to the
// provider); "reference" re-points an external reference without writing.
type RotateMode = "value" | "reference";
type SecretValueProvider = "company" | "user";
type ProvidedByFilter = "all" | SecretValueProvider;
type SecretsTab = "secrets" | "my-secrets" | "vaults" | "proposals";
type SecretsViewMode = "folders" | "flat";
const SECRETS_VIEW_MODE_STORAGE_KEY = "paperclip.secrets.viewMode";
function readStoredViewMode(): SecretsViewMode | null {
try {
const stored = window.localStorage.getItem(SECRETS_VIEW_MODE_STORAGE_KEY);
return stored === "folders" || stored === "flat" ? stored : null;
} catch {
return null;
}
}
/** "12 secrets · 3 folders" — folder part omitted when there are no subfolders. */
function formatSecretPathCounts(secretCount: number, folderCount: number): string {
const parts = [`${secretCount} ${secretCount === 1 ? "secret" : "secrets"}`];
if (folderCount > 0) {
parts.push(`${folderCount} ${folderCount === 1 ? "folder" : "folders"}`);
}
return parts.join(" · ");
}
type UnifiedSecretRow =
| { id: string; kind: "company"; secret: CompanySecret }
| { id: string; kind: "user"; definition: UserSecretDefinition };
type ProviderVaultForm = {
provider: SecretProvider;
displayName: string;
status: SecretProviderConfigStatus;
isDefault: boolean;
backupReminderAcknowledged: boolean;
region: string;
namespace: string;
secretNamePrefix: string;
kmsKeyId: string;
ownerTag: string;
environmentTag: string;
projectId: string;
location: string;
address: string;
mountPath: string;
secretPathPrefix: string;
};
type SafeProviderErrorDetails = {
code?: string;
provider?: string;
operation?: string;
providerConfigId?: string;
providerVaultContext?: string;
region?: string;
credentialPath?: string;
requiredCapability?: string;
actionableMessage?: string;
safeAlternative?: string;
};
const EMPTY_SECRETS: CompanySecret[] = [];
const EMPTY_USER_SECRET_DEFINITIONS: UserSecretDefinition[] = [];
const EMPTY_MY_USER_SECRETS: MyUserSecretEntry[] = [];
const EMPTY_SECRET_PROVIDERS: SecretProviderDescriptor[] = [];
const EMPTY_PROVIDER_CONFIGS: CompanySecretProviderConfig[] = [];
const PROVIDER_ORDER: SecretProvider[] = [
"local_encrypted",
"aws_secrets_manager",
"gcp_secret_manager",
"vault",
];
function defaultProviderVaultStatus(provider: SecretProvider): SecretProviderConfigStatus {
return provider === "gcp_secret_manager" || provider === "vault" ? "coming_soon" : "ready";
}
function emptyProviderVaultForm(provider: SecretProvider = "local_encrypted"): ProviderVaultForm {
return {
provider,
displayName: "",
status: defaultProviderVaultStatus(provider),
isDefault: false,
backupReminderAcknowledged: false,
region: "",
namespace: "",
secretNamePrefix: "",
kmsKeyId: "",
ownerTag: "",
environmentTag: "",
projectId: "",
location: "",
address: "",
mountPath: "",
secretPathPrefix: "",
};
}
function providerConfigValue(config: CompanySecretProviderConfig["config"], key: string) {
if (!config || typeof config !== "object" || Array.isArray(config)) return "";
const value = (config as Record<string, unknown>)[key];
return typeof value === "string" ? value : "";
}
function apiErrorDetails(error: unknown): SafeProviderErrorDetails | null {
if (!(error instanceof ApiError)) return null;
const body = error.body;
if (!body || typeof body !== "object") return null;
const details = (body as Record<string, unknown>).details;
if (!details || typeof details !== "object" || Array.isArray(details)) return null;
return details as SafeProviderErrorDetails;
}
function apiErrorCode(error: unknown): string | null {
return apiErrorDetails(error)?.code ?? null;
}
function isAwsDiscoveryAccessDenied(error: unknown): boolean {
const details = apiErrorDetails(error);
if (details?.provider === "aws_secrets_manager" && details.operation === "secret_provider_config.discovery.preview") {
return details.code === "access_denied";
}
if (!(error instanceof ApiError)) return false;
return apiErrorCode(error) === "access_denied";
}
function readableErrorMessage(error: unknown): string {
return describeError(error).body;
}
function providerVaultFormFromConfig(config: CompanySecretProviderConfig): ProviderVaultForm {
return {
...emptyProviderVaultForm(config.provider),
displayName: config.displayName,
status: config.status,
isDefault: config.isDefault,
backupReminderAcknowledged:
Boolean((config.config as Record<string, unknown> | undefined)?.backupReminderAcknowledged),
region: providerConfigValue(config.config, "region"),
namespace: providerConfigValue(config.config, "namespace"),
secretNamePrefix: providerConfigValue(config.config, "secretNamePrefix"),
kmsKeyId: providerConfigValue(config.config, "kmsKeyId"),
ownerTag: providerConfigValue(config.config, "ownerTag"),
environmentTag: providerConfigValue(config.config, "environmentTag"),
projectId: providerConfigValue(config.config, "projectId"),
location: providerConfigValue(config.config, "location"),
address: providerConfigValue(config.config, "address"),
mountPath: providerConfigValue(config.config, "mountPath"),
secretPathPrefix: providerConfigValue(config.config, "secretPathPrefix"),
};
}
function formatRelative(value: Date | string | null | undefined): string {
if (!value) return "—";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "—";
const diff = Date.now() - date.getTime();
if (diff < 0) return date.toLocaleString();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return date.toLocaleDateString();
}
function statusTextTone(status: SecretStatus) {
switch (status) {
case "active":
return "text-emerald-700 dark:text-emerald-300";
case "disabled":
return "text-amber-700 dark:text-amber-300";
case "archived":
return "text-muted-foreground";
case "deleted":
return "text-destructive";
default:
return "text-muted-foreground";
}
}
function providerLabel(providers: SecretProviderDescriptor[] | undefined, id: SecretProvider) {
return providers?.find((p) => p.id === id)?.label ?? id.replaceAll("_", " ");
}
function normalizeSecretKeyForPreview(input: string) {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9_.-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120);
}
function normalizeUserSecretKeyForPreview(input: string) {
return input
.trim()
.toUpperCase()
.replace(/[^A-Z0-9_]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 120);
}
function modeLabel(managedMode: SecretManagedMode) {
return managedMode === "paperclip_managed" ? "Paperclip-managed" : "Linked external";
}
function modeDescription(managedMode: SecretManagedMode, canWriteExternalValue = false) {
if (managedMode === "paperclip_managed") {
return "Paperclip owns create and rotation writes for this provider secret.";
}
return canWriteExternalValue
? "Paperclip resolves this provider reference and can write new values to it via Update value."
: "Paperclip resolves this provider reference but does not rotate the provider value.";
}
function statusLabel(status: SecretStatus) {
return status.charAt(0).toUpperCase() + status.slice(1);
}
function statusDotTone(status: SecretStatus) {
switch (status) {
case "active":
return "bg-emerald-500";
case "disabled":
return "bg-amber-500";
case "archived":
return "bg-muted-foreground";
case "deleted":
return "bg-destructive";
default:
return "bg-muted-foreground";
}
}
function StatusBadge({ status }: { status: SecretStatus }) {
return (
<span className={cn("inline-flex items-center gap-1.5 text-xs font-medium", statusTextTone(status))}>
<span className={cn("h-1.5 w-1.5 rounded-full", statusDotTone(status))} aria-hidden="true" />
{statusLabel(status)}
</span>
);
}
function MetaChip({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex min-w-0 items-center gap-1 rounded-md border border-border bg-background px-2 py-0.5 text-(length:--text-micro) text-muted-foreground">
{children}
</span>
);
}
function providerIndicatorLabel(
secret: CompanySecret,
providers: SecretProviderDescriptor[],
providerConfigs: CompanySecretProviderConfig[],
) {
const provider = providerLabel(providers, secret.provider);
const vault = providerVaultLabel(providerConfigs, secret.providerConfigId);
const custody = modeLabel(secret.managedMode);
return [
`${custody} · ${provider}`,
vault ? `Vault: ${vault}` : null,
secret.externalRef ? `Reference: ${secret.externalRef}` : null,
]
.filter(Boolean)
.join("\n");
}
function SecretProviderIndicator({
secret,
providers,
providerConfigs,
}: {
secret: CompanySecret;
providers: SecretProviderDescriptor[];
providerConfigs: CompanySecretProviderConfig[];
}) {
const label = providerIndicatorLabel(secret, providers, providerConfigs);
const Icon = secret.managedMode === "external_reference" ? ExternalLink : Lock;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={label}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-border bg-muted/40 text-muted-foreground"
>
<Icon className="h-3 w-3" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 whitespace-pre-wrap break-words">{label}</TooltipContent>
</Tooltip>
);
}
function UpdatedWithTooltip({
updatedAt,
tooltip,
}: {
updatedAt: Date | string | null | undefined;
tooltip: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={tooltip}
className="inline-flex cursor-help border-b border-dotted border-muted-foreground/60 text-xs text-muted-foreground"
>
{formatRelative(updatedAt)}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-72 whitespace-pre-wrap">{tooltip}</TooltipContent>
</Tooltip>
);
}
function healthEntryForProvider(
health: SecretProviderHealthResponse | null,
providerId: SecretProvider,
) {
return health?.providers.find((entry) => entry.provider === providerId) ?? null;
}
export function getCreateProviderBlockReason(
provider: SecretProviderDescriptor | null | undefined,
mode: CreateMode,
health: SecretProviderHealthResponse | null,
providerConfig?: CompanySecretProviderConfig | null,
) {
if (!provider) return "Select a provider.";
if (mode === "managed" && provider.supportsManagedValues === false) {
return `${provider.label} does not support Paperclip-managed secret values.`;
}
if (mode === "external" && provider.supportsExternalReferences === false) {
return `${provider.label} does not support linked external references.`;
}
const selectedProviderConfigBlockReason = providerConfig?.provider === provider.id
? getProviderConfigBlockReason(providerConfig)
: null;
const selectedProviderConfigReady =
providerConfig?.provider === provider.id && !selectedProviderConfigBlockReason;
if (provider.configured === false) {
if (selectedProviderConfigReady) return null;
if (selectedProviderConfigBlockReason) return selectedProviderConfigBlockReason;
const healthEntry = healthEntryForProvider(health, provider.id);
const deploymentMessage = `Deployment default ${provider.label} is not configured.`;
const nextStep = " Select a ready provider vault or configure the deployment default.";
return healthEntry?.message
? `${deploymentMessage}${nextStep} ${healthEntry.message}`
: `${deploymentMessage}${nextStep}`;
}
const healthEntry = healthEntryForProvider(health, provider.id);
if (healthEntry?.status === "error") {
return `${provider.label} health check failed: ${healthEntry.message}`;
}
return null;
}
function providerHealthText(
provider: SecretProviderDescriptor | null | undefined,
health: SecretProviderHealthResponse | null,
providerConfig?: CompanySecretProviderConfig | null,
) {
if (!provider) return null;
if (
provider.configured === false &&
providerConfig?.provider === provider.id &&
!getProviderConfigBlockReason(providerConfig)
) {
return `Using selected provider vault. Deployment default ${provider.label} is not configured.`;
}
const entry = healthEntryForProvider(health, provider.id);
if (!entry) return null;
const warnings = entry.warnings?.join(" ");
return [entry.message, warnings].filter(Boolean).join(" ");
}
function detailString(details: Record<string, unknown> | undefined, key: string) {
const value = details?.[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function getProviderConfigBlockReason(
config: CompanySecretProviderConfig | null | undefined,
) {
if (!config) return null;
if (config.status === "disabled") return "This provider vault is disabled.";
if (config.status === "coming_soon") return "This provider vault is saved as draft metadata only.";
if (config.healthStatus === "error") {
return config.healthMessage ?? "This provider vault health check failed.";
}
return null;
}
export function getSelectableProviderConfig(
configs: CompanySecretProviderConfig[],
provider: SecretProvider,
) {
const providerConfigs = configs.filter((config) => config.provider === provider);
return (
providerConfigs.find((config) => config.isDefault && !getProviderConfigBlockReason(config)) ??
providerConfigs.find((config) => !getProviderConfigBlockReason(config)) ??
null
);
}
export function getDefaultProviderConfigId(
configs: CompanySecretProviderConfig[],
provider: SecretProvider,
) {
const selected = getSelectableProviderConfig(configs, provider);
const providerConfigs = configs.filter((config) => config.provider === provider);
return (
selected?.id ??
providerConfigs.find((config) => config.isDefault)?.id ??
""
);
}
export function findCreateProviderReplacement({
providers,
providerConfigs,
currentProvider,
mode,
health,
}: {
providers: SecretProviderDescriptor[];
providerConfigs: CompanySecretProviderConfig[];
currentProvider: SecretProvider;
mode: CreateMode;
health: SecretProviderHealthResponse | null;
}) {
return (
providers.find((provider) => {
const selectedConfig =
provider.id === currentProvider
? providerConfigs.find(
(config) => config.provider === provider.id && !getProviderConfigBlockReason(config),
) ?? null
: getSelectableProviderConfig(providerConfigs, provider.id);
return !getCreateProviderBlockReason(provider, mode, health, selectedConfig);
}) ?? null
);
}
function providerVaultLabel(configs: CompanySecretProviderConfig[], id: string | null | undefined) {
if (!id) return "Deployment default";
return configs.find((config) => config.id === id)?.displayName ?? "Unknown vault";
}
function buildProviderVaultConfig(form: ProviderVaultForm): Record<string, unknown> {
const compact = (value: string) => value.trim() || null;
switch (form.provider) {
case "local_encrypted":
return { backupReminderAcknowledged: form.backupReminderAcknowledged };
case "aws_secrets_manager":
return {
region: form.region.trim(),
namespace: compact(form.namespace),
secretNamePrefix: compact(form.secretNamePrefix),
kmsKeyId: compact(form.kmsKeyId),
ownerTag: compact(form.ownerTag),
environmentTag: compact(form.environmentTag),
};
case "gcp_secret_manager":
return {
projectId: compact(form.projectId),
location: compact(form.location),
namespace: compact(form.namespace),
secretNamePrefix: compact(form.secretNamePrefix),
};
case "vault":
return {
address: compact(form.address),
namespace: compact(form.namespace),
mountPath: compact(form.mountPath),
secretPathPrefix: compact(form.secretPathPrefix),
};
default:
return {};
}
}
function getAwsProviderVaultDiscoveryQuery(form: ProviderVaultForm): string | null {
return (
form.secretNamePrefix.trim() ||
form.namespace.trim() ||
form.environmentTag.trim() ||
form.ownerTag.trim() ||
null
);
}
export function getAwsManagedPathPreview(input: {
provider: SecretProviderDescriptor | null | undefined;
health: SecretProviderHealthResponse | null;
companyId: string;
secretKeySource: string;
}) {
if (input.provider?.id !== "aws_secrets_manager") return null;
const healthEntry = healthEntryForProvider(input.health, "aws_secrets_manager");
const prefix = detailString(healthEntry?.details, "prefix") ?? "paperclip";
const deploymentId = detailString(healthEntry?.details, "deploymentId") ?? "{deploymentId}";
const secretKey = normalizeSecretKeyForPreview(input.secretKeySource) || "{secretKey}";
return `${prefix}/${deploymentId}/${input.companyId}/${secretKey}`;
}
export function Secrets() {
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToastActions();
const [activeTab, setActiveTab] = useState<SecretsTab>("secrets");
// Operator-hidden sub-tabs (UI-only; the secrets APIs stay live for agents).
const { hidden: hiddenSettings } = useHiddenSettings();
const hideVaultsTab = hidesCompanySection(hiddenSettings, "company.secrets.vaults");
const hideProposalsTab = hidesCompanySection(hiddenSettings, "company.secrets.proposals");
useEffect(() => {
if ((activeTab === "vaults" && hideVaultsTab) || (activeTab === "proposals" && hideProposalsTab)) {
setActiveTab("secrets");
}
}, [activeTab, hideVaultsTab, hideProposalsTab]);
const [secretDetailTab, setSecretDetailTab] = useState("details");
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<SecretStatus | "all">("active");
const [providerFilter, setProviderFilter] = useState<SecretProvider | "all">("all");
const [providedByFilter, setProvidedByFilter] = useState<ProvidedByFilter>("all");
const [searchParams, setSearchParams] = useSearchParams();
// The detail sheet is deep-linkable: `?secret=<id>` /
// `?definition=<id>` are the source of truth for the current selection, so
// every open secret has a shareable URL and Back closes the sheet.
const selectedSecretId = searchParams.get("secret");
const selectedDefinitionId = searchParams.get("definition");
const setDetailSelection = useCallback(
(secretId: string | null, definitionId: string | null = null) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (secretId) next.set("secret", secretId);
else next.delete("secret");
if (definitionId) next.set("definition", definitionId);
else next.delete("definition");
return next;
});
},
[setSearchParams],
);
const [usageDialogSecretId, setUsageDialogSecretId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importInitialVaultId, setImportInitialVaultId] = useState<string | null>(null);
const [secretValueProvider, setSecretValueProvider] = useState<SecretValueProvider>("company");
const [createMode, setCreateMode] = useState<CreateMode>("managed");
const [editingDefinition, setEditingDefinition] = useState<UserSecretDefinition | null>(null);
const [createNamePrefix, setCreateNamePrefix] = useState<string | null>(null);
const [createKeyDirty, setCreateKeyDirty] = useState(false);
const [createKeyEditable, setCreateKeyEditable] = useState(false);
const [createForm, setCreateForm] = useState({
name: "",
key: "",
value: "",
description: "",
usageGuidance: "",
externalRef: "",
provider: "local_encrypted" as SecretProvider,
providerConfigId: "",
});
const [createError, setCreateError] = useState<unknown>(null);
const [rotateOpen, setRotateOpen] = useState(false);
const [rotateMode, setRotateMode] = useState<RotateMode>("value");
const [rotateValue, setRotateValue] = useState("");
const [rotateExternalRef, setRotateExternalRef] = useState("");
const [rotateProviderConfigId, setRotateProviderConfigId] = useState("");
const [rotateError, setRotateError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<CompanySecret | null>(null);
const [definitionDeleteConfirm, setDefinitionDeleteConfirm] = useState<UserSecretDefinition | null>(null);
const [setMyValueFor, setSetMyValueFor] = useState<MyUserSecretEntry | null>(null);
const [vaultDialogOpen, setVaultDialogOpen] = useState(false);
const [editingVault, setEditingVault] = useState<CompanySecretProviderConfig | null>(null);
const [removeVaultConfirm, setRemoveVaultConfirm] = useState<CompanySecretProviderConfig | null>(null);
const [vaultForm, setVaultForm] = useState<ProviderVaultForm>(() => emptyProviderVaultForm());
const [vaultError, setVaultError] = useState<string | null>(null);
const [vaultDiscovery, setVaultDiscovery] =
useState<SecretProviderConfigDiscoveryPreviewResult | null>(null);
const [vaultDiscoveryError, setVaultDiscoveryError] = useState<unknown | null>(null);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [newFolderError, setNewFolderError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Secrets" }]);
}, [setBreadcrumbs]);
const secretsQuery = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.list(selectedCompanyId)
: ["secrets", "__disabled__"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const userDefinitionsQuery = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.userDefinitions(selectedCompanyId)
: ["user-secret-definitions", "__disabled__"],
queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const myUserSecretsQuery = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.myUserSecrets(selectedCompanyId)
: ["my-user-secrets", "__disabled__"],
queryFn: () => secretsApi.listMyUserSecrets(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const providersQuery = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.providers(selectedCompanyId)
: ["secret-providers", "__disabled__"],
queryFn: () => secretsApi.providers(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
staleTime: 5 * 60_000,
});
const providerHealthQuery = useQuery({
queryKey: selectedCompanyId
? ["secret-provider-health", selectedCompanyId]
: ["secret-provider-health", "__disabled__"],
queryFn: () => secretsApi.providerHealth(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
refetchInterval: 60_000,
});
const providerConfigsQuery = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.providerConfigs(selectedCompanyId)
: ["secret-provider-configs", "__disabled__"],
queryFn: () => secretsApi.providerConfigs(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const proposalsQuery = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.proposals(selectedCompanyId, "pending")
: ["secret-proposals", "__disabled__"],
queryFn: () => secretsApi.listProposals(selectedCompanyId!, "pending"),
enabled: Boolean(selectedCompanyId) && !hideProposalsTab,
});
const secretsView = useQueryView(secretsQuery);
const userDefinitionsView = useQueryView(userDefinitionsQuery);
const providerConfigsView = useQueryView(providerConfigsQuery);
const secretsListLoading =
secretsQuery.isPending ||
userDefinitionsQuery.isPending ||
secretsView.kind === "reconnecting" ||
userDefinitionsView.kind === "reconnecting";
const secrets = secretsQuery.data ?? EMPTY_SECRETS;
const userDefinitions = userDefinitionsQuery.data ?? EMPTY_USER_SECRET_DEFINITIONS;
const pendingProposalCount = proposalsQuery.data?.length ?? 0;
const myUserSecrets = myUserSecretsQuery.data ?? EMPTY_MY_USER_SECRETS;
const providers = providersQuery.data ?? EMPTY_SECRET_PROVIDERS;
const providerConfigs = providerConfigsQuery.data ?? EMPTY_PROVIDER_CONFIGS;
const selectedSecret = useMemo(
() => secrets.find((secret) => secret.id === selectedSecretId) ?? null,
[secrets, selectedSecretId],
);
const selectedDefinition = useMemo(
() => userDefinitions.find((definition) => definition.id === selectedDefinitionId) ?? null,
[selectedDefinitionId, userDefinitions],
);
const selectedSecretAccessReference = useMemo<AgentAccessReference | null>(
() => selectedSecret ? { kind: "company", secret: selectedSecret } : null,
[selectedSecret],
);
const selectedDefinitionAccessReference = useMemo<AgentAccessReference | null>(
() => selectedDefinition ? { kind: "user", definition: selectedDefinition } : null,
[selectedDefinition],
);
const selectedDefinitionMyEntry = useMemo(() => {
if (!selectedDefinition) return null;
return myUserSecrets.find((entry) => entry.definition.id === selectedDefinition.id) ?? {
definition: selectedDefinition,
secret: null,
};
}, [myUserSecrets, selectedDefinition]);
const usageDialogSecret = useMemo(
() => secrets.find((secret) => secret.id === usageDialogSecretId) ?? null,
[secrets, usageDialogSecretId],
);
const selectedCreateProvider = useMemo(
() => providers.find((provider) => provider.id === createForm.provider) ?? null,
[providers, createForm.provider],
);
const createProviderConfigs = useMemo(
() => providerConfigs.filter((config) => config.provider === createForm.provider),
[createForm.provider, providerConfigs],
);
const selectedCreateProviderConfig = useMemo(
() => providerConfigs.find((config) => config.id === createForm.providerConfigId) ?? null,
[createForm.providerConfigId, providerConfigs],
);
const selectedRotateProviderConfigs = useMemo(
() => providerConfigs.filter((config) => config.provider === selectedSecret?.provider),
[providerConfigs, selectedSecret?.provider],
);
const selectedRotateProviderConfig = useMemo(
() => providerConfigs.find((config) => config.id === rotateProviderConfigId) ?? null,
[providerConfigs, rotateProviderConfigId],
);
const createProviderBlockReason = getCreateProviderBlockReason(
selectedCreateProvider,
createMode,
providerHealthQuery.data ?? null,
selectedCreateProviderConfig,
) ?? getProviderConfigBlockReason(selectedCreateProviderConfig);
const rotateProviderBlockReason = getProviderConfigBlockReason(selectedRotateProviderConfig);
const createProviderHealthText = providerHealthText(
selectedCreateProvider,
providerHealthQuery.data ?? null,
selectedCreateProviderConfig,
);
const awsManagedPathPreview = getAwsManagedPathPreview({
provider: selectedCreateProvider,
health: providerHealthQuery.data ?? null,
companyId: selectedCompanyId ?? "{companyId}",
secretKeySource: createForm.key.trim() || createForm.name,
});
const unifiedRows = useMemo<UnifiedSecretRow[]>(
() => [
...secrets.map((secret) => ({ id: `company:${secret.id}`, kind: "company" as const, secret })),
...userDefinitions.map((definition) => ({
id: `user:${definition.id}`,
kind: "user" as const,
definition,
})),
],
[secrets, userDefinitions],
);
const filteredRows = useMemo(() => {
const needle = search.trim().toLowerCase();
return unifiedRows.filter((row) => {
const providedBy: SecretValueProvider = row.kind === "company" ? "company" : "user";
const status = row.kind === "company" ? row.secret.status : row.definition.status;
if (providedByFilter !== "all" && providedBy !== providedByFilter) return false;
if (statusFilter !== "all" && status !== statusFilter) return false;
if (providerFilter !== "all" && row.kind === "company" && row.secret.provider !== providerFilter) {
return false;
}
if (!needle) return true;
if (row.kind === "company") {
return (
row.secret.name.toLowerCase().includes(needle) ||
row.secret.key.toLowerCase().includes(needle) ||
(row.secret.description?.toLowerCase().includes(needle) ?? false) ||
(row.secret.externalRef?.toLowerCase().includes(needle) ?? false)
);
}
return (
row.definition.name.toLowerCase().includes(needle) ||
row.definition.key.toLowerCase().includes(needle) ||
(row.definition.description?.toLowerCase().includes(needle) ?? false) ||
(row.definition.usageGuidance?.toLowerCase().includes(needle) ?? false)
);
});
}, [providedByFilter, providerFilter, search, statusFilter, unifiedRows]);
const activeSecretFilterCount =
(statusFilter === "active" ? 0 : 1) +
(providerFilter === "all" ? 0 : 1) +
(providedByFilter === "all" ? 0 : 1);
// --- Folder view (PAP-14698) --------------------------------------------
// Folders are derived purely from slash-delimited secret names; there is no
// server-side folder record. `?path=` holds the normalized current folder
// and is only meaningful on the main Secrets tab (inert on the others).
const pathParam = normalizeSecretPath(searchParams.get("path") ?? "");
const folderPath = activeTab === "secrets" ? pathParam : "";
const searching = search.trim().length > 0;
const [storedViewMode, setStoredViewMode] = useState<SecretsViewMode | null>(readStoredViewMode);
const hasSlashNames = useMemo(
() => unifiedRows.some((row) => getSecretPathRowName(row).includes("/")),
[unifiedRows],
);
// No explicit preference → default to Folders once any name has a slash.
const resolvedViewMode: SecretsViewMode = storedViewMode ?? (hasSlashNames ? "folders" : "flat");
// A `?path=` deep link forces folder view for the visit even if the stored
// preference is Flat. Search always renders a flat global result set.
const effectiveViewMode: SecretsViewMode = folderPath ? "folders" : resolvedViewMode;
const showFolderView = effectiveViewMode === "folders" && !searching;
const goToFolder = useCallback(
(path: string) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
const normalized = normalizeSecretPath(path);
if (normalized) next.set("path", normalized);
else next.delete("path");
return next;
},
{ replace: false },
);
},
[setSearchParams],
);
function closeNewFolder() {
setNewFolderOpen(false);
setNewFolderName("");
setNewFolderError(null);
}
function stageNewFolder() {
const segment = newFolderName.trim();
const error = validateSecretFolderSegment(segment);
if (error) {
setNewFolderError(error);
return;
}
goToFolder(folderPath ? `${folderPath}/${segment}` : segment);
closeNewFolder();
}
const setViewMode = useCallback(
(mode: SecretsViewMode) => {
setStoredViewMode(mode);
try {
window.localStorage.setItem(SECRETS_VIEW_MODE_STORAGE_KEY, mode);
} catch {
// Ignore storage failures (private mode / disabled); view still works.
}
// Flat has no notion of a current folder — leaving it out of the URL.
if (mode === "flat") goToFolder("");
},
[goToFolder],
);
const folderListing = useMemo(
() => buildSecretPathListing(filteredRows, folderPath),
[filteredRows, folderPath],
);
const breadcrumbs = useMemo(() => buildSecretPathBreadcrumbs(folderPath), [folderPath]);
const parentFolderPath = useMemo(() => {
const segments = folderPath ? folderPath.split("/") : [];
return segments.slice(0, -1).join("/");
}, [folderPath]);
const currentFolderSecretCount =
folderListing.secrets.length +
folderListing.folders.reduce((total, folder) => total + folder.secretCount, 0);
const folderRows = showFolderView ? folderListing.folders : [];
const secretRows = showFolderView ? folderListing.secrets : filteredRows;
const showUpRow = showFolderView && folderPath.length > 0;
const usageQuery = useQuery({
queryKey: selectedSecret ? queryKeys.secrets.usage(selectedSecret.id) : ["secrets", "usage", "__disabled__"],
queryFn: () => secretsApi.usage(selectedSecret!.id),
enabled: Boolean(selectedSecret),
});
const eventsQuery = useQuery({
queryKey: selectedSecret
? queryKeys.secrets.accessEvents(selectedSecret.id)
: ["secrets", "access-events", "__disabled__"],
queryFn: () => secretsApi.accessEvents(selectedSecret!.id),
enabled: Boolean(selectedSecret),
});
const usageDialogQuery = useQuery({
queryKey: usageDialogSecret
? queryKeys.secrets.usage(usageDialogSecret.id)
: ["secrets", "usage-dialog", "__disabled__"],
queryFn: () => secretsApi.usage(usageDialogSecret!.id),
enabled: Boolean(usageDialogSecret),
});
function invalidateAll(extraIds: string[] = []) {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitions(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.myUserSecrets(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.providerConfigs(selectedCompanyId) });
for (const id of extraIds) {
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.usage(id) });
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.accessEvents(id) });
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.userDefinitionCoverage(selectedCompanyId, id) });
}
}
function openCreateSecret() {
const prefix = folderPath ? `${folderPath}/` : null;
setEditingDefinition(null);
setCreateNamePrefix(prefix);
setSecretValueProvider("company");
setCreateMode("managed");
setCreateKeyDirty(false);
setCreateKeyEditable(false);
setCreateError(null);
setCreateForm({
name: prefix ?? "",
key: "",
value: "",
description: "",
usageGuidance: "",
externalRef: "",
provider: "local_encrypted",
providerConfigId: getDefaultProviderConfigId(providerConfigs, "local_encrypted"),
});
setCreateOpen(true);
}
function openEditDefinition(definition: UserSecretDefinition) {
setEditingDefinition(definition);
setCreateNamePrefix(null);
setSecretValueProvider("user");
setCreateMode("managed");
setCreateKeyDirty(true);
setCreateKeyEditable(false);
setCreateError(null);
setCreateForm({
name: definition.name,
key: definition.key,
value: "",
description: definition.description ?? "",
usageGuidance: definition.usageGuidance ?? "",
externalRef: "",
provider: "local_encrypted",
providerConfigId: "",
});
setCreateOpen(true);
}
const createMutation = useMutation({
mutationFn: async () => {
const sharedDefinitionPayload = {
name: createForm.name.trim(),
description: createForm.description.trim() || null,
usageGuidance: createForm.usageGuidance.trim() || null,
};
if (editingDefinition) {
const definition = await secretsApi.updateUserSecretDefinition(
selectedCompanyId!,
editingDefinition.id,
sharedDefinitionPayload,
);
return { kind: "user" as const, item: definition, action: "updated" as const };
}
if (secretValueProvider === "user") {
const definition = await secretsApi.createUserSecretDefinition(selectedCompanyId!, {
...sharedDefinitionPayload,
key: createForm.key.trim(),
status: "active",
});
return { kind: "user" as const, item: definition, action: "created" as const };
}
const input: CreateSecretInput = {
name: createForm.name.trim(),
provider: createForm.provider,
providerConfigId: createForm.providerConfigId || null,
managedMode: createMode === "external" ? "external_reference" : "paperclip_managed",
description: createForm.description.trim() || null,
};
if (createForm.key.trim()) input.key = createForm.key.trim();
if (createMode === "managed") {
input.value = createForm.value;
} else {
input.externalRef = createForm.externalRef.trim();
}
const secret = await secretsApi.create(selectedCompanyId!, input);
return { kind: "company" as const, item: secret, action: "created" as const };
},
onSuccess: (result) => {
pushToast({
title:
result.kind === "company"
? "Secret created"
: result.action === "updated"
? "User-provided secret updated"
: "User-provided secret created",
body: result.item.name,
tone: "success",
});
setCreateOpen(false);
setEditingDefinition(null);
setCreateNamePrefix(null);
setSecretValueProvider("company");
setCreateKeyDirty(false);
setCreateKeyEditable(false);
setCreateForm({
name: "",
key: "",
value: "",
description: "",
usageGuidance: "",
externalRef: "",
provider: createForm.provider,
providerConfigId: getDefaultProviderConfigId(providerConfigs, createForm.provider),
});
setCreateError(null);
if (result.kind === "company") {
setDetailSelection(result.item.id);
invalidateAll([result.item.id]);
} else {
setDetailSelection(null, result.item.id);
invalidateAll([result.item.id]);
}
},
onError: (error) => {
setCreateError(error);
},
});
const rotateMutation = useMutation({
mutationFn: () => {
if (!selectedSecret) throw new Error("Select a secret first");
if (selectedSecret.managedMode === "external_reference" && rotateMode === "reference") {
return secretsApi.rotate(selectedSecret.id, {
externalRef: rotateExternalRef.trim() || selectedSecret.externalRef || undefined,
providerConfigId: rotateProviderConfigId || null,
});
}
return secretsApi.rotate(selectedSecret.id, {
value: rotateValue,
providerConfigId: rotateProviderConfigId || null,
});
},
onSuccess: (updated) => {
pushToast({ title: "Rotated", body: `${updated.name} → v${updated.latestVersion}`, tone: "success" });
setRotateOpen(false);
setRotateValue("");
setRotateExternalRef("");
setRotateProviderConfigId("");
setRotateError(null);
invalidateAll([updated.id]);
},
onError: (error) => {
setRotateError(describeError(error).body);
},
});
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: SecretStatus }) => {
switch (status) {
case "active":
return secretsApi.enable(id);
case "disabled":
return secretsApi.disable(id);
case "archived":
return secretsApi.archive(id);
default:
return secretsApi.update(id, { status });
}
},
onSuccess: (updated) => {
pushToast({ title: `Secret ${updated.status}`, body: updated.name, tone: "info" });
invalidateAll([updated.id]);
},
onError: (error) => {
pushToast({
title: "Status update failed",
body: describeError(error).body,
tone: "error",
});
},
});
const definitionStatusMutation = useMutation({
mutationFn: ({ definition, status }: { definition: UserSecretDefinition; status: SecretStatus }) =>
secretsApi.updateUserSecretDefinition(selectedCompanyId!, definition.id, { status }),
onSuccess: (updated) => {
pushToast({ title: `User-provided secret ${updated.status}`, body: updated.name, tone: "info" });
invalidateAll([updated.id]);
},
onError: (error) => {
pushToast({
title: "Status update failed",
body: describeError(error).body,
tone: "error",
});
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => secretsApi.remove(id),
onSuccess: (_response, id) => {
pushToast({ title: "Secret deleted", tone: "info" });
setDeleteConfirm(null);
if (selectedSecretId === id) setDetailSelection(null);
invalidateAll([id]);
},
onError: (error) => {
pushToast({
title: "Delete failed",
body: describeError(error).body,
tone: "error",
});
},
});
const deleteDefinitionMutation = useMutation({
mutationFn: (definition: UserSecretDefinition) =>
secretsApi.removeUserSecretDefinition(selectedCompanyId!, definition.id),
onSuccess: (_response, definition) => {
pushToast({ title: "User-provided secret removed", body: definition.name, tone: "info" });
setDefinitionDeleteConfirm(null);
if (selectedDefinitionId === definition.id) setDetailSelection(null);
invalidateAll([definition.id]);
},
onError: (error) => {
pushToast({
title: "Delete failed",
body: describeError(error).body,
tone: "error",
});
},
});
const saveVaultMutation = useMutation({
mutationFn: () => {
const data: CreateSecretProviderConfigInput | UpdateSecretProviderConfigInput = {
displayName: vaultForm.displayName.trim(),
status: vaultForm.status,
isDefault: vaultForm.isDefault,
config: buildProviderVaultConfig(vaultForm),
};
if (editingVault) {
return secretsApi.updateProviderConfig(editingVault.id, data);
}
return secretsApi.createProviderConfig(selectedCompanyId!, {
...(data as UpdateSecretProviderConfigInput),
provider: vaultForm.provider,
} as CreateSecretProviderConfigInput);
},
onSuccess: (saved) => {
pushToast({ title: editingVault ? "Provider vault updated" : "Provider vault created", body: saved.displayName, tone: "success" });
setVaultDialogOpen(false);
setEditingVault(null);
setVaultForm(emptyProviderVaultForm());
setVaultError(null);
invalidateAll();
},
onError: (error) => {
setVaultError(describeError(error).body);
},
});
const discoverVaultMutation = useMutation({
mutationFn: () =>
secretsApi.providerConfigDiscoveryPreview(selectedCompanyId!, {
provider: "aws_secrets_manager",
config: buildProviderVaultConfig(vaultForm),
query: getAwsProviderVaultDiscoveryQuery(vaultForm),
pageSize: 25,
}),
onSuccess: (preview) => {
setVaultDiscovery(preview);
setVaultDiscoveryError(null);
},
onError: (error) => {
setVaultDiscovery(null);
setVaultDiscoveryError(error);
},
});
const disableVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.disableProviderConfig(id),
onSuccess: (updated) => {
pushToast({ title: "Provider vault disabled", body: updated.displayName, tone: "info" });
invalidateAll();
},
onError: (error) => {
pushToast({
title: "Disable failed",
body: describeError(error).body,
tone: "error",
});
},
});
const removeVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.removeProviderConfig(id),
onSuccess: (removed) => {
pushToast({
title: "Provider vault removed",
body: `${removed.displayName} was removed from Paperclip only.`,
tone: "info",
});
setRemoveVaultConfirm(null);
invalidateAll();
},
onError: (error) => {
pushToast({
title: "Remove failed",
body: describeError(error).body,
tone: "error",
});
},
});
const defaultVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.setDefaultProviderConfig(id),
onSuccess: (updated) => {
pushToast({ title: "Default vault set", body: updated.displayName, tone: "success" });
invalidateAll();
},
onError: (error) => {
pushToast({
title: "Default update failed",
body: describeError(error).body,
tone: "error",
});
},
});
const healthVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.checkProviderConfigHealth(id),
onSuccess: (health) => {
pushToast({ title: "Health checked", body: health.message, tone: health.status === "error" ? "error" : "info" });
invalidateAll();
},
onError: (error) => {
pushToast({
title: "Health check failed",
body: describeError(error).body,
tone: "error",
});
},
});
useEffect(() => {
if (!createOpen || providers.length === 0) return;
const currentBlockReason = getCreateProviderBlockReason(
providers.find((provider) => provider.id === createForm.provider) ?? null,
createMode,
providerHealthQuery.data ?? null,
providerConfigs.find((config) => config.id === createForm.providerConfigId) ?? null,
);
if (!currentBlockReason) return;
const replacement = findCreateProviderReplacement({
providers,
providerConfigs,
currentProvider: createForm.provider,
mode: createMode,
health: providerHealthQuery.data ?? null,
});
if (replacement && replacement.id !== createForm.provider) {
setCreateForm((current) => ({
...current,
provider: replacement.id,
providerConfigId: getDefaultProviderConfigId(providerConfigs, replacement.id),
}));
}
}, [createForm.provider, createMode, createOpen, providerConfigs, providerHealthQuery.data, providers]);
useEffect(() => {
if (!createOpen) return;
const current = providerConfigs.find((config) => config.id === createForm.providerConfigId);
if (current?.provider === createForm.provider) return;
const nextProviderConfigId = getDefaultProviderConfigId(providerConfigs, createForm.provider);
if (nextProviderConfigId === createForm.providerConfigId) return;
setCreateForm((form) => ({
...form,
providerConfigId: nextProviderConfigId,
}));
}, [createForm.provider, createForm.providerConfigId, createOpen, providerConfigs]);
useEffect(() => {
if (!rotateOpen || !selectedSecret) return;
setRotateProviderConfigId(
selectedSecret.providerConfigId ?? getDefaultProviderConfigId(providerConfigs, selectedSecret.provider),
);
}, [providerConfigs, rotateOpen, selectedSecret]);
function openCreateVault(provider: SecretProvider = "local_encrypted") {
setEditingVault(null);
setVaultForm(emptyProviderVaultForm(provider));
setVaultError(null);
setVaultDiscovery(null);
setVaultDiscoveryError(null);
setVaultDialogOpen(true);
}
function openEditVault(config: CompanySecretProviderConfig) {
setEditingVault(config);
setVaultForm(providerVaultFormFromConfig(config));
setVaultError(null);
setVaultDiscovery(null);
setVaultDiscoveryError(null);
setVaultDialogOpen(true);
}
function openImportFromVault(config?: CompanySecretProviderConfig | null) {
setImportInitialVaultId(config?.id ?? null);
setImportOpen(true);
}
function applyVaultDiscoveryCandidate(candidate: SecretProviderConfigDiscoveryCandidate) {
if (candidate.provider !== "aws_secrets_manager") return;
const config = candidate.config as Record<string, unknown>;
setVaultForm((current) => ({
...current,
displayName: current.displayName.trim() ? current.displayName : candidate.displayName,
region: providerConfigValue(config, "region"),
namespace: providerConfigValue(config, "namespace"),
secretNamePrefix: providerConfigValue(config, "secretNamePrefix"),
kmsKeyId: providerConfigValue(config, "kmsKeyId"),
ownerTag: providerConfigValue(config, "ownerTag"),
environmentTag: providerConfigValue(config, "environmentTag"),
}));
}
function openCompanySecret(secret: CompanySecret) {
setSecretDetailTab("details");
setDetailSelection(secret.id);
}
function openUserDefinition(definition: UserSecretDefinition) {
setSecretDetailTab("details");
setDetailSelection(null, definition.id);
}
function secretSupportsExternalValueWrite(secret: CompanySecret) {
return (
secret.managedMode === "external_reference" &&
Boolean(secret.externalRef) &&
Boolean(providers.find((provider) => provider.id === secret.provider)?.supportsExternalValueWrites)
);
}
function rotateActionLabel(secret: CompanySecret) {
return secret.managedMode === "external_reference" && !secretSupportsExternalValueWrite(secret)
? "Update reference"
: "Update value";
}
function openRotateSecret(secret: CompanySecret) {
openCompanySecret(secret);
setRotateOpen(true);
setRotateMode(
secret.managedMode === "external_reference" && !secretSupportsExternalValueWrite(secret)
? "reference"
: "value",
);
setRotateValue("");
setRotateExternalRef("");
setRotateProviderConfigId(
secret.providerConfigId ?? getDefaultProviderConfigId(providerConfigs, secret.provider),
);
setRotateError(null);
}
function copyDetailLink() {
void copyTextToClipboard(window.location.href)
.then(() => pushToast({ title: "Link copied", body: "Deep link to this secret", tone: "success" }))
.catch((error) =>
pushToast({
title: "Copy failed",
body: describeError(error).body,
tone: "error",
}),
);
}
function copySecretKey(key: string) {
void copyTextToClipboard(key)
.then(() => pushToast({ title: "Secret key copied", body: key, tone: "success" }))
.catch((error) =>
pushToast({
title: "Copy failed",
body: describeError(error).body,
tone: "error",
}),
);
}
function renderRowActions(row: UnifiedSecretRow) {
const name = row.kind === "company" ? row.secret.name : row.definition.name;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Actions for ${name}`}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
onSelect={() => {
if (row.kind === "company") openCompanySecret(row.secret);
else openUserDefinition(row.definition);
}}
>
<KeyRound className="h-4 w-4" /> View details
</DropdownMenuItem>
{row.kind === "company" ? (
<>
<DropdownMenuItem onSelect={() => setUsageDialogSecretId(row.secret.id)}>
<Link2 className="h-4 w-4" /> View references ({row.secret.referenceCount ?? 0})
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openRotateSecret(row.secret)}>
<RefreshCw className="h-4 w-4" />
{rotateActionLabel(row.secret)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={statusMutation.isPending}
onSelect={() =>
statusMutation.mutate({
id: row.secret.id,
status: row.secret.status === "active" ? "disabled" : "active",
})
}
>
{row.secret.status === "active" ? <Ban className="h-4 w-4" /> : <CheckCircle2 className="h-4 w-4" />}
{row.secret.status === "active" ? "Disable" : "Activate"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={statusMutation.isPending}
onSelect={() =>
statusMutation.mutate({
id: row.secret.id,
status: row.secret.status === "archived" ? "active" : "archived",
})
}
>
{row.secret.status === "archived" ? (
<ArchiveRestore className="h-4 w-4" />
) : (
<Archive className="h-4 w-4" />
)}
{row.secret.status === "archived" ? "Unarchive" : "Archive"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => setDeleteConfirm(row.secret)}>
<Trash2 className="h-4 w-4" /> Delete secret
</DropdownMenuItem>
</>
) : (
<>
<DropdownMenuItem
disabled={row.definition.status !== "active"}
onSelect={() =>
setSetMyValueFor(
myUserSecrets.find((entry) => entry.definition.id === row.definition.id) ?? {
definition: row.definition,
secret: null,
},
)
}
>
<KeyRound className="h-4 w-4" />
{myUserSecrets.find((entry) => entry.definition.id === row.definition.id)?.secret
? "Update my value"
: "Set my value"}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openEditDefinition(row.definition)}>
<Pencil className="h-4 w-4" /> Edit definition
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={definitionStatusMutation.isPending}
onSelect={() =>
definitionStatusMutation.mutate({
definition: row.definition,
status: row.definition.status === "active" ? "disabled" : "active",
})
}
>
{row.definition.status === "active" ? (
<Ban className="h-4 w-4" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
{row.definition.status === "active" ? "Disable" : "Activate"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={definitionStatusMutation.isPending}
onSelect={() =>
definitionStatusMutation.mutate({
definition: row.definition,
status: row.definition.status === "archived" ? "active" : "archived",
})
}
>
{row.definition.status === "archived" ? (
<ArchiveRestore className="h-4 w-4" />
) : (
<Archive className="h-4 w-4" />
)}
{row.definition.status === "archived" ? "Unarchive" : "Archive"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => setDefinitionDeleteConfirm(row.definition)}>
<Trash2 className="h-4 w-4" /> Delete definition
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function folderLinkTo(path: string) {
const params = new URLSearchParams(searchParams);
const normalized = normalizeSecretPath(path);
if (normalized) params.set("path", normalized);
else params.delete("path");
const qs = params.toString();
return { search: qs ? `?${qs}` : "" };
}
/** Secret-name treatment: raw in flat view, muted-path/bold-leaf otherwise. */
function renderSecretName(name: string) {
if (searching) return <SecretPathName name={name} className="text-sm" />;
if (showFolderView) return <SecretPathName name={name} basePath={folderPath} className="text-sm" />;
return <span className="truncate font-medium text-foreground">{name}</span>;
}
function renderFolderTableRow(folder: SecretPathFolder) {
return (
<Link
key={`folder:${folder.path}`}
to={folderLinkTo(folder.path)}
role="row"
className="grid grid-cols-(--gtc-54) items-center gap-3 border-b border-border/60 px-3 py-3 hover:bg-accent/40"
>
<div role="cell" className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-foreground">{folder.name}</span>
</div>
<div className="mt-0.5 pl-6 text-xs text-muted-foreground">
{formatSecretPathCounts(folder.secretCount, folder.folderCount)}
</div>
</div>
<div role="cell" aria-hidden="true" />
<div role="cell" aria-hidden="true" />
<div role="cell" aria-hidden="true" />
<div role="cell" className="flex justify-end">
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</Link>
);
}
function renderFolderCard(folder: SecretPathFolder) {
return (
<Link
key={`folder:${folder.path}`}
to={folderLinkTo(folder.path)}
className="flex items-center justify-between gap-2 rounded-md border border-border bg-background p-3 hover:bg-accent/30"
>
<div className="flex min-w-0 items-center gap-2">
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="truncate font-medium text-foreground">{folder.name}</div>
<div className="text-xs text-muted-foreground">
{formatSecretPathCounts(folder.secretCount, folder.folderCount)}
</div>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</Link>
);
}
function renderUpRow(variant: "table" | "card") {
const parentLabel = parentFolderPath ? parentFolderPath.split("/").pop()! : "All secrets";
return (
<Link
to={folderLinkTo(parentFolderPath)}
role={variant === "table" ? "row" : undefined}
className={cn(
"flex items-center gap-2 text-xs text-muted-foreground hover:bg-accent/40",
variant === "table"
? "border-b border-border/60 px-3 py-2.5"
: "rounded-md border border-border bg-background px-3 py-2.5",
)}
>
<CornerLeftUp className="h-4 w-4 shrink-0" />
<span className="truncate">Up to {parentLabel}</span>
</Link>
);
}
function renderSecretsBreadcrumb() {
const currentName = breadcrumbs.length > 0 ? breadcrumbs[breadcrumbs.length - 1].name : "All secrets";
const parentLabel = parentFolderPath ? parentFolderPath.split("/").pop()! : "All secrets";
const fullTrail: { name: string; path: string }[] = [
{ name: "All secrets", path: "" },
...breadcrumbs,
];
// Middle-truncate deep paths: root · … · last two.
const collapsed =
fullTrail.length > 4
? [fullTrail[0], { name: "…", path: "" }, ...fullTrail.slice(-2)]
: fullTrail;
return (
<nav aria-label="Breadcrumb" className="min-w-0">
{/* Wide: full trail */}
<ol className="hidden min-w-0 items-center gap-1 text-sm @min-[40rem]:flex">
{collapsed.map((crumb, index) => {
const isLast = index === collapsed.length - 1;
const isEllipsis = crumb.name === "…" && crumb.path === "" && index > 0 && !isLast;
return (
<li key={`${crumb.path}:${index}`} className="flex min-w-0 items-center gap-1">
{index > 0 ? <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" /> : null}
{isEllipsis ? (
<span className="px-0.5 text-muted-foreground">…</span>
) : isLast ? (
<span aria-current="page" className="truncate font-medium text-foreground">
{crumb.name}
</span>
) : (
<Link
to={folderLinkTo(crumb.path)}
className="max-w-40 truncate text-muted-foreground hover:text-foreground hover:underline"
>
{crumb.name}
</Link>
)}
</li>
);
})}
</ol>
{/* Narrow: back-chevron + parent/current */}
<div className="flex min-w-0 items-center gap-1.5 text-sm @min-[40rem]:hidden">
{folderPath ? (
<>
<Link
to={folderLinkTo(parentFolderPath)}
aria-label="Up one folder"
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" />
</Link>
{parentLabel !== "All secrets" ? (
<span className="shrink-0 text-muted-foreground">{parentLabel} /</span>
) : null}
<span aria-current="page" className="truncate font-medium text-foreground">
{currentName}
</span>
</>
) : (
<span aria-current="page" className="truncate font-medium text-foreground">
All secrets
</span>
)}
</div>
</nav>
);
}
if (!selectedCompanyId) {
return (
<div className="p-6 text-sm text-muted-foreground">Select an organization to manage secrets.</div>
);
}
return (
<TooltipProvider>
<div className="flex max-w-6xl flex-col gap-4">
<div className="flex items-center gap-2">
<KeyRound className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Secrets</h1>
</div>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as SecretsTab)}
className="flex flex-col gap-4"
>
<PageTabBar
items={[
{ value: "secrets", label: "Secrets" },
{ value: "my-secrets", label: "My secrets" },
...(hideVaultsTab ? [] : [{ value: "vaults", label: "Provider vaults" }]),
...(hideProposalsTab
? []
: [
{
value: "proposals",
label: (
<span className="inline-flex items-center gap-1.5">
Proposals
{pendingProposalCount > 0 ? (
<Badge
variant="outline"
className="h-4 min-w-4 justify-center rounded-full border-amber-500/40 bg-amber-500/10 px-1 text-(length:--text-nano) font-medium text-amber-700 dark:text-amber-300"
>
{pendingProposalCount}
</Badge>
) : null}
</span>
),
},
]),
]}
align="start"
value={activeTab}
onValueChange={(value) => setActiveTab(value as SecretsTab)}
/>
<TabsContent value="secrets" className="flex flex-col gap-3">
<SecretsHowToUse />
<div data-canonical-toolbar="secrets" role="toolbar" aria-label="Secrets controls" className="flex flex-wrap items-center gap-2">
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search by name, key, ref"
className="pl-7 text-xs sm:text-sm"
aria-label="Search secrets"
data-page-search-target="true"
/>
</div>
<SecretsFiltersPopover
statusFilter={statusFilter}
providerFilter={providerFilter}
providedByFilter={providedByFilter}
providers={providers}
activeFilterCount={activeSecretFilterCount}
onStatusChange={setStatusFilter}
onProviderChange={setProviderFilter}
onProvidedByChange={setProvidedByFilter}
/>
<div
role="group"
aria-label="View mode"
className={cn(
"design-view-toggle inline-flex items-center rounded-md border border-border",
searching && "opacity-50",
)}
>
{(["folders", "flat"] as const).map((mode) => (
<button
key={mode}
type="button"
aria-pressed={effectiveViewMode === mode}
disabled={searching}
onClick={() => setViewMode(mode)}
className={cn(
"rounded-sm px-2.5 py-1 text-xs font-medium capitalize transition-colors disabled:cursor-not-allowed",
effectiveViewMode === mode
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{mode}
</button>
))}
</div>
<ImportFromVaultButton
providerConfigs={providerConfigs}
onClick={() => openImportFromVault()}
onManageVaults={hideVaultsTab ? undefined : () => setActiveTab("vaults")}
className="ml-auto"
/>
{showFolderView ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setNewFolderOpen(true);
setNewFolderError(null);
}}
>
<Folder className="mr-1 h-3.5 w-3.5" /> New folder
</Button>
) : null}
<Button data-toolbar-primary onClick={openCreateSecret} size="sm">
<Plus className="h-3.5 w-3.5 mr-1" /> New secret
</Button>
</div>
{newFolderOpen && showFolderView ? (
<div className="flex flex-wrap items-start gap-2" role="group" aria-label="Create folder">
<div className="min-w-48 flex-1 sm:max-w-80">
<Input
value={newFolderName}
onChange={(event) => {
setNewFolderName(event.target.value);
if (newFolderError) setNewFolderError(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") stageNewFolder();
if (event.key === "Escape") closeNewFolder();
}}
placeholder="Folder name"
aria-label="Folder name"
aria-invalid={Boolean(newFolderError)}
autoFocus
/>
{newFolderError ? (
<p className="mt-1 text-xs text-destructive" role="alert">
{newFolderError}
</p>
) : null}
</div>
<Button type="button" size="sm" onClick={stageNewFolder}>
Create folder
</Button>
<Button type="button" variant="ghost" size="sm" onClick={closeNewFolder}>
Cancel
</Button>
</div>
) : null}
<div>
{secretsView.kind === "error" || userDefinitionsView.kind === "error" ? (
<div className="py-4">
<QueryErrorState
error={secretsView.kind === "error" ? secretsQuery.error : userDefinitionsQuery.error}
action="load secrets"
onRetry={() => {
secretsView.retry();
userDefinitionsView.retry();
}}
retrying={secretsView.isFetching || userDefinitionsView.isFetching}
/>
</div>
) : unifiedRows.length === 0 &&
!secretsListLoading &&
!(showFolderView && folderPath) ? (
<EmptyState
icon={KeyRound}
message="No secrets yet. Create a shared organization secret or one that each user supplies."
action="New secret"
onAction={openCreateSecret}
/>
) : (
<div className="@container min-w-0 overflow-x-hidden text-sm" data-testid="secrets-list-container">
{showFolderView ? (
<div className="mb-3 flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
{renderSecretsBreadcrumb()}
<span className="shrink-0 text-xs text-muted-foreground">
{formatSecretPathCounts(currentFolderSecretCount, folderListing.folders.length)}
</span>
</div>
) : searching ? (
<div className="mb-3">
<div className="text-sm font-medium text-foreground">Search results</div>
<div className="text-xs text-muted-foreground">
{filteredRows.length} {filteredRows.length === 1 ? "match" : "matches"} across all
folders{folderPath ? ` · searching everywhere, not just ${folderPath}` : ""}
</div>
</div>
) : null}
{folderRows.length === 0 && secretRows.length === 0 ? (
secretsListLoading ? (
<div className="space-y-2 py-2" aria-hidden="true" data-testid="secrets-loading-skeleton">
{[0, 1, 2, 3].map((index) => (
<div key={index} className="h-14 animate-pulse rounded-md bg-muted/40" />
))}
</div>
) : showFolderView && folderPath && activeSecretFilterCount === 0 ? (
<EmptyState
icon={FolderOpen}
message="No secrets in this folder yet."
action="New secret here"
onAction={openCreateSecret}
/>
) : (
<EmptyState
icon={Search}
message={searching ? "No secrets match your search." : "No secrets match your filters."}
/>
)
) : (
<>
<div
role="table"
aria-label="Secrets"
className="hidden min-w-0 @min-[40rem]:block"
data-testid="secrets-table-view"
>
<div
role="row"
className="grid grid-cols-(--gtc-54) items-center gap-3 bg-muted/40 px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground"
>
<div role="columnheader" className="font-medium">Secret</div>
<div role="columnheader" className="font-medium">Status</div>
<div role="columnheader" className="font-medium">Version / coverage</div>
<div role="columnheader" className="font-medium">Updated</div>
<div role="columnheader" className="sr-only">Actions</div>
</div>
<div role="rowgroup">
{showUpRow ? renderUpRow("table") : null}
{folderRows.map(renderFolderTableRow)}
{secretRows.map((row) => {
const status = row.kind === "company" ? row.secret.status : row.definition.status;
const updatedAt = row.kind === "company" ? row.secret.updatedAt : row.definition.updatedAt;
const updatedTooltip =
row.kind === "company"
? [
`Updated: ${formatRelative(row.secret.updatedAt)}`,
`Last rotated: ${formatRelative(row.secret.lastRotatedAt)}`,
`Last resolved: ${formatRelative(row.secret.lastResolvedAt)}`,
].join("\n")
: `Updated: ${formatRelative(row.definition.updatedAt)}\nLast resolved: user values resolve per member`;
return (
<div
key={row.id}
role="row"
className={cn(
"grid cursor-pointer grid-cols-(--gtc-54) items-center gap-3 border-b border-border/60 px-3 py-3 hover:bg-accent/40",
row.kind === "company" && selectedSecretId === row.secret.id && "bg-accent/60",
row.kind === "user" && selectedDefinitionId === row.definition.id && "bg-accent/60",
)}
onClick={() => {
if (row.kind === "company") openCompanySecret(row.secret);
else openUserDefinition(row.definition);
}}
>
<div role="cell" className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
{renderSecretName(row.kind === "company" ? row.secret.name : row.definition.name)}
{row.kind === "company" ? (
<SecretProviderIndicator
secret={row.secret}
providers={providers}
providerConfigs={providerConfigs}
/>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label="Each user provides and owns their own value"
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-violet-500/30 bg-violet-500/5 text-violet-700 dark:text-violet-200"
>
<UserRound className="h-3 w-3" />
</span>
</TooltipTrigger>
<TooltipContent>Each user provides and owns their own value</TooltipContent>
</Tooltip>
)}
</div>
<code className="mt-0.5 block truncate text-(length:--text-micro) text-muted-foreground">
{row.kind === "company" ? row.secret.key : row.definition.key}
</code>
<div className="mt-1">
{row.kind === "company" ? (
<MetaChip>
<ShieldCheck className="h-3 w-3" /> Organization
</MetaChip>
) : (
<UserSecretChip label="Each user" />
)}
</div>
</div>
<div role="cell">
<StatusBadge status={status} />
</div>
<div role="cell" className="min-w-0 text-xs">
{row.kind === "company" ? (
<span className="truncate text-muted-foreground">
<span className="font-mono text-foreground">v{row.secret.latestVersion}</span>
<span> · {row.secret.managedMode === "external_reference" ? "linked" : "managed"}</span>
</span>
) : (
<CoverageInline companyId={selectedCompanyId} definitionId={row.definition.id} compact />
)}
</div>
<div role="cell">
<UpdatedWithTooltip updatedAt={updatedAt} tooltip={updatedTooltip} />
</div>
<div role="cell" className="text-right" onClick={(event) => event.stopPropagation()}>
{renderRowActions(row)}
</div>
</div>
);
})}
</div>
</div>
<div className="space-y-2 @min-[40rem]:hidden" data-testid="secrets-card-view">
{showUpRow ? renderUpRow("card") : null}
{folderRows.map(renderFolderCard)}
{secretRows.map((row) => {
const status = row.kind === "company" ? row.secret.status : row.definition.status;
return (
<div
key={row.id}
className={cn(
"cursor-pointer rounded-md border border-border bg-background p-3 hover:bg-accent/30",
row.kind === "company" && selectedSecretId === row.secret.id && "bg-accent/60",
row.kind === "user" && selectedDefinitionId === row.definition.id && "bg-accent/60",
)}
onClick={() => {
if (row.kind === "company") openCompanySecret(row.secret);
else openUserDefinition(row.definition);
}}
>
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="min-w-0 truncate">
{renderSecretName(row.kind === "company" ? row.secret.name : row.definition.name)}
</div>
<code className="mt-0.5 block truncate text-(length:--text-micro) text-muted-foreground">
{row.kind === "company" ? row.secret.key : row.definition.key}
</code>
</div>
<div onClick={(event) => event.stopPropagation()}>{renderRowActions(row)}</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{row.kind === "company" ? (
<>
<MetaChip>
<ShieldCheck className="h-3 w-3" /> Organization
</MetaChip>
<SecretProviderIndicator
secret={row.secret}
providers={providers}
providerConfigs={providerConfigs}
/>
<StatusBadge status={status} />
</>
) : (
<>
<UserSecretChip label="Each user" />
<StatusBadge status={status} />
<CoverageInline companyId={selectedCompanyId} definitionId={row.definition.id} compact />
</>
)}
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="min-w-0 truncate">
{row.kind === "company" ? (
<>
v{row.secret.latestVersion} ·{" "}
{row.secret.managedMode === "external_reference" ? "linked" : "managed"}
</>
) : (
"Member-owned values"
)}
</span>
<span>Updated {formatRelative(row.kind === "company" ? row.secret.updatedAt : row.definition.updatedAt)}</span>
</div>
</div>
);
})}
</div>
</>
)}
</div>
)}
</div>
</TabsContent>
<TabsContent
value="my-secrets"
className="flex flex-col gap-3"
>
<MyUserSecretsTab companyId={selectedCompanyId} />
</TabsContent>
{!hideVaultsTab && (
<TabsContent value="vaults">
<ProviderVaultsTab
providers={providers}
providerConfigs={providerConfigs}
loading={providerConfigsQuery.isPending || providerConfigsView.kind === "reconnecting"}
error={providerConfigsView.kind === "error" ? providerConfigsQuery.error : null}
onRetry={providerConfigsView.retry}
retrying={providerConfigsView.isFetching}
onCreate={openCreateVault}
onEdit={openEditVault}
onDisable={(config) => disableVaultMutation.mutate(config.id)}
onRemove={(config) => setRemoveVaultConfirm(config)}
onSetDefault={(config) => defaultVaultMutation.mutate(config.id)}
onHealthCheck={(config) => healthVaultMutation.mutate(config.id)}
onImportSecrets={openImportFromVault}
pendingActionId={
disableVaultMutation.variables ??
removeVaultMutation.variables ??
defaultVaultMutation.variables ??
healthVaultMutation.variables ??
null
}
/>
</TabsContent>
)}
{!hideProposalsTab && (
<TabsContent value="proposals">
{selectedCompanyId ? (
<ProposalsTab companyId={selectedCompanyId} providerConfigs={providerConfigs} />
) : null}
</TabsContent>
)}
</Tabs>
<Sheet
open={Boolean(selectedSecret || selectedDefinition)}
onOpenChange={(open) => {
if (!open && (selectedSecret || selectedDefinition)) setDetailSelection(null);
}}
>
<SheetContent className="w-full sm:max-w-xl flex flex-col gap-0">
{selectedSecret ? (
<>
<SheetHeader className="space-y-3">
<SheetTitle className="flex min-w-0 items-center gap-2 pr-8 text-base">
<KeyRound className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{selectedSecret.name}</span>
<span className="shrink-0">
<StatusBadge status={selectedSecret.status} />
</span>
</SheetTitle>
<SheetDescription className="sr-only">
{providerLabel(providers, selectedSecret.provider)} secret {selectedSecret.key}
</SheetDescription>
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border bg-muted/20 px-2 py-1.5">
<code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
{selectedSecret.key}
</code>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2 text-xs"
onClick={() => copySecretKey(selectedSecret.key)}
>
<Copy className="mr-1 h-3.5 w-3.5" /> Copy
</Button>
</div>
<div className="flex flex-wrap gap-1.5">
<MetaChip>
<ShieldCheck className="h-3 w-3" /> Organization
</MetaChip>
<MetaChip>{modeLabel(selectedSecret.managedMode)}</MetaChip>
<MetaChip>{providerLabel(providers, selectedSecret.provider)}</MetaChip>
<MetaChip>v{selectedSecret.latestVersion}</MetaChip>
</div>
</SheetHeader>
<div className="flex items-center gap-2 px-4 pb-2">
<Button
size="sm"
onClick={() => openRotateSecret(selectedSecret)}
>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
{rotateActionLabel(selectedSecret)}
</Button>
<Button variant="outline" size="sm" onClick={copyDetailLink}>
<Link2 className="h-3.5 w-3.5 mr-1" /> Copy link
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" aria-label={`More actions for ${selectedSecret.name}`}>
<MoreHorizontal className="mr-1 h-3.5 w-3.5" /> More
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
disabled={statusMutation.isPending}
onSelect={() =>
statusMutation.mutate({
id: selectedSecret.id,
status: selectedSecret.status === "active" ? "disabled" : "active",
})
}
>
{selectedSecret.status === "active" ? (
<Ban className="h-4 w-4" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
{selectedSecret.status === "active" ? "Disable" : "Activate"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={statusMutation.isPending}
onSelect={() =>
statusMutation.mutate({
id: selectedSecret.id,
status: selectedSecret.status === "archived" ? "active" : "archived",
})
}
>
{selectedSecret.status === "archived" ? (
<ArchiveRestore className="h-4 w-4" />
) : (
<Archive className="h-4 w-4" />
)}
{selectedSecret.status === "archived" ? "Unarchive" : "Archive"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => setDeleteConfirm(selectedSecret)}>
<Trash2 className="h-4 w-4" /> Delete secret
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Tabs value={secretDetailTab} onValueChange={setSecretDetailTab} className="flex-1 min-h-0 flex flex-col">
<div className="border-b border-border px-4">
<PageTabBar
items={[
{ value: "details", label: "Details" },
{ value: "usage", label: usageQuery.data ? `Usage (${usageQuery.data.bindings.length})` : "Usage" },
{ value: "events", label: "Access events" },
]}
align="start"
value={secretDetailTab}
onValueChange={setSecretDetailTab}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
<TabsContent value="details">
<div className="space-y-3">
<AgentAccessSection
companyId={selectedCompanyId}
reference={selectedSecretAccessReference!}
/>
<SecretDetailsTab
secret={selectedSecret}
providers={providers}
providerConfigs={providerConfigs}
onViewUsage={() => setSecretDetailTab("usage")}
/>
</div>
</TabsContent>
<TabsContent value="usage">
<SecretUsageTab loading={usageQuery.isPending} bindings={usageQuery.data?.bindings ?? []} />
</TabsContent>
<TabsContent value="events">
<SecretEventsTab
loading={eventsQuery.isPending}
events={eventsQuery.data ?? []}
companyId={selectedCompanyId}
/>
</TabsContent>
</div>
</Tabs>
</>
) : selectedDefinition ? (
<>
<SheetHeader className="space-y-3">
<SheetTitle className="flex min-w-0 items-center gap-2 pr-8 text-base">
<UserRound className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{selectedDefinition.name}</span>
<span className="shrink-0">
<StatusBadge status={selectedDefinition.status} />
</span>
</SheetTitle>
<SheetDescription className="sr-only">
Each user secret definition {selectedDefinition.key}
</SheetDescription>
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border bg-muted/20 px-2 py-1.5">
<code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
{selectedDefinition.key}
</code>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2 text-xs"
onClick={() => copySecretKey(selectedDefinition.key)}
>
<Copy className="mr-1 h-3.5 w-3.5" /> Copy
</Button>
</div>
<div className="flex flex-wrap gap-1.5">
<UserSecretChip label="Each user" />
<MetaChip>
<CoverageInline companyId={selectedCompanyId} definitionId={selectedDefinition.id} compact />
</MetaChip>
</div>
</SheetHeader>
<div className="flex items-center gap-2 px-4 pb-2">
<Button
size="sm"
onClick={() =>
setSetMyValueFor(
selectedDefinitionMyEntry ?? { definition: selectedDefinition, secret: null },
)
}
disabled={selectedDefinition.status !== "active"}
>
<KeyRound className="h-3.5 w-3.5 mr-1" />
{selectedDefinitionMyEntry?.secret ? "Update my value" : "Set my value"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" aria-label={`More actions for ${selectedDefinition.name}`}>
<MoreHorizontal className="mr-1 h-3.5 w-3.5" /> More
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onSelect={() => openEditDefinition(selectedDefinition)}>
<Pencil className="h-4 w-4" /> Edit definition
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={definitionStatusMutation.isPending}
onSelect={() =>
definitionStatusMutation.mutate({
definition: selectedDefinition,
status: selectedDefinition.status === "active" ? "disabled" : "active",
})
}
>
{selectedDefinition.status === "active" ? (
<Ban className="h-4 w-4" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
{selectedDefinition.status === "active" ? "Disable" : "Activate"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={definitionStatusMutation.isPending}
onSelect={() =>
definitionStatusMutation.mutate({
definition: selectedDefinition,
status: selectedDefinition.status === "archived" ? "active" : "archived",
})
}
>
{selectedDefinition.status === "archived" ? (
<ArchiveRestore className="h-4 w-4" />
) : (
<Archive className="h-4 w-4" />
)}
{selectedDefinition.status === "archived" ? "Unarchive" : "Archive"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => setDefinitionDeleteConfirm(selectedDefinition)}>
<Trash2 className="h-4 w-4" /> Delete definition
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Tabs value={secretDetailTab} onValueChange={setSecretDetailTab} className="flex-1 min-h-0 flex flex-col">
<div className="border-b border-border px-4">
<PageTabBar
items={[
{ value: "details", label: "Details" },
{ value: "coverage", label: "Coverage" },
{ value: "usage", label: "Usage" },
{ value: "events", label: "Access events" },
]}
align="start"
value={secretDetailTab}
onValueChange={setSecretDetailTab}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
<TabsContent value="details">
<div className="space-y-3">
<AgentAccessSection
companyId={selectedCompanyId}
reference={selectedDefinitionAccessReference!}
/>
<UserSecretDetailsTab
companyId={selectedCompanyId}
definition={selectedDefinition}
onViewCoverage={() => setSecretDetailTab("coverage")}
/>
</div>
</TabsContent>
<TabsContent value="coverage">
<UserSecretCoverageTab
companyId={selectedCompanyId}
definitionId={selectedDefinition.id}
/>
</TabsContent>
<TabsContent value="usage">
<UserSecretUsageTab definition={selectedDefinition} />
</TabsContent>
<TabsContent value="events">
<UserSecretAccessEventsTab />
</TabsContent>
</div>
</Tabs>
</>
) : null}
</SheetContent>
</Sheet>
<Dialog
open={Boolean(usageDialogSecret)}
onOpenChange={(open) => !open && setUsageDialogSecretId(null)}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Secret references</DialogTitle>
<DialogDescription>
{usageDialogSecret
? `${usageDialogSecret.name} is referenced by ${usageDialogSecret.referenceCount ?? 0} ${
(usageDialogSecret.referenceCount ?? 0) === 1 ? "place" : "places"
}.`
: null}
</DialogDescription>
</DialogHeader>
<SecretUsageTab
loading={usageDialogQuery.isPending}
bindings={usageDialogQuery.data?.bindings ?? []}
/>
</DialogContent>
</Dialog>
{selectedCompanyId && (
<ImportFromVaultDialog
open={importOpen}
onOpenChange={(open) => {
setImportOpen(open);
if (!open) setImportInitialVaultId(null);
}}
companyId={selectedCompanyId}
providerConfigs={providerConfigs}
existingSecrets={secrets}
initialProviderConfigId={importInitialVaultId}
onManageVaults={
hideVaultsTab
? undefined
: () => {
setImportOpen(false);
setImportInitialVaultId(null);
setActiveTab("vaults");
}
}
onImportComplete={() => {
void secretsQuery.refetch();
}}
/>
)}
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) setCreateNamePrefix(null);
}}
>
<DialogContent className="max-h-(--sz-calc-18) overflow-y-auto p-4 sm:max-w-lg sm:p-6">
<DialogHeader>
<DialogTitle>{editingDefinition ? "Edit user-provided secret" : "Create secret"}</DialogTitle>
<DialogDescription>
Choose who provides the value. Shared fields keep their values when you switch modes.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{!editingDefinition ? (
<div className="space-y-1.5">
<p className="text-xs font-medium text-foreground">Who provides the value?</p>
<Tabs
value={secretValueProvider}
onValueChange={(value) => {
const next = value as SecretValueProvider;
setSecretValueProvider(next);
setCreateKeyEditable(false);
setCreateForm((current) => ({
...current,
key: createKeyDirty
? current.key
: next === "user"
? normalizeUserSecretKeyForPreview(current.name)
: normalizeSecretKeyForPreview(current.name),
}));
}}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="company">Organization</TabsTrigger>
<TabsTrigger value="user">Each user</TabsTrigger>
</TabsList>
</Tabs>
<p className="text-(length:--text-micro) text-muted-foreground">
Organization stores one shared value. Each user lets every member supply their own value under My secrets.
</p>
</div>
) : null}
{secretValueProvider === "company" && !editingDefinition ? (
<Tabs value={createMode} onValueChange={(value) => setCreateMode(value as CreateMode)}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="managed">Managed value</TabsTrigger>
<TabsTrigger value="external">External reference</TabsTrigger>
</TabsList>
</Tabs>
) : null}
<div>
<label className="text-xs font-medium" htmlFor="new-secret-name">Name</label>
{createNamePrefix && !editingDefinition ? (
<div className="flex h-9 w-full min-w-0 items-center gap-1.5 rounded-md border border-input bg-transparent px-2 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3">
<span
className="inline-flex min-w-0 shrink items-center gap-1 rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground"
title={createNamePrefix}
>
<span className="truncate">{createNamePrefix}</span>
<button
type="button"
className="shrink-0 rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Remove folder prefix"
onClick={() => setCreateNamePrefix(null)}
>
<X className="h-3 w-3" />
</button>
</span>
<input
id="new-secret-name"
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
value={createForm.name.slice(createNamePrefix.length)}
onChange={(event) => {
const name = createNamePrefix + event.target.value;
setCreateForm((current) => ({
...current,
name,
key: createKeyDirty
? current.key
: secretValueProvider === "user"
? normalizeUserSecretKeyForPreview(name)
: normalizeSecretKeyForPreview(name),
}));
}}
placeholder="clientsecret"
autoFocus
/>
</div>
) : (
<Input
id="new-secret-name"
value={createForm.name}
onChange={(event) => {
const name = event.target.value;
setCreateForm((current) => ({
...current,
name,
key: createKeyDirty
? current.key
: secretValueProvider === "user"
? normalizeUserSecretKeyForPreview(name)
: normalizeSecretKeyForPreview(name),
}));
}}
placeholder={secretValueProvider === "user" ? "Personal GitHub token" : "/dev/foo/bar"}
autoFocus
/>
)}
{createNamePrefix && !editingDefinition ? (
<p className="mt-1 text-xs text-muted-foreground">
Creating in {folderPath} — remove the chip to type a different path.
</p>
) : null}
</div>
{secretValueProvider === "company" && createMode === "managed" ? (
<div>
<label className="text-xs font-medium" htmlFor="new-secret-value">Value</label>
<Textarea
id="new-secret-value"
value={createForm.value}
onChange={(event) =>
setCreateForm((current) => ({ ...current, value: event.target.value }))
}
rows={3}
className="min-w-0 overflow-x-hidden break-all font-mono text-xs"
placeholder="Stored once, never re-displayed"
/>
</div>
) : null}
{secretValueProvider === "company" && createMode === "external" ? (
<div>
<label className="text-xs font-medium" htmlFor="new-secret-ref">External reference</label>
<Input
id="new-secret-ref"
value={createForm.externalRef}
onChange={(event) =>
setCreateForm((current) => ({ ...current, externalRef: event.target.value }))
}
placeholder="arn:aws:secretsmanager:..."
className="font-mono text-xs"
/>
<p className="text-(length:--text-micro) text-muted-foreground mt-1">
Existing provider secrets are resolve-only in Paperclip. Rotate the value in the provider,
then update this reference only if the path, ARN, or version changes.
</p>
</div>
) : null}
{secretValueProvider === "user" ? (
<>
<div className="rounded-md border border-violet-500/30 bg-violet-500/5 p-2 text-(length:--text-micro) text-violet-800 dark:text-violet-200">
Every member supplies their own value under My secrets. Agents resolve the responsible
user&apos;s value at runtime.
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-foreground" htmlFor="new-secret-usage-guidance">
Usage guidance <span className="text-muted-foreground/70">(optional)</span>
</label>
<Textarea
id="new-secret-usage-guidance"
value={createForm.usageGuidance}
onChange={(event) =>
setCreateForm((current) => ({ ...current, usageGuidance: event.target.value }))
}
placeholder="Tell members how to create their token, required scopes, etc."
className="min-h-(--sz-70px) text-sm"
/>
</div>
</>
) : null}
<div>
<div className="flex items-center justify-between">
<label className="text-xs font-medium" htmlFor="new-secret-key">Key</label>
{!createKeyEditable && !editingDefinition ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 px-1.5 text-(length:--text-micro) text-muted-foreground"
onClick={() => setCreateKeyEditable(true)}
>
<Pencil className="mr-1 h-3 w-3" /> Edit
</Button>
) : null}
</div>
<Input
id="new-secret-key"
value={createForm.key}
readOnly={!createKeyEditable}
tabIndex={createKeyEditable && !editingDefinition ? undefined : -1}
onChange={(event) => {
if (!createKeyEditable || editingDefinition) return;
setCreateKeyDirty(true);
setCreateForm((current) => ({ ...current, key: event.target.value }));
}}
placeholder={secretValueProvider === "user" ? "PERSONAL_GH_TOKEN" : "auto from name"}
disabled={Boolean(editingDefinition)}
className={cn(
"font-mono text-sm",
!createKeyEditable && !editingDefinition && "border-dashed bg-muted/40 text-muted-foreground",
)}
/>
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">
{editingDefinition
? "Stable env binding key. Cannot be changed."
: !createKeyEditable
? "Generated from the name."
: secretValueProvider === "user"
? "Env-style key used by user-secret bindings."
: "Shared secret keys keep lowercase dash normalization."}
</p>
</div>
<div>
<label className="text-xs font-medium" htmlFor="new-secret-description">
Description <span className="text-muted-foreground/70">(optional)</span>
</label>
<Input
id="new-secret-description"
value={createForm.description}
onChange={(event) =>
setCreateForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="What is this secret used for? (no values)"
/>
</div>
{secretValueProvider === "company" ? (
<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="text-xs font-medium" htmlFor="new-secret-provider">Provider</label>
<select
id="new-secret-provider"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm outline-none"
value={createForm.provider}
onChange={(event) =>
setCreateForm((current) => {
const provider = event.target.value as SecretProvider;
return {
...current,
provider,
providerConfigId: getDefaultProviderConfigId(providerConfigs, provider),
};
})
}
>
{providers.map((provider) => (
<option
key={provider.id}
value={provider.id}
disabled={Boolean(
getCreateProviderBlockReason(
provider,
createMode,
providerHealthQuery.data ?? null,
getSelectableProviderConfig(providerConfigs, provider.id),
),
)}
>
{provider.label}
{provider.configured === false &&
!getSelectableProviderConfig(providerConfigs, provider.id)
? " (deployment default missing)"
: provider.requiresExternalRef
? " (external only)"
: ""}
</option>
))}
</select>
{createProviderBlockReason ? (
<p className="mt-1 flex items-center gap-1 text-(length:--text-micro) text-destructive">
<AlertCircle className="h-3 w-3" />
{createProviderBlockReason}
</p>
) : createProviderHealthText ? (
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">{createProviderHealthText}</p>
) : null}
</div>
<div>
<label className="text-xs font-medium" htmlFor="new-secret-vault">Provider vault</label>
<select
id="new-secret-vault"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm outline-none"
value={createForm.providerConfigId}
onChange={(event) =>
setCreateForm((current) => ({ ...current, providerConfigId: event.target.value }))
}
>
<option value="">Deployment default</option>
{createProviderConfigs.map((config) => {
const blockReason = getProviderConfigBlockReason(config);
return (
<option key={config.id} value={config.id} disabled={Boolean(blockReason)}>
{config.displayName}
{config.isDefault ? " (default)" : ""}
{blockReason ? ` (${blockReason})` : ""}
</option>
);
})}
</select>
{selectedCreateProviderConfig ? (
<ProviderVaultInlineWarning config={selectedCreateProviderConfig} />
) : null}
</div>
</div>
{createMode === "managed" ? (
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-2 text-(length:--text-micro) text-emerald-700 dark:text-emerald-300">
Paperclip-managed secrets are created in the selected provider and future rotations
write a new provider version through Paperclip.
{awsManagedPathPreview ? (
<div className="mt-1">
AWS managed path:{" "}
<code className="break-all rounded bg-background/70 px-1 py-0.5">
{awsManagedPathPreview}
</code>
</div>
) : null}
</div>
) : null}
</>
) : null}
{createError ? (
<SecretCreateError
error={createError}
provider={createForm.provider}
providerConfigId={createForm.providerConfigId || null}
/>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
setCreateError(null);
createMutation.mutate();
}}
disabled={
createMutation.isPending ||
!createForm.name.trim() ||
(secretValueProvider === "user"
? !createForm.key.trim()
: Boolean(createProviderBlockReason) ||
(createMode === "managed" ? !createForm.value : !createForm.externalRef.trim()))
}
>
{createMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
{editingDefinition
? "Save changes"
: secretValueProvider === "user"
? "Create user-provided secret"
: createMode === "managed"
? "Create secret"
: "Link reference"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={vaultDialogOpen} onOpenChange={setVaultDialogOpen}>
<DialogContent className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingVault ? "Edit provider vault" : "Create provider vault"}</DialogTitle>
<DialogDescription>
Save only non-sensitive routing metadata. Credentials stay in the runtime environment or provider identity.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="text-xs font-medium" htmlFor="vault-provider">Provider</label>
<select
id="vault-provider"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm outline-none disabled:opacity-60"
value={vaultForm.provider}
disabled={Boolean(editingVault)}
onChange={(event) => {
const provider = event.target.value as SecretProvider;
setVaultForm(emptyProviderVaultForm(provider));
setVaultDiscovery(null);
setVaultDiscoveryError(null);
}}
>
{PROVIDER_ORDER.map((provider) => (
<option key={provider} value={provider}>
{providerLabel(providers, provider)}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium" htmlFor="vault-name">Display name</label>
<Input
id="vault-name"
value={vaultForm.displayName}
onChange={(event) =>
setVaultForm((current) => ({ ...current, displayName: event.target.value }))
}
placeholder="Production local vault"
/>
</div>
<div>
<label className="text-xs font-medium" htmlFor="vault-status">Status</label>
<select
id="vault-status"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm outline-none"
value={vaultForm.status}
onChange={(event) => {
const status = event.target.value as SecretProviderConfigStatus;
setVaultForm((current) => ({
...current,
status,
isDefault:
status === "coming_soon" || status === "disabled" ? false : current.isDefault,
}));
}}
>
<option value="ready" disabled={vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault"}>
Ready
</option>
<option value="warning" disabled={vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault"}>
Warning
</option>
<option value="coming_soon">Coming soon</option>
<option value="disabled">Disabled</option>
</select>
</div>
<label className="flex items-center gap-2 pt-6 text-sm">
<input
type="checkbox"
className="h-4 w-4 rounded border-border"
checked={vaultForm.isDefault}
disabled={vaultForm.status === "coming_soon" || vaultForm.status === "disabled"}
onChange={(event) =>
setVaultForm((current) => ({ ...current, isDefault: event.target.checked }))
}
/>
Default for {providerLabel(providers, vaultForm.provider)}
</label>
</div>
<ProviderVaultFields form={vaultForm} onChange={setVaultForm} />
{!editingVault && vaultForm.provider === "aws_secrets_manager" ? (
<AwsProviderVaultDiscoveryPanel
form={vaultForm}
preview={vaultDiscovery}
error={vaultDiscoveryError}
loading={discoverVaultMutation.isPending}
onDiscover={() => {
setVaultDiscovery(null);
setVaultDiscoveryError(null);
discoverVaultMutation.mutate();
}}
onApply={applyVaultDiscoveryCandidate}
/>
) : null}
{vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault" ? (
<div className="rounded-md border border-sky-500/30 bg-sky-500/5 p-3 text-xs text-sky-700 dark:text-sky-300">
This provider can save draft routing metadata, but runtime writes and resolution stay disabled until
the provider module is implemented and reviewed.
</div>
) : null}
{vaultError ? <p className="text-xs text-destructive">{vaultError}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setVaultDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
setVaultError(null);
saveVaultMutation.mutate();
}}
disabled={
saveVaultMutation.isPending ||
!vaultForm.displayName.trim() ||
(vaultForm.provider === "aws_secrets_manager" && !vaultForm.region.trim())
}
>
{saveVaultMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
{editingVault ? "Save vault" : "Create vault"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={rotateOpen} onOpenChange={setRotateOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{selectedSecret?.managedMode === "external_reference" && rotateMode === "reference"
? "Update external reference"
: "Update secret value"}
</DialogTitle>
<DialogDescription>
{selectedSecret?.managedMode !== "external_reference"
? "Creates a new provider-backed version. Consumers pinned to latest pick up the new value on the next run."
: rotateMode === "reference"
? "Creates a new Paperclip metadata version that points at an existing provider secret. Paperclip does not write a new provider value."
: "Writes a new version of the referenced provider secret. The new value becomes current for every consumer of that secret, in and outside Paperclip."}
</DialogDescription>
</DialogHeader>
{selectedSecret && secretSupportsExternalValueWrite(selectedSecret) ? (
<Tabs value={rotateMode} onValueChange={(value) => setRotateMode(value as RotateMode)}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="value">Write new value</TabsTrigger>
<TabsTrigger value="reference">Change reference</TabsTrigger>
</TabsList>
</Tabs>
) : null}
<div>
<label className="text-xs font-medium" htmlFor="rotate-secret-vault">Provider vault</label>
<select
id="rotate-secret-vault"
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm outline-none"
value={rotateProviderConfigId}
onChange={(event) => setRotateProviderConfigId(event.target.value)}
>
<option value="">Deployment default</option>
{selectedRotateProviderConfigs.map((config) => {
const blockReason = getProviderConfigBlockReason(config);
return (
<option key={config.id} value={config.id} disabled={Boolean(blockReason)}>
{config.displayName}
{config.isDefault ? " (default)" : ""}
{blockReason ? ` (${blockReason})` : ""}
</option>
);
})}
</select>
{selectedRotateProviderConfig ? (
<ProviderVaultInlineWarning config={selectedRotateProviderConfig} />
) : (
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">
Rotating with the deployment default preserves current fallback behavior.
</p>
)}
</div>
{selectedSecret?.managedMode === "external_reference" && rotateMode === "reference" ? (
<div>
<label className="text-xs font-medium" htmlFor="rotate-ref">External reference</label>
<Input
id="rotate-ref"
value={rotateExternalRef}
onChange={(event) => setRotateExternalRef(event.target.value)}
placeholder={selectedSecret.externalRef ?? "Updated reference"}
className="font-mono text-xs"
/>
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">
Rotate the actual value in the provider before changing this Paperclip reference.
</p>
</div>
) : (
<div>
<label className="text-xs font-medium" htmlFor="rotate-value">New value</label>
<Textarea
id="rotate-value"
value={rotateValue}
onChange={(event) => setRotateValue(event.target.value)}
rows={3}
className="font-mono text-xs"
placeholder="Paste the new value"
/>
{selectedSecret?.managedMode === "external_reference" ? (
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">
Written to <code className="font-mono">{selectedSecret.externalRef}</code> in the provider.
</p>
) : null}
</div>
)}
{rotateError ? <p className="text-xs text-destructive">{rotateError}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={() => setRotateOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
setRotateError(null);
rotateMutation.mutate();
}}
disabled={
rotateMutation.isPending ||
Boolean(rotateProviderBlockReason) ||
(selectedSecret?.managedMode === "external_reference" && rotateMode === "reference"
? !rotateExternalRef.trim() && !selectedSecret?.externalRef
: !rotateValue)
}
>
{rotateMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
{selectedSecret?.managedMode === "external_reference" && rotateMode === "reference"
? "Update reference"
: "Update value"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(deleteConfirm)} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete secret</DialogTitle>
<DialogDescription>
Permanently removes <strong>{deleteConfirm?.name}</strong>. Active bindings will fail until you remap them.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(definitionDeleteConfirm)}
onOpenChange={(open) => !open && setDefinitionDeleteConfirm(null)}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete user-provided secret</DialogTitle>
<DialogDescription>
Permanently removes <strong>{definitionDeleteConfirm?.name}</strong> for the whole organization.
Existing member values become unreferenced and active bindings must be remapped.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDefinitionDeleteConfirm(null)}>Cancel</Button>
<Button
variant="destructive"
onClick={() =>
definitionDeleteConfirm && deleteDefinitionMutation.mutate(definitionDeleteConfirm)
}
disabled={deleteDefinitionMutation.isPending}
>
{deleteDefinitionMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SetMyUserSecretDialog
companyId={selectedCompanyId}
definition={setMyValueFor?.definition ?? null}
existingSecret={setMyValueFor?.secret ?? null}
open={setMyValueFor !== null}
onOpenChange={(open) => {
if (!open) setSetMyValueFor(null);
}}
/>
<Dialog open={Boolean(removeVaultConfirm)} onOpenChange={(open) => !open && setRemoveVaultConfirm(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Remove provider vault</DialogTitle>
<DialogDescription>
Removes <strong>{removeVaultConfirm?.displayName}</strong> from Paperclip only.{" "}
{removeVaultConfirm?.provider === "aws_secrets_manager"
? "This does not delete the remote AWS Secrets Manager vault, secrets, or any AWS data."
: "This does not delete any remote provider data."}{" "}
Secrets using this vault will lose the vault association until you assign another one.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRemoveVaultConfirm(null)}>Cancel</Button>
<Button
variant="destructive"
onClick={() => removeVaultConfirm && removeVaultMutation.mutate(removeVaultConfirm.id)}
disabled={removeVaultMutation.isPending}
>
{removeVaultMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
Remove from Paperclip
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</TooltipProvider>
);
}
function SecretsHowToUse() {
return (
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="space-y-1">
<p className="font-medium text-foreground">Use secrets by binding them to runtime environment variables.</p>
<p>
Create or link a secret here, then open an agent&apos;s Environment variables or a project&apos;s Env field.
Add the env key the process expects, for example <code className="font-mono">GH_TOKEN</code>, choose{" "}
<span className="font-medium text-foreground">Secret</span>, and select the stored secret version.
</p>
<p>
Paperclip resolves the value server-side when the run starts and injects it as that env var. Project env
applies to every task in the project and overrides agent env on matching keys.
</p>
</div>
</div>
);
}
function SecretsFiltersPopover({
statusFilter,
providerFilter,
providedByFilter,
providers,
activeFilterCount,
onStatusChange,
onProviderChange,
onProvidedByChange,
}: {
statusFilter: SecretStatus | "all";
providerFilter: SecretProvider | "all";
providedByFilter: ProvidedByFilter;
providers: SecretProviderDescriptor[];
activeFilterCount: number;
onStatusChange: (value: SecretStatus | "all") => void;
onProviderChange: (value: SecretProvider | "all") => void;
onProvidedByChange: (value: ProvidedByFilter) => void;
}) {
const resetFilters = () => {
onStatusChange("active");
onProviderChange("all");
onProvidedByChange("all");
};
const statusOptions: Array<{ value: SecretStatus | "all"; label: string }> = [
{ value: "active", label: "Active" },
{ value: "all", label: "All statuses" },
{ value: "disabled", label: "Disabled" },
{ value: "archived", label: "Archived" },
];
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn("relative h-8 w-8 shrink-0", activeFilterCount > 0 && "text-blue-600 dark:text-blue-400")}
title={activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}
>
<Filter className="h-3.5 w-3.5" />
{activeFilterCount > 0 ? (
<span className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-blue-600 text-(length:--text-nano) font-bold text-white">
{activeFilterCount}
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-(--sz-calc-41) max-h-(--sz-calc-42) overflow-y-auto overscroll-contain p-0"
>
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filters</span>
{activeFilterCount > 0 ? (
<button
type="button"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
onClick={resetFilters}
>
<X className="h-3 w-3" />
Clear
</button>
) : null}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Status</span>
<div className="space-y-0.5">
{statusOptions.map((option) => (
<label key={option.value} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={statusFilter === option.value}
onCheckedChange={() => onStatusChange(option.value)}
/>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Provided by</span>
<div className="space-y-0.5">
{[
{ value: "all" as const, label: "All sources" },
{ value: "company" as const, label: "Organization" },
{ value: "user" as const, label: "Each user" },
].map((option) => (
<label key={option.value} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={providedByFilter === option.value}
onCheckedChange={() => onProvidedByChange(option.value)}
/>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Provider</span>
<div className="max-h-48 space-y-0.5 overflow-y-auto pr-1">
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={providerFilter === "all"}
onCheckedChange={() => onProviderChange("all")}
/>
<span className="text-sm">All providers</span>
</label>
{providers.map((provider) => (
<label key={provider.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={providerFilter === provider.id}
onCheckedChange={() => onProviderChange(provider.id)}
/>
<span className="text-sm">{provider.label}</span>
</label>
))}
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
function providerConfigStatusTone(status: SecretProviderConfigStatus) {
switch (status) {
case "ready":
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
case "warning":
return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
case "coming_soon":
return "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300";
case "disabled":
return "border-muted bg-muted text-muted-foreground";
default:
return "border-border bg-muted text-muted-foreground";
}
}
function providerFamilyIcon(provider: SecretProvider) {
switch (provider) {
case "local_encrypted":
return Database;
case "aws_secrets_manager":
return Cloud;
case "gcp_secret_manager":
return ShieldCheck;
case "vault":
return KeyRound;
default:
return KeyRound;
}
}
function ProviderVaultInlineWarning({ config }: { config: CompanySecretProviderConfig }) {
const blockReason = getProviderConfigBlockReason(config);
const message = blockReason ?? config.healthMessage;
if (!message) {
return (
<p className="mt-1 text-(length:--text-micro) text-muted-foreground">
{config.isDefault ? "Default vault" : "Vault"} · {config.status.replace("_", " ")}
</p>
);
}
const warning = config.status === "warning" || config.healthStatus === "warning";
return (
<p className={cn("mt-1 flex items-center gap-1 text-(length:--text-micro)", warning ? "text-amber-600 dark:text-amber-400" : "text-destructive")}>
{warning ? <AlertTriangle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{message}
</p>
);
}
interface ImportFromVaultButtonProps {
providerConfigs: CompanySecretProviderConfig[];
onClick: () => void;
/** Absent when the operator hides the Provider vaults tab. */
onManageVaults?: () => void;
className?: string;
}
function ImportFromVaultButton({
providerConfigs,
onClick,
onManageVaults,
className,
}: ImportFromVaultButtonProps) {
const awsConfigs = providerConfigs.filter(
(config) => config.provider === "aws_secrets_manager",
);
const eligible = awsConfigs.filter(
(config) => config.status === "ready" || config.status === "warning",
);
if (awsConfigs.length === 0) return null;
if (eligible.length === 0) {
if (!onManageVaults) return null;
return (
<Button
variant="ghost"
size="sm"
onClick={onManageVaults}
className={cn("text-xs text-muted-foreground", className)}
title="Configure an AWS provider vault to enable remote import"
>
<Cloud className="h-3.5 w-3.5 mr-1" /> AWS vault disabled — manage
</Button>
);
}
return (
<Button
variant="outline"
size="sm"
onClick={onClick}
className={className}
data-testid="import-from-vault-button"
>
<Cloud className="h-3.5 w-3.5 mr-1" /> Import from vault
</Button>
);
}
export function ProviderVaultsTab({
providers,
providerConfigs,
loading,
error,
onRetry,
onCreate,
onEdit,
onDisable,
onRemove,
onSetDefault,
onHealthCheck,
onImportSecrets,
pendingActionId,
retrying = false,
}: {
providers: SecretProviderDescriptor[];
providerConfigs: CompanySecretProviderConfig[];
loading: boolean;
/** The query error when the vault list could not load; null while data (even stale) is available. */
error: unknown;
onRetry: () => void;
retrying?: boolean;
onCreate: (provider: SecretProvider) => void;
onEdit: (config: CompanySecretProviderConfig) => void;
onDisable: (config: CompanySecretProviderConfig) => void;
onRemove: (config: CompanySecretProviderConfig) => void;
onSetDefault: (config: CompanySecretProviderConfig) => void;
onHealthCheck: (config: CompanySecretProviderConfig) => void;
onImportSecrets: (config: CompanySecretProviderConfig) => void;
pendingActionId: string | null;
}) {
if (loading) {
return (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading provider vaults
</div>
);
}
if (error) {
return (
<div className="py-4">
<QueryErrorState error={error} action="load provider vaults" onRetry={onRetry} retrying={retrying} />
</div>
);
}
const providerMap = new Map(providers.map((provider) => [provider.id, provider]));
const providerRows = PROVIDER_ORDER.map((providerId) => ({
id: providerId,
provider: providerMap.get(providerId),
Icon: providerFamilyIcon(providerId),
isComingSoonFamily: providerId === "gcp_secret_manager" || providerId === "vault",
configs: providerConfigs.filter((config) => config.provider === providerId),
}));
return (
<div className="flex min-h-full gap-6">
<aside className="hidden w-56 shrink-0 md:block">
<nav className="sticky top-0 space-y-1">
{providerRows.map(({ id, provider, Icon }) => (
<a
key={id}
href={`#provider-vaults-${id}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent/50 hover:text-foreground"
>
<Icon className="h-4 w-4" />
<span className="truncate">{provider?.label ?? id.replaceAll("_", " ")}</span>
</a>
))}
</nav>
</aside>
<div className="min-w-0 flex-1 space-y-6">
{providerRows.map(({ id, provider, Icon, isComingSoonFamily, configs }) => (
<section key={id} id={`provider-vaults-${id}`} className={cn("scroll-mt-6 space-y-2", isComingSoonFamily && "opacity-50")}>
<div className="flex flex-wrap items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{provider?.label ?? id.replaceAll("_", " ")}</h2>
{isComingSoonFamily ? (
<span className="ml-auto text-xs text-muted-foreground">Coming soon</span>
) : (
<Button variant="outline" size="sm" className="ml-auto" onClick={() => onCreate(id)}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add vault
</Button>
)}
</div>
{configs.length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 p-4 text-sm text-muted-foreground">
{isComingSoonFamily
? "Not yet supported."
: "No organization-specific vaults yet. Secrets can still use the deployment default provider settings."}
</div>
) : (
<div className="space-y-3">
{configs.map((config) => (
<ProviderVaultCard
key={config.id}
config={config}
pending={pendingActionId === config.id}
onEdit={() => onEdit(config)}
onDisable={() => onDisable(config)}
onRemove={() => onRemove(config)}
onSetDefault={() => onSetDefault(config)}
onHealthCheck={() => onHealthCheck(config)}
onImportSecrets={() => onImportSecrets(config)}
/>
))}
</div>
)}
</section>
))}
</div>
</div>
);
}
function ProviderVaultCard({
config,
pending,
onEdit,
onDisable,
onRemove,
onSetDefault,
onHealthCheck,
onImportSecrets,
}: {
config: CompanySecretProviderConfig;
pending: boolean;
onEdit: () => void;
onDisable: () => void;
onRemove: () => void;
onSetDefault: () => void;
onHealthCheck: () => void;
onImportSecrets: () => void;
}) {
const blockReason = getProviderConfigBlockReason(config);
const details = config.healthDetails;
return (
<div className="rounded-md border border-border bg-background p-4">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-medium leading-snug">{config.displayName}</h3>
{config.isDefault ? (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<Star className="h-3 w-3 fill-current" />
Default
</span>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge variant="outline" className={cn("font-medium", providerConfigStatusTone(config.status))}>
{config.status.replace("_", " ")}
</Badge>
{config.healthStatus ? (
<span className="text-xs text-muted-foreground">
Health {config.healthStatus.replace("_", " ")} · {formatRelative(config.healthCheckedAt)}
</span>
) : (
<span className="text-xs text-muted-foreground">Health not checked</span>
)}
</div>
</div>
<Button variant="ghost" size="sm" onClick={onEdit}>
<Edit3 className="h-3.5 w-3.5" />
</Button>
</div>
{config.healthMessage || blockReason ? (
<div className={cn("mt-3 rounded-md p-2 text-xs", blockReason ? "bg-destructive/5 text-destructive" : "bg-muted/40 text-muted-foreground")}>
{blockReason ?? config.healthMessage}
{details?.guidance?.length ? (
<ul className="mt-1 list-disc space-y-0.5 pl-4">
{details.guidance.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
) : null}
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={onHealthCheck} disabled={pending}>
{pending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <RefreshCw className="h-3.5 w-3.5 mr-1" />}
Check health
</Button>
{config.provider === "aws_secrets_manager" ? (
<Button
variant="outline"
size="sm"
onClick={onImportSecrets}
disabled={pending || Boolean(blockReason)}
title={
blockReason
? blockReason
: "Refresh AWS metadata and import existing secrets"
}
data-testid={`provider-vault-refresh-secrets-${config.id}`}
>
<Cloud className="h-3.5 w-3.5 mr-1" />
Refresh secrets
</Button>
) : null}
<Button
variant="outline"
size="sm"
onClick={onSetDefault}
disabled={pending || Boolean(blockReason) || config.isDefault}
>
<Star className="h-3.5 w-3.5 mr-1" />
Make default
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDisable}
disabled={pending || config.status === "disabled"}
>
<Ban className="h-3.5 w-3.5 mr-1" />
Disable
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onRemove}
disabled={pending}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Remove
</Button>
</div>
</div>
);
}
function ProviderVaultFields({
form,
onChange,
}: {
form: ProviderVaultForm;
onChange: React.Dispatch<React.SetStateAction<ProviderVaultForm>>;
}) {
const setField = (key: keyof ProviderVaultForm, value: string | boolean) => {
onChange((current) => ({ ...current, [key]: value }));
};
if (form.provider === "local_encrypted") {
return (
<label className="flex items-start gap-2 rounded-md border border-border bg-muted/20 p-3 text-sm">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 rounded border-border"
checked={form.backupReminderAcknowledged}
onChange={(event) => setField("backupReminderAcknowledged", event.target.checked)}
/>
<span>
I understand backup and restore require both the database metadata and the local encrypted master key file.
</span>
</label>
);
}
if (form.provider === "aws_secrets_manager") {
return (
<div className="grid gap-3 sm:grid-cols-2">
<TextField label="AWS region" value={form.region} onChange={(value) => setField("region", value)} placeholder="us-east-1" required />
<TextField label="Namespace" value={form.namespace} onChange={(value) => setField("namespace", value)} placeholder="production" />
<TextField label="Secret name prefix" value={form.secretNamePrefix} onChange={(value) => setField("secretNamePrefix", value)} placeholder="paperclip" />
<TextField label="KMS key id" value={form.kmsKeyId} onChange={(value) => setField("kmsKeyId", value)} placeholder="alias/paperclip-secrets" />
<TextField label="Owner tag" value={form.ownerTag} onChange={(value) => setField("ownerTag", value)} placeholder="platform" />
<TextField label="Environment tag" value={form.environmentTag} onChange={(value) => setField("environmentTag", value)} placeholder="prod" />
</div>
);
}
if (form.provider === "gcp_secret_manager") {
return (
<div className="grid gap-3 sm:grid-cols-2">
<TextField label="Project id" value={form.projectId} onChange={(value) => setField("projectId", value)} placeholder="paperclip-prod" />
<TextField label="Location" value={form.location} onChange={(value) => setField("location", value)} placeholder="global" />
<TextField label="Namespace" value={form.namespace} onChange={(value) => setField("namespace", value)} placeholder="production" />
<TextField label="Secret name prefix" value={form.secretNamePrefix} onChange={(value) => setField("secretNamePrefix", value)} placeholder="paperclip" />
</div>
);
}
return (
<div className="grid gap-3 sm:grid-cols-2">
<TextField label="Address" value={form.address} onChange={(value) => setField("address", value)} placeholder="https://vault.example.com" />
<TextField label="Namespace" value={form.namespace} onChange={(value) => setField("namespace", value)} placeholder="admin" />
<TextField label="Mount path" value={form.mountPath} onChange={(value) => setField("mountPath", value)} placeholder="secret" />
<TextField label="Secret path prefix" value={form.secretPathPrefix} onChange={(value) => setField("secretPathPrefix", value)} placeholder="paperclip/prod" />
</div>
);
}
function AwsProviderVaultDiscoveryPanel({
form,
preview,
error,
loading,
onDiscover,
onApply,
}: {
form: ProviderVaultForm;
preview: SecretProviderConfigDiscoveryPreviewResult | null;
error: unknown | null;
loading: boolean;
onDiscover: () => void;
onApply: (candidate: SecretProviderConfigDiscoveryCandidate) => void;
}) {
const canDiscover = Boolean(form.region.trim());
const warnings = preview?.warnings ?? [];
return (
<div className="space-y-3 border-t border-border pt-3">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">AWS discovery</p>
<p className="text-xs text-muted-foreground">
Uses the current draft routing fields to inspect AWS Secrets Manager metadata. Values are not read.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onDiscover}
disabled={!canDiscover || loading}
data-testid="aws-vault-discovery-button"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
) : (
<Search className="h-3.5 w-3.5 mr-1" />
)}
Find existing AWS values
</Button>
</div>
{!canDiscover ? (
<p className="text-xs text-muted-foreground">Enter an AWS region before discovery.</p>
) : null}
{loading ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/20 p-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Searching AWS Secrets Manager metadata
</div>
) : null}
{error ? (
<AwsProviderVaultDiscoveryError form={form} error={error} />
) : null}
{warnings.length > 0 ? (
<div className="space-y-1 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-amber-700 dark:text-amber-300">
{warnings.map((warning) => (
<div key={warning} className="flex gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
) : null}
{preview && preview.candidates.length === 0 && !loading ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
No AWS vault metadata candidates found. Manual entry is still available.
</div>
) : null}
{preview && preview.candidates.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Database className="h-3.5 w-3.5" />
<span>
{preview.candidates.length} candidate{preview.candidates.length === 1 ? "" : "s"} from{" "}
{preview.sampledSecretCount} sampled secret{preview.sampledSecretCount === 1 ? "" : "s"}
</span>
</div>
<div className="space-y-2" data-testid="aws-vault-discovery-candidates">
{preview.candidates.map((candidate, index) => (
<AwsProviderVaultDiscoveryCandidateRow
key={`${candidate.displayName}-${index}`}
candidate={candidate}
onApply={() => onApply(candidate)}
/>
))}
</div>
</div>
) : null}
</div>
);
}
/**
* Copy the safe error payload out of a failure panel. People copy this to paste
* into a bug report, so the button has to confirm the clipboard took it.
*/
function CopyDetailsButton({ text }: { text: string }) {
const { copied, failed, copy } = useCopyAction();
return (
<Button type="button" variant="ghost" size="sm" onClick={() => void copy(text)}>
{copied ? "Copied" : failed ? "Copy failed" : "Copy"}
</Button>
);
}
function AwsProviderVaultDiscoveryError({
form,
error,
}: {
form: ProviderVaultForm;
error: unknown;
}) {
const details = apiErrorDetails(error);
const isAccessDenied = isAwsDiscoveryAccessDenied(error);
const region = (details?.region ?? form.region.trim()) || "unspecified";
const message = readableErrorMessage(error);
const safeDetails = {
message,
status: error instanceof ApiError ? error.status : undefined,
provider: details?.provider ?? form.provider,
operation: details?.operation ?? "secret_provider_config.discovery.preview",
providerVaultContext: details?.providerVaultContext ?? "draft_config",
region,
code: details?.code,
requiredCapability: details?.requiredCapability,
credentialPath: details?.credentialPath,
safeAlternative: details?.safeAlternative,
};
const detailsText = JSON.stringify(safeDetails, null, 2);
return (
<div
className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"
role="alert"
data-testid="aws-vault-discovery-error"
>
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-2">
<div>
<p className="font-medium">
{isAccessDenied ? "AWS discovery needs ListSecrets permission" : "AWS discovery failed"}
</p>
<p className="mt-1 leading-relaxed text-destructive/85">
{isAccessDenied
? details?.actionableMessage ??
"Discovery needs secretsmanager:ListSecrets in the selected region for the Paperclip server runtime/provider credential path."
: message}
</p>
</div>
{isAccessDenied ? (
<p className="leading-relaxed text-destructive/85">
{details?.safeAlternative ??
"If you already know the exact AWS Secrets Manager ARN, paste/link that ARN instead of using discovery. Exact-resource DescribeSecret and runtime read permissions are still required."}
</p>
) : null}
<dl className="grid gap-1 text-destructive/80 sm:grid-cols-2">
<div>
<dt className="font-medium">Region</dt>
<dd>{region}</dd>
</div>
<div>
<dt className="font-medium">Operation</dt>
<dd>{details?.operation ?? "secret_provider_config.discovery.preview"}</dd>
</div>
<div>
<dt className="font-medium">Provider</dt>
<dd>{details?.provider ?? "aws_secrets_manager"}</dd>
</div>
<div>
<dt className="font-medium">Vault context</dt>
<dd>{details?.providerVaultContext ?? "draft_config"}</dd>
</div>
</dl>
<div className="rounded-md border border-destructive/20 bg-background/70 p-2 text-foreground">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium text-muted-foreground">Safe request/error details</span>
<CopyDetailsButton text={detailsText} />
</div>
<pre className="max-h-36 overflow-auto whitespace-pre-wrap break-words font-mono text-(length:--text-micro) leading-relaxed">
{detailsText}
</pre>
</div>
</div>
</div>
</div>
);
}
function SecretCreateError({
error,
provider,
providerConfigId,
}: {
error: unknown;
provider: SecretProvider;
providerConfigId: string | null;
}) {
const details = apiErrorDetails(error);
const message = readableErrorMessage(error);
const isAwsCreateError =
details?.provider === "aws_secrets_manager" && details.operation === "secret.create";
const isAccessDenied = isAwsCreateError && details.code === "access_denied";
const safeDetails = {
message,
status: error instanceof ApiError ? error.status : undefined,
provider: details?.provider ?? provider,
operation: details?.operation ?? "secret.create",
providerConfigId: details?.providerConfigId ?? providerConfigId ?? "deployment-default",
region: details?.region,
code: details?.code,
requiredCapability: details?.requiredCapability,
credentialPath: details?.credentialPath,
safeAlternative: details?.safeAlternative,
};
const detailsText = JSON.stringify(safeDetails, null, 2);
if (!isAwsCreateError) {
return (
<p className="text-xs text-destructive" role="alert" data-testid="secret-create-error">
{message}
</p>
);
}
return (
<div
className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"
role="alert"
data-testid="secret-create-error"
>
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-2">
<div>
<p className="font-medium">
{isAccessDenied ? "AWS secret creation needs CreateSecret permission" : "AWS secret creation failed"}
</p>
<p className="mt-1 leading-relaxed text-destructive/85">
{details?.actionableMessage ?? message}
</p>
</div>
{details?.safeAlternative ? (
<p className="leading-relaxed text-destructive/85">{details.safeAlternative}</p>
) : null}
<dl className="grid gap-1 text-destructive/80 sm:grid-cols-2">
{details?.requiredCapability ? (
<div>
<dt className="font-medium">Required IAM capability</dt>
<dd className="font-mono">{details.requiredCapability}</dd>
</div>
) : null}
{details?.region ? (
<div>
<dt className="font-medium">Region</dt>
<dd>{details.region}</dd>
</div>
) : null}
<div>
<dt className="font-medium">Provider vault</dt>
<dd className="break-all">{details?.providerConfigId ?? providerConfigId ?? "Deployment default"}</dd>
</div>
<div>
<dt className="font-medium">Operation</dt>
<dd>{details?.operation ?? "secret.create"}</dd>
</div>
</dl>
<div className="rounded-md border border-destructive/20 bg-background/70 p-2 text-foreground">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium text-muted-foreground">Safe request/error details</span>
<CopyDetailsButton text={detailsText} />
</div>
<pre className="max-h-36 overflow-auto whitespace-pre-wrap break-words font-mono text-(length:--text-micro) leading-relaxed">
{detailsText}
</pre>
</div>
</div>
</div>
</div>
);
}
function AwsProviderVaultDiscoveryCandidateRow({
candidate,
onApply,
}: {
candidate: SecretProviderConfigDiscoveryCandidate;
onApply: () => void;
}) {
const fieldSummary = [
providerConfigValue(candidate.config, "region"),
providerConfigValue(candidate.config, "namespace"),
providerConfigValue(candidate.config, "secretNamePrefix"),
].filter(Boolean);
return (
<div className="rounded-md border border-border bg-background p-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium leading-snug">{candidate.displayName}</p>
<span className="text-xs text-muted-foreground">
{candidate.sampleCount} sample{candidate.sampleCount === 1 ? "" : "s"}
</span>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{fieldSummary.length > 0 ? fieldSummary.join(" / ") : "No stable namespace or prefix detected"}
</p>
{candidate.samples[0] ? (
<p className="mt-1 truncate font-mono text-(length:--text-micro) text-muted-foreground">
{candidate.samples[0].name}
</p>
) : null}
</div>
<Button type="button" variant="ghost" size="sm" onClick={onApply}>
Use values
</Button>
</div>
{candidate.warnings.length > 0 ? (
<div className="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-300">
{candidate.warnings.map((warning) => (
<div key={warning} className="flex gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
) : null}
</div>
);
}
function TextField({
label,
value,
onChange,
placeholder,
required,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
}) {
const id = `provider-vault-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
return (
<div>
<label className="text-xs font-medium" htmlFor={id}>
{label}
{required ? null : <span className="text-muted-foreground/70"> (optional)</span>}
</label>
<Input id={id} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} />
</div>
);
}
function CoverageInline({
companyId,
definitionId,
compact = false,
}: {
companyId: string;
definitionId: string;
compact?: boolean;
}) {
const coverageQuery = useQuery({
queryKey: queryKeys.secrets.userDefinitionCoverage(companyId, definitionId),
queryFn: () => secretsApi.userSecretDefinitionCoverage(companyId, definitionId),
staleTime: 30_000,
});
const coverageView = useQueryView(coverageQuery);
const summary = coverageQuery.data;
if (coverageQuery.isPending || coverageView.kind === "reconnecting") {
return <span className="text-muted-foreground">Loading…</span>;
}
if (coverageView.kind === "error") return <span className="text-destructive">Coverage unavailable</span>;
return (
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground">
<Users className="h-3 w-3" />
<span className="truncate">
{compact && summary
? `${summary.configuredCount}/${summary.configuredCount + summary.missingCount + summary.inactiveCount} set`
: coverageSummaryLabel(summary)}
</span>
{summary && summary.missingCount > 0 ? (
<span className="shrink-0 text-amber-600 dark:text-amber-400">
· {compact ? `${summary.missingCount} miss` : `${summary.missingCount} missing`}
</span>
) : null}
</span>
);
}
function UserSecretDetailsTab({
companyId,
definition,
onViewCoverage,
}: {
companyId: string;
definition: UserSecretDefinition;
onViewCoverage: () => void;
}) {
return (
<dl className="divide-y divide-border/60 text-xs">
<DetailRow label="Description">
<span>{definition.description ?? <span className="text-muted-foreground">—</span>}</span>
</DetailRow>
<DetailRow label="Provided by">Each user</DetailRow>
<DetailRow label="Key">
<code>{definition.key}</code>
</DetailRow>
<DetailRow label="Status"><StatusBadge status={definition.status} /></DetailRow>
<DetailRow label="Coverage">
<button
type="button"
className="inline-flex min-w-0 items-center gap-1 text-left text-primary hover:underline"
onClick={onViewCoverage}
>
<CoverageInline companyId={companyId} definitionId={definition.id} />
<span className="shrink-0 text-muted-foreground">· View in Coverage</span>
</button>
</DetailRow>
<DetailRow label="Created">{formatRelative(definition.createdAt)}</DetailRow>
<DetailRow label="Updated">{formatRelative(definition.updatedAt)}</DetailRow>
<DetailRow label="Usage guidance">
{definition.usageGuidance ?? <span className="text-muted-foreground">—</span>}
</DetailRow>
<div className="mt-3 rounded-md border border-violet-500/30 bg-violet-500/5 p-2 text-(length:--text-micro) text-violet-800 dark:text-violet-200">
No value is stored on this admin row. Each member manages their own value under My secrets.
</div>
</dl>
);
}
function UserSecretCoverageTab({
companyId,
definitionId,
}: {
companyId: string;
definitionId: string;
}) {
const coverageQuery = useQuery({
queryKey: queryKeys.secrets.userDefinitionCoverage(companyId, definitionId),
queryFn: () => secretsApi.userSecretDefinitionCoverage(companyId, definitionId),
staleTime: 30_000,
});
const coverageView = useQueryView(coverageQuery);
if (coverageQuery.isPending || coverageView.kind === "reconnecting") {
return <div className="py-6 text-center text-xs text-muted-foreground">Loading…</div>;
}
if (coverageView.kind === "error" || !coverageQuery.data) {
return <div className="py-6 text-center text-xs text-destructive">Coverage unavailable.</div>;
}
const summary: UserSecretCoverageSummary = coverageQuery.data;
const total = summary.configuredCount + summary.missingCount + summary.inactiveCount;
return (
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5" />
<span>{coverageSummaryLabel(summary)}</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
<div className="text-lg font-semibold text-emerald-700 dark:text-emerald-300">
{summary.configuredCount}
</div>
<div className="text-muted-foreground">Set</div>
</div>
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3">
<div className="text-lg font-semibold text-amber-700 dark:text-amber-300">
{summary.missingCount}
</div>
<div className="text-muted-foreground">Missing</div>
</div>
<div className="rounded-md border border-border bg-muted/30 p-3">
<div className="text-lg font-semibold text-muted-foreground">
{summary.inactiveCount}
</div>
<div className="text-muted-foreground">Inactive</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
Coverage is counts only across {total} member{total === 1 ? "" : "s"}. Secret values are never shown here.
</p>
</div>
);
}
function UserSecretUsageTab({ definition }: { definition: UserSecretDefinition }) {
return (
<div className="space-y-3 text-xs text-muted-foreground">
<div className="rounded-md border border-border bg-muted/20 p-3">
Bind runtime environment variables to this user-provided secret by choosing{" "}
<span className="font-medium text-foreground">User secret</span> and selecting{" "}
<code className="font-mono">{definition.key}</code>.
</div>
{definition.usageGuidance ? (
<div>
<p className="mb-1 text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Member guidance</p>
<p className="text-foreground">{definition.usageGuidance}</p>
</div>
) : null}
</div>
);
}
function UserSecretAccessEventsTab() {
return (
<div className="py-6 text-center text-xs text-muted-foreground">
Access events are recorded on each member&apos;s stored value when runtime resolution occurs.
</div>
);
}
type AgentAccessReference =
| { kind: "company"; secret: CompanySecret }
| { kind: "user"; definition: UserSecretDefinition };
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
/** Env keys in an agent's env config that resolve to this secret/definition. */
function envKeysReferencingSecret(env: unknown, reference: AgentAccessReference): string[] {
if (typeof env !== "object" || env === null || Array.isArray(env)) return [];
return Object.entries(env as Record<string, unknown>)
.filter(([, binding]) => {
if (typeof binding !== "object" || binding === null) return false;
const record = binding as Record<string, unknown>;
return reference.kind === "company"
? record.type === "secret_ref" && record.secretId === reference.secret.id
: record.type === "user_secret_ref" && record.key === reference.definition.key;
})
.map(([key]) => key)
.sort();
}
/**
* Top-level `access.<ALIAS>` keys in an agent's adapter config that resolve to
* this secret (API-access delivery). Only company secrets support API access;
* user secrets remain env-only.
*/
function apiAliasesReferencingSecret(adapterConfig: unknown, reference: AgentAccessReference): string[] {
if (reference.kind !== "company") return [];
if (typeof adapterConfig !== "object" || adapterConfig === null || Array.isArray(adapterConfig)) return [];
return Object.entries(adapterConfig as Record<string, unknown>)
.filter(([key, binding]) => {
if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return false;
if (typeof binding !== "object" || binding === null) return false;
const record = binding as Record<string, unknown>;
return record.type === "secret_ref" && record.secretId === reference.secret.id;
})
.map(([key]) => key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length))
.sort();
}
function AgentAccessSection({
companyId,
reference,
}: {
companyId: string;
reference: AgentAccessReference;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [selectedAgentId, setSelectedAgentId] = useState("");
const [envKey, setEnvKey] = useState("");
const [envKeyDirty, setEnvKeyDirty] = useState(false);
const [accessError, setAccessError] = useState<string | null>(null);
const referenceId = reference.kind === "company" ? reference.secret.id : reference.definition.id;
const referenceName = reference.kind === "company" ? reference.secret.name : reference.definition.name;
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(companyId),
staleTime: 30_000,
});
const agentsView = useQueryView(agentsQuery);
const agents = useMemo(
() => (agentsQuery.data ?? []).filter((agent) => agent.status !== "terminated"),
[agentsQuery.data],
);
const agentAccess = useMemo(
() =>
agents
.map((agent) => {
const adapterConfig = (agent.adapterConfig as Record<string, unknown> | null) ?? null;
return {
agent,
envKeys: envKeysReferencingSecret(adapterConfig?.env, reference),
apiAliases: apiAliasesReferencingSecret(adapterConfig, reference),
};
})
.filter((entry) => entry.envKeys.length > 0 || entry.apiAliases.length > 0),
[agents, reference],
);
const grantableAgents = useMemo(
() => agents.filter((agent) => !agentAccess.some((entry) => entry.agent.id === agent.id)),
[agents, agentAccess],
);
const effectiveEnvKey = envKeyDirty
? envKey
: reference.kind === "user"
? reference.definition.key
: envKeyFromSecretName(referenceName);
useEffect(() => {
setSelectedAgentId("");
setEnvKey("");
setEnvKeyDirty(false);
setAccessError(null);
}, [referenceId]);
function invalidateAfterChange(agentId: string) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
if (reference.kind === "company") {
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.usage(reference.secret.id) });
}
}
const grantMutation = useMutation({
mutationFn: async ({ agentId, key }: { agentId: string; key: string }) => {
// Re-fetch right before patching so we merge into the freshest env config.
const detail = await agentsApi.get(agentId, companyId);
const adapterConfig = { ...((detail.adapterConfig ?? {}) as Record<string, unknown>) };
const env = { ...((adapterConfig.env ?? {}) as Record<string, unknown>) };
if (env[key] !== undefined) {
throw new Error(`${detail.name} already has an env var named ${key}.`);
}
env[key] =
reference.kind === "company"
? { type: "secret_ref", secretId: reference.secret.id }
: { type: "user_secret_ref", key: reference.definition.key };
return agentsApi.update(
agentId,
{ adapterConfig: { ...adapterConfig, env }, replaceAdapterConfig: true },
companyId,
);
},
onSuccess: (agent, variables) => {
setSelectedAgentId("");
setEnvKey("");
setEnvKeyDirty(false);
setAccessError(null);
invalidateAfterChange(variables.agentId);
pushToast({ title: "Access granted", body: `${agent.name} now receives ${variables.key}`, tone: "success" });
},
onError: (error) => setAccessError(readableErrorMessage(error)),
});
const revokeMutation = useMutation({
mutationFn: async ({ agentId }: { agentId: string }) => {
const detail = await agentsApi.get(agentId, companyId);
const adapterConfig = { ...((detail.adapterConfig ?? {}) as Record<string, unknown>) };
const env = { ...((adapterConfig.env ?? {}) as Record<string, unknown>) };
const keys = envKeysReferencingSecret(env, reference);
const aliases = apiAliasesReferencingSecret(adapterConfig, reference);
if (keys.length === 0 && aliases.length === 0) return detail;
for (const key of keys) delete env[key];
for (const alias of aliases) delete adapterConfig[`${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`];
return agentsApi.update(
agentId,
{ adapterConfig: { ...adapterConfig, env }, replaceAdapterConfig: true },
companyId,
);
},
onSuccess: (agent, variables) => {
setAccessError(null);
invalidateAfterChange(variables.agentId);
pushToast({ title: "Access removed", body: agent.name, tone: "info" });
},
onError: (error) => setAccessError(readableErrorMessage(error)),
});
const envKeyValid = ENV_KEY_PATTERN.test(effectiveEnvKey);
const canGrant = Boolean(selectedAgentId) && envKeyValid && !grantMutation.isPending;
return (
<section className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<h3 className="text-xs font-medium text-foreground">Agent access</h3>
</div>
<p className="mt-0.5 text-(length:--text-micro) text-muted-foreground">
{reference.kind === "company"
? "Add here to inject this secret as an environment variable at run start. API-access grants (fetched on demand, no env var) are managed from each agent's Secret access settings and shown below."
: "These agents resolve the responsible user's value as an environment variable at run start."}
</p>
{agentsQuery.isPending || agentsView.kind === "reconnecting" ? (
<p className="mt-2 text-(length:--text-micro) text-muted-foreground">Loading agents…</p>
) : agentsView.kind === "error" ? (
<p className="mt-2 text-(length:--text-micro) text-muted-foreground">
Agent list unavailable. Manage access from each agent&apos;s configuration instead.
</p>
) : (
<>
{agentAccess.length > 0 ? (
<ul className="mt-2 space-y-1">
{agentAccess.map(({ agent, envKeys, apiAliases }) => (
<li
key={agent.id}
className="flex items-center gap-2 rounded border border-border/60 bg-background px-2 py-1"
>
<span className="min-w-0 flex-1 truncate text-xs font-medium">{agent.name}</span>
<span className="flex shrink-0 flex-wrap items-center justify-end gap-1">
{envKeys.length > 0 ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-(length:--text-nano) font-normal border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"
>
Env · {envKeys.join(", ")}
</Badge>
) : null}
{apiAliases.length > 0 ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-(length:--text-nano) font-normal border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300"
>
API · {apiAliases.join(", ")}
</Badge>
) : null}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 shrink-0 p-0 text-muted-foreground"
aria-label={`Remove access for ${agent.name}`}
disabled={revokeMutation.isPending}
onClick={() => revokeMutation.mutate({ agentId: agent.id })}
>
<X className="h-3 w-3" />
</Button>
</li>
))}
</ul>
) : (
<p className="mt-2 text-(length:--text-micro) text-muted-foreground">No agents have access yet.</p>
)}
<div className="mt-2 flex items-end gap-2">
<div className="min-w-0 flex-1">
<label
className="text-(length:--text-micro) font-medium text-muted-foreground"
htmlFor="agent-access-agent"
>
Agent
</label>
<AgentSelect
id="agent-access-agent"
agents={grantableAgents}
value={selectedAgentId}
onChange={setSelectedAgentId}
triggerClassName="h-8 text-xs"
emptyMessage="No agents available."
/>
</div>
<div className="min-w-0 flex-1">
<label
className="text-(length:--text-micro) font-medium text-muted-foreground"
htmlFor="agent-access-env-key"
>
Env var
</label>
<Input
id="agent-access-env-key"
value={effectiveEnvKey}
onChange={(event) => {
setEnvKeyDirty(true);
setEnvKey(event.target.value.toUpperCase());
}}
className="h-8 font-mono text-xs"
placeholder="MY_SECRET"
/>
</div>
<Button
type="button"
size="sm"
className="h-8 shrink-0"
disabled={!canGrant}
onClick={() => grantMutation.mutate({ agentId: selectedAgentId, key: effectiveEnvKey })}
>
{grantMutation.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="mr-1 h-3.5 w-3.5" />
)}
Add
</Button>
</div>
{effectiveEnvKey && !envKeyValid ? (
<p className="mt-1 text-(length:--text-micro) text-destructive">
Env keys use letters, digits, and underscores, and cannot start with a digit.
</p>
) : null}
{accessError ? (
<p className="mt-1 text-(length:--text-micro) text-destructive">{accessError}</p>
) : null}
</>
)}
</section>
);
}
function SecretDetailsTab({
secret,
providers,
providerConfigs,
onViewUsage,
}: {
secret: CompanySecret;
providers: SecretProviderDescriptor[];
providerConfigs: CompanySecretProviderConfig[];
onViewUsage: () => void;
}) {
const bindingLabel = (secret.referenceCount ?? 0) === 1
? "1 binding"
: `${secret.referenceCount ?? 0} bindings`;
return (
<dl className="divide-y divide-border/60 text-xs">
<DetailRow label="Description">
<span>{secret.description ?? <span className="text-muted-foreground">—</span>}</span>
</DetailRow>
<DetailRow label="Provided by">Organization</DetailRow>
<DetailRow label="Custody">{modeLabel(secret.managedMode)}</DetailRow>
<DetailRow label="Provider">{providerLabel(providers, secret.provider)}</DetailRow>
<DetailRow label="Provider vault">{providerVaultLabel(providerConfigs, secret.providerConfigId)}</DetailRow>
<DetailRow label="External ARN">
{secret.externalRef ? (
<span className="break-all font-mono">{secret.externalRef}</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</DetailRow>
<DetailRow label="Latest version">v{secret.latestVersion}</DetailRow>
<DetailRow label="References">
<button
type="button"
className="inline-flex items-center gap-1 text-left text-primary hover:underline"
onClick={onViewUsage}
>
{bindingLabel}
<span className="text-muted-foreground">· View in Usage</span>
</button>
</DetailRow>
<DetailRow label="Created">{formatRelative(secret.createdAt)}</DetailRow>
<DetailRow label="Updated">{formatRelative(secret.updatedAt)}</DetailRow>
<DetailRow label="Last rotated">{formatRelative(secret.lastRotatedAt)}</DetailRow>
<DetailRow label="Last resolved">{formatRelative(secret.lastResolvedAt)}</DetailRow>
<div className="mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-2 text-(length:--text-micro) text-amber-700 dark:text-amber-300">
{modeDescription(
secret.managedMode,
Boolean(
secret.externalRef &&
providers.find((provider) => provider.id === secret.provider)?.supportsExternalValueWrites,
),
)}{" "}
Paperclip never re-displays stored values.
</div>
</dl>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-(--gtc-55) gap-3 py-2">
<dt className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className="min-w-0 text-foreground">{children}</dd>
</div>
);
}
export function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: CompanySecretUsageBinding[] }) {
if (loading) {
return <div className="py-6 text-center text-xs text-muted-foreground">Loading…</div>;
}
if (bindings.length === 0) {
return (
<div className="py-6 text-center text-xs text-muted-foreground">
No active bindings. Add this secret in agent, project, environment, or plugin config to start using it.
</div>
);
}
return (
<div className="space-y-2">
{bindings.map((binding) => {
const deliveryMode = deliveryModeForConfigPath(binding.configPath);
return (
<div
key={binding.id}
className="rounded-md border border-border bg-muted/30 p-2 text-xs"
>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5">
<span className="font-medium capitalize">{binding.target.type}</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-(length:--text-nano) font-normal",
deliveryMode === "api"
? "border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300"
: deliveryMode === "env"
? "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"
: null,
)}
>
{deliveryModeLabel(deliveryMode)}
</Badge>
</span>
<span className="font-mono text-muted-foreground">v{binding.versionSelector}</span>
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-2">
{binding.target.href ? (
<Link to={binding.target.href} className="truncate font-medium text-primary hover:underline">
{binding.target.label}
</Link>
) : (
<span className="truncate font-medium">{binding.target.label}</span>
)}
{binding.target.status ? (
<Badge variant="outline" className="h-5 px-1.5 text-(length:--text-nano) font-normal">
{binding.target.status.replaceAll("_", " ")}
</Badge>
) : null}
</div>
<div className="font-mono text-(length:--text-micro) text-muted-foreground break-all">
{binding.targetId}
</div>
<div className="text-(length:--text-micro) text-muted-foreground">
{deliveryMode === "api" ? (
<>API alias <span className="font-mono">{aliasFromConfigPath(binding.configPath)}</span></>
) : (
<span className="font-mono">{binding.configPath}</span>
)}{" "}
{binding.required ? "· required" : "· optional"}
</div>
</div>
);
})}
</div>
);
}
export function SecretEventsTab({
loading,
events,
companyId,
}: {
loading: boolean;
events: SecretAccessEvent[];
companyId: string;
}) {
// Resolve responsible/owner user ids to human names for user-scoped events.
const anyUserScoped = events.some(
(event) =>
event.secretScope === "user" || event.responsibleUserId || event.credentialOwnerUserId,
);
const { data: directory } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(companyId),
queryFn: () => accessApi.listUserDirectory(companyId),
enabled: anyUserScoped,
staleTime: 60_000,
});
const userLabel = (userId: string | null): string => {
if (!userId) return "—";
const entry: CompanyUserDirectoryEntry | undefined = directory?.users.find(
(u) => u.principalId === userId,
);
return entry?.user?.name?.trim() || entry?.user?.email?.trim() || `${userId.slice(0, 8)}…`;
};
if (loading) {
return <div className="py-6 text-center text-xs text-muted-foreground">Loading…</div>;
}
if (events.length === 0) {
return (
<div className="py-6 text-center text-xs text-muted-foreground">
No access events recorded yet. Each runtime resolution writes a redacted entry here.
</div>
);
}
return (
<div className="space-y-1.5">
{events.map((event) => (
<div key={event.id} className="rounded border border-border px-2 py-1.5 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5">
<span>{consumerTypeLabel(event.consumerType)}</span>
<span className="capitalize">· {event.outcome}</span>
{event.secretScope === "user" ? (
<Badge
variant="outline"
className="border-violet-500/30 bg-violet-500/10 text-(length:--text-nano) text-violet-700 dark:text-violet-300"
>
User secret
</Badge>
) : null}
</span>
<span className="text-(length:--text-micro) text-muted-foreground">{formatRelative(event.createdAt)}</span>
</div>
<div className="font-mono text-(length:--text-micro) text-muted-foreground break-all">
{event.consumerId}
</div>
{event.responsibleUserId ? (
<div className="text-(length:--text-micro) text-muted-foreground">
Responsible user: <span className="text-foreground">{userLabel(event.responsibleUserId)}</span>
</div>
) : null}
{event.credentialOwnerUserId &&
event.credentialOwnerUserId !== event.responsibleUserId ? (
<div className="text-(length:--text-micro) text-muted-foreground">
Credential owner: <span className="text-foreground">{userLabel(event.credentialOwnerUserId)}</span>
</div>
) : null}
{event.errorCode ? (
<div className="text-(length:--text-micro) text-destructive">{event.errorCode}</div>
) : null}
</div>
))}
</div>
);
}