add audit log page, multi-select project assignment, and status banner

- Full audit log page (VELA-only) with search, filters, and pagination
- Sidebar nav item gated on global_role (VELA users)
- User detail "Add to Project" now supports multi-select with checkboxes
- Status banner system: admin-configurable messages in topbar with
  info/warning/critical types, cycling, dismiss, and expand for long text
- Banner management UI in admin settings page
- Fix table overflow in audit log with min-w-0 on app shell

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 16:48:35 -07:00
parent 9578d1acf0
commit dca541be61
12 changed files with 853 additions and 32 deletions

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime, timedelta, timezone
from flask import jsonify, session as flask_session
from flask import jsonify, request, session as flask_session
from sqlalchemy import func
from app.auth.decorators import login_required, _get_user_permissions
@@ -162,6 +162,49 @@ def expiring():
}), 200
@dashboard_bp.route("/audit-log", methods=["GET"])
@login_required
def audit_log():
username = flask_session.get("user", "")
perms = _get_user_permissions(username)
if not perms["global_role"]:
return jsonify({"error": "Forbidden"}), 403
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 50, type=int)
search = request.args.get("search", "").strip()
entity_type = request.args.get("entity_type", "").strip()
action = request.args.get("action", "").strip()
user_filter = request.args.get("user", "").strip()
query = AuditLog.query.order_by(AuditLog.created_at.desc())
if search:
like = f"%{search}%"
query = query.filter(db.or_(
AuditLog.user.ilike(like),
AuditLog.entity_type.ilike(like),
AuditLog.action.ilike(like),
AuditLog.details.ilike(like),
))
if entity_type:
query = query.filter(AuditLog.entity_type == entity_type)
if action:
query = query.filter(AuditLog.action == action)
if user_filter:
query = query.filter(AuditLog.user.ilike(f"%{user_filter}%"))
total = query.count()
entries = query.offset((page - 1) * per_page).limit(per_page).all()
return jsonify({
"entries": [e.to_dict() for e in entries],
"total": total,
"page": page,
"per_page": per_page,
}), 200
@dashboard_bp.route("/activity", methods=["GET"])
@login_required
def activity():

View File

@@ -85,6 +85,7 @@ class Setting(db.Model):
"work_hours_end": "16:00", # 4 PM PT
"work_hours_timezone": "America/Los_Angeles",
"agency_service_map": "[]",
"banner_messages": "[]",
}
@classmethod

View File

@@ -139,6 +139,38 @@ def update_agency_service_map():
return jsonify({"agency_service_map": mapping}), 200
@settings_bp.route("/banner", methods=["GET"])
@login_required
def get_banner():
data = Setting.get_json("banner_messages")
return jsonify({"banner_messages": data or []}), 200
@settings_bp.route("/banner", methods=["PUT"])
@admin_required
def update_banner():
import json
data = request.get_json()
if not data or "banner_messages" not in data:
return jsonify({"error": "banner_messages is required"}), 400
messages = data["banner_messages"]
if not isinstance(messages, list):
return jsonify({"error": "banner_messages must be an array"}), 400
valid_types = {"info", "warning", "critical"}
for msg in messages:
if not isinstance(msg, dict):
return jsonify({"error": "Each message must be an object"}), 400
if "text" not in msg or not isinstance(msg["text"], str) or not msg["text"].strip():
return jsonify({"error": "Each message must have a non-empty 'text'"}), 400
if "type" not in msg or msg["type"] not in valid_types:
return jsonify({"error": f"Each message type must be one of: {', '.join(valid_types)}"}), 400
Setting.set("banner_messages", json.dumps(messages))
return jsonify({"banner_messages": messages}), 200
@settings_bp.route("/backup", methods=["GET"])
@login_required
def download_backup():

View File

@@ -15,6 +15,7 @@ import UsersListPage from "@/pages/users-list";
import UserDetailPage from "@/pages/user-detail";
import DowntimeListPage from "@/pages/downtime-list";
import AdminPage from "@/pages/admin";
import AuditLogPage from "@/pages/audit-log";
const queryClient = new QueryClient({
defaultOptions: {
@@ -52,6 +53,7 @@ export default function App() {
<Route path="/downtime" element={<DowntimeListPage />} />
<Route path="/users" element={<UsersListPage />} />
<Route path="/users/:id" element={<UserDetailPage />} />
<Route path="/audit-log" element={<AuditLogPage />} />
<Route path="/admin" element={<AdminPage />} />
</Route>
<Route

View File

@@ -9,7 +9,7 @@ export function AppShell() {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<TopBar />
<main className="min-h-0 flex-1 overflow-y-auto bg-background p-6">
<Outlet />

View File

@@ -6,6 +6,7 @@ import {
KeyRound,
ShieldCheck,
Clock,
ScrollText,
Users,
LogOut,
PanelLeftClose,
@@ -128,6 +129,57 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
</NavLink>
);
})}
{/* Audit Log — VELA users only */}
{user?.permissions?.global_role && (() => {
const auditLink = (
<>
<ScrollText className="size-4 shrink-0" />
{!collapsed && <span>Audit Log</span>}
</>
);
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger
render={
<NavLink
to="/audit-log"
className={({ isActive }: { isActive: boolean }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-muted-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
)
}
/>
}
>
{auditLink}
</TooltipTrigger>
<TooltipContent side="right">Audit Log</TooltipContent>
</Tooltip>
);
}
return (
<NavLink
to="/audit-log"
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-muted-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
)
}
>
{auditLink}
</NavLink>
);
})()}
</nav>
{/* Footer */}

View File

@@ -1,7 +1,141 @@
import { useState, useEffect, useCallback } from "react";
import { ChevronLeft, ChevronRight, X, Info, AlertTriangle, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { useBannerMessages, type BannerMessage } from "@/hooks/use-settings";
const typeConfig: Record<
BannerMessage["type"],
{ icon: typeof Info; bg: string; text: string; border: string }
> = {
info: {
icon: Info,
bg: "bg-blue-50 dark:bg-blue-950/30",
text: "text-blue-700 dark:text-blue-300",
border: "border-blue-200 dark:border-blue-800",
},
warning: {
icon: AlertTriangle,
bg: "bg-amber-50 dark:bg-amber-950/30",
text: "text-amber-700 dark:text-amber-300",
border: "border-amber-200 dark:border-amber-800",
},
critical: {
icon: AlertCircle,
bg: "bg-red-50 dark:bg-red-950/30",
text: "text-red-700 dark:text-red-300",
border: "border-red-200 dark:border-red-800",
},
};
function getDismissKey(messages: BannerMessage[]): string {
const content = messages.map((m) => `${m.type}:${m.text}`).join("|");
let hash = 0;
for (let i = 0; i < content.length; i++) {
hash = ((hash << 5) - hash + content.charCodeAt(i)) | 0;
}
return `banner-dismissed-${hash}`;
}
export function TopBar() {
const { data: messages } = useBannerMessages();
const [index, setIndex] = useState(0);
const [dismissed, setDismissed] = useState(false);
const [expanded, setExpanded] = useState(false);
const activeMessages = messages?.length ? messages : [];
const dismissKey = activeMessages.length ? getDismissKey(activeMessages) : "";
// Reset dismiss state when messages change
useEffect(() => {
if (dismissKey) {
setDismissed(localStorage.getItem(dismissKey) === "1");
} else {
setDismissed(false);
}
setIndex(0);
setExpanded(false);
}, [dismissKey]);
const handleDismiss = useCallback(() => {
if (dismissKey) {
localStorage.setItem(dismissKey, "1");
}
setDismissed(true);
}, [dismissKey]);
const hasMessages = activeMessages.length > 0 && !dismissed;
const current = hasMessages ? activeMessages[index % activeMessages.length] : null;
const config = current ? typeConfig[current.type] : null;
const Icon = config?.icon ?? Info;
const isLong = current ? current.text.length > 120 : false;
if (!hasMessages) {
return (
<header className="flex h-14 shrink-0 items-center border-b bg-background px-6" />
);
}
return (
<header className="flex h-14 shrink-0 items-center border-b bg-background px-6">
{/* Reserved for banner announcements */}
<header className="flex h-14 shrink-0 items-center border-b bg-background px-4">
<div
className={cn(
"flex w-full items-center gap-2 rounded-md border px-3 py-1.5",
config?.bg,
config?.border
)}
>
<Icon className={cn("size-4 shrink-0", config?.text)} />
<div className={cn("min-w-0 flex-1 text-sm", config?.text)}>
{expanded ? (
<p className="whitespace-pre-wrap">{current!.text}</p>
) : (
<p className="truncate">{current!.text}</p>
)}
</div>
{isLong && (
<button
onClick={() => setExpanded((v) => !v)}
className={cn(
"shrink-0 text-xs font-medium underline underline-offset-2 hover:no-underline",
config?.text
)}
>
{expanded ? "Less" : "More"}
</button>
)}
{activeMessages.length > 1 && (
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => setIndex((i) => (i - 1 + activeMessages.length) % activeMessages.length)}
className={cn("rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10", config?.text)}
>
<ChevronLeft className="size-3.5" />
</button>
<span className={cn("text-xs tabular-nums", config?.text)}>
{(index % activeMessages.length) + 1}/{activeMessages.length}
</span>
<button
onClick={() => setIndex((i) => (i + 1) % activeMessages.length)}
className={cn("rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10", config?.text)}
>
<ChevronRight className="size-3.5" />
</button>
</div>
)}
<button
onClick={handleDismiss}
className={cn(
"shrink-0 rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10",
config?.text
)}
>
<X className="size-3.5" />
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,50 @@
import { useQuery } from "@tanstack/react-query";
import api from "@/lib/api";
export interface AuditEntry {
id: number;
entity_type: string;
entity_id: number | null;
entity_name: string | null;
action: string;
user: string;
details: Record<string, unknown> | null;
ip_address: string | null;
created_at: string;
timestamp: string;
}
interface AuditLogParams {
page?: number;
perPage?: number;
search?: string;
entityType?: string;
action?: string;
user?: string;
}
interface AuditLogResponse {
entries: AuditEntry[];
total: number;
page: number;
per_page: number;
}
export function useAuditLog(params: AuditLogParams = {}) {
return useQuery({
queryKey: ["audit-log", params],
queryFn: async () => {
const res = await api.get("/dashboard/audit-log", {
params: {
page: params.page,
per_page: params.perPage,
search: params.search || undefined,
entity_type: params.entityType || undefined,
action: params.action || undefined,
user: params.user || undefined,
},
});
return res.data as AuditLogResponse;
},
});
}

View File

@@ -166,6 +166,36 @@ export function useUpdateAgencyServiceMap() {
});
}
export interface BannerMessage {
text: string;
type: "info" | "warning" | "critical";
}
export function useBannerMessages() {
return useQuery<BannerMessage[]>({
queryKey: ["settings", "banner"],
queryFn: async () => {
const { data } = await api.get("/settings/banner");
return data.banner_messages;
},
});
}
export function useUpdateBannerMessages() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (messages: BannerMessage[]) => {
const { data } = await api.put("/settings/banner", {
banner_messages: messages,
});
return data.banner_messages as BannerMessage[];
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings", "banner"] });
},
});
}
export function useUpdateExpiryThresholds() {
const queryClient = useQueryClient();
return useMutation({

View File

@@ -13,6 +13,12 @@ import {
Loader2,
Mail,
Send,
Megaphone,
Plus,
Trash2,
Info,
AlertTriangle,
AlertCircle,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
@@ -56,6 +62,9 @@ import {
useUpdateSettings,
useSmtpStatus,
useSendTestEmail,
useBannerMessages,
useUpdateBannerMessages,
type BannerMessage,
} from "@/hooks/use-settings";
import {
FEEDBACK_STATUS_COLORS,
@@ -431,6 +440,166 @@ function NotificationsSection({
);
}
const BANNER_TYPE_OPTIONS: { value: BannerMessage["type"]; label: string; icon: typeof Info; color: string }[] = [
{ value: "info", label: "Info", icon: Info, color: "text-blue-600 dark:text-blue-400" },
{ value: "warning", label: "Warning", icon: AlertTriangle, color: "text-amber-600 dark:text-amber-400" },
{ value: "critical", label: "Critical", icon: AlertCircle, color: "text-red-600 dark:text-red-400" },
];
const BANNER_TYPE_BADGES: Record<string, string> = {
info: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
warning: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
critical: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
};
function BannerSection() {
const { data: messages, isLoading } = useBannerMessages();
const updateBanner = useUpdateBannerMessages();
const [draft, setDraft] = useState<BannerMessage[]>([]);
const [dirty, setDirty] = useState(false);
const [newText, setNewText] = useState("");
const [newType, setNewType] = useState<BannerMessage["type"]>("info");
useEffect(() => {
if (messages) {
setDraft(messages);
setDirty(false);
}
}, [messages]);
function addMessage() {
if (!newText.trim()) return;
setDraft((prev) => [...prev, { text: newText.trim(), type: newType }]);
setNewText("");
setNewType("info");
setDirty(true);
}
function removeMessage(index: number) {
setDraft((prev) => prev.filter((_, i) => i !== index));
setDirty(true);
}
function handleSave() {
updateBanner.mutate(draft, {
onSuccess: () => { toast.success("Banner updated"); setDirty(false); },
onError: () => toast.error("Failed to update banner"),
});
}
function handleClearAll() {
setDraft([]);
setDirty(true);
}
if (isLoading) {
return <Card><CardContent className="py-6"><Skeleton className="h-5 w-full" /></CardContent></Card>;
}
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-base font-semibold">
<Megaphone className="size-4" />
Status Banner
</CardTitle>
<p className="text-sm text-muted-foreground">
Messages shown to all users in the top bar. Supports multiple messages with cycling.
</p>
</div>
{draft.length > 0 && (
<Button variant="ghost" size="sm" onClick={handleClearAll} className="text-muted-foreground">
Clear all
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Existing messages */}
{draft.length > 0 && (
<div className="space-y-2">
{draft.map((msg, i) => {
const TypeIcon = BANNER_TYPE_OPTIONS.find((o) => o.value === msg.type)?.icon ?? Info;
return (
<div
key={i}
className="flex items-start gap-3 rounded-lg border p-3"
>
<span className={cn(
"mt-0.5 inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium shrink-0",
BANNER_TYPE_BADGES[msg.type]
)}>
<TypeIcon className="size-3" />
{msg.type}
</span>
<p className="flex-1 text-sm whitespace-pre-wrap">{msg.text}</p>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeMessage(i)}
className="shrink-0 text-muted-foreground hover:text-red-600"
>
<Trash2 className="size-3.5" />
</Button>
</div>
);
})}
</div>
)}
{/* Add new message */}
<div className="rounded-lg border border-dashed p-3 space-y-3">
<div className="flex items-center gap-3">
<Label className="text-sm shrink-0">Type</Label>
<Select value={newType} onValueChange={(v) => setNewType(v as BannerMessage["type"])}>
<SelectTrigger className="w-36 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BANNER_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<span className="flex items-center gap-2">
<opt.icon className={cn("size-3.5", opt.color)} />
{opt.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Input
placeholder="Enter banner message..."
value={newText}
onChange={(e) => setNewText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); addMessage(); }
}}
className="h-8 text-sm"
/>
<Button size="sm" variant="outline" onClick={addMessage} disabled={!newText.trim()}>
<Plus className="size-4 mr-1" />
Add
</Button>
</div>
</div>
{/* Save */}
{dirty && (
<div className="flex items-center gap-3">
<Button size="sm" onClick={handleSave} disabled={updateBanner.isPending}>
{updateBanner.isPending ? "Saving..." : "Save Banner"}
</Button>
<p className="text-xs text-muted-foreground">Unsaved changes</p>
</div>
)}
</CardContent>
</Card>
);
}
function AllSettingsSection() {
const { data: settings, isLoading } = useAllSettings();
const updateSettings = useUpdateSettings();
@@ -650,7 +819,8 @@ export default function AdminPage() {
<TabsTrigger value="keycloak">Keycloak</TabsTrigger>
</TabsList>
<TabsContent value="settings">
<TabsContent value="settings" className="space-y-4">
<BannerSection />
<AllSettingsSection />
</TabsContent>

View File

@@ -0,0 +1,278 @@
import { useState } from "react";
import { formatDistanceToNow, parseISO } from "date-fns";
import { ScrollText, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { useAuditLog } from "@/hooks/use-audit-log";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DataTableFacetedFilter } from "@/components/shared/data-table-faceted-filter";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
const actionColors: Record<string, string> = {
created: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
updated: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
deleted: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
exported: "bg-indigo-100 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-400",
imported: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
added: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
removed: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
billing_changed: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
privileges_changed: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
file_downloaded: "bg-indigo-100 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-400",
file_uploaded: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
};
const entityTypeColors: Record<string, string> = {
project: "bg-violet-100 text-violet-700 dark:bg-violet-950 dark:text-violet-400",
license: "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-400",
cert: "bg-teal-100 text-teal-700 dark:bg-teal-950 dark:text-teal-400",
user: "bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-400",
project_user: "bg-pink-100 text-pink-700 dark:bg-pink-950 dark:text-pink-400",
feedback: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-400",
downtime: "bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-400",
setting: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400",
};
const entityTypeOptions = [
{ label: "Project", value: "project" },
{ label: "License", value: "license" },
{ label: "Certificate", value: "cert" },
{ label: "User", value: "user" },
{ label: "Membership", value: "project_user" },
{ label: "Feedback", value: "feedback" },
{ label: "Downtime", value: "downtime" },
{ label: "Setting", value: "setting" },
];
const actionOptions = [
{ label: "Created", value: "created" },
{ label: "Updated", value: "updated" },
{ label: "Deleted", value: "deleted" },
{ label: "Added", value: "added" },
{ label: "Removed", value: "removed" },
{ label: "Exported", value: "exported" },
{ label: "Imported", value: "imported" },
{ label: "Billing Changed", value: "billing_changed" },
{ label: "Privileges Changed", value: "privileges_changed" },
{ label: "File Downloaded", value: "file_downloaded" },
{ label: "File Uploaded", value: "file_uploaded" },
];
function formatDetails(details: Record<string, unknown> | null): string {
if (!details) return "";
return Object.entries(details)
.map(([k, v]) => {
if (
v &&
typeof v === "object" &&
"old" in (v as Record<string, unknown>) &&
"new" in (v as Record<string, unknown>)
) {
const c = v as { old: unknown; new: unknown };
return `${k}: ${String(c.old)} \u2192 ${String(c.new)}`;
}
if (v === null || typeof v === "object") return null;
return `${k}: ${v}`;
})
.filter(Boolean)
.join(", ");
}
export default function AuditLogPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [entityTypes, setEntityTypes] = useState<string[]>([]);
const [actions, setActions] = useState<string[]>([]);
const perPage = 50;
const { data, isLoading } = useAuditLog({
page,
perPage,
search: debouncedSearch,
entityType: entityTypes[0],
action: actions[0],
});
const totalPages = data ? Math.ceil(data.total / perPage) : 0;
// Debounce search
const [debounceTimer, setDebounceTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
function handleSearchChange(value: string) {
setSearch(value);
if (debounceTimer) clearTimeout(debounceTimer);
setDebounceTimer(
setTimeout(() => {
setDebouncedSearch(value);
setPage(1);
}, 300)
);
}
function handleEntityTypeChange(values: string[]) {
setEntityTypes(values);
setPage(1);
}
function handleActionChange(values: string[]) {
setActions(values);
setPage(1);
}
return (
<div className="min-w-0 space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Audit Log</h1>
<p className="text-sm text-muted-foreground">
Complete history of all actions across the application
</p>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative w-72">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search users, actions, details..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="h-8 pl-8"
/>
</div>
<DataTableFacetedFilter
title="Type"
options={entityTypeOptions}
selected={entityTypes}
onChange={handleEntityTypeChange}
/>
<DataTableFacetedFilter
title="Action"
options={actionOptions}
selected={actions}
onChange={handleActionChange}
/>
{data && (
<span className="ml-auto text-sm text-muted-foreground">
{data.total.toLocaleString()} {data.total === 1 ? "entry" : "entries"}
</span>
)}
</div>
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-40">Time</TableHead>
<TableHead className="w-32">User</TableHead>
<TableHead className="w-36">Action</TableHead>
<TableHead className="w-32">Type</TableHead>
<TableHead className="w-44">Target</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 6 }).map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))
) : !data?.entries.length ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center">
<ScrollText className="mx-auto size-8 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No audit log entries found
</p>
</TableCell>
</TableRow>
) : (
data.entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="text-sm text-muted-foreground">
{formatDistanceToNow(parseISO(entry.timestamp), {
addSuffix: true,
})}
</TableCell>
<TableCell className="text-sm font-medium">
{entry.user}
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={
actionColors[entry.action] ??
"bg-muted text-muted-foreground"
}
>
{entry.action.replace(/_/g, " ")}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={
entityTypeColors[entry.entity_type] ??
"bg-muted text-muted-foreground"
}
>
{entry.entity_type.replace(/_/g, " ")}
</Badge>
</TableCell>
<TableCell className="text-sm">
{entry.entity_name ?? "\u2014"}
</TableCell>
<TableCell className="max-w-xs truncate text-sm text-muted-foreground">
{formatDetails(entry.details)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Page {data?.page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
<ChevronLeft className="mr-1 size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
<ChevronRight className="ml-1 size-4" />
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -85,7 +85,7 @@ export default function UserDetailPage() {
const [editNameOpen, setEditNameOpen] = useState(false);
const [editFirstName, setEditFirstName] = useState("");
const [editLastName, setEditLastName] = useState("");
const [selectedProjectKey, setSelectedProjectKey] = useState("");
const [selectedProjectKeys, setSelectedProjectKeys] = useState<Set<string>>(new Set());
const [projectSearch, setProjectSearch] = useState("");
if (isLoading) {
@@ -151,24 +151,28 @@ export default function UserDetailPage() {
(p) => !user.projects.some((m) => m.project_key === p.key)
) ?? [];
function handleAddToProject() {
if (!selectedProjectKey || !userId) return;
addToProject.mutate(
{ user_id: userId, project_key: selectedProjectKey },
{
onSuccess: () => {
toast.success("Added to project");
setAddProjectOpen(false);
setSelectedProjectKey("");
},
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data
?.error || "Failed to add to project";
toast.error(msg);
},
}
async function handleAddToProject() {
if (selectedProjectKeys.size === 0 || !userId) return;
const keys = Array.from(selectedProjectKeys);
const results = await Promise.allSettled(
keys.map((key) =>
addToProject.mutateAsync({ user_id: userId, project_key: key })
)
);
const failed = results.filter((r) => r.status === "rejected");
if (failed.length === 0) {
toast.success(
keys.length === 1
? "Added to project"
: `Added to ${keys.length} projects`
);
} else if (failed.length < keys.length) {
toast.warning(`Added to ${keys.length - failed.length} project(s), ${failed.length} failed`);
} else {
toast.error("Failed to add to projects");
}
setAddProjectOpen(false);
setSelectedProjectKeys(new Set());
}
function handleSetSponsor() {
@@ -387,20 +391,24 @@ export default function UserDetailPage() {
onOpenChange={(open) => {
setAddProjectOpen(open);
if (!open) {
setSelectedProjectKey("");
setSelectedProjectKeys(new Set());
setProjectSearch("");
}
}}
title="Add to Project"
description="Select a project to add this user to."
confirmLabel="Add"
description={
selectedProjectKeys.size > 0
? `${selectedProjectKeys.size} project${selectedProjectKeys.size > 1 ? "s" : ""} selected`
: "Select projects to add this user to."
}
confirmLabel={selectedProjectKeys.size > 1 ? `Add to ${selectedProjectKeys.size} Projects` : "Add"}
onConfirm={handleAddToProject}
isLoading={addToProject.isPending}
variant="success"
>
<div className="space-y-4 py-2">
<div className="grid gap-1.5">
<label className="text-sm font-medium">Project</label>
<label className="text-sm font-medium">Projects</label>
<Input
placeholder="Search projects..."
value={projectSearch}
@@ -421,7 +429,7 @@ export default function UserDetailPage() {
);
}
return filtered.map((p) => {
const isSelected = selectedProjectKey === p.key;
const isSelected = selectedProjectKeys.has(p.key);
return (
<div
key={p.key}
@@ -430,16 +438,37 @@ export default function UserDetailPage() {
tabIndex={0}
onPointerDown={(e) => {
e.preventDefault();
setSelectedProjectKey(p.key);
setSelectedProjectKeys((prev) => {
const next = new Set(prev);
if (next.has(p.key)) {
next.delete(p.key);
} else {
next.add(p.key);
}
return next;
});
}}
className={`w-full text-left px-3 py-2 text-sm border-b last:border-b-0 cursor-pointer transition-colors ${
className={`flex items-center gap-3 w-full text-left px-3 py-2 text-sm border-b last:border-b-0 cursor-pointer transition-colors ${
isSelected
? "bg-primary/10 ring-1 ring-inset ring-primary/30"
: "hover:bg-muted/50"
}`}
>
<p className="font-medium">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.key}</p>
<div className={`flex size-4 shrink-0 items-center justify-center rounded border ${
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/30"
}`}>
{isSelected && (
<svg className="size-3" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
<div className="min-w-0">
<p className="font-medium">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.key}</p>
</div>
</div>
);
});