框架
版本

如何设置基于角色的访问控制

本指南涵盖了在 TanStack Router 应用中实现基于角色的访问控制(RBAC)和基于权限的路由。

快速开始

扩展您的认证上下文以包含角色和权限,创建受角色保护的布局路由,并使用 beforeLoad 在渲染路由之前检查用户权限。


扩展认证上下文

1. 为用户类型添加角色

更新您的认证上下文以包含角色

tsx
// src/auth.tsx
import React, { createContext, useContext, useState } from 'react'

interface User {
  id: string
  username: string
  email: string
  roles: string[]
  permissions: string[]
}

interface AuthState {
  isAuthenticated: boolean
  user: User | null
  hasRole: (role: string) => boolean
  hasAnyRole: (roles: string[]) => boolean
  hasPermission: (permission: string) => boolean
  hasAnyPermission: (permissions: string[]) => boolean
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

const AuthContext = createContext<AuthState | undefined>(undefined)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [isAuthenticated, setIsAuthenticated] = useState(false)

  const hasRole = (role: string) => {
    return user?.roles.includes(role) ?? false
  }

  const hasAnyRole = (roles: string[]) => {
    return roles.some((role) => user?.roles.includes(role)) ?? false
  }

  const hasPermission = (permission: string) => {
    return user?.permissions.includes(permission) ?? false
  }

  const hasAnyPermission = (permissions: string[]) => {
    return (
      permissions.some((permission) =>
        user?.permissions.includes(permission),
      ) ?? false
    )
  }

  const login = async (username: string, password: string) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, password }),
    })

    if (response.ok) {
      const userData = await response.json()
      setUser(userData)
      setIsAuthenticated(true)
    } else {
      throw new Error('Authentication failed')
    }
  }

  const logout = () => {
    setUser(null)
    setIsAuthenticated(false)
  }

  return (
    <AuthContext.Provider
      value={{
        isAuthenticated,
        user,
        hasRole,
        hasAnyRole,
        hasPermission,
        hasAnyPermission,
        login,
        logout,
      }}
    >
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}
// src/auth.tsx
import React, { createContext, useContext, useState } from 'react'

interface User {
  id: string
  username: string
  email: string
  roles: string[]
  permissions: string[]
}

interface AuthState {
  isAuthenticated: boolean
  user: User | null
  hasRole: (role: string) => boolean
  hasAnyRole: (roles: string[]) => boolean
  hasPermission: (permission: string) => boolean
  hasAnyPermission: (permissions: string[]) => boolean
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

const AuthContext = createContext<AuthState | undefined>(undefined)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [isAuthenticated, setIsAuthenticated] = useState(false)

  const hasRole = (role: string) => {
    return user?.roles.includes(role) ?? false
  }

  const hasAnyRole = (roles: string[]) => {
    return roles.some((role) => user?.roles.includes(role)) ?? false
  }

  const hasPermission = (permission: string) => {
    return user?.permissions.includes(permission) ?? false
  }

  const hasAnyPermission = (permissions: string[]) => {
    return (
      permissions.some((permission) =>
        user?.permissions.includes(permission),
      ) ?? false
    )
  }

  const login = async (username: string, password: string) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, password }),
    })

    if (response.ok) {
      const userData = await response.json()
      setUser(userData)
      setIsAuthenticated(true)
    } else {
      throw new Error('Authentication failed')
    }
  }

  const logout = () => {
    setUser(null)
    setIsAuthenticated(false)
  }

  return (
    <AuthContext.Provider
      value={{
        isAuthenticated,
        user,
        hasRole,
        hasAnyRole,
        hasPermission,
        hasAnyPermission,
        login,
        logout,
      }}
    >
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

2. 更新路由上下文类型

更新 src/routes/__root.tsx

tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'

interface AuthState {
  isAuthenticated: boolean
  user: {
    id: string
    username: string
    email: string
    roles: string[]
    permissions: string[]
  } | null
  hasRole: (role: string) => boolean
  hasAnyRole: (roles: string[]) => boolean
  hasPermission: (permission: string) => boolean
  hasAnyPermission: (permissions: string[]) => boolean
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

interface MyRouterContext {
  auth: AuthState
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
  component: () => (
    <div>
      <Outlet />
    </div>
  ),
})
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'

interface AuthState {
  isAuthenticated: boolean
  user: {
    id: string
    username: string
    email: string
    roles: string[]
    permissions: string[]
  } | null
  hasRole: (role: string) => boolean
  hasAnyRole: (roles: string[]) => boolean
  hasPermission: (permission: string) => boolean
  hasAnyPermission: (permissions: string[]) => boolean
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

interface MyRouterContext {
  auth: AuthState
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
  component: () => (
    <div>
      <Outlet />
    </div>
  ),
})

创建受角色保护的路由

1. 仅管理员路由

创建 src/routes/_authenticated/_admin.tsx

tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_admin')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.hasRole('admin')) {
      throw redirect({
        to: '/unauthorized',
        search: {
          redirect: location.href,
        },
      })
    }
  },
  component: AdminLayout,
})

function AdminLayout() {
  return (
    <div>
      <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
        <strong>Admin Area:</strong> You have administrative privileges.
      </div>
      <Outlet />
    </div>
  )
}
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_admin')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.hasRole('admin')) {
      throw redirect({
        to: '/unauthorized',
        search: {
          redirect: location.href,
        },
      })
    }
  },
  component: AdminLayout,
})

function AdminLayout() {
  return (
    <div>
      <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
        <strong>Admin Area:</strong> You have administrative privileges.
      </div>
      <Outlet />
    </div>
  )
}

2. 多角色访问

创建 src/routes/_authenticated/_moderator.tsx

tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_moderator')({
  beforeLoad: ({ context, location }) => {
    const allowedRoles = ['admin', 'moderator']
    if (!context.auth.hasAnyRole(allowedRoles)) {
      throw redirect({
        to: '/unauthorized',
        search: {
          redirect: location.href,
          reason: 'insufficient_role',
        },
      })
    }
  },
  component: ModeratorLayout,
})

function ModeratorLayout() {
  const { auth } = Route.useRouteContext()

  return (
    <div>
      <div className="bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded mb-4">
        <strong>Moderator Area:</strong> Role: {auth.user?.roles.join(', ')}
      </div>
      <Outlet />
    </div>
  )
}
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_moderator')({
  beforeLoad: ({ context, location }) => {
    const allowedRoles = ['admin', 'moderator']
    if (!context.auth.hasAnyRole(allowedRoles)) {
      throw redirect({
        to: '/unauthorized',
        search: {
          redirect: location.href,
          reason: 'insufficient_role',
        },
      })
    }
  },
  component: ModeratorLayout,
})

function ModeratorLayout() {
  const { auth } = Route.useRouteContext()

  return (
    <div>
      <div className="bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded mb-4">
        <strong>Moderator Area:</strong> Role: {auth.user?.roles.join(', ')}
      </div>
      <Outlet />
    </div>
  )
}

3. 基于权限的路由

创建 src/routes/_authenticated/_users.tsx

tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_users')({
  beforeLoad: ({ context, location }) => {
    const requiredPermissions = ['users:read', 'users:write']
    if (!context.auth.hasAnyPermission(requiredPermissions)) {
      throw redirect({
        to: '/unauthorized',
        search: {
          redirect: location.href,
          reason: 'insufficient_permissions',
        },
      })
    }
  },
  component: () => <Outlet />,
})
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_users')({
  beforeLoad: ({ context, location }) => {
    const requiredPermissions = ['users:read', 'users:write']
    if (!context.auth.hasAnyPermission(requiredPermissions)) {
      throw redirect({
        to: '/unauthorized',
        search: {
          redirect: location.href,
          reason: 'insufficient_permissions',
        },
      })
    }
  },
  component: () => <Outlet />,
})

创建特定的受保护页面

1. 管理员仪表盘

创建 src/routes/_authenticated/_admin/dashboard.tsx

tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_admin/dashboard')({
  component: AdminDashboard,
})

function AdminDashboard() {
  const { auth } = Route.useRouteContext()

  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-6">Admin Dashboard</h1>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        <div className="bg-white p-6 rounded-lg shadow">
          <h2 className="text-xl font-semibold mb-2">User Management</h2>
          <p className="text-gray-600">Manage all users in the system</p>
          <button className="mt-4 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
            View Users
          </button>
        </div>

        <div className="bg-white p-6 rounded-lg shadow">
          <h2 className="text-xl font-semibold mb-2">System Settings</h2>
          <p className="text-gray-600">Configure system-wide settings</p>
          <button className="mt-4 bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700">
            Open Settings
          </button>
        </div>

        <div className="bg-white p-6 rounded-lg shadow">
          <h2 className="text-xl font-semibold mb-2">Reports</h2>
          <p className="text-gray-600">View system reports and analytics</p>
          <button className="mt-4 bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700">
            View Reports
          </button>
        </div>
      </div>

      <div className="mt-8 bg-gray-100 p-4 rounded">
        <h3 className="font-semibold">Your Info:</h3>
        <p>Username: {auth.user?.username}</p>
        <p>Roles: {auth.user?.roles.join(', ')}</p>
        <p>Permissions: {auth.user?.permissions.join(', ')}</p>
      </div>
    </div>
  )
}
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_admin/dashboard')({
  component: AdminDashboard,
})

function AdminDashboard() {
  const { auth } = Route.useRouteContext()

  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-6">Admin Dashboard</h1>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        <div className="bg-white p-6 rounded-lg shadow">
          <h2 className="text-xl font-semibold mb-2">User Management</h2>
          <p className="text-gray-600">Manage all users in the system</p>
          <button className="mt-4 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
            View Users
          </button>
        </div>

        <div className="bg-white p-6 rounded-lg shadow">
          <h2 className="text-xl font-semibold mb-2">System Settings</h2>
          <p className="text-gray-600">Configure system-wide settings</p>
          <button className="mt-4 bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700">
            Open Settings
          </button>
        </div>

        <div className="bg-white p-6 rounded-lg shadow">
          <h2 className="text-xl font-semibold mb-2">Reports</h2>
          <p className="text-gray-600">View system reports and analytics</p>
          <button className="mt-4 bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700">
            View Reports
          </button>
        </div>
      </div>

      <div className="mt-8 bg-gray-100 p-4 rounded">
        <h3 className="font-semibold">Your Info:</h3>
        <p>Username: {auth.user?.username}</p>
        <p>Roles: {auth.user?.roles.join(', ')}</p>
        <p>Permissions: {auth.user?.permissions.join(', ')}</p>
      </div>
    </div>
  )
}

2. 用户管理页面

创建 src/routes/_authenticated/_users/manage.tsx

tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_users/manage')({
  beforeLoad: ({ context }) => {
    // Additional permission check at the page level
    if (!context.auth.hasPermission('users:write')) {
      throw new Error('You need write permissions to manage users')
    }
  },
  component: UserManagement,
})

function UserManagement() {
  const { auth } = Route.useRouteContext()

  const canEdit = auth.hasPermission('users:write')
  const canDelete = auth.hasPermission('users:delete')

  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-6">User Management</h1>

      <div className="bg-white rounded-lg shadow overflow-hidden">
        <table className="min-w-full">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Name
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Email
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Role
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Actions
              </th>
            </tr>
          </thead>
          <tbody className="divide-y divide-gray-200">
            <tr>
              <td className="px-6 py-4 whitespace-nowrap">John Doe</td>
              <td className="px-6 py-4 whitespace-nowrap">john@example.com</td>
              <td className="px-6 py-4 whitespace-nowrap">
                <span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
                  User
                </span>
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm">
                {canEdit && (
                  <button className="text-blue-600 hover:text-blue-900 mr-4">
                    Edit
                  </button>
                )}
                {canDelete && (
                  <button className="text-red-600 hover:text-red-900">
                    Delete
                  </button>
                )}
              </td>
            </tr>
          </tbody>
        </table>
      </div>

      <div className="mt-6 p-4 bg-blue-50 rounded">
        <h3 className="font-semibold text-blue-800">Your Permissions:</h3>
        <ul className="text-blue-700 text-sm">
          {auth.user?.permissions.map((permission) => (
            <li key={permission}>✓ {permission}</li>
          ))}
        </ul>
      </div>
    </div>
  )
}
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_users/manage')({
  beforeLoad: ({ context }) => {
    // Additional permission check at the page level
    if (!context.auth.hasPermission('users:write')) {
      throw new Error('You need write permissions to manage users')
    }
  },
  component: UserManagement,
})

function UserManagement() {
  const { auth } = Route.useRouteContext()

  const canEdit = auth.hasPermission('users:write')
  const canDelete = auth.hasPermission('users:delete')

  return (
    <div className="p-6">
      <h1 className="text-3xl font-bold mb-6">User Management</h1>

      <div className="bg-white rounded-lg shadow overflow-hidden">
        <table className="min-w-full">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Name
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Email
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Role
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                Actions
              </th>
            </tr>
          </thead>
          <tbody className="divide-y divide-gray-200">
            <tr>
              <td className="px-6 py-4 whitespace-nowrap">John Doe</td>
              <td className="px-6 py-4 whitespace-nowrap">john@example.com</td>
              <td className="px-6 py-4 whitespace-nowrap">
                <span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
                  User
                </span>
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm">
                {canEdit && (
                  <button className="text-blue-600 hover:text-blue-900 mr-4">
                    Edit
                  </button>
                )}
                {canDelete && (
                  <button className="text-red-600 hover:text-red-900">
                    Delete
                  </button>
                )}
              </td>
            </tr>
          </tbody>
        </table>
      </div>

      <div className="mt-6 p-4 bg-blue-50 rounded">
        <h3 className="font-semibold text-blue-800">Your Permissions:</h3>
        <ul className="text-blue-700 text-sm">
          {auth.user?.permissions.map((permission) => (
            <li key={permission}>✓ {permission}</li>
          ))}
        </ul>
      </div>
    </div>
  )
}

创建未经授权的页面

创建 src/routes/unauthorized.tsx

tsx
import { createFileRoute, Link } from '@tanstack/react-router'

export const Route = createFileRoute('/unauthorized')({
  validateSearch: (search) => ({
    redirect: (search.redirect as string) || '/dashboard',
    reason: (search.reason as string) || 'insufficient_permissions',
  }),
  component: UnauthorizedPage,
})

function UnauthorizedPage() {
  const { redirect, reason } = Route.useSearch()
  const { auth } = Route.useRouteContext()

  const reasonMessages = {
    insufficient_role: 'You do not have the required role to access this page.',
    insufficient_permissions:
      'You do not have the required permissions to access this page.',
    default: 'You are not authorized to access this page.',
  }

  const message =
    reasonMessages[reason as keyof typeof reasonMessages] ||
    reasonMessages.default

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8 text-center">
        <div className="mb-6">
          <div className="mx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center">
            <svg
              className="w-8 h-8 text-red-600"
              fill="none"
              stroke="currentColor"
              viewBox="0 0 24 24"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
              />
            </svg>
          </div>
        </div>

        <h1 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h1>
        <p className="text-gray-600 mb-6">{message}</p>

        <div className="mb-6 text-sm text-gray-500">
          <p>
            <strong>Your roles:</strong> {auth.user?.roles.join(', ') || 'None'}
          </p>
          <p>
            <strong>Your permissions:</strong>{' '}
            {auth.user?.permissions.join(', ') || 'None'}
          </p>
        </div>

        <div className="space-y-3">
          <Link
            to="/dashboard"
            className="block w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition-colors"
          >
            Go to Dashboard
          </Link>

          <Link
            to={redirect}
            className="block w-full bg-gray-200 text-gray-800 py-2 px-4 rounded hover:bg-gray-300 transition-colors"
          >
            Try Again
          </Link>
        </div>
      </div>
    </div>
  )
}
import { createFileRoute, Link } from '@tanstack/react-router'

export const Route = createFileRoute('/unauthorized')({
  validateSearch: (search) => ({
    redirect: (search.redirect as string) || '/dashboard',
    reason: (search.reason as string) || 'insufficient_permissions',
  }),
  component: UnauthorizedPage,
})

function UnauthorizedPage() {
  const { redirect, reason } = Route.useSearch()
  const { auth } = Route.useRouteContext()

  const reasonMessages = {
    insufficient_role: 'You do not have the required role to access this page.',
    insufficient_permissions:
      'You do not have the required permissions to access this page.',
    default: 'You are not authorized to access this page.',
  }

  const message =
    reasonMessages[reason as keyof typeof reasonMessages] ||
    reasonMessages.default

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8 text-center">
        <div className="mb-6">
          <div className="mx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center">
            <svg
              className="w-8 h-8 text-red-600"
              fill="none"
              stroke="currentColor"
              viewBox="0 0 24 24"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
              />
            </svg>
          </div>
        </div>

        <h1 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h1>
        <p className="text-gray-600 mb-6">{message}</p>

        <div className="mb-6 text-sm text-gray-500">
          <p>
            <strong>Your roles:</strong> {auth.user?.roles.join(', ') || 'None'}
          </p>
          <p>
            <strong>Your permissions:</strong>{' '}
            {auth.user?.permissions.join(', ') || 'None'}
          </p>
        </div>

        <div className="space-y-3">
          <Link
            to="/dashboard"
            className="block w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition-colors"
          >
            Go to Dashboard
          </Link>

          <Link
            to={redirect}
            className="block w-full bg-gray-200 text-gray-800 py-2 px-4 rounded hover:bg-gray-300 transition-colors"
          >
            Try Again
          </Link>
        </div>
      </div>
    </div>
  )
}

组件级权限检查

1. 条件渲染 Hook

创建 src/hooks/usePermissions.ts

tsx
import { useRouter } from '@tanstack/react-router'

export function usePermissions() {
  const router = useRouter()
  const auth = router.options.context.auth

  return {
    hasRole: auth.hasRole,
    hasAnyRole: auth.hasAnyRole,
    hasPermission: auth.hasPermission,
    hasAnyPermission: auth.hasAnyPermission,
    user: auth.user,
  }
}
import { useRouter } from '@tanstack/react-router'

export function usePermissions() {
  const router = useRouter()
  const auth = router.options.context.auth

  return {
    hasRole: auth.hasRole,
    hasAnyRole: auth.hasAnyRole,
    hasPermission: auth.hasPermission,
    hasAnyPermission: auth.hasAnyPermission,
    user: auth.user,
  }
}

2. 权限守卫组件

创建 src/components/PermissionGuard.tsx

tsx
interface PermissionGuardProps {
  children: React.ReactNode
  roles?: string[]
  permissions?: string[]
  requireAll?: boolean
  fallback?: React.ReactNode
}

export function PermissionGuard({
  children,
  roles = [],
  permissions = [],
  requireAll = false,
  fallback = null,
}: PermissionGuardProps) {
  const { hasAnyRole, hasAnyPermission, hasRole, hasPermission } =
    usePermissions()

  const hasRequiredRoles =
    roles.length === 0 ||
    (requireAll ? roles.every((role) => hasRole(role)) : hasAnyRole(roles))

  const hasRequiredPermissions =
    permissions.length === 0 ||
    (requireAll
      ? permissions.every((permission) => hasPermission(permission))
      : hasAnyPermission(permissions))

  if (hasRequiredRoles && hasRequiredPermissions) {
    return <>{children}</>
  }

  return <>{fallback}</>
}
interface PermissionGuardProps {
  children: React.ReactNode
  roles?: string[]
  permissions?: string[]
  requireAll?: boolean
  fallback?: React.ReactNode
}

export function PermissionGuard({
  children,
  roles = [],
  permissions = [],
  requireAll = false,
  fallback = null,
}: PermissionGuardProps) {
  const { hasAnyRole, hasAnyPermission, hasRole, hasPermission } =
    usePermissions()

  const hasRequiredRoles =
    roles.length === 0 ||
    (requireAll ? roles.every((role) => hasRole(role)) : hasAnyRole(roles))

  const hasRequiredPermissions =
    permissions.length === 0 ||
    (requireAll
      ? permissions.every((permission) => hasPermission(permission))
      : hasAnyPermission(permissions))

  if (hasRequiredRoles && hasRequiredPermissions) {
    return <>{children}</>
  }

  return <>{fallback}</>
}

3. 使用权限守卫

tsx
import { PermissionGuard } from '../components/PermissionGuard'

function SomeComponent() {
  return (
    <div>
      <h1>Dashboard</h1>

      <PermissionGuard roles={['admin']}>
        <button className="bg-red-600 text-white px-4 py-2 rounded">
          Admin Only Button
        </button>
      </PermissionGuard>

      <PermissionGuard
        permissions={['users:write']}
        fallback={<p className="text-gray-500">You cannot edit users</p>}
      >
        <button className="bg-blue-600 text-white px-4 py-2 rounded">
          Edit Users
        </button>
      </PermissionGuard>

      <PermissionGuard
        roles={['admin', 'moderator']}
        permissions={['content:moderate']}
        requireAll={true}
      >
        <button className="bg-yellow-600 text-white px-4 py-2 rounded">
          Moderate Content (Admin/Mod + Permission)
        </button>
      </PermissionGuard>
    </div>
  )
}
import { PermissionGuard } from '../components/PermissionGuard'

function SomeComponent() {
  return (
    <div>
      <h1>Dashboard</h1>

      <PermissionGuard roles={['admin']}>
        <button className="bg-red-600 text-white px-4 py-2 rounded">
          Admin Only Button
        </button>
      </PermissionGuard>

      <PermissionGuard
        permissions={['users:write']}
        fallback={<p className="text-gray-500">You cannot edit users</p>}
      >
        <button className="bg-blue-600 text-white px-4 py-2 rounded">
          Edit Users
        </button>
      </PermissionGuard>

      <PermissionGuard
        roles={['admin', 'moderator']}
        permissions={['content:moderate']}
        requireAll={true}
      >
        <button className="bg-yellow-600 text-white px-4 py-2 rounded">
          Moderate Content (Admin/Mod + Permission)
        </button>
      </PermissionGuard>
    </div>
  )
}

高级权限模式

1. 基于资源的权限

tsx
// Check if user can edit a specific resource
function canEditResource(auth: AuthState, resourceId: string, ownerId: string) {
  // Admin can edit anything
  if (auth.hasRole('admin')) return true

  // Owner can edit their own resources
  if (auth.user?.id === ownerId && auth.hasPermission('resource:edit:own'))
    return true

  // Moderators can edit with permission
  if (auth.hasRole('moderator') && auth.hasPermission('resource:edit:any'))
    return true

  return false
}

// Usage in component
function ResourceEditor({ resource }) {
  const { auth } = Route.useRouteContext()

  if (!canEditResource(auth, resource.id, resource.ownerId)) {
    return <div>You cannot edit this resource</div>
  }

  return <EditForm resource={resource} />
}
// Check if user can edit a specific resource
function canEditResource(auth: AuthState, resourceId: string, ownerId: string) {
  // Admin can edit anything
  if (auth.hasRole('admin')) return true

  // Owner can edit their own resources
  if (auth.user?.id === ownerId && auth.hasPermission('resource:edit:own'))
    return true

  // Moderators can edit with permission
  if (auth.hasRole('moderator') && auth.hasPermission('resource:edit:any'))
    return true

  return false
}

// Usage in component
function ResourceEditor({ resource }) {
  const { auth } = Route.useRouteContext()

  if (!canEditResource(auth, resource.id, resource.ownerId)) {
    return <div>You cannot edit this resource</div>
  }

  return <EditForm resource={resource} />
}

2. 基于时间的权限

tsx
function hasTimeBasedPermission(auth: AuthState, permission: string) {
  const userPermissions = auth.user?.permissions || []
  const hasPermission = userPermissions.includes(permission)

  // Check if permission has time restrictions
  const timeRestricted = userPermissions.find((p) =>
    p.startsWith(`${permission}:time:`),
  )

  if (timeRestricted) {
    const [, , startHour, endHour] = timeRestricted.split(':')
    const currentHour = new Date().getHours()
    return (
      currentHour >= parseInt(startHour) && currentHour <= parseInt(endHour)
    )
  }

  return hasPermission
}
function hasTimeBasedPermission(auth: AuthState, permission: string) {
  const userPermissions = auth.user?.permissions || []
  const hasPermission = userPermissions.includes(permission)

  // Check if permission has time restrictions
  const timeRestricted = userPermissions.find((p) =>
    p.startsWith(`${permission}:time:`),
  )

  if (timeRestricted) {
    const [, , startHour, endHour] = timeRestricted.split(':')
    const currentHour = new Date().getHours()
    return (
      currentHour >= parseInt(startHour) && currentHour <= parseInt(endHour)
    )
  }

  return hasPermission
}

常见问题

角色/权限数据未加载

问题: 用户角色/权限在路由中未定义。

解决方案: 确保您的认证 API 返回完整的用户数据

tsx
const login = async (username: string, password: string) => {
  const response = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password }),
  })

  if (response.ok) {
    const userData = await response.json()
    // Ensure userData includes roles and permissions
    console.log('User data:', userData) // Debug log
    setUser(userData)
    setIsAuthenticated(true)
  }
}
const login = async (username: string, password: string) => {
  const response = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password }),
  })

  if (response.ok) {
    const userData = await response.json()
    // Ensure userData includes roles and permissions
    console.log('User data:', userData) // Debug log
    setUser(userData)
    setIsAuthenticated(true)
  }
}

权限检查过于严格

问题: 用户被锁定在应能访问的区域之外。

解决方案: 使用分层权限和角色继承

tsx
const roleHierarchy = {
  admin: ['admin', 'moderator', 'user'],
  moderator: ['moderator', 'user'],
  user: ['user'],
}

const hasRole = (requiredRole: string) => {
  const userRoles = user?.roles || []
  return userRoles.some((userRole) =>
    roleHierarchy[userRole]?.includes(requiredRole),
  )
}
const roleHierarchy = {
  admin: ['admin', 'moderator', 'user'],
  moderator: ['moderator', 'user'],
  user: ['user'],
}

const hasRole = (requiredRole: string) => {
  const userRoles = user?.roles || []
  return userRoles.some((userRole) =>
    roleHierarchy[userRole]?.includes(requiredRole),
  )
}

大量权限检查导致性能问题

问题: 过多的权限检查导致渲染缓慢。

解决方案: 缓存权限计算结果

tsx
import { useMemo } from 'react'

function usePermissions() {
  const { auth } = Route.useRouteContext()

  const permissions = useMemo(
    () => ({
      canEditUsers: auth.hasPermission('users:write'),
      canDeleteUsers: auth.hasPermission('users:delete'),
      isAdmin: auth.hasRole('admin'),
      isModerator: auth.hasAnyRole(['admin', 'moderator']),
    }),
    [auth.user?.roles, auth.user?.permissions],
  )

  return permissions
}
import { useMemo } from 'react'

function usePermissions() {
  const { auth } = Route.useRouteContext()

  const permissions = useMemo(
    () => ({
      canEditUsers: auth.hasPermission('users:write'),
      canDeleteUsers: auth.hasPermission('users:delete'),
      isAdmin: auth.hasRole('admin'),
      isModerator: auth.hasAnyRole(['admin', 'moderator']),
    }),
    [auth.user?.roles, auth.user?.permissions],
  )

  return permissions
}

常见后续步骤

设置 RBAC 后,您可能想要

我们的合作伙伴
Code Rabbit
Netlify
Neon
Clerk
Convex
Sentry
订阅 Bytes

您的每周 JavaScript 资讯。每周一免费发送给超过 10 万开发者。

Bytes

无垃圾邮件。您可以随时取消订阅。

订阅 Bytes

您的每周 JavaScript 资讯。每周一免费发送给超过 10 万开发者。

Bytes

无垃圾邮件。您可以随时取消订阅。