feat: implement user authentication module

- Backend (FastAPI):
  - External API authentication (pj-auth-api.vercel.app)
  - JWT token validation with Redis session storage
  - RBAC with department isolation
  - User, Role, Department models with pjctrl_ prefix
  - Alembic migrations with project-specific version table
  - Complete test coverage (13 tests)

- Frontend (React + Vite):
  - AuthContext for state management
  - Login page with error handling
  - Protected route component
  - Dashboard with user info display

- OpenSpec:
  - 7 capability specs defined
  - add-user-auth change archived

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-12-28 23:41:37 +08:00
commit 1fda7da2c2
77 changed files with 6562 additions and 0 deletions

32
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './contexts/AuthContext'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import ProtectedRoute from './components/ProtectedRoute'
function App() {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return <div className="container">Loading...</div>
}
return (
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" /> : <Login />}
/>
<Route
path="/"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
)
}
export default App

View File

@@ -0,0 +1,21 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { ReactNode } from 'react'
interface ProtectedRouteProps {
children: ReactNode
}
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return <div className="container">Loading...</div>
}
if (!isAuthenticated) {
return <Navigate to="/login" />
}
return <>{children}</>
}

View File

@@ -0,0 +1,74 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { authApi, User, LoginRequest } from '../services/api'
interface AuthContextType {
user: User | null
isAuthenticated: boolean
loading: boolean
login: (data: LoginRequest) => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// Check for existing token on mount
const token = localStorage.getItem('token')
const storedUser = localStorage.getItem('user')
if (token && storedUser) {
try {
setUser(JSON.parse(storedUser))
} catch {
localStorage.removeItem('token')
localStorage.removeItem('user')
}
}
setLoading(false)
}, [])
const login = async (data: LoginRequest) => {
const response = await authApi.login(data)
localStorage.setItem('token', response.access_token)
localStorage.setItem('user', JSON.stringify(response.user))
setUser(response.user)
}
const logout = async () => {
try {
await authApi.logout()
} catch {
// Ignore errors on logout
} finally {
localStorage.removeItem('token')
localStorage.removeItem('user')
setUser(null)
}
}
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
loading,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

18
frontend/src/index.css Normal file
View File

@@ -0,0 +1,18 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f5f5f5;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}

16
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './contexts/AuthContext'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,115 @@
import { useAuth } from '../contexts/AuthContext'
export default function Dashboard() {
const { user, logout } = useAuth()
const handleLogout = async () => {
await logout()
}
return (
<div style={styles.container}>
<header style={styles.header}>
<h1 style={styles.title}>Project Control</h1>
<div style={styles.userInfo}>
<span style={styles.userName}>{user?.name}</span>
{user?.is_system_admin && (
<span style={styles.badge}>Admin</span>
)}
<button onClick={handleLogout} style={styles.logoutButton}>
Logout
</button>
</div>
</header>
<main style={styles.main}>
<div style={styles.welcomeCard}>
<h2>Welcome, {user?.name}!</h2>
<p>Email: {user?.email}</p>
<p>Role: {user?.role || 'No role assigned'}</p>
{user?.is_system_admin && (
<p style={styles.adminNote}>
You have system administrator privileges.
</p>
)}
</div>
<div style={styles.infoCard}>
<h3>Getting Started</h3>
<p>
This is the Project Control system dashboard. Features will be
added as development progresses.
</p>
</div>
</main>
</div>
)
}
const styles: { [key: string]: React.CSSProperties } = {
container: {
minHeight: '100vh',
backgroundColor: '#f5f5f5',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 24px',
backgroundColor: 'white',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
},
title: {
fontSize: '20px',
fontWeight: 600,
color: '#333',
margin: 0,
},
userInfo: {
display: 'flex',
alignItems: 'center',
gap: '12px',
},
userName: {
color: '#666',
},
badge: {
backgroundColor: '#0066cc',
color: 'white',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 500,
},
logoutButton: {
padding: '8px 16px',
backgroundColor: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
},
main: {
padding: '24px',
maxWidth: '1200px',
margin: '0 auto',
},
welcomeCard: {
backgroundColor: 'white',
padding: '24px',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
marginBottom: '24px',
},
adminNote: {
color: '#0066cc',
fontWeight: 500,
marginTop: '12px',
},
infoCard: {
backgroundColor: 'white',
padding: '24px',
borderRadius: '8px',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
},
}

View File

@@ -0,0 +1,151 @@
import { useState, FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login({ email, password })
navigate('/')
} catch (err: any) {
if (err.response?.status === 401) {
setError('Invalid email or password')
} else if (err.response?.status === 503) {
setError('Authentication service temporarily unavailable')
} else {
setError('An error occurred. Please try again.')
}
} finally {
setLoading(false)
}
}
return (
<div style={styles.container}>
<div style={styles.card}>
<h1 style={styles.title}>Project Control</h1>
<p style={styles.subtitle}>Sign in to your account</p>
<form onSubmit={handleSubmit} style={styles.form}>
{error && <div style={styles.error}>{error}</div>}
<div style={styles.field}>
<label htmlFor="email" style={styles.label}>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={styles.input}
placeholder="your.email@company.com"
required
/>
</div>
<div style={styles.field}>
<label htmlFor="password" style={styles.label}>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={styles.input}
placeholder="Enter your password"
required
/>
</div>
<button
type="submit"
style={styles.button}
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
const styles: { [key: string]: React.CSSProperties } = {
container: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: '#f5f5f5',
},
card: {
backgroundColor: 'white',
padding: '40px',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.1)',
width: '100%',
maxWidth: '400px',
},
title: {
textAlign: 'center',
marginBottom: '8px',
color: '#333',
},
subtitle: {
textAlign: 'center',
color: '#666',
marginBottom: '24px',
},
form: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
},
field: {
display: 'flex',
flexDirection: 'column',
gap: '4px',
},
label: {
fontSize: '14px',
fontWeight: 500,
color: '#333',
},
input: {
padding: '10px 12px',
fontSize: '16px',
border: '1px solid #ddd',
borderRadius: '4px',
outline: 'none',
},
button: {
padding: '12px',
fontSize: '16px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginTop: '8px',
},
error: {
backgroundColor: '#fee',
color: '#c00',
padding: '10px',
borderRadius: '4px',
fontSize: '14px',
},
}

View File

@@ -0,0 +1,68 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
// Add token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Handle 401 responses
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export interface LoginRequest {
email: string
password: string
}
export interface User {
id: string
email: string
name: string
role: string | null
department_id: string | null
is_system_admin: boolean
}
export interface LoginResponse {
access_token: string
token_type: string
user: User
}
export const authApi = {
login: async (data: LoginRequest): Promise<LoginResponse> => {
const response = await api.post<LoginResponse>('/auth/login', data)
return response.data
},
logout: async (): Promise<void> => {
await api.post('/auth/logout')
},
me: async (): Promise<User> => {
const response = await api.get<User>('/auth/me')
return response.data
},
}
export default api

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />