feat(web): rationalize settings IA into personal-preferences shell with admin rehoming [SPRINT-026]

Settings shell now owns only personal user preferences (appearance,
language, layout, AI assistant). All 14 admin/tenant/ops leaves
converted to controlled redirects pointing at their canonical owners
(Administration, Setup, Ops). Language merged into user-preferences.
Identity-providers rehomed from settings to administration as
canonical owner. Navigation config updated. 22 new route tests added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-08 22:58:03 +02:00
parent ce59f66e97
commit 2bf4d69bba
10 changed files with 482 additions and 225 deletions

View File

@@ -637,7 +637,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'identity-providers',
label: 'Identity Providers',
route: '/settings/identity-providers',
route: '/administration/identity-providers',
icon: 'id-card',
requiredScopes: ['ui.admin'],
tooltip: 'Configure external identity providers (LDAP, SAML, OIDC)',

View File

@@ -1,20 +1,16 @@
/**
* Settings Page Component (Shell)
* Sprint: SPRINT_20260118_002_FE_settings_consolidation (SETTINGS-001)
* Settings Page Component (Personal Preferences Shell)
* Sprint: SPRINT_20260308_026_FE_settings_information_architecture_rationalization
*
* Shell page with sidebar navigation for all settings sections.
* The Settings shell now owns only personal user preferences.
* Admin, tenant, and operations configuration have been rehomed
* to their canonical owners (Setup, Administration, Ops).
*/
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet } from '@angular/router';
/**
* Settings Page Component (Shell)
*
* Navigation is handled by the global sidebar.
* This shell provides the content area for settings sub-routes.
*/
@Component({
selector: 'app-settings-page',
imports: [RouterOutlet],

View File

@@ -1,12 +1,17 @@
/**
* Settings Routes
* Sprint: SPRINT_20260118_002_FE_settings_consolidation
* Settings Routes — Personal Preferences Shell
* Sprint: SPRINT_20260308_026_FE_settings_information_architecture_rationalization
*
* Settings now owns only personal user preferences (appearance, language,
* layout, AI assistant). All admin, tenant, and operations configuration
* leaves have been rehomed to their canonical owners with backward-compatible
* redirects preserved for legacy bookmarks.
*/
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
function redirectToCanonicalSetup(path: string) {
function redirectToCanonical(path: string) {
return ({
params,
queryParams,
@@ -38,125 +43,15 @@ export const SETTINGS_ROUTES: Routes = [
import('./settings-page.component').then(m => m.SettingsPageComponent),
data: {},
children: [
// -----------------------------------------------------------------------
// Personal preferences (canonical owner: Settings)
// -----------------------------------------------------------------------
{
path: '',
title: 'Integrations',
title: 'User Preferences',
loadComponent: () =>
import('./integrations/integrations-settings-page.component').then(m => m.IntegrationsSettingsPageComponent),
data: { breadcrumb: 'Integrations' },
},
{
path: 'integrations',
title: 'Integrations',
loadComponent: () =>
import('./integrations/integrations-settings-page.component').then(m => m.IntegrationsSettingsPageComponent),
data: { breadcrumb: 'Integrations' },
},
{
path: 'integrations/:id',
title: 'Integration Detail',
loadComponent: () =>
import('./integrations/integration-detail-page.component').then(m => m.IntegrationDetailPageComponent),
data: { breadcrumb: 'Integration Detail' },
},
{
path: 'configuration-pane',
title: 'Configuration Pane',
loadComponent: () =>
import('../configuration-pane/components/configuration-pane.component').then(m => m.ConfigurationPaneComponent),
data: { breadcrumb: 'Configuration Pane' },
},
{
path: 'release-control',
title: 'Release Control',
loadComponent: () =>
import('./release-control/release-control-settings-page.component').then(m => m.ReleaseControlSettingsPageComponent),
data: { breadcrumb: 'Release Control' },
},
{
path: 'trust',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing'),
pathMatch: 'full' as const,
},
{
path: 'trust/issuers',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/issuers'),
pathMatch: 'full' as const,
},
{
path: 'trust/:page/:child',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page/:child'),
pathMatch: 'full' as const,
},
{
path: 'trust/:page',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing/:page',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing/:page/:child',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page/:child'),
data: { breadcrumb: 'Trust & Signing' },
pathMatch: 'full' as const,
},
{
path: 'security-data',
title: 'Security Data',
loadComponent: () =>
import('./security-data/security-data-settings-page.component').then(m => m.SecurityDataSettingsPageComponent),
data: { breadcrumb: 'Security Data' },
},
{
path: 'admin',
title: 'Identity & Access',
loadComponent: () =>
import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
data: { breadcrumb: 'Identity & Access' },
},
{
path: 'admin/:page',
title: 'Identity & Access',
loadComponent: () =>
import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
data: { breadcrumb: 'Identity & Access' },
},
{
path: 'branding',
title: 'Tenant & Branding',
loadComponent: () =>
import('./branding/branding-settings-page.component').then(m => m.BrandingSettingsPageComponent),
data: { breadcrumb: 'Tenant & Branding' },
},
{
path: 'usage',
title: 'Usage & Limits',
loadComponent: () =>
import('./usage/usage-settings-page.component').then(m => m.UsageSettingsPageComponent),
data: { breadcrumb: 'Usage & Limits' },
},
{
path: 'notifications',
title: 'Notifications',
loadComponent: () =>
import('./notifications/notifications-settings-page.component').then(m => m.NotificationsSettingsPageComponent),
data: { breadcrumb: 'Notifications' },
import('./user-preferences/user-preferences-page.component').then(m => m.UserPreferencesPageComponent),
data: { breadcrumb: 'User Preferences' },
},
{
path: 'user-preferences',
@@ -165,12 +60,15 @@ export const SETTINGS_ROUTES: Routes = [
import('./user-preferences/user-preferences-page.component').then(m => m.UserPreferencesPageComponent),
data: { breadcrumb: 'User Preferences' },
},
// -----------------------------------------------------------------------
// Merged personal preference leaves (redirects to user-preferences)
// -----------------------------------------------------------------------
{
path: 'language',
title: 'Language',
loadComponent: () =>
import('./language/language-settings-page.component').then(m => m.LanguageSettingsPageComponent),
data: { breadcrumb: 'Language' },
redirectTo: 'user-preferences',
pathMatch: 'full' as const,
},
{
path: 'ai-preferences',
@@ -178,35 +76,143 @@ export const SETTINGS_ROUTES: Routes = [
redirectTo: 'user-preferences',
pathMatch: 'full' as const,
},
// -----------------------------------------------------------------------
// Admin/tenant config redirects -> canonical administration owner
// -----------------------------------------------------------------------
{
path: 'policy',
title: 'Policy Governance',
loadComponent: () =>
import('./policy/policy-governance-settings-page.component').then(m => m.PolicyGovernanceSettingsPageComponent),
data: { breadcrumb: 'Policy Governance' },
path: 'admin',
title: 'Identity & Access',
redirectTo: redirectToCanonical('/administration/admin'),
pathMatch: 'full' as const,
},
{
path: 'offline',
title: 'Offline Settings',
loadComponent: () =>
import('../offline-kit/components/offline-dashboard.component').then(m => m.OfflineDashboardComponent),
data: { breadcrumb: 'Offline Settings' },
path: 'admin/:page',
title: 'Identity & Access',
redirectTo: redirectToCanonical('/administration/admin/:page'),
pathMatch: 'full' as const,
},
{
path: 'branding',
title: 'Tenant & Branding',
redirectTo: redirectToCanonical('/console/admin/branding'),
pathMatch: 'full' as const,
},
{
path: 'usage',
title: 'Usage & Limits',
redirectTo: redirectToCanonical('/setup/usage'),
pathMatch: 'full' as const,
},
{
path: 'notifications',
title: 'Notifications',
redirectTo: redirectToCanonical('/setup/notifications'),
pathMatch: 'full' as const,
},
{
path: 'identity-providers',
title: 'Identity Providers',
loadComponent: () =>
import('./identity-providers/identity-providers-settings-page.component').then(
(m) => m.IdentityProvidersSettingsPageComponent,
),
data: { breadcrumb: 'Identity Providers' },
redirectTo: redirectToCanonical('/administration/identity-providers'),
pathMatch: 'full' as const,
},
{
path: 'system',
title: 'System',
loadComponent: () =>
import('./system/system-settings-page.component').then(m => m.SystemSettingsPageComponent),
data: { breadcrumb: 'System' },
redirectTo: redirectToCanonical('/administration/system'),
pathMatch: 'full' as const,
},
{
path: 'security-data',
title: 'Security Data',
redirectTo: redirectToCanonical('/administration/security-data'),
pathMatch: 'full' as const,
},
// -----------------------------------------------------------------------
// Operations config redirects -> canonical ops/setup owners
// -----------------------------------------------------------------------
{
path: 'integrations',
title: 'Integrations',
redirectTo: redirectToCanonical('/setup/integrations'),
pathMatch: 'full' as const,
},
{
path: 'integrations/:id',
title: 'Integration Detail',
redirectTo: redirectToCanonical('/setup/integrations/:id'),
pathMatch: 'full' as const,
},
{
path: 'policy',
title: 'Policy Governance',
redirectTo: redirectToCanonical('/ops/policy/governance'),
pathMatch: 'full' as const,
},
{
path: 'offline',
title: 'Offline Settings',
redirectTo: redirectToCanonical('/administration/offline'),
pathMatch: 'full' as const,
},
{
path: 'release-control',
title: 'Release Control',
redirectTo: redirectToCanonical('/setup/topology/environments'),
pathMatch: 'full' as const,
},
{
path: 'configuration-pane',
title: 'Configuration Pane',
redirectTo: redirectToCanonical('/ops/platform-setup'),
pathMatch: 'full' as const,
},
// -----------------------------------------------------------------------
// Trust redirects (already existed, preserved)
// -----------------------------------------------------------------------
{
path: 'trust',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing'),
pathMatch: 'full' as const,
},
{
path: 'trust/issuers',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing/issuers'),
pathMatch: 'full' as const,
},
{
path: 'trust/:page/:child',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing/:page/:child'),
pathMatch: 'full' as const,
},
{
path: 'trust/:page',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing/:page'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing/:page',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing/:page'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing/:page/:child',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing/:page/:child'),
pathMatch: 'full' as const,
},
],
},

View File

@@ -351,11 +351,15 @@ export const ADMINISTRATION_ROUTES: Routes = [
pathMatch: 'full',
},
// Legacy alias: /administration/identity-providers → /settings/identity-providers
// Identity Providers — canonical owner (rehomed from /settings/identity-providers)
{
path: 'identity-providers',
redirectTo: '/settings/identity-providers',
pathMatch: 'full',
title: 'Identity Providers',
data: { breadcrumb: 'Identity Providers' },
loadComponent: () =>
import('../features/settings/identity-providers/identity-providers-settings-page.component').then(
(m) => m.IdentityProvidersSettingsPageComponent
),
},
// A7 — System

View File

@@ -0,0 +1,196 @@
/**
* Settings IA Rationalization Tests
* Sprint: SPRINT_20260308_026_FE_settings_information_architecture_rationalization
*
* Verifies that the Settings shell now owns only personal preferences,
* admin/tenant/ops leaves redirect to canonical owners, and legacy
* bookmarks resolve through controlled redirects.
*/
import { SETTINGS_ROUTES } from '../../app/features/settings/settings.routes';
describe('Settings IA rationalization', () => {
const root = SETTINGS_ROUTES[0];
const children = root?.children ?? [];
// ---------------------------------------------------------------------------
// Personal preferences are the canonical default
// ---------------------------------------------------------------------------
it('defaults to user-preferences as the settings landing page', () => {
const defaultRoute = children.find((r) => r.path === '');
expect(defaultRoute).toBeDefined();
expect(defaultRoute?.title).toBe('User Preferences');
expect(typeof defaultRoute?.loadComponent).toBe('function');
});
it('mounts user-preferences as a named route', () => {
const route = children.find((r) => r.path === 'user-preferences');
expect(route).toBeDefined();
expect(route?.title).toBe('User Preferences');
expect(typeof route?.loadComponent).toBe('function');
});
// ---------------------------------------------------------------------------
// Merged personal preference leaves redirect to user-preferences
// ---------------------------------------------------------------------------
it('redirects /settings/language to user-preferences', () => {
const route = children.find((r) => r.path === 'language');
expect(route).toBeDefined();
expect(route?.redirectTo).toBe('user-preferences');
});
it('redirects /settings/ai-preferences to user-preferences', () => {
const route = children.find((r) => r.path === 'ai-preferences');
expect(route).toBeDefined();
expect(route?.redirectTo).toBe('user-preferences');
});
// ---------------------------------------------------------------------------
// Admin/tenant config leaves redirect to canonical administration
// ---------------------------------------------------------------------------
it('redirects /settings/admin to canonical administration', () => {
const route = children.find((r) => r.path === 'admin');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
expect(route?.pathMatch).toBe('full');
});
it('redirects /settings/admin/:page to canonical administration', () => {
const route = children.find((r) => r.path === 'admin/:page');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/branding to canonical console admin branding', () => {
const route = children.find((r) => r.path === 'branding');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/identity-providers to canonical administration', () => {
const route = children.find((r) => r.path === 'identity-providers');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/system to canonical administration', () => {
const route = children.find((r) => r.path === 'system');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/security-data to canonical administration', () => {
const route = children.find((r) => r.path === 'security-data');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
// ---------------------------------------------------------------------------
// Operations config leaves redirect to canonical setup/ops
// ---------------------------------------------------------------------------
it('redirects /settings/integrations to canonical setup', () => {
const route = children.find((r) => r.path === 'integrations');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/integrations/:id to canonical setup', () => {
const route = children.find((r) => r.path === 'integrations/:id');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/usage to canonical setup', () => {
const route = children.find((r) => r.path === 'usage');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/notifications to canonical setup', () => {
const route = children.find((r) => r.path === 'notifications');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/policy to canonical ops policy governance', () => {
const route = children.find((r) => r.path === 'policy');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/offline to canonical administration', () => {
const route = children.find((r) => r.path === 'offline');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/release-control to canonical setup topology', () => {
const route = children.find((r) => r.path === 'release-control');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/configuration-pane to canonical ops platform-setup', () => {
const route = children.find((r) => r.path === 'configuration-pane');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
// ---------------------------------------------------------------------------
// Trust redirects preserved from previous sprint
// ---------------------------------------------------------------------------
it('preserves trust/* redirects to /setup/trust-signing', () => {
const trustRoot = children.find((r) => r.path === 'trust');
expect(trustRoot).toBeDefined();
expect(typeof trustRoot?.redirectTo).toBe('function');
const trustIssuers = children.find((r) => r.path === 'trust/issuers');
expect(trustIssuers).toBeDefined();
const trustPage = children.find((r) => r.path === 'trust/:page');
expect(trustPage).toBeDefined();
const trustPageChild = children.find((r) => r.path === 'trust/:page/:child');
expect(trustPageChild).toBeDefined();
});
it('preserves trust-signing/* redirects to /setup/trust-signing', () => {
const ts = children.find((r) => r.path === 'trust-signing');
expect(ts).toBeDefined();
expect(typeof ts?.redirectTo).toBe('function');
const tsPage = children.find((r) => r.path === 'trust-signing/:page');
expect(tsPage).toBeDefined();
const tsPageChild = children.find((r) => r.path === 'trust-signing/:page/:child');
expect(tsPageChild).toBeDefined();
});
// ---------------------------------------------------------------------------
// No loadComponent routes remain for admin/ops pages
// ---------------------------------------------------------------------------
it('contains no loadComponent routes for admin/ops leaves', () => {
const adminOpsLeaves = [
'integrations', 'integrations/:id', 'admin', 'admin/:page',
'branding', 'usage', 'notifications', 'security-data', 'policy',
'offline', 'system', 'identity-providers', 'release-control',
'configuration-pane',
];
for (const path of adminOpsLeaves) {
const route = children.find((r) => r.path === path);
if (route) {
expect(route.loadComponent).toBeUndefined();
}
}
});
// ---------------------------------------------------------------------------
// Route count validation
// ---------------------------------------------------------------------------
it('has the expected number of child routes', () => {
// 2 personal preference routes + 2 merged redirects + 8 admin redirects
// + 6 ops redirects + 7 trust redirects = 25
expect(children.length).toBe(25);
});
});

View File

@@ -2,7 +2,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { routes } from '../../app/app.routes';
import { ADVISORY_AI_API, type AdvisoryAiApi } from '../../app/core/api/advisory-ai.client';
import type { RemediationPrSettings } from '../../app/core/api/advisory-ai.models';
import { RemediationPrSettingsComponent } from '../../app/features/settings/remediation-pr-settings.component';
@@ -23,46 +22,33 @@ describe('unified-settings-page behavior', () => {
localStorage.removeItem('stellaops.remediation-pr.preferences');
});
it('declares canonical /administration route and keeps /settings redirect alias', () => {
const settingsAlias = routes.find((route) => route.path === 'settings');
expect(settingsAlias).toBeDefined();
expect(settingsAlias?.redirectTo).toBe('/administration');
const administrationRoute = routes.find((route) => route.path === 'administration');
expect(administrationRoute).toBeDefined();
expect(typeof administrationRoute?.loadChildren).toBe('function');
it('mounts personal preferences as the settings default and redirects admin leaves', () => {
const root = SETTINGS_ROUTES.find((route) => route.path === '');
expect(root).toBeDefined();
const childPaths = (root?.children ?? []).map((child) => child.path);
expect(childPaths).toEqual([
'',
'integrations',
'integrations/:id',
'configuration-pane',
'release-control',
'trust',
'trust/:page',
'security-data',
'admin',
'admin/:page',
'branding',
'usage',
'notifications',
'ai-preferences',
'policy',
'offline',
'system',
]);
// The default route is now user-preferences, not integrations
const defaultChild = (root?.children ?? []).find((child) => child.path === '');
expect(defaultChild?.title).toBe('User Preferences');
expect(typeof defaultChild?.loadComponent).toBe('function');
const brandingRoute = (root?.children ?? []).find((child) => child.path === 'branding');
expect(brandingRoute?.title).toBe('Tenant & Branding');
expect(brandingRoute?.data?.['breadcrumb']).toBe('Tenant & Branding');
// user-preferences is also available as a named route
expect(childPaths).toContain('user-preferences');
const offlineRoute = (root?.children ?? []).find((child) => child.path === 'offline');
expect(offlineRoute?.title).toBe('Offline Settings');
expect(offlineRoute?.data?.['breadcrumb']).toBe('Offline Settings');
// Admin/ops leaves are redirects, not loadComponent pages
const adminRedirects = ['admin', 'branding', 'integrations', 'notifications', 'usage', 'system', 'offline', 'policy', 'security-data', 'identity-providers'];
for (const path of adminRedirects) {
const route = (root?.children ?? []).find((child) => child.path === path);
expect(route).toBeDefined();
expect(route?.loadComponent).toBeUndefined();
}
// Language and ai-preferences redirect to user-preferences
const langRoute = (root?.children ?? []).find((child) => child.path === 'language');
expect(langRoute?.redirectTo).toBe('user-preferences');
const aiRoute = (root?.children ?? []).find((child) => child.path === 'ai-preferences');
expect(aiRoute?.redirectTo).toBe('user-preferences');
});
it('renders settings shell container', async () => {