Files
stack/apps/web/src/app/(authenticated)/settings/credentials/page.tsx
Jason Woltje 1667f28d71
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(web): implement credential management UI
Enable Add Credential button, implement add/rotate/delete dialogs,
wire CRUD operations to existing /api/credentials endpoints.
Displays credentials in responsive table/card layout (name, type,
scope, masked value, created date). Supports all credential types
(API_KEY, OAUTH_TOKEN, ACCESS_TOKEN, SECRET, PASSWORD, CUSTOM) and
scopes (USER, WORKSPACE, SYSTEM).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 05:13:03 -06:00

1069 lines
33 KiB
TypeScript

"use client";
import { useState, useEffect, type SyntheticEvent } from "react";
import { Plus, History, Key, RotateCw, Trash2, Eye, EyeOff } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
fetchCredentials,
createCredential,
deleteCredential,
rotateCredential,
CredentialType,
CredentialScope,
type Credential,
type CreateCredentialDto,
} from "@/lib/api/credentials";
import { useWorkspaceId } from "@/lib/hooks";
/* ---------------------------------------------------------------------------
Constants
--------------------------------------------------------------------------- */
const CREDENTIAL_TYPE_LABELS: Record<CredentialType, string> = {
[CredentialType.API_KEY]: "API Key",
[CredentialType.OAUTH_TOKEN]: "OAuth Token",
[CredentialType.ACCESS_TOKEN]: "Access Token",
[CredentialType.SECRET]: "Secret",
[CredentialType.PASSWORD]: "Password",
[CredentialType.CUSTOM]: "Custom",
};
const CREDENTIAL_SCOPE_LABELS: Record<CredentialScope, string> = {
[CredentialScope.USER]: "User",
[CredentialScope.WORKSPACE]: "Workspace",
[CredentialScope.SYSTEM]: "System",
};
/* ---------------------------------------------------------------------------
Add Credential Dialog
--------------------------------------------------------------------------- */
interface AddCredentialDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: CreateCredentialDto) => Promise<void>;
isSubmitting: boolean;
}
function AddCredentialDialog({
open,
onOpenChange,
onSubmit,
isSubmitting,
}: AddCredentialDialogProps): React.ReactElement | null {
const [name, setName] = useState("");
const [provider, setProvider] = useState("");
const [type, setType] = useState<CredentialType>(CredentialType.API_KEY);
const [scope, setScope] = useState<CredentialScope>(CredentialScope.WORKSPACE);
const [value, setValue] = useState("");
const [description, setDescription] = useState("");
const [showValue, setShowValue] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
function resetForm(): void {
setName("");
setProvider("");
setType(CredentialType.API_KEY);
setScope(CredentialScope.WORKSPACE);
setValue("");
setDescription("");
setShowValue(false);
setFormError(null);
}
async function handleSubmit(e: SyntheticEvent): Promise<void> {
e.preventDefault();
setFormError(null);
const trimmedName = name.trim();
if (!trimmedName) {
setFormError("Name is required.");
return;
}
const trimmedProvider = provider.trim();
if (!trimmedProvider) {
setFormError("Provider is required.");
return;
}
const trimmedValue = value.trim();
if (!trimmedValue) {
setFormError("Credential value is required.");
return;
}
try {
const payload: CreateCredentialDto = {
name: trimmedName,
provider: trimmedProvider,
type,
scope,
value: trimmedValue,
};
const trimmedDesc = description.trim();
if (trimmedDesc) {
payload.description = trimmedDesc;
}
await onSubmit(payload);
resetForm();
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : "Failed to create credential.");
}
}
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="add-credential-title"
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{/* Backdrop */}
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
}}
onClick={() => {
if (!isSubmitting) {
resetForm();
onOpenChange(false);
}
}}
/>
{/* Dialog */}
<div
style={{
position: "relative",
background: "var(--surface, #fff)",
borderRadius: "8px",
border: "1px solid var(--border, #e5e7eb)",
padding: 24,
width: "100%",
maxWidth: 520,
zIndex: 1,
maxHeight: "90vh",
overflowY: "auto",
}}
>
<h2
id="add-credential-title"
style={{
fontSize: "1.125rem",
fontWeight: 600,
color: "var(--text, #111)",
margin: "0 0 8px",
}}
>
Add Credential
</h2>
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 16px" }}>
Securely store an API key, token, or secret.
</p>
<form
onSubmit={(e) => {
void handleSubmit(e);
}}
>
{/* Name */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="cred-name"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Name <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
</label>
<Input
id="cred-name"
type="text"
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
}}
placeholder="e.g. GitHub Personal Access Token"
maxLength={255}
autoFocus
/>
</div>
{/* Provider */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="cred-provider"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Provider <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
</label>
<Input
id="cred-provider"
type="text"
value={provider}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setProvider(e.target.value);
}}
placeholder="e.g. github, openai, custom"
maxLength={100}
/>
<p style={{ fontSize: "0.75rem", color: "var(--muted, #6b7280)", margin: "4px 0 0" }}>
The service or system this credential belongs to.
</p>
</div>
{/* Type */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="cred-type"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Type <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
</label>
<Select
value={type}
onValueChange={(v) => {
setType(v as CredentialType);
}}
>
<SelectTrigger id="cred-type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{Object.values(CredentialType).map((t) => (
<SelectItem key={t} value={t}>
{CREDENTIAL_TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Scope */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="cred-scope"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Scope
</label>
<Select
value={scope}
onValueChange={(v) => {
setScope(v as CredentialScope);
}}
>
<SelectTrigger id="cred-scope">
<SelectValue placeholder="Select scope" />
</SelectTrigger>
<SelectContent>
{Object.values(CredentialScope).map((s) => (
<SelectItem key={s} value={s}>
{CREDENTIAL_SCOPE_LABELS[s]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Value */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="cred-value"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Value <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
</label>
<div style={{ position: "relative" }}>
<Input
id="cred-value"
type={showValue ? "text" : "password"}
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
}}
placeholder="Paste your secret value here"
style={{ paddingRight: "40px" }}
/>
<button
type="button"
onClick={() => {
setShowValue((prev) => !prev);
}}
style={{
position: "absolute",
right: "10px",
top: "50%",
transform: "translateY(-50%)",
background: "none",
border: "none",
cursor: "pointer",
color: "var(--muted, #6b7280)",
padding: 0,
display: "flex",
alignItems: "center",
}}
aria-label={showValue ? "Hide value" : "Show value"}
>
{showValue ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<p style={{ fontSize: "0.75rem", color: "var(--muted, #6b7280)", margin: "4px 0 0" }}>
The value is encrypted at rest and never returned in plaintext.
</p>
</div>
{/* Description */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="cred-description"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Description
</label>
<textarea
id="cred-description"
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
placeholder="Optional description..."
rows={2}
maxLength={1000}
style={{
width: "100%",
padding: "8px 12px",
background: "var(--bg, #f9fafb)",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text, #111)",
fontSize: "0.9rem",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
boxSizing: "border-box",
}}
/>
</div>
{/* Form error */}
{formError !== null && (
<p
style={{
color: "var(--danger, #ef4444)",
fontSize: "0.85rem",
margin: "0 0 12px",
}}
>
{formError}
</p>
)}
{/* Buttons */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<button
type="button"
onClick={() => {
resetForm();
onOpenChange(false);
}}
disabled={isSubmitting}
style={{
padding: "8px 16px",
background: "transparent",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text-2, #374151)",
fontSize: "0.85rem",
cursor: "pointer",
}}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim() || !provider.trim() || !value.trim()}
style={{
padding: "8px 16px",
background: "var(--primary, #111827)",
border: "none",
borderRadius: "6px",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor:
isSubmitting || !name.trim() || !provider.trim() || !value.trim()
? "not-allowed"
: "pointer",
opacity:
isSubmitting || !name.trim() || !provider.trim() || !value.trim() ? 0.6 : 1,
}}
>
{isSubmitting ? "Saving..." : "Add Credential"}
</button>
</div>
</form>
</div>
</div>
);
}
/* ---------------------------------------------------------------------------
Rotate Credential Dialog
--------------------------------------------------------------------------- */
interface RotateCredentialDialogProps {
credential: Credential;
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (credentialId: string, newValue: string) => Promise<void>;
isSubmitting: boolean;
}
function RotateCredentialDialog({
credential,
open,
onOpenChange,
onSubmit,
isSubmitting,
}: RotateCredentialDialogProps): React.ReactElement | null {
const [newValue, setNewValue] = useState("");
const [showValue, setShowValue] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
function resetForm(): void {
setNewValue("");
setShowValue(false);
setFormError(null);
}
async function handleSubmit(e: SyntheticEvent): Promise<void> {
e.preventDefault();
setFormError(null);
const trimmedValue = newValue.trim();
if (!trimmedValue) {
setFormError("New value is required.");
return;
}
try {
await onSubmit(credential.id, trimmedValue);
resetForm();
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : "Failed to rotate credential.");
}
}
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="rotate-credential-title"
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{/* Backdrop */}
<div
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)" }}
onClick={() => {
if (!isSubmitting) {
resetForm();
onOpenChange(false);
}
}}
/>
{/* Dialog */}
<div
style={{
position: "relative",
background: "var(--surface, #fff)",
borderRadius: "8px",
border: "1px solid var(--border, #e5e7eb)",
padding: 24,
width: "100%",
maxWidth: 480,
zIndex: 1,
}}
>
<h2
id="rotate-credential-title"
style={{
fontSize: "1.125rem",
fontWeight: 600,
color: "var(--text, #111)",
margin: "0 0 8px",
}}
>
Rotate Credential
</h2>
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 4px" }}>
Replace the value for{" "}
<strong style={{ color: "var(--text, #111)" }}>{credential.name}</strong>.
</p>
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 16px" }}>
The old value will be permanently replaced.
</p>
<form
onSubmit={(e) => {
void handleSubmit(e);
}}
>
<div style={{ marginBottom: 16 }}>
<label
htmlFor="rotate-value"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
New Value <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
</label>
<div style={{ position: "relative" }}>
<Input
id="rotate-value"
type={showValue ? "text" : "password"}
value={newValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNewValue(e.target.value);
}}
placeholder="Paste new secret value"
autoFocus
style={{ paddingRight: "40px" }}
/>
<button
type="button"
onClick={() => {
setShowValue((prev) => !prev);
}}
style={{
position: "absolute",
right: "10px",
top: "50%",
transform: "translateY(-50%)",
background: "none",
border: "none",
cursor: "pointer",
color: "var(--muted, #6b7280)",
padding: 0,
display: "flex",
alignItems: "center",
}}
aria-label={showValue ? "Hide value" : "Show value"}
>
{showValue ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{formError !== null && (
<p
style={{
color: "var(--danger, #ef4444)",
fontSize: "0.85rem",
margin: "0 0 12px",
}}
>
{formError}
</p>
)}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 8 }}>
<button
type="button"
onClick={() => {
resetForm();
onOpenChange(false);
}}
disabled={isSubmitting}
style={{
padding: "8px 16px",
background: "transparent",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text-2, #374151)",
fontSize: "0.85rem",
cursor: "pointer",
}}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !newValue.trim()}
style={{
padding: "8px 16px",
background: "var(--primary, #111827)",
border: "none",
borderRadius: "6px",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor: isSubmitting || !newValue.trim() ? "not-allowed" : "pointer",
opacity: isSubmitting || !newValue.trim() ? 0.6 : 1,
}}
>
{isSubmitting ? "Rotating..." : "Rotate Credential"}
</button>
</div>
</form>
</div>
</div>
);
}
/* ---------------------------------------------------------------------------
Credential Row
--------------------------------------------------------------------------- */
interface CredentialRowProps {
credential: Credential;
onDelete: (credential: Credential) => void;
onRotate: (credential: Credential) => void;
}
function formatCredentialDate(date: Date | string | null): string {
if (!date) return "-";
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(date));
}
function CredentialRow({ credential, onDelete, onRotate }: CredentialRowProps): React.ReactElement {
return (
<tr className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Key size={14} className="text-gray-400 shrink-0" />
<div>
<p className="font-medium text-gray-900 text-sm">{credential.name}</p>
{credential.description !== null && credential.description.length > 0 && (
<p className="text-xs text-gray-500 mt-0.5">{credential.description}</p>
)}
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-600">
<span className="capitalize">{credential.provider}</span>
</td>
<td className="px-4 py-3">
<span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700">
{CREDENTIAL_TYPE_LABELS[credential.type]}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">
{CREDENTIAL_SCOPE_LABELS[credential.scope]}
</span>
</td>
<td className="px-4 py-3 text-xs text-gray-500 font-mono">
{credential.maskedValue ?? "••••••••"}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{formatCredentialDate(credential.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 justify-end">
<button
type="button"
onClick={() => {
onRotate(credential);
}}
title="Rotate credential value"
style={{
padding: "4px 8px",
background: "transparent",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "4px",
cursor: "pointer",
color: "var(--text-2, #374151)",
display: "flex",
alignItems: "center",
gap: 4,
fontSize: "0.75rem",
}}
>
<RotateCw size={12} />
Rotate
</button>
<button
type="button"
onClick={() => {
onDelete(credential);
}}
title="Delete credential"
style={{
padding: "4px 8px",
background: "transparent",
border: "1px solid var(--danger, #fca5a5)",
borderRadius: "4px",
cursor: "pointer",
color: "var(--danger, #ef4444)",
display: "flex",
alignItems: "center",
gap: 4,
fontSize: "0.75rem",
}}
>
<Trash2 size={12} />
Delete
</button>
</div>
</td>
</tr>
);
}
/* ---------------------------------------------------------------------------
Credentials Page
--------------------------------------------------------------------------- */
export default function CredentialsPage(): React.ReactElement {
const [credentials, setCredentials] = useState<Credential[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Add dialog state
const [addOpen, setAddOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
// Rotate dialog state
const [rotateTarget, setRotateTarget] = useState<Credential | null>(null);
const [isRotating, setIsRotating] = useState(false);
const workspaceId = useWorkspaceId();
useEffect(() => {
if (!workspaceId) return;
void loadCredentials(workspaceId);
}, [workspaceId]);
async function loadCredentials(wsId: string): Promise<void> {
try {
setIsLoading(true);
const response = await fetchCredentials(wsId);
setCredentials(response.data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load credentials");
} finally {
setIsLoading(false);
}
}
async function handleAdd(data: CreateCredentialDto): Promise<void> {
if (!workspaceId) return;
setIsAdding(true);
try {
await createCredential(workspaceId, data);
setAddOpen(false);
await loadCredentials(workspaceId);
} finally {
setIsAdding(false);
}
}
async function handleDelete(credential: Credential): Promise<void> {
if (!workspaceId) return;
if (!confirm(`Delete credential "${credential.name}"? This action cannot be undone.`)) return;
try {
await deleteCredential(credential.id, workspaceId);
await loadCredentials(workspaceId);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete credential");
}
}
async function handleRotate(credentialId: string, newValue: string): Promise<void> {
if (!workspaceId) return;
setIsRotating(true);
try {
await rotateCredential(credentialId, workspaceId, newValue);
setRotateTarget(null);
await loadCredentials(workspaceId);
} finally {
setIsRotating(false);
}
}
return (
<div className="max-w-6xl mx-auto p-6">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Credentials</h1>
<p className="text-muted-foreground mt-1">
Securely store and manage API keys, tokens, and passwords
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => {
setAddOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add Credential
</Button>
<Link href="/settings/credentials/audit">
<Button variant="outline">
<History className="mr-2 h-4 w-4" />
Audit Log
</Button>
</Link>
</div>
</div>
</div>
{/* Error Display */}
{error !== null && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-800 rounded-md">
{error}
</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="text-center py-12">
<p className="text-gray-500">Loading credentials...</p>
</div>
) : credentials.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Key size={32} className="text-gray-300 mb-4" />
<p className="text-gray-500 mb-2 font-medium">No credentials yet</p>
<p className="text-gray-400 text-sm mb-4">
Add your first API key, token, or secret to get started.
</p>
<Button
onClick={() => {
setAddOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
Add First Credential
</Button>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Stored Credentials</CardTitle>
<CardDescription>
{credentials.length} credential{credentials.length !== 1 ? "s" : ""} stored
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{/* Desktop table */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-700">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-700">Provider</th>
<th className="px-4 py-3 text-left font-medium text-gray-700">Type</th>
<th className="px-4 py-3 text-left font-medium text-gray-700">Scope</th>
<th className="px-4 py-3 text-left font-medium text-gray-700">Value</th>
<th className="px-4 py-3 text-left font-medium text-gray-700">Created</th>
<th className="px-4 py-3 text-right font-medium text-gray-700">Actions</th>
</tr>
</thead>
<tbody>
{credentials.map((cred) => (
<CredentialRow
key={cred.id}
credential={cred}
onDelete={(c) => {
void handleDelete(c);
}}
onRotate={(c) => {
setRotateTarget(c);
}}
/>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="md:hidden divide-y divide-gray-100">
{credentials.map((cred) => (
<div key={cred.id} className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<Key size={14} className="text-gray-400 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-gray-900 text-sm">{cred.name}</p>
{cred.description !== null && cred.description.length > 0 && (
<p className="text-xs text-gray-500 mt-0.5">{cred.description}</p>
)}
</div>
</div>
<span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700">
{CREDENTIAL_TYPE_LABELS[cred.type]}
</span>
</div>
<div className="text-xs text-gray-500 mb-3 font-mono">
{cred.maskedValue ?? "••••••••"}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setRotateTarget(cred);
}}
style={{
padding: "4px 10px",
background: "transparent",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "4px",
cursor: "pointer",
color: "var(--text-2, #374151)",
display: "flex",
alignItems: "center",
gap: 4,
fontSize: "0.75rem",
}}
>
<RotateCw size={12} />
Rotate
</button>
<button
type="button"
onClick={() => {
void handleDelete(cred);
}}
style={{
padding: "4px 10px",
background: "transparent",
border: "1px solid var(--danger, #fca5a5)",
borderRadius: "4px",
cursor: "pointer",
color: "var(--danger, #ef4444)",
display: "flex",
alignItems: "center",
gap: 4,
fontSize: "0.75rem",
}}
>
<Trash2 size={12} />
Delete
</button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Add Credential Dialog */}
<AddCredentialDialog
open={addOpen}
onOpenChange={setAddOpen}
onSubmit={handleAdd}
isSubmitting={isAdding}
/>
{/* Rotate Credential Dialog */}
{rotateTarget !== null && (
<RotateCredentialDialog
credential={rotateTarget}
open
onOpenChange={(open) => {
if (!open) setRotateTarget(null);
}}
onSubmit={handleRotate}
isSubmitting={isRotating}
/>
)}
</div>
);
}