diff --git a/artifacts/api-server/src/routes/groups.ts b/artifacts/api-server/src/routes/groups.ts index 2233f8fa..cff6991f 100644 --- a/artifacts/api-server/src/routes/groups.ts +++ b/artifacts/api-server/src/routes/groups.ts @@ -301,6 +301,91 @@ router.patch("/groups/:id", requireAdmin, async (req, res): Promise => { }); }); +// 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 => { + 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 => { + 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 => { const id = Number(req.params.id); if (!Number.isInteger(id)) { diff --git a/artifacts/teaboy-os/src/pages/admin.tsx b/artifacts/teaboy-os/src/pages/admin.tsx index 799e3ef8..2d42d15e 100644 --- a/artifacts/teaboy-os/src/pages/admin.tsx +++ b/artifacts/teaboy-os/src/pages/admin.tsx @@ -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("dashboard"); + const [navOpenGroups, setNavOpenGroups] = useState>({}); const [mobileNavOpen, setMobileNavOpen] = useState(false); const [highlightedUserId, setHighlightedUserId] = useState(null); const userRowRefs = useRef>(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 (
-
+ 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}`} > - {entry.label} -
- {entry.children.map((leaf) => renderLeaf(leaf, true))} + {entry.label} + + + {expanded && entry.children.map((leaf) => renderLeaf(leaf, true))}
); } diff --git a/artifacts/teaboy-os/tests/admin-create-group-app-visibility.spec.mjs b/artifacts/teaboy-os/tests/admin-create-group-app-visibility.spec.mjs new file mode 100644 index 00000000..5be55bfa --- /dev/null +++ b/artifacts/teaboy-os/tests/admin-create-group-app-visibility.spec.mjs @@ -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); + }); +});