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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user