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).
This commit is contained in:
@@ -994,6 +994,151 @@ export const UpdateMyAppOrderResponseItem = zod.object({
|
||||
});
|
||||
export const UpdateMyAppOrderResponse = zod.array(UpdateMyAppOrderResponseItem);
|
||||
|
||||
/**
|
||||
* Returns every globally active app (regardless of the per-user hidden
|
||||
flag), each with a `hidden` boolean indicating whether the current
|
||||
user has hidden it from their own Home/Dock via PUT /me/apps/{appId}/hidden.
|
||||
Used by the per-user Settings panel; the Home and Dock continue to use
|
||||
GET /apps which already filters out hidden apps server-side.
|
||||
|
||||
* @summary List apps for the current user with their per-user hidden state
|
||||
*/
|
||||
export const ListMyAppsResponseItem = zod
|
||||
.object({
|
||||
id: zod.number(),
|
||||
slug: zod.string(),
|
||||
nameAr: zod.string(),
|
||||
nameEn: zod.string(),
|
||||
descriptionAr: zod.string().nullish(),
|
||||
descriptionEn: zod.string().nullish(),
|
||||
iconName: zod.string(),
|
||||
imageUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'Optional uploaded image used in place of the Lucide icon on the\nlauncher tile. Stored as the object-storage path returned by the\nupload presigner (e.g. \"\/objects\/<id>\").\n',
|
||||
),
|
||||
route: zod.string(),
|
||||
externalUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'For openMode \"external_tab\" or \"external_iframe\": the URL the\nlauncher should open. Ignored when openMode is \"internal\".\n',
|
||||
),
|
||||
openMode: zod
|
||||
.enum(["internal", "external_tab", "external_iframe"])
|
||||
.describe(
|
||||
'How the launcher opens this app: \"internal\" navigates to `route`\ninside the SPA; \"external_tab\" opens externalUrl in a new tab;\n\"external_iframe\" navigates to \/embedded\/:id, which renders\nexternalUrl inside an iframe shell.\n',
|
||||
),
|
||||
color: zod.string(),
|
||||
isActive: zod.boolean(),
|
||||
isSystem: zod.boolean(),
|
||||
sortOrder: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
groupCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of groups granting access to this app. Populated only by the\nadmin list endpoint (GET \/admin\/apps) so the delete dialog can warn\nbefore the first click. Omitted from non-admin and single-app\nresponses.\n",
|
||||
),
|
||||
restrictionCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of legacy permission restrictions for this app. Populated\nonly by the admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
openCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of recorded app-open events for this app. Populated only by\nthe admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
})
|
||||
.and(
|
||||
zod.object({
|
||||
hidden: zod
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the current user has hidden this app from their Home\/Dock.",
|
||||
),
|
||||
}),
|
||||
);
|
||||
export const ListMyAppsResponse = zod.array(ListMyAppsResponseItem);
|
||||
|
||||
/**
|
||||
* @summary Hide or show an app for the current user
|
||||
*/
|
||||
export const UpdateMyAppHiddenParams = zod.object({
|
||||
appId: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const UpdateMyAppHiddenBody = zod.object({
|
||||
hidden: zod.boolean(),
|
||||
});
|
||||
|
||||
export const UpdateMyAppHiddenResponse = zod
|
||||
.object({
|
||||
id: zod.number(),
|
||||
slug: zod.string(),
|
||||
nameAr: zod.string(),
|
||||
nameEn: zod.string(),
|
||||
descriptionAr: zod.string().nullish(),
|
||||
descriptionEn: zod.string().nullish(),
|
||||
iconName: zod.string(),
|
||||
imageUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'Optional uploaded image used in place of the Lucide icon on the\nlauncher tile. Stored as the object-storage path returned by the\nupload presigner (e.g. \"\/objects\/<id>\").\n',
|
||||
),
|
||||
route: zod.string(),
|
||||
externalUrl: zod
|
||||
.string()
|
||||
.nullish()
|
||||
.describe(
|
||||
'For openMode \"external_tab\" or \"external_iframe\": the URL the\nlauncher should open. Ignored when openMode is \"internal\".\n',
|
||||
),
|
||||
openMode: zod
|
||||
.enum(["internal", "external_tab", "external_iframe"])
|
||||
.describe(
|
||||
'How the launcher opens this app: \"internal\" navigates to `route`\ninside the SPA; \"external_tab\" opens externalUrl in a new tab;\n\"external_iframe\" navigates to \/embedded\/:id, which renders\nexternalUrl inside an iframe shell.\n',
|
||||
),
|
||||
color: zod.string(),
|
||||
isActive: zod.boolean(),
|
||||
isSystem: zod.boolean(),
|
||||
sortOrder: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
groupCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of groups granting access to this app. Populated only by the\nadmin list endpoint (GET \/admin\/apps) so the delete dialog can warn\nbefore the first click. Omitted from non-admin and single-app\nresponses.\n",
|
||||
),
|
||||
restrictionCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of legacy permission restrictions for this app. Populated\nonly by the admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
openCount: zod
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Number of recorded app-open events for this app. Populated only by\nthe admin list endpoint (GET \/admin\/apps).\n",
|
||||
),
|
||||
})
|
||||
.and(
|
||||
zod.object({
|
||||
hidden: zod
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the current user has hidden this app from their Home\/Dock.",
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @summary Get application settings (public)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user