Files
stack/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx
Jason Woltje edcff6a0e0
All checks were successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
fix(api,web): add workspace context to widgets and auto-detect workspace ID (#532)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-27 04:53:07 +00:00

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