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)) {
|
||||
|
||||
@@ -57,7 +57,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy } from "lucide-react";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -208,6 +208,7 @@ export default function AdminPage() {
|
||||
const [editingService, setEditingService] = useState<{ id?: number; form: ServiceForm } | null>(null);
|
||||
const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null);
|
||||
const [section, setSection] = useState<AdminSection>("dashboard");
|
||||
const [navOpenGroups, setNavOpenGroups] = useState<Record<string, boolean>>({});
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [highlightedUserId, setHighlightedUserId] = useState<number | null>(null);
|
||||
const userRowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
@@ -317,18 +318,35 @@ export default function AdminPage() {
|
||||
if ("children" in entry) {
|
||||
const Icon = entry.Icon;
|
||||
const anyChildActive = entry.children.some((c) => c.key === section);
|
||||
const expanded = navOpenGroups[entry.groupKey] ?? anyChildActive;
|
||||
return (
|
||||
<div key={entry.groupKey} className="flex flex-col gap-0.5">
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNavOpenGroups((prev) => ({
|
||||
...prev,
|
||||
[entry.groupKey]: !(prev[entry.groupKey] ?? anyChildActive),
|
||||
}))
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
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",
|
||||
"flex items-center gap-2 w-full px-3 py-2 rounded-xl text-xs uppercase tracking-wider transition-colors text-start",
|
||||
anyChildActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
data-testid={`nav-group-${entry.groupKey}`}
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="truncate">{entry.label}</span>
|
||||
</div>
|
||||
{entry.children.map((leaf) => renderLeaf(leaf, true))}
|
||||
<span className="truncate flex-1">{entry.label}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn(
|
||||
"shrink-0 transition-transform",
|
||||
expanded ? "rotate-0" : "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{expanded && entry.children.map((leaf) => renderLeaf(leaf, true))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// UI smoke: admin creates a group, assigns a user + a restricted app to it,
|
||||
// then a regular user (granted access via that group) sees the restricted
|
||||
// app in /api/apps. Cleans up after itself.
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run this test");
|
||||
}
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
const createdAppIds = [];
|
||||
const createdGroupNames = [];
|
||||
|
||||
function uniqueSuffix() {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix, opts = {}) {
|
||||
const username = `${prefix}_${uniqueSuffix()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
const roleName = opts.admin ? "admin" : "user";
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = $2`,
|
||||
[id, roleName],
|
||||
);
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
async function createRestrictedApp(prefix) {
|
||||
const slug = `${prefix}_${uniqueSuffix()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO apps (name_ar, name_en, slug, icon_name, route, color, sort_order, description_en)
|
||||
VALUES ($1, $2, $3, 'Grid2X2', '/test', '#000000', 999, 'visibility test app')
|
||||
RETURNING id`,
|
||||
[`تطبيق ${prefix}`, `App ${prefix}`, slug],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdAppIds.push(id);
|
||||
// Mark the app as restricted by attaching a permission that the regular
|
||||
// 'user' role does NOT have. Pick any permission held by 'admin' but not
|
||||
// by 'user'; if none exists, fall back to the first permission.
|
||||
const permRows = await pool.query(
|
||||
`SELECT id FROM permissions
|
||||
WHERE id IN (SELECT permission_id FROM role_permissions
|
||||
WHERE role_id = (SELECT id FROM roles WHERE name = 'admin'))
|
||||
AND id NOT IN (SELECT permission_id FROM role_permissions
|
||||
WHERE role_id = (SELECT id FROM roles WHERE name = 'user'))
|
||||
LIMIT 1`,
|
||||
);
|
||||
const fallbackRows = permRows.rows.length
|
||||
? permRows.rows
|
||||
: (await pool.query(`SELECT id FROM permissions LIMIT 1`)).rows;
|
||||
if (fallbackRows.length === 0) {
|
||||
throw new Error("No permissions exist in DB; cannot mark app as restricted");
|
||||
}
|
||||
await pool.query(
|
||||
`INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`,
|
||||
[id, fallbackRows[0].id],
|
||||
);
|
||||
return { id, slug };
|
||||
}
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (createdGroupNames.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM groups WHERE name = ANY($1::text[])`,
|
||||
[createdGroupNames],
|
||||
);
|
||||
}
|
||||
if (createdAppIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
|
||||
[createdAppIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM apps WHERE id = ANY($1::int[])`,
|
||||
[createdAppIds],
|
||||
);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM user_groups WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function login(page, username, password) {
|
||||
await page.goto("/login");
|
||||
await page.locator("#username").fill(username);
|
||||
await page.locator("#password").fill(password);
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
||||
timeout: 15_000,
|
||||
}),
|
||||
page.locator('form button[type="submit"]').click(),
|
||||
]);
|
||||
}
|
||||
|
||||
test.describe("Admin: create group + assign user/app → user sees restricted app", () => {
|
||||
test("end-to-end UI flow", async ({ page }) => {
|
||||
const admin = await createUser("vis_admin", { admin: true });
|
||||
const member = await createUser("vis_member");
|
||||
const app = await createRestrictedApp("vis");
|
||||
const groupName = `Vis Smoke ${uniqueSuffix()}`;
|
||||
createdGroupNames.push(groupName);
|
||||
|
||||
// 1) Admin logs in via UI and navigates to the Groups admin section.
|
||||
await login(page, admin.username, TEST_PASSWORD);
|
||||
await page.goto("/admin#section=groups");
|
||||
|
||||
// The "User Management" nav group should auto-expand because a child is active.
|
||||
await expect(page.locator('[data-testid="create-group-btn"]')).toBeVisible();
|
||||
|
||||
// 2) Create the group.
|
||||
await page.locator('[data-testid="create-group-btn"]').click();
|
||||
await page.locator('[data-testid="group-name-input"]').fill(groupName);
|
||||
await page.locator('[data-testid="create-group-submit"]').click();
|
||||
|
||||
// The new group card should appear.
|
||||
const newCard = page
|
||||
.locator('[data-testid^="group-card-"]')
|
||||
.filter({ hasText: groupName });
|
||||
await expect(newCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 3) Open the edit dialog for the new group.
|
||||
await newCard.locator('[data-testid^="edit-group-"]').click();
|
||||
|
||||
// 4) Apps tab → check the restricted app.
|
||||
await page.locator('[data-testid="group-tab-apps"]').click();
|
||||
const appLabel = page.locator("label").filter({ hasText: app.slug });
|
||||
await expect(appLabel).toBeVisible();
|
||||
await appLabel.locator('input[type="checkbox"]').check();
|
||||
|
||||
// 5) Users tab → check the member user.
|
||||
await page.locator('[data-testid="group-tab-users"]').click();
|
||||
await page.locator('[data-testid="group-user-search"]').fill(member.username);
|
||||
const memberLabel = page.locator("label").filter({ hasText: member.username });
|
||||
await expect(memberLabel).toBeVisible();
|
||||
await memberLabel.locator('input[type="checkbox"]').check();
|
||||
|
||||
// 6) Save.
|
||||
await page.locator('[data-testid="save-group"]').click();
|
||||
|
||||
// Wait for the save to settle by re-opening the card and checking counts,
|
||||
// or just give the network a moment.
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 7) Logout (clear cookies) and login as the member.
|
||||
await page.context().clearCookies();
|
||||
await login(page, member.username, TEST_PASSWORD);
|
||||
|
||||
// 8) Verify visibility through the API as the member's session.
|
||||
const apiRes = await page.request.get(`${API_BASE}/api/apps`);
|
||||
expect(apiRes.status()).toBe(200);
|
||||
const apps = await apiRes.json();
|
||||
const ids = apps.map((a) => a.id);
|
||||
expect(ids).toContain(app.id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user