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:
Riyadh
2026-05-19 11:06:21 +00:00
parent e3dc60b24b
commit d7d8ab3a2a
11 changed files with 637 additions and 3 deletions
@@ -763,6 +763,15 @@ export interface AppSettings {
updatedAt?: string;
}
export type MyApp = App & {
/** True when the current user has hidden this app from their Home/Dock. */
hidden: boolean;
};
export interface UpdateMyAppHiddenBody {
hidden: boolean;
}
export interface UpdateMyAppOrderBody {
/** App IDs in the desired display order */
order: number[];
+168
View File
@@ -80,6 +80,7 @@ import type {
ListReceivedNotesParams,
ListUsersParams,
LoginBody,
MyApp,
MyNote,
NoteFolder,
NoteReply,
@@ -118,6 +119,7 @@ import type {
UpdateClockStyleBody,
UpdateGroupBody,
UpdateLanguageBody,
UpdateMyAppHiddenBody,
UpdateMyAppOrderBody,
UpdateNoteFolderBody,
UpdateNoteFolderParams,
@@ -2252,6 +2254,172 @@ export const useUpdateMyAppOrder = <
return useMutation(getUpdateMyAppOrderMutationOptions(options));
};
/**
* 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 getListMyAppsUrl = () => {
return `/api/me/apps`;
};
export const listMyApps = async (options?: RequestInit): Promise<MyApp[]> => {
return customFetch<MyApp[]>(getListMyAppsUrl(), {
...options,
method: "GET",
});
};
export const getListMyAppsQueryKey = () => {
return [`/api/me/apps`] as const;
};
export const getListMyAppsQueryOptions = <
TData = Awaited<ReturnType<typeof listMyApps>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMyApps>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListMyAppsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listMyApps>>> = ({
signal,
}) => listMyApps({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listMyApps>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListMyAppsQueryResult = NonNullable<
Awaited<ReturnType<typeof listMyApps>>
>;
export type ListMyAppsQueryError = ErrorType<unknown>;
/**
* @summary List apps for the current user with their per-user hidden state
*/
export function useListMyApps<
TData = Awaited<ReturnType<typeof listMyApps>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMyApps>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListMyAppsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Hide or show an app for the current user
*/
export const getUpdateMyAppHiddenUrl = (appId: number) => {
return `/api/me/apps/${appId}/hidden`;
};
export const updateMyAppHidden = async (
appId: number,
updateMyAppHiddenBody: UpdateMyAppHiddenBody,
options?: RequestInit,
): Promise<MyApp> => {
return customFetch<MyApp>(getUpdateMyAppHiddenUrl(appId), {
...options,
method: "PUT",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateMyAppHiddenBody),
});
};
export const getUpdateMyAppHiddenMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMyAppHidden>>,
TError,
{ appId: number; data: BodyType<UpdateMyAppHiddenBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateMyAppHidden>>,
TError,
{ appId: number; data: BodyType<UpdateMyAppHiddenBody> },
TContext
> => {
const mutationKey = ["updateMyAppHidden"];
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 updateMyAppHidden>>,
{ appId: number; data: BodyType<UpdateMyAppHiddenBody> }
> = (props) => {
const { appId, data } = props ?? {};
return updateMyAppHidden(appId, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateMyAppHiddenMutationResult = NonNullable<
Awaited<ReturnType<typeof updateMyAppHidden>>
>;
export type UpdateMyAppHiddenMutationBody = BodyType<UpdateMyAppHiddenBody>;
export type UpdateMyAppHiddenMutationError = ErrorType<ErrorResponse>;
/**
* @summary Hide or show an app for the current user
*/
export const useUpdateMyAppHidden = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateMyAppHidden>>,
TError,
{ appId: number; data: BodyType<UpdateMyAppHiddenBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateMyAppHidden>>,
TError,
{ appId: number; data: BodyType<UpdateMyAppHiddenBody> },
TContext
> => {
return useMutation(getUpdateMyAppHiddenMutationOptions(options));
};
/**
* @summary Get application settings (public)
*/
+75
View File
@@ -633,6 +633,64 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/me/apps:
get:
operationId: listMyApps
tags: [apps]
summary: List apps for the current user with their per-user hidden state
description: |
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.
responses:
"200":
description: Apps visible to the current user, with per-user hidden state
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/MyApp"
/me/apps/{appId}/hidden:
put:
operationId: updateMyAppHidden
tags: [apps]
summary: Hide or show an app for the current user
parameters:
- name: appId
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateMyAppHiddenBody"
responses:
"200":
description: Updated per-user app state
content:
application/json:
schema:
$ref: "#/components/schemas/MyApp"
"400":
description: Validation error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: App not found or not visible to the current user
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# Settings
/settings:
get:
@@ -4231,6 +4289,23 @@ components:
type: string
format: date-time
MyApp:
allOf:
- $ref: "#/components/schemas/App"
- type: object
required: [hidden]
properties:
hidden:
type: boolean
description: True when the current user has hidden this app from their Home/Dock.
UpdateMyAppHiddenBody:
type: object
required: [hidden]
properties:
hidden:
type: boolean
UpdateMyAppOrderBody:
type: object
required: [order]
+145
View File
@@ -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)
*/
+1
View File
@@ -6,6 +6,7 @@ export * from "./service-orders";
export * from "./notifications";
export * from "./settings";
export * from "./user-app-orders";
export * from "./user-hidden-apps";
export * from "./app-opens";
export * from "./password-reset-tokens";
export * from "./notes";
+21
View File
@@ -0,0 +1,21 @@
import { pgTable, integer, primaryKey, timestamp } from "drizzle-orm/pg-core";
import { usersTable } from "./users";
import { appsTable } from "./apps";
export const userHiddenAppsTable = pgTable(
"user_hidden_apps",
{
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
appId: integer("app_id")
.notNull()
.references(() => appsTable.id, { onDelete: "cascade" }),
hiddenAt: timestamp("hidden_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [primaryKey({ columns: [t.userId, t.appId] })],
);
export type UserHiddenApp = typeof userHiddenAppsTable.$inferSelect;