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 70c131e1..0e6b3fdd 100644 Binary files a/artifacts/teaboy-os/public/opengraph.jpg and b/artifacts/teaboy-os/public/opengraph.jpg differ 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");