diff --git a/scripts/db-reset.ts b/scripts/db-reset.ts
index 0784d21..630b154 100644
--- a/scripts/db-reset.ts
+++ b/scripts/db-reset.ts
@@ -16,7 +16,7 @@
import 'dotenv/config';
import postgres from 'postgres';
-const url = process.env.DATABASE_URL;
+const url: string = process.env.DATABASE_URL ?? '';
if (!url) {
console.error('DATABASE_URL is not set; aborting.');
process.exit(1);
diff --git a/scripts/dev-open-browser.ts b/scripts/dev-open-browser.ts
index 877258c..9d19d2e 100644
--- a/scripts/dev-open-browser.ts
+++ b/scripts/dev-open-browser.ts
@@ -16,7 +16,10 @@
*/
import 'dotenv/config';
-import { chromium } from 'playwright';
+// @playwright/test re-exports the same chromium driver and is already
+// installed as a dev dep; using it avoids needing to add the standalone
+// `playwright` package as a separate dependency.
+import { chromium } from '@playwright/test';
const USERS: Record = {
super_admin: { email: 'admin@portnimara.test', password: 'SuperAdmin12345!' },
diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts
index 9900a3b..b79e685 100644
--- a/src/app/api/auth/[...all]/route.ts
+++ b/src/app/api/auth/[...all]/route.ts
@@ -1,4 +1,146 @@
-import { auth } from '@/lib/auth';
+import type { NextRequest } from 'next/server';
import { toNextJsHandler } from 'better-auth/next-js';
-export const { GET, POST } = toNextJsHandler(auth);
+import { auth } from '@/lib/auth';
+import { createAuditLog } from '@/lib/audit';
+import { logger } from '@/lib/logger';
+
+const upstream = toNextJsHandler(auth);
+
+/**
+ * Wrap better-auth's `[...all]` handler so we can stamp the audit log on
+ * authentication events. Better-auth itself doesn't fire any callback we
+ * can hook on sign-in / sign-out / failed-login — we inspect the route
+ * + response status after the upstream handler finishes.
+ *
+ * Successful sign-in → action 'login' (severity info)
+ * Failed sign-in → action 'login' (severity warning, ok=false)
+ * Sign-out → action 'logout' (userId resolved before cookie
+ * is cleared)
+ *
+ * Audit writes are fire-and-forget (createAuditLog never throws).
+ */
+
+interface AuthBody {
+ user?: { id?: string; email?: string };
+ id?: string;
+ email?: string;
+}
+
+function clientMeta(req: NextRequest): { ipAddress: string; userAgent: string } {
+ const ip =
+ req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? req.headers.get('x-real-ip') ?? '';
+ return { ipAddress: ip, userAgent: req.headers.get('user-agent') ?? '' };
+}
+
+function logSignIn(args: {
+ req: NextRequest;
+ responseBody: string;
+ status: number;
+ attemptedEmail: string | null;
+}) {
+ const meta = clientMeta(args.req);
+ let parsed: AuthBody | null = null;
+ try {
+ parsed = JSON.parse(args.responseBody) as AuthBody;
+ } catch {
+ /* upstream returned non-JSON */
+ }
+ const userId = parsed?.user?.id ?? parsed?.id ?? null;
+ const email = parsed?.user?.email ?? parsed?.email ?? args.attemptedEmail ?? null;
+ const ok = args.status >= 200 && args.status < 300;
+
+ void createAuditLog({
+ userId,
+ portId: null,
+ action: 'login',
+ entityType: 'session',
+ entityId: userId ?? args.attemptedEmail ?? 'unknown',
+ metadata: {
+ ok,
+ status: args.status,
+ attemptedEmail: args.attemptedEmail ?? email ?? null,
+ },
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ severity: ok ? 'info' : 'warning',
+ source: 'auth',
+ });
+}
+
+async function logSignOut(req: NextRequest) {
+ const meta = clientMeta(req);
+ let userId: string | null = null;
+ try {
+ const session = await auth.api.getSession({ headers: req.headers });
+ userId = session?.user?.id ?? null;
+ } catch {
+ /* unauthenticated or expired */
+ }
+
+ void createAuditLog({
+ userId,
+ portId: null,
+ action: 'logout',
+ entityType: 'session',
+ entityId: userId ?? 'unknown',
+ metadata: {},
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ severity: 'info',
+ source: 'auth',
+ });
+}
+
+async function withAuthAudit(req: NextRequest): Promise {
+ const url = new URL(req.url);
+ const path = url.pathname;
+ const isSignIn = path.endsWith('/sign-in/email') || path.endsWith('/sign-in');
+ const isSignOut = path.endsWith('/sign-out');
+
+ // Read the request body BEFORE forwarding so we can extract the
+ // attempted email even when the credentials are wrong (the upstream
+ // handler will consume the body stream and we can't read it twice).
+ let attemptedEmail: string | null = null;
+ let forwardReq: NextRequest = req;
+ if (isSignIn && req.method === 'POST') {
+ try {
+ const raw = await req.text();
+ try {
+ attemptedEmail = (JSON.parse(raw) as { email?: string }).email ?? null;
+ } catch {
+ /* form-encoded or non-JSON */
+ }
+ // Reconstruct a fresh Request so the upstream handler can read it.
+ forwardReq = new Request(req.url, {
+ method: req.method,
+ headers: req.headers,
+ body: raw,
+ }) as unknown as NextRequest;
+ } catch (err) {
+ logger.warn({ err }, 'Failed to read sign-in body for audit');
+ }
+ }
+
+ // Capture sign-out userId BEFORE the upstream handler clears the cookie.
+ const signOutPromise = isSignOut ? logSignOut(req) : null;
+
+ const res = await upstream.POST(forwardReq);
+
+ if (isSignIn) {
+ try {
+ const body = await res.clone().text();
+ logSignIn({ req, responseBody: body, status: res.status, attemptedEmail });
+ } catch (err) {
+ logger.warn({ err }, 'Failed to capture sign-in response for audit');
+ }
+ }
+ if (signOutPromise) void signOutPromise;
+
+ return res;
+}
+
+export const GET = upstream.GET;
+export async function POST(req: NextRequest): Promise {
+ return withAuthAudit(req);
+}
diff --git a/src/app/api/v1/admin/audit/route.ts b/src/app/api/v1/admin/audit/route.ts
index a2afa46..74854a9 100644
--- a/src/app/api/v1/admin/audit/route.ts
+++ b/src/app/api/v1/admin/audit/route.ts
@@ -15,6 +15,8 @@ const auditQuerySchema = z.object({
action: z.string().optional(),
userId: z.string().optional(),
entityId: z.string().optional(),
+ severity: z.enum(['info', 'warning', 'error', 'critical']).optional(),
+ source: z.enum(['user', 'system', 'auth', 'webhook', 'cron', 'job']).optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
/** Free-text query against the tsvector `search_text` column. */
@@ -39,6 +41,8 @@ export const GET = withAuth(
action: query.action,
entityType: query.entityType,
entityId: query.entityId,
+ severity: query.severity,
+ source: query.source,
from: query.dateFrom ? new Date(query.dateFrom) : undefined,
to: query.dateTo ? new Date(query.dateTo) : undefined,
cursor,
diff --git a/src/components/admin/audit/audit-log-card.tsx b/src/components/admin/audit/audit-log-card.tsx
index 28d406c..6bf08be 100644
--- a/src/components/admin/audit/audit-log-card.tsx
+++ b/src/components/admin/audit/audit-log-card.tsx
@@ -17,6 +17,9 @@ interface AuditEntry {
newValue: Record | null;
metadata: Record | null;
ipAddress: string | null;
+ userAgent?: string | null;
+ severity?: 'info' | 'warning' | 'error' | 'critical';
+ source?: 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job';
createdAt: string;
actor: { id: string; email: string; name: string } | null;
}
@@ -110,11 +113,19 @@ export function AuditLogCard({ entry }: AuditLogCardProps) {
- {/* Timestamp meta line */}
+ {/* Timestamp + IP meta line */}
}>
{formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true })}
+ {entry.ipAddress ? (
+ {entry.ipAddress}
+ ) : null}
+ {entry.severity && entry.severity !== 'info' ? (
+
+ {entry.severity}
+
+ ) : null}
{/* Action badge + changed-fields chips */}
diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx
index 074c98d..bb1e2eb 100644
--- a/src/components/admin/audit/audit-log-list.tsx
+++ b/src/components/admin/audit/audit-log-list.tsx
@@ -32,6 +32,9 @@ interface AuditEntry {
newValue: Record | null;
metadata: Record | null;
ipAddress: string | null;
+ userAgent: string | null;
+ severity: 'info' | 'warning' | 'error' | 'critical';
+ source: 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job';
createdAt: string;
actor: { id: string; email: string; name: string } | null;
}
@@ -47,10 +50,37 @@ const ACTION_COLORS: Record = {
delete: 'bg-red-600',
archive: 'bg-orange-500',
restore: 'bg-teal-500',
- login: 'bg-gray-500',
+ login: 'bg-slate-500',
+ logout: 'bg-slate-400',
permission_denied: 'bg-red-800',
merge: 'bg-purple-500',
revert: 'bg-amber-500',
+ hard_delete: 'bg-red-900',
+ request_hard_delete_code: 'bg-orange-700',
+ send: 'bg-indigo-500',
+ view: 'bg-gray-400',
+ webhook_delivered: 'bg-emerald-500',
+ webhook_failed: 'bg-amber-600',
+ webhook_dead_letter: 'bg-red-700',
+ webhook_retried: 'bg-indigo-600',
+ job_failed: 'bg-rose-700',
+ cron_run: 'bg-sky-500',
+};
+
+const SEVERITY_BADGE: Record = {
+ info: 'bg-slate-200 text-slate-800',
+ warning: 'bg-amber-200 text-amber-900',
+ error: 'bg-red-200 text-red-900',
+ critical: 'bg-red-600 text-white',
+};
+
+const SOURCE_LABEL: Record = {
+ user: 'User',
+ system: 'System',
+ auth: 'Auth',
+ webhook: 'Webhook',
+ cron: 'Cron',
+ job: 'Job',
};
const ENTITY_TYPES = [
@@ -91,6 +121,8 @@ export function AuditLogList() {
const [search, setSearch] = useState('');
const [entityType, setEntityType] = useState('all');
const [action, setAction] = useState('all');
+ const [severity, setSeverity] = useState('all');
+ const [source, setSource] = useState('all');
const [userId, setUserId] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
@@ -102,6 +134,8 @@ export function AuditLogList() {
const params = new URLSearchParams({ limit: '50' });
if (entityType !== 'all') params.set('entityType', entityType);
if (action !== 'all') params.set('action', action);
+ if (severity !== 'all') params.set('severity', severity);
+ if (source !== 'all') params.set('source', source);
if (debouncedSearch) params.set('search', debouncedSearch);
if (debouncedUserId) params.set('userId', debouncedUserId);
if (dateFrom) params.set('dateFrom', new Date(dateFrom).toISOString());
@@ -111,7 +145,7 @@ export function AuditLogList() {
params.set('dateTo', end.toISOString());
}
return params.toString();
- }, [entityType, action, debouncedSearch, debouncedUserId, dateFrom, dateTo]);
+ }, [entityType, action, severity, source, debouncedSearch, debouncedUserId, dateFrom, dateTo]);
const fetchFirstPage = useCallback(async () => {
setLoading(true);
@@ -147,6 +181,8 @@ export function AuditLogList() {
setSearch('');
setEntityType('all');
setAction('all');
+ setSeverity('all');
+ setSource('all');
setUserId('');
setDateFrom('');
setDateTo('');
@@ -156,6 +192,8 @@ export function AuditLogList() {
Boolean(search) ||
entityType !== 'all' ||
action !== 'all' ||
+ severity !== 'all' ||
+ source !== 'all' ||
Boolean(userId) ||
Boolean(dateFrom) ||
Boolean(dateTo);
@@ -178,13 +216,33 @@ export function AuditLogList() {
accessorKey: 'action',
header: 'Action',
cell: ({ row }) => (
-
- {row.original.action}
-
+
+
+ {row.original.action}
+
+ {row.original.severity !== 'info' && (
+
+ {row.original.severity}
+
+ )}
+
),
- size: 110,
+ size: 180,
+ },
+ {
+ accessorKey: 'source',
+ header: 'Source',
+ cell: ({ row }) => (
+
+ {SOURCE_LABEL[row.original.source] ?? row.original.source}
+
+ ),
+ size: 80,
},
{
accessorKey: 'entityType',
@@ -236,7 +294,18 @@ export function AuditLogList() {
}
return system;
},
- size: 200,
+ size: 180,
+ },
+ {
+ id: 'ip',
+ header: 'IP',
+ cell: ({ row }) =>
+ row.original.ipAddress ? (
+ {row.original.ipAddress}
+ ) : (
+ —
+ ),
+ size: 130,
},
];
@@ -287,7 +356,7 @@ export function AuditLogList() {
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/audit.ts b/src/lib/audit.ts
index e6ee6ab..591437f 100644
--- a/src/lib/audit.ts
+++ b/src/lib/audit.ts
@@ -25,7 +25,14 @@ export type AuditAction =
| 'send'
| 'view'
| 'request_hard_delete_code'
- | 'hard_delete';
+ | 'hard_delete'
+ // System / background events.
+ | 'webhook_delivered'
+ | 'webhook_failed'
+ | 'webhook_dead_letter'
+ | 'webhook_retried'
+ | 'job_failed'
+ | 'cron_run';
/**
* Common shape passed to service functions so they can stamp audit logs and
@@ -40,6 +47,9 @@ export interface AuditMeta {
userAgent: string;
}
+export type AuditSeverity = 'info' | 'warning' | 'error' | 'critical';
+export type AuditSource = 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job';
+
export interface AuditLogParams {
/** Null for system-generated events. */
userId: string | null;
@@ -56,6 +66,14 @@ export interface AuditLogParams {
* jobs, internal helpers) may omit. */
ipAddress?: string;
userAgent?: string;
+ /** Defaults to 'info'. Bump to 'warning' for permission_denied,
+ * 'error' for failed background jobs / webhook DLQ, 'critical' for
+ * hard-deletes / security-relevant events. */
+ severity?: AuditSeverity;
+ /** Defaults to 'user'. Use 'auth' for session lifecycle,
+ * 'webhook' for delivery events, 'job' / 'cron' / 'system' for
+ * background work. The inspector filters on this column. */
+ source?: AuditSource;
}
const SENSITIVE_FIELDS = new Set(['email', 'phone', 'password', 'credentials_enc', 'token']);
@@ -103,8 +121,19 @@ export function diffFields(
* This function NEVER throws - errors are caught and logged so that an audit
* failure never rolls back or disrupts the parent operation.
*/
+// Some actions get a default severity bump so callers don't have to
+// remember; explicit `severity` on the call still wins.
+const DEFAULT_SEVERITY_BY_ACTION: Partial> = {
+ permission_denied: 'warning',
+ hard_delete: 'critical',
+};
+const AUTH_ACTIONS = new Set(['login', 'logout', 'password_change']);
+
export async function createAuditLog(params: AuditLogParams): Promise {
try {
+ const severity = params.severity ?? DEFAULT_SEVERITY_BY_ACTION[params.action] ?? 'info';
+ const source = params.source ?? (AUTH_ACTIONS.has(params.action) ? 'auth' : 'user');
+
await db.insert(auditLogs).values({
portId: params.portId,
userId: params.userId,
@@ -117,6 +146,8 @@ export async function createAuditLog(params: AuditLogParams): Promise {
metadata: params.metadata ?? null,
ipAddress: params.ipAddress ?? null,
userAgent: params.userAgent ?? null,
+ severity,
+ source,
});
} catch (err) {
// Strip old/new values from the log to avoid secondary exposure of the data
diff --git a/src/lib/db/migrations/0044_audit_log_severity_source.sql b/src/lib/db/migrations/0044_audit_log_severity_source.sql
new file mode 100644
index 0000000..4f15c5c
--- /dev/null
+++ b/src/lib/db/migrations/0044_audit_log_severity_source.sql
@@ -0,0 +1,21 @@
+-- Audit log gets two new columns so the inspector can surface system
+-- events alongside user actions on a single timeline.
+--
+-- severity: 'info' | 'warning' | 'error' | 'critical'
+-- default 'info'. Most user actions are 'info'; a permission
+-- denied is 'warning'; a webhook DLQ entry is 'error'; a
+-- hard-delete or a CRITICAL alert is 'critical'.
+--
+-- source: 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'
+-- default 'user'. Lets the UI filter "show me only the
+-- system events" without grepping action names.
+--
+-- Both default-friendly + nullable-friendly so the back-history rows
+-- retain their existing semantics ('user' / 'info').
+
+ALTER TABLE audit_logs
+ ADD COLUMN IF NOT EXISTS severity text NOT NULL DEFAULT 'info',
+ ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'user';
+
+CREATE INDEX IF NOT EXISTS idx_al_severity ON audit_logs (port_id, severity, created_at);
+CREATE INDEX IF NOT EXISTS idx_al_source ON audit_logs (port_id, source, created_at);
diff --git a/src/lib/db/schema/system.ts b/src/lib/db/schema/system.ts
index de5fce2..a5b2dad 100644
--- a/src/lib/db/schema/system.ts
+++ b/src/lib/db/schema/system.ts
@@ -41,6 +41,12 @@ export const auditLogs = pgTable(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
revertOf: text('revert_of').references((): any => auditLogs.id),
metadata: jsonb('metadata').default({}),
+ /** 'info' | 'warning' | 'error' | 'critical' — drives the row badge
+ * in the inspector. Most user actions are 'info'. */
+ severity: text('severity').notNull().default('info'),
+ /** 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job' — lets the
+ * UI filter by event origin without grepping action names. */
+ source: text('source').notNull().default('user'),
/** Full-text search column. Stored generated; updated by the migration's
* GENERATED ALWAYS expression covering action + entityType + entityId
* + actor email lookup. */
@@ -52,6 +58,8 @@ export const auditLogs = pgTable(
index('idx_al_entity').on(table.entityType, table.entityId),
index('idx_al_user').on(table.userId, table.createdAt),
index('idx_al_created').on(table.createdAt),
+ index('idx_al_severity').on(table.portId, table.severity, table.createdAt),
+ index('idx_al_source').on(table.portId, table.source, table.createdAt),
],
);
diff --git a/src/lib/queue/workers/webhooks.ts b/src/lib/queue/workers/webhooks.ts
index 5b8fe1d..5c2ae28 100644
--- a/src/lib/queue/workers/webhooks.ts
+++ b/src/lib/queue/workers/webhooks.ts
@@ -200,6 +200,8 @@ export const webhooksWorker = new Worker(
const maxAttempts = QUEUE_CONFIGS.webhooks.maxAttempts;
const isFinalAttempt = attempt >= maxAttempts;
+ const { createAuditLog } = await import('@/lib/audit');
+
if (success) {
// 6a. Record success
await db
@@ -214,6 +216,17 @@ export const webhooksWorker = new Worker(
.where(eq(webhookDeliveries.id, deliveryId));
logger.info({ webhookId, deliveryId, event }, 'Webhook delivered successfully');
+
+ void createAuditLog({
+ userId: null,
+ portId,
+ action: 'webhook_delivered',
+ entityType: 'webhook_delivery',
+ entityId: deliveryId,
+ metadata: { webhookId, event, responseStatus, attempt },
+ source: 'webhook',
+ severity: 'info',
+ });
} else if (!success && isFinalAttempt) {
// 6b. Final failure → dead_letter + system alert
await db
@@ -231,6 +244,17 @@ export const webhooksWorker = new Worker(
'Webhook delivery permanently failed - dead_letter',
);
+ void createAuditLog({
+ userId: null,
+ portId,
+ action: 'webhook_dead_letter',
+ entityType: 'webhook_delivery',
+ entityId: deliveryId,
+ metadata: { webhookId, event, responseStatus, attempt, responseBody },
+ source: 'webhook',
+ severity: 'error',
+ });
+
// Notify all super admins
try {
const superAdmins = await db
@@ -272,6 +296,17 @@ export const webhooksWorker = new Worker(
})
.where(eq(webhookDeliveries.id, deliveryId));
+ void createAuditLog({
+ userId: null,
+ portId,
+ action: 'webhook_failed',
+ entityType: 'webhook_delivery',
+ entityId: deliveryId,
+ metadata: { webhookId, event, responseStatus, attempt },
+ source: 'webhook',
+ severity: 'warning',
+ });
+
throw new Error(
`Webhook delivery attempt ${attempt} failed. Status: ${responseStatus ?? 'network error'}. Retrying...`,
);
diff --git a/src/lib/services/audit-search.service.ts b/src/lib/services/audit-search.service.ts
index dfe58c8..5916526 100644
--- a/src/lib/services/audit-search.service.ts
+++ b/src/lib/services/audit-search.service.ts
@@ -22,6 +22,10 @@ export interface AuditSearchOptions {
entityType?: string;
/** Filter by exact entity id (e.g. paste a uuid into search). */
entityId?: string;
+ /** Filter by severity ('info' | 'warning' | 'error' | 'critical'). */
+ severity?: string;
+ /** Filter by source ('user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job'). */
+ source?: string;
/** Inclusive date range. */
from?: Date;
to?: Date;
@@ -42,6 +46,8 @@ export async function searchAuditLogs(options: AuditSearchOptions = {}): Promise
if (options.action) conds.push(eq(auditLogs.action, options.action));
if (options.entityType) conds.push(eq(auditLogs.entityType, options.entityType));
if (options.entityId) conds.push(eq(auditLogs.entityId, options.entityId));
+ if (options.severity) conds.push(eq(auditLogs.severity, options.severity));
+ if (options.source) conds.push(eq(auditLogs.source, options.source));
if (options.from) conds.push(gte(auditLogs.createdAt, options.from));
if (options.to) conds.push(lte(auditLogs.createdAt, options.to));
if (options.q) {
diff --git a/src/lib/services/webhooks.service.ts b/src/lib/services/webhooks.service.ts
index d2cb07a..16680a0 100644
--- a/src/lib/services/webhooks.service.ts
+++ b/src/lib/services/webhooks.service.ts
@@ -330,7 +330,7 @@ export async function redeliverWebhookDelivery(
void createAuditLog({
userId: meta.userId,
portId,
- action: 'send',
+ action: 'webhook_retried',
entityType: 'webhook_delivery',
entityId: next!.id,
metadata: { redeliveredFrom: deliveryId, originalStatus: source.status },