diff --git a/artifacts/api-server/src/routes/groups.ts b/artifacts/api-server/src/routes/groups.ts index 7ff4ad71..06114e16 100644 --- a/artifacts/api-server/src/routes/groups.ts +++ b/artifacts/api-server/src/routes/groups.ts @@ -395,11 +395,6 @@ router.patch("/groups/:id", requireAdmin, async (req, res): Promise => { // Sub-resource assignment endpoints — explicit add/remove for users, apps, roles. // These complement the aggregate PATCH /groups/:id by exposing single-link writes. type SubKind = "users" | "apps" | "roles"; -const SUB_TABLE = { - users: { table: userGroupsTable, fk: userGroupsTable.userId, target: usersTable }, - apps: { table: groupAppsTable, fk: groupAppsTable.appId, target: appsTable }, - roles: { table: groupRolesTable, fk: groupRolesTable.roleId, target: rolesTable }, -} as const; function isSubKind(k: string): k is SubKind { return k === "users" || k === "apps" || k === "roles"; @@ -410,6 +405,41 @@ async function ensureGroup(id: number) { return g; } +// Capture the human-readable name for the linked record so audit log entries +// remain meaningful even after the user/app/role is later deleted. +async function loadSubResourceNameFields( + kind: SubKind, + targetId: number, +): Promise | null> { + if (kind === "users") { + const [row] = await db + .select({ id: usersTable.id, username: usersTable.username }) + .from(usersTable) + .where(eq(usersTable.id, targetId)); + if (!row) return null; + return { username: row.username }; + } + if (kind === "apps") { + const [row] = await db + .select({ + id: appsTable.id, + slug: appsTable.slug, + nameAr: appsTable.nameAr, + nameEn: appsTable.nameEn, + }) + .from(appsTable) + .where(eq(appsTable.id, targetId)); + if (!row) return null; + return { appSlug: row.slug, appNameAr: row.nameAr, appNameEn: row.nameEn }; + } + const [row] = await db + .select({ id: rolesTable.id, name: rolesTable.name }) + .from(rolesTable) + .where(eq(rolesTable.id, targetId)); + if (!row) return null; + return { roleName: row.name }; +} + router.post("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Promise => { const id = Number(req.params.id); const targetId = Number(req.params.targetId); @@ -423,9 +453,8 @@ router.post("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Promi res.status(404).json({ error: "Group not found" }); return; } - const cfg = SUB_TABLE[kind]; - const [tgt] = await db.select({ id: cfg.target.id }).from(cfg.target).where(eq(cfg.target.id, targetId)); - if (!tgt) { + const nameFields = await loadSubResourceNameFields(kind, targetId); + if (!nameFields) { res.status(404).json({ error: `${kind.slice(0, -1)} not found` }); return; } @@ -462,6 +491,7 @@ router.post("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Promi metadata: { groupName: group.name, [`${subject}Id`]: targetId, + ...nameFields, }, }); } @@ -505,6 +535,10 @@ router.delete("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Pro } if (removed.length > 0) { const subject = kind === "users" ? "user" : kind === "apps" ? "app" : "role"; + // Best-effort: if the linked record still exists capture its name so the + // log entry stays readable after a future delete. If it's already gone + // we just record the id as before. + const nameFields = (await loadSubResourceNameFields(kind, targetId)) ?? {}; await db.insert(auditLogsTable).values({ actorUserId: req.session.userId ?? null, action: `group.${subject}.remove`, @@ -513,6 +547,7 @@ router.delete("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Pro metadata: { groupName: group.name, [`${subject}Id`]: targetId, + ...nameFields, }, }); } diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 5fc27c2a..6fc17434 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 9f4d43e3..71dee099 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -627,6 +627,7 @@ "app": { "create": "تم إنشاء التطبيق '{{name}}'", "update": "تم تعديل التطبيق '{{name}}' ({{changes}})", + "rename": "تمت إعادة تسمية التطبيق من '{{previousName}}' إلى '{{name}}'", "delete": "تم حذف التطبيق '{{name}}'", "forceDelete": "تم حذف التطبيق '{{name}}' قسراً (تأثرت {{groups}})" }, @@ -636,14 +637,21 @@ "group": { "create": "تم إنشاء المجموعة '{{name}}' ({{members}}، {{apps}})", "update": "تم تعديل المجموعة '{{name}}' ({{changes}})", + "rename": "تمت إعادة تسمية المجموعة من '{{previousName}}' إلى '{{name}}'", "delete": "تم حذف المجموعة '{{name}}'", "forceDelete": "تم حذف المجموعة '{{name}}' قسراً ({{members}})", - "userAdd": "تمت إضافة المستخدم #{{userId}} إلى المجموعة '{{name}}'", - "userRemove": "تمت إزالة المستخدم #{{userId}} من المجموعة '{{name}}'", - "appAdd": "تمت إضافة التطبيق #{{appId}} إلى المجموعة '{{name}}'", - "appRemove": "تمت إزالة التطبيق #{{appId}} من المجموعة '{{name}}'", - "roleAdd": "تمت إضافة الدور #{{roleId}} إلى المجموعة '{{name}}'", - "roleRemove": "تمت إزالة الدور #{{roleId}} من المجموعة '{{name}}'" + "userAdd": "تمت إضافة المستخدم @{{username}} إلى المجموعة '{{name}}'", + "userAddById": "تمت إضافة المستخدم #{{userId}} إلى المجموعة '{{name}}'", + "userRemove": "تمت إزالة المستخدم @{{username}} من المجموعة '{{name}}'", + "userRemoveById": "تمت إزالة المستخدم #{{userId}} من المجموعة '{{name}}'", + "appAdd": "تمت إضافة التطبيق '{{appName}}' إلى المجموعة '{{name}}'", + "appAddById": "تمت إضافة التطبيق #{{appId}} إلى المجموعة '{{name}}'", + "appRemove": "تمت إزالة التطبيق '{{appName}}' من المجموعة '{{name}}'", + "appRemoveById": "تمت إزالة التطبيق #{{appId}} من المجموعة '{{name}}'", + "roleAdd": "تمت إضافة الدور '{{roleName}}' إلى المجموعة '{{name}}'", + "roleAddById": "تمت إضافة الدور #{{roleId}} إلى المجموعة '{{name}}'", + "roleRemove": "تمت إزالة الدور '{{roleName}}' من المجموعة '{{name}}'", + "roleRemoveById": "تمت إزالة الدور #{{roleId}} من المجموعة '{{name}}'" }, "role": { "create": "تم إنشاء الدور '{{name}}'", @@ -658,7 +666,11 @@ "forceDelete": "تم حذف الخدمة '{{name}}' قسراً (تأثرت {{orders}})" }, "settings": { - "update": "تم تحديث إعدادات النظام ({{changes}})" + "update": "تم تحديث إعدادات النظام ({{changes}})", + "registrationOpened": "تم فتح التسجيل العام", + "registrationClosed": "تم إغلاق التسجيل العام", + "registrationOpenedWith": "تم فتح التسجيل العام و{{others}}", + "registrationClosedWith": "تم إغلاق التسجيل العام و{{others}}" }, "user": { "delete": "تم حذف المستخدم @{{username}}", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 6212a068..b2a09ec0 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -564,6 +564,7 @@ "app": { "create": "Created app '{{name}}'", "update": "Updated app '{{name}}' ({{changes}})", + "rename": "Renamed app '{{previousName}}' to '{{name}}'", "delete": "Deleted app '{{name}}'", "forceDelete": "Force-deleted app '{{name}}' (affected {{groups}})" }, @@ -573,14 +574,21 @@ "group": { "create": "Created group '{{name}}' ({{members}}, {{apps}})", "update": "Updated group '{{name}}' ({{changes}})", + "rename": "Renamed group '{{previousName}}' to '{{name}}'", "delete": "Deleted group '{{name}}'", "forceDelete": "Force-deleted group '{{name}}' ({{members}})", - "userAdd": "Added user #{{userId}} to group '{{name}}'", - "userRemove": "Removed user #{{userId}} from group '{{name}}'", - "appAdd": "Added app #{{appId}} to group '{{name}}'", - "appRemove": "Removed app #{{appId}} from group '{{name}}'", - "roleAdd": "Added role #{{roleId}} to group '{{name}}'", - "roleRemove": "Removed role #{{roleId}} from group '{{name}}'" + "userAdd": "Added @{{username}} to group '{{name}}'", + "userAddById": "Added user #{{userId}} to group '{{name}}'", + "userRemove": "Removed @{{username}} from group '{{name}}'", + "userRemoveById": "Removed user #{{userId}} from group '{{name}}'", + "appAdd": "Added app '{{appName}}' to group '{{name}}'", + "appAddById": "Added app #{{appId}} to group '{{name}}'", + "appRemove": "Removed app '{{appName}}' from group '{{name}}'", + "appRemoveById": "Removed app #{{appId}} from group '{{name}}'", + "roleAdd": "Added role '{{roleName}}' to group '{{name}}'", + "roleAddById": "Added role #{{roleId}} to group '{{name}}'", + "roleRemove": "Removed role '{{roleName}}' from group '{{name}}'", + "roleRemoveById": "Removed role #{{roleId}} from group '{{name}}'" }, "role": { "create": "Created role '{{name}}'", @@ -595,7 +603,11 @@ "forceDelete": "Force-deleted service '{{name}}' (affected {{orders}})" }, "settings": { - "update": "Updated system settings ({{changes}})" + "update": "Updated system settings ({{changes}})", + "registrationOpened": "Opened public registration", + "registrationClosed": "Closed public registration", + "registrationOpenedWith": "Opened public registration and {{others}}", + "registrationClosedWith": "Closed public registration and {{others}}" }, "user": { "delete": "Deleted user @{{username}}", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 6567c276..6b82f936 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -4310,6 +4310,13 @@ function appName(meta: Record, lang: string, fallbackId: AuditL return preferred ?? slug ?? `#${fallbackId ?? "?"}`; } +function linkedAppName(meta: Record, lang: string): string | null { + const en = asString(meta.appNameEn); + const ar = asString(meta.appNameAr); + const slug = asString(meta.appSlug); + return (lang === "ar" ? ar ?? en : en ?? ar) ?? slug ?? null; +} + function plainName(meta: Record, fallbackId: AuditLogEntry["targetId"]): string { return asString(meta.name) ?? `#${fallbackId ?? "?"}`; } @@ -4347,11 +4354,29 @@ function formatAuditSummary( return t("admin.audit.summary.app.create", { name: appName(meta, lang, targetId), }); - case "app.update": + case "app.update": { + const name = appName(meta, lang, targetId); + const changes = asRecord(meta.changes); + const order = lang === "ar" + ? ["nameAr", "nameEn", "slug"] + : ["nameEn", "nameAr", "slug"]; + for (const field of order) { + if (!(field in changes)) continue; + const change = asRecord(changes[field]); + const previousName = asString(change.from); + const nextName = asString(change.to); + if (previousName && nextName && previousName !== nextName) { + return t("admin.audit.summary.app.rename", { + previousName, + name: nextName, + }); + } + } return t("admin.audit.summary.app.update", { - name: appName(meta, lang, targetId), + name, changes: unitLabel(t, "change", changeCount(meta)), }); + } case "app.delete": { const name = appName(meta, lang, targetId); if (meta.force) { @@ -4373,11 +4398,21 @@ function formatAuditSummary( members: unitLabel(t, "member", asNumber(meta.memberCount) ?? 0), apps: unitLabel(t, "app", asNumber(meta.appCount) ?? 0), }); - case "group.update": + case "group.update": { + const name = plainName(meta, targetId); + const previousName = asString(meta.previousName); + const fieldsChanged = changeCount(meta); + if (previousName && previousName !== name && fieldsChanged <= 1) { + return t("admin.audit.summary.group.rename", { + previousName, + name, + }); + } return t("admin.audit.summary.group.update", { - name: plainName(meta, targetId), - changes: unitLabel(t, "change", Math.max(1, changeCount(meta))), + name, + changes: unitLabel(t, "change", Math.max(1, fieldsChanged)), }); + } case "group.delete": { const name = plainName(meta, targetId); if (meta.force) { @@ -4389,36 +4424,66 @@ function formatAuditSummary( } return t("admin.audit.summary.group.delete", { name }); } - case "group.user.add": - return t("admin.audit.summary.group.userAdd", { - name: asString(meta.groupName) ?? `#${targetId ?? "?"}`, - userId: asNumber(meta.userId) ?? "?", - }); - case "group.user.remove": - return t("admin.audit.summary.group.userRemove", { - name: asString(meta.groupName) ?? `#${targetId ?? "?"}`, - userId: asNumber(meta.userId) ?? "?", - }); - case "group.app.add": - return t("admin.audit.summary.group.appAdd", { - name: asString(meta.groupName) ?? `#${targetId ?? "?"}`, - appId: asNumber(meta.appId) ?? "?", - }); - case "group.app.remove": - return t("admin.audit.summary.group.appRemove", { - name: asString(meta.groupName) ?? `#${targetId ?? "?"}`, - appId: asNumber(meta.appId) ?? "?", - }); - case "group.role.add": - return t("admin.audit.summary.group.roleAdd", { - name: asString(meta.groupName) ?? `#${targetId ?? "?"}`, - roleId: asNumber(meta.roleId) ?? "?", - }); - case "group.role.remove": - return t("admin.audit.summary.group.roleRemove", { - name: asString(meta.groupName) ?? `#${targetId ?? "?"}`, - roleId: asNumber(meta.roleId) ?? "?", - }); + case "group.user.add": { + const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`; + const username = asString(meta.username); + return username + ? t("admin.audit.summary.group.userAdd", { name: groupName, username }) + : t("admin.audit.summary.group.userAddById", { + name: groupName, + userId: asNumber(meta.userId) ?? "?", + }); + } + case "group.user.remove": { + const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`; + const username = asString(meta.username); + return username + ? t("admin.audit.summary.group.userRemove", { name: groupName, username }) + : t("admin.audit.summary.group.userRemoveById", { + name: groupName, + userId: asNumber(meta.userId) ?? "?", + }); + } + case "group.app.add": { + const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`; + const appNm = linkedAppName(meta, lang); + return appNm + ? t("admin.audit.summary.group.appAdd", { name: groupName, appName: appNm }) + : t("admin.audit.summary.group.appAddById", { + name: groupName, + appId: asNumber(meta.appId) ?? "?", + }); + } + case "group.app.remove": { + const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`; + const appNm = linkedAppName(meta, lang); + return appNm + ? t("admin.audit.summary.group.appRemove", { name: groupName, appName: appNm }) + : t("admin.audit.summary.group.appRemoveById", { + name: groupName, + appId: asNumber(meta.appId) ?? "?", + }); + } + case "group.role.add": { + const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`; + const roleName = asString(meta.roleName); + return roleName + ? t("admin.audit.summary.group.roleAdd", { name: groupName, roleName }) + : t("admin.audit.summary.group.roleAddById", { + name: groupName, + roleId: asNumber(meta.roleId) ?? "?", + }); + } + case "group.role.remove": { + const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`; + const roleName = asString(meta.roleName); + return roleName + ? t("admin.audit.summary.group.roleRemove", { name: groupName, roleName }) + : t("admin.audit.summary.group.roleRemoveById", { + name: groupName, + roleId: asNumber(meta.roleId) ?? "?", + }); + } case "role.create": return t("admin.audit.summary.role.create", { name: plainName(meta, targetId), @@ -4469,10 +4534,31 @@ function formatAuditSummary( name: asString(meta.nameEn) ?? `#${targetId ?? "?"}`, orders: unitLabel(t, "order", asNumber(meta.orderCount) ?? 0), }); - case "settings.update": + case "settings.update": { + const changes = asRecord(meta.changes); + const totalChanges = Object.keys(changes).length; + if ("registrationOpen" in changes) { + const change = asRecord(changes.registrationOpen); + const opened = change.to === true; + const otherCount = totalChanges - 1; + if (otherCount <= 0) { + return t( + opened + ? "admin.audit.summary.settings.registrationOpened" + : "admin.audit.summary.settings.registrationClosed", + ); + } + return t( + opened + ? "admin.audit.summary.settings.registrationOpenedWith" + : "admin.audit.summary.settings.registrationClosedWith", + { others: unitLabel(t, "change", otherCount) }, + ); + } return t("admin.audit.summary.settings.update", { - changes: unitLabel(t, "change", changeCount(meta)), + changes: unitLabel(t, "change", Math.max(1, totalChanges)), }); + } case "user.delete": { const username = asString(meta.username) ?? `#${targetId ?? "?"}`; return meta.force