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:
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
50
frontend/src/hooks/use-audit-log.ts
Normal file
50
frontend/src/hooks/use-audit-log.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
278
frontend/src/pages/audit-log.tsx
Normal file
278
frontend/src/pages/audit-log.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user