All checks were successful
ci/woodpecker/push/web Pipeline was successful
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>
1069 lines
33 KiB
TypeScript
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>
|
|
);
|
|
}
|