feat: role-based sidebar visibility and landing redirect
All checks were successful
CI / build (push) Successful in 51s
CI / docker (push) Successful in 42s

- Vendor (platform:admin): sees only TENANTS in sidebar
- Tenant admin (tenant:manage): sees Dashboard, License, OIDC, Team, Settings
- Regular user (operator/viewer): redirected to server dashboard directly
- LandingRedirect checks scopes in priority order: vendor > admin > server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 23:24:22 +02:00
parent cdd495d985
commit 39c3b39711
2 changed files with 65 additions and 48 deletions

View File

@@ -34,6 +34,7 @@ export function Layout() {
const { username, organizations, currentOrgId } = useOrgStore(); const { username, organizations, currentOrgId } = useOrgStore();
const isVendor = scopes.has('platform:admin'); const isVendor = scopes.has('platform:admin');
const isTenantAdmin = scopes.has('tenant:manage');
// Determine current org slug for server dashboard link // Determine current org slug for server dashboard link
const currentOrg = organizations.find((o) => o.id === currentOrgId); const currentOrg = organizations.find((o) => o.id === currentOrgId);
@@ -41,7 +42,7 @@ export function Layout() {
// Build breadcrumbs from path // Build breadcrumbs from path
const segments = location.pathname.replace(/^\//, '').split('/').filter(Boolean); const segments = location.pathname.replace(/^\//, '').split('/').filter(Boolean);
const breadcrumb = segments.map((seg, i) => { const breadcrumb = segments.map((seg) => {
const label = seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, ' '); const label = seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, ' ');
return { label }; return { label };
}); });
@@ -67,56 +68,60 @@ export function Layout() {
</Sidebar.Section> </Sidebar.Section>
)} )}
{/* Tenant portal */} {/* Tenant portal — only visible to tenant admins (tenant:manage scope) */}
<Sidebar.Section {isTenantAdmin && (
icon={<LayoutDashboard size={16} />} <>
label="Dashboard" <Sidebar.Section
open={false} icon={<LayoutDashboard size={16} />}
active={location.pathname === '/tenant'} label="Dashboard"
onToggle={() => navigate('/tenant')} open={false}
> active={location.pathname === '/tenant'}
{null} onToggle={() => navigate('/tenant')}
</Sidebar.Section> >
{null}
</Sidebar.Section>
<Sidebar.Section <Sidebar.Section
icon={<ShieldCheck size={16} />} icon={<ShieldCheck size={16} />}
label="License" label="License"
open={false} open={false}
active={isActive(location, '/tenant/license')} active={isActive(location, '/tenant/license')}
onToggle={() => navigate('/tenant/license')} onToggle={() => navigate('/tenant/license')}
> >
{null} {null}
</Sidebar.Section> </Sidebar.Section>
<Sidebar.Section <Sidebar.Section
icon={<KeyRound size={16} />} icon={<KeyRound size={16} />}
label="OIDC" label="OIDC"
open={false} open={false}
active={isActive(location, '/tenant/oidc')} active={isActive(location, '/tenant/oidc')}
onToggle={() => navigate('/tenant/oidc')} onToggle={() => navigate('/tenant/oidc')}
> >
{null} {null}
</Sidebar.Section> </Sidebar.Section>
<Sidebar.Section <Sidebar.Section
icon={<Users size={16} />} icon={<Users size={16} />}
label="Team" label="Team"
open={false} open={false}
active={isActive(location, '/tenant/team')} active={isActive(location, '/tenant/team')}
onToggle={() => navigate('/tenant/team')} onToggle={() => navigate('/tenant/team')}
> >
{null} {null}
</Sidebar.Section> </Sidebar.Section>
<Sidebar.Section <Sidebar.Section
icon={<Settings size={16} />} icon={<Settings size={16} />}
label="Settings" label="Settings"
open={false} open={false}
active={isActive(location, '/tenant/settings')} active={isActive(location, '/tenant/settings')}
onToggle={() => navigate('/tenant/settings')} onToggle={() => navigate('/tenant/settings')}
> >
{null} {null}
</Sidebar.Section> </Sidebar.Section>
</>
)}
<Sidebar.Footer> <Sidebar.Footer>
<Sidebar.FooterLink <Sidebar.FooterLink

View File

@@ -6,6 +6,7 @@ import { OrgResolver } from './auth/OrgResolver';
import { Layout } from './components/Layout'; import { Layout } from './components/Layout';
import { RequireScope } from './components/RequireScope'; import { RequireScope } from './components/RequireScope';
import { useScopes } from './auth/useScopes'; import { useScopes } from './auth/useScopes';
import { useOrgStore } from './auth/useOrganization';
import { VendorTenantsPage } from './pages/vendor/VendorTenantsPage'; import { VendorTenantsPage } from './pages/vendor/VendorTenantsPage';
import { CreateTenantPage } from './pages/vendor/CreateTenantPage'; import { CreateTenantPage } from './pages/vendor/CreateTenantPage';
@@ -18,10 +19,21 @@ import { SettingsPage } from './pages/tenant/SettingsPage';
function LandingRedirect() { function LandingRedirect() {
const scopes = useScopes(); const scopes = useScopes();
const { organizations, currentOrgId } = useOrgStore();
const currentOrg = organizations.find((o) => o.id === currentOrgId);
// Vendor → vendor console
if (scopes.has('platform:admin')) { if (scopes.has('platform:admin')) {
return <Navigate to="/vendor/tenants" replace />; return <Navigate to="/vendor/tenants" replace />;
} }
return <Navigate to="/tenant" replace />; // Tenant admin → tenant portal
if (scopes.has('tenant:manage')) {
return <Navigate to="/tenant" replace />;
}
// Regular user (operator/viewer) → server dashboard directly
const serverUrl = currentOrg?.slug ? `/t/${currentOrg.slug}/` : '/server/';
window.location.href = serverUrl;
return null;
} }
export function AppRouter() { export function AppRouter() {