mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(audit): filterable audit log page under Settings
The audit trail existed only as a summary card — no table, no filters. The list endpoint was also self-scoped and never returned user_email (the field existed on the struct but was never selected). Backend: GET /audit-logs now joins users for email and accepts user_id / actor (email ILIKE) / since (RFC3339) filters alongside the existing resource/action/page/limit. Widening past self-scope matches the platform's no-roles trust model — every authed user can already create users. RLS on audit_logs is dormant (superuser connection, app.current_user_id never set). Frontend: /settings/audit-logs page with deferred filter inputs, time range select, paginated table (time, actor, resource, action, id, IP, details), linked from a Settings card. Spec updated and types regenerated. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
e127c62b0f
commit
2bfa683084
8 files changed
+337
-23
No files matched your search
+7
-2
@@ -267,8 +267,13 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo.
|
|||||||
- [ ] **Preview environments** (`/preview-environments/*`): per-PR/per-branch
|
- [ ] **Preview environments** (`/preview-environments/*`): per-PR/per-branch
|
||||||
preview creation, promote-to-production action, expiry + cleanup job.
|
preview creation, promote-to-production action, expiry + cleanup job.
|
||||||
Decide first whether this stays Phase 2 or moves with env support (§2.4).
|
Decide first whether this stays Phase 2 or moves with env support (§2.4).
|
||||||
- [ ] **Audit logs**: real UI page under Settings (filterable by resource,
|
- [x] **Audit logs**: real page at `/settings/audit-logs` (linked from the
|
||||||
actor, time); already queried — needs a table, not just a list call.
|
Settings page) — filterable table by resource, action, actor (email),
|
||||||
|
and time range, with pagination. Backend widened accordingly: the list
|
||||||
|
endpoint was self-scoped and dropped `user_email`; it now joins `users`
|
||||||
|
and accepts `user_id`/`actor`/`since` filters. Widening is consistent
|
||||||
|
with the platform's no-roles trust model (every authed user can already
|
||||||
|
create users).
|
||||||
- [ ] **Agents/nodes**: onboarding flow — generate token, show install command
|
- [ ] **Agents/nodes**: onboarding flow — generate token, show install command
|
||||||
for `cmd/agent`, node cards with heartbeat status + telemetry on Usage
|
for `cmd/agent`, node cards with heartbeat status + telemetry on Usage
|
||||||
page.
|
page.
|
||||||
|
|||||||
@@ -93,10 +93,11 @@ func SetAuditDB(db *database.DB) {
|
|||||||
|
|
||||||
func handleGetAuditLogs(c *gin.Context) {
|
func handleGetAuditLogs(c *gin.Context) {
|
||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
userID := c.MustGet("user_id").(string)
|
|
||||||
|
|
||||||
resource := strings.TrimSpace(c.Query("resource"))
|
resource := strings.TrimSpace(c.Query("resource"))
|
||||||
action := strings.TrimSpace(c.Query("action"))
|
action := strings.TrimSpace(c.Query("action"))
|
||||||
|
actor := strings.TrimSpace(c.Query("actor"))
|
||||||
|
userIDFilter := strings.TrimSpace(c.Query("user_id"))
|
||||||
|
since := strings.TrimSpace(c.Query("since"))
|
||||||
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
||||||
limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50)
|
limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50)
|
||||||
if limit > 500 {
|
if limit > 500 {
|
||||||
@@ -104,35 +105,57 @@ func handleGetAuditLogs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
offset := (page - 1) * limit
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
conditions := []string{"user_id::text = $1"}
|
conditions := []string{"TRUE"}
|
||||||
args := []interface{}{userID}
|
args := []interface{}{}
|
||||||
nextArg := 2
|
nextArg := 1
|
||||||
|
|
||||||
|
if userIDFilter != "" {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("a.user_id = $%d::uuid", nextArg))
|
||||||
|
args = append(args, userIDFilter)
|
||||||
|
nextArg++
|
||||||
|
}
|
||||||
|
if actor != "" {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("u.email ILIKE $%d", nextArg))
|
||||||
|
args = append(args, "%"+actor+"%")
|
||||||
|
nextArg++
|
||||||
|
}
|
||||||
if resource != "" {
|
if resource != "" {
|
||||||
conditions = append(conditions, fmt.Sprintf("resource = $%d", nextArg))
|
conditions = append(conditions, fmt.Sprintf("a.resource = $%d", nextArg))
|
||||||
args = append(args, resource)
|
args = append(args, resource)
|
||||||
nextArg++
|
nextArg++
|
||||||
}
|
}
|
||||||
if action != "" {
|
if action != "" {
|
||||||
conditions = append(conditions, fmt.Sprintf("action = $%d", nextArg))
|
conditions = append(conditions, fmt.Sprintf("a.action = $%d", nextArg))
|
||||||
args = append(args, action)
|
args = append(args, action)
|
||||||
nextArg++
|
nextArg++
|
||||||
}
|
}
|
||||||
|
if since != "" {
|
||||||
|
sinceTime, err := time.Parse(time.RFC3339, since)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid since timestamp, expected RFC3339"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conditions = append(conditions, fmt.Sprintf("a.created_at >= $%d", nextArg))
|
||||||
|
args = append(args, sinceTime)
|
||||||
|
nextArg++
|
||||||
|
}
|
||||||
|
|
||||||
whereClause := strings.Join(conditions, " AND ")
|
whereClause := strings.Join(conditions, " AND ")
|
||||||
query := fmt.Sprintf(`SELECT
|
query := fmt.Sprintf(`SELECT
|
||||||
id,
|
a.id,
|
||||||
COALESCE(user_id::text, ''),
|
COALESCE(a.user_id::text, ''),
|
||||||
resource,
|
COALESCE(u.email, ''),
|
||||||
COALESCE(resource_id::text, ''),
|
a.resource,
|
||||||
action,
|
COALESCE(a.resource_id::text, ''),
|
||||||
COALESCE(details::text, '{}'),
|
a.action,
|
||||||
COALESCE(ip_address::text, ''),
|
COALESCE(a.details::text, '{}'),
|
||||||
COALESCE(user_agent, ''),
|
COALESCE(a.ip_address::text, ''),
|
||||||
created_at
|
COALESCE(a.user_agent, ''),
|
||||||
FROM audit_logs
|
a.created_at
|
||||||
|
FROM audit_logs a
|
||||||
|
LEFT JOIN users u ON u.id = a.user_id
|
||||||
WHERE %s
|
WHERE %s
|
||||||
ORDER BY created_at DESC
|
ORDER BY a.created_at DESC
|
||||||
LIMIT $%d OFFSET $%d`, whereClause, nextArg, nextArg+1)
|
LIMIT $%d OFFSET $%d`, whereClause, nextArg, nextArg+1)
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
@@ -146,7 +169,7 @@ func handleGetAuditLogs(c *gin.Context) {
|
|||||||
var logs []AuditLog
|
var logs []AuditLog
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var log AuditLog
|
var log AuditLog
|
||||||
err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
|
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ServiceDetailPage } from '@/features/service/pages/ServiceDetailPage';
|
|||||||
import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetricsDashboard';
|
import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetricsDashboard';
|
||||||
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
|
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
|
||||||
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
|
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
|
||||||
|
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
|
||||||
import {
|
import {
|
||||||
DocsPage,
|
DocsPage,
|
||||||
PeoplePage,
|
PeoplePage,
|
||||||
@@ -62,6 +63,7 @@ export default function App() {
|
|||||||
<Route path="/usage" element={<UsagePage />} />
|
<Route path="/usage" element={<UsagePage />} />
|
||||||
<Route path="/people" element={<PeoplePage />} />
|
<Route path="/people" element={<PeoplePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
|
||||||
<Route path="/docs" element={<DocsPage />} />
|
<Route path="/docs" element={<DocsPage />} />
|
||||||
<Route path="/showcase" element={<ComponentShowcase />} />
|
<Route path="/showcase" element={<ComponentShowcase />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { useDeferredValue, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { listAuditLogs } from '@/lib/api-client';
|
||||||
|
import { formatDate, formatRelative } from '@/lib/time';
|
||||||
|
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
const rangeOptions = [
|
||||||
|
{ value: '', label: 'All time' },
|
||||||
|
{ value: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 },
|
||||||
|
{ value: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 },
|
||||||
|
{ value: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
|
||||||
|
|
||||||
|
export function AuditLogsPage() {
|
||||||
|
const [resource, setResource] = useState('');
|
||||||
|
const [action, setAction] = useState('');
|
||||||
|
const [actor, setActor] = useState('');
|
||||||
|
const [range, setRange] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const deferredResource = useDeferredValue(resource);
|
||||||
|
const deferredAction = useDeferredValue(action);
|
||||||
|
const deferredActor = useDeferredValue(actor);
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
resource: deferredResource.trim() || undefined,
|
||||||
|
action: deferredAction.trim() || undefined,
|
||||||
|
actor: deferredActor.trim() || undefined,
|
||||||
|
page,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const logsQuery = useQuery({
|
||||||
|
queryKey: ['audit-logs', filters, range],
|
||||||
|
queryFn: () => {
|
||||||
|
const option = rangeOptions.find((o) => o.value === range);
|
||||||
|
const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined;
|
||||||
|
return listAuditLogs({ ...filters, since });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs = logsQuery.data ?? [];
|
||||||
|
const hasNext = logs.length === PAGE_SIZE;
|
||||||
|
const hasFilters = Boolean(resource || action || actor || range);
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setResource('');
|
||||||
|
setAction('');
|
||||||
|
setActor('');
|
||||||
|
setRange('');
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div className="border-b border-[var(--border-subtle)]">
|
||||||
|
<div className="w-full px-8 py-5">
|
||||||
|
<h1 className="v-title">
|
||||||
|
Audit Logs<span className="v-cursor">_</span>
|
||||||
|
</h1>
|
||||||
|
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||||
|
Every authenticated action recorded by the platform
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full px-8 py-6">
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex flex-wrap items-end gap-3 mb-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||||
|
Resource
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={resource}
|
||||||
|
onChange={(e) => { setResource(e.target.value); setPage(1); }}
|
||||||
|
placeholder="service, project…"
|
||||||
|
className={`${inputClass} w-44`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||||
|
Action
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={action}
|
||||||
|
onChange={(e) => { setAction(e.target.value); setPage(1); }}
|
||||||
|
placeholder="create, deploy…"
|
||||||
|
className={`${inputClass} w-44`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||||
|
Actor
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={actor}
|
||||||
|
onChange={(e) => { setActor(e.target.value); setPage(1); }}
|
||||||
|
placeholder="user email"
|
||||||
|
className={`${inputClass} w-52`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-1.5">
|
||||||
|
Time
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={range}
|
||||||
|
onChange={(e) => { setRange(e.target.value); setPage(1); }}
|
||||||
|
className={`${inputClass} w-40`}
|
||||||
|
>
|
||||||
|
{rangeOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{hasFilters && (
|
||||||
|
<button
|
||||||
|
onClick={resetFilters}
|
||||||
|
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[var(--border-subtle)] text-left">
|
||||||
|
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Time</th>
|
||||||
|
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Actor</th>
|
||||||
|
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Resource</th>
|
||||||
|
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Action</th>
|
||||||
|
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Resource ID</th>
|
||||||
|
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">IP</th>
|
||||||
|
<th className="px-3 py-2.5 font-medium uppercase tracking-wider text-xs text-[var(--text-muted)]">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[var(--border-subtle)]">
|
||||||
|
{logsQuery.isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center text-[var(--text-muted)]">
|
||||||
|
<Loader2 size={18} className="animate-spin inline-block" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : logsQuery.isError ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center text-[var(--error)]">
|
||||||
|
Failed to load audit logs.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center">
|
||||||
|
<ScrollText size={20} className="inline-block mb-2 text-[var(--text-muted)]" />
|
||||||
|
<p className="text-[var(--text-secondary)]">No audit events{hasFilters ? ' match these filters' : ' recorded yet'}.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<tr key={log.id} className="hover:bg-[var(--surface-muted)]/50 transition-colors">
|
||||||
|
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--text-tertiary)]" title={log.createdAt ? formatDate(log.createdAt) : undefined}>
|
||||||
|
{log.createdAt ? formatRelative(log.createdAt) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-[var(--text-primary)]">
|
||||||
|
{log.userEmail || (log.userId ? log.userId.slice(0, 8) : 'system')}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 mono text-[var(--text-secondary)]">{log.resource}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className="px-2 py-0.5 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] text-xs font-medium text-[var(--accent-primary)]">
|
||||||
|
{log.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[140px] truncate" title={log.resourceId}>
|
||||||
|
{log.resourceId || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)]">{log.ipAddress || '—'}</td>
|
||||||
|
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[220px] truncate" title={log.details}>
|
||||||
|
{log.details && log.details !== '{}' ? log.details : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-[var(--text-muted)]">
|
||||||
|
Page {page}{logs.length > 0 ? ` — ${logs.length} entries` : ''}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={12} />
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
disabled={!hasNext}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,6 +50,8 @@ import {
|
|||||||
Unlink,
|
Unlink,
|
||||||
Cpu,
|
Cpu,
|
||||||
MemoryStick,
|
MemoryStick,
|
||||||
|
ScrollText,
|
||||||
|
ChevronRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||||
@@ -1057,6 +1059,29 @@ export function SettingsPage() {
|
|||||||
{/* Git Providers Section */}
|
{/* Git Providers Section */}
|
||||||
<GitProvidersSection />
|
<GitProvidersSection />
|
||||||
|
|
||||||
|
{/* Audit Logs Section */}
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<ScrollText size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Audit Logs</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">Every authenticated action recorded by the platform</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||||
|
Filter the full audit trail by resource, action, actor, and time range.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/settings/audit-logs')}
|
||||||
|
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
|
||||||
|
>
|
||||||
|
Open audit logs
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Runtime & Local State Section */}
|
{/* Runtime & Local State Section */}
|
||||||
<section className="panel p-6">
|
<section className="panel p-6">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
|||||||
@@ -1202,13 +1202,19 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* List audit logs
|
* List audit logs
|
||||||
* @description Retrieve recent audit log entries for the current user with optional filters
|
* @description Retrieve audit log entries with optional filters
|
||||||
*/
|
*/
|
||||||
get: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
/** @description Filter audit logs by resource type */
|
/** @description Filter audit logs by resource type */
|
||||||
resource?: string;
|
resource?: string;
|
||||||
|
/** @description Filter audit logs by actor email substring */
|
||||||
|
actor?: string;
|
||||||
|
/** @description Filter audit logs by actor user UUID */
|
||||||
|
user_id?: string;
|
||||||
|
/** @description Only return entries created at or after this RFC3339 timestamp */
|
||||||
|
since?: string;
|
||||||
/** @description Filter audit logs by action type */
|
/** @description Filter audit logs by action type */
|
||||||
action?: string;
|
action?: string;
|
||||||
/** @description 1-based page number */
|
/** @description 1-based page number */
|
||||||
|
|||||||
@@ -254,6 +254,9 @@ export type AuditLogEntity = {
|
|||||||
export type ListAuditLogsInput = {
|
export type ListAuditLogsInput = {
|
||||||
resource?: string;
|
resource?: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
|
actor?: string;
|
||||||
|
userId?: string;
|
||||||
|
since?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
@@ -981,6 +984,15 @@ export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<Aud
|
|||||||
if (input.action) {
|
if (input.action) {
|
||||||
searchParams.set('action', input.action);
|
searchParams.set('action', input.action);
|
||||||
}
|
}
|
||||||
|
if (input.actor) {
|
||||||
|
searchParams.set('actor', input.actor);
|
||||||
|
}
|
||||||
|
if (input.userId) {
|
||||||
|
searchParams.set('user_id', input.userId);
|
||||||
|
}
|
||||||
|
if (input.since) {
|
||||||
|
searchParams.set('since', input.since);
|
||||||
|
}
|
||||||
if (input.page && input.page > 0) {
|
if (input.page && input.page > 0) {
|
||||||
searchParams.set('page', String(input.page));
|
searchParams.set('page', String(input.page));
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-1
@@ -827,7 +827,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Audit
|
- Audit
|
||||||
summary: List audit logs
|
summary: List audit logs
|
||||||
description: Retrieve recent audit log entries for the current user with optional filters
|
description: Retrieve audit log entries with optional filters
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
@@ -837,6 +837,26 @@ paths:
|
|||||||
description: Filter audit logs by resource type
|
description: Filter audit logs by resource type
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- name: actor
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Filter audit logs by actor email substring
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: user_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Filter audit logs by actor user UUID
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- name: since
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Only return entries created at or after this RFC3339 timestamp
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
- name: action
|
- name: action
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
Reference in new issue
Block a user