[阶段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:
ericwyuan
2026-08-23 12:25:33 +08:00
parent 6b05d04773
commit 3b2d0697f0
28 changed files with 1970 additions and 75 deletions

View File

@@ -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>
);
}

View 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
View 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
View 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;

View File

@@ -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 },
});
}