Skip to content
7 changes: 4 additions & 3 deletions apps/csm-portal/webapp/public/config.js.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,15 @@ window.config = {
// Available ids: dashboard, support, operations{.service-requests,
// .change-requests, .incidents, .problems}, engagements, security-center
// {.reports, .vulnerabilities}, updates, time-cards, announcements,
// customers{.accounts, .projects}, admin{.users, .roles, .groups,
// .permissions}. Unknown ids are ignored with a console warning.
// customers{.accounts, .projects}, admin{.user-management{.users, .roles,
// .groups, .teams, .permissions}, .dashboards}. Unknown ids are ignored
// with a console warning.
CSM_PORTAL_FEATURE_OVERRIDES: {
"operations": "wip",
"operations.incidents": "enabled",
"customers": "wip",
"admin": "wip",
"admin.users": "enabled",
"admin.user-management.users": "enabled",
},

// Mock data: when true, data hooks short-circuit to seeded fixtures instead
Expand Down
72 changes: 64 additions & 8 deletions apps/csm-portal/webapp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ const CsmTeamsPage = lazy(
const TeamMembersPage = lazy(
() => import("@features/csm-admin/pages/TeamMembersPage"),
);
const DashboardBuilderRouteGuard = lazy(
() => import("@features/csm-admin/dashboards/pages/DashboardBuilderRouteGuard"),
);
const CsmDashboardBuilderListPage = lazy(
() => import("@features/csm-admin/dashboards/pages/CsmDashboardBuilderListPage"),
);
const CsmDashboardBuilderEditorPage = lazy(
() => import("@features/csm-admin/dashboards/pages/CsmDashboardBuilderEditorPage"),
);
const CsmCustomersLayout = lazy(
() => import("@features/csm-customers/pages/CsmCustomersLayout"),
);
Expand Down Expand Up @@ -323,19 +332,27 @@ export default function App(): JSX.Element {
element={<LegacyDetailRedirect to="/customers/projects" />}
/>

{/* Administration — Users/Roles/Groups/Teams are real,
Permissions is still WIP. */}
{/* Administration — "User management" groups the
Users/Roles/Groups/Teams/Permissions directory pages
(Users/Roles/Groups/Teams are real, Permissions is still
WIP) under one nested tab; Dashboards is a sibling. */}
<Route path="admin" element={<CsmAdminLayout />}>
<Route
index
element={<SectionIndexRedirect sectionId="admin" />}
/>
<Route path="users" element={<CsmUsersPage />} />
<Route path="roles" element={<CsmRolesPage />} />
<Route path="groups" element={<CsmGroupsPage />} />
<Route path="teams" element={<CsmTeamsPage />} />
<Route
path="permissions"
path="user-management"
element={
<SectionIndexRedirect sectionId="admin.user-management" />
}
/>
<Route path="user-management/users" element={<CsmUsersPage />} />
<Route path="user-management/roles" element={<CsmRolesPage />} />
<Route path="user-management/groups" element={<CsmGroupsPage />} />
<Route path="user-management/teams" element={<CsmTeamsPage />} />
<Route
path="user-management/permissions"
element={
<CsmComingSoonPage
title="Permissions"
Expand All @@ -344,12 +361,51 @@ export default function App(): JSX.Element {
/>
}
/>
{/* Dashboard builder — admin-role-gated, unlike every
sibling tab above (see DashboardBuilderRouteGuard's
own doc comment for why). Persists to localStorage
only; there is no backend behind this feature. */}
<Route path="dashboards" element={<DashboardBuilderRouteGuard />}>
<Route index element={<CsmDashboardBuilderListPage />} />
<Route path="new" element={<CsmDashboardBuilderEditorPage />} />
<Route path=":draftId" element={<CsmDashboardBuilderEditorPage />} />
</Route>
</Route>

{/* Legacy Settings paths kept alive so a pinned/deep link to
the pre-"User management" layout doesn't dead-end. Not
requested explicitly — a judgment call to match the
/accounts, /projects legacy-redirect convention above;
revert this block alone if unwanted. */}
<Route
path="admin/users"
element={<Navigate to="/admin/user-management/users" replace />}
/>
<Route
path="admin/roles"
element={<Navigate to="/admin/user-management/roles" replace />}
/>
<Route
path="admin/groups"
element={<Navigate to="/admin/user-management/groups" replace />}
/>
<Route
path="admin/teams"
element={<Navigate to="/admin/user-management/teams" replace />}
/>
<Route
path="admin/permissions"
element={<Navigate to="/admin/user-management/permissions" replace />}
/>

{/* Role/group/team member lists, one level below the
directory pages above. Not admin-permission-gated:
standing project rule is to show the action and let the
backend reject it, never gate in the frontend. */}
backend reject it, never gate in the frontend. Left at
their original /admin/<kind>/:id paths — out of scope
for the User management nesting above — so every
`routeBase="/admin/roles"` etc. link elsewhere in the app
keeps working unchanged. */}
<Route path="admin/roles/:id" element={<RoleMembersPage />} />
<Route path="admin/groups/:id" element={<GroupMembersPage />} />
<Route path="admin/teams/:id" element={<TeamMembersPage />} />
Expand Down
37 changes: 35 additions & 2 deletions apps/csm-portal/webapp/src/components/section-tabs/SectionTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,22 @@ interface SectionTabsProps extends SectionTabsState {
/** Accessible name for the strip, e.g. "Operations tabs". */
ariaLabel: string;
scrollable?: boolean;
/**
* Visual weight. `"primary"` (default) is a section's own tab strip.
* `"secondary"` renders smaller and indented, for a strip that belongs to
* one of those tabs rather than to the section itself — e.g. Settings'
* "User management" tab has its own row of sub-tabs underneath the primary
* strip.
*/
variant?: "primary" | "secondary";
}

/**
* A section's second-level tab strip, rendered from the navigation tree.
* A section's tab strip, rendered from the navigation tree. Also doubles as a
* nested tab's own strip via `variant="secondary"` — the underlying data
* (`useRouteTabs`/`useQueryTabs`) is already resolved per nav-node id, so a
* second level is just a second `<SectionTabs>` fed by a second hook call,
* not a different component.
*
* A tab the deployment marked WIP stays in the strip but is disabled and
* chipped, so the section still advertises what is coming without offering a
Expand All @@ -41,17 +53,38 @@ export default function SectionTabs({
select,
ariaLabel,
scrollable = false,
variant = "primary",
}: SectionTabsProps): JSX.Element | null {
if (tabs.length === 0) return null;
const isSecondary = variant === "secondary";

return (
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Box
sx={{
borderBottom: 1,
borderColor: "divider",
...(isSecondary && { pl: 2 }),
}}
>
<Tabs
aria-label={ariaLabel}
value={activeKey}
onChange={(_, key: string) => select(key)}
variant={scrollable ? "scrollable" : "standard"}
scrollButtons={scrollable ? "auto" : false}
sx={
isSecondary
? {
minHeight: 36,
"& .MuiTab-root": {
minHeight: 36,
paddingTop: 0.5,
paddingBottom: 0.5,
fontSize: "0.8125rem",
},
}
: undefined
}
>
{tabs.map((tab) =>
tab.state === "wip" ? (
Expand Down
2 changes: 1 addition & 1 deletion apps/csm-portal/webapp/src/config/csmNavItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,6 @@ describe("rendersOwnWipPage", () => {
const flagged = flattenNavNodes()
.filter((node) => node.rendersOwnWipPage)
.map((node) => node.id);
expect(flagged).toEqual(["admin.permissions"]);
expect(flagged).toEqual(["admin.user-management.permissions"]);
});
});
58 changes: 48 additions & 10 deletions apps/csm-portal/webapp/src/config/csmNavItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,55 @@ export const CSM_NAV_ITEMS: CsmNavSection[] = [
href: "/admin",
icon: Settings,
children: [
{ id: "admin.users", label: "Users", href: "/admin/users" },
{ id: "admin.roles", label: "Roles", href: "/admin/roles" },
{ id: "admin.groups", label: "Groups", href: "/admin/groups" },
{ id: "admin.teams", label: "Teams", href: "/admin/teams" },
// Routes to a placeholder that already names its backend blocker, so it
// renders itself rather than the generic WIP page.
{
id: "admin.permissions",
label: "Permissions",
href: "/admin/permissions",
rendersOwnWipPage: true,
id: "admin.user-management",
label: "User management",
href: "/admin/user-management",
children: [
{
id: "admin.user-management.users",
label: "Users",
href: "/admin/user-management/users",
},
{
id: "admin.user-management.roles",
label: "Roles",
href: "/admin/user-management/roles",
},
{
id: "admin.user-management.groups",
label: "Groups",
href: "/admin/user-management/groups",
},
{
id: "admin.user-management.teams",
label: "Teams",
href: "/admin/user-management/teams",
},
// Routes to a placeholder that already names its backend blocker, so
// it renders itself rather than the generic WIP page.
{
id: "admin.user-management.permissions",
label: "Permissions",
href: "/admin/user-management/permissions",
rendersOwnWipPage: true,
},
],
},
// Admin-role-gated (see `isDashboardBuilderVisibleForRoles` in
// `csmAdminAccess.ts`) — unlike every sibling tab above, this one is
// hidden from a non-admin signed-in user rather than merely relying
// on the backend to reject the action. Deliberate exception to this
// section's usual "show the action, let the backend reject it" rule
// (see App.tsx's own comment on the roles/groups/teams member
// routes): the dashboard builder exposes no privileged backend
// action at all (everything it does is local to the browser), so
// there is nothing for a backend gate to enforce here — the ONLY
// gate is this frontend one.
{
id: "admin.dashboards",
label: "Dashboards",
href: "/admin/dashboards",
},
],
},
Expand Down
43 changes: 30 additions & 13 deletions apps/csm-portal/webapp/src/config/featureFlags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("featureState", () => {
it("treats anything absent from the config as a working feature", () => {
expect(featureState("operations")).toBe("enabled");
expect(featureState("operations.incidents")).toBe("enabled");
expect(featureState("admin.roles")).toBe("enabled");
expect(featureState("admin.user-management.roles")).toBe("enabled");
});

it("treats an id that isn't in the nav tree as enabled", () => {
Expand Down Expand Up @@ -95,13 +95,13 @@ describe("featureState", () => {

describe("override parsing", () => {
it("accepts the map as a JSON string, for string-only config injection", () => {
setOverrides(JSON.stringify({ "admin.roles": "hidden" }));
expect(featureState("admin.roles")).toBe("hidden");
setOverrides(JSON.stringify({ "admin.user-management.roles": "hidden" }));
expect(featureState("admin.user-management.roles")).toBe("hidden");
});

it("ignores a malformed JSON string and warns", () => {
setOverrides("{not json");
expect(featureState("admin.roles")).toBe("enabled");
expect(featureState("admin.user-management.roles")).toBe("enabled");
expect(console.warn).toHaveBeenCalled();
});

Expand Down Expand Up @@ -159,15 +159,18 @@ describe("navigation helpers", () => {
});

it("separates visible tabs from usable ones", () => {
setOverrides({ "admin.roles": "wip", "admin.groups": "hidden" });
const admin = navNodeById("admin");
expect(admin).toBeDefined();
const visible = visibleNavChildren(admin!).map((child) => child.id);
const enabled = enabledNavChildren(admin!).map((child) => child.id);
expect(visible).toContain("admin.roles");
expect(visible).not.toContain("admin.groups");
expect(enabled).not.toContain("admin.roles");
expect(enabled).toContain("admin.users");
setOverrides({
"admin.user-management.roles": "wip",
"admin.user-management.groups": "hidden",
});
const userManagement = navNodeById("admin.user-management");
expect(userManagement).toBeDefined();
const visible = visibleNavChildren(userManagement!).map((child) => child.id);
const enabled = enabledNavChildren(userManagement!).map((child) => child.id);
expect(visible).toContain("admin.user-management.roles");
expect(visible).not.toContain("admin.user-management.groups");
expect(enabled).not.toContain("admin.user-management.roles");
expect(enabled).toContain("admin.user-management.users");
});

it("offers only usable destinations to the quick-nav palette", () => {
Expand All @@ -186,6 +189,20 @@ describe("navigation helpers", () => {
expect(incidents?.sublabel).toBe("Operations");
expect(incidents?.href).toBe("/operations?tab=incidents");
});

it("flattens a grandchild tab too, labelled by its immediate parent", () => {
const users = navigableNavNodes().find(
(node) => node.id === "admin.user-management.users",
);
expect(users?.label).toBe("Users");
expect(users?.sublabel).toBe("User management");
expect(users?.href).toBe("/admin/user-management/users");

const userManagement = navigableNavNodes().find(
(node) => node.id === "admin.user-management",
);
expect(userManagement?.sublabel).toBe("Settings");
});
});

describe("firstEnabledDestination", () => {
Expand Down
38 changes: 27 additions & 11 deletions apps/csm-portal/webapp/src/config/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,32 @@ export interface NavigableNavNode {
}

/**
* Every enabled destination, sections and their tabs alike, flattened for the
* Quick-nav palette. Tabs inherit their section's icon and carry its label as a
* sublabel so "Users" reads as "Users / Settings" rather than as a bare word.
* Every enabled descendant of `node`, flattened, each carrying its immediate
* parent's label as its sublabel (so a grandchild reads as "Users / User
* management" rather than "Users / Settings") and inheriting an icon down the
* chain until a node declares its own.
*/
function navigableDescendants(
node: CsmNavNode,
icon: ComponentType<{ size?: number | string }>,
): NavigableNavNode[] {
return enabledNavChildren(node).flatMap((child) => {
const self: NavigableNavNode = {
id: child.id,
label: child.label,
sublabel: node.label,
href: child.href,
icon: child.icon ?? icon,
};
return [self, ...navigableDescendants(child, self.icon)];
});
}

/**
* Every enabled destination — sections and every level of their tabs —
* flattened for the Quick-nav palette. Tabs inherit their parent's icon and
* carry its label as a sublabel so "Users" reads as "Users / User management"
* rather than as a bare word.
*/
export function navigableNavNodes(): NavigableNavNode[] {
return CSM_NAV_ITEMS.flatMap((section) => {
Expand All @@ -236,14 +259,7 @@ export function navigableNavNodes(): NavigableNavNode[] {
href: section.href,
icon: section.icon,
};
const tabs: NavigableNavNode[] = enabledNavChildren(section).map((child) => ({
id: child.id,
label: child.label,
sublabel: section.label,
href: child.href,
icon: child.icon ?? section.icon,
}));
return [self, ...tabs];
return [self, ...navigableDescendants(section, section.icon)];
});
}

Expand Down
Loading