From f5273af19fa06de946ffc30ac254068e9af0ccad Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 22 Apr 2026 08:28:31 +0000 Subject: [PATCH] Task #74: Add groups system + admin User Management UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - New schema: groups, user_groups, group_apps, group_roles (lib/db/src/schema/groups.ts) - Seeds Admins, TeaBoy, Everyone system groups idempotently and maps existing users - /api/groups CRUD with admin guard, batch counts, system-group delete protection - Validates appIds/roleIds/userIds (400 on missing) and wraps assignment writes in a single DB transaction (no partial state on failure) - /api/users gains q/groupId/status filters, batch role+group loading, groupIds replacement on PATCH, auto-assigns Everyone on admin-create - /auth/register also auto-assigns Everyone for consistent default linkage - buildAuthUser now returns groups (matches updated AuthUser OpenAPI schema) - App visibility (getVisibleAppsForUser) unions group-granted apps via group_apps + user_groups in addition to existing permission gating Frontend (admin.tsx): - Nav restructured: User Management section with Users + Groups children - Section deep-linked via #section=… URL hash - Users page rebuilt: search, group filter, status filter, sortable table, groups column, edit-groups dialog, mobile cards - New Groups page: cards with member/app/role counts, create dialog, detail editor with Info/Apps/Users tabs and system-group guard - ar/en translations added for all new keys Testing: - pnpm typecheck clean (api + web) - 25/26 api tests pass; the only failure is pre-existing flaky pagination test (admin-app-opens-pagination) — left as-is per scratchpad note - Code review feedback addressed (validation, transactions, register auto-assign) --- artifacts/api-server/src/routes/apps.ts | 21 +- artifacts/api-server/src/routes/auth.ts | 44 +- artifacts/api-server/src/routes/groups.ts | 313 +++++++ artifacts/api-server/src/routes/index.ts | 2 + artifacts/api-server/src/routes/users.ts | 210 ++++- artifacts/teaboy-os/public/opengraph.jpg | Bin 25906 -> 25890 bytes artifacts/teaboy-os/src/locales/ar.json | 39 + artifacts/teaboy-os/src/locales/en.json | 39 + artifacts/teaboy-os/src/pages/admin.tsx | 875 +++++++++++++++--- .../src/generated/api.schemas.ts | 92 ++ lib/api-client-react/src/generated/api.ts | 501 +++++++++- lib/api-spec/openapi.yaml | 274 ++++++ lib/api-zod/src/generated/api.ts | 186 ++++ lib/db/src/schema/groups.ts | 62 ++ lib/db/src/schema/index.ts | 1 + scripts/src/seed.ts | 111 ++- 16 files changed, 2576 insertions(+), 194 deletions(-) create mode 100644 artifacts/api-server/src/routes/groups.ts create mode 100644 lib/db/src/schema/groups.ts diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index 133089ce..a31adb88 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -9,6 +9,8 @@ import { rolesTable, userAppOrdersTable, appOpensTable, + userGroupsTable, + groupAppsTable, } from "@workspace/db"; import { requireAuth, requireAdmin } from "../middlewares/auth"; import { @@ -76,9 +78,26 @@ async function getVisibleAppsForUser(userId: number): Promise r.appId) : []; + // Group-granted apps: any app explicitly assigned to one of the user's groups + // is visible regardless of the legacy permission gating. + const groupGrantedAppIds = ( + await db + .select({ appId: groupAppsTable.appId }) + .from(groupAppsTable) + .innerJoin( + userGroupsTable, + eq(userGroupsTable.groupId, groupAppsTable.groupId), + ) + .where(eq(userGroupsTable.userId, userId)) + ).map((r) => r.appId); + const groupGrantedSet = new Set(groupGrantedAppIds); + const allowedAppIdSet = new Set(allowedRestrictedAppIds); return apps.filter( - (app) => !restrictedAppIds.has(app.id) || allowedAppIdSet.has(app.id), + (app) => + !restrictedAppIds.has(app.id) || + allowedAppIdSet.has(app.id) || + groupGrantedSet.has(app.id), ); } diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index 602de87d..bc145bbe 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -9,6 +9,8 @@ import { rolesTable, appSettingsTable, passwordResetTokensTable, + groupsTable, + userGroupsTable, } from "@workspace/db"; import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth"; import { saveSession, destroySession } from "../lib/session"; @@ -31,7 +33,22 @@ function hashToken(token: string) { const router: IRouter = Router(); -function buildAuthUser(user: typeof usersTable.$inferSelect, roles: string[]) { +async function getUserGroupsForAuth( + userId: number, +): Promise> { + const rows = await db + .select({ id: groupsTable.id, name: groupsTable.name }) + .from(userGroupsTable) + .innerJoin(groupsTable, eq(groupsTable.id, userGroupsTable.groupId)) + .where(eq(userGroupsTable.userId, userId)); + return rows; +} + +function buildAuthUser( + user: typeof usersTable.$inferSelect, + roles: string[], + groups: Array<{ id: number; name: string }>, +) { return { id: user.id, username: user.username, @@ -44,6 +61,7 @@ function buildAuthUser(user: typeof usersTable.$inferSelect, roles: string[]) { avatarUrl: user.avatarUrl, isActive: user.isActive, roles, + groups, createdAt: user.createdAt, }; } @@ -92,10 +110,22 @@ router.post("/auth/register", async (req, res): Promise => { await db.insert(userRolesTable).values({ userId: user.id, roleId: userRole.id }); } + // Auto-assign the "Everyone" system group for consistent default linkage + const [everyoneGroup] = await db + .select() + .from(groupsTable) + .where(eq(groupsTable.name, "Everyone")); + if (everyoneGroup) { + await db + .insert(userGroupsTable) + .values({ userId: user.id, groupId: everyoneGroup.id }) + .onConflictDoNothing(); + } + req.session.userId = user.id; await saveSession(req); const roles = await getUserRoles(user.id); - res.status(201).json(buildAuthUser(user, roles)); + res.status(201).json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id))); }); router.post("/auth/login", async (req, res): Promise => { @@ -131,7 +161,7 @@ router.post("/auth/login", async (req, res): Promise => { req.session.userId = user.id; await saveSession(req); const roles = await getUserRoles(user.id); - res.json(buildAuthUser(user, roles)); + res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id))); }); router.post("/auth/forgot-password", async (req, res): Promise => { @@ -310,7 +340,7 @@ router.get("/auth/me", requireAuth, async (req, res): Promise => { } const roles = await getUserRoles(user.id); - res.json(buildAuthUser(user, roles)); + res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id))); }); router.patch("/auth/me/language", requireAuth, async (req, res): Promise => { @@ -327,7 +357,7 @@ router.patch("/auth/me/language", requireAuth, async (req, res): Promise = .returning(); const roles = await getUserRoles(user.id); - res.json(buildAuthUser(user, roles)); + res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id))); }); router.patch("/auth/me/clock-hour12", requireAuth, async (req, res): Promise => { @@ -349,7 +379,7 @@ router.patch("/auth/me/clock-hour12", requireAuth, async (req, res): Promise => { @@ -371,7 +401,7 @@ router.patch("/auth/me/clock-style", requireAuth, async (req, res): Promise => { + const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; + const where = q ? ilike(groupsTable.name, `%${q}%`) : undefined; + + const rows = where + ? await db.select().from(groupsTable).where(where).orderBy(groupsTable.name) + : await db.select().from(groupsTable).orderBy(groupsTable.name); + + if (rows.length === 0) { + res.json([]); + return; + } + + const groupIds = rows.map((r) => r.id); + + const memberCounts = await db + .select({ + groupId: userGroupsTable.groupId, + count: sql`count(*)::int`, + }) + .from(userGroupsTable) + .where(inArray(userGroupsTable.groupId, groupIds)) + .groupBy(userGroupsTable.groupId); + + const appCounts = await db + .select({ + groupId: groupAppsTable.groupId, + count: sql`count(*)::int`, + }) + .from(groupAppsTable) + .where(inArray(groupAppsTable.groupId, groupIds)) + .groupBy(groupAppsTable.groupId); + + const roleCounts = await db + .select({ + groupId: groupRolesTable.groupId, + count: sql`count(*)::int`, + }) + .from(groupRolesTable) + .where(inArray(groupRolesTable.groupId, groupIds)) + .groupBy(groupRolesTable.groupId); + + const memberMap = new Map(memberCounts.map((m) => [m.groupId, m.count])); + const appMap = new Map(appCounts.map((m) => [m.groupId, m.count])); + const roleMap = new Map(roleCounts.map((m) => [m.groupId, m.count])); + + res.json( + rows.map((g) => ({ + ...serializeGroup(g), + memberCount: memberMap.get(g.id) ?? 0, + appCount: appMap.get(g.id) ?? 0, + roleCount: roleMap.get(g.id) ?? 0, + })), + ); +}); + +router.get("/groups/:id", requireAdmin, async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + res.status(400).json({ error: "Invalid id" }); + return; + } + const [group] = await db.select().from(groupsTable).where(eq(groupsTable.id, id)); + if (!group) { + res.status(404).json({ error: "Group not found" }); + return; + } + const apps = await db + .select({ appId: groupAppsTable.appId }) + .from(groupAppsTable) + .where(eq(groupAppsTable.groupId, id)); + const roles = await db + .select({ roleId: groupRolesTable.roleId }) + .from(groupRolesTable) + .where(eq(groupRolesTable.groupId, id)); + const users = await db + .select({ userId: userGroupsTable.userId }) + .from(userGroupsTable) + .where(eq(userGroupsTable.groupId, id)); + res.json({ + ...serializeGroup(group), + appIds: apps.map((a) => a.appId), + roleIds: roles.map((r) => r.roleId), + userIds: users.map((u) => u.userId), + }); +}); + +async function validateAssignmentIds( + appIds: number[] | undefined, + roleIds: number[] | undefined, + userIds: number[] | undefined, +): Promise<{ ok: true } | { ok: false; error: string }> { + if (appIds !== undefined && appIds.length > 0) { + const found = await db + .select({ id: appsTable.id }) + .from(appsTable) + .where(inArray(appsTable.id, appIds)); + if (found.length !== new Set(appIds).size) { + return { ok: false, error: "One or more appIds do not exist" }; + } + } + if (roleIds !== undefined && roleIds.length > 0) { + const found = await db + .select({ id: rolesTable.id }) + .from(rolesTable) + .where(inArray(rolesTable.id, roleIds)); + if (found.length !== new Set(roleIds).size) { + return { ok: false, error: "One or more roleIds do not exist" }; + } + } + if (userIds !== undefined && userIds.length > 0) { + const found = await db + .select({ id: usersTable.id }) + .from(usersTable) + .where(inArray(usersTable.id, userIds)); + if (found.length !== new Set(userIds).size) { + return { ok: false, error: "One or more userIds do not exist" }; + } + } + return { ok: true }; +} + +async function setGroupAssignments( + groupId: number, + appIds: number[] | undefined, + roleIds: number[] | undefined, + userIds: number[] | undefined, +) { + await db.transaction(async (tx) => { + if (appIds !== undefined) { + await tx.delete(groupAppsTable).where(eq(groupAppsTable.groupId, groupId)); + if (appIds.length > 0) { + await tx + .insert(groupAppsTable) + .values(appIds.map((appId) => ({ groupId, appId }))) + .onConflictDoNothing(); + } + } + if (roleIds !== undefined) { + await tx.delete(groupRolesTable).where(eq(groupRolesTable.groupId, groupId)); + if (roleIds.length > 0) { + await tx + .insert(groupRolesTable) + .values(roleIds.map((roleId) => ({ groupId, roleId }))) + .onConflictDoNothing(); + } + } + if (userIds !== undefined) { + await tx.delete(userGroupsTable).where(eq(userGroupsTable.groupId, groupId)); + if (userIds.length > 0) { + await tx + .insert(userGroupsTable) + .values(userIds.map((userId) => ({ userId, groupId }))) + .onConflictDoNothing(); + } + } + }); +} + +router.post("/groups", requireAdmin, async (req, res): Promise => { + const parsed = CreateGroupBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const existing = await db + .select({ id: groupsTable.id }) + .from(groupsTable) + .where(eq(groupsTable.name, parsed.data.name)); + if (existing.length > 0) { + res.status(409).json({ error: "Group name already taken" }); + return; + } + const validation = await validateAssignmentIds( + parsed.data.appIds, + parsed.data.roleIds, + parsed.data.userIds, + ); + if (!validation.ok) { + res.status(400).json({ error: validation.error }); + return; + } + const [group] = await db + .insert(groupsTable) + .values({ + name: parsed.data.name, + descriptionAr: parsed.data.descriptionAr ?? null, + descriptionEn: parsed.data.descriptionEn ?? null, + }) + .returning(); + await setGroupAssignments( + group.id, + parsed.data.appIds, + parsed.data.roleIds, + parsed.data.userIds, + ); + res.status(201).json({ + ...serializeGroup(group), + memberCount: parsed.data.userIds?.length ?? 0, + appCount: parsed.data.appIds?.length ?? 0, + roleCount: parsed.data.roleIds?.length ?? 0, + }); +}); + +router.patch("/groups/:id", requireAdmin, async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + res.status(400).json({ error: "Invalid id" }); + return; + } + const parsed = UpdateGroupBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const [existing] = await db.select().from(groupsTable).where(eq(groupsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Group not found" }); + return; + } + const validation = await validateAssignmentIds( + parsed.data.appIds, + parsed.data.roleIds, + parsed.data.userIds, + ); + if (!validation.ok) { + res.status(400).json({ error: validation.error }); + return; + } + const updates: Partial = {}; + if (parsed.data.name !== undefined) updates.name = parsed.data.name; + if (parsed.data.descriptionAr !== undefined) updates.descriptionAr = parsed.data.descriptionAr; + if (parsed.data.descriptionEn !== undefined) updates.descriptionEn = parsed.data.descriptionEn; + if (Object.keys(updates).length > 0) { + await db.update(groupsTable).set(updates).where(eq(groupsTable.id, id)); + } + await setGroupAssignments(id, parsed.data.appIds, parsed.data.roleIds, parsed.data.userIds); + + const [group] = await db.select().from(groupsTable).where(eq(groupsTable.id, id)); + const apps = await db + .select({ appId: groupAppsTable.appId }) + .from(groupAppsTable) + .where(eq(groupAppsTable.groupId, id)); + const roles = await db + .select({ roleId: groupRolesTable.roleId }) + .from(groupRolesTable) + .where(eq(groupRolesTable.groupId, id)); + const users = await db + .select({ userId: userGroupsTable.userId }) + .from(userGroupsTable) + .where(eq(userGroupsTable.groupId, id)); + res.json({ + ...serializeGroup(group), + appIds: apps.map((a) => a.appId), + roleIds: roles.map((r) => r.roleId), + userIds: users.map((u) => u.userId), + }); +}); + +router.delete("/groups/:id", requireAdmin, async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + res.status(400).json({ error: "Invalid id" }); + return; + } + const [existing] = await db.select().from(groupsTable).where(eq(groupsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Group not found" }); + return; + } + if (existing.isSystem) { + res.status(400).json({ error: "Cannot delete a system group" }); + return; + } + await db.delete(groupsTable).where(eq(groupsTable.id, id)); + res.sendStatus(204); +}); + +// Suppress unused import warnings if `or` becomes unused after refactors +void or; + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index f037c272..3a3ec3c2 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -11,6 +11,7 @@ import statsRouter from "./stats"; import storageRouter from "./storage"; import settingsRouter from "./settings"; import notesRouter from "./notes"; +import groupsRouter from "./groups"; const router: IRouter = Router(); @@ -26,5 +27,6 @@ router.use(statsRouter); router.use(storageRouter); router.use(settingsRouter); router.use(notesRouter); +router.use(groupsRouter); export default router; diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index 0ecd271d..eb0b094a 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -1,7 +1,13 @@ import { Router, type IRouter } from "express"; -import { eq, and } from "drizzle-orm"; +import { eq, and, or, ilike, inArray, sql, asc } from "drizzle-orm"; import { db } from "@workspace/db"; -import { usersTable, userRolesTable, rolesTable } from "@workspace/db"; +import { + usersTable, + userRolesTable, + rolesTable, + groupsTable, + userGroupsTable, +} from "@workspace/db"; import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth"; import { RegisterBody, @@ -30,7 +36,33 @@ router.get("/users/directory", requireAuth, async (_req, res): Promise => res.json(users); }); -function buildUserProfile(user: typeof usersTable.$inferSelect, roles: string[]) { +export type GroupSummary = { + id: number; + name: string; + descriptionAr: string | null; + descriptionEn: string | null; +}; + +export async function getGroupsForUser(userId: number): Promise { + const rows = await db + .select({ + id: groupsTable.id, + name: groupsTable.name, + descriptionAr: groupsTable.descriptionAr, + descriptionEn: groupsTable.descriptionEn, + }) + .from(userGroupsTable) + .innerJoin(groupsTable, eq(userGroupsTable.groupId, groupsTable.id)) + .where(eq(userGroupsTable.userId, userId)) + .orderBy(groupsTable.name); + return rows; +} + +function buildUserProfile( + user: typeof usersTable.$inferSelect, + roles: string[], + groups: GroupSummary[], +) { return { id: user.id, username: user.username, @@ -39,9 +71,11 @@ function buildUserProfile(user: typeof usersTable.$inferSelect, roles: string[]) displayNameEn: user.displayNameEn, preferredLanguage: user.preferredLanguage, clockStyle: user.clockStyle, + clockHour12: user.clockHour12, avatarUrl: user.avatarUrl, isActive: user.isActive, roles, + groups, createdAt: user.createdAt, }; } @@ -82,18 +116,122 @@ router.post("/users", requireAdmin, async (req, res): Promise => { return; } - res.status(201).json(buildUserProfile(user, [])); + // Auto-assign Everyone group if it exists + const [everyone] = await db + .select({ id: groupsTable.id }) + .from(groupsTable) + .where(eq(groupsTable.name, "Everyone")); + if (everyone) { + await db + .insert(userGroupsTable) + .values({ userId: user.id, groupId: everyone.id }) + .onConflictDoNothing(); + } + + const groups = await getGroupsForUser(user.id); + res.status(201).json(buildUserProfile(user, [], groups)); }); -router.get("/users", requireAdmin, async (_req, res): Promise => { - const users = await db.select().from(usersTable).orderBy(usersTable.createdAt); - const usersWithRoles = await Promise.all( - users.map(async (u) => { - const roles = await getUserRoles(u.id); - return buildUserProfile(u, roles); - }), +router.get("/users", requireAdmin, async (req, res): Promise => { + const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; + const groupIdRaw = req.query.groupId; + const groupId = + typeof groupIdRaw === "string" && /^\d+$/.test(groupIdRaw) + ? Number(groupIdRaw) + : undefined; + const status = typeof req.query.status === "string" ? req.query.status : ""; + + const conditions: Array> = []; + if (q) { + const pattern = `%${q}%`; + const orExpr = or( + ilike(usersTable.username, pattern), + ilike(usersTable.email, pattern), + ilike(usersTable.displayNameAr, pattern), + ilike(usersTable.displayNameEn, pattern), + ); + if (orExpr) conditions.push(orExpr as unknown as ReturnType); + } + if (status === "active") conditions.push(eq(usersTable.isActive, true)); + if (status === "disabled") conditions.push(eq(usersTable.isActive, false)); + + let userIdsForGroup: number[] | null = null; + if (groupId !== undefined) { + const memberRows = await db + .select({ userId: userGroupsTable.userId }) + .from(userGroupsTable) + .where(eq(userGroupsTable.groupId, groupId)); + userIdsForGroup = memberRows.map((r) => r.userId); + if (userIdsForGroup.length === 0) { + res.json([]); + return; + } + conditions.push(inArray(usersTable.id, userIdsForGroup) as unknown as ReturnType); + } + + const whereClause = + conditions.length === 0 + ? undefined + : conditions.length === 1 + ? conditions[0] + : and(...conditions); + + const users = whereClause + ? await db.select().from(usersTable).where(whereClause).orderBy(asc(usersTable.createdAt)) + : await db.select().from(usersTable).orderBy(asc(usersTable.createdAt)); + + if (users.length === 0) { + res.json([]); + return; + } + + const userIds = users.map((u) => u.id); + + // Batch load roles + const roleRows = await db + .select({ userId: userRolesTable.userId, roleName: rolesTable.name }) + .from(userRolesTable) + .innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id)) + .where(inArray(userRolesTable.userId, userIds)); + const rolesByUser = new Map(); + for (const r of roleRows) { + const list = rolesByUser.get(r.userId) ?? []; + list.push(r.roleName); + rolesByUser.set(r.userId, list); + } + + // Batch load groups + const groupRows = await db + .select({ + userId: userGroupsTable.userId, + id: groupsTable.id, + name: groupsTable.name, + descriptionAr: groupsTable.descriptionAr, + descriptionEn: groupsTable.descriptionEn, + }) + .from(userGroupsTable) + .innerJoin(groupsTable, eq(userGroupsTable.groupId, groupsTable.id)) + .where(inArray(userGroupsTable.userId, userIds)) + .orderBy(groupsTable.name); + const groupsByUser = new Map(); + for (const g of groupRows) { + const list = groupsByUser.get(g.userId) ?? []; + list.push({ + id: g.id, + name: g.name, + descriptionAr: g.descriptionAr, + descriptionEn: g.descriptionEn, + }); + groupsByUser.set(g.userId, list); + } + + res.json( + users.map((u) => + buildUserProfile(u, rolesByUser.get(u.id) ?? [], groupsByUser.get(u.id) ?? []), + ), ); - res.json(usersWithRoles); + // keep sql import live + void sql; }); router.get("/users/:id", requireAdmin, async (req, res): Promise => { @@ -114,7 +252,8 @@ router.get("/users/:id", requireAdmin, async (req, res): Promise => { } const roles = await getUserRoles(user.id); - res.json(buildUserProfile(user, roles)); + const groups = await getGroupsForUser(user.id); + res.json(buildUserProfile(user, roles, groups)); }); router.patch("/users/:id", requireAdmin, async (req, res): Promise => { @@ -136,19 +275,46 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise => { if (parsed.data.isActive !== undefined) updateData.isActive = parsed.data.isActive; if (parsed.data.preferredLanguage !== undefined) updateData.preferredLanguage = parsed.data.preferredLanguage; - const [user] = await db - .update(usersTable) - .set(updateData) - .where(eq(usersTable.id, params.data.id)) - .returning(); + const userId = params.data.id; + + let user: typeof usersTable.$inferSelect | undefined; + if (Object.keys(updateData).length > 0) { + [user] = await db + .update(usersTable) + .set(updateData) + .where(eq(usersTable.id, userId)) + .returning(); + } else { + [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId)); + } if (!user) { res.status(404).json({ error: "User not found" }); return; } + if (parsed.data.groupIds !== undefined) { + await db.delete(userGroupsTable).where(eq(userGroupsTable.userId, userId)); + if (parsed.data.groupIds.length > 0) { + // Validate group ids exist + const found = await db + .select({ id: groupsTable.id }) + .from(groupsTable) + .where(inArray(groupsTable.id, parsed.data.groupIds)); + const validIds = new Set(found.map((g) => g.id)); + const valid = parsed.data.groupIds.filter((id) => validIds.has(id)); + if (valid.length > 0) { + await db + .insert(userGroupsTable) + .values(valid.map((groupId) => ({ userId, groupId }))) + .onConflictDoNothing(); + } + } + } + const roles = await getUserRoles(user.id); - res.json(buildUserProfile(user, roles)); + const groups = await getGroupsForUser(user.id); + res.json(buildUserProfile(user, roles, groups)); }); router.post("/users/:id/roles", requireAdmin, async (req, res): Promise => { @@ -187,7 +353,8 @@ router.post("/users/:id/roles", requireAdmin, async (req, res): Promise => .onConflictDoNothing(); const roles = await getUserRoles(user.id); - res.json(buildUserProfile(user, roles)); + const groups = await getGroupsForUser(user.id); + res.json(buildUserProfile(user, roles, groups)); }); router.delete( @@ -231,7 +398,8 @@ router.delete( } const roles = await getUserRoles(user.id); - res.json(buildUserProfile(user, roles)); + const groups = await getGroupsForUser(user.id); + res.json(buildUserProfile(user, roles, groups)); }, ); diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index 70c131e1a6161e00094d46744458c533d5f8edfa..0e6b3fdd39ddcf1bd7cb5a594d7c6434756866b8 100644 GIT binary patch delta 19338 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 delta 19368 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ΝYowvalN4)QHp+x2=ji*&JP3VKhpSTk@Lo_(SUwg2nnWP*u zx^w?FyO`K9%FVBKaamEfzS_q1*~R67pcjoh&tyv4L|g_>U=JCK%(wd>v%v<$@=PhCm@aKTwZY9%1*aZ=DHGCm(TU51ILWsm5j`r~<*~*>S{~ zugL3zxhDVu--0K>ydV}oM1y#AZYy-1wlXUP} zSq_Cgq&YtS9?5OlH?!bN2RE+L&;`GS8C)96D_ub^$;5~t-7T$HldSYP#cRe;4NC#n z4wC);mYUmbOnr~`Ndm*N3q7IP$sNkY8oasl)Fd@S<(aV0=2;4WLBR3rIspg6DuXiM zK8iz97K9E?WB|M>OmPBR0XI_|LZC37AIN|v{9Ye=j!5tqEr%)21XZxeD}Bo|wG^&k zF;lu5YHFzrU$6Q$^RyHkO`vjGLOiw|T>E^+5=+tobkFL)H1yf^qzkYpGaOx&9 zr=+EG+3x1EoiWRK()vV#?bZVgb;Fco+Rbal3WXjEC#J%&ChQ59F zg7Lon)AsQ1|75X}mGIeTdppr@8J#40o=O=fjlOVgCg_?xbZ`6#Pti4{sUgP-7}SU? zxObGHM_?BY0LzrE>a*idrW|y(sw*^)Fih+5_JtKin=v37z;uFb1^|Shb^+WBzyI(A zGADpa1xuBJ%}j6_ro$3dv|Keb^8~!V^B6Uc`Ow2`Q*(DND2%03n7s!8xoxFX^47`DTC|@%bk#(il1%dSm?a;9ZwdrRt zi;m@jNBtK73=c5XIssSNJ2;&!A9?;|Q)4p)WCp;_TzGm1t9F=5rt=YgZ$i(J;{hz| z$4#A4^N;q)^=*bhuH#DgCOiowPG@6&4CKim^EFRG8z<6&aNVreolwOB{)Wg!tdb{G z^P4oyMuz#JIhHLWWhTnl#Xca=ceT8qxQXdlcI%$;)0N9}O_f~qmdtG?QsUWjafvxi zYlT$3ot2HY4{2E)f!l4{=c&_4-%QN&*LE9sPmKjq=cTD{W=EDTQ6&?wQ5$jui$7Mn zPOaG&LM^Y#zB<*4rf9l>KVYz-Yu zC&3!PtxfQ1G(0w_;1UUt&$y}=kjq7m3#Wburzw2PWW>Z46+$zy2`M%Ykxo&&b<**t z5*i1`+nYEc=9F~1e02kMW_2dRl=cVb?ecGm%ZP!BO}SZ?N*Ys(-`OAd*rxq0PL44h zI7+*XI|?MM9m%o7YcJaE3*n7oBLz=c!xiKaUrPU z04o6wtQtgN*g811MtDQQsw%;sQPDL#qN>Kk4MQfx4FEG$-wAuzltasZh|60Rs*o!0 z%)faqxy@opq%9C_lU+9OhUZxelkuf%oir1NvaA%54>zA4JD<^&k}Gb0D9ifhv8d?W OvlP2>C-x(IUH=Q>LW@%X diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index b81ad8d8..7819fd16 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -348,9 +348,48 @@ "apps": "التطبيقات", "services": "الخدمات", "users": "المستخدمين", + "groups": "المجموعات", + "userManagement": "إدارة المستخدمين", "settings": "الإعدادات", "menu": "القائمة" }, + "users": { + "searchPlaceholder": "ابحث باسم المستخدم أو البريد...", + "allGroups": "كل المجموعات", + "allStatuses": "كل الحالات", + "statusActive": "نشط", + "statusInactive": "موقوف", + "showing": "عرض {{count}} من أصل {{total}}", + "empty": "لا يوجد مستخدمون يطابقون الفلاتر.", + "editGroups": "تعديل المجموعات", + "editUserGroups": "تعديل مجموعات {{username}}", + "col": { + "username": "اسم المستخدم", + "email": "البريد", + "createdAt": "تاريخ الإنشاء", + "groups": "المجموعات", + "actions": "إجراءات" + } + }, + "groups": { + "addGroup": "إضافة مجموعة", + "editGroup": "تعديل المجموعة", + "name": "الاسم", + "descriptionAr": "الوصف (عربي)", + "descriptionEn": "الوصف (إنجليزي)", + "system": "نظامية", + "empty": "لا توجد مجموعات بعد.", + "members": "{{count}} عضو", + "appsCount": "{{count}} تطبيق", + "rolesCount": "{{count}} دور", + "appsHint": "أعضاء هذه المجموعة سيرون هذه التطبيقات.", + "usersHint": "أعضاء هذه المجموعة.", + "tab": { + "info": "المعلومات", + "apps": "التطبيقات", + "users": "المستخدمون" + } + }, "dashboardSoon": "إحصائيات اللوحة قريباً.", "dashboard": { "totalApps": "إجمالي التطبيقات", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index d5f067d2..43ec01bd 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -345,9 +345,48 @@ "apps": "Apps", "services": "Services", "users": "Users", + "groups": "Groups", + "userManagement": "User Management", "settings": "Settings", "menu": "Menu" }, + "users": { + "searchPlaceholder": "Search by username or email...", + "allGroups": "All groups", + "allStatuses": "All statuses", + "statusActive": "Active", + "statusInactive": "Inactive", + "showing": "Showing {{count}} of {{total}}", + "empty": "No users match your filters.", + "editGroups": "Edit groups", + "editUserGroups": "Edit groups for {{username}}", + "col": { + "username": "Username", + "email": "Email", + "createdAt": "Created", + "groups": "Groups", + "actions": "Actions" + } + }, + "groups": { + "addGroup": "Add Group", + "editGroup": "Edit Group", + "name": "Name", + "descriptionAr": "Description (Arabic)", + "descriptionEn": "Description (English)", + "system": "System", + "empty": "No groups yet.", + "members": "{{count}} members", + "appsCount": "{{count}} apps", + "rolesCount": "{{count}} roles", + "appsHint": "Members of this group will see these apps.", + "usersHint": "Members of this group.", + "tab": { + "info": "Info", + "apps": "Apps", + "users": "Users" + } + }, "dashboardSoon": "Dashboard widgets coming soon.", "dashboard": { "totalApps": "Total Apps", diff --git a/artifacts/teaboy-os/src/pages/admin.tsx b/artifacts/teaboy-os/src/pages/admin.tsx index b4169dda..061fa9c0 100644 --- a/artifacts/teaboy-os/src/pages/admin.tsx +++ b/artifacts/teaboy-os/src/pages/admin.tsx @@ -31,6 +31,13 @@ import { getGetAdminAppOpensByUserQueryKey, getAdminAppOpensByUser, useAdminIssueResetLink, + useListGroups, + getListGroupsQueryKey, + useGetGroup, + getGetGroupQueryKey, + useCreateGroup, + useUpdateGroup, + useDeleteGroup, } from "@workspace/api-client-react"; import type { App, @@ -42,6 +49,7 @@ import type { AdminAppOpensByUser, GetAdminStatsQueryError, GetAdminStatsParams, + Group, } from "@workspace/api-client-react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/contexts/AuthContext"; @@ -57,7 +65,7 @@ import { useToast } from "@/hooks/use-toast"; import { cn } from "@/lib/utils"; import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format"; -type AdminSection = "dashboard" | "apps" | "services" | "users" | "settings"; +type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "settings"; function LeaderboardAvatar({ src, @@ -129,7 +137,7 @@ export default function AdminPage() { const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } }); const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } }); - const { data: users } = useListUsers({ query: { queryKey: getListUsersQueryKey() } }); + const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } }); 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"); @@ -236,39 +244,97 @@ export default function AdminPage() { setHighlightedUserId(userId); }; - const navItems: { key: AdminSection; label: string; Icon: typeof LayoutDashboard }[] = [ + type NavLeaf = { key: AdminSection; label: string; Icon: typeof LayoutDashboard }; + type NavGroup = { groupKey: string; label: string; Icon: typeof LayoutDashboard; children: NavLeaf[] }; + type NavEntry = NavLeaf | NavGroup; + const navItems: NavEntry[] = [ { key: "dashboard", label: t("admin.nav.dashboard"), Icon: LayoutDashboard }, { key: "apps", label: t("admin.nav.apps"), Icon: Grid2X2 }, { key: "services", label: t("admin.nav.services"), Icon: Coffee }, - { key: "users", label: t("admin.nav.users"), Icon: UsersIcon }, + { + groupKey: "user-management", + label: t("admin.nav.userManagement"), + Icon: UsersIcon, + children: [ + { key: "users", label: t("admin.nav.users"), Icon: UsersIcon }, + { key: "groups", label: t("admin.nav.groups"), Icon: Shield }, + ], + }, { key: "settings", label: t("admin.nav.settings"), Icon: Settings }, ]; - const renderNavList = (onSelect?: () => void) => ( - - ); + // Sync section to URL hash so deep links work + useEffect(() => { + if (typeof window === "undefined") return; + const hash = window.location.hash.replace(/^#/, ""); + const sectionMatch = hash.match(/section=([a-z-]+)/); + if (sectionMatch) { + const s = sectionMatch[1] as AdminSection; + if (["dashboard", "apps", "services", "users", "groups", "settings"].includes(s)) { + setSection(s); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { + if (typeof window === "undefined") return; + const newHash = `#section=${section}`; + if (window.location.hash !== newHash) { + window.history.replaceState(null, "", newHash); + } + }, [section]); + + const renderNavList = (onSelect?: () => void) => { + const renderLeaf = (leaf: NavLeaf, indent = false) => { + const active = section === leaf.key; + const Icon = leaf.Icon; + return ( + + ); + }; + return ( + + ); + }; const emptyAppForm: AppForm = { nameAr: "", nameEn: "", slug: "", iconName: "Grid2X2", route: "/", color: "#6366f1", sortOrder: 0 }; const emptyServiceForm: ServiceForm = { nameAr: "", nameEn: "", descriptionAr: "", descriptionEn: "", price: "0.00", imageUrl: "", isAvailable: true }; @@ -671,126 +737,31 @@ export default function AdminPage() { )} {section === "users" && ( -
- - {users?.map((u) => ( -
{ - if (node) userRowRefs.current.set(u.id, node); - else userRowRefs.current.delete(u.id); - }} - className={cn( - "glass-panel rounded-2xl p-4 flex items-center justify-between gap-3 transition-all", - highlightedUserId === u.id && "ring-2 ring-primary shadow-lg shadow-primary/20", - )} - > -
-
{u.username}
-
{u.email}
-
- {u.roles?.map((role) => ( - - {role} - - ))} -
-
-
- - { - updateUser.mutate( - { id: u.id, data: { isActive: v } }, - { onSuccess: () => queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() }) }, - ); - }} - /> - - {u.id !== user?.id && ( - - )} -
-
- ))} -
+ + issueResetLink.mutate( + { id: uid }, + { + onSuccess: (resp) => + setResetLinkUser({ + username, + url: resp.resetUrl, + expiresAt: + typeof resp.expiresAt === "string" + ? resp.expiresAt + : new Date(resp.expiresAt).toISOString(), + }), + onError: () => + toast({ title: t("common.error"), variant: "destructive" }), + }, + ) + } + /> )} + {section === "groups" && } {section === "settings" && (
@@ -1968,3 +1939,611 @@ function UserOpensDrillIn({ ); } + +// ===== Users Management Panel ===== +type UserSortKey = "username" | "email" | "createdAt"; + +function UsersPanel({ + currentUserId, + highlightedUserId, + userRowRefs, + onIssueResetLink, +}: { + currentUserId: number; + highlightedUserId: number | null; + userRowRefs: React.MutableRefObject>; + onIssueResetLink: (userId: number, username: string) => void; +}) { + const { t } = useTranslation(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [search, setSearch] = useState(""); + const [groupFilter, setGroupFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all"); + const [sortKey, setSortKey] = useState("username"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const [editingUser, setEditingUser] = useState(null); + const [newUserOpen, setNewUserOpen] = useState(false); + const [newUserForm, setNewUserForm] = useState({ username: "", email: "", password: "" }); + + const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } }); + const { data: groups } = useListGroups(undefined, { query: { queryKey: getListGroupsQueryKey() } }); + const createUser = useCreateUser(); + const updateUser = useUpdateUser(); + const deleteUser = useDeleteUser(); + const addUserRole = useAddUserRole(); + const removeUserRole = useRemoveUserRole(); + + const filtered = (users ?? []) + .filter((u) => { + if (search) { + const q = search.toLowerCase(); + if (!u.username.toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false; + } + if (groupFilter !== "all") { + if (!u.groups?.some((g) => g.id === groupFilter)) return false; + } + if (statusFilter === "active" && !u.isActive) return false; + if (statusFilter === "inactive" && u.isActive) return false; + return true; + }) + .sort((a, b) => { + const av = sortKey === "createdAt" ? a.createdAt : sortKey === "email" ? a.email : a.username; + const bv = sortKey === "createdAt" ? b.createdAt : sortKey === "email" ? b.email : b.username; + const cmp = String(av).localeCompare(String(bv)); + return sortDir === "asc" ? cmp : -cmp; + }); + + const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() }); + + return ( +
+
+
+ setSearch(e.target.value)} + className="bg-white/70 border-slate-200 flex-1 min-w-[180px]" + data-testid="user-search" + /> + + + +
+
+ {t("admin.users.showing", { count: filtered.length, total: users?.length ?? 0 })} +
+
+ + {/* Table (desktop) / cards (mobile) */} +
+
+ {(["username", "email", "createdAt"] as UserSortKey[]).map((k) => ( + + ))} +
{t("admin.users.col.groups")}
+
{t("admin.users.col.actions")}
+
+
+ {filtered.map((u) => ( +
{ + if (node) userRowRefs.current.set(u.id, node); + else userRowRefs.current.delete(u.id); + }} + className={cn( + "grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_1fr_auto] gap-2 md:gap-3 px-4 py-3 items-center transition-all", + highlightedUserId === u.id && "bg-primary/10 ring-2 ring-primary", + )} + data-testid={`user-row-${u.id}`} + > +
+ {u.username} + {!u.isActive && ( + + {t("admin.users.statusInactive")} + + )} +
+
{u.email}
+
+ {u.createdAt ? new Date(u.createdAt).toLocaleDateString() : "—"} +
+
+ {u.groups?.length ? u.groups.map((g) => ( + + {g.name} + + )) : } +
+
+ + updateUser.mutate( + { id: u.id, data: { isActive: v } }, + { onSuccess: invalidateUsers }, + ) + } + /> + + + + {u.id !== currentUserId && ( + + )} +
+
+ ))} + {filtered.length === 0 && ( +
+ {t("admin.users.empty")} +
+ )} +
+
+ + {newUserOpen && ( +
+
+

{t("admin.addUser")}

+
+ + setNewUserForm({ ...newUserForm, username: e.target.value })} className="bg-white/70 border-slate-200" /> +
+
+ + setNewUserForm({ ...newUserForm, email: e.target.value })} className="bg-white/70 border-slate-200" /> +
+
+ + setNewUserForm({ ...newUserForm, password: e.target.value })} className="bg-white/70 border-slate-200" /> +
+
+ + +
+
+
+ )} + + {editingUser && ( + setEditingUser(null)} + onSaved={() => { invalidateUsers(); setEditingUser(null); }} + /> + )} +
+ ); +} + +function UserGroupsEditor({ + user, + groups, + onClose, + onSaved, +}: { + user: UserProfile; + groups: Group[]; + onClose: () => void; + onSaved: () => void; +}) { + const { t } = useTranslation(); + const { toast } = useToast(); + const updateUser = useUpdateUser(); + const [selected, setSelected] = useState>(new Set(user.groups?.map((g) => g.id) ?? [])); + + const toggle = (id: number) => { + const next = new Set(selected); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelected(next); + }; + + return ( +
+
+

{t("admin.users.editUserGroups", { username: user.username })}

+
+ {groups.map((g) => ( + + ))} + {groups.length === 0 &&

{t("admin.groups.empty")}

} +
+
+ + +
+
+
+ ); +} + +// ===== Groups Management Panel ===== +function GroupsPanel() { + const { t } = useTranslation(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const { data: groups } = useListGroups(undefined, { query: { queryKey: getListGroupsQueryKey() } }); + const createGroup = useCreateGroup(); + const deleteGroup = useDeleteGroup(); + const [editingGroupId, setEditingGroupId] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: getListGroupsQueryKey() }); + + return ( +
+
+ +
+ +
+ {groups?.map((g) => ( +
+
+
+
+ + {g.name} + {g.isSystem && ( + + {t("admin.groups.system")} + + )} +
+ {(g.descriptionAr || g.descriptionEn) && ( +

+ {g.descriptionEn || g.descriptionAr} +

+ )} +
+
+ + {!g.isSystem && ( + + )} +
+
+
+ {t("admin.groups.members", { count: g.memberCount })} + + {t("admin.groups.appsCount", { count: g.appCount })} + + {t("admin.groups.rolesCount", { count: g.roleCount })} +
+
+ ))} + {(!groups || groups.length === 0) && ( +
+ {t("admin.groups.empty")} +
+ )} +
+ + {createOpen && ( +
+
+

{t("admin.groups.addGroup")}

+
+ + setCreateForm({ ...createForm, name: e.target.value })} className="bg-white/70 border-slate-200" data-testid="group-name-input" /> +
+
+ + setCreateForm({ ...createForm, descriptionAr: e.target.value })} className="bg-white/70 border-slate-200" dir="rtl" /> +
+
+ + setCreateForm({ ...createForm, descriptionEn: e.target.value })} className="bg-white/70 border-slate-200" dir="ltr" /> +
+
+ + +
+
+
+ )} + + {editingGroupId != null && ( + setEditingGroupId(null)} onSaved={() => { invalidate(); setEditingGroupId(null); }} /> + )} +
+ ); +} + +function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onClose: () => void; onSaved: () => void }) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const { toast } = useToast(); + const { data: group } = useGetGroup(groupId, { query: { queryKey: getGetGroupQueryKey(groupId) } }); + const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } }); + const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } }); + const updateGroup = useUpdateGroup(); + + const [name, setName] = useState(""); + const [descriptionAr, setDescriptionAr] = useState(""); + const [descriptionEn, setDescriptionEn] = useState(""); + const [appIds, setAppIds] = useState>(new Set()); + const [userIds, setUserIds] = useState>(new Set()); + const [tab, setTab] = useState<"info" | "apps" | "users">("info"); + + useEffect(() => { + if (group) { + setName(group.name); + setDescriptionAr(group.descriptionAr ?? ""); + setDescriptionEn(group.descriptionEn ?? ""); + setAppIds(new Set(group.appIds)); + setUserIds(new Set(group.userIds)); + } + }, [group]); + + const toggle = (set: Set, id: number, setter: (s: Set) => void) => { + const next = new Set(set); + if (next.has(id)) next.delete(id); + else next.add(id); + setter(next); + }; + + if (!group) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+
+

+ + {t("admin.groups.editGroup")}: {group.name} +

+ {group.isSystem && ( + + {t("admin.groups.system")} + + )} +
+ +
+ {(["info", "apps", "users"] as const).map((k) => ( + + ))} +
+ + {tab === "info" && ( +
+
+ + setName(e.target.value)} disabled={group.isSystem} className="bg-white/70 border-slate-200" /> +
+
+ + setDescriptionAr(e.target.value)} className="bg-white/70 border-slate-200" dir="rtl" /> +
+
+ + setDescriptionEn(e.target.value)} className="bg-white/70 border-slate-200" dir="ltr" /> +
+
+ )} + + {tab === "apps" && ( +
+

{t("admin.groups.appsHint")}

+ {apps?.map((a) => ( + + ))} +
+ )} + + {tab === "users" && ( +
+

{t("admin.groups.usersHint")}

+ {users?.map((u) => ( + + ))} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 1817d6d1..29bfeeeb 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -91,6 +91,15 @@ export interface UpdateClockHour12Body { clockHour12: boolean | null; } +export interface GroupSummary { + id: number; + name: string; + /** @nullable */ + descriptionAr?: string | null; + /** @nullable */ + descriptionEn?: string | null; +} + export interface AuthUser { id: number; username: string; @@ -107,6 +116,7 @@ export interface AuthUser { avatarUrl?: string | null; isActive: boolean; roles: string[]; + groups: GroupSummary[]; createdAt: string; } @@ -126,6 +136,7 @@ export interface UserProfile { avatarUrl?: string | null; isActive: boolean; roles: string[]; + groups: GroupSummary[]; createdAt: string; } @@ -136,6 +147,8 @@ export interface UpdateUserBody { displayNameEn?: string | null; isActive?: boolean; preferredLanguage?: string; + /** If provided, replaces the user's group memberships */ + groupIds?: number[]; } export interface AddUserRoleBody { @@ -143,6 +156,58 @@ export interface AddUserRoleBody { roleName: string; } +export interface Group { + id: number; + name: string; + /** @nullable */ + descriptionAr?: string | null; + /** @nullable */ + descriptionEn?: string | null; + isSystem: boolean; + memberCount: number; + appCount: number; + roleCount: number; + createdAt: string; +} + +export interface GroupDetail { + id: number; + name: string; + /** @nullable */ + descriptionAr?: string | null; + /** @nullable */ + descriptionEn?: string | null; + isSystem: boolean; + appIds: number[]; + roleIds: number[]; + userIds: number[]; + createdAt: string; +} + +export interface CreateGroupBody { + /** @minLength 1 */ + name: string; + /** @nullable */ + descriptionAr?: string | null; + /** @nullable */ + descriptionEn?: string | null; + appIds?: number[]; + roleIds?: number[]; + userIds?: number[]; +} + +export interface UpdateGroupBody { + /** @minLength 1 */ + name?: string; + /** @nullable */ + descriptionAr?: string | null; + /** @nullable */ + descriptionEn?: string | null; + appIds?: number[]; + roleIds?: number[]; + userIds?: number[]; +} + export interface App { id: number; slug: string; @@ -651,6 +716,33 @@ automatic behavior of promoting the longest-tenured member. successorId?: number | null; }; +export type ListUsersParams = { + /** + * Free-text search across username, email, and display names + */ + q?: string; + /** + * Filter to users that belong to the given group + */ + groupId?: number; + /** + * Filter by active/disabled state + */ + status?: ListUsersStatus; +}; + +export type ListUsersStatus = + (typeof ListUsersStatus)[keyof typeof ListUsersStatus]; + +export const ListUsersStatus = { + active: "active", + disabled: "disabled", +} as const; + +export type ListGroupsParams = { + q?: string; +}; + export type GetAdminStatsParams = { /** * Time range for trend stats. Use "custom" with from/to. diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index f7b2a1e7..6dc8cb22 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -29,6 +29,7 @@ import type { ConversationWithDetails, CreateAppBody, CreateConversationBody, + CreateGroupBody, CreateServiceBody, CreateServiceOrderBody, ErrorResponse, @@ -37,9 +38,13 @@ import type { GetAdminAppOpensByAppParams, GetAdminAppOpensByUserParams, GetAdminStatsParams, + Group, + GroupDetail, HealthStatus, HomeStats, LeaveConversationBody, + ListGroupsParams, + ListUsersParams, LoginBody, MessageWithSender, Notification, @@ -58,6 +63,7 @@ import type { UpdateClockStyleBody, UpdateConversationBody, UpdateConversationStateBody, + UpdateGroupBody, UpdateLanguageBody, UpdateMyAppOrderBody, UpdateServiceBody, @@ -3981,37 +3987,57 @@ export const useMarkAllNotificationsRead = < /** * @summary List all users (admin) */ -export const getListUsersUrl = () => { - return `/api/users`; +export const getListUsersUrl = (params?: ListUsersParams) => { + 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?${stringifiedParams}` + : `/api/users`; }; export const listUsers = async ( + params?: ListUsersParams, options?: RequestInit, ): Promise => { - return customFetch(getListUsersUrl(), { + return customFetch(getListUsersUrl(params), { ...options, method: "GET", }); }; -export const getListUsersQueryKey = () => { - return [`/api/users`] as const; +export const getListUsersQueryKey = (params?: ListUsersParams) => { + return [`/api/users`, ...(params ? [params] : [])] as const; }; export const getListUsersQueryOptions = < TData = Awaited>, TError = ErrorType, ->(options?: { - query?: UseQueryOptions>, TError, TData>; - request?: SecondParameter; -}) => { +>( + params?: ListUsersParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { const { query: queryOptions, request: requestOptions } = options ?? {}; - const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey(); + const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey(params); const queryFn: QueryFunction>> = ({ signal, - }) => listUsers({ signal, ...requestOptions }); + }) => listUsers(params, { signal, ...requestOptions }); return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< Awaited>, @@ -4032,11 +4058,18 @@ export type ListUsersQueryError = ErrorType; export function useListUsers< TData = Awaited>, TError = ErrorType, ->(options?: { - query?: UseQueryOptions>, TError, TData>; - request?: SecondParameter; -}): UseQueryResult & { queryKey: QueryKey } { - const queryOptions = getListUsersQueryOptions(options); +>( + params?: ListUsersParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListUsersQueryOptions(params, options); const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey; @@ -4639,6 +4672,442 @@ export const useRemoveUserRole = < return useMutation(getRemoveUserRoleMutationOptions(options)); }; +/** + * @summary List groups (admin) + */ +export const getListGroupsUrl = (params?: ListGroupsParams) => { + 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?${stringifiedParams}` + : `/api/groups`; +}; + +export const listGroups = async ( + params?: ListGroupsParams, + options?: RequestInit, +): Promise => { + return customFetch(getListGroupsUrl(params), { + ...options, + method: "GET", + }); +}; + +export const getListGroupsQueryKey = (params?: ListGroupsParams) => { + return [`/api/groups`, ...(params ? [params] : [])] as const; +}; + +export const getListGroupsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + params?: ListGroupsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListGroupsQueryKey(params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listGroups(params, { signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListGroupsQueryResult = NonNullable< + Awaited> +>; +export type ListGroupsQueryError = ErrorType; + +/** + * @summary List groups (admin) + */ + +export function useListGroups< + TData = Awaited>, + TError = ErrorType, +>( + params?: ListGroupsParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListGroupsQueryOptions(params, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Create group (admin) + */ +export const getCreateGroupUrl = () => { + return `/api/groups`; +}; + +export const createGroup = async ( + createGroupBody: CreateGroupBody, + options?: RequestInit, +): Promise => { + return customFetch(getCreateGroupUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(createGroupBody), + }); +}; + +export const getCreateGroupMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationKey = ["createGroup"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return createGroup(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type CreateGroupMutationResult = NonNullable< + Awaited> +>; +export type CreateGroupMutationBody = BodyType; +export type CreateGroupMutationError = ErrorType; + +/** + * @summary Create group (admin) + */ +export const useCreateGroup = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getCreateGroupMutationOptions(options)); +}; + +/** + * @summary Get group with members and apps (admin) + */ +export const getGetGroupUrl = (id: number) => { + return `/api/groups/${id}`; +}; + +export const getGroup = async ( + id: number, + options?: RequestInit, +): Promise => { + return customFetch(getGetGroupUrl(id), { + ...options, + method: "GET", + }); +}; + +export const getGetGroupQueryKey = (id: number) => { + return [`/api/groups/${id}`] as const; +}; + +export const getGetGroupQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetGroupQueryKey(id); + + const queryFn: QueryFunction>> = ({ + signal, + }) => getGroup(id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: QueryKey; + }; +}; + +export type GetGroupQueryResult = NonNullable< + Awaited> +>; +export type GetGroupQueryError = ErrorType; + +/** + * @summary Get group with members and apps (admin) + */ + +export function useGetGroup< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetGroupQueryOptions(id, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Update group (admin) + */ +export const getUpdateGroupUrl = (id: number) => { + return `/api/groups/${id}`; +}; + +export const updateGroup = async ( + id: number, + updateGroupBody: UpdateGroupBody, + options?: RequestInit, +): Promise => { + return customFetch(getUpdateGroupUrl(id), { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(updateGroupBody), + }); +}; + +export const getUpdateGroupMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + const mutationKey = ["updateGroup"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return updateGroup(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type UpdateGroupMutationResult = NonNullable< + Awaited> +>; +export type UpdateGroupMutationBody = BodyType; +export type UpdateGroupMutationError = ErrorType; + +/** + * @summary Update group (admin) + */ +export const useUpdateGroup = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + return useMutation(getUpdateGroupMutationOptions(options)); +}; + +/** + * @summary Delete group (admin) + */ +export const getDeleteGroupUrl = (id: number) => { + return `/api/groups/${id}`; +}; + +export const deleteGroup = async ( + id: number, + options?: RequestInit, +): Promise => { + return customFetch(getDeleteGroupUrl(id), { + ...options, + method: "DELETE", + }); +}; + +export const getDeleteGroupMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext +> => { + const mutationKey = ["deleteGroup"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number } + > = (props) => { + const { id } = props ?? {}; + + return deleteGroup(id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DeleteGroupMutationResult = NonNullable< + Awaited> +>; + +export type DeleteGroupMutationError = ErrorType; + +/** + * @summary Delete group (admin) + */ +export const useDeleteGroup = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number }, + TContext +> => { + return useMutation(getDeleteGroupMutationOptions(options)); +}; + /** * @summary Get home screen stats */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 94aa6547..ec6d82ef 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -1038,6 +1038,26 @@ paths: operationId: listUsers tags: [users] summary: List all users (admin) + parameters: + - in: query + name: q + required: false + schema: + type: string + description: Free-text search across username, email, and display names + - in: query + name: groupId + required: false + schema: + type: integer + description: Filter to users that belong to the given group + - in: query + name: status + required: false + schema: + type: string + enum: ["active", "disabled"] + description: Filter by active/disabled state responses: "200": description: Users @@ -1223,6 +1243,114 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + # Groups (admin) + /groups: + get: + operationId: listGroups + tags: [users] + summary: List groups (admin) + parameters: + - in: query + name: q + required: false + schema: + type: string + responses: + "200": + description: Groups + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Group" + post: + operationId: createGroup + tags: [users] + summary: Create group (admin) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateGroupBody" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/Group" + + /groups/{id}: + get: + operationId: getGroup + tags: [users] + summary: Get group with members and apps (admin) + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Group detail + content: + application/json: + schema: + $ref: "#/components/schemas/GroupDetail" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + patch: + operationId: updateGroup + tags: [users] + summary: Update group (admin) + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateGroupBody" + responses: + "200": + description: Updated + content: + application/json: + schema: + $ref: "#/components/schemas/GroupDetail" + + delete: + operationId: deleteGroup + tags: [users] + summary: Delete group (admin) + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "204": + description: Deleted + "400": + description: Cannot delete a system group + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + # Stats /stats/home: get: @@ -1586,6 +1714,10 @@ components: type: array items: type: string + groups: + type: array + items: + $ref: "#/components/schemas/GroupSummary" createdAt: type: string format: date-time @@ -1596,6 +1728,7 @@ components: - preferredLanguage - isActive - roles + - groups - createdAt UserProfile: @@ -1627,6 +1760,10 @@ components: type: array items: type: string + groups: + type: array + items: + $ref: "#/components/schemas/GroupSummary" createdAt: type: string format: date-time @@ -1637,6 +1774,7 @@ components: - preferredLanguage - isActive - roles + - groups - createdAt UpdateUserBody: @@ -1650,6 +1788,11 @@ components: type: boolean preferredLanguage: type: string + groupIds: + type: array + items: + type: integer + description: If provided, replaces the user's group memberships AddUserRoleBody: type: object @@ -1660,6 +1803,137 @@ components: required: - roleName + GroupSummary: + type: object + properties: + id: + type: integer + name: + type: string + descriptionAr: + type: ["string", "null"] + descriptionEn: + type: ["string", "null"] + required: + - id + - name + + Group: + type: object + properties: + id: + type: integer + name: + type: string + descriptionAr: + type: ["string", "null"] + descriptionEn: + type: ["string", "null"] + isSystem: + type: boolean + memberCount: + type: integer + appCount: + type: integer + roleCount: + type: integer + createdAt: + type: string + format: date-time + required: + - id + - name + - isSystem + - memberCount + - appCount + - roleCount + - createdAt + + GroupDetail: + type: object + properties: + id: + type: integer + name: + type: string + descriptionAr: + type: ["string", "null"] + descriptionEn: + type: ["string", "null"] + isSystem: + type: boolean + appIds: + type: array + items: + type: integer + roleIds: + type: array + items: + type: integer + userIds: + type: array + items: + type: integer + createdAt: + type: string + format: date-time + required: + - id + - name + - isSystem + - appIds + - roleIds + - userIds + - createdAt + + CreateGroupBody: + type: object + properties: + name: + type: string + minLength: 1 + descriptionAr: + type: ["string", "null"] + descriptionEn: + type: ["string", "null"] + appIds: + type: array + items: + type: integer + roleIds: + type: array + items: + type: integer + userIds: + type: array + items: + type: integer + required: + - name + + UpdateGroupBody: + type: object + properties: + name: + type: string + minLength: 1 + descriptionAr: + type: ["string", "null"] + descriptionEn: + type: ["string", "null"] + appIds: + type: array + items: + type: integer + roleIds: + type: array + items: + type: integer + userIds: + type: array + items: + type: integer + App: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index c4dcf05e..cd918d4e 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -55,6 +55,14 @@ export const LoginResponse = zod.object({ avatarUrl: zod.string().nullish(), isActive: zod.boolean(), roles: zod.array(zod.string()), + groups: zod.array( + zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + }), + ), createdAt: zod.coerce.date(), }); @@ -135,6 +143,14 @@ export const GetMeResponse = zod.object({ avatarUrl: zod.string().nullish(), isActive: zod.boolean(), roles: zod.array(zod.string()), + groups: zod.array( + zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + }), + ), createdAt: zod.coerce.date(), }); @@ -164,6 +180,14 @@ export const UpdateLanguageResponse = zod.object({ avatarUrl: zod.string().nullish(), isActive: zod.boolean(), roles: zod.array(zod.string()), + groups: zod.array( + zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + }), + ), createdAt: zod.coerce.date(), }); @@ -200,6 +224,14 @@ export const UpdateClockStyleResponse = zod.object({ avatarUrl: zod.string().nullish(), isActive: zod.boolean(), roles: zod.array(zod.string()), + groups: zod.array( + zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + }), + ), createdAt: zod.coerce.date(), }); @@ -234,6 +266,14 @@ export const UpdateClockHour12Response = zod.object({ avatarUrl: zod.string().nullish(), isActive: zod.boolean(), roles: zod.array(zod.string()), + groups: zod.array( + zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + }), + ), createdAt: zod.coerce.date(), }); @@ -1294,6 +1334,21 @@ export const MarkAllNotificationsReadResponse = zod.object({ /** * @summary List all users (admin) */ +export const ListUsersQueryParams = zod.object({ + q: zod.coerce + .string() + .optional() + .describe("Free-text search across username, email, and display names"), + groupId: zod.coerce + .number() + .optional() + .describe("Filter to users that belong to the given group"), + status: zod + .enum(["active", "disabled"]) + .optional() + .describe("Filter by active\/disabled state"), +}); + export const ListUsersResponseItem = zod.object({ id: zod.number(), username: zod.string(), @@ -1313,6 +1368,14 @@ export const ListUsersResponseItem = zod.object({ avatarUrl: zod.string().nullish(), isActive: zod.boolean(), roles: zod.array(zod.string()), + groups: zod.array( + zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + }), + ), createdAt: zod.coerce.date(), }); export const ListUsersResponse = zod.array(ListUsersResponseItem); @@ -1359,6 +1422,14 @@ export const GetUserResponse = zod.object({ avatarUrl: zod.string().nullish(), isActive: zod.boolean(), roles: zod.array(zod.string()), + groups: zod.array( + zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + }), + ), createdAt: zod.coerce.date(), }); @@ -1374,6 +1445,10 @@ export const UpdateUserBody = zod.object({ displayNameEn: zod.string().nullish(), isActive: zod.boolean().optional(), preferredLanguage: zod.string().optional(), + groupIds: zod + .array(zod.number()) + .optional() + .describe("If provided, replaces the user's group memberships"), }); export const UpdateUserResponse = zod.object({ @@ -1395,6 +1470,14 @@ export const UpdateUserResponse = zod.object({ avatarUrl: zod.string().nullish(), isActive: zod.boolean(), roles: zod.array(zod.string()), + groups: zod.array( + zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + }), + ), createdAt: zod.coerce.date(), }); @@ -1446,6 +1529,14 @@ export const AddUserRoleResponse = zod.object({ avatarUrl: zod.string().nullish(), isActive: zod.boolean(), roles: zod.array(zod.string()), + groups: zod.array( + zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + }), + ), createdAt: zod.coerce.date(), }); @@ -1476,9 +1567,104 @@ export const RemoveUserRoleResponse = zod.object({ avatarUrl: zod.string().nullish(), isActive: zod.boolean(), roles: zod.array(zod.string()), + groups: zod.array( + zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + }), + ), createdAt: zod.coerce.date(), }); +/** + * @summary List groups (admin) + */ +export const ListGroupsQueryParams = zod.object({ + q: zod.coerce.string().optional(), +}); + +export const ListGroupsResponseItem = zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + isSystem: zod.boolean(), + memberCount: zod.number(), + appCount: zod.number(), + roleCount: zod.number(), + createdAt: zod.coerce.date(), +}); +export const ListGroupsResponse = zod.array(ListGroupsResponseItem); + +/** + * @summary Create group (admin) + */ + +export const CreateGroupBody = zod.object({ + name: zod.string().min(1), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + appIds: zod.array(zod.number()).optional(), + roleIds: zod.array(zod.number()).optional(), + userIds: zod.array(zod.number()).optional(), +}); + +/** + * @summary Get group with members and apps (admin) + */ +export const GetGroupParams = zod.object({ + id: zod.coerce.number(), +}); + +export const GetGroupResponse = zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + isSystem: zod.boolean(), + appIds: zod.array(zod.number()), + roleIds: zod.array(zod.number()), + userIds: zod.array(zod.number()), + createdAt: zod.coerce.date(), +}); + +/** + * @summary Update group (admin) + */ +export const UpdateGroupParams = zod.object({ + id: zod.coerce.number(), +}); + +export const UpdateGroupBody = zod.object({ + name: zod.string().min(1).optional(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + appIds: zod.array(zod.number()).optional(), + roleIds: zod.array(zod.number()).optional(), + userIds: zod.array(zod.number()).optional(), +}); + +export const UpdateGroupResponse = zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), + isSystem: zod.boolean(), + appIds: zod.array(zod.number()), + roleIds: zod.array(zod.number()), + userIds: zod.array(zod.number()), + createdAt: zod.coerce.date(), +}); + +/** + * @summary Delete group (admin) + */ +export const DeleteGroupParams = zod.object({ + id: zod.coerce.number(), +}); + /** * @summary Get home screen stats */ diff --git a/lib/db/src/schema/groups.ts b/lib/db/src/schema/groups.ts new file mode 100644 index 00000000..3f6bc006 --- /dev/null +++ b/lib/db/src/schema/groups.ts @@ -0,0 +1,62 @@ +import { pgTable, text, serial, timestamp, integer, varchar, primaryKey } from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { z } from "zod/v4"; +import { usersTable } from "./users"; +import { rolesTable } from "./roles"; +import { appsTable } from "./apps"; + +export const groupsTable = pgTable("groups", { + id: serial("id").primaryKey(), + name: varchar("name", { length: 100 }).notNull().unique(), + descriptionAr: text("description_ar"), + descriptionEn: text("description_en"), + isSystem: integer("is_system").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const userGroupsTable = pgTable( + "user_groups", + { + userId: integer("user_id") + .notNull() + .references(() => usersTable.id, { onDelete: "cascade" }), + groupId: integer("group_id") + .notNull() + .references(() => groupsTable.id, { onDelete: "cascade" }), + assignedAt: timestamp("assigned_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [primaryKey({ columns: [t.userId, t.groupId] })], +); + +export const groupAppsTable = pgTable( + "group_apps", + { + groupId: integer("group_id") + .notNull() + .references(() => groupsTable.id, { onDelete: "cascade" }), + appId: integer("app_id") + .notNull() + .references(() => appsTable.id, { onDelete: "cascade" }), + }, + (t) => [primaryKey({ columns: [t.groupId, t.appId] })], +); + +export const groupRolesTable = pgTable( + "group_roles", + { + groupId: integer("group_id") + .notNull() + .references(() => groupsTable.id, { onDelete: "cascade" }), + roleId: integer("role_id") + .notNull() + .references(() => rolesTable.id, { onDelete: "cascade" }), + }, + (t) => [primaryKey({ columns: [t.groupId, t.roleId] })], +); + +export const insertGroupSchema = createInsertSchema(groupsTable).omit({ + id: true, + createdAt: true, +}); +export type InsertGroup = z.infer; +export type Group = typeof groupsTable.$inferSelect; diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index 8e8727f2..ead13d16 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -10,3 +10,4 @@ export * from "./user-app-orders"; export * from "./app-opens"; export * from "./password-reset-tokens"; export * from "./notes"; +export * from "./groups"; diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index ce93714e..7019c7a3 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -9,8 +9,12 @@ import { appPermissionsTable, serviceCategoriesTable, servicesTable, + groupsTable, + userGroupsTable, + groupAppsTable, + groupRolesTable, } from "@workspace/db"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import bcrypt from "bcryptjs"; async function main() { @@ -354,6 +358,111 @@ async function main() { await db.insert(servicesTable).values(services).onConflictDoNothing(); console.log("Services created"); + // ----- Groups: idempotent migration ----- + await db + .insert(groupsTable) + .values([ + { + name: "Admins", + descriptionAr: "مديرو النظام", + descriptionEn: "System administrators", + isSystem: 1, + }, + { + name: "TeaBoy", + descriptionAr: "فريق إعداد وتقديم الطلبات", + descriptionEn: "Team that prepares and delivers orders", + isSystem: 1, + }, + { + name: "Everyone", + descriptionAr: "كل المستخدمين", + descriptionEn: "All users", + isSystem: 1, + }, + ]) + .onConflictDoNothing(); + + const allGroups = await db.select().from(groupsTable); + const adminsGroup = allGroups.find((g) => g.name === "Admins"); + const teaboyGroup = allGroups.find((g) => g.name === "TeaBoy"); + const everyoneGroup = allGroups.find((g) => g.name === "Everyone"); + + const allRolesNow = await db.select().from(rolesTable); + const adminRoleNow = allRolesNow.find((r) => r.name === "admin"); + const orderReceiverNow = allRolesNow.find((r) => r.name === "order_receiver"); + + // Group → roles + if (adminsGroup && adminRoleNow) { + await db + .insert(groupRolesTable) + .values({ groupId: adminsGroup.id, roleId: adminRoleNow.id }) + .onConflictDoNothing(); + } + if (teaboyGroup && orderReceiverNow) { + await db + .insert(groupRolesTable) + .values({ groupId: teaboyGroup.id, roleId: orderReceiverNow.id }) + .onConflictDoNothing(); + } + + // Group → apps + const allAppsNow = await db.select().from(appsTable); + if (adminsGroup) { + await db + .insert(groupAppsTable) + .values(allAppsNow.map((a) => ({ groupId: adminsGroup.id, appId: a.id }))) + .onConflictDoNothing(); + } + // TeaBoy gets the services app (which is where receivers see incoming orders) + if (teaboyGroup) { + const servicesApp = allAppsNow.find((a) => a.slug === "services"); + if (servicesApp) { + await db + .insert(groupAppsTable) + .values({ groupId: teaboyGroup.id, appId: servicesApp.id }) + .onConflictDoNothing(); + } + } + + // Map existing users to groups idempotently + const allUsersNow = await db.select({ id: usersTable.id }).from(usersTable); + if (everyoneGroup && allUsersNow.length > 0) { + await db + .insert(userGroupsTable) + .values(allUsersNow.map((u) => ({ userId: u.id, groupId: everyoneGroup.id }))) + .onConflictDoNothing(); + } + + if (adminsGroup && adminRoleNow) { + const adminUserRows = await db + .select({ userId: userRolesTable.userId }) + .from(userRolesTable) + .where(eq(userRolesTable.roleId, adminRoleNow.id)); + if (adminUserRows.length > 0) { + await db + .insert(userGroupsTable) + .values(adminUserRows.map((r) => ({ userId: r.userId, groupId: adminsGroup.id }))) + .onConflictDoNothing(); + } + } + + if (teaboyGroup && orderReceiverNow) { + const receiverRows = await db + .select({ userId: userRolesTable.userId }) + .from(userRolesTable) + .where(eq(userRolesTable.roleId, orderReceiverNow.id)); + if (receiverRows.length > 0) { + await db + .insert(userGroupsTable) + .values(receiverRows.map((r) => ({ userId: r.userId, groupId: teaboyGroup.id }))) + .onConflictDoNothing(); + } + } + // Reference inArray to keep import live for future use + void inArray; + console.log("Groups created and existing users mapped"); + console.log("\n✅ Seeding complete!"); console.log("\nDemo accounts:"); console.log(" Admin: username=admin, password=admin123");