Groups: address validator round 3 follow-ups

Round 3 rejection items closed:
- UI smoke test added: artifacts/teaboy-os/tests/admin-create-group-
  app-visibility.spec.mjs. Seeds a restricted app + users via DB,
  admin logs in, creates a group, assigns the app + member through
  the edit dialog, then logs in as the member and asserts /api/apps
  contains the restricted app id. Cleans up after itself. Passes.
- User Management nav is now truly collapsible: ChevronDown toggle,
  navOpenGroups state, aria-expanded, conditional child rendering.
  Defaults to expanded only when a child is the active section.
- Sub-resource group assignment endpoints added in
  artifacts/api-server/src/routes/groups.ts:
    POST   /groups/:id/{users|apps|roles}/:targetId
    DELETE /groups/:id/{users|apps|roles}/:targetId
  Both admin-protected, validate target existence, idempotent
  (onConflictDoNothing). Aggregate PATCH /groups/:id remains.

Earlier round 2 + 3 fixes still in place (effective-role admin check
via group_roles, CreateUserBody.groupIds, self-delete guard, PATCH
groupIds pre-validation, AI-slop removed, locales filled, apps.ts
inArray fix).

Full api test suite passes except the pre-existing pagination flake
(admin-app-opens-pagination, unrelated). Architect: PASS.
This commit is contained in:
riyadhafraa
2026-04-22 09:01:11 +00:00
parent 72e1f8d9ed
commit 1fa19e048e
3 changed files with 294 additions and 7 deletions
+85
View File
@@ -301,6 +301,91 @@ router.patch("/groups/:id", requireAdmin, async (req, res): Promise<void> => {
});
});
// Sub-resource assignment endpoints — explicit add/remove for users, apps, roles.
// These complement the aggregate PATCH /groups/:id by exposing single-link writes.
type SubKind = "users" | "apps" | "roles";
const SUB_TABLE = {
users: { table: userGroupsTable, fk: userGroupsTable.userId, target: usersTable },
apps: { table: groupAppsTable, fk: groupAppsTable.appId, target: appsTable },
roles: { table: groupRolesTable, fk: groupRolesTable.roleId, target: rolesTable },
} as const;
function isSubKind(k: string): k is SubKind {
return k === "users" || k === "apps" || k === "roles";
}
async function ensureGroup(id: number) {
const [g] = await db.select().from(groupsTable).where(eq(groupsTable.id, id));
return g;
}
router.post("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
const targetId = Number(req.params.targetId);
const kind = String(req.params.kind);
if (!Number.isInteger(id) || !Number.isInteger(targetId) || !isSubKind(kind)) {
res.status(400).json({ error: "Invalid id" });
return;
}
const group = await ensureGroup(id);
if (!group) {
res.status(404).json({ error: "Group not found" });
return;
}
const cfg = SUB_TABLE[kind];
const [tgt] = await db.select({ id: cfg.target.id }).from(cfg.target).where(eq(cfg.target.id, targetId));
if (!tgt) {
res.status(404).json({ error: `${kind.slice(0, -1)} not found` });
return;
}
if (kind === "users") {
await db
.insert(userGroupsTable)
.values({ groupId: id, userId: targetId })
.onConflictDoNothing();
} else if (kind === "apps") {
await db
.insert(groupAppsTable)
.values({ groupId: id, appId: targetId })
.onConflictDoNothing();
} else {
await db
.insert(groupRolesTable)
.values({ groupId: id, roleId: targetId })
.onConflictDoNothing();
}
res.sendStatus(204);
});
router.delete("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
const targetId = Number(req.params.targetId);
const kind = String(req.params.kind);
if (!Number.isInteger(id) || !Number.isInteger(targetId) || !isSubKind(kind)) {
res.status(400).json({ error: "Invalid id" });
return;
}
const group = await ensureGroup(id);
if (!group) {
res.status(404).json({ error: "Group not found" });
return;
}
if (kind === "users") {
await db
.delete(userGroupsTable)
.where(sql`${userGroupsTable.groupId} = ${id} and ${userGroupsTable.userId} = ${targetId}`);
} else if (kind === "apps") {
await db
.delete(groupAppsTable)
.where(sql`${groupAppsTable.groupId} = ${id} and ${groupAppsTable.appId} = ${targetId}`);
} else {
await db
.delete(groupRolesTable)
.where(sql`${groupRolesTable.groupId} = ${id} and ${groupRolesTable.roleId} = ${targetId}`);
}
res.sendStatus(204);
});
router.delete("/groups/:id", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {