From 61c99d59f1de92dc569a519185f756574369cb2e Mon Sep 17 00:00:00 2001 From: Riyadh Date: Wed, 29 Apr 2026 15:31:42 +0000 Subject: [PATCH] Task #109: Admin UI to manage app required permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added 3 admin-only API endpoints in artifacts/api-server/src/routes/apps.ts: - GET /api/apps/:id/permissions — list permissions gating an app - POST /api/apps/:id/permissions — add a permission (idempotent via onConflictDoNothing() on the (app_id, permission_id) composite PK) - DELETE /api/apps/:id/permissions/:permissionId — remove (idempotent, 204) - Documented the new endpoints in lib/api-spec/openapi.yaml with two new schemas (AddAppPermissionBody, AppPermissionLink) and re-ran codegen. - Added a "Required permissions" section (AppPermissionsEditor) to the existing Edit App dialog in artifacts/tx-os/src/pages/admin.tsx, using the generated hooks. The section is shown only when editing an existing app (it needs an app id). Wired up admin.appPermissions.* i18n keys in en.json + ar.json. - Added artifacts/api-server/tests/app-permissions-crud.test.mjs with 5 tests (empty list, idempotent add, 404 on unknown app/perm, idempotent delete, 403 for non-admins). All 5 pass; related tests (app-permissions-unique, apps-group-visibility, list-dependency-counts) still pass. - Verified the new admin UI end-to-end with the testing skill: admin login, open Edit App dialog, add/remove a required permission, and confirm the section is hidden in the Add App dialog. Notes / scope: - Pre-existing duplicate rows in app_permissions had to be deduped and `pnpm --filter @workspace/db run push` was run once so the composite PK could be added (separate task "Re-run the Drizzle schema push" already exists for this project-wide chore). - No audit logging here — separate existing task already covers it. - The "test" workflow shows a pre-existing ECONNREFUSED race; an existing task already tracks making the test workflow wait for the API server. --- artifacts/api-server/src/routes/apps.ts | 109 +++++++ .../tests/app-permissions-crud.test.mjs | 274 ++++++++++++++++++ artifacts/tx-os/src/locales/ar.json | 8 + artifacts/tx-os/src/locales/en.json | 8 + artifacts/tx-os/src/pages/admin.tsx | 138 +++++++++ .../src/generated/api.schemas.ts | 10 + lib/api-client-react/src/generated/api.ts | 270 +++++++++++++++++ lib/api-spec/openapi.yaml | 111 +++++++ lib/api-zod/src/generated/api.ts | 47 +++ 9 files changed, 975 insertions(+) create mode 100644 artifacts/api-server/tests/app-permissions-crud.test.mjs diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index 348fdea5..d214f8c5 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -12,6 +12,7 @@ import { userGroupsTable, groupAppsTable, auditLogsTable, + permissionsTable, } from "@workspace/db"; import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth"; import { @@ -315,6 +316,114 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise => { res.json(app); }); +router.get("/apps/:id/permissions", requireAdmin, async (req, res): Promise => { + const params = GetAppParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [app] = await db + .select({ id: appsTable.id }) + .from(appsTable) + .where(eq(appsTable.id, params.data.id)); + if (!app) { + res.status(404).json({ error: "App not found" }); + return; + } + + const rows = await db + .select({ + id: permissionsTable.id, + name: permissionsTable.name, + descriptionAr: permissionsTable.descriptionAr, + descriptionEn: permissionsTable.descriptionEn, + }) + .from(appPermissionsTable) + .innerJoin( + permissionsTable, + eq(appPermissionsTable.permissionId, permissionsTable.id), + ) + .where(eq(appPermissionsTable.appId, app.id)) + .orderBy(permissionsTable.name); + + res.json(rows); +}); + +router.post("/apps/:id/permissions", requireAdmin, async (req, res): Promise => { + const params = GetAppParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + const permissionId = Number(req.body?.permissionId); + if (!Number.isInteger(permissionId)) { + res.status(400).json({ error: "permissionId is required" }); + return; + } + + const [app] = await db + .select({ id: appsTable.id }) + .from(appsTable) + .where(eq(appsTable.id, params.data.id)); + if (!app) { + res.status(404).json({ error: "App not found" }); + return; + } + + const [perm] = await db + .select({ id: permissionsTable.id }) + .from(permissionsTable) + .where(eq(permissionsTable.id, permissionId)); + if (!perm) { + res.status(404).json({ error: "Permission not found" }); + return; + } + + // The composite primary key on (app_id, permission_id) means a duplicate + // insert would otherwise fail with 23505. onConflictDoNothing makes the + // endpoint idempotent so the UI can safely re-add an existing pair. + await db + .insert(appPermissionsTable) + .values({ appId: app.id, permissionId }) + .onConflictDoNothing(); + + res.status(201).json({ appId: app.id, permissionId }); +}); + +router.delete( + "/apps/:id/permissions/:permissionId", + requireAdmin, + async (req, res): Promise => { + const appId = Number(req.params.id); + const permissionId = Number(req.params.permissionId); + if (!Number.isInteger(appId) || !Number.isInteger(permissionId)) { + res.status(400).json({ error: "Invalid id" }); + return; + } + + const [app] = await db + .select({ id: appsTable.id }) + .from(appsTable) + .where(eq(appsTable.id, appId)); + if (!app) { + res.status(404).json({ error: "App not found" }); + return; + } + + await db + .delete(appPermissionsTable) + .where( + and( + eq(appPermissionsTable.appId, appId), + eq(appPermissionsTable.permissionId, permissionId), + ), + ); + + res.sendStatus(204); + }, +); + router.post("/apps/:id/open", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; const params = GetAppParams.safeParse(req.params); diff --git a/artifacts/api-server/tests/app-permissions-crud.test.mjs b/artifacts/api-server/tests/app-permissions-crud.test.mjs new file mode 100644 index 00000000..c1d28432 --- /dev/null +++ b/artifacts/api-server/tests/app-permissions-crud.test.mjs @@ -0,0 +1,274 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +const TEST_PASSWORD = "TestPass123!"; +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +let adminId; +let adminUsername; +let adminCookie; + +let nonAdminId; +let nonAdminUsername; +let nonAdminCookie; + +const createdAppIds = []; +const createdPermissionIds = []; +const createdUserIds = []; + +async function loginAndGetCookie(username, password) { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + assert.equal(res.status, 200, `login expected 200, got ${res.status}`); + const setCookie = res.headers.get("set-cookie"); + return setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")); +} + +async function createApp(prefix) { + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const slug = `${prefix}_${stamp}`; + const res = await pool.query( + `INSERT INTO apps (slug, name_ar, name_en, route, is_active, sort_order) + VALUES ($1, $2, $3, $4, true, 999) + RETURNING id`, + [slug, slug, slug, `/${slug}`], + ); + const id = res.rows[0].id; + createdAppIds.push(id); + return id; +} + +async function createPermission(prefix) { + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const name = `${prefix}.${stamp}`; + const res = await pool.query( + `INSERT INTO permissions (name, description_en) VALUES ($1, $2) RETURNING id`, + [name, name], + ); + const id = res.rows[0].id; + createdPermissionIds.push(id); + return { id, name }; +} + +async function getPairsForApp(appId) { + const rows = await pool.query( + `SELECT permission_id FROM app_permissions WHERE app_id = $1 ORDER BY permission_id`, + [appId], + ); + return rows.rows.map((r) => r.permission_id); +} + +before(async () => { + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + adminUsername = `app_perms_admin_${stamp}`; + nonAdminUsername = `app_perms_user_${stamp}`; + + const admin = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, 'App Perms Admin', 'en', true) RETURNING id`, + [adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH], + ); + adminId = admin.rows[0].id; + createdUserIds.push(adminId); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, + [adminId], + ); + + const user = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, 'App Perms User', 'en', true) RETURNING id`, + [nonAdminUsername, `${nonAdminUsername}@example.com`, TEST_PASSWORD_HASH], + ); + nonAdminId = user.rows[0].id; + createdUserIds.push(nonAdminId); + + adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); + nonAdminCookie = await loginAndGetCookie(nonAdminUsername, TEST_PASSWORD); +}); + +after(async () => { + if (createdAppIds.length) { + 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 (createdPermissionIds.length) { + await pool.query( + `DELETE FROM app_permissions WHERE permission_id = ANY($1::int[])`, + [createdPermissionIds], + ); + await pool.query(`DELETE FROM permissions WHERE id = ANY($1::int[])`, [ + createdPermissionIds, + ]); + } + for (const uid of createdUserIds) { + if (uid !== undefined) { + await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]); + await pool.query(`DELETE FROM users WHERE id = $1`, [uid]); + } + } + await pool.end(); +}); + +test("GET /api/apps/:id/permissions lists nothing for a fresh app", async () => { + const appId = await createApp("apc_list_empty"); + const res = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, { + headers: { Cookie: adminCookie }, + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.deepEqual(body, []); +}); + +test("POST /api/apps/:id/permissions adds a permission and is idempotent on duplicates", async () => { + const appId = await createApp("apc_add"); + const { id: permId, name: permName } = await createPermission("apc.add"); + + // First insert: 201, body contains the link. + const first = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionId: permId }), + }); + assert.equal(first.status, 201); + const firstBody = await first.json(); + assert.equal(firstBody.appId, appId); + assert.equal(firstBody.permissionId, permId); + assert.deepEqual(await getPairsForApp(appId), [permId]); + + // Re-adding the exact same pair must NOT throw a unique-violation; the + // route uses .onConflictDoNothing() so the UI can safely retry. + const second = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionId: permId }), + }); + assert.equal(second.status, 201); + // Still exactly one row — the composite primary key was respected without + // an error bubbling up to the client. + assert.deepEqual(await getPairsForApp(appId), [permId]); + + // List endpoint reflects the assignment with the joined permission record. + const list = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, { + headers: { Cookie: adminCookie }, + }); + assert.equal(list.status, 200); + const listBody = await list.json(); + assert.equal(listBody.length, 1); + assert.equal(listBody[0].id, permId); + assert.equal(listBody[0].name, permName); +}); + +test("POST /api/apps/:id/permissions returns 404 for an unknown app or permission", async () => { + const appId = await createApp("apc_unknown"); + const { id: permId } = await createPermission("apc.unknown"); + + const unknownAppRow = await pool.query( + `SELECT COALESCE(MAX(id), 0) AS m FROM apps`, + ); + const unknownAppId = unknownAppRow.rows[0].m + 9999; + const unknownPermRow = await pool.query( + `SELECT COALESCE(MAX(id), 0) AS m FROM permissions`, + ); + const unknownPermId = unknownPermRow.rows[0].m + 9999; + + const noApp = await fetch(`${API_BASE}/api/apps/${unknownAppId}/permissions`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionId: permId }), + }); + assert.equal(noApp.status, 404); + + const noPerm = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionId: unknownPermId }), + }); + assert.equal(noPerm.status, 404); + + // Neither rejected call should have left a row behind. + assert.deepEqual(await getPairsForApp(appId), []); +}); + +test("DELETE /api/apps/:id/permissions/:permissionId removes the pair and is idempotent", async () => { + const appId = await createApp("apc_delete"); + const { id: p1 } = await createPermission("apc.delete.a"); + const { id: p2 } = await createPermission("apc.delete.b"); + + // Seed the app with two permissions. + for (const pid of [p1, p2]) { + const r = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: adminCookie }, + body: JSON.stringify({ permissionId: pid }), + }); + assert.equal(r.status, 201); + } + assert.deepEqual( + await getPairsForApp(appId), + [p1, p2].sort((a, b) => a - b), + ); + + const del = await fetch( + `${API_BASE}/api/apps/${appId}/permissions/${p1}`, + { method: "DELETE", headers: { Cookie: adminCookie } }, + ); + assert.equal(del.status, 204); + assert.deepEqual(await getPairsForApp(appId), [p2]); + + // Re-deleting a pair that no longer exists is a 204 no-op (the row count + // is zero but the contract is idempotent so the UI can safely retry). + const again = await fetch( + `${API_BASE}/api/apps/${appId}/permissions/${p1}`, + { method: "DELETE", headers: { Cookie: adminCookie } }, + ); + assert.equal(again.status, 204); + assert.deepEqual(await getPairsForApp(appId), [p2]); +}); + +test("non-admins receive 403 from every app-permissions admin endpoint", async () => { + const appId = await createApp("apc_forbidden"); + const { id: permId } = await createPermission("apc.forbidden"); + + const list = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, { + headers: { Cookie: nonAdminCookie }, + }); + assert.equal(list.status, 403); + + const add = await fetch(`${API_BASE}/api/apps/${appId}/permissions`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: nonAdminCookie }, + body: JSON.stringify({ permissionId: permId }), + }); + assert.equal(add.status, 403); + + const del = await fetch( + `${API_BASE}/api/apps/${appId}/permissions/${permId}`, + { method: "DELETE", headers: { Cookie: nonAdminCookie } }, + ); + assert.equal(del.status, 403); + + // None of the rejected calls should have written a row. + assert.deepEqual(await getPairsForApp(appId), []); +}); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 9d0eaff0..c25f3e0b 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -348,6 +348,14 @@ "appIcon": "الأيقونة", "appColor": "اللون", "appSortOrder": "الترتيب", + "appPermissions": { + "title": "الصلاحيات المطلوبة", + "help": "يجب أن يمتلك المستخدم على الأقل واحدة منها ليرى التطبيق. اتركها فارغة لإلغاء التقييد.", + "none": "لا توجد صلاحيات مطلوبة — التطبيق ظاهر للجميع.", + "selectPlaceholder": "اختر صلاحية…", + "add": "إضافة", + "removeAria": "إزالة الصلاحية {{name}}" + }, "serviceName": "اسم الخدمة", "serviceNameAr": "اسم الخدمة (عربي)", "serviceNameEn": "اسم الخدمة (إنجليزي)", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 5b877ec3..6f2598c0 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -345,6 +345,14 @@ "appIcon": "Icon", "appColor": "Color", "appSortOrder": "Sort Order", + "appPermissions": { + "title": "Required permissions", + "help": "Users need at least one of these to see the app. Leave empty for no restriction.", + "none": "No permissions required — visible to everyone.", + "selectPlaceholder": "Select a permission…", + "add": "Add", + "removeAria": "Remove permission {{name}}" + }, "serviceName": "Service Name", "serviceNameAr": "Service Name (Arabic)", "serviceNameEn": "Service Name (English)", diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index d8f90220..6567c276 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -5,6 +5,10 @@ import { useCreateApp, useUpdateApp, useDeleteApp, + useListAppPermissions, + getListAppPermissionsQueryKey, + useAddAppPermission, + useRemoveAppPermission, useListServices, getListServicesQueryKey, useCreateService, @@ -230,6 +234,137 @@ function DeletionWarningDialog({ ); } +function AppPermissionsEditor({ appId }: { appId: number }) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + const { data: assigned, isLoading } = useListAppPermissions(appId, { + query: { queryKey: getListAppPermissionsQueryKey(appId) }, + }); + const { data: allPermissions } = useListPermissions({ + query: { queryKey: getListPermissionsQueryKey() }, + }); + const addPermission = useAddAppPermission(); + const removePermission = useRemoveAppPermission(); + const [pendingId, setPendingId] = useState(""); + + const assignedIds = useMemo( + () => new Set((assigned ?? []).map((p) => p.id)), + [assigned], + ); + const available = useMemo( + () => (allPermissions ?? []).filter((p) => !assignedIds.has(p.id)), + [allPermissions, assignedIds], + ); + + const refresh = () => { + queryClient.invalidateQueries({ + queryKey: getListAppPermissionsQueryKey(appId), + }); + queryClient.invalidateQueries({ queryKey: ADMIN_APPS_KEY }); + }; + + const onAdd = () => { + if (pendingId === "" || typeof pendingId !== "number") return; + addPermission.mutate( + { id: appId, data: { permissionId: pendingId } }, + { + onSuccess: () => { + setPendingId(""); + refresh(); + }, + onError: () => { + toast({ title: t("common.error"), variant: "destructive" }); + }, + }, + ); + }; + + const onRemove = (permissionId: number) => { + removePermission.mutate( + { id: appId, permissionId }, + { + onSuccess: refresh, + onError: () => { + toast({ title: t("common.error"), variant: "destructive" }); + }, + }, + ); + }; + + return ( +
+ +

+ {t("admin.appPermissions.help")} +

+ {isLoading ? ( +

{t("common.loading")}

+ ) : (assigned?.length ?? 0) === 0 ? ( +

+ {t("admin.appPermissions.none")} +

+ ) : ( +
    + {assigned!.map((p) => ( +
  • + {p.name} + +
  • + ))} +
+ )} +
+ + +
+
+ ); +} + export default function AdminPage() { const { t, i18n } = useTranslation(); const [, setLocation] = useLocation(); @@ -654,6 +789,9 @@ export default function AdminPage() { /> ))} + {editingApp.id !== undefined && ( + + )}
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index b72f79b9..9ad00724 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -235,6 +235,16 @@ export interface Permission { descriptionEn?: string | null; } +export interface AddAppPermissionBody { + /** ID of the permission to require for this app. */ + permissionId: number; +} + +export interface AppPermissionLink { + appId: number; + permissionId: number; +} + export interface ReplaceRolePermissionsBody { /** Full set of permission IDs the role should grant. Existing assignments not present here are removed. */ permissionIds: number[]; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index d5a0097c..261a5c7e 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -17,6 +17,7 @@ import type { } from "@tanstack/react-query"; import type { + AddAppPermissionBody, AddParticipantsBody, AddUserRoleBody, AdminAppOpensByApp, @@ -25,6 +26,7 @@ import type { AdminStats, App, AppDeletionConflict, + AppPermissionLink, AppSettings, AuditLogList, AuthUser, @@ -1507,6 +1509,274 @@ export const useDeleteApp = < return useMutation(getDeleteAppMutationOptions(options)); }; +/** + * Returns the permissions currently required for users to see this app +in their launcher. An app with no rows here is unrestricted (visible +to anyone). When more than one permission is configured, holding any +one of them is sufficient. + + * @summary List the permissions that gate this app (admin) + */ +export const getListAppPermissionsUrl = (id: number) => { + return `/api/apps/${id}/permissions`; +}; + +export const listAppPermissions = async ( + id: number, + options?: RequestInit, +): Promise => { + return customFetch(getListAppPermissionsUrl(id), { + ...options, + method: "GET", + }); +}; + +export const getListAppPermissionsQueryKey = (id: number) => { + return [`/api/apps/${id}/permissions`] as const; +}; + +export const getListAppPermissionsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListAppPermissionsQueryKey(id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => listAppPermissions(id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListAppPermissionsQueryResult = NonNullable< + Awaited> +>; +export type ListAppPermissionsQueryError = ErrorType; + +/** + * @summary List the permissions that gate this app (admin) + */ + +export function useListAppPermissions< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListAppPermissionsQueryOptions(id, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Adds a row to `app_permissions` for the (app_id, permission_id) pair. +Uses `ON CONFLICT DO NOTHING` against the composite primary key so +re-adding an existing pair is a no-op. + + * @summary Add a required permission to this app (admin) + */ +export const getAddAppPermissionUrl = (id: number) => { + return `/api/apps/${id}/permissions`; +}; + +export const addAppPermission = async ( + id: number, + addAppPermissionBody: AddAppPermissionBody, + options?: RequestInit, +): Promise => { + return customFetch(getAddAppPermissionUrl(id), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(addAppPermissionBody), + }); +}; + +export const getAddAppPermissionMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + const mutationKey = ["addAppPermission"]; + 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>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return addAppPermission(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type AddAppPermissionMutationResult = NonNullable< + Awaited> +>; +export type AddAppPermissionMutationBody = BodyType; +export type AddAppPermissionMutationError = ErrorType; + +/** + * @summary Add a required permission to this app (admin) + */ +export const useAddAppPermission = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + return useMutation(getAddAppPermissionMutationOptions(options)); +}; + +/** + * @summary Remove a required permission from this app (admin) + */ +export const getRemoveAppPermissionUrl = (id: number, permissionId: number) => { + return `/api/apps/${id}/permissions/${permissionId}`; +}; + +export const removeAppPermission = async ( + id: number, + permissionId: number, + options?: RequestInit, +): Promise => { + return customFetch(getRemoveAppPermissionUrl(id, permissionId), { + ...options, + method: "DELETE", + }); +}; + +export const getRemoveAppPermissionMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; permissionId: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; permissionId: number }, + TContext +> => { + const mutationKey = ["removeAppPermission"]; + 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>, + { id: number; permissionId: number } + > = (props) => { + const { id, permissionId } = props ?? {}; + + return removeAppPermission(id, permissionId, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type RemoveAppPermissionMutationResult = NonNullable< + Awaited> +>; + +export type RemoveAppPermissionMutationError = ErrorType; + +/** + * @summary Remove a required permission from this app (admin) + */ +export const useRemoveAppPermission = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; permissionId: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; permissionId: number }, + TContext +> => { + return useMutation(getRemoveAppPermissionMutationOptions(options)); +}; + /** * @summary Log an app open event for the current user */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index ab75ab2b..058ecc2b 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -405,6 +405,97 @@ paths: schema: $ref: "#/components/schemas/AppDeletionConflict" + /apps/{id}/permissions: + get: + operationId: listAppPermissions + tags: [apps] + summary: List the permissions that gate this app (admin) + description: | + Returns the permissions currently required for users to see this app + in their launcher. An app with no rows here is unrestricted (visible + to anyone). When more than one permission is configured, holding any + one of them is sufficient. + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + "200": + description: Permissions gating the app + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Permission" + "404": + description: App not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + post: + operationId: addAppPermission + tags: [apps] + summary: Add a required permission to this app (admin) + description: | + Adds a row to `app_permissions` for the (app_id, permission_id) pair. + Uses `ON CONFLICT DO NOTHING` against the composite primary key so + re-adding an existing pair is a no-op. + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AddAppPermissionBody" + responses: + "201": + description: Permission requirement added (or already present) + content: + application/json: + schema: + $ref: "#/components/schemas/AppPermissionLink" + "404": + description: App or permission not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /apps/{id}/permissions/{permissionId}: + delete: + operationId: removeAppPermission + tags: [apps] + summary: Remove a required permission from this app (admin) + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: permissionId + in: path + required: true + schema: + type: integer + responses: + "204": + description: Removed (or no row existed for the pair) + "404": + description: App not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /apps/{id}/open: post: operationId: logAppOpen @@ -2429,6 +2520,26 @@ components: - id - name + AddAppPermissionBody: + type: object + properties: + permissionId: + type: integer + description: ID of the permission to require for this app. + required: + - permissionId + + AppPermissionLink: + type: object + properties: + appId: + type: integer + permissionId: + type: integer + required: + - appId + - permissionId + ReplaceRolePermissionsBody: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 70cf2c0b..542dd7fa 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -447,6 +447,53 @@ export const DeleteAppQueryParams = zod.object({ ), }); +/** + * Returns the permissions currently required for users to see this app +in their launcher. An app with no rows here is unrestricted (visible +to anyone). When more than one permission is configured, holding any +one of them is sufficient. + + * @summary List the permissions that gate this app (admin) + */ +export const ListAppPermissionsParams = zod.object({ + id: zod.coerce.number(), +}); + +export const ListAppPermissionsResponseItem = zod.object({ + id: zod.number(), + name: zod.string(), + descriptionAr: zod.string().nullish(), + descriptionEn: zod.string().nullish(), +}); +export const ListAppPermissionsResponse = zod.array( + ListAppPermissionsResponseItem, +); + +/** + * Adds a row to `app_permissions` for the (app_id, permission_id) pair. +Uses `ON CONFLICT DO NOTHING` against the composite primary key so +re-adding an existing pair is a no-op. + + * @summary Add a required permission to this app (admin) + */ +export const AddAppPermissionParams = zod.object({ + id: zod.coerce.number(), +}); + +export const AddAppPermissionBody = zod.object({ + permissionId: zod + .number() + .describe("ID of the permission to require for this app."), +}); + +/** + * @summary Remove a required permission from this app (admin) + */ +export const RemoveAppPermissionParams = zod.object({ + id: zod.coerce.number(), + permissionId: zod.coerce.number(), +}); + /** * @summary Log an app open event for the current user */