b082e44389
Fixes found while reviewing #4547, stacked on that branch so they can be reviewed on their own and merged into it. One commit per fix. ## Write-path correctness **Refuse account writes while impersonating.** The five `dashboardPreferences` writers already no-op for an impersonating admin, but the three profile writers added next to them did not, and `requireUserId` returns the impersonated user's id. Both gates now refuse up front and say so, rather than the preference writers silently no-opping while the page reports success. **Preserve unknown keys on a full-blob write.** `mutateDashboardPreferences` parses the JSON column, hands the result to a mutator and persists the whole object back. zod strips keys it does not declare, so a deploy that predates a preference field drops it on the next write through that path — and `updateCurrentProjectEnvironmentId` sits on the navigation hot path. `preserveUnknownKeys` re-attaches them at the write. Note this cannot help deploys already running, so it makes this the last release able to strip rather than retroactively protecting the fields added in #4547. **Scope hidden-sidebar writes to what was shown.** The customize dialog builds its hidden map from the sections it can see and the write replaced `hiddenItems` wholesale. The profile page has no org in scope, so it resolves sections from the most-recently-updated project's org: confirming there dropped hidden ids belonging to sections that org's flags exclude. The payload now carries the ids the dialog rendered and the write only replaces those. Submissions without the list stay authoritative. **Consider both addresses when checking email ownership.** The check only looked at the address the user already had; it now considers the current and submitted address together, so an org managing either one governs the change. Validation moved ahead of the check, and `emailDomainOf` splits on the last `@`. ## Interaction **Revert unsaved themes, debounce contrast saves.** The theme and system-theme selects stamp `data-theme` before the write lands. When it fails, the loader returns the value it always had — so `useSystemThemeSync`'s effect deps are unchanged and React's vdom diff sees no change either, and nothing rewrites the attribute. The page kept rendering a theme that was never stored while the select showed the stored one. The stored pair is now re-applied explicitly, as the side menu's switcher already did. The contrast slider is debounced because Radix commits on every arrow keypress, so a keyboard user crossing the range fired one write per step. **Tick More options for themes outside the short list.** The appearance submenu offers System, Light and Dark; Black and White live on the profile page. With one of those stored, every row read as unselected. ## Subtraction **Drop the profile update rate limiter.** It covered one of four paths that write the same column — `resources.preferences.sidemenu` and `.favorites` take unlimited authenticated writes and go through the locked read-modify-write, which is more expensive than the single narrow `jsonb_set` this capped. It was also what made the contrast slider unusable by keyboard. If preference writes want limiting, it belongs in one place covering all of them. **Resolve email ownership when the dialog opens.** It fans out one SSO status lookup per organization the user belongs to and ran in the profile loader on every page view, purely to pick which body the dialog renders. The action re-derives it before writing either way, so the check that guards the write now has one call site instead of two. ## Testing `typecheck --filter webapp` and `lint` clean. New unit tests for `preserveUnknownKeys`, `mergeHiddenItems` and `emailDomainOf`; `themePreference`, `mergeHiddenItems` and `ssoManagedIdentity` suites pass locally (26 tests). The rest of the webapp suite needs testcontainers and is left to CI. No changeset or `.server-changes` entry: everything here fixes code on the parent branch that has not shipped. The one exception worth a maintainer's call is `mergeHiddenItems`, which also touches the side menu's own customize path.
142 lines
5.2 KiB
TypeScript
142 lines
5.2 KiB
TypeScript
import { z } from "zod";
|
|
import { SystemDarkTheme, SystemLightTheme, ThemePreference } from "~/utils/themePreference";
|
|
|
|
/* Schema and pure parsing for the User.dashboardPreferences JSON column.
|
|
Kept out of the .server module so tests can exercise the schema without
|
|
pulling in the server env graph. */
|
|
|
|
export const FavoritePage = z.object({
|
|
/** Stable id, generated client-side when the page is favorited. */
|
|
id: z.string(),
|
|
/** App-relative URL including any search params (filters, tabs). */
|
|
url: z.string(),
|
|
/** Display label shown in the side menu; user-renamable. */
|
|
label: z.string(),
|
|
/** Key into the favorite page icon registry. */
|
|
icon: z.string().optional(),
|
|
});
|
|
|
|
export type FavoritePage = z.infer<typeof FavoritePage>;
|
|
|
|
export const SideMenuPreferences = z.object({
|
|
isCollapsed: z.boolean().default(false),
|
|
/** Expanded side menu width in px, set by the resize handle. */
|
|
width: z.number().optional(),
|
|
// Map for section collapsed states - keys are section identifiers
|
|
collapsedSections: z.record(z.string(), z.boolean()).optional(),
|
|
/** Organization-specific settings */
|
|
organizations: z
|
|
.record(
|
|
z.string(),
|
|
z.object({
|
|
orderedItems: z.record(z.string(), z.array(z.string())),
|
|
})
|
|
)
|
|
.optional(),
|
|
/** Pages the user favorited, in display order. */
|
|
favorites: z.array(FavoritePage).optional(),
|
|
/** Custom top-to-bottom order of side menu sections (section ids). */
|
|
sectionOrder: z.array(z.string()).optional(),
|
|
/** Per-item visibility overrides (item id -> hidden). Items absent fall back to their default. */
|
|
hiddenItems: z.record(z.string(), z.boolean()).optional(),
|
|
/** Custom item order within a section (section id -> item ids). */
|
|
sectionItemOrder: z.record(z.string(), z.array(z.string())).optional(),
|
|
});
|
|
|
|
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
|
|
|
|
const DashboardPreferences = z.object({
|
|
version: z.literal("1"),
|
|
/* An unknown value (e.g. written by a newer deploy) degrades to undefined
|
|
instead of failing the whole blob and erasing every other setting */
|
|
theme: ThemePreference.optional().catch(undefined),
|
|
/** 0-100, a position within the active theme's own range. */
|
|
contrast: z.number().int().min(0).max(100).optional().catch(undefined),
|
|
/** Swaps the base icon and badge accents for the high-contrast set. */
|
|
iconContrast: z.boolean().optional().catch(undefined),
|
|
/** Underlines inline links. */
|
|
underlineLinks: z.boolean().optional().catch(undefined),
|
|
/** Which theme `system` resolves to at each end of the OS setting. */
|
|
systemLightTheme: SystemLightTheme.optional().catch(undefined),
|
|
systemDarkTheme: SystemDarkTheme.optional().catch(undefined),
|
|
currentProjectId: z.string().optional(),
|
|
projects: z.record(
|
|
z.string(),
|
|
z.object({
|
|
currentEnvironment: z.object({ id: z.string() }),
|
|
})
|
|
),
|
|
sideMenu: SideMenuPreferences.optional(),
|
|
});
|
|
|
|
export type DashboardPreferences = z.infer<typeof DashboardPreferences>;
|
|
|
|
/* A function, not a shared constant: the writers mutate through these objects,
|
|
so each caller needs its own container */
|
|
function defaultPreferences(): DashboardPreferences {
|
|
return {
|
|
version: "1",
|
|
projects: {},
|
|
};
|
|
}
|
|
|
|
/** Parses the stored JSON, falling back to defaults on missing or invalid data. */
|
|
export function parseDashboardPreferences(
|
|
data?: any | null,
|
|
onError?: (error: z.ZodError) => void
|
|
): DashboardPreferences {
|
|
if (!data) {
|
|
return defaultPreferences();
|
|
}
|
|
|
|
const result = DashboardPreferences.safeParse(data);
|
|
if (!result.success) {
|
|
onError?.(result.error);
|
|
return defaultPreferences();
|
|
}
|
|
|
|
return result.data;
|
|
}
|
|
|
|
/**
|
|
* Re-attach keys the schema dropped, so a full-blob write preserves fields this
|
|
* deploy was not compiled against. The parsed result wins for every key it
|
|
* carries, including ones it deliberately cleared to undefined.
|
|
*/
|
|
export function preserveUnknownKeys(
|
|
raw: unknown,
|
|
updated: DashboardPreferences
|
|
): DashboardPreferences {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
return updated;
|
|
}
|
|
|
|
const known = new Set(Object.keys(DashboardPreferences.shape));
|
|
const unknownKeys = Object.entries(raw as Record<string, unknown>).filter(
|
|
([key]) => !known.has(key)
|
|
);
|
|
|
|
return unknownKeys.length > 0 ? { ...Object.fromEntries(unknownKeys), ...updated } : updated;
|
|
}
|
|
|
|
/**
|
|
* Fold a customize-sidebar submission into the stored hidden map. `submitted`
|
|
* only describes `knownItemIds`, so ids outside that list keep what they had -
|
|
* the dialog's section list depends on which org's feature flags were in scope,
|
|
* and a narrower list must not un-hide items belonging to a wider one. Without
|
|
* the list the submission is authoritative, as it was before.
|
|
*/
|
|
export function mergeHiddenItems(
|
|
current: Record<string, boolean> | undefined,
|
|
submitted: Record<string, boolean> | null,
|
|
knownItemIds: string[] | undefined
|
|
): Record<string, boolean> | undefined {
|
|
const known = knownItemIds ? new Set(knownItemIds) : undefined;
|
|
const preserved: Array<[string, boolean]> = known
|
|
? Object.entries(current ?? {}).filter(([id]) => !known.has(id))
|
|
: [];
|
|
const merged = { ...Object.fromEntries(preserved), ...(submitted ?? {}) };
|
|
|
|
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
}
|