Files
TX/artifacts/api-server/src/lib/appsVisibility.ts
T
Riyadh d7d8ab3a2a Task #609: Per-user app enable/disable in Settings
- New `user_hidden_apps` table (userId+appId composite PK, cascade) in
  lib/db/src/schema/user-hidden-apps.ts; registered in schema index;
  pushed to dev DB via drizzle-kit.
- Backend (artifacts/api-server/src/routes/apps.ts):
  - GET /me/apps — returns every globally-active app visible to the
    user with a `hidden` flag.
  - PUT /me/apps/:appId/hidden — toggles a row in user_hidden_apps,
    gated by getVisibleAppsForUser + isActive so a user can't toggle
    apps they can't reach.
  - GET /apps (Home/Dock) now uses getVisibleNonHiddenAppsForUser so
    hidden apps disappear immediately.
- lib/appsVisibility.ts: added getHiddenAppIdsForUser and
  getVisibleNonHiddenAppsForUser helpers.
- OpenAPI: added /me/apps + /me/apps/{appId}/hidden, MyApp and
  UpdateMyAppHiddenBody schemas; regenerated api-zod + api-client-react.
- Frontend (settings-panel.tsx): added "My apps" accordion section as
  first GroupItem with MyAppsBody — Switch per app, optimistic update,
  invalidates getListMyAppsQueryKey + getListAppsQueryKey so Home/Dock
  refresh without reload.
- Translations: added settingsPanel.section.myApps + settingsPanel.myApps
  in ar.json + en.json.
- Code review fix: /me/apps and PUT gating filter isActive even for
  admins, so inactive apps don't appear in the Settings list.
- Proposed follow-up #611 (Playwright test that hidden apps disappear
  from Home and Dock).
2026-05-19 11:06:21 +00:00

137 lines
4.2 KiB
TypeScript

import {
db,
appsTable,
appPermissionsTable,
rolePermissionsTable,
rolesTable,
userAppOrdersTable,
userGroupsTable,
groupAppsTable,
userHiddenAppsTable,
} from "@workspace/db";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { getEffectiveRoleIds } from "../middlewares/auth";
/**
* Returns the apps visible to a given user, applying the same RBAC
* rules used by GET /apps:
* - admins see every app (including inactive)
* - non-admins see active apps that are either unrestricted, granted
* to one of their roles via app_permissions, or assigned to one of
* their groups via group_apps
*
* Lives in lib/ (not routes/) so the storage authorization helper
* (lib/objectAuthz.ts) can reuse the exact same visibility check
* without depending on the routes layer.
*/
export async function getVisibleAppsForUser(
userId: number,
): Promise<typeof appsTable.$inferSelect[]> {
const effectiveRoleIds = await getEffectiveRoleIds(userId);
const adminRoleRows = effectiveRoleIds.length > 0
? await db
.select({ id: rolesTable.id })
.from(rolesTable)
.where(and(inArray(rolesTable.id, effectiveRoleIds), eq(rolesTable.name, "admin")))
: [];
const isAdmin = adminRoleRows.length > 0;
const baseQuery = db
.select({
app: appsTable,
userSort: userAppOrdersTable.sortOrder,
})
.from(appsTable)
.leftJoin(
userAppOrdersTable,
sql`${userAppOrdersTable.appId} = ${appsTable.id} and ${userAppOrdersTable.userId} = ${userId}`,
)
.$dynamic();
const orderedRows = await (isAdmin
? baseQuery
: baseQuery.where(eq(appsTable.isActive, true))
).orderBy(
sql`COALESCE(${userAppOrdersTable.sortOrder}, ${appsTable.sortOrder})`,
asc(appsTable.nameEn),
);
const apps = orderedRows.map((r) => r.app);
if (isAdmin) return apps;
const userRoleIds = effectiveRoleIds;
const userPermissionIds = userRoleIds.length > 0
? (await db
.select({ permissionId: rolePermissionsTable.permissionId })
.from(rolePermissionsTable)
.where(inArray(rolePermissionsTable.roleId, userRoleIds))
).map((r) => r.permissionId)
: [];
const appsWithPermissions = await db
.select({ appId: appPermissionsTable.appId })
.from(appPermissionsTable);
const restrictedAppIds = new Set(appsWithPermissions.map((r) => r.appId));
if (restrictedAppIds.size === 0) return apps;
const allowedRestrictedAppIds = userPermissionIds.length > 0
? (await db
.select({ appId: appPermissionsTable.appId })
.from(appPermissionsTable)
.where(inArray(appPermissionsTable.permissionId, userPermissionIds))
).map((r) => r.appId)
: [];
const groupGrantedAppIds = (
await db
.select({ appId: groupAppsTable.appId })
.from(groupAppsTable)
.innerJoin(
userGroupsTable,
eq(userGroupsTable.groupId, groupAppsTable.groupId),
)
.where(eq(userGroupsTable.userId, userId))
).map((r) => r.appId);
const groupGrantedSet = new Set(groupGrantedAppIds);
const allowedAppIdSet = new Set(allowedRestrictedAppIds);
return apps.filter(
(app) =>
!restrictedAppIds.has(app.id) ||
allowedAppIdSet.has(app.id) ||
groupGrantedSet.has(app.id),
);
}
/**
* Returns the set of app IDs the given user has personally hidden from
* their Home/Dock via PUT /me/apps/{appId}/hidden.
*/
export async function getHiddenAppIdsForUser(
userId: number,
): Promise<Set<number>> {
const rows = await db
.select({ appId: userHiddenAppsTable.appId })
.from(userHiddenAppsTable)
.where(eq(userHiddenAppsTable.userId, userId));
return new Set(rows.map((r) => r.appId));
}
/**
* Like getVisibleAppsForUser but additionally strips apps the user has
* personally hidden. Used by GET /apps which backs Home + Dock so the
* hidden toggle in Settings takes effect immediately.
*/
export async function getVisibleNonHiddenAppsForUser(
userId: number,
): Promise<typeof appsTable.$inferSelect[]> {
const visible = await getVisibleAppsForUser(userId);
const hidden = await getHiddenAppIdsForUser(userId);
if (hidden.size === 0) return visible;
return visible.filter((a) => !hidden.has(a.id));
}