[阶段1.1-1.7] 实现完整的认证系统
后端实现: - 创建 AuthService 包含密码加密、JWT 生成和验证 - 创建 authMiddleware 用于 API 路由保护 - 实现 auth 路由 (register, login, logout, /me) 前端实现: - 创建 Login 页面 (登录/注册标签页) - 创建 ProtectedRoute 组件用于路由保护 - 更新 App.tsx 集成路由保护 - 前端 API 客户端已包含认证方法和拦截器 验收标准已满足: - 用户可以注册和登录 - JWT Token 正确生成和验证 - 受保护的路由需要有效 Token - 未认证用户重定向到登录页面 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import DataSync from './pages/DataSync';
|
||||
import Analysis from './pages/Analysis';
|
||||
@@ -10,15 +12,26 @@ import Settings from './pages/Settings';
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/sync" element={<DataSync />} />
|
||||
<Route path="/analysis" element={<Analysis />} />
|
||||
<Route path="/recommendations" element={<Recommendations />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/sync" element={<DataSync />} />
|
||||
<Route path="/analysis" element={<Analysis />} />
|
||||
<Route path="/recommendations" element={<Recommendations />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
18
client/src/components/ProtectedRoute.tsx
Normal file
18
client/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const token = localStorage.getItem('ghl_token');
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default ProtectedRoute;
|
||||
156
client/src/pages/Login.css
Normal file
156
client/src/pages/Login.css
Normal file
@@ -0,0 +1,156 @@
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: #999;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #999;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #667eea;
|
||||
border-bottom-color: #667eea;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
border: 1px solid #fcc;
|
||||
color: #c33;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
238
client/src/pages/Login.tsx
Normal file
238
client/src/pages/Login.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiClient } from '../services/api';
|
||||
import './Login.css';
|
||||
|
||||
type TabType = 'login' | 'register';
|
||||
|
||||
function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('login');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
// Login form
|
||||
const [loginEmail, setLoginEmail] = useState('');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
|
||||
// Register form
|
||||
const [regEmail, setRegEmail] = useState('');
|
||||
const [regGarminEmail, setRegGarminEmail] = useState('');
|
||||
const [regPassword, setRegPassword] = useState('');
|
||||
const [regConfirmPassword, setRegConfirmPassword] = useState('');
|
||||
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
};
|
||||
|
||||
const validatePassword = (password: string): boolean => {
|
||||
return password.length >= 6;
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!validateEmail(loginEmail)) {
|
||||
setError('Please enter a valid email');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePassword(loginPassword)) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await apiClient.login(loginEmail, loginPassword);
|
||||
const { token } = response.data.data;
|
||||
|
||||
// Store token
|
||||
apiClient.setSession(token);
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.error?.message || 'Login failed';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!validateEmail(regEmail)) {
|
||||
setError('Please enter a valid email');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEmail(regGarminEmail)) {
|
||||
setError('Please enter a valid Garmin email');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePassword(regPassword)) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (regPassword !== regConfirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await apiClient.register(regEmail, regGarminEmail, regPassword);
|
||||
const { token } = response.data.data;
|
||||
|
||||
// Store token
|
||||
apiClient.setSession(token);
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.error?.message || 'Registration failed';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>🏃 Garmin Health Lab</h1>
|
||||
<p>健康数据分析平台</p>
|
||||
</div>
|
||||
|
||||
<div className="login-tabs">
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'login' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('login');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('register');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{activeTab === 'login' && (
|
||||
<form onSubmit={handleLogin} className="login-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-email">邮箱</label>
|
||||
<input
|
||||
id="login-email"
|
||||
type="email"
|
||||
value={loginEmail}
|
||||
onChange={(e) => setLoginEmail(e.target.value)}
|
||||
placeholder="example@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-password">密码</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-button" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === 'register' && (
|
||||
<form onSubmit={handleRegister} className="login-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-email">邮箱</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
value={regEmail}
|
||||
onChange={(e) => setRegEmail(e.target.value)}
|
||||
placeholder="example@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-garmin-email">Garmin 邮箱</label>
|
||||
<input
|
||||
id="reg-garmin-email"
|
||||
type="email"
|
||||
value={regGarminEmail}
|
||||
onChange={(e) => setRegGarminEmail(e.target.value)}
|
||||
placeholder="garmin@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-password">密码</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
type="password"
|
||||
value={regPassword}
|
||||
onChange={(e) => setRegPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-confirm-password">确认密码</label>
|
||||
<input
|
||||
id="reg-confirm-password"
|
||||
type="password"
|
||||
value={regConfirmPassword}
|
||||
onChange={(e) => setRegConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-button" disabled={loading}>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
||||
const TOKEN_KEY = 'ghl_token';
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
@@ -12,9 +13,34 @@ class ApiClient {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Attach the saved JWT to every request.
|
||||
this.client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// On 401, drop the stored session so the UI can redirect to login.
|
||||
this.client.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
register(email: string, garminEmail: string, garminPassword: string) {
|
||||
return this.client.post('/auth/register', { email, garminEmail, garminPassword });
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
login(email: string, password: string) {
|
||||
return this.client.post('/auth/login', { email, password });
|
||||
}
|
||||
@@ -23,50 +49,55 @@ class ApiClient {
|
||||
return this.client.post('/auth/logout');
|
||||
}
|
||||
|
||||
// Garmin endpoints
|
||||
syncGarminData() {
|
||||
return this.client.post('/garmin/sync');
|
||||
refresh() {
|
||||
return this.client.post('/auth/refresh');
|
||||
}
|
||||
|
||||
// Persist the JWT returned by register/login.
|
||||
setSession(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
clearSession() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
// --- Garmin ---
|
||||
syncGarminData(garminEmail?: string, garminPassword?: string) {
|
||||
const body =
|
||||
garminEmail || garminPassword ? { garminEmail, garminPassword } : {};
|
||||
return this.client.post('/garmin/sync', body);
|
||||
}
|
||||
|
||||
getGarminSyncStatus() {
|
||||
return this.client.get('/garmin/status');
|
||||
}
|
||||
|
||||
// Health endpoints
|
||||
// --- Health ---
|
||||
getHealthSummary(startDate?: string, endDate?: string) {
|
||||
return this.client.get('/health/summary', {
|
||||
params: { startDate, endDate }
|
||||
});
|
||||
return this.client.get('/health/summary', { params: { startDate, endDate } });
|
||||
}
|
||||
|
||||
getStepsData(startDate?: string, endDate?: string) {
|
||||
return this.client.get('/health/steps', {
|
||||
params: { startDate, endDate }
|
||||
});
|
||||
return this.client.get('/health/steps', { params: { startDate, endDate } });
|
||||
}
|
||||
|
||||
getHeartRateData(startDate?: string, endDate?: string) {
|
||||
return this.client.get('/health/heart-rate', {
|
||||
params: { startDate, endDate }
|
||||
});
|
||||
return this.client.get('/health/heart-rate', { params: { startDate, endDate } });
|
||||
}
|
||||
|
||||
getSleepData(startDate?: string, endDate?: string) {
|
||||
return this.client.get('/health/sleep', {
|
||||
params: { startDate, endDate }
|
||||
});
|
||||
return this.client.get('/health/sleep', { params: { startDate, endDate } });
|
||||
}
|
||||
|
||||
getActivities(startDate?: string, endDate?: string) {
|
||||
return this.client.get('/health/activities', {
|
||||
params: { startDate, endDate }
|
||||
});
|
||||
return this.client.get('/health/activities', { params: { startDate, endDate } });
|
||||
}
|
||||
|
||||
// Analysis endpoints
|
||||
getTrends(metricType?: string) {
|
||||
// --- Analysis ---
|
||||
getTrends(metricType?: string, startDate?: string, endDate?: string) {
|
||||
return this.client.get('/analysis/trends', {
|
||||
params: { metricType }
|
||||
params: { metricType, startDate, endDate },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user