Task #74: Add groups system + admin User Management UI

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)
This commit is contained in:
riyadhafraa
2026-04-22 08:28:31 +00:00
parent a772607636
commit f5273af19f
16 changed files with 2576 additions and 194 deletions
+20 -1
View File
@@ -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<typeof appsTable.$
).map((r) => 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),
);
}
+37 -7
View File
@@ -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<Array<{ id: number; name: string }>> {
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<void> => {
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<void> => {
@@ -131,7 +161,7 @@ router.post("/auth/login", async (req, res): Promise<void> => {
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<void> => {
@@ -310,7 +340,7 @@ router.get("/auth/me", requireAuth, async (req, res): Promise<void> => {
}
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<void> => {
@@ -327,7 +357,7 @@ router.patch("/auth/me/language", requireAuth, async (req, res): Promise<void> =
.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<void> => {
@@ -349,7 +379,7 @@ router.patch("/auth/me/clock-hour12", requireAuth, async (req, res): Promise<voi
}
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-style", requireAuth, async (req, res): Promise<void> => {
@@ -371,7 +401,7 @@ router.patch("/auth/me/clock-style", requireAuth, async (req, res): Promise<void
}
const roles = await getUserRoles(user.id);
res.json(buildAuthUser(user, roles));
res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id)));
});
export default router;
+313
View File
@@ -0,0 +1,313 @@
import { Router, type IRouter } from "express";
import { eq, ilike, or, inArray, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
groupsTable,
userGroupsTable,
groupAppsTable,
groupRolesTable,
usersTable,
appsTable,
rolesTable,
} from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
import {
CreateGroupBody,
UpdateGroupBody,
} from "@workspace/api-zod";
const router: IRouter = Router();
function serializeGroup(g: typeof groupsTable.$inferSelect) {
return {
id: g.id,
name: g.name,
descriptionAr: g.descriptionAr,
descriptionEn: g.descriptionEn,
isSystem: Boolean(g.isSystem),
createdAt: g.createdAt,
};
}
router.get("/groups", requireAdmin, async (req, res): Promise<void> => {
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<number>`count(*)::int`,
})
.from(userGroupsTable)
.where(inArray(userGroupsTable.groupId, groupIds))
.groupBy(userGroupsTable.groupId);
const appCounts = await db
.select({
groupId: groupAppsTable.groupId,
count: sql<number>`count(*)::int`,
})
.from(groupAppsTable)
.where(inArray(groupAppsTable.groupId, groupIds))
.groupBy(groupAppsTable.groupId);
const roleCounts = await db
.select({
groupId: groupRolesTable.groupId,
count: sql<number>`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<void> => {
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<void> => {
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<void> => {
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<typeof groupsTable.$inferInsert> = {};
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<void> => {
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;
+2
View File
@@ -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;
+189 -21
View File
@@ -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<void> =>
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<GroupSummary[]> {
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<void> => {
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<void> => {
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<void> => {
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<ReturnType<typeof eq>> = [];
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<typeof eq>);
}
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<typeof eq>);
}
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<number, string[]>();
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<number, GroupSummary[]>();
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<void> => {
@@ -114,7 +252,8 @@ router.get("/users/:id", requireAdmin, async (req, res): Promise<void> => {
}
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<void> => {
@@ -136,19 +275,46 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
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<void> => {
@@ -187,7 +353,8 @@ router.post("/users/:id/roles", requireAdmin, async (req, res): Promise<void> =>
.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));
},
);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+39
View File
@@ -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": "إجمالي التطبيقات",
+39
View File
@@ -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",
+727 -148
View File
@@ -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) => (
<nav className="flex flex-col gap-1">
{navItems.map(({ key, label, Icon }) => {
const active = section === key;
return (
<button
key={key}
onClick={() => {
setSection(key);
onSelect?.();
}}
className={cn(
"flex items-center gap-3 w-full px-3 py-2.5 rounded-xl text-sm transition-all text-start relative",
active
? "bg-primary text-primary-foreground font-semibold shadow-md shadow-primary/20"
: "text-foreground/70 hover:bg-slate-100 hover:text-foreground",
)}
>
<Icon size={18} className="shrink-0" />
<span className="truncate">{label}</span>
</button>
);
})}
</nav>
);
// 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 (
<button
key={leaf.key}
onClick={() => {
setSection(leaf.key);
onSelect?.();
}}
className={cn(
"flex items-center gap-3 w-full py-2.5 rounded-xl text-sm transition-all text-start relative",
indent ? "ps-9 pe-3" : "px-3",
active
? "bg-primary text-primary-foreground font-semibold shadow-md shadow-primary/20"
: "text-foreground/70 hover:bg-slate-100 hover:text-foreground",
)}
data-testid={`nav-${leaf.key}`}
>
<Icon size={16} className="shrink-0" />
<span className="truncate">{leaf.label}</span>
</button>
);
};
return (
<nav className="flex flex-col gap-1">
{navItems.map((entry) => {
if ("children" in entry) {
const Icon = entry.Icon;
const anyChildActive = entry.children.some((c) => c.key === section);
return (
<div key={entry.groupKey} className="flex flex-col gap-0.5">
<div
className={cn(
"flex items-center gap-3 w-full px-3 py-2 rounded-xl text-xs uppercase tracking-wider",
anyChildActive ? "text-primary" : "text-muted-foreground",
)}
>
<Icon size={14} className="shrink-0" />
<span className="truncate">{entry.label}</span>
</div>
{entry.children.map((leaf) => renderLeaf(leaf, true))}
</div>
);
}
return renderLeaf(entry, false);
})}
</nav>
);
};
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" && (
<div className="space-y-3">
<Button
size="sm"
className="w-full"
onClick={() => setNewUserForm({ username: "", email: "", password: "" })}
>
<Plus size={14} className="mr-1" />
{t("admin.addUser")}
</Button>
{users?.map((u) => (
<div
key={u.id}
ref={(node) => {
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",
)}
>
<div className="min-w-0 flex-1">
<div className="font-medium text-sm text-foreground">{u.username}</div>
<div className="text-xs text-muted-foreground">{u.email}</div>
<div className="flex flex-wrap gap-1 mt-1">
{u.roles?.map((role) => (
<span key={role} className="text-xs px-2 py-0.5 rounded-full bg-primary/20 text-primary">
{role}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground select-none">
<span>{t("admin.orderReceiverRole")}</span>
<Switch
checked={u.roles?.includes("order_receiver") ?? false}
onCheckedChange={(v) => {
const opts = {
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: getListUsersQueryKey(),
}),
onError: () =>
toast({
title: t("common.error"),
variant: "destructive",
}),
};
if (v) {
addUserRole.mutate(
{ id: u.id, data: { roleName: "order_receiver" } },
opts,
);
} else {
removeUserRole.mutate(
{ id: u.id, roleName: "order_receiver" },
opts,
);
}
}}
/>
</label>
<Switch
checked={u.isActive}
onCheckedChange={(v) => {
updateUser.mutate(
{ id: u.id, data: { isActive: v } },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() }) },
);
}}
/>
<button
onClick={() => {
issueResetLink.mutate(
{ id: u.id },
{
onSuccess: (resp) =>
setResetLinkUser({
username: u.username,
url: resp.resetUrl,
expiresAt:
typeof resp.expiresAt === "string"
? resp.expiresAt
: new Date(resp.expiresAt).toISOString(),
}),
onError: () =>
toast({
title: t("common.error"),
variant: "destructive",
}),
},
);
}}
disabled={issueResetLink.isPending}
title={t("admin.issueResetLink")}
className="p-1.5 hover:bg-primary/20 rounded-lg text-muted-foreground hover:text-primary disabled:opacity-50"
>
<KeyRound size={14} />
</button>
{u.id !== user?.id && (
<button
onClick={() => {
if (confirm(t("admin.deleteConfirm"))) {
deleteUser.mutate(
{ id: u.id },
{ onSuccess: () => queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() }) },
);
}
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
))}
</div>
<UsersPanel
currentUserId={user.id}
highlightedUserId={highlightedUserId}
userRowRefs={userRowRefs}
onIssueResetLink={(uid, username) =>
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" && <GroupsPanel />}
{section === "settings" && (
<div className="space-y-3">
@@ -1968,3 +1939,611 @@ function UserOpensDrillIn({
</DrillInShell>
);
}
// ===== Users Management Panel =====
type UserSortKey = "username" | "email" | "createdAt";
function UsersPanel({
currentUserId,
highlightedUserId,
userRowRefs,
onIssueResetLink,
}: {
currentUserId: number;
highlightedUserId: number | null;
userRowRefs: React.MutableRefObject<Map<number, HTMLDivElement>>;
onIssueResetLink: (userId: number, username: string) => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [groupFilter, setGroupFilter] = useState<number | "all">("all");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
const [sortKey, setSortKey] = useState<UserSortKey>("username");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [editingUser, setEditingUser] = useState<UserProfile | null>(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 (
<div className="space-y-3">
<div className="glass-panel rounded-2xl p-3 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<Input
placeholder={t("admin.users.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="bg-white/70 border-slate-200 flex-1 min-w-[180px]"
data-testid="user-search"
/>
<select
value={groupFilter === "all" ? "all" : String(groupFilter)}
onChange={(e) => setGroupFilter(e.target.value === "all" ? "all" : Number(e.target.value))}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm"
data-testid="user-group-filter"
>
<option value="all">{t("admin.users.allGroups")}</option>
{groups?.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as "all" | "active" | "inactive")}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm"
data-testid="user-status-filter"
>
<option value="all">{t("admin.users.allStatuses")}</option>
<option value="active">{t("admin.users.statusActive")}</option>
<option value="inactive">{t("admin.users.statusInactive")}</option>
</select>
<Button size="sm" onClick={() => { setNewUserForm({ username: "", email: "", password: "" }); setNewUserOpen(true); }}>
<Plus size={14} className="me-1" />
{t("admin.addUser")}
</Button>
</div>
<div className="text-xs text-muted-foreground">
{t("admin.users.showing", { count: filtered.length, total: users?.length ?? 0 })}
</div>
</div>
{/* Table (desktop) / cards (mobile) */}
<div className="overflow-hidden rounded-2xl glass-panel">
<div className="hidden md:grid grid-cols-[1fr_1fr_1fr_1fr_auto] gap-3 px-4 py-2 text-xs font-medium text-muted-foreground border-b border-slate-200/70">
{(["username", "email", "createdAt"] as UserSortKey[]).map((k) => (
<button
key={k}
onClick={() => {
if (sortKey === k) setSortDir(sortDir === "asc" ? "desc" : "asc");
else { setSortKey(k); setSortDir("asc"); }
}}
className="text-start uppercase tracking-wider hover:text-foreground"
>
{t(`admin.users.col.${k}`)}
{sortKey === k && (sortDir === "asc" ? " ▲" : " ▼")}
</button>
))}
<div className="uppercase tracking-wider">{t("admin.users.col.groups")}</div>
<div className="uppercase tracking-wider text-end">{t("admin.users.col.actions")}</div>
</div>
<div className="divide-y divide-slate-200/70">
{filtered.map((u) => (
<div
key={u.id}
ref={(node) => {
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}`}
>
<div className="font-medium text-sm text-foreground truncate">
{u.username}
{!u.isActive && (
<span className="ms-2 text-[10px] uppercase px-1.5 py-0.5 rounded-full bg-slate-200 text-muted-foreground">
{t("admin.users.statusInactive")}
</span>
)}
</div>
<div className="text-xs text-muted-foreground truncate">{u.email}</div>
<div className="text-xs text-muted-foreground">
{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : "—"}
</div>
<div className="flex flex-wrap gap-1">
{u.groups?.length ? u.groups.map((g) => (
<span key={g.id} className="text-[10px] px-2 py-0.5 rounded-full bg-primary/15 text-primary">
{g.name}
</span>
)) : <span className="text-xs text-muted-foreground"></span>}
</div>
<div className="flex items-center gap-1 justify-end">
<Switch
checked={u.isActive}
onCheckedChange={(v) =>
updateUser.mutate(
{ id: u.id, data: { isActive: v } },
{ onSuccess: invalidateUsers },
)
}
/>
<button
onClick={() => setEditingUser(u)}
title={t("admin.users.editGroups")}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
data-testid={`user-edit-${u.id}`}
>
<Pencil size={14} />
</button>
<button
onClick={() => onIssueResetLink(u.id, u.username)}
title={t("admin.issueResetLink")}
className="p-1.5 hover:bg-primary/20 rounded-lg text-muted-foreground hover:text-primary"
>
<KeyRound size={14} />
</button>
<label className="flex items-center gap-1 text-[10px] text-muted-foreground select-none">
<span className="hidden lg:inline">{t("admin.orderReceiverRole")}</span>
<Switch
checked={u.roles?.includes("order_receiver") ?? false}
onCheckedChange={(v) => {
const opts = {
onSuccess: invalidateUsers,
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
};
if (v) addUserRole.mutate({ id: u.id, data: { roleName: "order_receiver" } }, opts);
else removeUserRole.mutate({ id: u.id, roleName: "order_receiver" }, opts);
}}
/>
</label>
{u.id !== currentUserId && (
<button
onClick={() => {
if (confirm(t("admin.deleteConfirm"))) {
deleteUser.mutate({ id: u.id }, { onSuccess: invalidateUsers });
}
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t("admin.users.empty")}
</div>
)}
</div>
</div>
{newUserOpen && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3">
<h2 className="font-semibold text-foreground">{t("admin.addUser")}</h2>
<div className="space-y-1">
<Label>{t("auth.username")}</Label>
<Input value={newUserForm.username} onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })} className="bg-white/70 border-slate-200" />
</div>
<div className="space-y-1">
<Label>{t("auth.email")}</Label>
<Input type="email" value={newUserForm.email} onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })} className="bg-white/70 border-slate-200" />
</div>
<div className="space-y-1">
<Label>{t("auth.password")}</Label>
<Input type="password" value={newUserForm.password} onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })} className="bg-white/70 border-slate-200" />
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setNewUserOpen(false)}>{t("common.cancel")}</Button>
<Button
className="flex-1"
onClick={() => {
createUser.mutate(
{ data: newUserForm },
{
onSuccess: () => {
invalidateUsers();
setNewUserOpen(false);
toast({ title: t("common.success") });
},
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
);
}}
>
{t("common.save")}
</Button>
</div>
</div>
</div>
)}
{editingUser && (
<UserGroupsEditor
user={editingUser}
groups={groups ?? []}
onClose={() => setEditingUser(null)}
onSaved={() => { invalidateUsers(); setEditingUser(null); }}
/>
)}
</div>
);
}
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<Set<number>>(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 (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-3 max-h-[85vh] overflow-y-auto">
<h2 className="font-semibold text-foreground">{t("admin.users.editUserGroups", { username: user.username })}</h2>
<div className="space-y-1">
{groups.map((g) => (
<label key={g.id} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer">
<input type="checkbox" checked={selected.has(g.id)} onChange={() => toggle(g.id)} />
<span className="text-sm flex-1">{g.name}</span>
{g.isSystem && <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-200 text-muted-foreground">{t("admin.groups.system")}</span>}
</label>
))}
{groups.length === 0 && <p className="text-sm text-muted-foreground p-2">{t("admin.groups.empty")}</p>}
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
<Button
className="flex-1"
onClick={() =>
updateUser.mutate(
{ id: user.id, data: { groupIds: Array.from(selected) } },
{
onSuccess: () => { toast({ title: t("common.success") }); onSaved(); },
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
)
}
>
{t("common.save")}
</Button>
</div>
</div>
</div>
);
}
// ===== 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<number | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState({ name: "", descriptionAr: "", descriptionEn: "" });
const invalidate = () => queryClient.invalidateQueries({ queryKey: getListGroupsQueryKey() });
return (
<div className="space-y-3">
<div className="flex justify-end">
<Button size="sm" onClick={() => { setCreateForm({ name: "", descriptionAr: "", descriptionEn: "" }); setCreateOpen(true); }} data-testid="create-group-btn">
<Plus size={14} className="me-1" />
{t("admin.groups.addGroup")}
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
{groups?.map((g) => (
<div key={g.id} className="glass-panel rounded-2xl p-4 space-y-2" data-testid={`group-card-${g.id}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold text-foreground flex items-center gap-2">
<Shield size={16} className="text-primary" />
<span className="truncate">{g.name}</span>
{g.isSystem && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase tracking-wider">
{t("admin.groups.system")}
</span>
)}
</div>
{(g.descriptionAr || g.descriptionEn) && (
<p className="text-xs text-muted-foreground mt-1 truncate">
{g.descriptionEn || g.descriptionAr}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setEditingGroupId(g.id)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-muted-foreground hover:text-foreground"
data-testid={`edit-group-${g.id}`}
>
<Pencil size={14} />
</button>
{!g.isSystem && (
<button
onClick={() => {
if (confirm(t("admin.deleteConfirm"))) {
deleteGroup.mutate(
{ id: g.id },
{
onSuccess: () => { invalidate(); toast({ title: t("common.success") }); },
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
);
}
}}
className="p-1.5 hover:bg-destructive/20 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground pt-1">
<span>{t("admin.groups.members", { count: g.memberCount })}</span>
<span></span>
<span>{t("admin.groups.appsCount", { count: g.appCount })}</span>
<span></span>
<span>{t("admin.groups.rolesCount", { count: g.roleCount })}</span>
</div>
</div>
))}
{(!groups || groups.length === 0) && (
<div className="glass-panel rounded-2xl p-6 text-center text-sm text-muted-foreground md:col-span-2">
{t("admin.groups.empty")}
</div>
)}
</div>
{createOpen && (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3">
<h2 className="font-semibold text-foreground">{t("admin.groups.addGroup")}</h2>
<div className="space-y-1">
<Label>{t("admin.groups.name")}</Label>
<Input value={createForm.name} onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })} className="bg-white/70 border-slate-200" data-testid="group-name-input" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionAr")}</Label>
<Input value={createForm.descriptionAr} onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })} className="bg-white/70 border-slate-200" dir="rtl" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionEn")}</Label>
<Input value={createForm.descriptionEn} onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })} className="bg-white/70 border-slate-200" dir="ltr" />
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setCreateOpen(false)}>{t("common.cancel")}</Button>
<Button
className="flex-1"
disabled={!createForm.name.trim()}
onClick={() =>
createGroup.mutate(
{ data: { name: createForm.name.trim(), descriptionAr: createForm.descriptionAr || null, descriptionEn: createForm.descriptionEn || null } },
{
onSuccess: () => { invalidate(); setCreateOpen(false); toast({ title: t("common.success") }); },
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
)
}
data-testid="create-group-submit"
>
{t("common.save")}
</Button>
</div>
</div>
</div>
)}
{editingGroupId != null && (
<GroupDetailEditor groupId={editingGroupId} onClose={() => setEditingGroupId(null)} onSaved={() => { invalidate(); setEditingGroupId(null); }} />
)}
</div>
);
}
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<Set<number>>(new Set());
const [userIds, setUserIds] = useState<Set<number>>(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<number>, id: number, setter: (s: Set<number>) => void) => {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
setter(next);
};
if (!group) {
return (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-md text-center">
<Loader2 className="animate-spin mx-auto" />
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-6 w-full max-w-lg space-y-3 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between gap-2">
<h2 className="font-semibold text-foreground flex items-center gap-2">
<Shield size={18} className="text-primary" />
{t("admin.groups.editGroup")}: {group.name}
</h2>
{group.isSystem && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700 uppercase tracking-wider">
{t("admin.groups.system")}
</span>
)}
</div>
<div className="flex border-b border-slate-200/70">
{(["info", "apps", "users"] as const).map((k) => (
<button
key={k}
onClick={() => setTab(k)}
className={cn(
"px-3 py-2 text-sm border-b-2 transition-colors",
tab === k ? "border-primary text-primary font-semibold" : "border-transparent text-muted-foreground hover:text-foreground",
)}
data-testid={`group-tab-${k}`}
>
{t(`admin.groups.tab.${k}`)}
</button>
))}
</div>
{tab === "info" && (
<div className="space-y-2">
<div className="space-y-1">
<Label>{t("admin.groups.name")}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} disabled={group.isSystem} className="bg-white/70 border-slate-200" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionAr")}</Label>
<Input value={descriptionAr} onChange={(e) => setDescriptionAr(e.target.value)} className="bg-white/70 border-slate-200" dir="rtl" />
</div>
<div className="space-y-1">
<Label>{t("admin.groups.descriptionEn")}</Label>
<Input value={descriptionEn} onChange={(e) => setDescriptionEn(e.target.value)} className="bg-white/70 border-slate-200" dir="ltr" />
</div>
</div>
)}
{tab === "apps" && (
<div className="space-y-1 max-h-72 overflow-y-auto">
<p className="text-xs text-muted-foreground px-1 pb-2">{t("admin.groups.appsHint")}</p>
{apps?.map((a) => (
<label key={a.id} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer">
<input type="checkbox" checked={appIds.has(a.id)} onChange={() => toggle(appIds, a.id, setAppIds)} />
<span className="text-sm flex-1">{lang === "ar" ? a.nameAr : a.nameEn}</span>
<span className="text-[10px] text-muted-foreground">{a.slug}</span>
</label>
))}
</div>
)}
{tab === "users" && (
<div className="space-y-1 max-h-72 overflow-y-auto">
<p className="text-xs text-muted-foreground px-1 pb-2">{t("admin.groups.usersHint")}</p>
{users?.map((u) => (
<label key={u.id} className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-slate-100 cursor-pointer">
<input type="checkbox" checked={userIds.has(u.id)} onChange={() => toggle(userIds, u.id, setUserIds)} />
<span className="text-sm flex-1">{u.username}</span>
<span className="text-[10px] text-muted-foreground">{u.email}</span>
</label>
))}
</div>
)}
<div className="flex gap-2 pt-2 border-t border-slate-200/70">
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
<Button
className="flex-1"
onClick={() =>
updateGroup.mutate(
{
id: group.id,
data: {
name: group.isSystem ? undefined : name.trim() || undefined,
descriptionAr: descriptionAr || null,
descriptionEn: descriptionEn || null,
appIds: Array.from(appIds),
userIds: Array.from(userIds),
},
},
{
onSuccess: () => { toast({ title: t("common.success") }); onSaved(); },
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
)
}
data-testid="save-group"
>
{t("common.save")}
</Button>
</div>
</div>
</div>
);
}
@@ -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.
+485 -16
View File
@@ -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<UserProfile[]> => {
return customFetch<UserProfile[]>(getListUsersUrl(), {
return customFetch<UserProfile[]>(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<ReturnType<typeof listUsers>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>;
request?: SecondParameter<typeof customFetch>;
}) => {
>(
params?: ListUsersParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUsers>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey();
const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listUsers>>> = ({
signal,
}) => listUsers({ signal, ...requestOptions });
}) => listUsers(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listUsers>>,
@@ -4032,11 +4058,18 @@ export type ListUsersQueryError = ErrorType<unknown>;
export function useListUsers<
TData = Awaited<ReturnType<typeof listUsers>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListUsersQueryOptions(options);
>(
params?: ListUsersParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUsers>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListUsersQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
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<Group[]> => {
return customFetch<Group[]>(getListGroupsUrl(params), {
...options,
method: "GET",
});
};
export const getListGroupsQueryKey = (params?: ListGroupsParams) => {
return [`/api/groups`, ...(params ? [params] : [])] as const;
};
export const getListGroupsQueryOptions = <
TData = Awaited<ReturnType<typeof listGroups>>,
TError = ErrorType<unknown>,
>(
params?: ListGroupsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listGroups>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListGroupsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listGroups>>> = ({
signal,
}) => listGroups(params, { signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listGroups>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListGroupsQueryResult = NonNullable<
Awaited<ReturnType<typeof listGroups>>
>;
export type ListGroupsQueryError = ErrorType<unknown>;
/**
* @summary List groups (admin)
*/
export function useListGroups<
TData = Awaited<ReturnType<typeof listGroups>>,
TError = ErrorType<unknown>,
>(
params?: ListGroupsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listGroups>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListGroupsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
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<Group> => {
return customFetch<Group>(getCreateGroupUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(createGroupBody),
});
};
export const getCreateGroupMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createGroup>>,
TError,
{ data: BodyType<CreateGroupBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createGroup>>,
TError,
{ data: BodyType<CreateGroupBody> },
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<ReturnType<typeof createGroup>>,
{ data: BodyType<CreateGroupBody> }
> = (props) => {
const { data } = props ?? {};
return createGroup(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type CreateGroupMutationResult = NonNullable<
Awaited<ReturnType<typeof createGroup>>
>;
export type CreateGroupMutationBody = BodyType<CreateGroupBody>;
export type CreateGroupMutationError = ErrorType<unknown>;
/**
* @summary Create group (admin)
*/
export const useCreateGroup = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createGroup>>,
TError,
{ data: BodyType<CreateGroupBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof createGroup>>,
TError,
{ data: BodyType<CreateGroupBody> },
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<GroupDetail> => {
return customFetch<GroupDetail>(getGetGroupUrl(id), {
...options,
method: "GET",
});
};
export const getGetGroupQueryKey = (id: number) => {
return [`/api/groups/${id}`] as const;
};
export const getGetGroupQueryOptions = <
TData = Awaited<ReturnType<typeof getGroup>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getGroup>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetGroupQueryKey(id);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getGroup>>> = ({
signal,
}) => getGroup(id, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getGroup>>, TError, TData> & {
queryKey: QueryKey;
};
};
export type GetGroupQueryResult = NonNullable<
Awaited<ReturnType<typeof getGroup>>
>;
export type GetGroupQueryError = ErrorType<ErrorResponse>;
/**
* @summary Get group with members and apps (admin)
*/
export function useGetGroup<
TData = Awaited<ReturnType<typeof getGroup>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getGroup>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetGroupQueryOptions(id, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
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<GroupDetail> => {
return customFetch<GroupDetail>(getUpdateGroupUrl(id), {
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateGroupBody),
});
};
export const getUpdateGroupMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateGroup>>,
TError,
{ id: number; data: BodyType<UpdateGroupBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateGroup>>,
TError,
{ id: number; data: BodyType<UpdateGroupBody> },
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<ReturnType<typeof updateGroup>>,
{ id: number; data: BodyType<UpdateGroupBody> }
> = (props) => {
const { id, data } = props ?? {};
return updateGroup(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateGroupMutationResult = NonNullable<
Awaited<ReturnType<typeof updateGroup>>
>;
export type UpdateGroupMutationBody = BodyType<UpdateGroupBody>;
export type UpdateGroupMutationError = ErrorType<unknown>;
/**
* @summary Update group (admin)
*/
export const useUpdateGroup = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateGroup>>,
TError,
{ id: number; data: BodyType<UpdateGroupBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateGroup>>,
TError,
{ id: number; data: BodyType<UpdateGroupBody> },
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<void> => {
return customFetch<void>(getDeleteGroupUrl(id), {
...options,
method: "DELETE",
});
};
export const getDeleteGroupMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteGroup>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteGroup>>,
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<ReturnType<typeof deleteGroup>>,
{ id: number }
> = (props) => {
const { id } = props ?? {};
return deleteGroup(id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteGroupMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteGroup>>
>;
export type DeleteGroupMutationError = ErrorType<ErrorResponse>;
/**
* @summary Delete group (admin)
*/
export const useDeleteGroup = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteGroup>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteGroup>>,
TError,
{ id: number },
TContext
> => {
return useMutation(getDeleteGroupMutationOptions(options));
};
/**
* @summary Get home screen stats
*/
+274
View File
@@ -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:
+186
View File
@@ -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
*/
+62
View File
@@ -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<typeof insertGroupSchema>;
export type Group = typeof groupsTable.$inferSelect;
+1
View File
@@ -10,3 +10,4 @@ export * from "./user-app-orders";
export * from "./app-opens";
export * from "./password-reset-tokens";
export * from "./notes";
export * from "./groups";
+110 -1
View File
@@ -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");