feat(web): add credential management UI pages and components

Add credentials settings page, audit log page, CRUD dialog components
(create, view, edit, rotate), credential card, dialog UI component,
and API client for the M7-CredentialSecurity feature.

Refs #346

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 09:42:41 -06:00
parent ab64583951
commit 6a5a4e4de8
10 changed files with 1841 additions and 0 deletions

View File

@@ -0,0 +1,361 @@
"use client";
import { useState, useEffect } from "react";
import { ArrowLeft, Filter, X } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials";
const ACTIVITY_ACTIONS = [
{ value: "CREDENTIAL_CREATED", label: "Created" },
{ value: "CREDENTIAL_ACCESSED", label: "Accessed" },
{ value: "CREDENTIAL_ROTATED", label: "Rotated" },
{ value: "CREDENTIAL_REVOKED", label: "Revoked" },
{ value: "UPDATED", label: "Updated" },
];
interface FilterState {
action?: string;
startDate?: string;
endDate?: string;
}
export default function CredentialAuditPage(): React.ReactElement {
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [limit] = useState(20);
const [totalPages, setTotalPages] = useState(0);
const [filters, setFilters] = useState<FilterState>({});
const [hasFilters, setHasFilters] = useState(false);
// TODO: Get workspace ID from context/auth
const workspaceId = "default-workspace-id"; // Placeholder
useEffect(() => {
void loadLogs();
}, [page, filters]);
async function loadLogs(): Promise<void> {
try {
setIsLoading(true);
const response = await fetchCredentialAuditLog(workspaceId, {
...filters,
page,
limit,
});
setLogs(response.data);
setTotalPages(response.meta.totalPages);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load audit logs");
setLogs([]);
} finally {
setIsLoading(false);
}
}
function handleFilterChange(filterKey: keyof FilterState, value: string | undefined): void {
const newFilters = { ...filters, [filterKey]: value };
if (!value) {
const { [filterKey]: _, ...rest } = newFilters;
setFilters(rest);
setHasFilters(Object.keys(rest).length > 0);
setPage(1);
return;
}
setFilters(newFilters);
setHasFilters(Object.keys(newFilters).length > 0);
setPage(1);
}
function handleClearFilters(): void {
setFilters({});
setHasFilters(false);
setPage(1);
}
function formatTimestamp(dateString: string): string {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
function getActionBadgeColor(action: string): string {
const colors: Record<string, string> = {
CREDENTIAL_CREATED: "bg-green-100 text-green-800",
CREDENTIAL_ACCESSED: "bg-blue-100 text-blue-800",
CREDENTIAL_ROTATED: "bg-purple-100 text-purple-800",
CREDENTIAL_REVOKED: "bg-red-100 text-red-800",
UPDATED: "bg-yellow-100 text-yellow-800",
};
return colors[action] ?? "bg-gray-100 text-gray-800";
}
function getActionLabel(action: string): string {
const label = ACTIVITY_ACTIONS.find((a) => a.value === action)?.label;
return label ?? action;
}
return (
<main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<Link href="/settings/credentials">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Credentials
</Button>
</Link>
</div>
<div className="mb-2">
<h1 className="text-3xl font-bold text-gray-900">Credential Audit Log</h1>
<p className="text-gray-600 mt-1">
View all activities related to your stored credentials
</p>
</div>
</div>
{/* Filters Card */}
<Card className="mb-6">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-gray-600" />
<CardTitle className="text-lg">Filter Logs</CardTitle>
</div>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
<X className="w-4 h-4 mr-1" />
Clear Filters
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Action Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Activity Type</label>
<Select
value={filters.action ?? ""}
onValueChange={(value) => {
handleFilterChange("action", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="All activities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All activities</SelectItem>
{ACTIVITY_ACTIONS.map((action) => (
<SelectItem key={action.value} value={action.value}>
{action.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Start Date Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">From Date</label>
<Input
type="date"
value={filters.startDate ?? ""}
onChange={(e) => {
handleFilterChange("startDate", e.target.value);
}}
/>
</div>
{/* End Date Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">To Date</label>
<Input
type="date"
value={filters.endDate ?? ""}
onChange={(e) => {
handleFilterChange("endDate", e.target.value);
}}
/>
</div>
</div>
</CardContent>
</Card>
{/* Audit Logs List */}
<Card>
<CardHeader>
<CardTitle>Activity History</CardTitle>
<CardDescription>
{logs.length > 0
? `Showing ${String((page - 1) * limit + 1)}-${String(Math.min(page * limit, logs.length))} entries`
: "No activities found"}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500">Loading audit logs...</p>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-800">{error}</p>
</div>
) : logs.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-gray-500 text-lg">No audit logs found</p>
<p className="text-gray-400 text-sm mt-1">
{hasFilters ? "Try adjusting your filters" : "No credential activities yet"}
</p>
</div>
</div>
) : (
<div className="space-y-4">
{/* Desktop view */}
<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">Timestamp</th>
<th className="px-4 py-3 text-left font-medium text-gray-700">Activity</th>
<th className="px-4 py-3 text-left font-medium text-gray-700">User</th>
<th className="px-4 py-3 text-left font-medium text-gray-700">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-600 text-xs whitespace-nowrap">
{formatTimestamp(log.createdAt)}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-1 rounded text-xs font-medium ${getActionBadgeColor(log.action)}`}
>
{getActionLabel(log.action)}
</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-gray-900">
{log.user.name ?? "Unknown"}
</p>
<p className="text-xs text-gray-500">{log.user.email}</p>
</div>
</td>
<td className="px-4 py-3 text-xs text-gray-600">
<div className="text-ellipsis overflow-hidden">
{(log.details.name as string) && (
<p>
<span className="font-medium">Name:</span>{" "}
{log.details.name as string}
</p>
)}
{(log.details.provider as string) && (
<p>
<span className="font-medium">Provider:</span>{" "}
{log.details.provider as string}
</p>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile view */}
<div className="md:hidden space-y-3">
{logs.map((log) => (
<div key={log.id} className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex items-center justify-between mb-2">
<span
className={`inline-block px-2 py-1 rounded text-xs font-medium ${getActionBadgeColor(log.action)}`}
>
{getActionLabel(log.action)}
</span>
<span className="text-xs text-gray-500">
{formatTimestamp(log.createdAt)}
</span>
</div>
<div className="mb-2">
<p className="font-medium text-gray-900">{log.user.name ?? "Unknown"}</p>
<p className="text-xs text-gray-600">{log.user.email}</p>
</div>
{((log.details.name as string) || (log.details.provider as string)) && (
<div className="text-xs text-gray-600 border-t border-gray-200 pt-2 mt-2">
{(log.details.name as string) && (
<p>
<span className="font-medium">Name:</span> {log.details.name as string}
</p>
)}
{(log.details.provider as string) && (
<p>
<span className="font-medium">Provider:</span>{" "}
{log.details.provider as string}
</p>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-6 border-t border-gray-200">
<div className="text-sm text-gray-600">
Page {page} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 1}
onClick={() => {
setPage(Math.max(1, page - 1));
}}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page === totalPages}
onClick={() => {
setPage(Math.min(totalPages, page + 1));
}}
>
Next
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</main>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useState, useEffect } from "react";
import { Plus, History } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { fetchCredentials, type Credential } from "@/lib/api/credentials";
export default function CredentialsPage(): React.ReactElement {
const [credentials, setCredentials] = useState<Credential[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const workspaceId = "default-workspace-id";
useEffect(() => {
void loadCredentials();
}, []);
async function loadCredentials(): Promise<void> {
try {
setIsLoading(true);
const response = await fetchCredentials(workspaceId);
setCredentials(response.data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load credentials");
} finally {
setIsLoading(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 disabled>
<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 && <div className="mb-4 p-4 bg-red-50 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">
<p className="text-gray-500 mb-4">No credentials found</p>
<Button disabled>
<Plus className="mr-2 h-4 w-4" />
Add First Credential
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 grid-cols-1">
<Card>
<CardContent className="pt-6">
<div className="text-sm text-gray-600">
<p>Credentials feature coming soon.</p>
<p className="mt-2">
<Link href="/settings/credentials/audit">
<Button variant="link" className="p-0">
View Audit Log
</Button>
</Link>
</p>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,247 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CredentialType, CredentialScope } from "@/lib/api/credentials";
interface CreateCredentialDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: CreateCredentialFormData) => Promise<void>;
}
export interface CreateCredentialFormData {
name: string;
provider: string;
type: CredentialType;
scope: CredentialScope;
value: string;
description?: string;
expiresAt?: string;
}
const PROVIDERS = [
{ value: "github", label: "GitHub" },
{ value: "gitlab", label: "GitLab" },
{ value: "bitbucket", label: "Bitbucket" },
{ value: "openai", label: "OpenAI" },
{ value: "custom", label: "Custom" },
];
export function CreateCredentialDialog({
open,
onOpenChange,
onSubmit,
}: CreateCredentialDialogProps): React.ReactElement {
const [formData, setFormData] = useState<CreateCredentialFormData>({
name: "",
provider: "custom",
type: CredentialType.API_KEY,
scope: CredentialScope.USER,
value: "",
description: "",
expiresAt: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
e.preventDefault();
setError(null);
// Validation
if (!formData.name.trim()) {
setError("Name is required");
return;
}
if (!formData.value.trim()) {
setError("Value is required");
return;
}
setIsSubmitting(true);
try {
await onSubmit(formData);
// Reset form on success
setFormData({
name: "",
provider: "custom",
type: CredentialType.API_KEY,
scope: CredentialScope.USER,
value: "",
description: "",
expiresAt: "",
});
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create credential");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Add Credential</DialogTitle>
<DialogDescription>
Store a new credential securely. All values are encrypted at rest.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Name */}
<div className="grid gap-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
}}
placeholder="e.g., GitHub Personal Token"
disabled={isSubmitting}
/>
</div>
{/* Provider */}
<div className="grid gap-2">
<Label htmlFor="provider">Provider</Label>
<Select
value={formData.provider}
onValueChange={(value) => {
setFormData({ ...formData, provider: value });
}}
disabled={isSubmitting}
>
<SelectTrigger id="provider">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map((provider) => (
<SelectItem key={provider.value} value={provider.value}>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Type */}
<div className="grid gap-2">
<Label htmlFor="type">Type</Label>
<Select
value={formData.type}
onValueChange={(value) => {
setFormData({ ...formData, type: value as CredentialType });
}}
disabled={isSubmitting}
>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={CredentialType.API_KEY}>API Key</SelectItem>
<SelectItem value={CredentialType.ACCESS_TOKEN}>Access Token</SelectItem>
<SelectItem value={CredentialType.OAUTH_TOKEN}>OAuth Token</SelectItem>
<SelectItem value={CredentialType.PASSWORD}>Password</SelectItem>
<SelectItem value={CredentialType.SECRET}>Secret</SelectItem>
<SelectItem value={CredentialType.CUSTOM}>Custom</SelectItem>
</SelectContent>
</Select>
</div>
{/* Value */}
<div className="grid gap-2">
<Label htmlFor="value">Value *</Label>
<Input
id="value"
type="password"
value={formData.value}
onChange={(e) => {
setFormData({ ...formData, value: e.target.value });
}}
placeholder="Enter credential value"
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
This value will be encrypted and cannot be viewed in the list
</p>
</div>
{/* Description */}
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => {
setFormData({ ...formData, description: e.target.value });
}}
placeholder="Optional description"
disabled={isSubmitting}
rows={2}
/>
</div>
{/* Expiry Date */}
<div className="grid gap-2">
<Label htmlFor="expiresAt">Target Date (optional)</Label>
<Input
id="expiresAt"
type="date"
value={formData.expiresAt}
onChange={(e) => {
setFormData({ ...formData, expiresAt: e.target.value });
}}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Consider rotating the credential by this date
</p>
</div>
{/* Error Display */}
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false);
}}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Credential"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Eye, Pencil, RotateCw, Trash2 } from "lucide-react";
import type { Credential } from "@/lib/api/credentials";
import { getExpiryStatus } from "@/lib/api/credentials";
interface CredentialCardProps {
credential: Credential;
onView: (credential: Credential) => void;
onEdit: (credential: Credential) => void;
onRotate: (credential: Credential) => void;
onDelete: (credential: Credential) => void;
}
export function CredentialCard({
credential,
onView,
onEdit,
onRotate,
onDelete,
}: CredentialCardProps): React.ReactElement {
const expiryInfo = getExpiryStatus(credential.expiresAt);
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
{credential.name}
{!credential.isActive && <Badge variant="outline">Inactive</Badge>}
</CardTitle>
{credential.description && (
<p className="mt-1 text-sm text-muted-foreground">{credential.description}</p>
)}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
onView(credential);
}}
title="View details"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
onEdit(credential);
}}
title="Edit metadata"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
onRotate(credential);
}}
title="Rotate value"
>
<RotateCw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
onDelete(credential);
}}
title="Remove credential"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-3">
{/* Provider & Type */}
<div className="flex gap-4 text-sm">
<div>
<span className="text-muted-foreground">Provider:</span>
<Badge variant="outline" className="ml-2">
{credential.provider}
</Badge>
</div>
<div>
<span className="text-muted-foreground">Type:</span>
<Badge variant="outline" className="ml-2">
{credential.type.replace(/_/g, " ")}
</Badge>
</div>
</div>
{/* Masked Value */}
<div>
<p className="mb-1 text-sm text-muted-foreground">Value (masked)</p>
<code className="block rounded bg-muted px-3 py-2 font-mono text-sm">
{credential.maskedValue ?? "••••••••"}
</code>
</div>
{/* Expiry & Last Used */}
<div className="flex flex-wrap gap-4 text-sm">
{credential.expiresAt && (
<div>
<span className="text-muted-foreground">Target:</span>
<Badge variant="outline" className={`ml-2 ${expiryInfo.className}`}>
{expiryInfo.label}
</Badge>
</div>
)}
{credential.lastUsedAt && (
<div>
<span className="text-muted-foreground">Last used:</span>
<span className="ml-2">{new Date(credential.lastUsedAt).toLocaleDateString()}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { Credential } from "@/lib/api/credentials";
interface EditCredentialDialogProps {
credential: Credential | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (id: string, data: EditCredentialFormData) => Promise<void>;
}
export interface EditCredentialFormData {
name?: string;
description?: string;
expiresAt?: string;
}
export function EditCredentialDialog({
credential,
open,
onOpenChange,
onSubmit,
}: EditCredentialDialogProps): React.ReactElement {
const [formData, setFormData] = useState<EditCredentialFormData>({
name: "",
description: "",
expiresAt: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Initialize form when credential changes
useEffect(() => {
if (credential) {
const expiryDate = credential.expiresAt
? new Date(credential.expiresAt).toISOString().split("T")[0]
: "";
setFormData({
name: credential.name,
description: credential.description ?? "",
...(expiryDate && { expiresAt: expiryDate }),
});
}
}, [credential]);
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
e.preventDefault();
if (!credential) return;
setError(null);
// Validation
if (!formData.name?.trim()) {
setError("Name is required");
return;
}
setIsSubmitting(true);
try {
await onSubmit(credential.id, formData);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update credential");
} finally {
setIsSubmitting(false);
}
};
if (!credential) return <></>;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Edit Credential</DialogTitle>
<DialogDescription>
Update credential metadata. To change the value, use the Rotate option.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Name */}
<div className="grid gap-2">
<Label htmlFor="edit-name">Name *</Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
}}
placeholder="e.g., GitHub Personal Token"
disabled={isSubmitting}
/>
</div>
{/* Description */}
<div className="grid gap-2">
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
value={formData.description}
onChange={(e) => {
setFormData({ ...formData, description: e.target.value });
}}
placeholder="Optional description"
disabled={isSubmitting}
rows={3}
/>
</div>
{/* Expiry Date */}
<div className="grid gap-2">
<Label htmlFor="edit-expiresAt">Target Date</Label>
<Input
id="edit-expiresAt"
type="date"
value={formData.expiresAt}
onChange={(e) => {
setFormData({ ...formData, expiresAt: e.target.value });
}}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Consider rotating the credential by this date
</p>
</div>
{/* Provider & Type (read-only) */}
<div className="rounded-lg bg-muted p-3">
<p className="mb-2 text-sm font-medium">Read-Only Fields</p>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Provider:</span>
<span className="ml-2">{credential.provider}</span>
</div>
<div>
<span className="text-muted-foreground">Type:</span>
<span className="ml-2">{credential.type.replace(/_/g, " ")}</span>
</div>
</div>
</div>
{/* Error Display */}
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false);
}}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { Credential } from "@/lib/api/credentials";
interface RotateCredentialDialogProps {
credential: Credential | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (id: string, newValue: string) => Promise<void>;
}
export function RotateCredentialDialog({
credential,
open,
onOpenChange,
onSubmit,
}: RotateCredentialDialogProps): React.ReactElement {
const [newValue, setNewValue] = useState("");
const [confirmValue, setConfirmValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.SyntheticEvent): Promise<void> => {
e.preventDefault();
if (!credential) return;
setError(null);
// Validation
if (!newValue.trim()) {
setError("New value is required");
return;
}
if (newValue !== confirmValue) {
setError("Values do not match");
return;
}
setIsSubmitting(true);
try {
await onSubmit(credential.id, newValue);
setNewValue("");
setConfirmValue("");
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to rotate credential");
} finally {
setIsSubmitting(false);
}
};
if (!credential) return <></>;
return (
<Dialog
open={open}
onOpenChange={(open) => {
onOpenChange(open);
if (!open) {
setNewValue("");
setConfirmValue("");
setError(null);
}
}}
>
<DialogContent className="sm:max-w-[525px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Rotate Credential</DialogTitle>
<DialogDescription>
Replace the credential value with a new one. The old value will be permanently
replaced.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Credential Info */}
<div className="rounded-lg bg-muted p-3">
<p className="mb-1 font-medium">{credential.name}</p>
<p className="text-sm text-muted-foreground">
{credential.provider} {credential.type.replace(/_/g, " ")}
</p>
</div>
{/* New Value */}
<div className="grid gap-2">
<Label htmlFor="rotate-new-value">New Value *</Label>
<Input
id="rotate-new-value"
type="password"
value={newValue}
onChange={(e) => {
setNewValue(e.target.value);
}}
placeholder="Enter new credential value"
disabled={isSubmitting}
/>
</div>
{/* Confirm Value */}
<div className="grid gap-2">
<Label htmlFor="rotate-confirm-value">Confirm New Value *</Label>
<Input
id="rotate-confirm-value"
type="password"
value={confirmValue}
onChange={(e) => {
setConfirmValue(e.target.value);
}}
placeholder="Re-enter new credential value"
disabled={isSubmitting}
/>
</div>
{/* Warning */}
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
<p className="text-sm text-orange-900">
<strong>Note:</strong> This will permanently replace the existing credential value.
The old value cannot be recovered after rotation.
</p>
</div>
{/* Error Display */}
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false);
}}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Rotating..." : "Rotate Credential"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,257 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Eye, EyeOff, Copy, Check } from "lucide-react";
import type { Credential } from "@/lib/api/credentials";
import { getExpiryStatus } from "@/lib/api/credentials";
interface ViewCredentialDialogProps {
credential: Credential | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onRevealValue: (id: string) => Promise<string>;
}
const AUTO_HIDE_DURATION_MS = 30000; // 30 seconds
export function ViewCredentialDialog({
credential,
open,
onOpenChange,
onRevealValue,
}: ViewCredentialDialogProps): React.ReactElement {
const [isRevealing, setIsRevealing] = useState(false);
const [revealedValue, setRevealedValue] = useState<string | null>(null);
const [showWarning, setShowWarning] = useState(false);
const [autoHideTimer, setAutoHideTimer] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
// Cleanup on unmount or dialog close
useEffect(() => {
if (!open) {
setRevealedValue(null);
setShowWarning(false);
setError(null);
if (autoHideTimer) {
clearTimeout(autoHideTimer);
setAutoHideTimer(null);
}
}
}, [open, autoHideTimer]);
const handleRevealClick = useCallback((): void => {
setShowWarning(true);
}, []);
const handleConfirmReveal = useCallback(async (): Promise<void> => {
if (!credential) return;
setIsRevealing(true);
setError(null);
setShowWarning(false);
try {
const value = await onRevealValue(credential.id);
setRevealedValue(value);
// Auto-hide after 30 seconds
const timerId = window.setTimeout(() => {
setRevealedValue(null);
setAutoHideTimer(null);
}, AUTO_HIDE_DURATION_MS);
setAutoHideTimer(timerId);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to reveal credential. You may have exceeded the rate limit (10 requests/minute)."
);
} finally {
setIsRevealing(false);
}
}, [credential, onRevealValue]);
const handleCopy = useCallback(async (): Promise<void> => {
if (!revealedValue) return;
try {
await navigator.clipboard.writeText(revealedValue);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}, [revealedValue]);
const handleHideValue = useCallback((): void => {
setRevealedValue(null);
if (autoHideTimer) {
clearTimeout(autoHideTimer);
setAutoHideTimer(null);
}
}, [autoHideTimer]);
if (!credential) return <></>;
const expiryInfo = getExpiryStatus(credential.expiresAt);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>{credential.name}</DialogTitle>
<DialogDescription>View credential details and reveal value</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Provider & Type */}
<div className="flex gap-4">
<div>
<p className="text-sm text-muted-foreground">Provider</p>
<Badge variant="outline" className="mt-1">
{credential.provider}
</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">Type</p>
<Badge variant="outline" className="mt-1">
{credential.type.replace(/_/g, " ")}
</Badge>
</div>
</div>
{/* Description */}
{credential.description && (
<div>
<p className="text-sm text-muted-foreground">Description</p>
<p className="mt-1 text-sm">{credential.description}</p>
</div>
)}
{/* Masked Value */}
<div>
<p className="text-sm text-muted-foreground">Masked Value</p>
<code className="mt-1 block rounded bg-muted px-3 py-2 font-mono text-sm">
{credential.maskedValue ?? "••••••••"}
</code>
</div>
{/* Revealed Value */}
{revealedValue && (
<div className="rounded-lg border border-orange-200 bg-orange-50 p-4">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-medium text-orange-900">Decrypted Value</p>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={handleCopy}
className="h-8 text-orange-900 hover:bg-orange-100"
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleHideValue}
className="h-8 text-orange-900 hover:bg-orange-100"
>
<EyeOff className="h-4 w-4" />
</Button>
</div>
</div>
<code className="block rounded bg-white px-3 py-2 font-mono text-sm text-orange-900 break-all">
{revealedValue}
</code>
<p className="mt-2 text-xs text-orange-700">Value will auto-hide in 30 seconds</p>
</div>
)}
{/* Warning before reveal */}
{showWarning && !revealedValue && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<p className="mb-2 text-sm font-medium text-yellow-900">Reveal Credential Value?</p>
<p className="mb-4 text-sm text-yellow-700">
This will decrypt and display the credential value in plaintext. This action is
logged. The value will auto-hide after 30 seconds.
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setShowWarning(false);
}}
disabled={isRevealing}
>
Cancel
</Button>
<Button size="sm" onClick={handleConfirmReveal} disabled={isRevealing}>
{isRevealing ? "Revealing..." : "Confirm Reveal"}
</Button>
</div>
</div>
)}
{/* Reveal Button */}
{!showWarning && !revealedValue && (
<Button onClick={handleRevealClick} variant="outline" className="w-full">
<Eye className="mr-2 h-4 w-4" />
Reveal Value
</Button>
)}
{/* Error Display */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Expiry Status */}
{credential.expiresAt && (
<div>
<p className="text-sm text-muted-foreground">Target Date</p>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className={expiryInfo.className}>
{expiryInfo.label}
</Badge>
<span className="text-sm text-muted-foreground">
{new Date(credential.expiresAt).toLocaleDateString()}
</span>
</div>
</div>
)}
{/* Last Used */}
{credential.lastUsedAt && (
<div>
<p className="text-sm text-muted-foreground">Last Used</p>
<p className="mt-1 text-sm">{new Date(credential.lastUsedAt).toLocaleString()}</p>
</div>
)}
{/* Metadata */}
{credential.rotatedAt && (
<div>
<p className="text-sm text-muted-foreground">Last Rotated</p>
<p className="mt-1 text-sm">{new Date(credential.rotatedAt).toLocaleString()}</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,7 @@
export { CredentialCard } from "./CredentialCard";
export { CreateCredentialDialog } from "./CreateCredentialDialog";
export type { CreateCredentialFormData } from "./CreateCredentialDialog";
export { EditCredentialDialog } from "./EditCredentialDialog";
export type { EditCredentialFormData } from "./EditCredentialDialog";
export { RotateCredentialDialog } from "./RotateCredentialDialog";
export { ViewCredentialDialog } from "./ViewCredentialDialog";

View File

@@ -0,0 +1,128 @@
import * as React from "react";
import { X } from "lucide-react";
export interface DialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
}
export interface DialogTriggerProps {
children?: React.ReactNode;
asChild?: boolean;
}
export interface DialogContentProps {
children?: React.ReactNode;
className?: string;
}
export interface DialogHeaderProps {
children?: React.ReactNode;
className?: string;
}
export interface DialogFooterProps {
children?: React.ReactNode;
className?: string;
}
export interface DialogTitleProps {
children?: React.ReactNode;
className?: string;
}
export interface DialogDescriptionProps {
children?: React.ReactNode;
className?: string;
}
const DialogContext = React.createContext<{
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>({});
export function Dialog({ open, onOpenChange, children }: DialogProps): React.JSX.Element {
const contextValue: { open?: boolean; onOpenChange?: (open: boolean) => void } = {};
if (open !== undefined) {
contextValue.open = open;
}
if (onOpenChange !== undefined) {
contextValue.onOpenChange = onOpenChange;
}
return <DialogContext.Provider value={contextValue}>{children}</DialogContext.Provider>;
}
export function DialogTrigger({ children, asChild }: DialogTriggerProps): React.JSX.Element {
const { onOpenChange } = React.useContext(DialogContext);
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
onClick: () => onOpenChange?.(true),
} as React.HTMLAttributes<HTMLElement>);
}
return <div onClick={() => onOpenChange?.(true)}>{children}</div>;
}
export function DialogContent({
children,
className = "",
}: DialogContentProps): React.JSX.Element | null {
const { open, onOpenChange } = React.useContext(DialogContext);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Overlay */}
<div className="fixed inset-0 bg-black/50" onClick={() => onOpenChange?.(false)} />
{/* Dialog Content */}
<div
className={`relative z-50 w-full max-w-lg rounded-lg bg-white p-6 shadow-lg ${className}`}
>
{/* Close Button */}
<button
onClick={() => onOpenChange?.(false)}
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 transition-opacity"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
{children}
</div>
</div>
);
}
export function DialogHeader({ children, className = "" }: DialogHeaderProps): React.JSX.Element {
return <div className={`mb-4 ${className}`}>{children}</div>;
}
export function DialogFooter({ children, className = "" }: DialogFooterProps): React.JSX.Element {
return <div className={`mt-4 flex justify-end gap-2 ${className}`}>{children}</div>;
}
export function DialogTitle({ children, className = "" }: DialogTitleProps): React.JSX.Element {
return <h2 className={`text-lg font-semibold ${className}`}>{children}</h2>;
}
export function DialogDescription({
children,
className = "",
}: DialogDescriptionProps): React.JSX.Element {
return <p className={`text-sm text-gray-600 ${className}`}>{children}</p>;
}
export const DialogPortal = ({ children }: { children: React.ReactNode }): React.JSX.Element => (
<>{children}</>
);
export const DialogOverlay = ({ children }: { children: React.ReactNode }): React.JSX.Element => (
<>{children}</>
);
export const DialogClose = ({ children }: { children: React.ReactNode }): React.JSX.Element => (
<>{children}</>
);

View File

@@ -0,0 +1,274 @@
/**
* Credentials API Client
* Handles credential-related API requests
*/
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
/**
* Credential type enum (matches backend)
*/
export enum CredentialType {
API_KEY = "API_KEY",
OAUTH_TOKEN = "OAUTH_TOKEN",
ACCESS_TOKEN = "ACCESS_TOKEN",
SECRET = "SECRET",
PASSWORD = "PASSWORD",
CUSTOM = "CUSTOM",
}
/**
* Credential scope enum (matches backend)
*/
export enum CredentialScope {
USER = "USER",
WORKSPACE = "WORKSPACE",
SYSTEM = "SYSTEM",
}
/**
* Credential response interface
*/
export interface Credential {
id: string;
userId: string;
workspaceId: string | null;
name: string;
provider: string;
type: CredentialType;
scope: CredentialScope;
maskedValue: string | null;
description: string | null;
expiresAt: Date | null;
lastUsedAt: Date | null;
metadata: Record<string, unknown>;
isActive: boolean;
rotatedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Create credential DTO
*/
export interface CreateCredentialDto {
name: string;
provider: string;
type: CredentialType;
scope?: CredentialScope;
value: string;
description?: string;
expiresAt?: Date;
metadata?: Record<string, unknown>;
}
/**
* Update credential DTO
*/
export interface UpdateCredentialDto {
name?: string;
description?: string;
expiresAt?: Date;
metadata?: Record<string, unknown>;
}
/**
* Query credential DTO
*/
export interface QueryCredentialDto {
provider?: string;
type?: CredentialType;
scope?: CredentialScope;
isActive?: boolean;
page?: number;
limit?: number;
}
/**
* Credential value response
*/
export interface CredentialValueResponse {
value: string;
}
/**
* Fetch all credentials with optional filters
* NEVER returns plaintext values - only maskedValue
*/
export async function fetchCredentials(
workspaceId: string,
query?: QueryCredentialDto
): Promise<ApiResponse<Credential[]>> {
const params = new URLSearchParams();
if (query?.provider) params.append("provider", query.provider);
if (query?.type) params.append("type", query.type);
if (query?.scope) params.append("scope", query.scope);
if (query?.isActive !== undefined) params.append("isActive", String(query.isActive));
if (query?.page) params.append("page", String(query.page));
if (query?.limit) params.append("limit", String(query.limit));
const endpoint = `/api/credentials${params.toString() ? `?${params.toString()}` : ""}`;
return apiGet<ApiResponse<Credential[]>>(endpoint, workspaceId);
}
/**
* Fetch a single credential by ID
* NEVER returns plaintext value - only maskedValue
*/
export async function fetchCredential(id: string, workspaceId: string): Promise<Credential> {
return apiGet<Credential>(`/api/credentials/${id}`, workspaceId);
}
/**
* Decrypt and return credential value
* CRITICAL: This is the ONLY endpoint that returns plaintext
* Rate limited to 10 requests per minute per user
*/
export async function fetchCredentialValue(
id: string,
workspaceId: string
): Promise<CredentialValueResponse> {
return apiGet<CredentialValueResponse>(`/api/credentials/${id}/value`, workspaceId);
}
/**
* Create a new credential
*/
export async function createCredential(
workspaceId: string,
data: CreateCredentialDto
): Promise<Credential> {
return apiPost<Credential>("/api/credentials", data, workspaceId);
}
/**
* Update credential metadata (NOT the value itself)
* Use rotateCredential to change the credential value
*/
export async function updateCredential(
id: string,
workspaceId: string,
data: UpdateCredentialDto
): Promise<Credential> {
return apiPatch<Credential>(`/api/credentials/${id}`, data, workspaceId);
}
/**
* Replace credential value with new encrypted value
*/
export async function rotateCredential(
id: string,
workspaceId: string,
newValue: string
): Promise<Credential> {
return apiPost<Credential>(`/api/credentials/${id}/rotate`, { newValue }, workspaceId);
}
/**
* Soft-delete credential (set isActive = false)
*/
export async function deleteCredential(
id: string,
workspaceId: string
): Promise<Record<string, never>> {
return apiDelete<Record<string, never>>(`/api/credentials/${id}`, workspaceId);
}
/**
* Audit log entry interface
*/
export interface AuditLogEntry {
id: string;
action: string;
entityId: string;
createdAt: string;
details: Record<string, unknown>;
user: {
id: string;
name: string | null;
email: string;
};
}
/**
* Query parameters for audit log
*/
export interface QueryAuditLogDto {
credentialId?: string;
action?: string;
startDate?: string;
endDate?: string;
page?: number;
limit?: number;
}
/**
* Fetch credential audit logs with optional filters
* Shows all credential-related activities for the workspace
*/
export async function fetchCredentialAuditLog(
workspaceId: string,
query?: QueryAuditLogDto
): Promise<{
data: AuditLogEntry[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}> {
const params = new URLSearchParams();
if (query?.credentialId) params.append("credentialId", query.credentialId);
if (query?.action) params.append("action", query.action);
if (query?.startDate) params.append("startDate", query.startDate);
if (query?.endDate) params.append("endDate", query.endDate);
if (query?.page) params.append("page", String(query.page));
if (query?.limit) params.append("limit", String(query.limit));
const endpoint = `/api/credentials/audit${params.toString() ? `?${params.toString()}` : ""}`;
return apiGet(endpoint, workspaceId);
}
/**
* Get provider icon name for lucide-react
*/
export function getProviderIcon(provider: string): string {
const icons: Record<string, string> = {
github: "Github",
gitlab: "Gitlab",
bitbucket: "Bitbucket",
openai: "Brain",
custom: "Key",
};
return icons[provider.toLowerCase()] ?? "Key";
}
/**
* Format expiry status for PDA-friendly display
*/
export function getExpiryStatus(expiresAt: Date | null): {
status: "active" | "approaching" | "past";
label: string;
className: string;
} {
if (!expiresAt) {
return { status: "active", label: "No expiry", className: "text-muted-foreground" };
}
const now = new Date();
const expiry = new Date(expiresAt);
const daysUntilExpiry = Math.floor((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry < 0) {
return { status: "past", label: "Past target date", className: "text-orange-600" };
} else if (daysUntilExpiry <= 7) {
return { status: "approaching", label: "Approaching target", className: "text-yellow-600" };
} else {
return {
status: "active",
label: `Active (${String(daysUntilExpiry)}d)`,
className: "text-green-600",
};
}
}