Task #109: Admin UI to manage app required permissions

- 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.

Replit-Task-Id: 912bb163-6b6f-43d3-9934-41fc97519337
This commit is contained in:
riyadhafraa
2026-04-29 15:31:42 +00:00
parent 2982a28432
commit 8b2fe3164d
9 changed files with 975 additions and 0 deletions
+109
View File
@@ -12,6 +12,7 @@ import {
userGroupsTable, userGroupsTable,
groupAppsTable, groupAppsTable,
auditLogsTable, auditLogsTable,
permissionsTable,
} from "@workspace/db"; } from "@workspace/db";
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth"; import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
import { import {
@@ -315,6 +316,114 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
res.json(app); res.json(app);
}); });
router.get("/apps/:id/permissions", requireAdmin, async (req, res): Promise<void> => {
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<void> => {
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<void> => {
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<void> => { router.post("/apps/:id/open", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!; const userId = req.session.userId!;
const params = GetAppParams.safeParse(req.params); const params = GetAppParams.safeParse(req.params);
@@ -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), []);
});
+8
View File
@@ -348,6 +348,14 @@
"appIcon": "الأيقونة", "appIcon": "الأيقونة",
"appColor": "اللون", "appColor": "اللون",
"appSortOrder": "الترتيب", "appSortOrder": "الترتيب",
"appPermissions": {
"title": "الصلاحيات المطلوبة",
"help": "يجب أن يمتلك المستخدم على الأقل واحدة منها ليرى التطبيق. اتركها فارغة لإلغاء التقييد.",
"none": "لا توجد صلاحيات مطلوبة — التطبيق ظاهر للجميع.",
"selectPlaceholder": "اختر صلاحية…",
"add": "إضافة",
"removeAria": "إزالة الصلاحية {{name}}"
},
"serviceName": "اسم الخدمة", "serviceName": "اسم الخدمة",
"serviceNameAr": "اسم الخدمة (عربي)", "serviceNameAr": "اسم الخدمة (عربي)",
"serviceNameEn": "اسم الخدمة (إنجليزي)", "serviceNameEn": "اسم الخدمة (إنجليزي)",
+8
View File
@@ -345,6 +345,14 @@
"appIcon": "Icon", "appIcon": "Icon",
"appColor": "Color", "appColor": "Color",
"appSortOrder": "Sort Order", "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", "serviceName": "Service Name",
"serviceNameAr": "Service Name (Arabic)", "serviceNameAr": "Service Name (Arabic)",
"serviceNameEn": "Service Name (English)", "serviceNameEn": "Service Name (English)",
+138
View File
@@ -5,6 +5,10 @@ import {
useCreateApp, useCreateApp,
useUpdateApp, useUpdateApp,
useDeleteApp, useDeleteApp,
useListAppPermissions,
getListAppPermissionsQueryKey,
useAddAppPermission,
useRemoveAppPermission,
useListServices, useListServices,
getListServicesQueryKey, getListServicesQueryKey,
useCreateService, 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<number | "">("");
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 (
<div
className="space-y-2 border-t border-slate-200/70 pt-3"
data-testid="app-permissions-editor"
>
<Label>{t("admin.appPermissions.title")}</Label>
<p className="text-xs text-muted-foreground">
{t("admin.appPermissions.help")}
</p>
{isLoading ? (
<p className="text-xs text-muted-foreground">{t("common.loading")}</p>
) : (assigned?.length ?? 0) === 0 ? (
<p className="text-xs text-muted-foreground">
{t("admin.appPermissions.none")}
</p>
) : (
<ul className="space-y-1" data-testid="app-permissions-list">
{assigned!.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-2 rounded-xl bg-white/70 border border-slate-200 px-3 py-1.5"
>
<span className="text-sm text-foreground truncate">{p.name}</span>
<button
type="button"
aria-label={t("admin.appPermissions.removeAria", {
name: p.name,
})}
className="text-muted-foreground hover:text-destructive transition-colors"
disabled={removePermission.isPending}
onClick={() => onRemove(p.id)}
data-testid={`app-permission-remove-${p.id}`}
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2">
<select
className="flex-1 rounded-xl bg-white/70 border border-slate-200 px-3 py-2 text-sm"
value={pendingId === "" ? "" : String(pendingId)}
onChange={(e) =>
setPendingId(e.target.value === "" ? "" : Number(e.target.value))
}
disabled={available.length === 0 || addPermission.isPending}
data-testid="app-permission-select"
>
<option value="">{t("admin.appPermissions.selectPlaceholder")}</option>
{available.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<Button
size="sm"
onClick={onAdd}
disabled={
pendingId === "" ||
addPermission.isPending ||
available.length === 0
}
data-testid="app-permission-add"
>
{t("admin.appPermissions.add")}
</Button>
</div>
</div>
);
}
export default function AdminPage() { export default function AdminPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
@@ -654,6 +789,9 @@ export default function AdminPage() {
/> />
</div> </div>
))} ))}
{editingApp.id !== undefined && (
<AppPermissionsEditor appId={editingApp.id} />
)}
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => setEditingApp(null)}>{t("common.cancel")}</Button> <Button variant="outline" className="flex-1" onClick={() => setEditingApp(null)}>{t("common.cancel")}</Button>
<Button className="flex-1" onClick={handleSaveApp}>{t("common.save")}</Button> <Button className="flex-1" onClick={handleSaveApp}>{t("common.save")}</Button>
@@ -235,6 +235,16 @@ export interface Permission {
descriptionEn?: string | null; 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 { export interface ReplaceRolePermissionsBody {
/** Full set of permission IDs the role should grant. Existing assignments not present here are removed. */ /** Full set of permission IDs the role should grant. Existing assignments not present here are removed. */
permissionIds: number[]; permissionIds: number[];
+270
View File
@@ -17,6 +17,7 @@ import type {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import type { import type {
AddAppPermissionBody,
AddParticipantsBody, AddParticipantsBody,
AddUserRoleBody, AddUserRoleBody,
AdminAppOpensByApp, AdminAppOpensByApp,
@@ -25,6 +26,7 @@ import type {
AdminStats, AdminStats,
App, App,
AppDeletionConflict, AppDeletionConflict,
AppPermissionLink,
AppSettings, AppSettings,
AuditLogList, AuditLogList,
AuthUser, AuthUser,
@@ -1507,6 +1509,274 @@ export const useDeleteApp = <
return useMutation(getDeleteAppMutationOptions(options)); 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<Permission[]> => {
return customFetch<Permission[]>(getListAppPermissionsUrl(id), {
...options,
method: "GET",
});
};
export const getListAppPermissionsQueryKey = (id: number) => {
return [`/api/apps/${id}/permissions`] as const;
};
export const getListAppPermissionsQueryOptions = <
TData = Awaited<ReturnType<typeof listAppPermissions>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAppPermissions>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListAppPermissionsQueryKey(id);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listAppPermissions>>
> = ({ signal }) => listAppPermissions(id, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listAppPermissions>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListAppPermissionsQueryResult = NonNullable<
Awaited<ReturnType<typeof listAppPermissions>>
>;
export type ListAppPermissionsQueryError = ErrorType<ErrorResponse>;
/**
* @summary List the permissions that gate this app (admin)
*/
export function useListAppPermissions<
TData = Awaited<ReturnType<typeof listAppPermissions>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAppPermissions>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListAppPermissionsQueryOptions(id, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
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<AppPermissionLink> => {
return customFetch<AppPermissionLink>(getAddAppPermissionUrl(id), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(addAppPermissionBody),
});
};
export const getAddAppPermissionMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof addAppPermission>>,
TError,
{ id: number; data: BodyType<AddAppPermissionBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof addAppPermission>>,
TError,
{ id: number; data: BodyType<AddAppPermissionBody> },
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<ReturnType<typeof addAppPermission>>,
{ id: number; data: BodyType<AddAppPermissionBody> }
> = (props) => {
const { id, data } = props ?? {};
return addAppPermission(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type AddAppPermissionMutationResult = NonNullable<
Awaited<ReturnType<typeof addAppPermission>>
>;
export type AddAppPermissionMutationBody = BodyType<AddAppPermissionBody>;
export type AddAppPermissionMutationError = ErrorType<ErrorResponse>;
/**
* @summary Add a required permission to this app (admin)
*/
export const useAddAppPermission = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof addAppPermission>>,
TError,
{ id: number; data: BodyType<AddAppPermissionBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof addAppPermission>>,
TError,
{ id: number; data: BodyType<AddAppPermissionBody> },
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<void> => {
return customFetch<void>(getRemoveAppPermissionUrl(id, permissionId), {
...options,
method: "DELETE",
});
};
export const getRemoveAppPermissionMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof removeAppPermission>>,
TError,
{ id: number; permissionId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof removeAppPermission>>,
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<ReturnType<typeof removeAppPermission>>,
{ id: number; permissionId: number }
> = (props) => {
const { id, permissionId } = props ?? {};
return removeAppPermission(id, permissionId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type RemoveAppPermissionMutationResult = NonNullable<
Awaited<ReturnType<typeof removeAppPermission>>
>;
export type RemoveAppPermissionMutationError = ErrorType<ErrorResponse>;
/**
* @summary Remove a required permission from this app (admin)
*/
export const useRemoveAppPermission = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof removeAppPermission>>,
TError,
{ id: number; permissionId: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof removeAppPermission>>,
TError,
{ id: number; permissionId: number },
TContext
> => {
return useMutation(getRemoveAppPermissionMutationOptions(options));
};
/** /**
* @summary Log an app open event for the current user * @summary Log an app open event for the current user
*/ */
+111
View File
@@ -405,6 +405,97 @@ paths:
schema: schema:
$ref: "#/components/schemas/AppDeletionConflict" $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: /apps/{id}/open:
post: post:
operationId: logAppOpen operationId: logAppOpen
@@ -2429,6 +2520,26 @@ components:
- id - id
- name - 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: ReplaceRolePermissionsBody:
type: object type: object
properties: properties:
+47
View File
@@ -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 * @summary Log an app open event for the current user
*/ */