This commit is contained in:
2025-07-29 15:36:25 -07:00
commit 0c481c7a0e
29 changed files with 6682 additions and 0 deletions

34
src/App.jsx Normal file
View File

@ -0,0 +1,34 @@
import React from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Box } from '@mui/material'
import Login from './pages/Login'
import Register from './pages/Register'
import Dashboard from './pages/Dashboard'
import OAuthAuthorize from './pages/OAuthAuthorize'
import { AuthProvider } from './contexts/AuthContext'
import PrivateRoute from './components/PrivateRoute'
function App() {
return (
<AuthProvider>
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/oauth/authorize" element={<OAuthAuthorize />} />
<Route
path="/dashboard"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route path="/" element={<Navigate to="/login" replace />} />
</Routes>
</Box>
</AuthProvider>
)
}
export default App

View File

@ -0,0 +1,25 @@
import React from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { CircularProgress, Box } from '@mui/material'
const PrivateRoute = ({ children }) => {
const { user, loading } = useAuth()
if (loading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="100vh"
>
<CircularProgress />
</Box>
)
}
return user ? children : <Navigate to="/login" replace />
}
export default PrivateRoute

View File

@ -0,0 +1,91 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import axios from 'axios'
const AuthContext = createContext()
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
checkAuthStatus()
} else {
setLoading(false)
}
}, [])
const checkAuthStatus = async () => {
try {
const response = await axios.get('/api/auth/profile')
setUser(response.data.data)
} catch (error) {
localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization']
} finally {
setLoading(false)
}
}
const login = async (credentials) => {
try {
const response = await axios.post('/api/auth/login', credentials)
const { token, user } = response.data.data
localStorage.setItem('token', token)
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
setUser(user)
return { success: true }
} catch (error) {
return {
success: false,
message: error.response?.data?.message || '登录失败'
}
}
}
const register = async (userData) => {
try {
const response = await axios.post('/api/auth/register', userData)
const { token, user } = response.data.data
localStorage.setItem('token', token)
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
setUser(user)
return { success: true }
} catch (error) {
return {
success: false,
message: error.response?.data?.message || '注册失败'
}
}
}
const logout = () => {
localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization']
setUser(null)
}
const value = {
user,
loading,
login,
register,
logout
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}

28
src/main.jsx Normal file
View File

@ -0,0 +1,28 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import App from './App'
const theme = createTheme({
palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
})
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</React.StrictMode>
)

273
src/pages/Dashboard.jsx Normal file
View File

@ -0,0 +1,273 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
import {
Container,
Paper,
Typography,
Box,
Button,
Grid,
Card,
CardContent,
CardActions,
List,
ListItem,
ListItemText,
ListItemIcon,
Divider,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Alert
} from '@mui/material'
import {
Person,
Email,
CalendarToday,
Security,
Add,
Delete,
Visibility,
VisibilityOff,
Logout
} from '@mui/icons-material'
import axios from 'axios'
const Dashboard = () => {
const { user, logout } = useAuth()
const [clients, setClients] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [openCreateDialog, setOpenCreateDialog] = useState(false)
const [newClient, setNewClient] = useState({
name: '',
description: '',
redirect_uris: [''],
scopes: ['read', 'write']
})
useEffect(() => {
fetchClients()
}, [])
const fetchClients = async () => {
try {
const response = await axios.get('/api/oauth/clients')
setClients(response.data.data.clients)
} catch (error) {
setError('获取客户端列表失败')
}
}
const handleCreateClient = async () => {
setLoading(true)
try {
await axios.post('/api/oauth/clients', newClient)
setOpenCreateDialog(false)
setNewClient({ name: '', description: '', redirect_uris: [''], scopes: ['read', 'write'] })
fetchClients()
} catch (error) {
setError(error.response?.data?.message || '创建客户端失败')
}
setLoading(false)
}
const handleDeleteClient = async (clientId) => {
if (window.confirm('确定要删除这个客户端吗?')) {
try {
await axios.delete(`/api/oauth/clients/${clientId}`)
fetchClients()
} catch (error) {
setError('删除客户端失败')
}
}
}
const handleLogout = () => {
logout()
}
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
个人中心
</Typography>
<Typography variant="body1" color="text.secondary">
欢迎回来{user?.username}
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
{error}
</Alert>
)}
<Grid container spacing={3}>
{/* 用户信息卡片 */}
<Grid item xs={12} md={4}>
<Paper elevation={2} sx={{ p: 3 }}>
<Box display="flex" alignItems="center" mb={2}>
<Person sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h6">用户信息</Typography>
</Box>
<List>
<ListItem>
<ListItemIcon>
<Person />
</ListItemIcon>
<ListItemText primary="用户名" secondary={user?.username} />
</ListItem>
<ListItem>
<ListItemIcon>
<Email />
</ListItemIcon>
<ListItemText primary="邮箱" secondary={user?.email} />
</ListItem>
<ListItem>
<ListItemIcon>
<CalendarToday />
</ListItemIcon>
<ListItemText
primary="注册时间"
secondary={new Date(user?.created_at).toLocaleDateString()}
/>
</ListItem>
</List>
<Button
variant="outlined"
color="error"
startIcon={<Logout />}
onClick={handleLogout}
fullWidth
>
退出登录
</Button>
</Paper>
</Grid>
{/* OAuth客户端管理 */}
<Grid item xs={12} md={8}>
<Paper elevation={2} sx={{ p: 3 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Box display="flex" alignItems="center">
<Security sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h6">OAuth客户端</Typography>
</Box>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setOpenCreateDialog(true)}
>
创建客户端
</Button>
</Box>
{clients.length === 0 ? (
<Box textAlign="center" py={4}>
<Typography variant="body2" color="text.secondary">
还没有OAuth客户端点击上方按钮创建一个
</Typography>
</Box>
) : (
<Grid container spacing={2}>
{clients.map((client) => (
<Grid item xs={12} key={client.client_id}>
<Card variant="outlined">
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography variant="h6" gutterBottom>
{client.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{client.description}
</Typography>
<Box mt={1}>
<Typography variant="caption" color="text.secondary">
客户端ID: {client.client_id}
</Typography>
</Box>
<Box mt={1}>
{client.scopes.map((scope) => (
<Chip
key={scope}
label={scope}
size="small"
sx={{ mr: 0.5, mb: 0.5 }}
/>
))}
</Box>
</Box>
<Button
color="error"
size="small"
onClick={() => handleDeleteClient(client.client_id)}
>
<Delete />
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</Paper>
</Grid>
</Grid>
{/* 创建客户端对话框 */}
<Dialog open={openCreateDialog} onClose={() => setOpenCreateDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>创建OAuth客户端</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="客户端名称"
value={newClient.name}
onChange={(e) => setNewClient({ ...newClient, name: e.target.value })}
margin="normal"
required
/>
<TextField
fullWidth
label="描述"
value={newClient.description}
onChange={(e) => setNewClient({ ...newClient, description: e.target.value })}
margin="normal"
multiline
rows={2}
/>
<TextField
fullWidth
label="重定向URI"
value={newClient.redirect_uris[0]}
onChange={(e) => setNewClient({
...newClient,
redirect_uris: [e.target.value]
})}
margin="normal"
required
placeholder="http://localhost:3001/callback"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenCreateDialog(false)}>取消</Button>
<Button
onClick={handleCreateClient}
variant="contained"
disabled={loading || !newClient.name}
>
{loading ? '创建中...' : '创建'}
</Button>
</DialogActions>
</Dialog>
</Container>
)
}
export default Dashboard

153
src/pages/Login.jsx Normal file
View File

@ -0,0 +1,153 @@
import React, { useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
Container,
Paper,
TextField,
Button,
Typography,
Box,
Alert,
InputAdornment,
IconButton
} from '@mui/material'
import { Visibility, VisibilityOff, Login as LoginIcon } from '@mui/icons-material'
const Login = () => {
const { login, user } = useAuth()
const [formData, setFormData] = useState({
username: '',
password: ''
})
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
if (user) {
return <Navigate to="/dashboard" replace />
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
const result = await login(formData)
if (!result.success) {
setError(result.message)
}
setLoading(false)
}
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Paper
elevation={3}
sx={{
p: 4,
width: '100%',
maxWidth: 400
}}
>
<Box textAlign="center" mb={3}>
<LoginIcon sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" component="h1" gutterBottom>
登录
</Typography>
<Typography variant="body2" color="text.secondary">
欢迎回来请登录您的账户
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="用户名"
name="username"
value={formData.username}
onChange={handleChange}
margin="normal"
required
autoComplete="username"
/>
<TextField
fullWidth
label="密码"
name="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleChange}
margin="normal"
required
autoComplete="current-password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? '登录中...' : '登录'}
</Button>
<Box textAlign="center">
<Typography variant="body2">
还没有账户{' '}
<Link to="/register" style={{ textDecoration: 'none' }}>
<Typography
component="span"
variant="body2"
color="primary"
sx={{ cursor: 'pointer' }}
>
立即注册
</Typography>
</Link>
</Typography>
</Box>
</form>
</Paper>
</Box>
</Container>
)
}
export default Login

View File

@ -0,0 +1,250 @@
import React, { useState, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
Container,
Paper,
Typography,
Box,
Button,
Grid,
Card,
CardContent,
List,
ListItem,
ListItemIcon,
ListItemText,
Chip,
Alert,
CircularProgress
} from '@mui/material'
import {
Security,
CheckCircle,
Cancel,
Person,
Email,
CalendarToday,
Visibility,
VisibilityOff
} from '@mui/icons-material'
import axios from 'axios'
const OAuthAuthorize = () => {
const { user } = useAuth()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [clientInfo, setClientInfo] = useState(null)
const clientId = searchParams.get('client_id')
const redirectUri = searchParams.get('redirect_uri')
const scope = searchParams.get('scope')
const state = searchParams.get('state')
const responseType = searchParams.get('response_type')
useEffect(() => {
if (!user) {
navigate('/login')
return
}
if (!clientId || !redirectUri || responseType !== 'code') {
setError('无效的授权请求')
return
}
// 这里可以添加客户端信息获取逻辑
// 为了演示,我们使用默认信息
setClientInfo({
name: '第三方应用',
description: '请求访问您的账户信息',
scopes: scope ? scope.split(' ') : ['read', 'write']
})
}, [user, clientId, redirectUri, scope, responseType, navigate])
const handleAuthorize = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: scope || 'read write',
state: state || ''
})
const response = await axios.get(`/api/oauth/authorize?${params}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
})
if (response.data.success) {
const { redirect_url } = response.data.data
window.location.href = redirect_url
}
} catch (error) {
setError(error.response?.data?.message || '授权失败')
}
setLoading(false)
}
const handleDeny = () => {
// 拒绝授权,重定向回应用
const denyUrl = new URL(redirectUri)
denyUrl.searchParams.set('error', 'access_denied')
if (state) {
denyUrl.searchParams.set('state', state)
}
window.location.href = denyUrl.toString()
}
if (!user) {
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<CircularProgress />
</Box>
</Container>
)
}
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Paper
elevation={3}
sx={{
p: 4,
width: '100%',
maxWidth: 500
}}
>
<Box textAlign="center" mb={3}>
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" component="h1" gutterBottom>
授权请求
</Typography>
<Typography variant="body2" color="text.secondary">
第三方应用请求访问您的账户
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{clientInfo && (
<>
{/* 应用信息 */}
<Card variant="outlined" sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{clientInfo.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{clientInfo.description}
</Typography>
<Box mt={2}>
<Typography variant="body2" color="text.secondary" gutterBottom>
请求的权限
</Typography>
{clientInfo.scopes.map((scope) => (
<Chip
key={scope}
label={scope === 'read' ? '读取信息' : scope === 'write' ? '写入信息' : scope}
size="small"
sx={{ mr: 0.5, mb: 0.5 }}
color="primary"
variant="outlined"
/>
))}
</Box>
</CardContent>
</Card>
{/* 用户信息 */}
<Card variant="outlined" sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
您的账户信息
</Typography>
<List dense>
<ListItem>
<ListItemIcon>
<Person />
</ListItemIcon>
<ListItemText primary="用户名" secondary={user.username} />
</ListItem>
<ListItem>
<ListItemIcon>
<Email />
</ListItemIcon>
<ListItemText primary="邮箱" secondary={user.email} />
</ListItem>
<ListItem>
<ListItemIcon>
<CalendarToday />
</ListItemIcon>
<ListItemText
primary="注册时间"
secondary={new Date(user.created_at).toLocaleDateString()}
/>
</ListItem>
</List>
</CardContent>
</Card>
{/* 操作按钮 */}
<Grid container spacing={2}>
<Grid item xs={6}>
<Button
fullWidth
variant="outlined"
color="error"
startIcon={<Cancel />}
onClick={handleDeny}
disabled={loading}
>
拒绝
</Button>
</Grid>
<Grid item xs={6}>
<Button
fullWidth
variant="contained"
startIcon={<CheckCircle />}
onClick={handleAuthorize}
disabled={loading}
>
{loading ? '授权中...' : '授权'}
</Button>
</Grid>
</Grid>
</>
)}
</Paper>
</Box>
</Container>
)
}
export default OAuthAuthorize

210
src/pages/Register.jsx Normal file
View File

@ -0,0 +1,210 @@
import React, { useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import {
Container,
Paper,
TextField,
Button,
Typography,
Box,
Alert,
InputAdornment,
IconButton
} from '@mui/material'
import { Visibility, VisibilityOff, PersonAdd as RegisterIcon } from '@mui/icons-material'
const Register = () => {
const { register, user } = useAuth()
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: ''
})
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
if (user) {
return <Navigate to="/dashboard" replace />
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const validateForm = () => {
if (formData.password !== formData.confirmPassword) {
setError('两次输入的密码不一致')
return false
}
if (formData.password.length < 6) {
setError('密码长度至少6位')
return false
}
return true
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!validateForm()) {
return
}
setLoading(true)
const { confirmPassword, ...userData } = formData
const result = await register(userData)
if (!result.success) {
setError(result.message)
}
setLoading(false)
}
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Paper
elevation={3}
sx={{
p: 4,
width: '100%',
maxWidth: 400
}}
>
<Box textAlign="center" mb={3}>
<RegisterIcon sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" component="h1" gutterBottom>
注册
</Typography>
<Typography variant="body2" color="text.secondary">
创建您的账户
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="用户名"
name="username"
value={formData.username}
onChange={handleChange}
margin="normal"
required
autoComplete="username"
/>
<TextField
fullWidth
label="邮箱"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
margin="normal"
required
autoComplete="email"
/>
<TextField
fullWidth
label="密码"
name="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleChange}
margin="normal"
required
autoComplete="new-password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
<TextField
fullWidth
label="确认密码"
name="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleChange}
margin="normal"
required
autoComplete="new-password"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? '注册中...' : '注册'}
</Button>
<Box textAlign="center">
<Typography variant="body2">
已有账户{' '}
<Link to="/login" style={{ textDecoration: 'none' }}>
<Typography
component="span"
variant="body2"
color="primary"
sx={{ cursor: 'pointer' }}
>
立即登录
</Typography>
</Link>
</Typography>
</Box>
</form>
</Paper>
</Box>
</Container>
)
}
export default Register