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:
@@ -7,6 +7,7 @@ import {
|
||||
userAppOrdersTable,
|
||||
userGroupsTable,
|
||||
groupAppsTable,
|
||||
userHiddenAppsTable,
|
||||
} from "@workspace/db";
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { getEffectiveRoleIds } from "../middlewares/auth";
|
||||
@@ -105,3 +106,31 @@ export async function getVisibleAppsForUser(
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
auditLogsTable,
|
||||
permissionsTable,
|
||||
usersTable,
|
||||
userHiddenAppsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
|
||||
import {
|
||||
@@ -27,7 +28,11 @@ import { emitAppsChangedToPermissionHolders } from "../lib/realtime";
|
||||
// Re-exported from lib/appsVisibility so storage authorization
|
||||
// (lib/objectAuthz.ts) can use the exact same RBAC without taking a
|
||||
// dependency on this routes module.
|
||||
import { getVisibleAppsForUser } from "../lib/appsVisibility";
|
||||
import {
|
||||
getVisibleAppsForUser,
|
||||
getVisibleNonHiddenAppsForUser,
|
||||
getHiddenAppIdsForUser,
|
||||
} from "../lib/appsVisibility";
|
||||
export { getVisibleAppsForUser };
|
||||
import {
|
||||
CreateAppBody,
|
||||
@@ -42,10 +47,71 @@ const router: IRouter = Router();
|
||||
|
||||
router.get("/apps", requireAuth, async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const visible = await getVisibleAppsForUser(userId);
|
||||
// Home + Dock want the user's effective list, so apply the per-user
|
||||
// hidden filter here. The Settings panel calls GET /me/apps below to
|
||||
// see the full unfiltered list with the hidden flag for toggling.
|
||||
const visible = await getVisibleNonHiddenAppsForUser(userId);
|
||||
res.json(visible);
|
||||
});
|
||||
|
||||
router.get("/me/apps", requireAuth, async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
// Exclude globally-inactive apps even for admins — the per-user
|
||||
// Settings list is "which of my apps do I want visible", and the
|
||||
// user can't reach inactive apps from Home/Dock anyway.
|
||||
const visible = (await getVisibleAppsForUser(userId)).filter(
|
||||
(a) => a.isActive,
|
||||
);
|
||||
const hidden = await getHiddenAppIdsForUser(userId);
|
||||
res.json(visible.map((a) => ({ ...a, hidden: hidden.has(a.id) })));
|
||||
});
|
||||
|
||||
router.put(
|
||||
"/me/apps/:appId/hidden",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const userId = req.session.userId!;
|
||||
const appId = Number(req.params.appId);
|
||||
if (!Number.isInteger(appId) || appId <= 0) {
|
||||
res.status(400).json({ error: "Invalid appId" });
|
||||
return;
|
||||
}
|
||||
const body = req.body as { hidden?: unknown } | undefined;
|
||||
if (typeof body?.hidden !== "boolean") {
|
||||
res.status(400).json({ error: "hidden must be a boolean" });
|
||||
return;
|
||||
}
|
||||
// Gate on the user's actual visibility so a non-admin can't toggle
|
||||
// a hidden flag for an app they shouldn't even know exists.
|
||||
// Also require the app to be globally active so we don't persist
|
||||
// user_hidden_apps rows for apps the user can't reach anyway.
|
||||
const visible = (await getVisibleAppsForUser(userId)).filter(
|
||||
(a) => a.isActive,
|
||||
);
|
||||
const app = visible.find((a) => a.id === appId);
|
||||
if (!app) {
|
||||
res.status(404).json({ error: "App not found" });
|
||||
return;
|
||||
}
|
||||
if (body.hidden) {
|
||||
await db
|
||||
.insert(userHiddenAppsTable)
|
||||
.values({ userId, appId })
|
||||
.onConflictDoNothing();
|
||||
} else {
|
||||
await db
|
||||
.delete(userHiddenAppsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(userHiddenAppsTable.userId, userId),
|
||||
eq(userHiddenAppsTable.appId, appId),
|
||||
),
|
||||
);
|
||||
}
|
||||
res.json({ ...app, hidden: body.hidden });
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/admin/apps", requireAdmin, async (_req, res): Promise<void> => {
|
||||
const allApps = await db
|
||||
.select()
|
||||
|
||||
@@ -4,7 +4,14 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useUpdateLanguage,
|
||||
getGetMeQueryKey,
|
||||
useListMyApps,
|
||||
useUpdateMyAppHidden,
|
||||
getListMyAppsQueryKey,
|
||||
getListAppsQueryKey,
|
||||
type MyApp,
|
||||
} from "@workspace/api-client-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
Volume2,
|
||||
@@ -132,6 +139,106 @@ function LanguageBody() {
|
||||
);
|
||||
}
|
||||
|
||||
type IconName = keyof typeof LucideIcons;
|
||||
function isIconName(x: string): x is IconName {
|
||||
return x in LucideIcons;
|
||||
}
|
||||
function resolveAppIcon(name: string): LucideIcon {
|
||||
if (isIconName(name)) {
|
||||
const Candidate = LucideIcons[name] as unknown;
|
||||
if (typeof Candidate === "function" || typeof Candidate === "object") {
|
||||
return Candidate as LucideIcon;
|
||||
}
|
||||
}
|
||||
return LucideIcons.Grid2X2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user Home/Dock app visibility. Each row is a Switch that toggles a
|
||||
* row in `user_hidden_apps` for the current user. The mutation invalidates
|
||||
* both this list and the GET /apps query that backs Home + Dock so the
|
||||
* change reflects everywhere immediately without a reload.
|
||||
*/
|
||||
function MyAppsBody() {
|
||||
const { t, i18n: i18nInstance } = useTranslation();
|
||||
const lang = i18nInstance.language;
|
||||
const queryClient = useQueryClient();
|
||||
const { data: apps, isLoading } = useListMyApps({
|
||||
query: { queryKey: getListMyAppsQueryKey() },
|
||||
});
|
||||
const update = useUpdateMyAppHidden({
|
||||
mutation: {
|
||||
onMutate: async ({ appId, data }) => {
|
||||
const key = getListMyAppsQueryKey();
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
const previous = queryClient.getQueryData<MyApp[]>(key);
|
||||
if (previous) {
|
||||
queryClient.setQueryData<MyApp[]>(
|
||||
key,
|
||||
previous.map((a) =>
|
||||
a.id === appId ? { ...a, hidden: data.hidden } : a,
|
||||
),
|
||||
);
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.previous) {
|
||||
queryClient.setQueryData(getListMyAppsQueryKey(), ctx.previous);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getListMyAppsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{t("settingsPanel.myApps.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!apps || apps.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
{t("settingsPanel.myApps.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{apps.map((app) => {
|
||||
const Icon = resolveAppIcon(app.iconName);
|
||||
const name = lang === "ar" ? app.nameAr : app.nameEn;
|
||||
const visible = !app.hidden;
|
||||
return (
|
||||
<ToggleRow
|
||||
key={app.id}
|
||||
active={visible}
|
||||
onClick={() =>
|
||||
update.mutate({ appId: app.id, data: { hidden: visible } })
|
||||
}
|
||||
label={name}
|
||||
leftIcon={
|
||||
<span
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
|
||||
style={{ backgroundColor: `${app.color}20`, color: app.color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SoundBody() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
@@ -154,7 +261,7 @@ function SoundBody() {
|
||||
);
|
||||
}
|
||||
|
||||
type GroupId = "language" | "notifications" | "sound" | "clock";
|
||||
type GroupId = "myApps" | "language" | "notifications" | "sound" | "clock";
|
||||
|
||||
/**
|
||||
* Section wrapper that styles each accordion item like the existing
|
||||
@@ -219,6 +326,9 @@ function SettingsPanelBody({
|
||||
onValueChange={(v) => onOpenChange(v as GroupId[])}
|
||||
className="space-y-2"
|
||||
>
|
||||
<GroupItem id="myApps" title={t("settingsPanel.section.myApps")}>
|
||||
<MyAppsBody />
|
||||
</GroupItem>
|
||||
<GroupItem id="language" title={t("settingsPanel.section.language")}>
|
||||
<LanguageBody />
|
||||
</GroupItem>
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
"settingsPanel": {
|
||||
"title": "الإعدادات",
|
||||
"section": {
|
||||
"myApps": "تطبيقاتي",
|
||||
"language": "اللغة",
|
||||
"notifications": "الإشعارات",
|
||||
"sound": "الصوت",
|
||||
@@ -204,6 +205,10 @@
|
||||
},
|
||||
"sound": {
|
||||
"master": "تشغيل أصوات الإشعارات"
|
||||
},
|
||||
"myApps": {
|
||||
"loading": "جارٍ تحميل التطبيقات...",
|
||||
"empty": "لا توجد تطبيقات متاحة."
|
||||
}
|
||||
},
|
||||
"notifSettings": {
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"settingsPanel": {
|
||||
"title": "Settings",
|
||||
"section": {
|
||||
"myApps": "My apps",
|
||||
"language": "Language",
|
||||
"notifications": "Notifications",
|
||||
"sound": "Sound",
|
||||
@@ -210,6 +211,10 @@
|
||||
},
|
||||
"sound": {
|
||||
"master": "Play notification sounds"
|
||||
},
|
||||
"myApps": {
|
||||
"loading": "Loading apps…",
|
||||
"empty": "No apps available."
|
||||
}
|
||||
},
|
||||
"notifSettings": {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user