在前一篇(Day 26)中,我們已經完成了 後端 RBAC 權限控管,
讓伺服器能根據使用者角色決定哪些 API 可以被呼叫。
而今天,我們要進一步讓「前端畫面」也具備同樣的智慧:
能根據使用者的角色,自動顯示或隱藏對應的功能與按鈕。
登入的角色不同,畫面也會跟著改變
user → 只能瀏覽文章
editor → 可新增、編輯自己的文章
admin → 享有完整管理權限(新增、刪除文章,並可進入後台)
在這篇教學中,我們將使用 React 框架,搭配 Chakra UI 快速打造一個小型專案,
透過連接後端 API,實作出「前端角色導向」的動態畫面與權限控制。
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
讓整個 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>
)
我們用 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)
整體的頁面路線如下:
/
→ 文章列表(所有人)/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>
)
}
用戶可以選擇角色註冊(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>
)
}
登入成功後會解析 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>
)
}
根據角色動態顯示「管理後台」按鈕。
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>
)
}
只允許特定角色訪問某些頁面。
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
}
這裡是「權限控制的頁面」
// 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>
)
}
// 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 下載並運行:
前端程式碼,可實際跑起來體驗整個權限系統