mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Polish secrets settings layout
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
06d39188a9
commit
58dc2fe299
2 files changed
+373
-132
No files matched your search
@@ -0,0 +1,217 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ProviderVaultsTab, Secrets } from "./Secrets";
|
||||
|
||||
const mockSecretsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
providers: vi.fn(),
|
||||
providerHealth: vi.fn(),
|
||||
providerConfigs: vi.fn(),
|
||||
createProviderConfig: vi.fn(),
|
||||
updateProviderConfig: vi.fn(),
|
||||
disableProviderConfig: vi.fn(),
|
||||
setDefaultProviderConfig: vi.fn(),
|
||||
checkProviderConfigHealth: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
usage: vi.fn(),
|
||||
accessEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/secrets", () => ({
|
||||
secretsApi: mockSecretsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({
|
||||
setBreadcrumbs: mockSetBreadcrumbs,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/ToastContext", () => ({
|
||||
useToast: () => ({
|
||||
pushToast: mockPushToast,
|
||||
}),
|
||||
useToastActions: () => ({
|
||||
pushToast: mockPushToast,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const providers: SecretProviderDescriptor[] = [
|
||||
{
|
||||
id: "local_encrypted",
|
||||
label: "Local encrypted",
|
||||
requiresExternalRef: false,
|
||||
supportsManagedValues: true,
|
||||
supportsExternalReferences: false,
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
id: "aws_secrets_manager",
|
||||
label: "AWS Secrets Manager",
|
||||
requiresExternalRef: false,
|
||||
supportsManagedValues: true,
|
||||
supportsExternalReferences: true,
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
id: "gcp_secret_manager",
|
||||
label: "GCP Secret Manager",
|
||||
requiresExternalRef: false,
|
||||
supportsManagedValues: false,
|
||||
supportsExternalReferences: true,
|
||||
configured: false,
|
||||
},
|
||||
{
|
||||
id: "vault",
|
||||
label: "Vault",
|
||||
requiresExternalRef: false,
|
||||
supportsManagedValues: false,
|
||||
supportsExternalReferences: true,
|
||||
configured: false,
|
||||
},
|
||||
];
|
||||
|
||||
const providerConfigs = [
|
||||
{
|
||||
id: "vault-local",
|
||||
provider: "local_encrypted",
|
||||
displayName: "Local default",
|
||||
status: "ready",
|
||||
isDefault: true,
|
||||
healthStatus: "ready",
|
||||
healthCheckedAt: null,
|
||||
healthMessage: null,
|
||||
healthDetails: null,
|
||||
},
|
||||
{
|
||||
id: "vault-aws",
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
status: "ready",
|
||||
isDefault: false,
|
||||
healthStatus: null,
|
||||
healthCheckedAt: null,
|
||||
healthMessage: null,
|
||||
healthDetails: null,
|
||||
},
|
||||
] satisfies Partial<CompanySecretProviderConfig>[];
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("Secrets page layout", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
mockSecretsApi.list.mockResolvedValue([]);
|
||||
mockSecretsApi.providers.mockResolvedValue(providers);
|
||||
mockSecretsApi.providerHealth.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
provider: "local_encrypted",
|
||||
status: "warn",
|
||||
message: "Local encrypted provider has a warning.",
|
||||
warnings: ["Backup reminder"],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses the shared search/filter/tab affordances and keeps vault sections quiet", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector('input[data-page-search-target="true"][aria-label="Search secrets"]')).not.toBeNull();
|
||||
expect(container.querySelectorAll("select")).toHaveLength(0);
|
||||
expect(container.textContent).not.toContain("Provider warnings detected");
|
||||
expect(container.textContent).not.toContain("2/2 active");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
const vaultRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
vaultRoot.render(
|
||||
<ProviderVaultsTab
|
||||
providers={providers}
|
||||
providerConfigs={providerConfigs as CompanySecretProviderConfig[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDisable={vi.fn()}
|
||||
onSetDefault={vi.fn()}
|
||||
onHealthCheck={vi.fn()}
|
||||
pendingActionId={null}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector('a[href="#provider-vaults-local_encrypted"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("AWS production");
|
||||
expect(container.textContent).not.toContain("Managed writes");
|
||||
expect(container.textContent).not.toContain("External refs");
|
||||
|
||||
await act(async () => {
|
||||
vaultRoot.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
+156
-132
@@ -20,7 +20,8 @@ import {
|
||||
ShieldCheck,
|
||||
Star,
|
||||
Trash2,
|
||||
Wifi,
|
||||
X,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
CompanySecret,
|
||||
@@ -49,6 +50,8 @@ import { EmptyState } from "../components/EmptyState";
|
||||
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,
|
||||
@@ -67,6 +70,7 @@ import {
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "../lib/utils";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
|
||||
type CreateMode = "managed" | "external";
|
||||
type SecretsTab = "secrets" | "vaults";
|
||||
@@ -468,6 +472,7 @@ export function Secrets() {
|
||||
);
|
||||
});
|
||||
}, [secrets, search, statusFilter, providerFilter]);
|
||||
const activeSecretFilterCount = (statusFilter === "active" ? 0 : 1) + (providerFilter === "all" ? 0 : 1);
|
||||
|
||||
const usageQuery = useQuery({
|
||||
queryKey: selectedSecret ? queryKeys.secrets.usage(selectedSecret.id) : ["secrets", "usage", "__disabled__"],
|
||||
@@ -733,52 +738,35 @@ export function Secrets() {
|
||||
);
|
||||
}
|
||||
|
||||
const activeSecrets = secrets.filter((s) => s.status === "active").length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex flex-wrap items-center gap-3 border-b border-border px-6 py-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>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{activeSecrets}/{secrets.length} active · {providerConfigs.length} vault{providerConfigs.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center gap-2 justify-end">
|
||||
{activeTab === "secrets" ? (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<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 h-8 w-64"
|
||||
className="pl-7 text-xs sm:text-sm"
|
||||
aria-label="Search secrets"
|
||||
data-page-search-target="true"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="h-8 rounded-md border border-border bg-background px-2 text-xs outline-none"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as SecretStatus | "all")}
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-8 rounded-md border border-border bg-background px-2 text-xs outline-none"
|
||||
value={providerFilter}
|
||||
onChange={(event) => setProviderFilter(event.target.value as SecretProvider | "all")}
|
||||
>
|
||||
<option value="all">All providers</option>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SecretsFiltersPopover
|
||||
statusFilter={statusFilter}
|
||||
providerFilter={providerFilter}
|
||||
providers={providers}
|
||||
activeFilterCount={activeSecretFilterCount}
|
||||
onStatusChange={setStatusFilter}
|
||||
onProviderChange={setProviderFilter}
|
||||
/>
|
||||
<Button onClick={() => setCreateOpen(true)} size="sm">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> New secret
|
||||
</Button>
|
||||
@@ -791,19 +779,17 @@ export function Secrets() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ProviderHealthBar
|
||||
providers={providers}
|
||||
health={providerHealthQuery.data ?? null}
|
||||
loading={providersQuery.isPending}
|
||||
error={providersQuery.error ?? providerHealthQuery.error}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as SecretsTab)} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b border-border px-6 py-2">
|
||||
<TabsList>
|
||||
<TabsTrigger value="secrets">Secrets</TabsTrigger>
|
||||
<TabsTrigger value="vaults">Provider vaults</TabsTrigger>
|
||||
</TabsList>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "secrets", label: "Secrets" },
|
||||
{ value: "vaults", label: "Provider vaults" },
|
||||
]}
|
||||
align="start"
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as SecretsTab)}
|
||||
/>
|
||||
</div>
|
||||
<TabsContent value="secrets" className="min-h-0 flex-1 overflow-y-auto">
|
||||
{secretsQuery.isError ? (
|
||||
@@ -1464,75 +1450,110 @@ export function Secrets() {
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderHealthBar({
|
||||
function SecretsFiltersPopover({
|
||||
statusFilter,
|
||||
providerFilter,
|
||||
providers,
|
||||
health,
|
||||
loading,
|
||||
error,
|
||||
activeFilterCount,
|
||||
onStatusChange,
|
||||
onProviderChange,
|
||||
}: {
|
||||
statusFilter: SecretStatus | "all";
|
||||
providerFilter: SecretProvider | "all";
|
||||
providers: SecretProviderDescriptor[];
|
||||
health: SecretProviderHealthResponse | null;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
activeFilterCount: number;
|
||||
onStatusChange: (value: SecretStatus | "all") => void;
|
||||
onProviderChange: (value: SecretProvider | "all") => void;
|
||||
}) {
|
||||
if (loading || providers.length === 0) return null;
|
||||
const resetFilters = () => {
|
||||
onStatusChange("active");
|
||||
onProviderChange("all");
|
||||
};
|
||||
|
||||
const healthMap = new Map(health?.providers.map((entry) => [entry.provider, entry]) ?? []);
|
||||
const worstStatus = (() => {
|
||||
if (error) return "error" as const;
|
||||
let worst: "ok" | "warn" | "error" = "ok";
|
||||
for (const entry of healthMap.values()) {
|
||||
if (entry.status === "error") return "error" as const;
|
||||
if (entry.status === "warn") worst = "warn";
|
||||
}
|
||||
return worst;
|
||||
})();
|
||||
|
||||
const tone =
|
||||
worstStatus === "error"
|
||||
? "border-destructive/40 bg-destructive/5 text-destructive"
|
||||
: worstStatus === "warn"
|
||||
? "border-amber-500/40 bg-amber-500/5 text-amber-700 dark:text-amber-300"
|
||||
: "border-border bg-muted/30 text-muted-foreground";
|
||||
const dot = (status: "ok" | "warn" | "error") =>
|
||||
status === "error" ? "bg-destructive" : status === "warn" ? "bg-amber-500" : "bg-emerald-500";
|
||||
const message = error
|
||||
? "Provider health check failed."
|
||||
: worstStatus === "error"
|
||||
? "One or more providers reported errors."
|
||||
: worstStatus === "warn"
|
||||
? "Provider warnings detected — review setup."
|
||||
: `Connected to ${providers.length} provider${providers.length === 1 ? "" : "s"}`;
|
||||
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 (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 border-b px-6 py-1.5 text-[11px]", tone)}>
|
||||
<Wifi className="h-3 w-3" />
|
||||
<span>{message}</span>
|
||||
<span>·</span>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{providers.map((provider) => {
|
||||
const entry = healthMap.get(provider.id);
|
||||
const status = entry?.status ?? "ok";
|
||||
const warningCount = entry?.warnings?.length ?? 0;
|
||||
return (
|
||||
<span
|
||||
key={provider.id}
|
||||
className="inline-flex items-center gap-1"
|
||||
title={entry?.message ?? provider.label}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", dot(status))} />
|
||||
{provider.label}
|
||||
{warningCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-0.5 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-2.5 w-2.5" />
|
||||
{warningCount} warning{warningCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
) : null}
|
||||
<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-[9px] font-bold text-white">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[min(520px,calc(100vw-2rem))] max-h-[min(80vh,34rem)] 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-2">
|
||||
<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">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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1585,7 +1606,7 @@ function ProviderVaultInlineWarning({ config }: { config: CompanySecretProviderC
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderVaultsTab({
|
||||
export function ProviderVaultsTab({
|
||||
providers,
|
||||
providerConfigs,
|
||||
loading,
|
||||
@@ -1631,27 +1652,38 @@ function ProviderVaultsTab({
|
||||
}
|
||||
|
||||
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="space-y-5 p-6">
|
||||
{PROVIDER_ORDER.map((providerId) => {
|
||||
const provider = providerMap.get(providerId);
|
||||
const configs = providerConfigs.filter((config) => config.provider === providerId);
|
||||
const Icon = providerFamilyIcon(providerId);
|
||||
const isComingSoonFamily = providerId === "gcp_secret_manager" || providerId === "vault";
|
||||
return (
|
||||
<section key={providerId} className="space-y-2">
|
||||
<div className="flex min-h-full gap-6 p-6">
|
||||
<aside className="hidden w-56 shrink-0 md:block">
|
||||
<nav className="sticky top-6 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="scroll-mt-6 space-y-2">
|
||||
<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 ?? providerId.replaceAll("_", " ")}</h2>
|
||||
{provider?.supportsManagedValues ? <CapabilityBadge label="Managed writes" /> : null}
|
||||
{provider?.supportsExternalReferences ? <CapabilityBadge label="External refs" /> : null}
|
||||
{isComingSoonFamily ? (
|
||||
<Badge variant="outline" className={cn("font-medium", providerConfigStatusTone("coming_soon"))}>
|
||||
coming soon
|
||||
</Badge>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" className="ml-auto" onClick={() => onCreate(providerId)}>
|
||||
<h2 className="text-sm font-semibold">{provider?.label ?? id.replaceAll("_", " ")}</h2>
|
||||
<Button variant="outline" size="sm" className="ml-auto" onClick={() => onCreate(id)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add vault
|
||||
</Button>
|
||||
@@ -1663,7 +1695,7 @@ function ProviderVaultsTab({
|
||||
: "No company-specific vaults yet. Secrets can still use the deployment default provider settings."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
{configs.map((config) => (
|
||||
<ProviderVaultCard
|
||||
key={config.id}
|
||||
@@ -1678,20 +1710,12 @@ function ProviderVaultsTab({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-border bg-background text-[11px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderVaultCard({
|
||||
config,
|
||||
pending,
|
||||
|
||||
Reference in new issue
Block a user