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:
@@ -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<void> => {
|
||||
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> => {
|
||||
const userId = req.session.userId!;
|
||||
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), []);
|
||||
});
|
||||
@@ -348,6 +348,14 @@
|
||||
"appIcon": "الأيقونة",
|
||||
"appColor": "اللون",
|
||||
"appSortOrder": "الترتيب",
|
||||
"appPermissions": {
|
||||
"title": "الصلاحيات المطلوبة",
|
||||
"help": "يجب أن يمتلك المستخدم على الأقل واحدة منها ليرى التطبيق. اتركها فارغة لإلغاء التقييد.",
|
||||
"none": "لا توجد صلاحيات مطلوبة — التطبيق ظاهر للجميع.",
|
||||
"selectPlaceholder": "اختر صلاحية…",
|
||||
"add": "إضافة",
|
||||
"removeAria": "إزالة الصلاحية {{name}}"
|
||||
},
|
||||
"serviceName": "اسم الخدمة",
|
||||
"serviceNameAr": "اسم الخدمة (عربي)",
|
||||
"serviceNameEn": "اسم الخدمة (إنجليزي)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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<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() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
@@ -654,6 +789,9 @@ export default function AdminPage() {
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{editingApp.id !== undefined && (
|
||||
<AppPermissionsEditor appId={editingApp.id} />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setEditingApp(null)}>{t("common.cancel")}</Button>
|
||||
<Button className="flex-1" onClick={handleSaveApp}>{t("common.save")}</Button>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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<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
|
||||
*/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user