iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 11

Day 11: 30天打造SaaS產品前端篇-多租戶管理介面與租戶切換功能

  • 分享至 

  • xImage
  •  

前情提要

在AWS和後端挑戰中,我們建立了多租戶資料庫架構和 Auth Service,今天我們要從前端角度實作多租戶管理介面

一個企業級 SaaS 前端需求:如何讓不同健身房的管理員能夠安全地管理自己的資料,同時提供平台管理員統一管理的能力?

多租戶前端架構設計

租戶識別策略

在多租戶 SaaS 系統中,前端需要處理三種租戶識別方式:

// packages/kyo-types/src/tenant.ts
export interface TenantContext {
  id: string;
  name: string;
  subdomain: string;
  planType: 'basic' | 'pro' | 'enterprise';
  settings: TenantSettings;
  permissions: string[];
}

export interface TenantSettings {
  branding: {
    logo?: string;
    primaryColor: string;
    secondaryColor: string;
    theme: 'light' | 'dark' | 'auto';
  };
  features: {
    lineIntegration: boolean;
    smsNotification: boolean;
    emailNotification: boolean;
    advancedAnalytics: boolean;
  };
  businessInfo: {
    businessHours: {
      open: string;
      close: string;
    };
    timezone: string;
    address: string;
    phone: string;
  };
}

export enum TenantIdentificationMethod {
  SUBDOMAIN = 'subdomain',    // gym1.kyo-system.com
  PATH = 'path',              // kyo-system.com/gym1
  HEADER = 'header'           // X-Tenant-ID
}

租戶狀態管理

// apps/kyo-dashboard/src/stores/tenantStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface TenantState {
  currentTenant: TenantContext | null;
  availableTenants: TenantContext[]; // 平台管理員可見多個租戶
  identificationMethod: TenantIdentificationMethod;

  // Actions
  setCurrentTenant: (tenant: TenantContext) => void;
  switchTenant: (tenantId: string) => void;
  updateTenantSettings: (settings: Partial<TenantSettings>) => void;
  loadAvailableTenants: () => Promise<void>;
}

export const useTenantStore = create<TenantState>()(
  persist(
    (set, get) => ({
      currentTenant: null,
      availableTenants: [],
      identificationMethod: TenantIdentificationMethod.SUBDOMAIN,

      setCurrentTenant: (tenant) => {
        set({ currentTenant: tenant });

        // 應用租戶主題
        applyTenantTheme(tenant.settings.branding);

        // 設定全域 CSS 變數
        document.documentElement.style.setProperty(
          '--tenant-primary-color',
          tenant.settings.branding.primaryColor
        );
      },

      switchTenant: async (tenantId) => {
        const tenant = get().availableTenants.find(t => t.id === tenantId);
        if (tenant) {
          get().setCurrentTenant(tenant);

          // 重新導向到新租戶的子域名
          if (get().identificationMethod === TenantIdentificationMethod.SUBDOMAIN) {
            window.location.href = `https://${tenant.subdomain}.kyo-system.com${window.location.pathname}`;
          }
        }
      },

      updateTenantSettings: async (settings) => {
        const current = get().currentTenant;
        if (!current) return;

        try {
          const response = await fetch(`/api/tenants/${current.id}/settings`, {
            method: 'PUT',
            headers: {
              'Content-Type': 'application/json',
              'Authorization': `Bearer ${getAuthToken()}`
            },
            body: JSON.stringify(settings)
          });

          if (response.ok) {
            const updatedTenant = {
              ...current,
              settings: { ...current.settings, ...settings }
            };
            set({ currentTenant: updatedTenant });
          }
        } catch (error) {
          console.error('Failed to update tenant settings:', error);
        }
      },

      loadAvailableTenants: async () => {
        try {
          const response = await fetch('/api/tenants', {
            headers: {
              'Authorization': `Bearer ${getAuthToken()}`
            }
          });

          if (response.ok) {
            const tenants = await response.json();
            set({ availableTenants: tenants });
          }
        } catch (error) {
          console.error('Failed to load tenants:', error);
        }
      }
    }),
    {
      name: 'tenant-storage',
      partialize: (state) => ({
        currentTenant: state.currentTenant,
        identificationMethod: state.identificationMethod
      })
    }
  )
);

function applyTenantTheme(branding: TenantSettings['branding']) {
  const root = document.documentElement;
  root.style.setProperty('--tenant-primary', branding.primaryColor);
  root.style.setProperty('--tenant-secondary', branding.secondaryColor);

  // 設定 Mantine 主題
  document.body.setAttribute('data-tenant-theme', branding.theme);
}

🏢 租戶識別與初始化

1. 應用程式啟動時的租戶檢測

// apps/kyo-dashboard/src/hooks/useTenantInitialization.ts
import { useEffect, useState } from 'react';
import { useTenantStore } from '../stores/tenantStore';
import { useAuthStore } from '../stores/authStore';

export function useTenantInitialization() {
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const {
    currentTenant,
    setCurrentTenant,
    identificationMethod
  } = useTenantStore();

  const { isAuthenticated } = useAuthStore();

  useEffect(() => {
    initializeTenant();
  }, [isAuthenticated]);

  const initializeTenant = async () => {
    try {
      setIsLoading(true);
      setError(null);

      let tenantInfo: TenantContext | null = null;

      switch (identificationMethod) {
        case TenantIdentificationMethod.SUBDOMAIN:
          tenantInfo = await detectTenantFromSubdomain();
          break;
        case TenantIdentificationMethod.PATH:
          tenantInfo = await detectTenantFromPath();
          break;
        case TenantIdentificationMethod.HEADER:
          tenantInfo = await detectTenantFromHeader();
          break;
      }

      if (!tenantInfo) {
        // 如果無法識別租戶,導向租戶選擇頁面
        if (isAuthenticated) {
          redirectToTenantSelection();
        } else {
          redirectToLogin();
        }
        return;
      }

      // 驗證租戶狀態
      const isValidTenant = await validateTenant(tenantInfo.id);
      if (!isValidTenant) {
        throw new Error('Tenant is not active or accessible');
      }

      setCurrentTenant(tenantInfo);

    } catch (err: any) {
      setError(err.message);
      console.error('Tenant initialization failed:', err);
    } finally {
      setIsLoading(false);
    }
  };

  const detectTenantFromSubdomain = async (): Promise<TenantContext | null> => {
    const hostname = window.location.hostname;
    const parts = hostname.split('.');

    // 檢查是否為子域名格式 (gym1.kyo-system.com)
    if (parts.length >= 3 && parts[0] !== 'www') {
      const subdomain = parts[0];

      try {
        const response = await fetch(`/api/tenants/by-subdomain/${subdomain}`);
        if (response.ok) {
          return await response.json();
        }
      } catch (error) {
        console.error('Failed to fetch tenant by subdomain:', error);
      }
    }

    return null;
  };

  const detectTenantFromPath = async (): Promise<TenantContext | null> => {
    const pathParts = window.location.pathname.split('/');

    // 檢查路徑格式 /tenant/gym1/dashboard
    if (pathParts[1] === 'tenant' && pathParts[2]) {
      const tenantSlug = pathParts[2];

      try {
        const response = await fetch(`/api/tenants/by-slug/${tenantSlug}`);
        if (response.ok) {
          return await response.json();
        }
      } catch (error) {
        console.error('Failed to fetch tenant by path:', error);
      }
    }

    return null;
  };

  const validateTenant = async (tenantId: string): Promise<boolean> => {
    try {
      const response = await fetch(`/api/tenants/${tenantId}/status`, {
        headers: {
          'Authorization': `Bearer ${getAuthToken()}`
        }
      });

      const data = await response.json();
      return data.status === 'active';
    } catch {
      return false;
    }
  };

  const redirectToTenantSelection = () => {
    window.location.href = '/select-tenant';
  };

  const redirectToLogin = () => {
    window.location.href = '/login';
  };

  return { isLoading, error, currentTenant };
}

2. 租戶切換介面組件

// apps/kyo-dashboard/src/components/tenant/TenantSwitcher.tsx
import React, { useState } from 'react';
import {
  Menu,
  Button,
  Avatar,
  Group,
  Text,
  Divider,
  ActionIcon,
  Badge,
  Stack
} from '@mantine/core';
import {
  IconChevronDown,
  IconBuilding,
  IconCrown,
  IconSettings,
  IconPlus
} from '@tabler/icons-react';
import { useTenantStore } from '../../stores/tenantStore';
import { useAuthStore } from '../../stores/authStore';

export const TenantSwitcher: React.FC = () => {
  const [opened, setOpened] = useState(false);

  const {
    currentTenant,
    availableTenants,
    switchTenant,
    loadAvailableTenants
  } = useTenantStore();

  const { user } = useAuthStore();

  React.useEffect(() => {
    if (user?.role === 'platform_admin') {
      loadAvailableTenants();
    }
  }, [user, loadAvailableTenants]);

  if (!currentTenant) {
    return null;
  }

  const isPlatformAdmin = user?.role === 'platform_admin';
  const canSwitchTenant = isPlatformAdmin && availableTenants.length > 1;

  const handleTenantSwitch = (tenantId: string) => {
    switchTenant(tenantId);
    setOpened(false);
  };

  const getPlanBadgeColor = (planType: string) => {
    const colors = {
      basic: 'gray',
      pro: 'blue',
      enterprise: 'gold'
    };
    return colors[planType as keyof typeof colors] || 'gray';
  };

  return (
    <Menu
      opened={opened}
      onChange={setOpened}
      position="bottom-start"
      shadow="lg"
      width={300}
    >
      <Menu.Target>
        <Button
          variant="subtle"
          leftSection={
            <Avatar
              size="sm"
              src={currentTenant.settings.branding.logo}
              color={currentTenant.settings.branding.primaryColor}
            >
              <IconBuilding size="1rem" />
            </Avatar>
          }
          rightSection={canSwitchTenant && <IconChevronDown size="1rem" />}
          styles={{
            root: {
              padding: '8px 12px',
              height: 'auto',
              borderRadius: '8px'
            }
          }}
        >
          <Stack gap={2}>
            <Text size="sm" fw={500}>
              {currentTenant.name}
            </Text>
            <Badge
              size="xs"
              color={getPlanBadgeColor(currentTenant.planType)}
              variant="light"
            >
              {currentTenant.planType.toUpperCase()}
            </Badge>
          </Stack>
        </Button>
      </Menu.Target>

      <Menu.Dropdown>
        <Menu.Label>當前健身房</Menu.Label>

        <Group p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
          <Avatar
            src={currentTenant.settings.branding.logo}
            color={currentTenant.settings.branding.primaryColor}
            size="md"
          >
            <IconBuilding size="1.2rem" />
          </Avatar>
          <div style={{ flex: 1 }}>
            <Text size="sm" fw={500}>
              {currentTenant.name}
            </Text>
            <Text size="xs" c="dimmed">
              {currentTenant.subdomain}.kyo-system.com
            </Text>
          </div>
          <Badge color={getPlanBadgeColor(currentTenant.planType)} size="sm">
            {currentTenant.planType}
          </Badge>
        </Group>

        {canSwitchTenant && (
          <>
            <Divider my="sm" />
            <Menu.Label>
              <Group justify="space-between">
                <span>可切換的健身房</span>
                <Badge size="xs" variant="light">
                  {availableTenants.length}
                </Badge>
              </Group>
            </Menu.Label>

            <div style={{ maxHeight: '200px', overflowY: 'auto' }}>
              {availableTenants
                .filter(tenant => tenant.id !== currentTenant.id)
                .map((tenant) => (
                  <Menu.Item
                    key={tenant.id}
                    onClick={() => handleTenantSwitch(tenant.id)}
                    leftSection={
                      <Avatar
                        size="xs"
                        src={tenant.settings.branding.logo}
                        color={tenant.settings.branding.primaryColor}
                      >
                        <IconBuilding size="0.8rem" />
                      </Avatar>
                    }
                    rightSection={
                      <Badge
                        size="xs"
                        color={getPlanBadgeColor(tenant.planType)}
                        variant="light"
                      >
                        {tenant.planType}
                      </Badge>
                    }
                  >
                    <div>
                      <Text size="sm">{tenant.name}</Text>
                      <Text size="xs" c="dimmed">
                        {tenant.subdomain}
                      </Text>
                    </div>
                  </Menu.Item>
                ))}
            </div>

            <Divider my="sm" />

            <Menu.Item
              leftSection={<IconPlus size="1rem" />}
              onClick={() => window.open('/admin/tenants/create', '_blank')}
            >
              新增健身房
            </Menu.Item>
          </>
        )}

        <Divider my="sm" />

        <Menu.Item
          leftSection={<IconSettings size="1rem" />}
          onClick={() => window.location.href = '/settings/tenant'}
        >
          健身房設定
        </Menu.Item>

        {isPlatformAdmin && (
          <Menu.Item
            leftSection={<IconCrown size="1rem" />}
            onClick={() => window.location.href = '/admin'}
            c="orange"
          >
            平台管理
          </Menu.Item>
        )}
      </Menu.Dropdown>
    </Menu>
  );
};

🎨 租戶品牌客製化

1. 動態主題系統

// apps/kyo-dashboard/src/hooks/useTenantTheme.ts
import { useMantineTheme, MantineTheme } from '@mantine/core';
import { useTenantStore } from '../stores/tenantStore';

export function useTenantTheme() {
  const mantineTheme = useMantineTheme();
  const { currentTenant } = useTenantStore();

  const tenantTheme: Partial<MantineTheme> = React.useMemo(() => {
    if (!currentTenant?.settings.branding) {
      return mantineTheme;
    }

    const branding = currentTenant.settings.branding;

    return {
      ...mantineTheme,
      colors: {
        ...mantineTheme.colors,
        primary: [
          lighten(branding.primaryColor, 0.9),
          lighten(branding.primaryColor, 0.8),
          lighten(branding.primaryColor, 0.7),
          lighten(branding.primaryColor, 0.6),
          lighten(branding.primaryColor, 0.4),
          branding.primaryColor,
          darken(branding.primaryColor, 0.1),
          darken(branding.primaryColor, 0.2),
          darken(branding.primaryColor, 0.3),
          darken(branding.primaryColor, 0.4),
        ],
        secondary: [
          lighten(branding.secondaryColor, 0.9),
          lighten(branding.secondaryColor, 0.8),
          lighten(branding.secondaryColor, 0.7),
          lighten(branding.secondaryColor, 0.6),
          lighten(branding.secondaryColor, 0.4),
          branding.secondaryColor,
          darken(branding.secondaryColor, 0.1),
          darken(branding.secondaryColor, 0.2),
          darken(branding.secondaryColor, 0.3),
          darken(branding.secondaryColor, 0.4),
        ],
      },
      primaryColor: 'primary',
      colorScheme: branding.theme === 'auto'
        ? mantineTheme.colorScheme
        : branding.theme,
    };
  }, [currentTenant, mantineTheme]);

  // 應用 CSS 變數
  React.useEffect(() => {
    if (currentTenant?.settings.branding) {
      const branding = currentTenant.settings.branding;
      const root = document.documentElement;

      root.style.setProperty('--tenant-primary', branding.primaryColor);
      root.style.setProperty('--tenant-secondary', branding.secondaryColor);
      root.style.setProperty('--tenant-primary-rgb', hexToRgb(branding.primaryColor));
      root.style.setProperty('--tenant-secondary-rgb', hexToRgb(branding.secondaryColor));
    }
  }, [currentTenant]);

  return tenantTheme;
}

// 色彩工具函數
function lighten(color: string, amount: number): string {
  // 實作顏色變亮邏輯
  return color;
}

function darken(color: string, amount: number): string {
  // 實作顏色變暗邏輯
  return color;
}

function hexToRgb(hex: string): string {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  return result
    ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
    : '0, 0, 0';
}

2. 租戶品牌組件

// apps/kyo-dashboard/src/components/tenant/TenantLogo.tsx
import React from 'react';
import { Group, Image, Text, ThemeIcon } from '@mantine/core';
import { IconBuilding } from '@tabler/icons-react';
import { useTenantStore } from '../../stores/tenantStore';

interface TenantLogoProps {
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
  showName?: boolean;
  variant?: 'default' | 'white' | 'minimal';
}

export const TenantLogo: React.FC<TenantLogoProps> = ({
  size = 'md',
  showName = true,
  variant = 'default'
}) => {
  const { currentTenant } = useTenantStore();

  if (!currentTenant) {
    return (
      <Group gap="sm">
        <ThemeIcon size={size} variant="light">
          <IconBuilding size="1rem" />
        </ThemeIcon>
        {showName && (
          <Text size={size} fw={600}>
            Kyo System
          </Text>
        )}
      </Group>
    );
  }

  const branding = currentTenant.settings.branding;
  const logoSize = {
    xs: 20,
    sm: 24,
    md: 32,
    lg: 40,
    xl: 48
  }[size];

  return (
    <Group gap="sm">
      {branding.logo ? (
        <Image
          src={branding.logo}
          alt={`${currentTenant.name} Logo`}
          width={logoSize}
          height={logoSize}
          style={{ borderRadius: '4px' }}
        />
      ) : (
        <ThemeIcon
          size={logoSize}
          color={branding.primaryColor}
          variant={variant === 'white' ? 'white' : 'light'}
        >
          <IconBuilding size={logoSize * 0.6} />
        </ThemeIcon>
      )}

      {showName && (
        <Text
          size={size}
          fw={600}
          c={variant === 'white' ? 'white' : undefined}
          style={{
            color: variant === 'minimal' ? branding.primaryColor : undefined
          }}
        >
          {currentTenant.name}
        </Text>
      )}
    </Group>
  );
};

📱 響應式租戶導航

1. 租戶專用 AppShell

// apps/kyo-dashboard/src/components/layout/TenantAppShell.tsx
import React from 'react';
import {
  AppShell,
  Burger,
  Group,
  NavLink,
  Stack,
  Divider,
  Text,
  Badge
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useLocation } from 'react-router-dom';
import {
  IconDashboard,
  IconUsers,
  IconUserCheck,
  IconCalendar,
  IconCreditCard,
  IconChart,
  IconSettings
} from '@tabler/icons-react';

import { TenantLogo } from '../tenant/TenantLogo';
import { TenantSwitcher } from '../tenant/TenantSwitcher';
import { useTenantStore } from '../../stores/tenantStore';
import { useAuthStore } from '../../stores/authStore';

interface TenantAppShellProps {
  children: React.ReactNode;
}

export const TenantAppShell: React.FC<TenantAppShellProps> = ({ children }) => {
  const [opened, { toggle }] = useDisclosure();
  const location = useLocation();

  const { currentTenant } = useTenantStore();
  const { user } = useAuthStore();

  // 根據用戶角色和租戶設定決定導航項目
  const navigationItems = React.useMemo(() => {
    if (!currentTenant || !user) return [];

    const baseItems = [
      {
        icon: IconDashboard,
        label: '儀表板',
        href: '/dashboard',
        permission: 'dashboard:read'
      }
    ];

    const managementItems = [
      {
        icon: IconUsers,
        label: '會員管理',
        href: '/members',
        permission: 'member:read'
      },
      {
        icon: IconUserCheck,
        label: '教練管理',
        href: '/trainers',
        permission: 'trainer:read'
      },
      {
        icon: IconCalendar,
        label: '課程管理',
        href: '/courses',
        permission: 'course:read'
      },
      {
        icon: IconCreditCard,
        label: '會籍管理',
        href: '/memberships',
        permission: 'membership:read'
      }
    ];

    const analyticsItems = [
      {
        icon: IconChart,
        label: '數據分析',
        href: '/analytics',
        permission: 'system:analytics',
        badge: currentTenant.planType === 'basic' ? 'PRO' : undefined
      }
    ];

    const settingsItems = [
      {
        icon: IconSettings,
        label: '系統設定',
        href: '/settings',
        permission: 'system:config'
      }
    ];

    return [
      ...baseItems,
      ...managementItems,
      ...analyticsItems,
      ...settingsItems
    ].filter(item => {
      // 檢查用戶權限
      return user.permissions?.includes(item.permission) || user.role === 'gym_owner';
    });
  }, [currentTenant, user]);

  const isActive = (href: string) => {
    return location.pathname === href || location.pathname.startsWith(`${href}/`);
  };

  if (!currentTenant) {
    return <div>Loading tenant...</div>;
  }

  return (
    <AppShell
      header={{ height: 70 }}
      navbar={{
        width: 280,
        breakpoint: 'sm',
        collapsed: { mobile: !opened }
      }}
      padding="md"
    >
      <AppShell.Header>
        <Group h="100%" px="md" justify="space-between">
          <Group>
            <Burger
              opened={opened}
              onClick={toggle}
              hiddenFrom="sm"
              size="sm"
            />
            <TenantLogo size="sm" />
          </Group>

          <TenantSwitcher />
        </Group>
      </AppShell.Header>

      <AppShell.Navbar p="md">
        <Stack gap="xs">
          <Text size="xs" tt="uppercase" fw={700} c="dimmed" mb="sm">
            導航選單
          </Text>

          {navigationItems.map((item) => (
            <NavLink
              key={item.href}
              href={item.href}
              label={
                <Group justify="space-between" w="100%">
                  <span>{item.label}</span>
                  {item.badge && (
                    <Badge size="xs" color="orange" variant="light">
                      {item.badge}
                    </Badge>
                  )}
                </Group>
              }
              leftSection={<item.icon size="1rem" />}
              active={isActive(item.href)}
              style={{
                borderRadius: '8px',
                padding: '12px',
                marginBottom: '4px'
              }}
            />
          ))}

          <Divider my="sm" />

          <Text size="xs" c="dimmed">
            當前方案:
            <Badge
              size="xs"
              ml="xs"
              color={currentTenant.planType === 'enterprise' ? 'gold' : 'blue'}
            >
              {currentTenant.planType.toUpperCase()}
            </Badge>
          </Text>

          {currentTenant.planType === 'basic' && (
            <Text size="xs" c="dimmed">
              <a
                href="/upgrade"
                style={{
                  color: 'var(--tenant-primary)',
                  textDecoration: 'none'
                }}
              >
                升級解鎖更多功能 →
              </a>
            </Text>
          )}
        </Stack>
      </AppShell.Navbar>

      <AppShell.Main>{children}</AppShell.Main>
    </AppShell>
  );
};

🔐 租戶安全與權限控制

1. 路由權限守衛

// apps/kyo-dashboard/src/components/auth/TenantRouteGuard.tsx
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Loader, Center, Alert } from '@mantine/core';
import { IconAlertCircle } from '@tabler/icons-react';

import { useTenantStore } from '../../stores/tenantStore';
import { useAuthStore } from '../../stores/authStore';
import { useTenantInitialization } from '../../hooks/useTenantInitialization';

interface TenantRouteGuardProps {
  children: React.ReactNode;
  requiredPermissions?: string[];
  requiredPlan?: 'basic' | 'pro' | 'enterprise';
}

export const TenantRouteGuard: React.FC<TenantRouteGuardProps> = ({
  children,
  requiredPermissions = [],
  requiredPlan
}) => {
  const { isAuthenticated, user } = useAuthStore();
  const { currentTenant } = useTenantStore();
  const { isLoading, error } = useTenantInitialization();

  // 等待租戶初始化完成
  if (isLoading) {
    return (
      <Center h="100vh">
        <Loader size="lg" />
      </Center>
    );
  }

  // 租戶初始化錯誤
  if (error) {
    return (
      <Center h="100vh" p="md">
        <Alert
          icon={<IconAlertCircle size="1rem" />}
          title="租戶載入失敗"
          color="red"
        >
          {error}
        </Alert>
      </Center>
    );
  }

  // 未認證用戶
  if (!isAuthenticated || !user) {
    return <Navigate to="/login" replace />;
  }

  // 沒有租戶上下文
  if (!currentTenant) {
    return <Navigate to="/select-tenant" replace />;
  }

  // 檢查租戶狀態
  if (currentTenant.status !== 'active') {
    return (
      <Center h="100vh" p="md">
        <Alert
          icon={<IconAlertCircle size="1rem" />}
          title="租戶暫停服務"
          color="yellow"
        >
          此健身房帳戶目前暫停服務,請聯絡客服。
        </Alert>
      </Center>
    );
  }

  // 檢查方案限制
  if (requiredPlan) {
    const planLevels = { basic: 1, pro: 2, enterprise: 3 };
    const currentLevel = planLevels[currentTenant.planType];
    const requiredLevel = planLevels[requiredPlan];

    if (currentLevel < requiredLevel) {
      return (
        <Center h="100vh" p="md">
          <Alert
            icon={<IconAlertCircle size="1rem" />}
            title="功能限制"
            color="orange"
          >
            此功能需要 {requiredPlan.toUpperCase()} 方案,
            <a href="/upgrade" style={{ marginLeft: '4px' }}>
              立即升級
            </a>
          </Alert>
        </Center>
      );
    }
  }

  // 檢查權限
  if (requiredPermissions.length > 0) {
    const hasPermission = requiredPermissions.every(permission =>
      user.permissions?.includes(permission) || user.role === 'gym_owner'
    );

    if (!hasPermission) {
      return (
        <Center h="100vh" p="md">
          <Alert
            icon={<IconAlertCircle size="1rem" />}
            title="權限不足"
            color="red"
          >
            您沒有存取此頁面的權限。
          </Alert>
        </Center>
      );
    }
  }

  return <>{children}</>;
};

2. API 請求攔截器

// apps/kyo-dashboard/src/lib/apiClient.ts
import { useTenantStore } from '../stores/tenantStore';
import { useAuthStore } from '../stores/authStore';

class TenantApiClient {
  private baseURL: string;

  constructor() {
    this.baseURL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
  }

  private getHeaders(): Record<string, string> {
    const { currentTenant } = useTenantStore.getState();
    const { token } = useAuthStore.getState();

    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
    };

    if (token) {
      headers['Authorization'] = `Bearer ${token}`;
    }

    if (currentTenant) {
      headers['X-Tenant-ID'] = currentTenant.id;
    }

    return headers;
  }

  async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const url = `${this.baseURL}${endpoint}`;

    const response = await fetch(url, {
      ...options,
      headers: {
        ...this.getHeaders(),
        ...options.headers,
      },
    });

    if (!response.ok) {
      if (response.status === 401) {
        // Token 過期,重新導向到登入頁
        useAuthStore.getState().logout();
        window.location.href = '/login';
        throw new Error('Authentication required');
      }

      if (response.status === 403) {
        throw new Error('Permission denied');
      }

      if (response.status === 404 && endpoint.includes('tenant')) {
        // 租戶不存在或無權存取
        useTenantStore.getState().setCurrentTenant(null);
        window.location.href = '/select-tenant';
        throw new Error('Tenant not found');
      }

      const errorData = await response.json().catch(() => ({}));
      throw new Error(errorData.message || `HTTP ${response.status}`);
    }

    return response.json();
  }

  // 便利方法
  async get<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'GET' });
  }

  async post<T>(endpoint: string, data?: any): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'POST',
      body: data ? JSON.stringify(data) : undefined,
    });
  }

  async put<T>(endpoint: string, data?: any): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'PUT',
      body: data ? JSON.stringify(data) : undefined,
    });
  }

  async delete<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'DELETE' });
  }
}

export const apiClient = new TenantApiClient();

今日成果

今天我們完成了多租戶前端管理介面的核心功能:

租戶識別系統: 支援子域名、路徑、標頭多種識別方式
租戶切換功能: 平台管理員可快速切換不同健身房
品牌客製化: 動態主題系統支援租戶專屬品牌
安全權限控制: 完整的路由守衛與 API 攔截
響應式設計: 適應各種裝置的租戶導航


上一篇
Day 10: 30天打造SaaS產品前端篇-現代前端架構總結與最佳實踐
系列文
30 天製作工作室 SaaS 產品 (前端篇)11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言