Component: Single Admin UI (Shared)¶
Date: December 16, 2025
Status: Architecture Specification
Component Type: Frontend Application
Table of Contents¶
- Overview
- Architecture
- Tenant Context Management
- Component Structure
- Tenant Identification
- Configuration Loading
- State Management
- Routing
- Branding & Customization
- Security Considerations
- Implementation Details
- Testing Strategy
- Deployment
Overview¶
The Single Admin UI (Shared) is a React-based single-page application that serves all tenants through a single codebase and deployment. It dynamically adapts its behavior, branding, and data based on the tenant context extracted from the request.
Key Characteristics¶
- Single Build: One React application build serves all tenants
- Dynamic Configuration: Tenant-specific config loaded at runtime
- Tenant Isolation: UI-level tenant context ensures data isolation
- Shared Infrastructure: Single deployment, shared resources
- Cost Effective: Lower infrastructure and maintenance costs
Benefits¶
- ✅ Single codebase to maintain
- ✅ Consistent UX across tenants
- ✅ Fast updates (deploy once, all tenants benefit)
- ✅ Lower operational overhead
- ✅ Easier monitoring and debugging
Architecture¶
High-Level Architecture¶
┌─────────────────────────────────────────────────────────┐
│ Single Admin UI (Shared) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Tenant Context Layer │ │
│ │ - Tenant Identification │ │
│ │ - Configuration Loading │ │
│ │ - Context Provider │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Application Layer │ │
│ │ - Authentication │ │
│ │ - Setup Wizard │ │
│ │ - Operational Dashboards │ │
│ │ - Ontology Builder │ │
│ │ - Governance │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Data Layer │ │
│ │ - tRPC Client (BFF) │ │
│ │ - Tenant-Aware API Calls │ │
│ │ - Local State Management │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ BFF Layer │
│ (Tenant Aware) │
└─────────────────┘
Component Hierarchy¶
App (Root)
├── TenantProvider (Context Provider)
│ ├── TRPCProvider (API Client)
│ │ ├── AuthProvider (Authentication)
│ │ │ ├── LoginScreen (if not authenticated)
│ │ │ └── MainApp (if authenticated)
│ │ │ ├── TopBar (Tenant-aware header)
│ │ │ ├── LeftNav (Navigation)
│ │ │ └── ContentArea
│ │ │ ├── SetupMode (Wizard)
│ │ │ └── OperationalMode (Dashboards)
│ │ └── CATDrawer (AI Assistant)
Tenant Context Management¶
TenantProvider Component¶
Purpose: Provides tenant context to all child components
File: apps/admin-ui/src/providers/TenantProvider.tsx
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { trpc } from '../lib/trpc';
import { logger } from '../utils/logger';
export interface TenantConfig {
tenantId: string;
name: string;
slug: string;
branding: {
logo?: string;
logoDark?: string;
colors: {
primary: string;
secondary: string;
accent: string;
};
theme?: 'light' | 'dark' | 'auto';
};
features: {
enabled: string[];
disabled: string[];
};
cats: Array<{
id: string;
prefix: string;
name: string;
status: 'active' | 'inactive';
}>;
environments: Array<'dev' | 'stage' | 'prod'>;
limits: {
maxCats: number;
maxUsers: number;
maxStorage: number;
};
}
interface TenantContextValue {
tenant: TenantConfig | null;
isLoading: boolean;
error: Error | null;
refresh: () => void;
}
const TenantContext = createContext<TenantContextValue>({
tenant: null,
isLoading: true,
error: null,
refresh: () => {},
});
export function TenantProvider({ children }: { children: ReactNode }) {
const [tenantId, setTenantId] = useState<string | null>(null);
// Extract tenant ID from URL or headers
useEffect(() => {
const extractedTenantId = extractTenantId();
if (extractedTenantId) {
setTenantId(extractedTenantId);
logger.info(`Tenant ID extracted: ${extractedTenantId}`);
} else {
logger.warn('No tenant ID found in request');
}
}, []);
const { data: tenant, isLoading, error, refetch } = trpc.tenant.getConfig.useQuery(
{ tenantId: tenantId! },
{
enabled: !!tenantId,
retry: 2,
staleTime: 5 * 60 * 1000, // 5 minutes
onError: (err) => {
logger.error('Failed to load tenant config:', err);
},
}
);
const refresh = () => {
refetch();
};
return (
<TenantContext.Provider
value={{
tenant: tenant || null,
isLoading,
error: error as Error | null,
refresh,
}}
>
{children}
</TenantContext.Provider>
);
}
export function useTenant() {
const context = useContext(TenantContext);
if (!context) {
throw new Error('useTenant must be used within TenantProvider');
}
return context;
}
// Helper function to extract tenant ID
function extractTenantId(): string | null {
// Method 1: Subdomain (tenant1.bast.ai)
const hostname = window.location.hostname;
const subdomain = hostname.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
return subdomain;
}
// Method 2: Path parameter (/tenant/tenant1)
const pathMatch = window.location.pathname.match(/^\/tenant\/([^\/]+)/);
if (pathMatch) {
return pathMatch[1];
}
// Method 3: Query parameter (?tenant=tenant1)
const urlParams = new URLSearchParams(window.location.search);
const tenantParam = urlParams.get('tenant');
if (tenantParam) {
return tenantParam;
}
// Method 4: LocalStorage (for development)
if (process.env.NODE_ENV === 'development') {
const devTenant = localStorage.getItem('dev_tenant_id');
if (devTenant) {
return devTenant;
}
}
return null;
}
Usage in Components¶
import { useTenant } from '../providers/TenantProvider';
function MyComponent() {
const { tenant, isLoading, error } = useTenant();
if (isLoading) {
return <LoadingSpinner />;
}
if (error || !tenant) {
return <ErrorScreen error={error} />;
}
return (
<div>
<h1>Welcome to {tenant.name}</h1>
{/* Use tenant-specific data */}
</div>
);
}
Component Structure¶
Current Structure¶
apps/admin-ui/src/
├── providers/
│ ├── TenantProvider.tsx # NEW: Tenant context
│ └── TRPCProvider.tsx # Existing: tRPC client
├── components/
│ ├── auth/
│ │ └── LoginScreen.tsx
│ ├── setup/ # Setup wizard
│ │ ├── WelcomeScreen.tsx
│ │ ├── IdentityScreen.tsx
│ │ ├── DataScreen.tsx
│ │ ├── GovernanceScreen.tsx
│ │ └── FinalizeScreen.tsx
│ ├── operational/ # Dashboards
│ │ ├── OverviewDashboard.tsx
│ │ ├── SystemStatusDashboard.tsx
│ │ ├── GovernanceDashboard.tsx
│ │ ├── ObservabilityDashboard.tsx
│ │ └── SecurityDashboard.tsx
│ ├── ontology/
│ │ └── OntologyBuilderContainer.tsx
│ ├── customization/
│ │ └── BrandingContainer.tsx
│ ├── TopBar.tsx # Tenant-aware header
│ ├── LeftNav.tsx # Navigation
│ └── CATDrawer.tsx # AI assistant
├── hooks/
│ ├── useTenant.ts # NEW: Tenant hook
│ ├── useAuth.ts # Authentication hook
│ └── useTenantConfig.ts # NEW: Config hook
├── utils/
│ ├── tenant.ts # NEW: Tenant utilities
│ └── logger.ts # Logging
└── App.tsx # Root component
Updated App.tsx Structure¶
import { TenantProvider } from './providers/TenantProvider';
import { TRPCProvider } from './lib/trpc-provider';
import { AuthProvider } from './providers/AuthProvider';
export default function App() {
return (
<TenantProvider>
<TRPCProvider>
<AuthProvider>
<AppContent />
</AuthProvider>
</TRPCProvider>
</TenantProvider>
);
}
function AppContent() {
const { tenant, isLoading } = useTenant();
const { isAuthenticated } = useAuth();
if (isLoading) {
return <LoadingScreen />;
}
if (!tenant) {
return <TenantNotFoundScreen />;
}
if (!isAuthenticated) {
return <LoginScreen />;
}
return <MainApplication />;
}
Tenant Identification¶
Identification Methods (Priority Order)¶
1. Subdomain-Based (Recommended)¶
Pattern: {tenantId}.bast.ai
Example:
- acme.bast.ai → Tenant ID: acme
- contoso.bast.ai → Tenant ID: contoso
Implementation:
function extractTenantFromSubdomain(): string | null {
const hostname = window.location.hostname;
const parts = hostname.split('.');
// Skip 'www' and 'app' subdomains
if (parts.length >= 3 && !['www', 'app'].includes(parts[0])) {
return parts[0];
}
return null;
}
DNS Configuration:
Load Balancer Configuration:
- Route all *.bast.ai to same UI instance
- Pass Host header to application
- Application extracts tenant from header
2. Path-Based¶
Pattern: /tenant/{tenantId}
Example:
- bast.ai/tenant/acme → Tenant ID: acme
- bast.ai/tenant/contoso → Tenant ID: contoso
Implementation:
function extractTenantFromPath(): string | null {
const pathMatch = window.location.pathname.match(/^\/tenant\/([^\/]+)/);
return pathMatch ? pathMatch[1] : null;
}
Routing:
3. Header-Based¶
Pattern: X-Tenant-ID header
Use Case: API-to-API communication, reverse proxy
Implementation:
// In BFF or reverse proxy
app.use((req, res, next) => {
const tenantId = req.headers['x-tenant-id'];
if (tenantId) {
req.tenantId = tenantId;
}
next();
});
4. Query Parameter (Development Only)¶
Pattern: ?tenant={tenantId}
Use Case: Development, testing
Implementation:
function extractTenantFromQuery(): string | null {
if (process.env.NODE_ENV === 'development') {
const params = new URLSearchParams(window.location.search);
return params.get('tenant');
}
return null;
}
5. JWT Token Claim¶
Pattern: Tenant ID in JWT token
Use Case: Authenticated requests
Implementation:
// After authentication, extract from token
const token = localStorage.getItem('token');
const decoded = jwt.decode(token);
const tenantId = decoded?.tenantId || decoded?.organization;
Configuration Loading¶
Configuration Sources¶
- BFF API (Primary)
- Tenant configuration endpoint
- Real-time updates
-
Tenant-specific settings
-
Environment Variables (Fallback)
- Default configuration
- Feature flags
-
API endpoints
-
Local Storage (Cache)
- Cached tenant config
- Reduces API calls
- TTL-based expiration
Configuration Schema¶
interface TenantConfig {
// Identity
tenantId: string;
name: string;
slug: string;
// Branding
branding: {
logo?: string;
logoDark?: string;
favicon?: string;
colors: {
primary: string;
secondary: string;
accent: string;
background: string;
text: string;
};
theme?: 'light' | 'dark' | 'auto';
};
// Features
features: {
enabled: string[];
disabled: string[];
limits: {
maxCats: number;
maxUsers: number;
maxStorage: number; // GB
};
};
// CATs
cats: Array<{
id: string;
prefix: string;
name: string;
description?: string;
status: 'active' | 'inactive' | 'pending';
createdAt: string;
}>;
// Environments
environments: Array<'dev' | 'stage' | 'prod'>;
// Metadata
createdAt: string;
updatedAt: string;
subscription: {
tier: 'standard' | 'enterprise';
expiresAt?: string;
};
}
Configuration Loading Hook¶
File: apps/admin-ui/src/hooks/useTenantConfig.ts
import { useQuery } from '@tanstack/react-query';
import { trpc } from '../lib/trpc';
import { useTenant } from '../providers/TenantProvider';
export function useTenantConfig() {
const { tenant } = useTenant();
const { data: config, isLoading, error } = trpc.tenant.getConfig.useQuery(
{ tenantId: tenant?.tenantId! },
{
enabled: !!tenant?.tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
}
);
return {
config,
isLoading,
error,
hasFeature: (feature: string) => {
return config?.features.enabled.includes(feature) ?? false;
},
isFeatureDisabled: (feature: string) => {
return config?.features.disabled.includes(feature) ?? false;
},
};
}
State Management¶
Tenant-Aware State¶
Current State (App.tsx):
const [currentUser, setCurrentUser] = useState(...);
const [environment, setEnvironment] = useState<Environment>('dev');
const [mode, setMode] = useState<Mode>('setup');
Enhanced State (Tenant-Aware):
// Tenant context (from TenantProvider)
const { tenant } = useTenant();
// User state (tenant-scoped)
const [currentUser, setCurrentUser] = useState(() => {
const stored = localStorage.getItem(`user_${tenant?.tenantId}`);
return stored ? JSON.parse(stored) : null;
});
// Environment state (tenant-specific)
const [environment, setEnvironment] = useState<Environment>(() => {
const stored = localStorage.getItem(`env_${tenant?.tenantId}`);
return (stored as Environment) || 'dev';
});
// CAT selection (tenant-specific)
const [selectedCat, setSelectedCat] = useState<string | null>(() => {
const stored = localStorage.getItem(`cat_${tenant?.tenantId}`);
return stored;
});
State Persistence¶
Tenant-Scoped LocalStorage:
// Utility functions
export function setTenantStorage(key: string, value: any, tenantId: string) {
localStorage.setItem(`${key}_${tenantId}`, JSON.stringify(value));
}
export function getTenantStorage(key: string, tenantId: string) {
const stored = localStorage.getItem(`${key}_${tenantId}`);
return stored ? JSON.parse(stored) : null;
}
// Usage
setTenantStorage('user', userData, tenant.tenantId);
const user = getTenantStorage('user', tenant.tenantId);
Routing¶
Tenant-Aware Routing¶
File: apps/admin-ui/src/routes/AppRouter.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useTenant } from '../providers/TenantProvider';
export function AppRouter() {
const { tenant } = useTenant();
if (!tenant) {
return <TenantNotFoundScreen />;
}
return (
<BrowserRouter>
<Routes>
{/* Tenant-scoped routes */}
<Route path="/tenant/:tenantId" element={<TenantLayout />}>
<Route index element={<Navigate to="overview" replace />} />
<Route path="overview" element={<OverviewDashboard />} />
<Route path="setup/*" element={<SetupWizard />} />
<Route path="ontology" element={<OntologyBuilder />} />
<Route path="governance" element={<GovernanceDashboard />} />
<Route path="observability" element={<ObservabilityDashboard />} />
<Route path="security" element={<SecurityDashboard />} />
<Route path="branding" element={<BrandingContainer />} />
</Route>
{/* Root redirect */}
<Route path="/" element={<Navigate to={`/tenant/${tenant.tenantId}`} replace />} />
{/* Catch-all */}
<Route path="*" element={<NotFoundScreen />} />
</Routes>
</BrowserRouter>
);
}
Dynamic Route Generation¶
// Generate routes based on tenant features
function generateRoutes(tenant: TenantConfig) {
const routes = [];
// Always available
routes.push({ path: 'overview', component: OverviewDashboard });
// Feature-based routes
if (tenant.features.enabled.includes('ontology')) {
routes.push({ path: 'ontology', component: OntologyBuilder });
}
if (tenant.features.enabled.includes('governance')) {
routes.push({ path: 'governance', component: GovernanceDashboard });
}
return routes;
}
Branding & Customization¶
Dynamic Branding¶
File: apps/admin-ui/src/utils/branding.ts
import { useTenant } from '../providers/TenantProvider';
export function useBranding() {
const { tenant } = useTenant();
if (!tenant?.branding) {
return getDefaultBranding();
}
return {
logo: tenant.branding.logo,
logoDark: tenant.branding.logoDark || tenant.branding.logo,
colors: tenant.branding.colors,
theme: tenant.branding.theme || 'auto',
};
}
export function applyBranding(branding: TenantConfig['branding']) {
// Apply CSS variables
const root = document.documentElement;
if (branding.colors) {
root.style.setProperty('--color-primary', branding.colors.primary);
root.style.setProperty('--color-secondary', branding.colors.secondary);
root.style.setProperty('--color-accent', branding.colors.accent);
}
// Apply favicon
if (branding.favicon) {
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (link) {
link.href = branding.favicon;
}
}
// Apply theme
if (branding.theme === 'dark') {
document.documentElement.classList.add('dark');
} else if (branding.theme === 'light') {
document.documentElement.classList.remove('dark');
}
}
Usage in Components¶
import { useBranding } from '../utils/branding';
function TopBar() {
const branding = useBranding();
return (
<header style={{ backgroundColor: branding.colors.primary }}>
<img src={branding.logo} alt="Logo" />
</header>
);
}
Security Considerations¶
Tenant Isolation¶
- Client-Side Validation
- Always verify tenant context
- Never trust client-provided tenant ID
-
Validate tenant access on every request
-
API Calls
- Include tenant ID in all API requests
- BFF validates tenant access
-
Backend APIs enforce tenant isolation
-
Data Storage
- Tenant-scoped localStorage keys
- Clear tenant data on logout
- Prevent cross-tenant data leakage
Security Best Practices¶
// Always validate tenant access
function useTenantData() {
const { tenant } = useTenant();
const { data } = trpc.tenant.getData.useQuery(
{ tenantId: tenant?.tenantId! },
{
enabled: !!tenant?.tenantId,
// BFF will validate tenant access
}
);
return data;
}
// Clear tenant data on logout
function handleLogout() {
const { tenant } = useTenant();
// Clear tenant-specific storage
if (tenant) {
Object.keys(localStorage).forEach(key => {
if (key.startsWith(`${tenant.tenantId}_`)) {
localStorage.removeItem(key);
}
});
}
// Clear auth
localStorage.removeItem('token');
localStorage.removeItem('userData');
}
Implementation Details¶
Step-by-Step Implementation¶
Phase 1: Tenant Context Setup (Week 1-2)¶
- Create TenantProvider
- Create
providers/TenantProvider.tsx - Implement tenant ID extraction
- Add tenant config loading
-
Create
useTenanthook -
Update App.tsx
- Wrap app with TenantProvider
- Add tenant loading state
-
Add tenant error handling
-
Create Tenant Utilities
- Create
utils/tenant.ts - Add tenant ID extraction functions
- Add tenant storage utilities
Phase 2: Configuration Loading (Week 3-4)¶
- BFF Integration
- Create tenant config endpoint in BFF
- Add tRPC tenant router
-
Implement config caching
-
Frontend Integration
- Create
hooks/useTenantConfig.ts - Update components to use tenant config
- Add feature flag checks
Phase 3: Routing & Navigation (Week 5-6)¶
- Update Routing
- Add tenant-aware routes
- Update navigation components
-
Add tenant context to all routes
-
Update Components
- Make TopBar tenant-aware
- Update LeftNav with tenant context
- Add tenant selector (if multi-tenant user)
Phase 4: Branding & Customization (Week 7-8)¶
- Dynamic Branding
- Create branding utilities
- Apply tenant branding on load
-
Update theme system
-
Logo & Assets
- Add logo loading
- Support light/dark logos
- Add favicon support
Testing Strategy¶
Unit Tests¶
// TenantProvider.test.tsx
describe('TenantProvider', () => {
it('extracts tenant ID from subdomain', () => {
// Mock window.location.hostname
// Verify tenant ID extraction
});
it('loads tenant config from API', async () => {
// Mock tRPC query
// Verify config loading
});
it('handles tenant not found', async () => {
// Mock API error
// Verify error handling
});
});
Integration Tests¶
// Tenant integration test
describe('Tenant Integration', () => {
it('loads tenant config on app start', async () => {
// Render app with tenant context
// Verify config loaded
});
it('applies tenant branding', async () => {
// Load tenant with branding
// Verify CSS variables applied
});
});
E2E Tests¶
// Tenant E2E test
describe('Tenant E2E', () => {
it('completes full tenant flow', async () => {
// Navigate to tenant subdomain
// Verify tenant context loaded
// Verify tenant-specific data displayed
});
});
Deployment¶
Build Configuration¶
File: apps/admin-ui/vite.config.ts
export default defineConfig({
build: {
outDir: 'build',
// Single build for all tenants
},
define: {
// No tenant-specific build-time config
},
});
Environment Variables¶
File: apps/admin-ui/.env
# BFF URL (tenant-aware)
VITE_BFF_URL=http://localhost:4000/trpc
# Default tenant (development only)
VITE_DEFAULT_TENANT=dev-tenant
# Feature flags (global)
VITE_ENABLE_ANALYTICS=true
VITE_ENABLE_EXPERIMENTAL_FEATURES=false
Deployment Steps¶
-
Build
-
Deploy
- Single build artifact
- Deploy to CDN or static hosting
-
Configure DNS for subdomain routing
-
DNS Configuration
-
CDN Configuration
- Route all
*.bast.aito same origin - Pass
Hostheader to application - Cache static assets
Monitoring & Observability¶
Metrics to Track¶
- Tenant Metrics
- Tenant config load time
- Tenant config cache hit rate
-
Tenant identification failures
-
Performance Metrics
- Page load time per tenant
- API response time per tenant
-
Error rate per tenant
-
Usage Metrics
- Active tenants
- Tenant feature usage
- Tenant-specific errors
Logging¶
import { logger } from '../utils/logger';
// Log tenant context
logger.info('Tenant loaded', {
tenantId: tenant.tenantId,
tenantName: tenant.name,
features: tenant.features.enabled,
});
// Log tenant errors
logger.error('Tenant config load failed', {
tenantId,
error: error.message,
});
Future Enhancements¶
- Multi-Tenant User Support
- Users can belong to multiple tenants
- Tenant switcher in UI
-
Cross-tenant navigation
-
Tenant Templates
- Pre-configured tenant templates
- Quick tenant provisioning
-
Template-based branding
-
Tenant Analytics
- Per-tenant usage analytics
- Tenant health dashboard
- Tenant performance metrics
Document created: December 16, 2025
Component: Single Admin UI (Shared)