iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Modern Web

現在就學Node.js系列 第 27

RBAC 角色權限控管(下)— React + Chakra UI 權限控制 - Day27

  • 分享至 

  • xImage
  •  

在前一篇(Day 26)中,我們已經完成了 後端 RBAC 權限控管,
讓伺服器能根據使用者角色決定哪些 API 可以被呼叫。

而今天,我們要進一步讓「前端畫面」也具備同樣的智慧:
能根據使用者的角色,自動顯示或隱藏對應的功能與按鈕。

登入的角色不同,畫面也會跟著改變

user → 只能瀏覽文章

editor → 可新增、編輯自己的文章

admin → 享有完整管理權限(新增、刪除文章,並可進入後台)

在這篇教學中,我們將使用 React 框架,搭配 Chakra UI 快速打造一個小型專案,
透過連接後端 API,實作出「前端角色導向」的動態畫面與權限控制。

Step 1:建立 React + Chakra UI 專案

npm create vite@latest rbac-client
cd rbac-client
npm install @chakra-ui/react @emotion/react @emotion/styled framer-motion react-router-dom axios react-icons

Step 2:設定 Chakra Provider

讓整個 App 都能使用 Chakra UI 組件與樣式。

// main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import {ChakraProvider} from '@chakra-ui/react'
import App from './App'

ReactDOM.createRoot(document.getElementById('root')).render(
  <ChakraProvider>
    <App />
  </ChakraProvider>
)

Step 3:建立 Auth Context(登入資訊與角色管理)

我們用 React Context 管理全域的登入狀態。

讓所有頁面都能存取使用者的角色、token 等資料。

// src/context/AuthContext.jsx
import {createContext, useContext, useState} from 'react'

const AuthContext = createContext()

export const AuthProvider = ({children}) => {
  const [user, setUser] = useState(null) // { email, role, token }

  const login = data => setUser(data)
  const logout = () => setUser(null)

  return (
    <AuthContext.Provider value={{user, login, logout}}>
      {children}
    </AuthContext.Provider>
  )
}

export const useAuth = () => useContext(AuthContext)

Step 4:設定 Router 與頁面結構

整體的頁面路線如下:

  • / → 文章列表(所有人)
  • /login → 登入頁
  • /register → 註冊頁
  • /admin → 管理員專區(限 admin
// src/App.jsx
import {BrowserRouter as Router, Routes, Route} from 'react-router-dom'
import {AuthProvider} from './context/AuthContext'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import PostsPage from './pages/PostsPage'
import AdminPage from './pages/AdminPage'
import Navbar from './components/Navbar'

export default function App() {
  return (
    <AuthProvider>
      <Router>
        <Navbar />
        <Routes>
          <Route path="/" element={<PostsPage />} />
          <Route path="/login" element={<LoginPage />} />
          <Route path="/register" element={<RegisterPage />} />
          <Route path="/admin" element={<AdminPage />} />
        </Routes>
      </Router>
    </AuthProvider>
  )
}

Step 5:註冊頁面(RegisterPage)

用戶可以選擇角色註冊(user / editor / admin)。

// src/pages/RegisterPage.jsx
import {useState} from 'react'
import {
  Box,
  VStack,
  Heading,
  Input,
  Button,
  Select,
  useToast,
} from '@chakra-ui/react'
import axios from 'axios'
import {useNavigate} from 'react-router-dom'

const ROLES = [
  {value: 'user', label: '一般使用者'},
  {value: 'editor', label: '編輯'},
  {value: 'admin', label: '管理員'},
]

export default function RegisterPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [role, setRole] = useState('user')
  const [loading, setLoading] = useState(false)
  const toast = useToast()
  const navigate = useNavigate()

  const handleRegister = async () => {
    if (!email.trim() || !password.trim()) {
      toast({
        title: '請輸入帳號與密碼',
        status: 'warning',
        duration: 2000,
      })
      return
    }

    setLoading(true)
    try {
      await axios.post('/auth/register', {email, password, role})
      toast({
        title: '註冊成功,請使用帳密登入',
        status: 'success',
        duration: 2000,
      })
      navigate('/login')
    } catch (err) {
      toast({
        title: err.response?.data?.error || '註冊失敗',
        status: 'error',
        duration: 2000,
      })
    } finally {
      setLoading(false)
    }
  }

  return (
    <Box
      w="400px"
      m="auto"
      mt="100px"
      p="6"
      borderWidth="1px"
      borderRadius="md">
      <Heading size="md" mb="4" textAlign="center">
        建立新帳號
      </Heading>
      <VStack spacing="4">
        <Input
          placeholder="帳號"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
        <Input
          placeholder="密碼"
          type="password"
          value={password}
          onChange={e => setPassword(e.target.value)}
        />
        <Select value={role} onChange={e => setRole(e.target.value)}>
          {ROLES.map(option => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </Select>
        <Button
          colorScheme="teal"
          w="full"
          onClick={handleRegister}
          isLoading={loading}>
          註冊
        </Button>
        <Button
          variant="ghost"
          w="full"
          onClick={() => navigate('/login')}
          isDisabled={loading}>
          返回登入頁
        </Button>
      </VStack>
    </Box>
  )
}

Step 6:登入頁 LoginPage

登入成功後會解析 JWT,取得角色與信箱資訊,

再透過 login() 更新全域 Context。

// src/pages/LoginPage.jsx
import {useState} from 'react'
import {Box, Input, Button, Heading, VStack, useToast} from '@chakra-ui/react'
import axios from 'axios'
import {useAuth} from '../context/AuthContext'
import {useNavigate} from 'react-router-dom'

export default function LoginPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)
  const toast = useToast()
  const {login} = useAuth()
  const navigate = useNavigate()

  const handleLogin = async () => {
    if (!email.trim() || !password.trim()) {
      toast({
        title: '請輸入帳號與密碼',
        status: 'warning',
        duration: 2000,
      })
      return
    }

    setLoading(true)
    try {
      const res = await axios.post('/auth/login', {email, password})
      const token = res.data.token
      const payload = JSON.parse(atob(token.split('.')[1]))

      const userData = {
        _id: payload.id,
        email,
        role: payload.role,
        token,
      }

      login(userData)
      toast({title: '登入成功', status: 'success', duration: 2000})
      navigate('/')
    } catch (err) {
      toast({
        title: err.response?.data?.error || err.message || '登入失敗',
        status: 'error',
        duration: 2000,
      })
    } finally {
      setLoading(false)
    }
  }

  return (
    <Box
      w="400px"
      m="auto"
      mt="100px"
      p="6"
      borderWidth="1px"
      borderRadius="md">
      <Heading size="md" mb="4" textAlign="center">
        使用者登入
      </Heading>
      <VStack spacing="4">
        <Input
          placeholder="帳號"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
        <Input
          placeholder="密碼"
          type="password"
          value={password}
          onChange={e => setPassword(e.target.value)}
        />
        <Button
          colorScheme="teal"
          w="full"
          onClick={handleLogin}
          isLoading={loading}>
          登入
        </Button>
        <Button
          variant="ghost"
          w="full"
          onClick={() => navigate('/register')}
          isDisabled={loading}>
          前往註冊頁
        </Button>
      </VStack>
    </Box>
  )
}

Step 6:導航列 Navbar

根據角色動態顯示「管理後台」按鈕。

import {Flex, Button, Text} from '@chakra-ui/react'
import {useNavigate} from 'react-router-dom'
import {useAuth} from '../context/AuthContext'

export default function Navbar() {
  const {user, logout} = useAuth()
  const navigate = useNavigate()

  return (
    <Flex as="nav" bg="gray.700" color="white" p="4" justify="space-between">
      <Text fontWeight="bold" cursor="pointer" onClick={() => navigate('/')}>
        RBAC Blog 系統
      </Text>

      {user ? (
        <Flex align="center" gap="3">
          <Text>
            {user.email}({user.role})
          </Text>
          {user.role === 'admin' && (
            <Button
              size="sm"
              colorScheme="yellow"
              onClick={() => navigate('/admin')}>
              管理後台
            </Button>
          )}
          <Button size="sm" colorScheme="red" onClick={logout}>
            登出
          </Button>
        </Flex>
      ) : (
        <Flex align="center" gap="2">
          <Button
            colorScheme="teal"
            size="sm"
            onClick={() => navigate('/login')}>
            登入
          </Button>
          <Button
            variant="outline"
            colorScheme="teal"
            size="sm"
            onClick={() => navigate('/register')}>
            註冊
          </Button>
        </Flex>
      )}
    </Flex>
  )
}

Step 8:路由保護元件 ProtectedRoute

只允許特定角色訪問某些頁面。

import {Navigate} from 'react-router-dom'
import {useAuth} from '../context/AuthContext'

export default function ProtectedRoute({roles = [], children}) {
  const {user} = useAuth()

  if (!user) return <Navigate to="/login" replace />
  if (roles.length && !roles.includes(user.role))
    return <Navigate to="/" replace />

  return children
}

Step 9:文章列表頁(RBAC )

這裡是「權限控制的頁面」

  • user:只能瀏覽
  • editor:能編輯自己的文章
  • admin:能編輯與刪除所有文章
// src/pages/PostsPage.jsx
import {useCallback, useEffect, useState} from 'react'
import {useAuth} from '../context/AuthContext'
import {
  Box,
  Heading,
  Button,
  Text,
  Spinner,
  useToast,
  Modal,
  ModalOverlay,
  ModalContent,
  ModalHeader,
  ModalBody,
  ModalFooter,
  ModalCloseButton,
  Input,
  Textarea,
  useDisclosure,
} from '@chakra-ui/react'
import axios from 'axios'

export default function PostsPage() {
  const {user} = useAuth()
  const [posts, setPosts] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  const [formData, setFormData] = useState({title: '', content: '', id: null})
  const toast = useToast()
  const {isOpen, onOpen, onClose} = useDisclosure()

  const resetForm = () => {
    setFormData({title: '', content: '', id: null})
  }

  const closeModal = () => {
    onClose()
    resetForm()
  }

  const fetchPosts = useCallback(async () => {
    if (!user?.token) {
      setPosts([])
      setLoading(false)
      setError(null)
      return
    }

    setLoading(true)
    try {
      const res = await axios.get('/posts', {
        headers: {Authorization: `Bearer ${user.token}`},
      })
      setPosts(res.data)
      setError(null)
    } catch (err) {
      console.error('取得文章列表失敗:', err)
      setError(err.response?.data?.error || '無法取得文章資料')
    } finally {
      setLoading(false)
    }
  }, [user?.token])

  useEffect(() => {
    fetchPosts()
  }, [fetchPosts])

  const handleSave = async () => {
    if (!formData.title.trim()) {
      toast({title: '標題不可為空', status: 'warning', duration: 2000})
      return
    }

    try {
      if (formData.id) {
        await axios.put(
          `/posts/${formData.id}`,
          {title: formData.title, content: formData.content},
          {headers: {Authorization: `Bearer ${user.token}`}},
        )
        toast({title: '文章已更新', status: 'success', duration: 2000})
      } else {
        await axios.post(
          '/posts',
          {title: formData.title, content: formData.content},
          {headers: {Authorization: `Bearer ${user.token}`}},
        )
        toast({title: '文章建立成功', status: 'success', duration: 2000})
      }

      await fetchPosts()
      closeModal()
    } catch (err) {
      toast({
        title: err.response?.data?.error || '儲存失敗',
        status: 'error',
        duration: 2000,
      })
    }
  }

  const handleEdit = post => {
    setFormData({
      id: post._id || post.id || null,
      title: post.title || '',
      content: post.content || '',
    })
    onOpen()
  }

  const handleDelete = async id => {
    if (!window.confirm('確定要刪除這篇文章嗎?')) return
    try {
      await axios.delete(`/posts/${id}`, {
        headers: {Authorization: `Bearer ${user.token}`},
      })
      toast({title: '文章已刪除', status: 'success', duration: 2000})
      await fetchPosts()
    } catch (err) {
      toast({
        title: err.response?.data?.error || '刪除失敗',
        status: 'error',
        duration: 2000,
      })
    }
  }

  if (!user?.token) {
    return (
      <Box p="8" textAlign="center">
        <Heading size="md" mb="4">
          請先登入以瀏覽文章
        </Heading>
      </Box>
    )
  }

  if (loading) {
    return (
      <Box textAlign="center" mt="10">
        <Spinner />
        <Text mt="2">讀取中...</Text>
      </Box>
    )
  }

  if (error) {
    return (
      <Box p="8" color="red.500">
        發生錯誤:{error}
      </Box>
    )
  }

  return (
    <Box p="8">
      <Heading size="md" mb="6">
        文章列表
      </Heading>

      <Text mb="4">
        👋 歡迎,{user.email}({user.role})
      </Text>

      {['editor', 'admin'].includes(user.role) && (
        <Button
          colorScheme="teal"
          mb="4"
          onClick={() => {
            resetForm()
            onOpen()
          }}>
          新增文章
        </Button>
      )}

      {posts.map(post => {
        const postId = post._id || post.id
        const authorName =
          typeof post.author === 'string'
            ? post.author
            : post.author?.email || post.author?.name || '未知作者'
        const createdAt = post.createdAt
          ? new Date(post.createdAt).toLocaleString('zh-TW', {
              timeZone: 'Asia/Taipei',
              hour12: false,
            })
          : '未知時間'

        const isAuthor =
          user.role === 'admin' ||
          (user.role === 'editor' && post.author?.email === user.email)

        return (
          <Box
            key={postId}
            borderWidth="1px"
            p="4"
            borderRadius="md"
            mb="4"
            maxW="700px">
            <Heading size="sm">{post.title}</Heading>
            <Text whiteSpace="pre-wrap" mt="1">
              {post.content}
            </Text>
            <Text mt="2" fontSize="sm" color="gray.600">
              作者:{authorName}
            </Text>
            <Text mt="2" fontSize="sm" color="gray.600">
              建立時間:{createdAt}
            </Text>

            {isAuthor && (
              <Box mt="2">
                <Button
                  size="sm"
                  colorScheme="blue"
                  mr={user.role === 'admin' ? 2 : 0}
                  onClick={() => handleEdit(post)}>
                  編輯
                </Button>
                {user.role === 'admin' && (
                  <Button
                    size="sm"
                    colorScheme="red"
                    onClick={() => handleDelete(postId)}>
                    刪除
                  </Button>
                )}
              </Box>
            )}
          </Box>
        )
      })}

      <Modal isOpen={isOpen} onClose={closeModal}>
        <ModalOverlay />
        <ModalContent>
          <ModalHeader>{formData.id ? '編輯文章' : '新增文章'}</ModalHeader>
          <ModalCloseButton />
          <ModalBody>
            <Input
              placeholder="標題"
              mb="3"
              value={formData.title}
              onChange={e =>
                setFormData(current => ({
                  ...current,
                  title: e.target.value,
                }))
              }
            />
            <Textarea
              placeholder="內容"
              rows={6}
              value={formData.content}
              onChange={e =>
                setFormData(current => ({
                  ...current,
                  content: e.target.value,
                }))
              }
            />
          </ModalBody>
          <ModalFooter>
            <Button colorScheme="teal" mr="3" onClick={handleSave}>
              儲存
            </Button>
            <Button onClick={closeModal}>取消</Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </Box>
  )
}

Step 10:AdminPage(僅限管理員)

// src/pages/AdminPage.jsx
import {Box, Heading, Text} from '@chakra-ui/react'
import ProtectedRoute from '../components/ProtectedRoute'

export default function AdminPage() {
  return (
    <ProtectedRoute roles={['admin']}>
      <Box p="8">
        <Heading size="md" mb="4">
          🛠️ 管理後台
        </Heading>
        <Text>這裡只有 Admin 可以看到。</Text>
      </Box>
    </ProtectedRoute>
  )
}

小結:

這次的實作,正式完成 RBAC 權限架構的最後一塊拼圖 —— 畫面層控制

我們不只在後端限制操作,也讓前端畫面能夠「知道誰能看到什麼」。

整個系統,從 API 到 UI,都真正具備了角色辨識與權限意識。

在今天的開發中,我們完成了三個關鍵重點:

1️⃣ 畫面層(UI-Level)

根據角色動態調整按鈕與頁面呈現:

  • user → 僅能瀏覽
  • editor → 可編輯自己文章
  • admin → 全權管理(新增/編輯/刪除)

2️⃣ Token 驗證與前後端整合

登入後自動解析 JWT,將角色資訊存入 Context,

每次請求都攜帶 Token,權限驗證更安全流暢。

3️⃣ 彈性 UI 控制

透過 Chakra UI + React Context,打造出權限明確的操作體驗。

我們的 RBAC 架構 已從「後端邏輯」延伸到「前端體驗」。

權限不再只是 API 層的條件判斷,而是一種使用者體驗設計。

💡 前端 RBAC 的關鍵,不只是「能不能操作」,

而是「是否該被看到」。

備註:

這次的範例程式篇幅較多,如果你想直接執行或參考完整專案架構,

可前往以下 GitHub Repo 下載並運行:

👉 ithome-day27-rbac 範例專案

前端程式碼,可實際跑起來體驗整個權限系統


上一篇
RBAC 角色權限控管(上)— Blog API 實作篇 -Day26
下一篇
API 文件自動化 — 用 Swagger + JSDoc 打造開發者友善介面 -Day28
系列文
現在就學Node.js29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言