Skip to content

Component: Single Admin UI (Shared)

Date: December 16, 2025
Status: Architecture Specification
Component Type: Frontend Application


Table of Contents

  1. Overview
  2. Architecture
  3. Tenant Context Management
  4. Component Structure
  5. Tenant Identification
  6. Configuration Loading
  7. State Management
  8. Routing
  9. Branding & Customization
  10. Security Considerations
  11. Implementation Details
  12. Testing Strategy
  13. 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)

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:

*.bast.ai  CNAME  bast-lb.example.com

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:

<Router>
  <Route path="/tenant/:tenantId/*" element={<TenantApp />} />
</Router>

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

  1. BFF API (Primary)
  2. Tenant configuration endpoint
  3. Real-time updates
  4. Tenant-specific settings

  5. Environment Variables (Fallback)

  6. Default configuration
  7. Feature flags
  8. API endpoints

  9. Local Storage (Cache)

  10. Cached tenant config
  11. Reduces API calls
  12. 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

  1. Client-Side Validation
  2. Always verify tenant context
  3. Never trust client-provided tenant ID
  4. Validate tenant access on every request

  5. API Calls

  6. Include tenant ID in all API requests
  7. BFF validates tenant access
  8. Backend APIs enforce tenant isolation

  9. Data Storage

  10. Tenant-scoped localStorage keys
  11. Clear tenant data on logout
  12. 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)

  1. Create TenantProvider
  2. Create providers/TenantProvider.tsx
  3. Implement tenant ID extraction
  4. Add tenant config loading
  5. Create useTenant hook

  6. Update App.tsx

  7. Wrap app with TenantProvider
  8. Add tenant loading state
  9. Add tenant error handling

  10. Create Tenant Utilities

  11. Create utils/tenant.ts
  12. Add tenant ID extraction functions
  13. Add tenant storage utilities

Phase 2: Configuration Loading (Week 3-4)

  1. BFF Integration
  2. Create tenant config endpoint in BFF
  3. Add tRPC tenant router
  4. Implement config caching

  5. Frontend Integration

  6. Create hooks/useTenantConfig.ts
  7. Update components to use tenant config
  8. Add feature flag checks

Phase 3: Routing & Navigation (Week 5-6)

  1. Update Routing
  2. Add tenant-aware routes
  3. Update navigation components
  4. Add tenant context to all routes

  5. Update Components

  6. Make TopBar tenant-aware
  7. Update LeftNav with tenant context
  8. Add tenant selector (if multi-tenant user)

Phase 4: Branding & Customization (Week 7-8)

  1. Dynamic Branding
  2. Create branding utilities
  3. Apply tenant branding on load
  4. Update theme system

  5. Logo & Assets

  6. Add logo loading
  7. Support light/dark logos
  8. 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

  1. Build

    cd apps/admin-ui
    pnpm build
    

  2. Deploy

  3. Single build artifact
  4. Deploy to CDN or static hosting
  5. Configure DNS for subdomain routing

  6. DNS Configuration

    *.bast.ai  CNAME  bast-cdn.example.com
    

  7. CDN Configuration

  8. Route all *.bast.ai to same origin
  9. Pass Host header to application
  10. Cache static assets

Monitoring & Observability

Metrics to Track

  1. Tenant Metrics
  2. Tenant config load time
  3. Tenant config cache hit rate
  4. Tenant identification failures

  5. Performance Metrics

  6. Page load time per tenant
  7. API response time per tenant
  8. Error rate per tenant

  9. Usage Metrics

  10. Active tenants
  11. Tenant feature usage
  12. 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

  1. Multi-Tenant User Support
  2. Users can belong to multiple tenants
  3. Tenant switcher in UI
  4. Cross-tenant navigation

  5. Tenant Templates

  6. Pre-configured tenant templates
  7. Quick tenant provisioning
  8. Template-based branding

  9. Tenant Analytics

  10. Per-tenant usage analytics
  11. Tenant health dashboard
  12. Tenant performance metrics

Document created: December 16, 2025
Component: Single Admin UI (Shared)