Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
"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";
|
|
import { useWorkspaceId } from "@/lib/hooks";
|
|
|
|
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);
|
|
|
|
const workspaceId = useWorkspaceId();
|
|
|
|
useEffect(() => {
|
|
if (!workspaceId) return;
|
|
void loadLogs(workspaceId);
|
|
}, [workspaceId, page, filters]);
|
|
|
|
async function loadLogs(wsId: string): Promise<void> {
|
|
try {
|
|
setIsLoading(true);
|
|
const response = await fetchCredentialAuditLog(wsId, {
|
|
...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: React.ChangeEvent<HTMLInputElement>) => {
|
|
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: React.ChangeEvent<HTMLInputElement>) => {
|
|
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>
|
|
);
|
|
}
|