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:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user