Task #613: replace per-app hide toggles with single Dock visibility switch

The per-app hidden feature shipped in #609 was rejected — user wanted one
toggle that hides/shows the entire bottom AppDock bar, leaving Home apps
untouched.

Removed:
- lib/db/src/schema/user-hidden-apps.ts (and index.ts export); table
  dropped via `pnpm --filter @workspace/db run push`
- GET /me/apps and PUT /me/apps/:appId/hidden routes
- getHiddenAppIdsForUser + getVisibleNonHiddenAppsForUser helpers
- MyApp + UpdateMyAppHiddenBody OpenAPI schemas
- MyAppsBody settings UI + related i18n keys
- Regenerated api-client-react + api-zod from the trimmed spec
- Reverted GET /apps back to getVisibleAppsForUser

Added:
- artifacts/tx-os/src/hooks/use-dock-visible.ts — localStorage-backed
  preference with a custom window event for in-tab sync and the native
  `storage` event for cross-tab sync. Default = true.
- DockBody in settings-panel: single ToggleRow under a new "App dock"
  section ("settingsPanel.section.dock" / "settingsPanel.dock.show")
- AppDock now returns null when the preference is off and clears
  `--app-dock-height` so page padding doesn't stay reserved.

Code review: PASS (architect). No remaining references to the removed
infra. Typecheck shows only pre-existing errors in executive-meetings.ts
and push.ts unrelated to this change.

Follow-up: publish to Gitea + redeploy on Mac (proposed as a follow-up
task).
This commit is contained in:
riyadhafraa
2026-05-19 11:35:13 +00:00
parent f1536cb4db
commit 3f6a0fb02f
13 changed files with 95 additions and 638 deletions
@@ -7,7 +7,6 @@ import {
userAppOrdersTable,
userGroupsTable,
groupAppsTable,
userHiddenAppsTable,
} from "@workspace/db";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { getEffectiveRoleIds } from "../middlewares/auth";
@@ -107,30 +106,3 @@ export async function getVisibleAppsForUser(
);
}
/**
* 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));
}
+2 -68
View File
@@ -16,7 +16,6 @@ import {
auditLogsTable,
permissionsTable,
usersTable,
userHiddenAppsTable,
} from "@workspace/db";
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
import {
@@ -28,11 +27,7 @@ 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,
getVisibleNonHiddenAppsForUser,
getHiddenAppIdsForUser,
} from "../lib/appsVisibility";
import { getVisibleAppsForUser } from "../lib/appsVisibility";
export { getVisibleAppsForUser };
import {
CreateAppBody,
@@ -47,71 +42,10 @@ const router: IRouter = Router();
router.get("/apps", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.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);
const visible = await getVisibleAppsForUser(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()