diff --git a/src/config/ui.rs b/src/config/ui.rs index 6ed954a..39e27a4 100644 --- a/src/config/ui.rs +++ b/src/config/ui.rs @@ -28,6 +28,10 @@ pub struct UiConfig { /// Branding customization. #[serde(default)] pub branding: BrandingConfig, + + /// Per-page visibility configuration. + #[serde(default)] + pub pages: PagesConfig, } impl Default for UiConfig { @@ -39,6 +43,7 @@ impl Default for UiConfig { chat: ChatConfig::default(), admin: AdminConfig::default(), branding: BrandingConfig::default(), + pages: PagesConfig::default(), } } } @@ -390,6 +395,117 @@ pub struct FooterLink { pub url: String, } +/// Page visibility status. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum PageStatus { + #[default] + Enabled, + Disabled, + Notice, +} + +/// Per-page configuration. Accepts either a bare string (`"enabled"`) or an inline table +/// (`{ status = "notice", notice_message = "..." }`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum PageConfig { + Simple(PageStatus), + Detailed { + status: PageStatus, + #[serde(default)] + notice_message: Option, + }, +} + +impl Default for PageConfig { + fn default() -> Self { + Self::Simple(PageStatus::Enabled) + } +} + +impl PageConfig { + pub fn status(&self) -> &PageStatus { + match self { + Self::Simple(s) => s, + Self::Detailed { status, .. } => status, + } + } + + pub fn notice_message(&self) -> Option<&str> { + match self { + Self::Simple(_) => None, + Self::Detailed { notice_message, .. } => notice_message.as_deref(), + } + } +} + +/// Per-page visibility for main UI pages. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[serde(deny_unknown_fields)] +pub struct PagesConfig { + #[serde(default)] + pub chat: PageConfig, + #[serde(default)] + pub studio: PageConfig, + #[serde(default)] + pub projects: PageConfig, + #[serde(default)] + pub teams: PageConfig, + #[serde(default)] + pub knowledge_bases: PageConfig, + #[serde(default)] + pub api_keys: PageConfig, + #[serde(default)] + pub providers: PageConfig, + #[serde(default)] + pub usage: PageConfig, + #[serde(default)] + pub admin: AdminPagesConfig, +} + +/// Per-page visibility for admin pages. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[serde(deny_unknown_fields)] +pub struct AdminPagesConfig { + #[serde(default)] + pub dashboard: PageConfig, + #[serde(default)] + pub organizations: PageConfig, + #[serde(default)] + pub projects: PageConfig, + #[serde(default)] + pub teams: PageConfig, + #[serde(default)] + pub service_accounts: PageConfig, + #[serde(default)] + pub users: PageConfig, + #[serde(default)] + pub sso: PageConfig, + #[serde(default)] + pub session_info: PageConfig, + #[serde(default)] + pub api_keys: PageConfig, + #[serde(default)] + pub providers: PageConfig, + #[serde(default)] + pub provider_health: PageConfig, + #[serde(default)] + pub knowledge_bases: PageConfig, + #[serde(default)] + pub pricing: PageConfig, + #[serde(default)] + pub usage: PageConfig, + #[serde(default)] + pub audit_logs: PageConfig, + #[serde(default)] + pub settings: PageConfig, +} + /// Login page customization. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] @@ -411,3 +527,53 @@ pub struct LoginConfig { #[serde(default = "default_true")] pub show_logo: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_string_status() { + let toml = r#"chat = "enabled""#; + let pages: PagesConfig = toml::from_str(toml).unwrap(); + assert_eq!(pages.chat.status(), &PageStatus::Enabled); + } + + #[test] + fn detailed_table() { + let toml = r#" +[chat] +status = "notice" +notice_message = "Under maintenance" +"#; + let pages: PagesConfig = toml::from_str(toml).unwrap(); + assert_eq!(pages.chat.status(), &PageStatus::Notice); + assert_eq!(pages.chat.notice_message(), Some("Under maintenance")); + } + + #[test] + fn mixed_formats_with_defaults() { + let toml = r#" +chat = "disabled" +studio = "enabled" +"#; + let pages: PagesConfig = toml::from_str(toml).unwrap(); + assert_eq!(pages.chat.status(), &PageStatus::Disabled); + assert_eq!(pages.studio.status(), &PageStatus::Enabled); + // Omitted fields default to enabled + assert_eq!(pages.teams.status(), &PageStatus::Enabled); + assert_eq!(pages.usage.status(), &PageStatus::Enabled); + } + + #[test] + fn invalid_status_fails() { + let toml = r#"chat = "bogus""#; + assert!(toml::from_str::(toml).is_err()); + } + + #[test] + fn unknown_field_rejected() { + let toml = r#"nonexistent_page = "enabled""#; + assert!(toml::from_str::(toml).is_err()); + } +} diff --git a/src/routes/admin/ui_config.rs b/src/routes/admin/ui_config.rs index ce5ba64..d478b17 100644 --- a/src/routes/admin/ui_config.rs +++ b/src/routes/admin/ui_config.rs @@ -4,8 +4,8 @@ use serde::Serialize; use crate::{ AppState, config::{ - AdminConfig, AuthMode, BrandingConfig, ChatConfig, ColorPalette, CustomFont, FontsConfig, - LoginConfig, UiConfig, + AdminConfig, AdminPagesConfig, AuthMode, BrandingConfig, ChatConfig, ColorPalette, + CustomFont, FontsConfig, LoginConfig, PageConfig, PageStatus, PagesConfig, UiConfig, }, }; @@ -17,6 +17,7 @@ pub struct UiConfigResponse { pub admin: AdminResponse, pub auth: AuthResponse, pub sovereignty: SovereigntyUiResponse, + pub pages: PagesResponse, } #[derive(Debug, Serialize)] @@ -155,6 +156,94 @@ pub struct CustomFieldDefResponse { pub description: Option, } +#[derive(Debug, Serialize)] +pub struct PageConfigResponse { + pub status: PageStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub notice_message: Option, +} + +#[derive(Debug, Serialize)] +pub struct PagesResponse { + pub chat: PageConfigResponse, + pub studio: PageConfigResponse, + pub projects: PageConfigResponse, + pub teams: PageConfigResponse, + pub knowledge_bases: PageConfigResponse, + pub api_keys: PageConfigResponse, + pub providers: PageConfigResponse, + pub usage: PageConfigResponse, + pub admin: AdminPagesResponse, +} + +#[derive(Debug, Serialize)] +pub struct AdminPagesResponse { + pub dashboard: PageConfigResponse, + pub organizations: PageConfigResponse, + pub projects: PageConfigResponse, + pub teams: PageConfigResponse, + pub service_accounts: PageConfigResponse, + pub users: PageConfigResponse, + pub sso: PageConfigResponse, + pub session_info: PageConfigResponse, + pub api_keys: PageConfigResponse, + pub providers: PageConfigResponse, + pub provider_health: PageConfigResponse, + pub knowledge_bases: PageConfigResponse, + pub pricing: PageConfigResponse, + pub usage: PageConfigResponse, + pub audit_logs: PageConfigResponse, + pub settings: PageConfigResponse, +} + +impl From<&PageConfig> for PageConfigResponse { + fn from(config: &PageConfig) -> Self { + Self { + status: config.status().clone(), + notice_message: config.notice_message().map(String::from), + } + } +} + +impl From<&PagesConfig> for PagesResponse { + fn from(config: &PagesConfig) -> Self { + Self { + chat: PageConfigResponse::from(&config.chat), + studio: PageConfigResponse::from(&config.studio), + projects: PageConfigResponse::from(&config.projects), + teams: PageConfigResponse::from(&config.teams), + knowledge_bases: PageConfigResponse::from(&config.knowledge_bases), + api_keys: PageConfigResponse::from(&config.api_keys), + providers: PageConfigResponse::from(&config.providers), + usage: PageConfigResponse::from(&config.usage), + admin: AdminPagesResponse::from(&config.admin), + } + } +} + +impl From<&AdminPagesConfig> for AdminPagesResponse { + fn from(config: &AdminPagesConfig) -> Self { + Self { + dashboard: PageConfigResponse::from(&config.dashboard), + organizations: PageConfigResponse::from(&config.organizations), + projects: PageConfigResponse::from(&config.projects), + teams: PageConfigResponse::from(&config.teams), + service_accounts: PageConfigResponse::from(&config.service_accounts), + users: PageConfigResponse::from(&config.users), + sso: PageConfigResponse::from(&config.sso), + session_info: PageConfigResponse::from(&config.session_info), + api_keys: PageConfigResponse::from(&config.api_keys), + providers: PageConfigResponse::from(&config.providers), + provider_health: PageConfigResponse::from(&config.provider_health), + knowledge_bases: PageConfigResponse::from(&config.knowledge_bases), + pricing: PageConfigResponse::from(&config.pricing), + usage: PageConfigResponse::from(&config.usage), + audit_logs: PageConfigResponse::from(&config.audit_logs), + settings: PageConfigResponse::from(&config.settings), + } + } +} + impl From<&UiConfig> for UiConfigResponse { fn from(config: &UiConfig) -> Self { Self { @@ -165,6 +254,7 @@ impl From<&UiConfig> for UiConfigResponse { sovereignty: SovereigntyUiResponse { custom_fields: vec![], }, + pages: PagesResponse::from(&config.pages), } } } diff --git a/src/wasm.rs b/src/wasm.rs index 7353243..883f4d1 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -458,6 +458,12 @@ fn error_response(status: u16, message: &str) -> Response { /// Create a minimal config suitable for WASM browser operation. fn wasm_default_config() -> config::GatewayConfig { + use config::{PageConfig, PageStatus}; + + let notice = |msg: &str| PageConfig::Detailed { + status: PageStatus::Notice, + notice_message: Some(format!("This feature requires Hadrian Server. {msg}")), + }; config::GatewayConfig { server: config::ServerConfig { allow_loopback_urls: true, @@ -474,7 +480,32 @@ fn wasm_default_config() -> config::GatewayConfig { limits: config::LimitsConfig::default(), features: config::FeaturesConfig::default(), observability: config::ObservabilityConfig::default(), - ui: config::UiConfig::default(), + ui: config::UiConfig { + pages: config::PagesConfig { + teams: notice("Team management needs multi-user authentication."), + api_keys: notice("API key management is not available in browser mode."), + usage: notice("Usage tracking is not available in browser mode."), + admin: config::AdminPagesConfig { + organizations: notice( + "Organization management is not available in browser mode.", + ), + teams: notice("Team management needs multi-user authentication."), + service_accounts: notice("Service accounts are not available in browser mode."), + users: notice("User management is not available in browser mode."), + sso: notice("Single sign-on is not available in browser mode."), + api_keys: notice("API key management is not available in browser mode."), + provider_health: notice( + "Provider health monitoring is not available in browser mode.", + ), + pricing: notice("Pricing configuration requires usage tracking."), + usage: notice("Usage tracking is not available in browser mode."), + audit_logs: notice("Audit logging is not available in browser mode."), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, docs: config::DocsConfig::default(), pricing: pricing::PricingConfig::default(), secrets: config::SecretsConfig::None, diff --git a/ui/src/components/AdminLayout/AdminLayout.tsx b/ui/src/components/AdminLayout/AdminLayout.tsx index 55d89c8..fa1f6f5 100644 --- a/ui/src/components/AdminLayout/AdminLayout.tsx +++ b/ui/src/components/AdminLayout/AdminLayout.tsx @@ -15,6 +15,7 @@ import { AlphaBanner } from "@/components/AlphaBanner/AlphaBanner"; import { Header } from "@/components/Header/Header"; import { AdminSidebar } from "./AdminSidebar"; import { usePreferences } from "@/preferences/PreferencesProvider"; +import { useConfig } from "@/config/ConfigProvider"; import { useCommandPalette } from "@/components/CommandPalette/CommandPalette"; import { useResizable } from "@/hooks/useResizable"; import { SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_DEFAULT_WIDTH } from "@/preferences/types"; @@ -27,6 +28,7 @@ export interface AdminLayoutProps { export function AdminLayout({ children }: AdminLayoutProps) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const { preferences, setPreferences } = usePreferences(); + const { config } = useConfig(); const navigate = useNavigate(); const { registerCommand, unregisterCommand } = useCommandPalette(); @@ -63,9 +65,10 @@ export function AdminLayout({ children }: AdminLayoutProps) { [setPreferences] ); - // Register admin navigation commands + // Register admin navigation commands (filtered by page visibility) useEffect(() => { - const commands = [ + const adminPages = config.pages.admin; + const allCommands: (Parameters[0] & { pageKey?: string })[] = [ { id: "admin-go-dashboard", label: "Go to Dashboard", @@ -73,6 +76,7 @@ export function AdminLayout({ children }: AdminLayoutProps) { icon: , shortcut: ["G", "D"], category: "Admin", + pageKey: "dashboard", onSelect: () => navigate("/admin"), }, { @@ -81,6 +85,7 @@ export function AdminLayout({ children }: AdminLayoutProps) { description: "Manage organizations", icon: , category: "Admin", + pageKey: "organizations", onSelect: () => navigate("/admin/organizations"), }, { @@ -89,6 +94,7 @@ export function AdminLayout({ children }: AdminLayoutProps) { description: "Manage projects", icon: , category: "Admin", + pageKey: "projects", onSelect: () => navigate("/admin/projects"), }, { @@ -97,6 +103,7 @@ export function AdminLayout({ children }: AdminLayoutProps) { description: "Manage users", icon: , category: "Admin", + pageKey: "users", onSelect: () => navigate("/admin/users"), }, { @@ -105,6 +112,7 @@ export function AdminLayout({ children }: AdminLayoutProps) { description: "Manage API keys", icon: , category: "Admin", + pageKey: "api_keys", onSelect: () => navigate("/admin/api-keys"), }, { @@ -113,6 +121,7 @@ export function AdminLayout({ children }: AdminLayoutProps) { description: "Configure LLM providers", icon: , category: "Admin", + pageKey: "providers", onSelect: () => navigate("/admin/providers"), }, { @@ -121,6 +130,7 @@ export function AdminLayout({ children }: AdminLayoutProps) { description: "Configure model pricing", icon: , category: "Admin", + pageKey: "pricing", onSelect: () => navigate("/admin/pricing"), }, { @@ -129,6 +139,7 @@ export function AdminLayout({ children }: AdminLayoutProps) { description: "View usage statistics", icon: , category: "Admin", + pageKey: "usage", onSelect: () => navigate("/admin/usage"), }, { @@ -137,6 +148,7 @@ export function AdminLayout({ children }: AdminLayoutProps) { description: "Configure gateway settings", icon: , category: "Admin", + pageKey: "settings", onSelect: () => navigate("/admin/settings"), }, { @@ -148,12 +160,24 @@ export function AdminLayout({ children }: AdminLayoutProps) { }, ]; + const commands = allCommands.filter( + (cmd) => + !cmd.pageKey || adminPages[cmd.pageKey as keyof typeof adminPages]?.status !== "disabled" + ); + commands.forEach(registerCommand); return () => { commands.forEach((cmd) => unregisterCommand(cmd.id)); }; - }, [navigate, registerCommand, unregisterCommand, adminSidebarCollapsed, handleCollapsedChange]); + }, [ + navigate, + registerCommand, + unregisterCommand, + adminSidebarCollapsed, + handleCollapsedChange, + config.pages, + ]); return (
diff --git a/ui/src/components/AdminLayout/AdminSidebar.stories.tsx b/ui/src/components/AdminLayout/AdminSidebar.stories.tsx index 48b3637..6722224 100644 --- a/ui/src/components/AdminLayout/AdminSidebar.stories.tsx +++ b/ui/src/components/AdminLayout/AdminSidebar.stories.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { MemoryRouter } from "react-router-dom"; +import { ConfigProvider } from "@/config/ConfigProvider"; import { AdminSidebar } from "./AdminSidebar"; import { useResizable } from "@/hooks/useResizable"; import { SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH } from "@/preferences/types"; @@ -16,9 +17,11 @@ const meta: Meta = { const initialEntries = context.parameters.initialEntries || ["/admin"]; return ( -
- -
+ +
+ +
+
); }, diff --git a/ui/src/components/AdminLayout/AdminSidebar.tsx b/ui/src/components/AdminLayout/AdminSidebar.tsx index 7411eba..919e805 100644 --- a/ui/src/components/AdminLayout/AdminSidebar.tsx +++ b/ui/src/components/AdminLayout/AdminSidebar.tsx @@ -21,6 +21,8 @@ import { import { cn } from "@/utils/cn"; import { Button } from "@/components/Button/Button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/Tooltip/Tooltip"; +import { useConfig } from "@/config/ConfigProvider"; +import type { AdminPagesConfig } from "@/config/types"; import { SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH } from "@/preferences/types"; interface NavItem { @@ -28,25 +30,41 @@ interface NavItem { icon: typeof LayoutDashboard; label: string; exact?: boolean; + pageKey?: keyof AdminPagesConfig; } const adminNavItems: NavItem[] = [ - { to: "/admin", icon: LayoutDashboard, label: "Dashboard", exact: true }, - { to: "/admin/organizations", icon: Building2, label: "Organizations" }, - { to: "/admin/projects", icon: FolderOpen, label: "Projects" }, - { to: "/admin/teams", icon: Users2, label: "Teams" }, - { to: "/admin/service-accounts", icon: Bot, label: "Service Accounts" }, - { to: "/admin/users", icon: Users, label: "Users" }, - { to: "/admin/sso", icon: Shield, label: "SSO" }, - { to: "/session", icon: Bug, label: "Session Info" }, - { to: "/admin/api-keys", icon: Key, label: "API Keys" }, - { to: "/admin/providers", icon: Server, label: "Providers" }, - { to: "/admin/provider-health", icon: Activity, label: "Provider Health" }, - { to: "/admin/vector-stores", icon: Database, label: "Knowledge Bases" }, - { to: "/admin/pricing", icon: DollarSign, label: "Pricing" }, - { to: "/admin/usage", icon: BarChart3, label: "Usage" }, - { to: "/admin/audit-logs", icon: FileText, label: "Audit Logs" }, - { to: "/admin/settings", icon: Settings, label: "Settings" }, + { to: "/admin", icon: LayoutDashboard, label: "Dashboard", exact: true, pageKey: "dashboard" }, + { to: "/admin/organizations", icon: Building2, label: "Organizations", pageKey: "organizations" }, + { to: "/admin/projects", icon: FolderOpen, label: "Projects", pageKey: "projects" }, + { to: "/admin/teams", icon: Users2, label: "Teams", pageKey: "teams" }, + { + to: "/admin/service-accounts", + icon: Bot, + label: "Service Accounts", + pageKey: "service_accounts", + }, + { to: "/admin/users", icon: Users, label: "Users", pageKey: "users" }, + { to: "/admin/sso", icon: Shield, label: "SSO", pageKey: "sso" }, + { to: "/session", icon: Bug, label: "Session Info", pageKey: "session_info" }, + { to: "/admin/api-keys", icon: Key, label: "API Keys", pageKey: "api_keys" }, + { to: "/admin/providers", icon: Server, label: "Providers", pageKey: "providers" }, + { + to: "/admin/provider-health", + icon: Activity, + label: "Provider Health", + pageKey: "provider_health", + }, + { + to: "/admin/vector-stores", + icon: Database, + label: "Knowledge Bases", + pageKey: "knowledge_bases", + }, + { to: "/admin/pricing", icon: DollarSign, label: "Pricing", pageKey: "pricing" }, + { to: "/admin/usage", icon: BarChart3, label: "Usage", pageKey: "usage" }, + { to: "/admin/audit-logs", icon: FileText, label: "Audit Logs", pageKey: "audit_logs" }, + { to: "/admin/settings", icon: Settings, label: "Settings", pageKey: "settings" }, ]; export interface AdminSidebarProps { @@ -81,6 +99,12 @@ export function AdminSidebar({ className, }: AdminSidebarProps) { const location = useLocation(); + const { config } = useConfig(); + + const visibleItems = adminNavItems.filter((item) => { + if (!item.pageKey) return true; + return config.pages.admin[item.pageKey]?.status !== "disabled"; + }); const isActive = (item: NavItem) => { if (item.exact) { @@ -131,7 +155,7 @@ export function AdminSidebar({ aria-label="Admin navigation" >
    - {adminNavItems.map((item) => { + {visibleItems.map((item) => { const active = isActive(item); const Icon = item.icon; diff --git a/ui/src/components/AppLayout/AppLayout.tsx b/ui/src/components/AppLayout/AppLayout.tsx index fd9ac32..e87032d 100644 --- a/ui/src/components/AppLayout/AppLayout.tsx +++ b/ui/src/components/AppLayout/AppLayout.tsx @@ -18,6 +18,8 @@ import { AlphaBanner } from "@/components/AlphaBanner/AlphaBanner"; import { Header } from "@/components/Header/Header"; import { Sidebar } from "@/components/Sidebar/Sidebar"; import { usePreferences } from "@/preferences/PreferencesProvider"; +import { useConfig } from "@/config/ConfigProvider"; +import { getPageConfig } from "@/components/PageGuard/PageGuard"; import { useCommandPalette } from "@/components/CommandPalette/CommandPalette"; import { useResizable } from "@/hooks/useResizable"; import { SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_DEFAULT_WIDTH } from "@/preferences/types"; @@ -30,6 +32,7 @@ interface AppLayoutProps { export function AppLayout({ children }: AppLayoutProps) { const [sidebarOpen, setSidebarOpen] = useState(false); const { preferences, setPreferences } = usePreferences(); + const { config } = useConfig(); const navigate = useNavigate(); const location = useLocation(); const { registerCommand, unregisterCommand } = useCommandPalette(); @@ -58,9 +61,10 @@ export function AppLayout({ children }: AppLayoutProps) { onResizeEnd: handleResizeEnd, }); - // Register navigation commands + // Register navigation commands (filtered by page visibility) useEffect(() => { - const commands = [ + const pages = config.pages; + const allCommands: (Parameters[0] & { pageKey?: string })[] = [ { id: "new-chat", label: "New Chat", @@ -68,6 +72,7 @@ export function AppLayout({ children }: AppLayoutProps) { icon: , shortcut: ["N"], category: "Chat", + pageKey: "chat", onSelect: () => navigate("/chat"), }, { @@ -77,6 +82,7 @@ export function AppLayout({ children }: AppLayoutProps) { icon: , shortcut: ["G", "C"], category: "Navigation", + pageKey: "chat", onSelect: () => navigate("/chat"), }, { @@ -86,6 +92,7 @@ export function AppLayout({ children }: AppLayoutProps) { icon: , shortcut: ["G", "S"], category: "Navigation", + pageKey: "studio", onSelect: () => navigate("/studio"), }, { @@ -95,6 +102,7 @@ export function AppLayout({ children }: AppLayoutProps) { icon: , shortcut: ["G", "D"], category: "Navigation", + pageKey: "admin.dashboard", onSelect: () => navigate("/admin"), }, { @@ -103,6 +111,7 @@ export function AppLayout({ children }: AppLayoutProps) { description: "Manage organizations", icon: , category: "Admin", + pageKey: "admin.organizations", onSelect: () => navigate("/admin/organizations"), }, { @@ -111,6 +120,7 @@ export function AppLayout({ children }: AppLayoutProps) { description: "Manage projects", icon: , category: "Admin", + pageKey: "admin.projects", onSelect: () => navigate("/admin/projects"), }, { @@ -119,6 +129,7 @@ export function AppLayout({ children }: AppLayoutProps) { description: "Manage users", icon: , category: "Admin", + pageKey: "admin.users", onSelect: () => navigate("/admin/users"), }, { @@ -127,6 +138,7 @@ export function AppLayout({ children }: AppLayoutProps) { description: "Manage API keys", icon: , category: "Admin", + pageKey: "admin.api_keys", onSelect: () => navigate("/admin/api-keys"), }, { @@ -135,6 +147,7 @@ export function AppLayout({ children }: AppLayoutProps) { description: "Configure LLM providers", icon: , category: "Admin", + pageKey: "admin.providers", onSelect: () => navigate("/admin/providers"), }, { @@ -143,6 +156,7 @@ export function AppLayout({ children }: AppLayoutProps) { description: "Configure model pricing", icon: , category: "Admin", + pageKey: "admin.pricing", onSelect: () => navigate("/admin/pricing"), }, { @@ -151,6 +165,7 @@ export function AppLayout({ children }: AppLayoutProps) { description: "View usage statistics", icon: , category: "Admin", + pageKey: "admin.usage", onSelect: () => navigate("/admin/usage"), }, { @@ -159,6 +174,7 @@ export function AppLayout({ children }: AppLayoutProps) { description: "Configure gateway settings", icon: , category: "Admin", + pageKey: "admin.settings", onSelect: () => navigate("/admin/settings"), }, { @@ -170,12 +186,23 @@ export function AppLayout({ children }: AppLayoutProps) { }, ]; + const commands = allCommands.filter( + (cmd) => !cmd.pageKey || getPageConfig(pages, cmd.pageKey).status !== "disabled" + ); + commands.forEach(registerCommand); return () => { commands.forEach((cmd) => unregisterCommand(cmd.id)); }; - }, [navigate, registerCommand, unregisterCommand, preferences.sidebarCollapsed, setPreferences]); + }, [ + navigate, + registerCommand, + unregisterCommand, + preferences.sidebarCollapsed, + setPreferences, + config.pages, + ]); return (
    diff --git a/ui/src/components/Header/Header.tsx b/ui/src/components/Header/Header.tsx index 3b2a4da..395e7cc 100644 --- a/ui/src/components/Header/Header.tsx +++ b/ui/src/components/Header/Header.tsx @@ -18,6 +18,7 @@ import { ThemeToggle } from "@/components/ThemeToggle/ThemeToggle"; import { UserMenu } from "@/components/UserMenu/UserMenu"; import { useWasmSetup } from "@/components/WasmSetup/WasmSetupGuard"; import { useConfig } from "@/config/ConfigProvider"; +import { getPageConfig, getFirstEnabledRoute } from "@/components/PageGuard/PageGuard"; import { usePreferences } from "@/preferences/PreferencesProvider"; import { useAuth, hasAdminAccess } from "@/auth"; import { cn } from "@/utils/cn"; @@ -27,17 +28,18 @@ export interface NavItem { icon: React.ComponentType<{ className?: string }>; label: string; matchPrefix?: string; + pageKey?: string; } export const navItems: NavItem[] = [ - { to: "/chat", icon: MessageSquare, label: "Chat" }, - { to: "/studio", icon: Palette, label: "Studio" }, - { to: "/projects", icon: FolderOpen, label: "Projects" }, - { to: "/teams", icon: UsersRound, label: "Teams" }, - { to: "/knowledge-bases", icon: BookOpen, label: "Knowledge" }, - { to: "/api-keys", icon: Key, label: "API Keys" }, - { to: "/providers", icon: Server, label: "Providers" }, - { to: "/usage", icon: BarChart3, label: "Usage" }, + { to: "/chat", icon: MessageSquare, label: "Chat", pageKey: "chat" }, + { to: "/studio", icon: Palette, label: "Studio", pageKey: "studio" }, + { to: "/projects", icon: FolderOpen, label: "Projects", pageKey: "projects" }, + { to: "/teams", icon: UsersRound, label: "Teams", pageKey: "teams" }, + { to: "/knowledge-bases", icon: BookOpen, label: "Knowledge", pageKey: "knowledge_bases" }, + { to: "/api-keys", icon: Key, label: "API Keys", pageKey: "api_keys" }, + { to: "/providers", icon: Server, label: "Providers", pageKey: "providers" }, + { to: "/usage", icon: BarChart3, label: "Usage", pageKey: "usage" }, ]; export const adminNavItem: NavItem = { @@ -66,9 +68,15 @@ export function Header({ onMenuClick, showMenuButton = false, className }: Heade ? config.branding.logo_dark_url : config?.branding.logo_url; + // Filter nav items by page visibility + const visibleNavItems = navItems.filter((item) => { + if (!item.pageKey) return true; + return getPageConfig(config.pages, item.pageKey).status !== "disabled"; + }); + // Only show admin nav if admin is enabled AND user has admin access const showAdmin = config?.admin.enabled && hasAdminAccess(user); - const allNavItems = showAdmin ? [...navItems, adminNavItem] : navItems; + const allNavItems = showAdmin ? [...visibleNavItems, adminNavItem] : visibleNavItems; const isActive = (item: NavItem) => { if (item.matchPrefix) { @@ -92,7 +100,7 @@ export function Header({ onMenuClick, showMenuButton = false, className }: Heade Toggle menu )} - + {logoUrl ? ( ; + +const mainPageOrder: MainPageKey[] = [ + "chat", + "studio", + "projects", + "teams", + "knowledge_bases", + "api_keys", + "providers", + "usage", +]; + +const mainPageRoutes: Record = { + chat: "/chat", + studio: "/studio", + projects: "/projects", + teams: "/teams", + knowledge_bases: "/knowledge-bases", + api_keys: "/api-keys", + providers: "/providers", + usage: "/usage", +}; + +export function getPageConfig(pages: PagesConfig, key: string): PageConfig { + if (key.startsWith("admin.")) { + const adminKey = key.slice(6) as keyof AdminPagesConfig; + return pages.admin[adminKey] ?? { status: "enabled" }; + } + return pages[key as MainPageKey] ?? { status: "enabled" }; +} + +export function getFirstEnabledRoute(pages: PagesConfig): string { + for (const key of mainPageOrder) { + if (pages[key].status !== "disabled") { + return mainPageRoutes[key]; + } + } + return "/account"; +} + +interface PageGuardProps { + pageKey: string; + pageTitle: string; + children: React.ReactNode; +} + +export function PageGuard({ pageKey, pageTitle, children }: PageGuardProps) { + const { config } = useConfig(); + const pageConfig = getPageConfig(config.pages, pageKey); + + if (pageConfig.status === "disabled") { + return ; + } + + if (pageConfig.status === "notice") { + return ( + + ); + } + + return <>{children}; +} diff --git a/ui/src/components/PageNotice/PageNotice.tsx b/ui/src/components/PageNotice/PageNotice.tsx new file mode 100644 index 0000000..5d2cb64 --- /dev/null +++ b/ui/src/components/PageNotice/PageNotice.tsx @@ -0,0 +1,23 @@ +import { Info } from "lucide-react"; +import { Card, CardContent } from "@/components/Card/Card"; + +interface PageNoticeProps { + title: string; + message: string; +} + +export function PageNotice({ title, message }: PageNoticeProps) { + return ( +
    + + +
    + +
    +

    {title}

    +

    {message}

    +
    +
    +
    + ); +} diff --git a/ui/src/components/UserMenu/UserMenu.tsx b/ui/src/components/UserMenu/UserMenu.tsx index c4cdb59..3c2a3dc 100644 --- a/ui/src/components/UserMenu/UserMenu.tsx +++ b/ui/src/components/UserMenu/UserMenu.tsx @@ -12,6 +12,7 @@ import { DropdownTrigger, } from "@/components/Dropdown/Dropdown"; import { navItems, adminNavItem } from "@/components/Header/Header"; +import { getPageConfig } from "@/components/PageGuard/PageGuard"; import { cn } from "@/utils/cn"; interface UserMenuProps { @@ -22,8 +23,12 @@ export function UserMenu({ className }: UserMenuProps) { const { user, logout, isAuthenticated } = useAuth(); const { config } = useConfig(); const navigate = useNavigate(); + const visibleNavItems = navItems.filter((item) => { + if (!item.pageKey) return true; + return getPageConfig(config.pages, item.pageKey).status !== "disabled"; + }); const showAdmin = config?.admin.enabled && hasAdminAccess(user); - const allNavItems = showAdmin ? [...navItems, adminNavItem] : navItems; + const allNavItems = showAdmin ? [...visibleNavItems, adminNavItem] : visibleNavItems; if (!isAuthenticated) { return null; @@ -82,10 +87,12 @@ export function UserMenu({ className }: UserMenuProps) { Account Settings - navigate("/session")}> - - Session Info - + {getPageConfig(config.pages, "admin.session_info").status !== "disabled" && ( + navigate("/session")}> + + Session Info + + )} diff --git a/ui/src/config/ConfigProvider.tsx b/ui/src/config/ConfigProvider.tsx index 80538ee..c2515d5 100644 --- a/ui/src/config/ConfigProvider.tsx +++ b/ui/src/config/ConfigProvider.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; import type { UiConfig, ColorPalette, FontsConfig, CustomFont } from "./types"; -import { defaultConfig, getApiBaseUrl } from "./defaults"; +import { defaultConfig, defaultPagesConfig, getApiBaseUrl } from "./defaults"; interface ConfigContextValue { config: UiConfig; @@ -165,7 +165,13 @@ export function ConfigProvider({ children }: ConfigProviderProps) { const response = await fetch(`${apiBaseUrl}/admin/v1/ui/config`); if (response.ok) { const data = (await response.json()) as UiConfig; - setConfig(data); + // Deep-merge pages so partial server responses fill in defaults + const mergedPages = { + ...defaultPagesConfig, + ...data.pages, + admin: { ...defaultPagesConfig.admin, ...data.pages?.admin }, + }; + setConfig({ ...data, pages: mergedPages }); } else { // Use defaults if endpoint is not available console.warn("UI config endpoint not available, using defaults"); diff --git a/ui/src/config/defaults.ts b/ui/src/config/defaults.ts index 1eb9f54..9a5c390 100644 --- a/ui/src/config/defaults.ts +++ b/ui/src/config/defaults.ts @@ -1,4 +1,35 @@ -import type { UiConfig } from "./types"; +import type { UiConfig, PagesConfig } from "./types"; + +const enabledPage = { status: "enabled" as const }; + +export const defaultPagesConfig: PagesConfig = { + chat: enabledPage, + studio: enabledPage, + projects: enabledPage, + teams: enabledPage, + knowledge_bases: enabledPage, + api_keys: enabledPage, + providers: enabledPage, + usage: enabledPage, + admin: { + dashboard: enabledPage, + organizations: enabledPage, + projects: enabledPage, + teams: enabledPage, + service_accounts: enabledPage, + users: enabledPage, + sso: enabledPage, + session_info: enabledPage, + api_keys: enabledPage, + providers: enabledPage, + provider_health: enabledPage, + knowledge_bases: enabledPage, + pricing: enabledPage, + usage: enabledPage, + audit_logs: enabledPage, + settings: enabledPage, + }, +}; export const defaultConfig: UiConfig = { branding: { @@ -34,6 +65,7 @@ export const defaultConfig: UiConfig = { sovereignty: { custom_fields: [], }, + pages: defaultPagesConfig, }; export function getApiBaseUrl(): string { diff --git a/ui/src/config/types.ts b/ui/src/config/types.ts index 07a9dd7..82c6445 100644 --- a/ui/src/config/types.ts +++ b/ui/src/config/types.ts @@ -1,9 +1,48 @@ +export type PageStatus = "enabled" | "disabled" | "notice"; + +export interface PageConfig { + status: PageStatus; + notice_message?: string; +} + +export interface PagesConfig { + chat: PageConfig; + studio: PageConfig; + projects: PageConfig; + teams: PageConfig; + knowledge_bases: PageConfig; + api_keys: PageConfig; + providers: PageConfig; + usage: PageConfig; + admin: AdminPagesConfig; +} + +export interface AdminPagesConfig { + dashboard: PageConfig; + organizations: PageConfig; + projects: PageConfig; + teams: PageConfig; + service_accounts: PageConfig; + users: PageConfig; + sso: PageConfig; + session_info: PageConfig; + api_keys: PageConfig; + providers: PageConfig; + provider_health: PageConfig; + knowledge_bases: PageConfig; + pricing: PageConfig; + usage: PageConfig; + audit_logs: PageConfig; + settings: PageConfig; +} + export interface UiConfig { branding: BrandingConfig; chat: ChatConfig; admin: AdminConfig; auth: AuthConfig; sovereignty: SovereigntyUiConfig; + pages: PagesConfig; } export interface BrandingConfig { diff --git a/ui/src/routes/AppRoutes.tsx b/ui/src/routes/AppRoutes.tsx index ed2d444..8fcc61c 100644 --- a/ui/src/routes/AppRoutes.tsx +++ b/ui/src/routes/AppRoutes.tsx @@ -2,6 +2,8 @@ import { Routes, Route, Navigate } from "react-router-dom"; import { RequireAuth, RequireAdmin } from "@/auth"; import { AppLayout } from "@/components/AppLayout/AppLayout"; import { AdminLayout } from "@/components/AdminLayout/AdminLayout"; +import { PageGuard, getFirstEnabledRoute } from "@/components/PageGuard/PageGuard"; +import { useConfig } from "@/config/ConfigProvider"; import { lazy, Suspense } from "react"; import { Spinner } from "@/components/Spinner/Spinner"; @@ -52,11 +54,16 @@ function PageLoader() { ); } +function RootRedirect() { + const { config } = useConfig(); + return ; +} + export function AppRoutes() { return ( {/* Root redirect */} - } /> + } /> {/* Login route */} }> - + + + } /> @@ -99,7 +108,9 @@ export function AppRoutes() { path="/chat/:conversationId" element={ }> - + + + } /> @@ -109,7 +120,9 @@ export function AppRoutes() { path="/projects" element={ }> - + + + } /> @@ -119,7 +132,9 @@ export function AppRoutes() { path="/projects/:orgSlug/:projectSlug" element={ }> - + + + } /> @@ -129,7 +144,9 @@ export function AppRoutes() { path="/teams" element={ }> - + + + } /> @@ -139,7 +156,9 @@ export function AppRoutes() { path="/knowledge-bases" element={ }> - + + + } /> @@ -149,7 +168,9 @@ export function AppRoutes() { path="/api-keys" element={ }> - + + + } /> @@ -157,7 +178,9 @@ export function AppRoutes() { path="/api-keys/:keyId" element={ }> - + + + } /> @@ -167,7 +190,9 @@ export function AppRoutes() { path="/providers" element={ }> - + + + } /> @@ -177,7 +202,9 @@ export function AppRoutes() { path="/usage" element={ }> - + + + } /> @@ -187,7 +214,9 @@ export function AppRoutes() { path="/studio" element={ }> - + + + } /> @@ -207,7 +236,9 @@ export function AppRoutes() { path="/session" element={ }> - + + + } /> @@ -225,7 +256,9 @@ export function AppRoutes() { path="/admin" element={ }> - + + + } /> @@ -233,7 +266,9 @@ export function AppRoutes() { path="/admin/organizations" element={ }> - + + + } /> @@ -241,7 +276,9 @@ export function AppRoutes() { path="/admin/organizations/:slug" element={ }> - + + + } /> @@ -249,7 +286,9 @@ export function AppRoutes() { path="/admin/organizations/:orgSlug/projects/:projectSlug" element={ }> - + + + } /> @@ -257,7 +296,9 @@ export function AppRoutes() { path="/admin/users" element={ }> - + + + } /> @@ -265,7 +306,9 @@ export function AppRoutes() { path="/admin/users/:userId" element={ }> - + + + } /> @@ -273,7 +316,9 @@ export function AppRoutes() { path="/admin/sso" element={ }> - + + + } /> @@ -281,7 +326,9 @@ export function AppRoutes() { path="/admin/organizations/:orgSlug/sso-group-mappings" element={ }> - + + + } /> @@ -289,7 +336,9 @@ export function AppRoutes() { path="/admin/organizations/:orgSlug/sso-config" element={ }> - + + + } /> @@ -297,7 +346,9 @@ export function AppRoutes() { path="/admin/organizations/:orgSlug/scim-config" element={ }> - + + + } /> @@ -305,7 +356,9 @@ export function AppRoutes() { path="/admin/organizations/:orgSlug/rbac-policies" element={ }> - + + + } /> @@ -313,7 +366,9 @@ export function AppRoutes() { path="/admin/api-keys" element={ }> - + + + } /> @@ -321,7 +376,9 @@ export function AppRoutes() { path="/admin/providers" element={ }> - + + + } /> @@ -329,7 +386,9 @@ export function AppRoutes() { path="/admin/provider-health" element={ }> - + + + } /> @@ -337,7 +396,9 @@ export function AppRoutes() { path="/admin/provider-health/:providerName" element={ }> - + + + } /> @@ -345,7 +406,9 @@ export function AppRoutes() { path="/admin/pricing" element={ }> - + + + } /> @@ -353,7 +416,9 @@ export function AppRoutes() { path="/admin/usage" element={ }> - + + + } /> @@ -361,7 +426,9 @@ export function AppRoutes() { path="/admin/projects" element={ }> - + + + } /> @@ -369,7 +436,9 @@ export function AppRoutes() { path="/admin/teams" element={ }> - + + + } /> @@ -377,7 +446,9 @@ export function AppRoutes() { path="/admin/service-accounts" element={ }> - + + + } /> @@ -385,7 +456,9 @@ export function AppRoutes() { path="/admin/organizations/:orgSlug/teams/:teamSlug" element={ }> - + + + } /> @@ -393,7 +466,9 @@ export function AppRoutes() { path="/admin/settings" element={ }> - + + + } /> @@ -401,7 +476,9 @@ export function AppRoutes() { path="/admin/audit-logs" element={ }> - + + + } /> @@ -409,7 +486,9 @@ export function AppRoutes() { path="/admin/vector-stores" element={ }> - + + + } /> @@ -417,14 +496,16 @@ export function AppRoutes() { path="/admin/vector-stores/:vectorStoreId" element={ }> - + + + } /> - {/* Catch all - redirect to chat */} - } /> + {/* Catch all - redirect to first enabled page */} + } /> ); }