Task #147: Add structured permission-change audit (users, groups, apps)
Mirrors the existing role-permission audit pattern with a unified
`permission_audit` table capturing actor, target, prev/new id sets, and
timestamp written in the same transaction as the change.
Schema & API
- New `permission_audit` table (target_kind, target_id, change_kind,
actor_user_id, previous_ids[], new_ids[], created_at) with index on
(target_kind, target_id, created_at).
- Transactional audit writes in routes/users.ts (POST/DELETE roles,
PATCH groupIds), routes/groups.ts (PATCH + add/remove members for
users/roles/apps), routes/apps.ts (POST/DELETE permissions).
- Cross-entity mirroring: when group membership changes via a group
endpoint, a user.groups row is also written for each affected user
(and vice versa via PATCH /users), so each entity's history is
exhaustive regardless of which editor was used.
- Admin-only GET /users/:id/audit, /groups/:id/audit, /apps/:id/audit
with limit/offset/actorUserId/from/to filters and the same response
shape as role audit.
- OpenAPI types + codegen regenerated.
UI
- Reusable PermissionAuditHistory component in admin.tsx wired into
UserGroupsEditor, GroupDetailEditor (new "history" tab), and the
editing-app dialog. App history correctly resolves permission ids
(NOT roles) via useListPermissions.
- Bilingual i18n keys added under admin.{users,groups,apps}.history*
in en.json + ar.json.
Tests
- New backend tests: user-permission-audit, group-permission-audit,
app-permission-audit (14 cases — transactional capture, GET filters
& pagination, admin-only, 404 on unknown id, plus 2 new mirror
tests covering cross-entity audit visibility). All pass; 35
adjacent role/groups/users/audit-coverage tests still pass.
Notes
- replit.md updated to list `permission_audit` table.
- Restored opengraph.jpg (an unrelated stray binary diff).
- Code-review comments addressed: cross-entity asymmetry fixed via
mirroring; opengraph.jpg restored.
- Follow-ups proposed: timeline UI improvements, cascade audit on
delete/bulk paths, e2e UI test for History sections.
Replit-Task-Id: 4ba8c8de-8dfd-42c7-a3bc-964a1ed3a1a3
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import { and, desc, eq, gte, lt, sql, type SQL } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { db, permissionAuditTable, usersTable } from "@workspace/db";
|
||||
import type {
|
||||
PermissionAuditChangeKind,
|
||||
PermissionAuditTargetKind,
|
||||
} from "@workspace/db";
|
||||
|
||||
// Shared building blocks for the permission_audit table. Mirrors the role
|
||||
// permission audit pattern in routes/roles.ts so the per-entity history
|
||||
// endpoints stay consistent in shape, filters and pagination semantics.
|
||||
|
||||
export const PERMISSION_AUDIT_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function parseUtcDate(s: string): Date | null {
|
||||
if (!PERMISSION_AUDIT_DATE_RE.test(s)) return null;
|
||||
const d = new Date(`${s}T00:00:00.000Z`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const [y, m, day] = s.split("-").map(Number);
|
||||
if (
|
||||
d.getUTCFullYear() !== y ||
|
||||
d.getUTCMonth() + 1 !== m ||
|
||||
d.getUTCDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export type PermissionAuditFilters = {
|
||||
actorUserId: number | null;
|
||||
fromDate: Date | null;
|
||||
toDateExclusive: Date | null;
|
||||
};
|
||||
|
||||
export function parsePermissionAuditFilters(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): PermissionAuditFilters | null {
|
||||
const actorUserIdStr =
|
||||
typeof req.query.actorUserId === "string"
|
||||
? req.query.actorUserId.trim()
|
||||
: "";
|
||||
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
|
||||
const toStr = typeof req.query.to === "string" ? req.query.to.trim() : "";
|
||||
|
||||
let actorUserId: number | null = null;
|
||||
if (actorUserIdStr) {
|
||||
const parsed = Number(actorUserIdStr);
|
||||
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Invalid 'actorUserId' (expected non-negative integer)" });
|
||||
return null;
|
||||
}
|
||||
// 0 = explicit "no filter" so admins can clear the dropdown without
|
||||
// dropping the param entirely from the URL.
|
||||
actorUserId = parsed > 0 ? parsed : null;
|
||||
}
|
||||
let fromDate: Date | null = null;
|
||||
let toDateExclusive: Date | null = null;
|
||||
if (fromStr) {
|
||||
fromDate = parseUtcDate(fromStr);
|
||||
if (!fromDate) {
|
||||
res.status(400).json({ error: "Invalid 'from' date (expected YYYY-MM-DD)" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (toStr) {
|
||||
const toDate = parseUtcDate(toStr);
|
||||
if (!toDate) {
|
||||
res.status(400).json({ error: "Invalid 'to' date (expected YYYY-MM-DD)" });
|
||||
return null;
|
||||
}
|
||||
toDateExclusive = new Date(toDate.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
if (fromDate && toDateExclusive && fromDate >= toDateExclusive) {
|
||||
res.status(400).json({ error: "'from' must be on or before 'to'" });
|
||||
return null;
|
||||
}
|
||||
return { actorUserId, fromDate, toDateExclusive };
|
||||
}
|
||||
|
||||
function buildWhere(
|
||||
targetKind: PermissionAuditTargetKind,
|
||||
targetId: number,
|
||||
filters: PermissionAuditFilters,
|
||||
): SQL {
|
||||
const conditions: SQL[] = [
|
||||
eq(permissionAuditTable.targetKind, targetKind),
|
||||
eq(permissionAuditTable.targetId, targetId),
|
||||
];
|
||||
if (filters.actorUserId != null)
|
||||
conditions.push(eq(permissionAuditTable.actorUserId, filters.actorUserId));
|
||||
if (filters.fromDate)
|
||||
conditions.push(gte(permissionAuditTable.createdAt, filters.fromDate));
|
||||
if (filters.toDateExclusive)
|
||||
conditions.push(lt(permissionAuditTable.createdAt, filters.toDateExclusive));
|
||||
return and(...conditions) as SQL;
|
||||
}
|
||||
|
||||
type SerializedActor = {
|
||||
id: number;
|
||||
username: string;
|
||||
displayNameAr: string | null;
|
||||
displayNameEn: string | null;
|
||||
avatarUrl: string | null;
|
||||
} | null;
|
||||
|
||||
export type SerializedPermissionAuditEntry = {
|
||||
id: number;
|
||||
targetKind: PermissionAuditTargetKind;
|
||||
targetId: number;
|
||||
changeKind: PermissionAuditChangeKind;
|
||||
previousIds: number[];
|
||||
newIds: number[];
|
||||
addedIds: number[];
|
||||
removedIds: number[];
|
||||
createdAt: Date;
|
||||
actor: SerializedActor;
|
||||
};
|
||||
|
||||
export async function listPermissionAudit(opts: {
|
||||
targetKind: PermissionAuditTargetKind;
|
||||
targetId: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
filters: PermissionAuditFilters;
|
||||
}): Promise<{
|
||||
entries: SerializedPermissionAuditEntry[];
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
nextOffset: number | null;
|
||||
}> {
|
||||
const where = buildWhere(opts.targetKind, opts.targetId, opts.filters);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: permissionAuditTable.id,
|
||||
targetKind: permissionAuditTable.targetKind,
|
||||
targetId: permissionAuditTable.targetId,
|
||||
changeKind: permissionAuditTable.changeKind,
|
||||
previousIds: permissionAuditTable.previousIds,
|
||||
newIds: permissionAuditTable.newIds,
|
||||
createdAt: permissionAuditTable.createdAt,
|
||||
actorUserId: permissionAuditTable.actorUserId,
|
||||
actorUsername: usersTable.username,
|
||||
actorDisplayNameAr: usersTable.displayNameAr,
|
||||
actorDisplayNameEn: usersTable.displayNameEn,
|
||||
actorAvatarUrl: usersTable.avatarUrl,
|
||||
})
|
||||
.from(permissionAuditTable)
|
||||
.leftJoin(usersTable, eq(usersTable.id, permissionAuditTable.actorUserId))
|
||||
.where(where)
|
||||
.orderBy(desc(permissionAuditTable.createdAt), desc(permissionAuditTable.id))
|
||||
.limit(opts.limit)
|
||||
.offset(opts.offset);
|
||||
|
||||
const [{ count: totalCount } = { count: 0 }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(permissionAuditTable)
|
||||
.where(where);
|
||||
|
||||
const entries: SerializedPermissionAuditEntry[] = rows.map((r) => {
|
||||
const prev = (r.previousIds ?? []) as number[];
|
||||
const next = (r.newIds ?? []) as number[];
|
||||
const prevSet = new Set(prev);
|
||||
const nextSet = new Set(next);
|
||||
return {
|
||||
id: r.id,
|
||||
targetKind: r.targetKind as PermissionAuditTargetKind,
|
||||
targetId: r.targetId,
|
||||
changeKind: r.changeKind as PermissionAuditChangeKind,
|
||||
previousIds: prev,
|
||||
newIds: next,
|
||||
addedIds: next.filter((p) => !prevSet.has(p)),
|
||||
removedIds: prev.filter((p) => !nextSet.has(p)),
|
||||
createdAt: r.createdAt,
|
||||
actor:
|
||||
r.actorUserId != null && r.actorUsername != null
|
||||
? {
|
||||
id: r.actorUserId,
|
||||
username: r.actorUsername,
|
||||
displayNameAr: r.actorDisplayNameAr,
|
||||
displayNameEn: r.actorDisplayNameEn,
|
||||
avatarUrl: r.actorAvatarUrl,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
const nextOffset =
|
||||
opts.offset + rows.length < totalCount
|
||||
? opts.offset + rows.length
|
||||
: null;
|
||||
|
||||
return {
|
||||
entries,
|
||||
totalCount,
|
||||
limit: opts.limit,
|
||||
offset: opts.offset,
|
||||
nextOffset,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper used by route handlers to serialize the prev/new id arrays into
|
||||
// the shape the audit table expects. We always sort to keep entries
|
||||
// canonical and easy to diff.
|
||||
export function sortedIds(ids: Iterable<number>): number[] {
|
||||
return Array.from(new Set(ids)).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
export async function recordPermissionAudit(
|
||||
tx: Tx,
|
||||
args: {
|
||||
targetKind: PermissionAuditTargetKind;
|
||||
targetId: number;
|
||||
changeKind: PermissionAuditChangeKind;
|
||||
actorUserId: number | null;
|
||||
previousIds: number[];
|
||||
newIds: number[];
|
||||
},
|
||||
) {
|
||||
await tx.insert(permissionAuditTable).values({
|
||||
targetKind: args.targetKind,
|
||||
targetId: args.targetId,
|
||||
changeKind: args.changeKind,
|
||||
actorUserId: args.actorUserId ?? null,
|
||||
previousIds: sortedIds(args.previousIds),
|
||||
newIds: sortedIds(args.newIds),
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
permissionsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
|
||||
import {
|
||||
listPermissionAudit,
|
||||
parsePermissionAuditFilters,
|
||||
recordPermissionAudit,
|
||||
} from "../lib/permission-audit";
|
||||
import {
|
||||
CreateAppBody,
|
||||
UpdateAppBody,
|
||||
@@ -389,11 +394,32 @@ router.post("/apps/:id/permissions", requireAdmin, async (req, res): Promise<voi
|
||||
// endpoint idempotent so the UI can safely re-add an existing pair.
|
||||
// .returning() lets us tell a real insert from a no-op so the audit log
|
||||
// only records actual permission tightening, not retry clicks.
|
||||
const inserted = await db
|
||||
.insert(appPermissionsTable)
|
||||
.values({ appId: app.id, permissionId })
|
||||
.onConflictDoNothing()
|
||||
.returning({ appId: appPermissionsTable.appId });
|
||||
const inserted = await db.transaction(async (tx) => {
|
||||
const previousIds = (
|
||||
await tx
|
||||
.select({ permissionId: appPermissionsTable.permissionId })
|
||||
.from(appPermissionsTable)
|
||||
.where(eq(appPermissionsTable.appId, app.id))
|
||||
).map((r) => r.permissionId);
|
||||
const ins = await tx
|
||||
.insert(appPermissionsTable)
|
||||
.values({ appId: app.id, permissionId })
|
||||
.onConflictDoNothing()
|
||||
.returning({ appId: appPermissionsTable.appId });
|
||||
if (ins.length > 0) {
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "app",
|
||||
targetId: app.id,
|
||||
changeKind: "app.permissions",
|
||||
actorUserId: req.session.userId ?? null,
|
||||
previousIds,
|
||||
newIds: previousIds.includes(permissionId)
|
||||
? previousIds
|
||||
: [...previousIds, permissionId],
|
||||
});
|
||||
}
|
||||
return ins;
|
||||
});
|
||||
|
||||
if (inserted.length > 0) {
|
||||
await db.insert(auditLogsTable).values({
|
||||
@@ -446,15 +472,34 @@ router.delete(
|
||||
.from(permissionsTable)
|
||||
.where(eq(permissionsTable.id, permissionId));
|
||||
|
||||
const deleted = await db
|
||||
.delete(appPermissionsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(appPermissionsTable.appId, appId),
|
||||
eq(appPermissionsTable.permissionId, permissionId),
|
||||
),
|
||||
)
|
||||
.returning({ appId: appPermissionsTable.appId });
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
const previousIds = (
|
||||
await tx
|
||||
.select({ permissionId: appPermissionsTable.permissionId })
|
||||
.from(appPermissionsTable)
|
||||
.where(eq(appPermissionsTable.appId, appId))
|
||||
).map((r) => r.permissionId);
|
||||
const del = await tx
|
||||
.delete(appPermissionsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(appPermissionsTable.appId, appId),
|
||||
eq(appPermissionsTable.permissionId, permissionId),
|
||||
),
|
||||
)
|
||||
.returning({ appId: appPermissionsTable.appId });
|
||||
if (del.length > 0) {
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "app",
|
||||
targetId: app.id,
|
||||
changeKind: "app.permissions",
|
||||
actorUserId: req.session.userId ?? null,
|
||||
previousIds,
|
||||
newIds: previousIds.filter((pid) => pid !== permissionId),
|
||||
});
|
||||
}
|
||||
return del;
|
||||
});
|
||||
|
||||
if (deleted.length > 0) {
|
||||
await db.insert(auditLogsTable).values({
|
||||
@@ -475,6 +520,44 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/apps/:id/audit",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
const limitRaw = Number(req.query.limit);
|
||||
const offsetRaw = Number(req.query.offset);
|
||||
const limit =
|
||||
Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.min(Math.floor(limitRaw), 200)
|
||||
: 10;
|
||||
const offset =
|
||||
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
|
||||
const [app] = await db
|
||||
.select({ id: appsTable.id })
|
||||
.from(appsTable)
|
||||
.where(eq(appsTable.id, id));
|
||||
if (!app) {
|
||||
res.status(404).json({ error: "App not found" });
|
||||
return;
|
||||
}
|
||||
const filters = parsePermissionAuditFilters(req, res);
|
||||
if (!filters) return;
|
||||
const result = await listPermissionAudit({
|
||||
targetKind: "app",
|
||||
targetId: id,
|
||||
limit,
|
||||
offset,
|
||||
filters,
|
||||
});
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/apps/:id/open", requireAuth, async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const params = GetAppParams.safeParse(req.params);
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
} from "@workspace/db";
|
||||
import { requireAdmin } from "../middlewares/auth";
|
||||
import { emitAppsChangedToUsers } from "../lib/realtime";
|
||||
import {
|
||||
listPermissionAudit,
|
||||
parsePermissionAuditFilters,
|
||||
recordPermissionAudit,
|
||||
} from "../lib/permission-audit";
|
||||
import type { PermissionAuditChangeKind } from "@workspace/db";
|
||||
import {
|
||||
CreateGroupBody,
|
||||
UpdateGroupBody,
|
||||
@@ -95,6 +101,44 @@ router.get("/groups", requireAdmin, async (req, res): Promise<void> => {
|
||||
);
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/groups/:id/audit",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
const limitRaw = Number(req.query.limit);
|
||||
const offsetRaw = Number(req.query.offset);
|
||||
const limit =
|
||||
Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.min(Math.floor(limitRaw), 200)
|
||||
: 10;
|
||||
const offset =
|
||||
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
|
||||
const [group] = await db
|
||||
.select({ id: groupsTable.id })
|
||||
.from(groupsTable)
|
||||
.where(eq(groupsTable.id, id));
|
||||
if (!group) {
|
||||
res.status(404).json({ error: "Group not found" });
|
||||
return;
|
||||
}
|
||||
const filters = parsePermissionAuditFilters(req, res);
|
||||
if (!filters) return;
|
||||
const result = await listPermissionAudit({
|
||||
targetKind: "group",
|
||||
targetId: id,
|
||||
limit,
|
||||
offset,
|
||||
filters,
|
||||
});
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/groups/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
@@ -169,8 +213,15 @@ async function applyGroupAssignmentsTx(
|
||||
appIds: number[] | undefined,
|
||||
roleIds: number[] | undefined,
|
||||
userIds: number[] | undefined,
|
||||
actorUserId: number | null,
|
||||
) {
|
||||
if (appIds !== undefined) {
|
||||
const previousIds = (
|
||||
await tx
|
||||
.select({ appId: groupAppsTable.appId })
|
||||
.from(groupAppsTable)
|
||||
.where(eq(groupAppsTable.groupId, groupId))
|
||||
).map((r) => r.appId);
|
||||
await tx.delete(groupAppsTable).where(eq(groupAppsTable.groupId, groupId));
|
||||
if (appIds.length > 0) {
|
||||
await tx
|
||||
@@ -178,8 +229,22 @@ async function applyGroupAssignmentsTx(
|
||||
.values(appIds.map((appId) => ({ groupId, appId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "group",
|
||||
targetId: groupId,
|
||||
changeKind: "group.apps",
|
||||
actorUserId,
|
||||
previousIds,
|
||||
newIds: appIds,
|
||||
});
|
||||
}
|
||||
if (roleIds !== undefined) {
|
||||
const previousIds = (
|
||||
await tx
|
||||
.select({ roleId: groupRolesTable.roleId })
|
||||
.from(groupRolesTable)
|
||||
.where(eq(groupRolesTable.groupId, groupId))
|
||||
).map((r) => r.roleId);
|
||||
await tx.delete(groupRolesTable).where(eq(groupRolesTable.groupId, groupId));
|
||||
if (roleIds.length > 0) {
|
||||
await tx
|
||||
@@ -187,8 +252,22 @@ async function applyGroupAssignmentsTx(
|
||||
.values(roleIds.map((roleId) => ({ groupId, roleId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "group",
|
||||
targetId: groupId,
|
||||
changeKind: "group.roles",
|
||||
actorUserId,
|
||||
previousIds,
|
||||
newIds: roleIds,
|
||||
});
|
||||
}
|
||||
if (userIds !== undefined) {
|
||||
const previousIds = (
|
||||
await tx
|
||||
.select({ userId: userGroupsTable.userId })
|
||||
.from(userGroupsTable)
|
||||
.where(eq(userGroupsTable.groupId, groupId))
|
||||
).map((r) => r.userId);
|
||||
await tx.delete(userGroupsTable).where(eq(userGroupsTable.groupId, groupId));
|
||||
if (userIds.length > 0) {
|
||||
await tx
|
||||
@@ -196,6 +275,37 @@ async function applyGroupAssignmentsTx(
|
||||
.values(userIds.map((userId) => ({ userId, groupId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "group",
|
||||
targetId: groupId,
|
||||
changeKind: "group.users",
|
||||
actorUserId,
|
||||
previousIds,
|
||||
newIds: userIds,
|
||||
});
|
||||
// Mirror onto each affected user so their personal history shows the
|
||||
// group-membership change regardless of which editor was used.
|
||||
const added = userIds.filter((uid) => !previousIds.includes(uid));
|
||||
const removed = previousIds.filter((uid) => !userIds.includes(uid));
|
||||
for (const uid of [...added, ...removed]) {
|
||||
const newGroupIds = (
|
||||
await tx
|
||||
.select({ groupId: userGroupsTable.groupId })
|
||||
.from(userGroupsTable)
|
||||
.where(eq(userGroupsTable.userId, uid))
|
||||
).map((r) => r.groupId);
|
||||
const previousGroupIds = added.includes(uid)
|
||||
? newGroupIds.filter((g) => g !== groupId)
|
||||
: [...newGroupIds, groupId];
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "user",
|
||||
targetId: uid,
|
||||
changeKind: "user.groups",
|
||||
actorUserId,
|
||||
previousIds: previousGroupIds,
|
||||
newIds: newGroupIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +347,7 @@ router.post("/groups", requireAdmin, async (req, res): Promise<void> => {
|
||||
parsed.data.appIds,
|
||||
parsed.data.roleIds,
|
||||
parsed.data.userIds,
|
||||
req.session.userId ?? null,
|
||||
);
|
||||
return g;
|
||||
});
|
||||
@@ -314,6 +425,7 @@ router.patch("/groups/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
parsed.data.appIds,
|
||||
parsed.data.roleIds,
|
||||
parsed.data.userIds,
|
||||
req.session.userId ?? null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -458,28 +570,89 @@ router.post("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Promi
|
||||
res.status(404).json({ error: `${kind.slice(0, -1)} not found` });
|
||||
return;
|
||||
}
|
||||
let inserted: { groupId: number }[] = [];
|
||||
if (kind === "users") {
|
||||
inserted = await db
|
||||
.insert(userGroupsTable)
|
||||
.values({ groupId: id, userId: targetId })
|
||||
.onConflictDoNothing()
|
||||
.returning({ groupId: userGroupsTable.groupId });
|
||||
await emitAppsChangedToUsers([targetId]);
|
||||
} else if (kind === "apps") {
|
||||
inserted = await db
|
||||
.insert(groupAppsTable)
|
||||
.values({ groupId: id, appId: targetId })
|
||||
.onConflictDoNothing()
|
||||
.returning({ groupId: groupAppsTable.groupId });
|
||||
await emitAppsChangedToUsers(await getGroupMemberIds(id));
|
||||
} else {
|
||||
inserted = await db
|
||||
.insert(groupRolesTable)
|
||||
.values({ groupId: id, roleId: targetId })
|
||||
.onConflictDoNothing()
|
||||
.returning({ groupId: groupRolesTable.groupId });
|
||||
await emitAppsChangedToUsers(await getGroupMemberIds(id));
|
||||
const auditChangeKind: PermissionAuditChangeKind =
|
||||
kind === "users"
|
||||
? "group.users"
|
||||
: kind === "apps"
|
||||
? "group.apps"
|
||||
: "group.roles";
|
||||
const inserted = await db.transaction(async (tx) => {
|
||||
let previousIds: number[] = [];
|
||||
let ins: { groupId: number }[] = [];
|
||||
if (kind === "users") {
|
||||
previousIds = (
|
||||
await tx
|
||||
.select({ userId: userGroupsTable.userId })
|
||||
.from(userGroupsTable)
|
||||
.where(eq(userGroupsTable.groupId, id))
|
||||
).map((r) => r.userId);
|
||||
ins = await tx
|
||||
.insert(userGroupsTable)
|
||||
.values({ groupId: id, userId: targetId })
|
||||
.onConflictDoNothing()
|
||||
.returning({ groupId: userGroupsTable.groupId });
|
||||
} else if (kind === "apps") {
|
||||
previousIds = (
|
||||
await tx
|
||||
.select({ appId: groupAppsTable.appId })
|
||||
.from(groupAppsTable)
|
||||
.where(eq(groupAppsTable.groupId, id))
|
||||
).map((r) => r.appId);
|
||||
ins = await tx
|
||||
.insert(groupAppsTable)
|
||||
.values({ groupId: id, appId: targetId })
|
||||
.onConflictDoNothing()
|
||||
.returning({ groupId: groupAppsTable.groupId });
|
||||
} else {
|
||||
previousIds = (
|
||||
await tx
|
||||
.select({ roleId: groupRolesTable.roleId })
|
||||
.from(groupRolesTable)
|
||||
.where(eq(groupRolesTable.groupId, id))
|
||||
).map((r) => r.roleId);
|
||||
ins = await tx
|
||||
.insert(groupRolesTable)
|
||||
.values({ groupId: id, roleId: targetId })
|
||||
.onConflictDoNothing()
|
||||
.returning({ groupId: groupRolesTable.groupId });
|
||||
}
|
||||
if (ins.length > 0) {
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "group",
|
||||
targetId: id,
|
||||
changeKind: auditChangeKind,
|
||||
actorUserId: req.session.userId ?? null,
|
||||
previousIds,
|
||||
newIds: previousIds.includes(targetId)
|
||||
? previousIds
|
||||
: [...previousIds, targetId],
|
||||
});
|
||||
if (kind === "users") {
|
||||
// Mirror onto the affected user's history.
|
||||
const newGroupIds = (
|
||||
await tx
|
||||
.select({ groupId: userGroupsTable.groupId })
|
||||
.from(userGroupsTable)
|
||||
.where(eq(userGroupsTable.userId, targetId))
|
||||
).map((r) => r.groupId);
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "user",
|
||||
targetId,
|
||||
changeKind: "user.groups",
|
||||
actorUserId: req.session.userId ?? null,
|
||||
previousIds: newGroupIds.filter((g) => g !== id),
|
||||
newIds: newGroupIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
return ins;
|
||||
});
|
||||
if (inserted.length > 0) {
|
||||
if (kind === "users") {
|
||||
await emitAppsChangedToUsers([targetId]);
|
||||
} else {
|
||||
await emitAppsChangedToUsers(await getGroupMemberIds(id));
|
||||
}
|
||||
}
|
||||
if (inserted.length > 0) {
|
||||
const subject = kind === "users" ? "user" : kind === "apps" ? "app" : "role";
|
||||
@@ -511,27 +684,86 @@ router.delete("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Pro
|
||||
res.status(404).json({ error: "Group not found" });
|
||||
return;
|
||||
}
|
||||
let removed: { groupId: number }[] = [];
|
||||
if (kind === "users") {
|
||||
removed = await db
|
||||
.delete(userGroupsTable)
|
||||
.where(sql`${userGroupsTable.groupId} = ${id} and ${userGroupsTable.userId} = ${targetId}`)
|
||||
.returning({ groupId: userGroupsTable.groupId });
|
||||
await emitAppsChangedToUsers([targetId]);
|
||||
} else if (kind === "apps") {
|
||||
const memberIds = await getGroupMemberIds(id);
|
||||
removed = await db
|
||||
.delete(groupAppsTable)
|
||||
.where(sql`${groupAppsTable.groupId} = ${id} and ${groupAppsTable.appId} = ${targetId}`)
|
||||
.returning({ groupId: groupAppsTable.groupId });
|
||||
await emitAppsChangedToUsers(memberIds);
|
||||
} else {
|
||||
const memberIds = await getGroupMemberIds(id);
|
||||
removed = await db
|
||||
.delete(groupRolesTable)
|
||||
.where(sql`${groupRolesTable.groupId} = ${id} and ${groupRolesTable.roleId} = ${targetId}`)
|
||||
.returning({ groupId: groupRolesTable.groupId });
|
||||
await emitAppsChangedToUsers(memberIds);
|
||||
const auditChangeKind: PermissionAuditChangeKind =
|
||||
kind === "users"
|
||||
? "group.users"
|
||||
: kind === "apps"
|
||||
? "group.apps"
|
||||
: "group.roles";
|
||||
const memberIdsBefore =
|
||||
kind === "users" ? null : await getGroupMemberIds(id);
|
||||
const removed = await db.transaction(async (tx) => {
|
||||
let previousIds: number[] = [];
|
||||
let del: { groupId: number }[] = [];
|
||||
if (kind === "users") {
|
||||
previousIds = (
|
||||
await tx
|
||||
.select({ userId: userGroupsTable.userId })
|
||||
.from(userGroupsTable)
|
||||
.where(eq(userGroupsTable.groupId, id))
|
||||
).map((r) => r.userId);
|
||||
del = await tx
|
||||
.delete(userGroupsTable)
|
||||
.where(sql`${userGroupsTable.groupId} = ${id} and ${userGroupsTable.userId} = ${targetId}`)
|
||||
.returning({ groupId: userGroupsTable.groupId });
|
||||
} else if (kind === "apps") {
|
||||
previousIds = (
|
||||
await tx
|
||||
.select({ appId: groupAppsTable.appId })
|
||||
.from(groupAppsTable)
|
||||
.where(eq(groupAppsTable.groupId, id))
|
||||
).map((r) => r.appId);
|
||||
del = await tx
|
||||
.delete(groupAppsTable)
|
||||
.where(sql`${groupAppsTable.groupId} = ${id} and ${groupAppsTable.appId} = ${targetId}`)
|
||||
.returning({ groupId: groupAppsTable.groupId });
|
||||
} else {
|
||||
previousIds = (
|
||||
await tx
|
||||
.select({ roleId: groupRolesTable.roleId })
|
||||
.from(groupRolesTable)
|
||||
.where(eq(groupRolesTable.groupId, id))
|
||||
).map((r) => r.roleId);
|
||||
del = await tx
|
||||
.delete(groupRolesTable)
|
||||
.where(sql`${groupRolesTable.groupId} = ${id} and ${groupRolesTable.roleId} = ${targetId}`)
|
||||
.returning({ groupId: groupRolesTable.groupId });
|
||||
}
|
||||
if (del.length > 0) {
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "group",
|
||||
targetId: id,
|
||||
changeKind: auditChangeKind,
|
||||
actorUserId: req.session.userId ?? null,
|
||||
previousIds,
|
||||
newIds: previousIds.filter((pid) => pid !== targetId),
|
||||
});
|
||||
if (kind === "users") {
|
||||
// Mirror onto the affected user's history.
|
||||
const newGroupIds = (
|
||||
await tx
|
||||
.select({ groupId: userGroupsTable.groupId })
|
||||
.from(userGroupsTable)
|
||||
.where(eq(userGroupsTable.userId, targetId))
|
||||
).map((r) => r.groupId);
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "user",
|
||||
targetId,
|
||||
changeKind: "user.groups",
|
||||
actorUserId: req.session.userId ?? null,
|
||||
previousIds: [...newGroupIds, id],
|
||||
newIds: newGroupIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
return del;
|
||||
});
|
||||
if (removed.length > 0) {
|
||||
if (kind === "users") {
|
||||
await emitAppsChangedToUsers([targetId]);
|
||||
} else {
|
||||
await emitAppsChangedToUsers(memberIdsBefore!);
|
||||
}
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
const subject = kind === "users" ? "user" : kind === "apps" ? "app" : "role";
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
|
||||
import { emitAppsChangedToUsers } from "../lib/realtime";
|
||||
import {
|
||||
listPermissionAudit,
|
||||
parsePermissionAuditFilters,
|
||||
recordPermissionAudit,
|
||||
} from "../lib/permission-audit";
|
||||
import {
|
||||
RegisterBody,
|
||||
CreateUserBody,
|
||||
@@ -326,6 +331,44 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
);
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/users/:id/audit",
|
||||
requireAdmin,
|
||||
async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
res.status(400).json({ error: "Invalid id" });
|
||||
return;
|
||||
}
|
||||
const limitRaw = Number(req.query.limit);
|
||||
const offsetRaw = Number(req.query.offset);
|
||||
const limit =
|
||||
Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.min(Math.floor(limitRaw), 200)
|
||||
: 10;
|
||||
const offset =
|
||||
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
|
||||
const [user] = await db
|
||||
.select({ id: usersTable.id })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, id));
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
const filters = parsePermissionAuditFilters(req, res);
|
||||
if (!filters) return;
|
||||
const result = await listPermissionAudit({
|
||||
targetKind: "user",
|
||||
targetId: id,
|
||||
limit,
|
||||
offset,
|
||||
filters,
|
||||
});
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
const params = GetUserParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
@@ -399,7 +442,16 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
}
|
||||
|
||||
if (parsed.data.groupIds !== undefined) {
|
||||
// Same-transaction audit: read previous group ids, apply the change, and
|
||||
// write the permission_audit row atomically so the history can never
|
||||
// disagree with reality.
|
||||
await db.transaction(async (tx) => {
|
||||
const previousIds = (
|
||||
await tx
|
||||
.select({ groupId: userGroupsTable.groupId })
|
||||
.from(userGroupsTable)
|
||||
.where(eq(userGroupsTable.userId, userId))
|
||||
).map((r) => r.groupId);
|
||||
await tx.delete(userGroupsTable).where(eq(userGroupsTable.userId, userId));
|
||||
if (parsed.data.groupIds && parsed.data.groupIds.length > 0) {
|
||||
await tx
|
||||
@@ -407,6 +459,38 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
.values(parsed.data.groupIds.map((groupId) => ({ userId, groupId })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "user",
|
||||
targetId: userId,
|
||||
changeKind: "user.groups",
|
||||
actorUserId: req.session.userId ?? null,
|
||||
previousIds,
|
||||
newIds: parsed.data.groupIds ?? [],
|
||||
});
|
||||
// Mirror onto each affected group so its history shows the
|
||||
// membership change regardless of which editor was used.
|
||||
const newIds = parsed.data.groupIds ?? [];
|
||||
const added = newIds.filter((gid) => !previousIds.includes(gid));
|
||||
const removed = previousIds.filter((gid) => !newIds.includes(gid));
|
||||
for (const gid of [...added, ...removed]) {
|
||||
const newUserIds = (
|
||||
await tx
|
||||
.select({ userId: userGroupsTable.userId })
|
||||
.from(userGroupsTable)
|
||||
.where(eq(userGroupsTable.groupId, gid))
|
||||
).map((r) => r.userId);
|
||||
const previousUserIds = added.includes(gid)
|
||||
? newUserIds.filter((u) => u !== userId)
|
||||
: [...newUserIds, userId];
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "group",
|
||||
targetId: gid,
|
||||
changeKind: "group.users",
|
||||
actorUserId: req.session.userId ?? null,
|
||||
previousIds: previousUserIds,
|
||||
newIds: newUserIds,
|
||||
});
|
||||
}
|
||||
});
|
||||
await emitAppsChangedToUsers([userId]);
|
||||
}
|
||||
@@ -446,11 +530,30 @@ router.post("/users/:id/roles", requireAdmin, async (req, res): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
const inserted = await db
|
||||
.insert(userRolesTable)
|
||||
.values({ userId: user.id, roleId: role.id })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const inserted = await db.transaction(async (tx) => {
|
||||
const previousRoleIds = (
|
||||
await tx
|
||||
.select({ roleId: userRolesTable.roleId })
|
||||
.from(userRolesTable)
|
||||
.where(eq(userRolesTable.userId, user.id))
|
||||
).map((r) => r.roleId);
|
||||
const ins = await tx
|
||||
.insert(userRolesTable)
|
||||
.values({ userId: user.id, roleId: role.id })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
if (ins.length > 0) {
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "user",
|
||||
targetId: user.id,
|
||||
changeKind: "user.roles",
|
||||
actorUserId: req.session.userId ?? null,
|
||||
previousIds: previousRoleIds,
|
||||
newIds: [...previousRoleIds, role.id],
|
||||
});
|
||||
}
|
||||
return ins;
|
||||
});
|
||||
if (inserted.length > 0) {
|
||||
await emitAppsChangedToUsers([user.id]);
|
||||
}
|
||||
@@ -490,15 +593,34 @@ router.delete(
|
||||
.where(eq(rolesTable.name, roleName));
|
||||
|
||||
if (role) {
|
||||
const deleted = await db
|
||||
.delete(userRolesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(userRolesTable.userId, user.id),
|
||||
eq(userRolesTable.roleId, role.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
const previousRoleIds = (
|
||||
await tx
|
||||
.select({ roleId: userRolesTable.roleId })
|
||||
.from(userRolesTable)
|
||||
.where(eq(userRolesTable.userId, user.id))
|
||||
).map((r) => r.roleId);
|
||||
const del = await tx
|
||||
.delete(userRolesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(userRolesTable.userId, user.id),
|
||||
eq(userRolesTable.roleId, role.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (del.length > 0) {
|
||||
await recordPermissionAudit(tx, {
|
||||
targetKind: "user",
|
||||
targetId: user.id,
|
||||
changeKind: "user.roles",
|
||||
actorUserId: req.session.userId ?? null,
|
||||
previousIds: previousRoleIds,
|
||||
newIds: previousRoleIds.filter((rid) => rid !== role.id),
|
||||
});
|
||||
}
|
||||
return del;
|
||||
});
|
||||
if (deleted.length > 0) {
|
||||
await emitAppsChangedToUsers([user.id]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
let adminId;
|
||||
let adminUsername;
|
||||
let adminCookie;
|
||||
let nonAdminCookie;
|
||||
const createdUserIds = [];
|
||||
const createdAppIds = [];
|
||||
const createdPermissionIds = [];
|
||||
|
||||
async function loginAndGetCookie(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
return setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid="));
|
||||
}
|
||||
|
||||
function uniqueName(prefix) {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 6)}`;
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
const slug = uniqueName("app_audit");
|
||||
const r = await pool.query(
|
||||
`INSERT INTO apps (slug, name_ar, name_en, icon_name, color, route)
|
||||
VALUES ($1, 'تطبيق', 'App', 'Grid', '#6366f1', '/x') RETURNING id`,
|
||||
[slug],
|
||||
);
|
||||
const id = r.rows[0].id;
|
||||
createdAppIds.push(id);
|
||||
return { id, slug };
|
||||
}
|
||||
|
||||
async function createPermission() {
|
||||
const name = uniqueName("app_audit_perm");
|
||||
const r = await pool.query(
|
||||
`INSERT INTO permissions (name, description_en) VALUES ($1::varchar, $1::text) RETURNING id`,
|
||||
[name],
|
||||
);
|
||||
const id = r.rows[0].id;
|
||||
createdPermissionIds.push(id);
|
||||
return { id, name };
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
adminUsername = uniqueName("app_audit_admin");
|
||||
const a = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'App Audit Admin', 'en', true) RETURNING id`,
|
||||
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
adminId = a.rows[0].id;
|
||||
createdUserIds.push(adminId);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
||||
[adminId],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO user_groups (user_id, group_id)
|
||||
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[adminId],
|
||||
);
|
||||
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
||||
|
||||
const naUsername = uniqueName("app_audit_nonadmin");
|
||||
const na = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Non Admin', 'en', true) RETURNING id`,
|
||||
[naUsername, `${naUsername}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
createdUserIds.push(na.rows[0].id);
|
||||
nonAdminCookie = await loginAndGetCookie(naUsername, TEST_PASSWORD);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (createdAppIds.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM permission_audit WHERE target_kind = 'app' AND target_id = ANY($1::int[])`,
|
||||
[createdAppIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
|
||||
[createdAppIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
|
||||
createdAppIds,
|
||||
]);
|
||||
}
|
||||
if (createdPermissionIds.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM permissions WHERE id = ANY($1::int[])`,
|
||||
[createdPermissionIds],
|
||||
);
|
||||
}
|
||||
for (const uid of createdUserIds) {
|
||||
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("POST /api/apps/:id/permissions writes an app.permissions audit row transactionally", async () => {
|
||||
const app = await createApp();
|
||||
const p1 = await createPermission();
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM permission_audit
|
||||
WHERE target_kind = 'app' AND target_id = $1`,
|
||||
[app.id],
|
||||
);
|
||||
assert.equal(before.rows[0].c, 0);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ permissionId: p1.id }),
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
|
||||
const rows = await pool.query(
|
||||
`SELECT actor_user_id, change_kind, previous_ids, new_ids
|
||||
FROM permission_audit
|
||||
WHERE target_kind = 'app' AND target_id = $1`,
|
||||
[app.id],
|
||||
);
|
||||
assert.equal(rows.rowCount, 1);
|
||||
const row = rows.rows[0];
|
||||
assert.equal(row.actor_user_id, adminId);
|
||||
assert.equal(row.change_kind, "app.permissions");
|
||||
assert.deepEqual(row.previous_ids, []);
|
||||
assert.deepEqual(row.new_ids, [p1.id]);
|
||||
|
||||
// Idempotent re-add (same permissionId) → no new audit row.
|
||||
const dup = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ permissionId: p1.id }),
|
||||
});
|
||||
assert.equal(dup.status, 201);
|
||||
const after = await pool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM permission_audit
|
||||
WHERE target_kind = 'app' AND target_id = $1`,
|
||||
[app.id],
|
||||
);
|
||||
assert.equal(after.rows[0].c, 1);
|
||||
});
|
||||
|
||||
test("DELETE /api/apps/:id/permissions/:permissionId records previous/new ids", async () => {
|
||||
const app = await createApp();
|
||||
const p1 = await createPermission();
|
||||
const p2 = await createPermission();
|
||||
|
||||
for (const p of [p1, p2]) {
|
||||
const r = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ permissionId: p.id }),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
|
||||
const del = await fetch(
|
||||
`${API_BASE}/api/apps/${app.id}/permissions/${p1.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
},
|
||||
);
|
||||
assert.equal(del.status, 204);
|
||||
|
||||
const rows = await pool.query(
|
||||
`SELECT change_kind, previous_ids, new_ids
|
||||
FROM permission_audit
|
||||
WHERE target_kind = 'app' AND target_id = $1
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
[app.id],
|
||||
);
|
||||
assert.equal(rows.rowCount, 1);
|
||||
const sortedIds = (a) => [...a].sort((x, y) => x - y);
|
||||
assert.equal(rows.rows[0].change_kind, "app.permissions");
|
||||
assert.deepEqual(sortedIds(rows.rows[0].previous_ids), sortedIds([p1.id, p2.id]));
|
||||
assert.deepEqual(rows.rows[0].new_ids, [p2.id]);
|
||||
});
|
||||
|
||||
test("GET /api/apps/:id/audit returns entries with diff fields, actor, pagination and date filters", async () => {
|
||||
const app = await createApp();
|
||||
const p1 = await createPermission();
|
||||
const p2 = await createPermission();
|
||||
const p3 = await createPermission();
|
||||
|
||||
for (const p of [p1, p2, p3]) {
|
||||
const r = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ permissionId: p.id }),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
|
||||
const allRes = await fetch(`${API_BASE}/api/apps/${app.id}/audit`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(allRes.status, 200);
|
||||
const all = await allRes.json();
|
||||
assert.equal(all.totalCount, 3);
|
||||
assert.equal(all.entries.length, 3);
|
||||
// Newest first: p3 added.
|
||||
const latest = all.entries[0];
|
||||
assert.equal(latest.changeKind, "app.permissions");
|
||||
assert.deepEqual(latest.addedIds, [p3.id]);
|
||||
assert.deepEqual(latest.removedIds, []);
|
||||
assert.ok(latest.actor);
|
||||
assert.equal(latest.actor.id, adminId);
|
||||
assert.equal(latest.actor.username, adminUsername);
|
||||
|
||||
const pageRes = await fetch(
|
||||
`${API_BASE}/api/apps/${app.id}/audit?limit=2&offset=0`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(pageRes.status, 200);
|
||||
const page = await pageRes.json();
|
||||
assert.equal(page.entries.length, 2);
|
||||
assert.equal(page.totalCount, 3);
|
||||
assert.equal(page.nextOffset, 2);
|
||||
|
||||
// actorUserId filter — admin authored every row.
|
||||
const filteredRes = await fetch(
|
||||
`${API_BASE}/api/apps/${app.id}/audit?actorUserId=${adminId}`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
const filtered = await filteredRes.json();
|
||||
assert.equal(filtered.totalCount, 3);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
const past = await fetch(
|
||||
`${API_BASE}/api/apps/${app.id}/audit?from=${yesterday}&to=${yesterday}`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
const pastBody = await past.json();
|
||||
assert.equal(pastBody.totalCount, 0);
|
||||
|
||||
const inRange = await fetch(
|
||||
`${API_BASE}/api/apps/${app.id}/audit?from=${today}&to=${today}`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
const inRangeBody = await inRange.json();
|
||||
assert.equal(inRangeBody.totalCount, 3);
|
||||
});
|
||||
|
||||
test("GET /api/apps/:id/audit is admin-only and 404s on unknown app", async () => {
|
||||
const app = await createApp();
|
||||
const res = await fetch(`${API_BASE}/api/apps/${app.id}/audit`, {
|
||||
headers: { Cookie: nonAdminCookie },
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
|
||||
const missing = await fetch(`${API_BASE}/api/apps/99999999/audit`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
let adminId;
|
||||
let adminUsername;
|
||||
let adminCookie;
|
||||
let nonAdminCookie;
|
||||
const createdUserIds = [];
|
||||
const createdGroupIds = [];
|
||||
const createdRoleIds = [];
|
||||
const createdAppIds = [];
|
||||
|
||||
async function loginAndGetCookie(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
return setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid="));
|
||||
}
|
||||
|
||||
function uniqueName(prefix) {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 6)}`;
|
||||
}
|
||||
|
||||
async function createGroup() {
|
||||
const res = await fetch(`${API_BASE}/api/groups`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ name: uniqueName("grp_audit") }),
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const body = await res.json();
|
||||
createdGroupIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function createRole() {
|
||||
const res = await fetch(`${API_BASE}/api/roles`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ name: uniqueName("grp_audit_role") }),
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const body = await res.json();
|
||||
createdRoleIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
const username = uniqueName("grp_audit_user");
|
||||
const r = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Member', 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
const id = r.rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
const slug = uniqueName("grp_audit_app");
|
||||
const r = await pool.query(
|
||||
`INSERT INTO apps (slug, name_ar, name_en, icon_name, color, route)
|
||||
VALUES ($1, 'تطبيق', 'App', 'Grid', '#6366f1', '/x') RETURNING id`,
|
||||
[slug],
|
||||
);
|
||||
const id = r.rows[0].id;
|
||||
createdAppIds.push(id);
|
||||
return { id, slug };
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
adminUsername = uniqueName("grp_audit_admin");
|
||||
const a = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Group Audit Admin', 'en', true) RETURNING id`,
|
||||
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
adminId = a.rows[0].id;
|
||||
createdUserIds.push(adminId);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
||||
[adminId],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO user_groups (user_id, group_id)
|
||||
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[adminId],
|
||||
);
|
||||
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
||||
|
||||
const naUsername = uniqueName("grp_audit_nonadmin");
|
||||
const na = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Non Admin', 'en', true) RETURNING id`,
|
||||
[naUsername, `${naUsername}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
createdUserIds.push(na.rows[0].id);
|
||||
nonAdminCookie = await loginAndGetCookie(naUsername, TEST_PASSWORD);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (createdGroupIds.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM permission_audit WHERE target_kind = 'group' AND target_id = ANY($1::int[])`,
|
||||
[createdGroupIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM user_groups WHERE group_id = ANY($1::int[])`,
|
||||
[createdGroupIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM group_roles WHERE group_id = ANY($1::int[])`,
|
||||
[createdGroupIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM group_apps WHERE group_id = ANY($1::int[])`,
|
||||
[createdGroupIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
|
||||
createdGroupIds,
|
||||
]);
|
||||
}
|
||||
if (createdAppIds.length) {
|
||||
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
|
||||
createdAppIds,
|
||||
]);
|
||||
}
|
||||
if (createdRoleIds.length) {
|
||||
await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [
|
||||
createdRoleIds,
|
||||
]);
|
||||
}
|
||||
for (const uid of createdUserIds) {
|
||||
await pool.query(
|
||||
`DELETE FROM permission_audit WHERE target_kind = 'user' AND target_id = $1`,
|
||||
[uid],
|
||||
);
|
||||
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("POST /api/groups/:id/users/:targetId writes a group.users permission_audit row transactionally", async () => {
|
||||
const grp = await createGroup();
|
||||
const u = await createUser();
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM permission_audit
|
||||
WHERE target_kind = 'group' AND target_id = $1`,
|
||||
[grp.id],
|
||||
);
|
||||
assert.equal(before.rows[0].c, 0);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
|
||||
const rows = await pool.query(
|
||||
`SELECT actor_user_id, change_kind, previous_ids, new_ids
|
||||
FROM permission_audit
|
||||
WHERE target_kind = 'group' AND target_id = $1`,
|
||||
[grp.id],
|
||||
);
|
||||
assert.equal(rows.rowCount, 1);
|
||||
const row = rows.rows[0];
|
||||
assert.equal(row.actor_user_id, adminId);
|
||||
assert.equal(row.change_kind, "group.users");
|
||||
assert.deepEqual(row.previous_ids, []);
|
||||
assert.deepEqual(row.new_ids, [u.id]);
|
||||
});
|
||||
|
||||
test("Group user-membership changes mirror onto the affected user's history", async () => {
|
||||
const grp = await createGroup();
|
||||
const u = await createUser();
|
||||
|
||||
// Add user via group endpoint → expect group.users AND mirrored user.groups.
|
||||
let res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
let userRows = await pool.query(
|
||||
`SELECT change_kind, previous_ids, new_ids FROM permission_audit
|
||||
WHERE target_kind = 'user' AND target_id = $1 ORDER BY id`,
|
||||
[u.id],
|
||||
);
|
||||
assert.equal(userRows.rowCount, 1);
|
||||
assert.equal(userRows.rows[0].change_kind, "user.groups");
|
||||
assert.deepEqual(userRows.rows[0].previous_ids, []);
|
||||
assert.deepEqual(userRows.rows[0].new_ids, [grp.id]);
|
||||
|
||||
// Remove via group endpoint → another mirrored user.groups row.
|
||||
res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
userRows = await pool.query(
|
||||
`SELECT change_kind, previous_ids, new_ids FROM permission_audit
|
||||
WHERE target_kind = 'user' AND target_id = $1 ORDER BY id`,
|
||||
[u.id],
|
||||
);
|
||||
assert.equal(userRows.rowCount, 2);
|
||||
assert.equal(userRows.rows[1].change_kind, "user.groups");
|
||||
assert.deepEqual(userRows.rows[1].previous_ids, [grp.id]);
|
||||
assert.deepEqual(userRows.rows[1].new_ids, []);
|
||||
});
|
||||
|
||||
test("DELETE /api/groups/:id/roles/:targetId records previous/new ids", async () => {
|
||||
const grp = await createGroup();
|
||||
const r1 = await createRole();
|
||||
const r2 = await createRole();
|
||||
|
||||
// Add two roles → audit rows captured by POST.
|
||||
for (const r of [r1, r2]) {
|
||||
const res = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${r.id}`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 204);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
|
||||
// Remove r1 → expect a delete audit row with prev=[r1,r2], new=[r2].
|
||||
const del = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${r1.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(del.status, 204);
|
||||
|
||||
const rows = await pool.query(
|
||||
`SELECT change_kind, previous_ids, new_ids
|
||||
FROM permission_audit
|
||||
WHERE target_kind = 'group' AND target_id = $1
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
[grp.id],
|
||||
);
|
||||
assert.equal(rows.rowCount, 1);
|
||||
const sortedIds = (a) => [...a].sort((x, y) => x - y);
|
||||
assert.equal(rows.rows[0].change_kind, "group.roles");
|
||||
assert.deepEqual(sortedIds(rows.rows[0].previous_ids), sortedIds([r1.id, r2.id]));
|
||||
assert.deepEqual(rows.rows[0].new_ids, [r2.id]);
|
||||
});
|
||||
|
||||
test("GET /api/groups/:id/audit returns mixed-kind history with pagination and date filters", async () => {
|
||||
const grp = await createGroup();
|
||||
const u = await createUser();
|
||||
const role = await createRole();
|
||||
const app = await createApp();
|
||||
|
||||
// 1: add user
|
||||
let r = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(r.status, 204);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// 2: add role
|
||||
r = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${role.id}`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(r.status, 204);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// 3: add app
|
||||
r = await fetch(`${API_BASE}/api/groups/${grp.id}/apps/${app.id}`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(r.status, 204);
|
||||
|
||||
const allRes = await fetch(`${API_BASE}/api/groups/${grp.id}/audit`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(allRes.status, 200);
|
||||
const all = await allRes.json();
|
||||
assert.equal(all.totalCount, 3);
|
||||
assert.equal(all.entries.length, 3);
|
||||
// Newest first → app, role, user.
|
||||
assert.equal(all.entries[0].changeKind, "group.apps");
|
||||
assert.deepEqual(all.entries[0].addedIds, [app.id]);
|
||||
assert.equal(all.entries[1].changeKind, "group.roles");
|
||||
assert.equal(all.entries[2].changeKind, "group.users");
|
||||
assert.ok(all.entries[0].actor);
|
||||
assert.equal(all.entries[0].actor.id, adminId);
|
||||
|
||||
const pageRes = await fetch(
|
||||
`${API_BASE}/api/groups/${grp.id}/audit?limit=2&offset=0`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(pageRes.status, 200);
|
||||
const page = await pageRes.json();
|
||||
assert.equal(page.entries.length, 2);
|
||||
assert.equal(page.nextOffset, 2);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const inRange = await fetch(
|
||||
`${API_BASE}/api/groups/${grp.id}/audit?from=${today}&to=${today}`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
const inRangeBody = await inRange.json();
|
||||
assert.equal(inRangeBody.totalCount, 3);
|
||||
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
const past = await fetch(
|
||||
`${API_BASE}/api/groups/${grp.id}/audit?from=${yesterday}&to=${yesterday}`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
const pastBody = await past.json();
|
||||
assert.equal(pastBody.totalCount, 0);
|
||||
});
|
||||
|
||||
test("GET /api/groups/:id/audit is admin-only and 404s on unknown group", async () => {
|
||||
const grp = await createGroup();
|
||||
const res = await fetch(`${API_BASE}/api/groups/${grp.id}/audit`, {
|
||||
headers: { Cookie: nonAdminCookie },
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
|
||||
const missing = await fetch(`${API_BASE}/api/groups/99999999/audit`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
let adminId;
|
||||
let adminUsername;
|
||||
let adminCookie;
|
||||
let nonAdminCookie;
|
||||
let nonAdminId;
|
||||
const createdUserIds = [];
|
||||
const createdGroupIds = [];
|
||||
const createdRoleIds = [];
|
||||
|
||||
async function loginAndGetCookie(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
return setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid="));
|
||||
}
|
||||
|
||||
function uniqueName(prefix) {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 6)}`;
|
||||
}
|
||||
|
||||
async function createSubjectUser() {
|
||||
const username = uniqueName("user_audit_subj");
|
||||
const r = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Subject', 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
const id = r.rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
async function createGroup() {
|
||||
const name = uniqueName("user_audit_grp");
|
||||
const res = await fetch(`${API_BASE}/api/groups`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const body = await res.json();
|
||||
createdGroupIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function createRole() {
|
||||
const name = uniqueName("user_audit_role");
|
||||
const res = await fetch(`${API_BASE}/api/roles`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const body = await res.json();
|
||||
createdRoleIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
adminUsername = uniqueName("user_audit_admin");
|
||||
const a = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'User Audit Admin', 'en', true) RETURNING id`,
|
||||
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
adminId = a.rows[0].id;
|
||||
createdUserIds.push(adminId);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
|
||||
[adminId],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO user_groups (user_id, group_id)
|
||||
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[adminId],
|
||||
);
|
||||
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
||||
|
||||
// A non-admin user used to assert /audit is admin-only.
|
||||
const naUsername = uniqueName("user_audit_nonadmin");
|
||||
const na = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Non Admin', 'en', true) RETURNING id`,
|
||||
[naUsername, `${naUsername}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
nonAdminId = na.rows[0].id;
|
||||
createdUserIds.push(nonAdminId);
|
||||
nonAdminCookie = await loginAndGetCookie(naUsername, TEST_PASSWORD);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (createdUserIds.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM permission_audit WHERE target_kind = 'user' AND target_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
}
|
||||
if (createdGroupIds.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM permission_audit WHERE target_kind = 'group' AND target_id = ANY($1::int[])`,
|
||||
[createdGroupIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM user_groups WHERE group_id = ANY($1::int[])`,
|
||||
[createdGroupIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
|
||||
createdGroupIds,
|
||||
]);
|
||||
}
|
||||
if (createdRoleIds.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM user_roles WHERE role_id = ANY($1::int[])`,
|
||||
[createdRoleIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [
|
||||
createdRoleIds,
|
||||
]);
|
||||
}
|
||||
for (const uid of createdUserIds) {
|
||||
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
|
||||
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("POST /api/users/:id/roles writes a user.roles permission_audit row transactionally", async () => {
|
||||
const subject = await createSubjectUser();
|
||||
const role = await createRole();
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM permission_audit
|
||||
WHERE target_kind = 'user' AND target_id = $1`,
|
||||
[subject.id],
|
||||
);
|
||||
assert.equal(before.rows[0].c, 0);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/users/${subject.id}/roles`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ roleName: role.name }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const rows = await pool.query(
|
||||
`SELECT actor_user_id, change_kind, previous_ids, new_ids
|
||||
FROM permission_audit
|
||||
WHERE target_kind = 'user' AND target_id = $1`,
|
||||
[subject.id],
|
||||
);
|
||||
assert.equal(rows.rowCount, 1);
|
||||
const row = rows.rows[0];
|
||||
assert.equal(row.actor_user_id, adminId);
|
||||
assert.equal(row.change_kind, "user.roles");
|
||||
assert.deepEqual(row.previous_ids, []);
|
||||
assert.deepEqual(row.new_ids, [role.id]);
|
||||
});
|
||||
|
||||
test("PATCH /api/users/:id with groupIds writes a user.groups permission_audit row", async () => {
|
||||
const subject = await createSubjectUser();
|
||||
const g1 = await createGroup();
|
||||
const g2 = await createGroup();
|
||||
|
||||
// First write: empty → [g1, g2]
|
||||
const r1 = await fetch(`${API_BASE}/api/users/${subject.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ groupIds: [g1.id, g2.id] }),
|
||||
});
|
||||
assert.equal(r1.status, 200);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
|
||||
// Second write: [g1, g2] → [g2]
|
||||
const r2 = await fetch(`${API_BASE}/api/users/${subject.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ groupIds: [g2.id] }),
|
||||
});
|
||||
assert.equal(r2.status, 200);
|
||||
|
||||
const rows = await pool.query(
|
||||
`SELECT change_kind, previous_ids, new_ids
|
||||
FROM permission_audit
|
||||
WHERE target_kind = 'user' AND target_id = $1
|
||||
ORDER BY id`,
|
||||
[subject.id],
|
||||
);
|
||||
assert.equal(rows.rowCount, 2);
|
||||
for (const row of rows.rows) {
|
||||
assert.equal(row.change_kind, "user.groups");
|
||||
}
|
||||
const sortedIds = (a) => [...a].sort((x, y) => x - y);
|
||||
assert.deepEqual(rows.rows[0].previous_ids, []);
|
||||
assert.deepEqual(sortedIds(rows.rows[0].new_ids), sortedIds([g1.id, g2.id]));
|
||||
assert.deepEqual(sortedIds(rows.rows[1].previous_ids), sortedIds([g1.id, g2.id]));
|
||||
assert.deepEqual(rows.rows[1].new_ids, [g2.id]);
|
||||
});
|
||||
|
||||
test("PATCH /api/users/:id with groupIds mirrors onto each affected group's history", async () => {
|
||||
const subject = await createSubjectUser();
|
||||
const g1 = await createGroup();
|
||||
const g2 = await createGroup();
|
||||
|
||||
// Add user to [g1, g2] → expect mirrored group.users rows on g1 AND g2.
|
||||
let res = await fetch(`${API_BASE}/api/users/${subject.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ groupIds: [g1.id, g2.id] }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
for (const g of [g1, g2]) {
|
||||
const rows = await pool.query(
|
||||
`SELECT change_kind, previous_ids, new_ids FROM permission_audit
|
||||
WHERE target_kind = 'group' AND target_id = $1 ORDER BY id`,
|
||||
[g.id],
|
||||
);
|
||||
assert.equal(rows.rowCount, 1);
|
||||
assert.equal(rows.rows[0].change_kind, "group.users");
|
||||
assert.deepEqual(rows.rows[0].previous_ids, []);
|
||||
assert.deepEqual(rows.rows[0].new_ids, [subject.id]);
|
||||
}
|
||||
|
||||
// Now drop g1 → expect a mirrored group.users row only on g1.
|
||||
res = await fetch(`${API_BASE}/api/users/${subject.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ groupIds: [g2.id] }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const g1Rows = await pool.query(
|
||||
`SELECT change_kind, previous_ids, new_ids FROM permission_audit
|
||||
WHERE target_kind = 'group' AND target_id = $1 ORDER BY id`,
|
||||
[g1.id],
|
||||
);
|
||||
assert.equal(g1Rows.rowCount, 2);
|
||||
assert.deepEqual(g1Rows.rows[1].previous_ids, [subject.id]);
|
||||
assert.deepEqual(g1Rows.rows[1].new_ids, []);
|
||||
|
||||
const g2Rows = await pool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM permission_audit
|
||||
WHERE target_kind = 'group' AND target_id = $1`,
|
||||
[g2.id],
|
||||
);
|
||||
assert.equal(g2Rows.rows[0].c, 1);
|
||||
});
|
||||
|
||||
test("GET /api/users/:id/audit returns entries newest first with diff fields, actor and pagination", async () => {
|
||||
const subject = await createSubjectUser();
|
||||
const g1 = await createGroup();
|
||||
const g2 = await createGroup();
|
||||
const g3 = await createGroup();
|
||||
const role = await createRole();
|
||||
|
||||
const sets = [[g1.id], [g1.id, g2.id], [g3.id]];
|
||||
for (const s of sets) {
|
||||
const r = await fetch(`${API_BASE}/api/users/${subject.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ groupIds: s }),
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
// One role mutation too — to exercise filtering by changeKind via the
|
||||
// cross-kind history.
|
||||
const ar = await fetch(`${API_BASE}/api/users/${subject.id}/roles`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ roleName: role.name }),
|
||||
});
|
||||
assert.equal(ar.status, 200);
|
||||
|
||||
const allRes = await fetch(`${API_BASE}/api/users/${subject.id}/audit`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(allRes.status, 200);
|
||||
const all = await allRes.json();
|
||||
assert.equal(all.totalCount, 4);
|
||||
assert.equal(all.entries.length, 4);
|
||||
// Latest entry is the role addition.
|
||||
const latest = all.entries[0];
|
||||
assert.equal(latest.changeKind, "user.roles");
|
||||
assert.deepEqual(latest.addedIds, [role.id]);
|
||||
assert.deepEqual(latest.removedIds, []);
|
||||
assert.ok(latest.actor);
|
||||
assert.equal(latest.actor.id, adminId);
|
||||
assert.equal(latest.actor.username, adminUsername);
|
||||
|
||||
// Pagination: limit=2 should yield nextOffset=2.
|
||||
const pageRes = await fetch(
|
||||
`${API_BASE}/api/users/${subject.id}/audit?limit=2`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(pageRes.status, 200);
|
||||
const page = await pageRes.json();
|
||||
assert.equal(page.entries.length, 2);
|
||||
assert.equal(page.totalCount, 4);
|
||||
assert.equal(page.nextOffset, 2);
|
||||
|
||||
// Date range: today must include all 4.
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const rangeRes = await fetch(
|
||||
`${API_BASE}/api/users/${subject.id}/audit?from=${today}&to=${today}`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(rangeRes.status, 200);
|
||||
const range = await rangeRes.json();
|
||||
assert.equal(range.totalCount, 4);
|
||||
|
||||
// Inverted range → 400.
|
||||
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
const badRes = await fetch(
|
||||
`${API_BASE}/api/users/${subject.id}/audit?from=${tomorrow}&to=${yesterday}`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(badRes.status, 400);
|
||||
});
|
||||
|
||||
test("GET /api/users/:id/audit is admin-only and 404s on unknown user", async () => {
|
||||
const subject = await createSubjectUser();
|
||||
const res = await fetch(`${API_BASE}/api/users/${subject.id}/audit`, {
|
||||
headers: { Cookie: nonAdminCookie },
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
|
||||
const missing = await fetch(`${API_BASE}/api/users/99999999/audit`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
@@ -437,6 +437,34 @@
|
||||
"orders": "{{count}} طلب",
|
||||
"conversations": "{{count}} محادثة",
|
||||
"messages": "{{count}} رسالة"
|
||||
},
|
||||
"historyTitle": "سجل الصلاحيات",
|
||||
"historyHint": "الأدوار والمجموعات المسندة لهذا المستخدم. صفِّ بحسب من غيّر أو متى.",
|
||||
"historyEmpty": "لم تُسجَّل تغييرات صلاحيات لهذا المستخدم بعد.",
|
||||
"historyEmptyFiltered": "لا توجد تغييرات صلاحيات تطابق الفلاتر الحالية.",
|
||||
"historyAdded_zero": "أُضيف ({{count}}):",
|
||||
"historyAdded_one": "أُضيف ({{count}}):",
|
||||
"historyAdded_two": "أُضيف ({{count}}):",
|
||||
"historyAdded_few": "أُضيف ({{count}}):",
|
||||
"historyAdded_many": "أُضيف ({{count}}):",
|
||||
"historyAdded_other": "أُضيف ({{count}}):",
|
||||
"historyRemoved_zero": "أُزيل ({{count}}):",
|
||||
"historyRemoved_one": "أُزيل ({{count}}):",
|
||||
"historyRemoved_two": "أُزيل ({{count}}):",
|
||||
"historyRemoved_few": "أُزيل ({{count}}):",
|
||||
"historyRemoved_many": "أُزيل ({{count}}):",
|
||||
"historyRemoved_other": "أُزيل ({{count}}):",
|
||||
"historyTotal_zero": "{{count}} عنصر بعد التغيير",
|
||||
"historyTotal_one": "{{count}} عنصر بعد التغيير",
|
||||
"historyTotal_two": "{{count}} عنصران بعد التغيير",
|
||||
"historyTotal_few": "{{count}} عناصر بعد التغيير",
|
||||
"historyTotal_many": "{{count}} عنصراً بعد التغيير",
|
||||
"historyTotal_other": "{{count}} عنصر بعد التغيير",
|
||||
"history": {
|
||||
"kind": {
|
||||
"roles": "الأدوار",
|
||||
"groups": "المجموعات"
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
@@ -444,7 +472,29 @@
|
||||
"groups": "{{count}} مجموعة",
|
||||
"restrictions": "{{count}} قيد",
|
||||
"opens": "{{count}} فتحة"
|
||||
}
|
||||
},
|
||||
"historyTitle": "سجل الصلاحيات",
|
||||
"historyHint": "الصلاحيات المطلوبة لاستخدام هذا التطبيق. صفِّ بحسب من غيّر أو متى.",
|
||||
"historyEmpty": "لم تُسجَّل تغييرات صلاحيات لهذا التطبيق بعد.",
|
||||
"historyEmptyFiltered": "لا توجد تغييرات صلاحيات تطابق الفلاتر الحالية.",
|
||||
"historyAdded_zero": "أُضيف ({{count}}):",
|
||||
"historyAdded_one": "أُضيف ({{count}}):",
|
||||
"historyAdded_two": "أُضيف ({{count}}):",
|
||||
"historyAdded_few": "أُضيف ({{count}}):",
|
||||
"historyAdded_many": "أُضيف ({{count}}):",
|
||||
"historyAdded_other": "أُضيف ({{count}}):",
|
||||
"historyRemoved_zero": "أُزيل ({{count}}):",
|
||||
"historyRemoved_one": "أُزيل ({{count}}):",
|
||||
"historyRemoved_two": "أُزيل ({{count}}):",
|
||||
"historyRemoved_few": "أُزيل ({{count}}):",
|
||||
"historyRemoved_many": "أُزيل ({{count}}):",
|
||||
"historyRemoved_other": "أُزيل ({{count}}):",
|
||||
"historyTotal_zero": "{{count}} صلاحية بعد التغيير",
|
||||
"historyTotal_one": "{{count}} صلاحية بعد التغيير",
|
||||
"historyTotal_two": "{{count}} صلاحيتان بعد التغيير",
|
||||
"historyTotal_few": "{{count}} صلاحيات بعد التغيير",
|
||||
"historyTotal_many": "{{count}} صلاحية بعد التغيير",
|
||||
"historyTotal_other": "{{count}} صلاحية بعد التغيير"
|
||||
},
|
||||
"services": {
|
||||
"counts": {
|
||||
@@ -562,7 +612,37 @@
|
||||
"info": "المعلومات",
|
||||
"apps": "التطبيقات",
|
||||
"users": "المستخدمون",
|
||||
"roles": "الأدوار"
|
||||
"roles": "الأدوار",
|
||||
"history": "السجل"
|
||||
},
|
||||
"historyTitle": "سجل الصلاحيات",
|
||||
"historyHint": "الأعضاء والأدوار والتطبيقات المرتبطة بهذه المجموعة. صفِّ بحسب من غيّر أو متى.",
|
||||
"historyEmpty": "لم تُسجَّل تغييرات صلاحيات لهذه المجموعة بعد.",
|
||||
"historyEmptyFiltered": "لا توجد تغييرات صلاحيات تطابق الفلاتر الحالية.",
|
||||
"historyAdded_zero": "أُضيف ({{count}}):",
|
||||
"historyAdded_one": "أُضيف ({{count}}):",
|
||||
"historyAdded_two": "أُضيف ({{count}}):",
|
||||
"historyAdded_few": "أُضيف ({{count}}):",
|
||||
"historyAdded_many": "أُضيف ({{count}}):",
|
||||
"historyAdded_other": "أُضيف ({{count}}):",
|
||||
"historyRemoved_zero": "أُزيل ({{count}}):",
|
||||
"historyRemoved_one": "أُزيل ({{count}}):",
|
||||
"historyRemoved_two": "أُزيل ({{count}}):",
|
||||
"historyRemoved_few": "أُزيل ({{count}}):",
|
||||
"historyRemoved_many": "أُزيل ({{count}}):",
|
||||
"historyRemoved_other": "أُزيل ({{count}}):",
|
||||
"historyTotal_zero": "{{count}} عنصر بعد التغيير",
|
||||
"historyTotal_one": "{{count}} عنصر بعد التغيير",
|
||||
"historyTotal_two": "{{count}} عنصران بعد التغيير",
|
||||
"historyTotal_few": "{{count}} عناصر بعد التغيير",
|
||||
"historyTotal_many": "{{count}} عنصراً بعد التغيير",
|
||||
"historyTotal_other": "{{count}} عنصر بعد التغيير",
|
||||
"history": {
|
||||
"kind": {
|
||||
"users": "المستخدمون",
|
||||
"roles": "الأدوار",
|
||||
"apps": "التطبيقات"
|
||||
}
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
|
||||
@@ -434,6 +434,22 @@
|
||||
"orders": "{{count}} orders",
|
||||
"conversations": "{{count}} convos",
|
||||
"messages": "{{count}} messages"
|
||||
},
|
||||
"historyTitle": "Permission history",
|
||||
"historyHint": "Roles and groups assigned to this user. Filter by who changed it or when.",
|
||||
"historyEmpty": "No permission changes have been recorded for this user yet.",
|
||||
"historyEmptyFiltered": "No permission changes match the current filters.",
|
||||
"historyAdded_one": "Added ({{count}}):",
|
||||
"historyAdded_other": "Added ({{count}}):",
|
||||
"historyRemoved_one": "Removed ({{count}}):",
|
||||
"historyRemoved_other": "Removed ({{count}}):",
|
||||
"historyTotal_one": "{{count}} entry after change",
|
||||
"historyTotal_other": "{{count}} entries after change",
|
||||
"history": {
|
||||
"kind": {
|
||||
"roles": "Roles",
|
||||
"groups": "Groups"
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
@@ -441,7 +457,17 @@
|
||||
"groups": "{{count}} groups",
|
||||
"restrictions": "{{count}} restrictions",
|
||||
"opens": "{{count}} opens"
|
||||
}
|
||||
},
|
||||
"historyTitle": "Permission history",
|
||||
"historyHint": "Permissions required to use this app. Filter by who changed it or when.",
|
||||
"historyEmpty": "No permission changes have been recorded for this app yet.",
|
||||
"historyEmptyFiltered": "No permission changes match the current filters.",
|
||||
"historyAdded_one": "Added ({{count}}):",
|
||||
"historyAdded_other": "Added ({{count}}):",
|
||||
"historyRemoved_one": "Removed ({{count}}):",
|
||||
"historyRemoved_other": "Removed ({{count}}):",
|
||||
"historyTotal_one": "{{count}} permission after change",
|
||||
"historyTotal_other": "{{count}} permissions after change"
|
||||
},
|
||||
"services": {
|
||||
"counts": {
|
||||
@@ -547,7 +573,25 @@
|
||||
"info": "Info",
|
||||
"apps": "Apps",
|
||||
"users": "Users",
|
||||
"roles": "Roles"
|
||||
"roles": "Roles",
|
||||
"history": "History"
|
||||
},
|
||||
"historyTitle": "Permission history",
|
||||
"historyHint": "Members, roles and apps attached to this group. Filter by who changed it or when.",
|
||||
"historyEmpty": "No permission changes have been recorded for this group yet.",
|
||||
"historyEmptyFiltered": "No permission changes match the current filters.",
|
||||
"historyAdded_one": "Added ({{count}}):",
|
||||
"historyAdded_other": "Added ({{count}}):",
|
||||
"historyRemoved_one": "Removed ({{count}}):",
|
||||
"historyRemoved_other": "Removed ({{count}}):",
|
||||
"historyTotal_one": "{{count}} entry after change",
|
||||
"historyTotal_other": "{{count}} entries after change",
|
||||
"history": {
|
||||
"kind": {
|
||||
"users": "Users",
|
||||
"roles": "Roles",
|
||||
"apps": "Apps"
|
||||
}
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
|
||||
@@ -56,6 +56,15 @@ import {
|
||||
usePreviewRolePermissionsImpact,
|
||||
useGetRolePermissionAudit,
|
||||
getGetRolePermissionAuditQueryKey,
|
||||
useGetUserPermissionAudit,
|
||||
getGetUserPermissionAuditQueryKey,
|
||||
getUserPermissionAudit,
|
||||
useGetGroupPermissionAudit,
|
||||
getGetGroupPermissionAuditQueryKey,
|
||||
getGroupPermissionAudit,
|
||||
useGetAppPermissionAudit,
|
||||
getGetAppPermissionAuditQueryKey,
|
||||
getAppPermissionAudit,
|
||||
useListAuditLogs,
|
||||
getListAuditLogsQueryKey,
|
||||
getExportAuditLogsCsvUrl,
|
||||
@@ -82,6 +91,11 @@ import type {
|
||||
RolePermissionsImpact,
|
||||
GetRolePermissionAuditParams,
|
||||
RolePermissionAuditEntry,
|
||||
GetUserPermissionAuditParams,
|
||||
GetGroupPermissionAuditParams,
|
||||
GetAppPermissionAuditParams,
|
||||
PermissionAuditEntry,
|
||||
PermissionAuditEntryChangeKind,
|
||||
Permission,
|
||||
} from "@workspace/api-client-react";
|
||||
import { ListUsersStatus } from "@workspace/api-client-react";
|
||||
@@ -388,6 +402,8 @@ export default function AdminPage() {
|
||||
const { data: apps } = useQuery({ queryKey: ADMIN_APPS_KEY, queryFn: fetchAdminApps, enabled: isAdmin });
|
||||
const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } });
|
||||
const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } });
|
||||
const { data: rolesList } = useListRoles({ query: { queryKey: getListRolesQueryKey(), enabled: isAdmin } });
|
||||
const { data: permissionsList } = useListPermissions({ query: { queryKey: getListPermissionsQueryKey(), enabled: isAdmin } });
|
||||
const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
|
||||
const statsRangeStorageKey = user?.id ? `admin.statsRange.${user.id}` : null;
|
||||
const [statsRange, setStatsRange] = useState<"7d" | "30d" | "90d" | "custom">("7d");
|
||||
@@ -813,6 +829,20 @@ export default function AdminPage() {
|
||||
{editingApp.id !== undefined && (
|
||||
<AppPermissionsEditor appId={editingApp.id} />
|
||||
)}
|
||||
{editingApp.id !== undefined && (
|
||||
<PermissionAuditHistory
|
||||
targetKind="app"
|
||||
targetId={editingApp.id}
|
||||
enabled={true}
|
||||
language={lang}
|
||||
scopeI18nKey="admin.apps.history"
|
||||
changeKindLabel={null}
|
||||
labelResolver={(_ck, id) => {
|
||||
const p = (permissionsList ?? []).find((x) => x.id === id);
|
||||
return p ? p.name : `#${id}`;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setEditingApp(null)}>{t("common.cancel")}</Button>
|
||||
<Button className="flex-1" onClick={handleSaveApp}>{t("common.save")}</Button>
|
||||
@@ -2836,9 +2866,12 @@ function UserGroupsEditor({
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const updateUser = useUpdateUser();
|
||||
const { data: roles } = useListRoles({
|
||||
query: { queryKey: getListRolesQueryKey() },
|
||||
});
|
||||
|
||||
const original = useMemo(
|
||||
() => ({
|
||||
@@ -3028,6 +3061,25 @@ function UserGroupsEditor({
|
||||
{visibleGroups.length === 0 && <p className="text-sm text-muted-foreground p-2">{t("admin.groups.empty")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<PermissionAuditHistory
|
||||
targetKind="user"
|
||||
targetId={user.id}
|
||||
enabled={true}
|
||||
language={i18n.language}
|
||||
scopeI18nKey="admin.users.history"
|
||||
changeKindLabel={(ck) =>
|
||||
ck === "user.roles"
|
||||
? t("admin.users.history.kind.roles")
|
||||
: t("admin.users.history.kind.groups")
|
||||
}
|
||||
labelResolver={(ck, id) => {
|
||||
if (ck === "user.groups") {
|
||||
return groupNameById.get(id) ?? `#${id}`;
|
||||
}
|
||||
const r = (roles ?? []).find((x) => x.id === id);
|
||||
return r ? r.name : `#${id}`;
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
@@ -3400,7 +3452,9 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
|
||||
const [appIds, setAppIds] = useState<Set<number>>(new Set());
|
||||
const [userIds, setUserIds] = useState<Set<number>>(new Set());
|
||||
const [roleIds, setRoleIds] = useState<Set<number>>(new Set());
|
||||
const [tab, setTab] = useState<"info" | "apps" | "users" | "roles">("info");
|
||||
const [tab, setTab] = useState<
|
||||
"info" | "apps" | "users" | "roles" | "history"
|
||||
>("info");
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
|
||||
const visibleUsers = (users ?? []).filter((u) => {
|
||||
@@ -3458,7 +3512,7 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-slate-200/70">
|
||||
{(["info", "apps", "users", "roles"] as const).map((k) => (
|
||||
{(["info", "apps", "users", "roles", "history"] as const).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setTab(k)}
|
||||
@@ -3583,6 +3637,35 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "history" && (
|
||||
<PermissionAuditHistory
|
||||
targetKind="group"
|
||||
targetId={group.id}
|
||||
enabled={true}
|
||||
language={lang}
|
||||
scopeI18nKey="admin.groups.history"
|
||||
changeKindLabel={(ck) =>
|
||||
ck === "group.users"
|
||||
? t("admin.groups.history.kind.users")
|
||||
: ck === "group.roles"
|
||||
? t("admin.groups.history.kind.roles")
|
||||
: t("admin.groups.history.kind.apps")
|
||||
}
|
||||
labelResolver={(ck, id) => {
|
||||
if (ck === "group.users") {
|
||||
const u = (users ?? []).find((x) => x.id === id);
|
||||
return u ? u.username : `#${id}`;
|
||||
}
|
||||
if (ck === "group.roles") {
|
||||
const r = (roles ?? []).find((x) => x.id === id);
|
||||
return r ? r.name : `#${id}`;
|
||||
}
|
||||
const a = (apps ?? []).find((x) => x.id === id);
|
||||
return a ? (lang === "ar" ? a.nameAr : a.nameEn) : `#${id}`;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2 border-t border-slate-200/70">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
@@ -4035,6 +4118,458 @@ function RolePermissionHistory({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PermissionAuditHistory
|
||||
//
|
||||
// Generalized version of RolePermissionHistory that powers the "History"
|
||||
// section embedded into the User, Group and App admin dialogs. The shape of
|
||||
// the audit endpoint is identical across the three entities (see
|
||||
// permission_audit table); the only thing that varies is which generated
|
||||
// hook to call and how to render id labels for each changeKind. We always
|
||||
// call all three React Query hooks (rules-of-hooks) but only the one
|
||||
// matching `targetKind` is enabled.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PermissionAuditScope = "user" | "group" | "app";
|
||||
type PermAuditLabelResolver = (
|
||||
changeKind: PermissionAuditEntryChangeKind,
|
||||
id: number,
|
||||
) => string;
|
||||
|
||||
function PermissionAuditHistory({
|
||||
targetKind,
|
||||
targetId,
|
||||
enabled,
|
||||
language,
|
||||
scopeI18nKey,
|
||||
labelResolver,
|
||||
changeKindLabel,
|
||||
}: {
|
||||
targetKind: PermissionAuditScope;
|
||||
targetId: number;
|
||||
enabled: boolean;
|
||||
language: string;
|
||||
// Translation prefix, e.g. "admin.users.history" / "admin.groups.history"
|
||||
// / "admin.apps.history". Mirrors `admin.roles.history*` keys so we get
|
||||
// bilingual support for free without forking copy per call site.
|
||||
scopeI18nKey: "admin.users.history" | "admin.groups.history" | "admin.apps.history";
|
||||
labelResolver: PermAuditLabelResolver;
|
||||
// Group history mixes user/role/app changes — the caller can render a
|
||||
// small badge per entry indicating which set was changed. Pass `null`
|
||||
// for users/apps where the change kind is fixed.
|
||||
changeKindLabel?: ((changeKind: PermissionAuditEntryChangeKind) => string) | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const testIdRoot = `${targetKind}-history`;
|
||||
|
||||
const [actorUserId, setActorUserId] = useState<number | "">("");
|
||||
const [fromInput, setFromInput] = useState<string>("");
|
||||
const [toInput, setToInput] = useState<string>("");
|
||||
const [appliedFrom, setAppliedFrom] = useState<string>("");
|
||||
const [appliedTo, setAppliedTo] = useState<string>("");
|
||||
|
||||
const [extraEntries, setExtraEntries] = useState<PermissionAuditEntry[]>([]);
|
||||
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
|
||||
const [hasExtras, setHasExtras] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActorUserId("");
|
||||
setFromInput("");
|
||||
setToInput("");
|
||||
setAppliedFrom("");
|
||||
setAppliedTo("");
|
||||
setExtraEntries([]);
|
||||
setExtraNextOffset(null);
|
||||
setHasExtras(false);
|
||||
setLoadMoreError(null);
|
||||
setIsLoadingMore(false);
|
||||
}, [targetKind, targetId]);
|
||||
|
||||
const fromValid = !appliedFrom || ROLE_HISTORY_DATE_RE.test(appliedFrom);
|
||||
const toValid = !appliedTo || ROLE_HISTORY_DATE_RE.test(appliedTo);
|
||||
const orderValid = !appliedFrom || !appliedTo || appliedFrom <= appliedTo;
|
||||
const filtersValid = fromValid && toValid && orderValid;
|
||||
|
||||
const { data: users } = useListUsers(undefined, {
|
||||
query: { queryKey: getListUsersQueryKey(), enabled },
|
||||
});
|
||||
const sortedUsers = useMemo(() => {
|
||||
const list = (users ?? []).slice();
|
||||
list.sort((a, b) => {
|
||||
const aLabel =
|
||||
(language === "ar"
|
||||
? a.displayNameAr ?? a.displayNameEn
|
||||
: a.displayNameEn ?? a.displayNameAr) ?? a.username;
|
||||
const bLabel =
|
||||
(language === "ar"
|
||||
? b.displayNameAr ?? b.displayNameEn
|
||||
: b.displayNameEn ?? b.displayNameAr) ?? b.username;
|
||||
return aLabel.localeCompare(bLabel);
|
||||
});
|
||||
return list;
|
||||
}, [users, language]);
|
||||
|
||||
const firstPageParams: GetUserPermissionAuditParams = {
|
||||
limit: ROLE_HISTORY_PAGE_SIZE,
|
||||
offset: 0,
|
||||
...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}),
|
||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||
...(appliedTo ? { to: appliedTo } : {}),
|
||||
};
|
||||
|
||||
// We must call all three hooks to satisfy rules-of-hooks; the
|
||||
// `enabled` flag ensures only the one matching `targetKind` actually
|
||||
// fires a request.
|
||||
const userQuery = useGetUserPermissionAudit(targetId, firstPageParams, {
|
||||
query: {
|
||||
queryKey: getGetUserPermissionAuditQueryKey(targetId, firstPageParams),
|
||||
enabled: enabled && filtersValid && targetKind === "user",
|
||||
},
|
||||
});
|
||||
const groupQuery = useGetGroupPermissionAudit(
|
||||
targetId,
|
||||
firstPageParams as GetGroupPermissionAuditParams,
|
||||
{
|
||||
query: {
|
||||
queryKey: getGetGroupPermissionAuditQueryKey(
|
||||
targetId,
|
||||
firstPageParams as GetGroupPermissionAuditParams,
|
||||
),
|
||||
enabled: enabled && filtersValid && targetKind === "group",
|
||||
},
|
||||
},
|
||||
);
|
||||
const appQuery = useGetAppPermissionAudit(
|
||||
targetId,
|
||||
firstPageParams as GetAppPermissionAuditParams,
|
||||
{
|
||||
query: {
|
||||
queryKey: getGetAppPermissionAuditQueryKey(
|
||||
targetId,
|
||||
firstPageParams as GetAppPermissionAuditParams,
|
||||
),
|
||||
enabled: enabled && filtersValid && targetKind === "app",
|
||||
},
|
||||
},
|
||||
);
|
||||
const activeQuery =
|
||||
targetKind === "user" ? userQuery : targetKind === "group" ? groupQuery : appQuery;
|
||||
const { data, isLoading, isFetching } = activeQuery;
|
||||
|
||||
const filtersKey = JSON.stringify(firstPageParams);
|
||||
useEffect(() => {
|
||||
setExtraEntries([]);
|
||||
setExtraNextOffset(null);
|
||||
setHasExtras(false);
|
||||
setLoadMoreError(null);
|
||||
setIsLoadingMore(false);
|
||||
}, [filtersKey]);
|
||||
|
||||
const firstPageSignature = data
|
||||
? `${data.totalCount}|${data.entries[0]?.id ?? ""}`
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (firstPageSignature == null) return;
|
||||
setExtraEntries([]);
|
||||
setExtraNextOffset(null);
|
||||
setHasExtras(false);
|
||||
setLoadMoreError(null);
|
||||
setIsLoadingMore(false);
|
||||
}, [firstPageSignature]);
|
||||
|
||||
const firstPageEntries = data?.entries ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
const entries = hasExtras
|
||||
? [...firstPageEntries, ...extraEntries]
|
||||
: firstPageEntries;
|
||||
const showingCount = entries.length;
|
||||
const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null;
|
||||
const hasMore = nextOffset !== null;
|
||||
|
||||
const applyFilters = () => {
|
||||
setAppliedFrom(fromInput.trim());
|
||||
setAppliedTo(toInput.trim());
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setActorUserId("");
|
||||
setFromInput("");
|
||||
setToInput("");
|
||||
setAppliedFrom("");
|
||||
setAppliedTo("");
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (nextOffset === null || isLoadingMore) return;
|
||||
setIsLoadingMore(true);
|
||||
setLoadMoreError(null);
|
||||
try {
|
||||
const params = { ...firstPageParams, offset: nextOffset };
|
||||
const next =
|
||||
targetKind === "user"
|
||||
? await getUserPermissionAudit(targetId, params)
|
||||
: targetKind === "group"
|
||||
? await getGroupPermissionAudit(targetId, params)
|
||||
: await getAppPermissionAudit(targetId, params);
|
||||
setExtraEntries((prev) => [...prev, ...next.entries]);
|
||||
setExtraNextOffset(next.nextOffset);
|
||||
setHasExtras(true);
|
||||
} catch (e) {
|
||||
setLoadMoreError(
|
||||
(e as { message?: string }).message ?? t("common.error"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtersActive =
|
||||
actorUserId !== "" || appliedFrom !== "" || appliedTo !== "";
|
||||
|
||||
const filterErrors: string[] = [];
|
||||
if (!fromValid || !toValid)
|
||||
filterErrors.push(t("admin.roles.historyErrors.invalidDate"));
|
||||
else if (!orderValid)
|
||||
filterErrors.push(t("admin.roles.historyErrors.invertedDates"));
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-2" data-testid={`${testIdRoot}-section`}>
|
||||
<Label>{t(`${scopeI18nKey}Title`)}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t(`${scopeI18nKey}Hint`)}</p>
|
||||
<div
|
||||
className="grid gap-2 sm:grid-cols-2 md:grid-cols-4"
|
||||
data-testid={`${testIdRoot}-filters`}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("admin.roles.historyFilters.actor")}</Label>
|
||||
<select
|
||||
value={actorUserId === "" ? "" : String(actorUserId)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setActorUserId(v === "" ? "" : Number(v));
|
||||
}}
|
||||
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
|
||||
data-testid={`${testIdRoot}-actor-filter`}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.roles.historyFilters.allActors")}
|
||||
</option>
|
||||
{sortedUsers.map((u) => {
|
||||
const label =
|
||||
(language === "ar"
|
||||
? u.displayNameAr ?? u.displayNameEn
|
||||
: u.displayNameEn ?? u.displayNameAr) ?? u.username;
|
||||
return (
|
||||
<option key={u.id} value={u.id}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("admin.roles.historyFilters.from")}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={fromInput}
|
||||
onChange={(e) => setFromInput(e.target.value)}
|
||||
className="bg-white/70 border-slate-200"
|
||||
data-testid={`${testIdRoot}-from-input`}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t("admin.roles.historyFilters.to")}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={toInput}
|
||||
onChange={(e) => setToInput(e.target.value)}
|
||||
className="bg-white/70 border-slate-200"
|
||||
data-testid={`${testIdRoot}-to-input`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={applyFilters}
|
||||
className="flex-1"
|
||||
data-testid={`${testIdRoot}-apply-filters`}
|
||||
>
|
||||
{t("admin.roles.historyFilters.apply")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={resetFilters}
|
||||
disabled={!filtersActive && !fromInput && !toInput}
|
||||
data-testid={`${testIdRoot}-reset-filters`}
|
||||
>
|
||||
{t("admin.roles.historyFilters.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{filterErrors.map((msg, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="text-xs text-destructive"
|
||||
data-testid={`${testIdRoot}-filter-error`}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
))}
|
||||
<div
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid={`${testIdRoot}-showing`}
|
||||
>
|
||||
{filtersValid
|
||||
? t("admin.roles.historyShowing", {
|
||||
shown: showingCount,
|
||||
total: totalCount,
|
||||
})
|
||||
: t("admin.roles.historyErrors.fixFiltersFirst")}
|
||||
</div>
|
||||
<div
|
||||
className="border border-slate-200 rounded-xl bg-white/60 max-h-56 overflow-y-auto divide-y divide-slate-100"
|
||||
data-testid={`${testIdRoot}-list`}
|
||||
>
|
||||
{!filtersValid ? (
|
||||
<p
|
||||
className="text-xs text-muted-foreground p-3 text-center"
|
||||
data-testid={`${testIdRoot}-fix-filters`}
|
||||
>
|
||||
{t("admin.roles.historyErrors.fixFiltersFirst")}
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<div className="flex justify-center py-6 text-muted-foreground">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p
|
||||
className="text-xs text-muted-foreground p-3 text-center"
|
||||
data-testid={`${testIdRoot}-empty`}
|
||||
>
|
||||
{filtersActive
|
||||
? t(`${scopeI18nKey}EmptyFiltered`)
|
||||
: t(`${scopeI18nKey}Empty`)}
|
||||
</p>
|
||||
) : (
|
||||
entries.map((entry) => {
|
||||
const actor = entry.actor;
|
||||
const ar = actor?.displayNameAr ?? null;
|
||||
const en = actor?.displayNameEn ?? null;
|
||||
const preferred =
|
||||
language === "ar" ? ar ?? en : en ?? ar;
|
||||
const actorName =
|
||||
actor && (preferred?.trim() ? preferred : actor.username);
|
||||
const ck = entry.changeKind;
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="p-2.5 text-xs space-y-1"
|
||||
data-testid={`${testIdRoot}-entry-${entry.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-foreground">
|
||||
<span
|
||||
className="font-medium"
|
||||
data-testid={`${testIdRoot}-actor-${entry.id}`}
|
||||
>
|
||||
{actorName ?? t("admin.roles.historyActorUnknown")}
|
||||
</span>
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
data-testid={`${testIdRoot}-time-${entry.id}`}
|
||||
>
|
||||
{formatAuditTimestamp(entry.createdAt, language)}
|
||||
</span>
|
||||
</div>
|
||||
{changeKindLabel && (
|
||||
<div
|
||||
className="text-[10px] uppercase tracking-wider text-muted-foreground"
|
||||
data-testid={`${testIdRoot}-kind-${entry.id}`}
|
||||
>
|
||||
{changeKindLabel(ck)}
|
||||
</div>
|
||||
)}
|
||||
{entry.addedIds.length > 0 && (
|
||||
<div
|
||||
className="text-emerald-700"
|
||||
data-testid={`${testIdRoot}-added-${entry.id}`}
|
||||
>
|
||||
<span className="font-semibold">
|
||||
{t(`${scopeI18nKey}Added`, {
|
||||
count: entry.addedIds.length,
|
||||
})}
|
||||
</span>{" "}
|
||||
<span className="font-mono break-all">
|
||||
{entry.addedIds
|
||||
.map((id) => labelResolver(ck, id))
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.removedIds.length > 0 && (
|
||||
<div
|
||||
className="text-rose-700"
|
||||
data-testid={`${testIdRoot}-removed-${entry.id}`}
|
||||
>
|
||||
<span className="font-semibold">
|
||||
{t(`${scopeI18nKey}Removed`, {
|
||||
count: entry.removedIds.length,
|
||||
})}
|
||||
</span>{" "}
|
||||
<span className="font-mono break-all">
|
||||
{entry.removedIds
|
||||
.map((id) => labelResolver(ck, id))
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="text-muted-foreground"
|
||||
data-testid={`${testIdRoot}-total-${entry.id}`}
|
||||
>
|
||||
{t(`${scopeI18nKey}Total`, {
|
||||
count: entry.newIds.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{filtersValid && hasMore && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isLoadingMore || isFetching}
|
||||
onClick={loadMore}
|
||||
data-testid={`${testIdRoot}-load-more`}
|
||||
>
|
||||
{isLoadingMore || isFetching ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
t("admin.roles.historyLoadMore")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{loadMoreError && (
|
||||
<p
|
||||
className="text-xs text-destructive text-center"
|
||||
data-testid={`${testIdRoot}-load-more-error`}
|
||||
>
|
||||
{loadMoreError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesPanel() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -910,6 +910,49 @@ export interface RolePermissionAuditList {
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export type PermissionAuditEntryTargetKind =
|
||||
(typeof PermissionAuditEntryTargetKind)[keyof typeof PermissionAuditEntryTargetKind];
|
||||
|
||||
export const PermissionAuditEntryTargetKind = {
|
||||
user: "user",
|
||||
group: "group",
|
||||
app: "app",
|
||||
} as const;
|
||||
|
||||
export type PermissionAuditEntryChangeKind =
|
||||
(typeof PermissionAuditEntryChangeKind)[keyof typeof PermissionAuditEntryChangeKind];
|
||||
|
||||
export const PermissionAuditEntryChangeKind = {
|
||||
userroles: "user.roles",
|
||||
usergroups: "user.groups",
|
||||
groupusers: "group.users",
|
||||
grouproles: "group.roles",
|
||||
groupapps: "group.apps",
|
||||
apppermissions: "app.permissions",
|
||||
} as const;
|
||||
|
||||
export interface PermissionAuditEntry {
|
||||
id: number;
|
||||
targetKind: PermissionAuditEntryTargetKind;
|
||||
targetId: number;
|
||||
changeKind: PermissionAuditEntryChangeKind;
|
||||
previousIds: number[];
|
||||
newIds: number[];
|
||||
addedIds: number[];
|
||||
removedIds: number[];
|
||||
createdAt: string;
|
||||
actor: AuditLogActor | null;
|
||||
}
|
||||
|
||||
export interface PermissionAuditList {
|
||||
entries: PermissionAuditEntry[];
|
||||
totalCount: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
/** @nullable */
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
@@ -1028,6 +1071,60 @@ export type DeleteGroupParams = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type GetUserPermissionAuditParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
actorUserId?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type GetGroupPermissionAuditParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
actorUserId?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type GetAppPermissionAuditParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 200
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @minimum 0
|
||||
*/
|
||||
actorUserId?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type GetAdminStatsParams = {
|
||||
/**
|
||||
* Time range for trend stats. Use "custom" with from/to.
|
||||
|
||||
@@ -49,7 +49,10 @@ import type {
|
||||
GetAdminAppOpensByAppParams,
|
||||
GetAdminAppOpensByUserParams,
|
||||
GetAdminStatsParams,
|
||||
GetAppPermissionAuditParams,
|
||||
GetGroupPermissionAuditParams,
|
||||
GetRolePermissionAuditParams,
|
||||
GetUserPermissionAuditParams,
|
||||
Group,
|
||||
GroupDeletionConflict,
|
||||
GroupDetail,
|
||||
@@ -63,6 +66,7 @@ import type {
|
||||
MessageWithSender,
|
||||
Notification,
|
||||
Permission,
|
||||
PermissionAuditList,
|
||||
RegisterBody,
|
||||
ReplaceRolePermissionsBody,
|
||||
RequestUploadUrlBody,
|
||||
@@ -6342,6 +6346,371 @@ export const useDeleteGroup = <
|
||||
return useMutation(getDeleteGroupMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns permission-change audit records for the given user, newest
|
||||
first. Mirrors the role audit endpoint shape and supports the same
|
||||
`limit`/`offset`/`actorUserId`/`from`/`to` filters. Each entry
|
||||
captures the actor (if known), the previous and new id sets, and
|
||||
the change kind (`user.roles` or `user.groups`).
|
||||
|
||||
* @summary List recent permission-change audit entries for a user (admin)
|
||||
*/
|
||||
export const getGetUserPermissionAuditUrl = (
|
||||
id: number,
|
||||
params?: GetUserPermissionAuditParams,
|
||||
) => {
|
||||
const normalizedParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? "null" : value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/users/${id}/audit?${stringifiedParams}`
|
||||
: `/api/users/${id}/audit`;
|
||||
};
|
||||
|
||||
export const getUserPermissionAudit = async (
|
||||
id: number,
|
||||
params?: GetUserPermissionAuditParams,
|
||||
options?: RequestInit,
|
||||
): Promise<PermissionAuditList> => {
|
||||
return customFetch<PermissionAuditList>(
|
||||
getGetUserPermissionAuditUrl(id, params),
|
||||
{
|
||||
...options,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getGetUserPermissionAuditQueryKey = (
|
||||
id: number,
|
||||
params?: GetUserPermissionAuditParams,
|
||||
) => {
|
||||
return [`/api/users/${id}/audit`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetUserPermissionAuditQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getUserPermissionAudit>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
params?: GetUserPermissionAuditParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserPermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetUserPermissionAuditQueryKey(id, params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getUserPermissionAudit>>
|
||||
> = ({ signal }) =>
|
||||
getUserPermissionAudit(id, params, { signal, ...requestOptions });
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserPermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetUserPermissionAuditQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getUserPermissionAudit>>
|
||||
>;
|
||||
export type GetUserPermissionAuditQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary List recent permission-change audit entries for a user (admin)
|
||||
*/
|
||||
|
||||
export function useGetUserPermissionAudit<
|
||||
TData = Awaited<ReturnType<typeof getUserPermissionAudit>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
params?: GetUserPermissionAuditParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserPermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetUserPermissionAuditQueryOptions(
|
||||
id,
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns permission-change audit records for the given group, newest
|
||||
first. Captures changes to the group's user, role and app sets via
|
||||
the discriminator field `changeKind`.
|
||||
|
||||
* @summary List recent permission-change audit entries for a group (admin)
|
||||
*/
|
||||
export const getGetGroupPermissionAuditUrl = (
|
||||
id: number,
|
||||
params?: GetGroupPermissionAuditParams,
|
||||
) => {
|
||||
const normalizedParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? "null" : value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/groups/${id}/audit?${stringifiedParams}`
|
||||
: `/api/groups/${id}/audit`;
|
||||
};
|
||||
|
||||
export const getGroupPermissionAudit = async (
|
||||
id: number,
|
||||
params?: GetGroupPermissionAuditParams,
|
||||
options?: RequestInit,
|
||||
): Promise<PermissionAuditList> => {
|
||||
return customFetch<PermissionAuditList>(
|
||||
getGetGroupPermissionAuditUrl(id, params),
|
||||
{
|
||||
...options,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getGetGroupPermissionAuditQueryKey = (
|
||||
id: number,
|
||||
params?: GetGroupPermissionAuditParams,
|
||||
) => {
|
||||
return [`/api/groups/${id}/audit`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetGroupPermissionAuditQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getGroupPermissionAudit>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
params?: GetGroupPermissionAuditParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getGroupPermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetGroupPermissionAuditQueryKey(id, params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getGroupPermissionAudit>>
|
||||
> = ({ signal }) =>
|
||||
getGroupPermissionAudit(id, params, { signal, ...requestOptions });
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getGroupPermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetGroupPermissionAuditQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getGroupPermissionAudit>>
|
||||
>;
|
||||
export type GetGroupPermissionAuditQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary List recent permission-change audit entries for a group (admin)
|
||||
*/
|
||||
|
||||
export function useGetGroupPermissionAudit<
|
||||
TData = Awaited<ReturnType<typeof getGroupPermissionAudit>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
params?: GetGroupPermissionAuditParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getGroupPermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetGroupPermissionAuditQueryOptions(
|
||||
id,
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns permission-change audit records for the given app, newest
|
||||
first. Captures changes to the set of permissions required to open
|
||||
the app (`changeKind: app.permissions`).
|
||||
|
||||
* @summary List recent permission-change audit entries for an app (admin)
|
||||
*/
|
||||
export const getGetAppPermissionAuditUrl = (
|
||||
id: number,
|
||||
params?: GetAppPermissionAuditParams,
|
||||
) => {
|
||||
const normalizedParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? "null" : value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/apps/${id}/audit?${stringifiedParams}`
|
||||
: `/api/apps/${id}/audit`;
|
||||
};
|
||||
|
||||
export const getAppPermissionAudit = async (
|
||||
id: number,
|
||||
params?: GetAppPermissionAuditParams,
|
||||
options?: RequestInit,
|
||||
): Promise<PermissionAuditList> => {
|
||||
return customFetch<PermissionAuditList>(
|
||||
getGetAppPermissionAuditUrl(id, params),
|
||||
{
|
||||
...options,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getGetAppPermissionAuditQueryKey = (
|
||||
id: number,
|
||||
params?: GetAppPermissionAuditParams,
|
||||
) => {
|
||||
return [`/api/apps/${id}/audit`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetAppPermissionAuditQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getAppPermissionAudit>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
params?: GetAppPermissionAuditParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAppPermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetAppPermissionAuditQueryKey(id, params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getAppPermissionAudit>>
|
||||
> = ({ signal }) =>
|
||||
getAppPermissionAudit(id, params, { signal, ...requestOptions });
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAppPermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetAppPermissionAuditQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getAppPermissionAudit>>
|
||||
>;
|
||||
export type GetAppPermissionAuditQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary List recent permission-change audit entries for an app (admin)
|
||||
*/
|
||||
|
||||
export function useGetAppPermissionAudit<
|
||||
TData = Awaited<ReturnType<typeof getAppPermissionAudit>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
params?: GetAppPermissionAuditParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAppPermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetAppPermissionAuditQueryOptions(
|
||||
id,
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get home screen stats
|
||||
*/
|
||||
|
||||
@@ -1846,6 +1846,212 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GroupDeletionConflict"
|
||||
|
||||
/users/{id}/audit:
|
||||
get:
|
||||
operationId: getUserPermissionAudit
|
||||
tags: [users]
|
||||
summary: List recent permission-change audit entries for a user (admin)
|
||||
description: |
|
||||
Returns permission-change audit records for the given user, newest
|
||||
first. Mirrors the role audit endpoint shape and supports the same
|
||||
`limit`/`offset`/`actorUserId`/`from`/`to` filters. Each entry
|
||||
captures the actor (if known), the previous and new id sets, and
|
||||
the change kind (`user.roles` or `user.groups`).
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 10
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
- in: query
|
||||
name: actorUserId
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
- in: query
|
||||
name: from
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: to
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
responses:
|
||||
"200":
|
||||
description: A page of permission-change audit entries for the user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PermissionAuditList"
|
||||
"400":
|
||||
description: Invalid filter parameters
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"404":
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/groups/{id}/audit:
|
||||
get:
|
||||
operationId: getGroupPermissionAudit
|
||||
tags: [users]
|
||||
summary: List recent permission-change audit entries for a group (admin)
|
||||
description: |
|
||||
Returns permission-change audit records for the given group, newest
|
||||
first. Captures changes to the group's user, role and app sets via
|
||||
the discriminator field `changeKind`.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 10
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
- in: query
|
||||
name: actorUserId
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
- in: query
|
||||
name: from
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: to
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
responses:
|
||||
"200":
|
||||
description: A page of permission-change audit entries for the group
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PermissionAuditList"
|
||||
"400":
|
||||
description: Invalid filter parameters
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"404":
|
||||
description: Group not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/apps/{id}/audit:
|
||||
get:
|
||||
operationId: getAppPermissionAudit
|
||||
tags: [apps]
|
||||
summary: List recent permission-change audit entries for an app (admin)
|
||||
description: |
|
||||
Returns permission-change audit records for the given app, newest
|
||||
first. Captures changes to the set of permissions required to open
|
||||
the app (`changeKind: app.permissions`).
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 10
|
||||
- in: query
|
||||
name: offset
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
- in: query
|
||||
name: actorUserId
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
- in: query
|
||||
name: from
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
- in: query
|
||||
name: to
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date
|
||||
responses:
|
||||
"200":
|
||||
description: A page of permission-change audit entries for the app
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PermissionAuditList"
|
||||
"400":
|
||||
description: Invalid filter parameters
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"404":
|
||||
description: App not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
# Stats
|
||||
/stats/home:
|
||||
get:
|
||||
@@ -3864,6 +4070,82 @@ components:
|
||||
- createdAt
|
||||
- actor
|
||||
|
||||
PermissionAuditList:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PermissionAuditEntry"
|
||||
totalCount:
|
||||
type: integer
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
nextOffset:
|
||||
type: ["integer", "null"]
|
||||
required:
|
||||
- entries
|
||||
- totalCount
|
||||
- limit
|
||||
- offset
|
||||
- nextOffset
|
||||
|
||||
PermissionAuditEntry:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
targetKind:
|
||||
type: string
|
||||
enum: [user, group, app]
|
||||
targetId:
|
||||
type: integer
|
||||
changeKind:
|
||||
type: string
|
||||
enum:
|
||||
- user.roles
|
||||
- user.groups
|
||||
- group.users
|
||||
- group.roles
|
||||
- group.apps
|
||||
- app.permissions
|
||||
previousIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
newIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
addedIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
removedIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
actor:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/AuditLogActor"
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- targetKind
|
||||
- targetId
|
||||
- changeKind
|
||||
- previousIds
|
||||
- newIds
|
||||
- addedIds
|
||||
- removedIds
|
||||
- createdAt
|
||||
- actor
|
||||
|
||||
AuditLogEntry:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -2227,6 +2227,230 @@ export const DeleteGroupQueryParams = zod.object({
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns permission-change audit records for the given user, newest
|
||||
first. Mirrors the role audit endpoint shape and supports the same
|
||||
`limit`/`offset`/`actorUserId`/`from`/`to` filters. Each entry
|
||||
captures the actor (if known), the previous and new id sets, and
|
||||
the change kind (`user.roles` or `user.groups`).
|
||||
|
||||
* @summary List recent permission-change audit entries for a user (admin)
|
||||
*/
|
||||
export const GetUserPermissionAuditParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getUserPermissionAuditQueryLimitDefault = 10;
|
||||
export const getUserPermissionAuditQueryLimitMax = 200;
|
||||
|
||||
export const getUserPermissionAuditQueryOffsetDefault = 0;
|
||||
export const getUserPermissionAuditQueryOffsetMin = 0;
|
||||
|
||||
export const getUserPermissionAuditQueryActorUserIdMin = 0;
|
||||
|
||||
export const GetUserPermissionAuditQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getUserPermissionAuditQueryLimitMax)
|
||||
.default(getUserPermissionAuditQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getUserPermissionAuditQueryOffsetMin)
|
||||
.default(getUserPermissionAuditQueryOffsetDefault),
|
||||
actorUserId: zod.coerce
|
||||
.number()
|
||||
.min(getUserPermissionAuditQueryActorUserIdMin)
|
||||
.optional(),
|
||||
from: zod.date().optional(),
|
||||
to: zod.date().optional(),
|
||||
});
|
||||
|
||||
export const GetUserPermissionAuditResponse = zod.object({
|
||||
entries: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
targetKind: zod.enum(["user", "group", "app"]),
|
||||
targetId: zod.number(),
|
||||
changeKind: zod.enum([
|
||||
"user.roles",
|
||||
"user.groups",
|
||||
"group.users",
|
||||
"group.roles",
|
||||
"group.apps",
|
||||
"app.permissions",
|
||||
]),
|
||||
previousIds: zod.array(zod.number()),
|
||||
newIds: zod.array(zod.number()),
|
||||
addedIds: zod.array(zod.number()),
|
||||
removedIds: zod.array(zod.number()),
|
||||
createdAt: zod.coerce.date(),
|
||||
actor: zod.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns permission-change audit records for the given group, newest
|
||||
first. Captures changes to the group's user, role and app sets via
|
||||
the discriminator field `changeKind`.
|
||||
|
||||
* @summary List recent permission-change audit entries for a group (admin)
|
||||
*/
|
||||
export const GetGroupPermissionAuditParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getGroupPermissionAuditQueryLimitDefault = 10;
|
||||
export const getGroupPermissionAuditQueryLimitMax = 200;
|
||||
|
||||
export const getGroupPermissionAuditQueryOffsetDefault = 0;
|
||||
export const getGroupPermissionAuditQueryOffsetMin = 0;
|
||||
|
||||
export const getGroupPermissionAuditQueryActorUserIdMin = 0;
|
||||
|
||||
export const GetGroupPermissionAuditQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getGroupPermissionAuditQueryLimitMax)
|
||||
.default(getGroupPermissionAuditQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getGroupPermissionAuditQueryOffsetMin)
|
||||
.default(getGroupPermissionAuditQueryOffsetDefault),
|
||||
actorUserId: zod.coerce
|
||||
.number()
|
||||
.min(getGroupPermissionAuditQueryActorUserIdMin)
|
||||
.optional(),
|
||||
from: zod.date().optional(),
|
||||
to: zod.date().optional(),
|
||||
});
|
||||
|
||||
export const GetGroupPermissionAuditResponse = zod.object({
|
||||
entries: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
targetKind: zod.enum(["user", "group", "app"]),
|
||||
targetId: zod.number(),
|
||||
changeKind: zod.enum([
|
||||
"user.roles",
|
||||
"user.groups",
|
||||
"group.users",
|
||||
"group.roles",
|
||||
"group.apps",
|
||||
"app.permissions",
|
||||
]),
|
||||
previousIds: zod.array(zod.number()),
|
||||
newIds: zod.array(zod.number()),
|
||||
addedIds: zod.array(zod.number()),
|
||||
removedIds: zod.array(zod.number()),
|
||||
createdAt: zod.coerce.date(),
|
||||
actor: zod.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns permission-change audit records for the given app, newest
|
||||
first. Captures changes to the set of permissions required to open
|
||||
the app (`changeKind: app.permissions`).
|
||||
|
||||
* @summary List recent permission-change audit entries for an app (admin)
|
||||
*/
|
||||
export const GetAppPermissionAuditParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getAppPermissionAuditQueryLimitDefault = 10;
|
||||
export const getAppPermissionAuditQueryLimitMax = 200;
|
||||
|
||||
export const getAppPermissionAuditQueryOffsetDefault = 0;
|
||||
export const getAppPermissionAuditQueryOffsetMin = 0;
|
||||
|
||||
export const getAppPermissionAuditQueryActorUserIdMin = 0;
|
||||
|
||||
export const GetAppPermissionAuditQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAppPermissionAuditQueryLimitMax)
|
||||
.default(getAppPermissionAuditQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAppPermissionAuditQueryOffsetMin)
|
||||
.default(getAppPermissionAuditQueryOffsetDefault),
|
||||
actorUserId: zod.coerce
|
||||
.number()
|
||||
.min(getAppPermissionAuditQueryActorUserIdMin)
|
||||
.optional(),
|
||||
from: zod.date().optional(),
|
||||
to: zod.date().optional(),
|
||||
});
|
||||
|
||||
export const GetAppPermissionAuditResponse = zod.object({
|
||||
entries: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
targetKind: zod.enum(["user", "group", "app"]),
|
||||
targetId: zod.number(),
|
||||
changeKind: zod.enum([
|
||||
"user.roles",
|
||||
"user.groups",
|
||||
"group.users",
|
||||
"group.roles",
|
||||
"group.apps",
|
||||
"app.permissions",
|
||||
]),
|
||||
previousIds: zod.array(zod.number()),
|
||||
newIds: zod.array(zod.number()),
|
||||
addedIds: zod.array(zod.number()),
|
||||
removedIds: zod.array(zod.number()),
|
||||
createdAt: zod.coerce.date(),
|
||||
actor: zod.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Get home screen stats
|
||||
*/
|
||||
|
||||
@@ -13,4 +13,5 @@ export * from "./notes";
|
||||
export * from "./groups";
|
||||
export * from "./audit-logs";
|
||||
export * from "./role-audit";
|
||||
export * from "./permission-audit";
|
||||
export * from "./executive-meetings";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
timestamp,
|
||||
integer,
|
||||
varchar,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { usersTable } from "./users";
|
||||
|
||||
// Unified permission-change audit table for admin actions across user-role
|
||||
// assignments, group memberships (users/roles/apps) and app-permission grants.
|
||||
//
|
||||
// We chose one table over per-entity tables so the API and UI can share a
|
||||
// single shape and pagination semantics. Discrimination is via:
|
||||
// - targetKind: the entity whose history this row belongs to
|
||||
// ('user' | 'group' | 'app')
|
||||
// - targetId: its id (kept as a plain integer because the FK target
|
||||
// varies; the row stays valid even if the entity is later
|
||||
// deleted)
|
||||
// - changeKind: what set of links was modified
|
||||
// ('user.roles' | 'user.groups' | 'group.users' |
|
||||
// 'group.roles' | 'group.apps' | 'app.permissions')
|
||||
// previous_ids / new_ids store the FULL sorted set of linked ids before and
|
||||
// after the change, mirroring role_permission_audit. The GET endpoint
|
||||
// derives added/removed on read so the UI can render diffs without us
|
||||
// duplicating that data on disk.
|
||||
export const permissionAuditTable = pgTable(
|
||||
"permission_audit",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
targetKind: varchar("target_kind", { length: 16 }).notNull(),
|
||||
targetId: integer("target_id").notNull(),
|
||||
changeKind: varchar("change_kind", { length: 32 }).notNull(),
|
||||
actorUserId: integer("actor_user_id").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
previousIds: integer("previous_ids").array().notNull().default([]),
|
||||
newIds: integer("new_ids").array().notNull().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("permission_audit_target_idx").on(
|
||||
t.targetKind,
|
||||
t.targetId,
|
||||
t.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export type PermissionAudit = typeof permissionAuditTable.$inferSelect;
|
||||
|
||||
export const PERMISSION_AUDIT_TARGET_KINDS = ["user", "group", "app"] as const;
|
||||
export type PermissionAuditTargetKind =
|
||||
(typeof PERMISSION_AUDIT_TARGET_KINDS)[number];
|
||||
|
||||
export const PERMISSION_AUDIT_CHANGE_KINDS = [
|
||||
"user.roles",
|
||||
"user.groups",
|
||||
"group.users",
|
||||
"group.roles",
|
||||
"group.apps",
|
||||
"app.permissions",
|
||||
] as const;
|
||||
export type PermissionAuditChangeKind =
|
||||
(typeof PERMISSION_AUDIT_CHANGE_KINDS)[number];
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
## Database Tables
|
||||
|
||||
`users`, `roles`, `permissions`, `user_roles`, `role_permissions`, `role_permission_audit`, `apps`, `app_permissions`, `service_categories`, `services`, `conversations`, `conversation_participants`, `messages`, `message_reads`, `notifications`, `user_sessions`, `audit_logs`, `executive_meetings`, `executive_meeting_attendees`, `executive_meeting_requests`, `executive_meeting_tasks`, `executive_meeting_notifications`, `executive_meeting_audit_logs`, `executive_meeting_pdf_archives`, `executive_meeting_font_settings`
|
||||
`users`, `roles`, `permissions`, `user_roles`, `role_permissions`, `role_permission_audit`, `permission_audit`, `apps`, `app_permissions`, `service_categories`, `services`, `conversations`, `conversation_participants`, `messages`, `message_reads`, `notifications`, `user_sessions`, `audit_logs`, `executive_meetings`, `executive_meeting_attendees`, `executive_meeting_requests`, `executive_meeting_tasks`, `executive_meeting_notifications`, `executive_meeting_audit_logs`, `executive_meeting_pdf_archives`, `executive_meeting_font_settings`
|
||||
|
||||
## Important Notes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user