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:
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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": "إجمالي التطبيقات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user