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>
);
}