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:
riyadhafraa
2026-05-19 11:06:21 +00:00
parent 7609fa95e7
commit c31156be9f
11 changed files with 637 additions and 3 deletions
@@ -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>