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

22
backend/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Database
MYSQL_HOST=mysql.theaken.com
MYSQL_PORT=33306
MYSQL_USER=A060
MYSQL_PASSWORD=your_password_here
MYSQL_DATABASE=db_A060
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
# JWT
JWT_SECRET_KEY=generate-a-random-secret-key-here
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=15
# External Auth API
AUTH_API_URL=https://pj-auth-api.vercel.app
# System Admin
SYSTEM_ADMIN_EMAIL=ymirliu@panjit.com.tw

45
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Environment
.env
*.env.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
.venv/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
# Logs
*.log

42
backend/alembic.ini Normal file
View File

@@ -0,0 +1,42 @@
[alembic]
script_location = migrations
prepend_sys_path = .
sqlalchemy.url = driver://user:pass@localhost/dbname
# Use project-specific version table to avoid conflicts with other projects
version_table = pjctrl_alembic_version
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,3 @@
from app.api.auth import router
__all__ = ["router"]

View File

@@ -0,0 +1,127 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import create_access_token, create_token_payload
from app.core.redis import get_redis
from app.models.user import User
from app.schemas.auth import LoginRequest, LoginResponse, UserInfo
from app.services.auth_client import (
verify_credentials,
AuthAPIError,
AuthAPIConnectionError,
)
from app.middleware.auth import get_current_user
router = APIRouter()
@router.post("/login", response_model=LoginResponse)
async def login(
request: LoginRequest,
db: Session = Depends(get_db),
redis_client=Depends(get_redis),
):
"""
Authenticate user via external API and return JWT token.
"""
try:
# Verify credentials with external API
auth_result = await verify_credentials(request.email, request.password)
except AuthAPIConnectionError:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication service temporarily unavailable",
)
except AuthAPIError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
# Find or create user in local database
user = db.query(User).filter(User.email == request.email).first()
if not user:
# Create new user based on auth API response
user = User(
email=request.email,
name=auth_result.get("name", request.email.split("@")[0]),
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled",
)
# Get role name
role_name = user.role.name if user.role else None
# Create token payload
token_data = create_token_payload(
user_id=user.id,
email=user.email,
role=role_name,
department_id=user.department_id,
is_system_admin=user.is_system_admin,
)
# Create access token
access_token = create_access_token(token_data)
# Store session in Redis
redis_client.setex(
f"session:{user.id}",
900, # 15 minutes
access_token,
)
return LoginResponse(
access_token=access_token,
user=UserInfo(
id=user.id,
email=user.email,
name=user.name,
role=role_name,
department_id=user.department_id,
is_system_admin=user.is_system_admin,
),
)
@router.post("/logout")
async def logout(
current_user: User = Depends(get_current_user),
redis_client=Depends(get_redis),
):
"""
Logout user and invalidate session.
"""
# Remove session from Redis
redis_client.delete(f"session:{current_user.id}")
return {"message": "Successfully logged out"}
@router.get("/me", response_model=UserInfo)
async def get_current_user_info(
current_user: User = Depends(get_current_user),
):
"""
Get current authenticated user information.
"""
role_name = current_user.role.name if current_user.role else None
return UserInfo(
id=current_user.id,
email=current_user.email,
name=current_user.name,
role=role_name,
department_id=current_user.department_id,
is_system_admin=current_user.is_system_admin,
)

View File

@@ -0,0 +1,3 @@
from app.api.departments import router
__all__ = ["router"]

View File

@@ -0,0 +1,152 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.core.database import get_db
from app.models.department import Department
from app.models.user import User
from app.schemas.department import DepartmentCreate, DepartmentUpdate, DepartmentResponse
from app.middleware.auth import require_permission, require_system_admin
router = APIRouter()
@router.get("", response_model=List[DepartmentResponse])
async def list_departments(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.read")),
):
"""
List all departments.
"""
departments = db.query(Department).offset(skip).limit(limit).all()
return departments
@router.get("/{department_id}", response_model=DepartmentResponse)
async def get_department(
department_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.read")),
):
"""
Get a specific department by ID.
"""
department = db.query(Department).filter(Department.id == department_id).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Department not found",
)
return department
@router.post("", response_model=DepartmentResponse, status_code=status.HTTP_201_CREATED)
async def create_department(
department_data: DepartmentCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_system_admin),
):
"""
Create a new department. Requires system admin.
"""
# Check if parent exists if specified
if department_data.parent_id:
parent = db.query(Department).filter(
Department.id == department_data.parent_id
).first()
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent department not found",
)
department = Department(**department_data.model_dump())
db.add(department)
db.commit()
db.refresh(department)
return department
@router.patch("/{department_id}", response_model=DepartmentResponse)
async def update_department(
department_id: str,
department_update: DepartmentUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_system_admin),
):
"""
Update a department. Requires system admin.
"""
department = db.query(Department).filter(Department.id == department_id).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Department not found",
)
# Check if new parent exists if specified
update_data = department_update.model_dump(exclude_unset=True)
if "parent_id" in update_data and update_data["parent_id"]:
# Prevent circular reference
if update_data["parent_id"] == department_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Department cannot be its own parent",
)
parent = db.query(Department).filter(
Department.id == update_data["parent_id"]
).first()
if not parent:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent department not found",
)
for field, value in update_data.items():
setattr(department, field, value)
db.commit()
db.refresh(department)
return department
@router.delete("/{department_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_department(
department_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_system_admin),
):
"""
Delete a department. Requires system admin.
"""
department = db.query(Department).filter(Department.id == department_id).first()
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Department not found",
)
# Check if department has users
user_count = db.query(User).filter(User.department_id == department_id).count()
if user_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete department with {user_count} users",
)
# Check if department has children
child_count = db.query(Department).filter(
Department.parent_id == department_id
).count()
if child_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete department with {child_count} child departments",
)
db.delete(department)
db.commit()

View File

@@ -0,0 +1,3 @@
from app.api.users import router
__all__ = ["router"]

View File

@@ -0,0 +1,148 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.core.database import get_db
from app.models.user import User
from app.models.role import Role
from app.schemas.user import UserResponse, UserUpdate
from app.middleware.auth import (
get_current_user,
require_permission,
require_system_admin,
check_department_access,
)
router = APIRouter()
@router.get("", response_model=List[UserResponse])
async def list_users(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.read")),
):
"""
List all users. Filtered by department if not system admin.
"""
query = db.query(User)
# Filter by department if not system admin
if not current_user.is_system_admin and current_user.department_id:
query = query.filter(User.department_id == current_user.department_id)
users = query.offset(skip).limit(limit).all()
return users
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.read")),
):
"""
Get a specific user by ID.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Check department access
if not check_department_access(current_user, user.department_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this user",
)
return user
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: str,
user_update: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.write")),
):
"""
Update user information.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Check department access
if not check_department_access(current_user, user.department_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this user",
)
# Prevent modification of system admin properties by non-system-admins
if user.is_system_admin and not current_user.is_system_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify system administrator",
)
# Update fields
update_data = user_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
db.commit()
db.refresh(user)
return user
@router.patch("/{user_id}/role", response_model=UserResponse)
async def assign_role(
user_id: str,
role_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_system_admin),
):
"""
Assign a role to a user. Requires system admin.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Prevent modification of system admin
if user.is_system_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify system administrator role",
)
# Verify role exists
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Role not found",
)
# Prevent assigning system role to non-system-admin
if role.is_system_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot assign system role",
)
user.role_id = role_id
db.commit()
db.refresh(user)
return user

View File

View File

@@ -0,0 +1,46 @@
from pydantic_settings import BaseSettings
from typing import List
import os
class Settings(BaseSettings):
# Database
MYSQL_HOST: str = "localhost"
MYSQL_PORT: int = 3306
MYSQL_USER: str = "root"
MYSQL_PASSWORD: str = ""
MYSQL_DATABASE: str = "pjctrl"
@property
def DATABASE_URL(self) -> str:
return f"mysql+pymysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}"
# Redis
REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379
REDIS_DB: int = 0
@property
def REDIS_URL(self) -> str:
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
# JWT
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 15
# External Auth API
AUTH_API_URL: str = "https://pj-auth-api.vercel.app"
# CORS
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
# System Admin
SYSTEM_ADMIN_EMAIL: str = "ymirliu@panjit.com.tw"
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

View File

@@ -0,0 +1,24 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Dependency for getting database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()

14
backend/app/core/redis.py Normal file
View File

@@ -0,0 +1,14 @@
import redis
from app.core.config import settings
redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
decode_responses=True,
)
def get_redis():
"""Dependency for getting Redis client."""
return redis_client

View File

@@ -0,0 +1,82 @@
from datetime import datetime, timedelta
from typing import Optional, Any
from jose import jwt, JWTError
from app.core.config import settings
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
Args:
data: Data to encode in the token
expires_delta: Optional custom expiration time
Returns:
Encoded JWT token string
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "iat": datetime.utcnow()})
encoded_jwt = jwt.encode(
to_encode,
settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM
)
return encoded_jwt
def decode_access_token(token: str) -> Optional[dict]:
"""
Decode and verify a JWT access token.
Args:
token: The JWT token to decode
Returns:
Decoded token payload if valid, None if invalid or expired
"""
try:
payload = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM]
)
return payload
except JWTError:
return None
def create_token_payload(
user_id: str,
email: str,
role: str,
department_id: Optional[str],
is_system_admin: bool
) -> dict:
"""
Create a standardized token payload.
Args:
user_id: User's unique ID
email: User's email
role: User's role name
department_id: User's department ID (can be None)
is_system_admin: Whether user is a system admin
Returns:
dict: Token payload
"""
return {
"sub": user_id,
"email": email,
"role": role,
"department_id": department_id,
"is_system_admin": is_system_admin,
}

32
backend/app/main.py Normal file
View File

@@ -0,0 +1,32 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.auth import router as auth_router
from app.api.users import router as users_router
from app.api.departments import router as departments_router
from app.core.config import settings
app = FastAPI(
title="Project Control API",
description="Cross-departmental project management system API",
version="0.1.0",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth_router.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(users_router.router, prefix="/api/users", tags=["Users"])
app.include_router(departments_router.router, prefix="/api/departments", tags=["Departments"])
@app.get("/health")
async def health_check():
return {"status": "healthy"}

View File

View File

@@ -0,0 +1,169 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.core.security import decode_access_token
from app.core.redis import get_redis
from app.models.user import User
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
redis_client=Depends(get_redis),
) -> User:
"""
Dependency to get the current authenticated user.
Validates the JWT token and checks session in Redis.
"""
token = credentials.credentials
# Decode and verify token
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
# Check session in Redis
stored_token = redis_client.get(f"session:{user_id}")
if stored_token is None or stored_token != token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired or invalid",
headers={"WWW-Authenticate": "Bearer"},
)
# Get user from database
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled",
)
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Dependency to ensure user is active.
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user",
)
return current_user
def require_system_admin(
current_user: User = Depends(get_current_user),
) -> User:
"""
Dependency to require system admin privileges.
"""
if not current_user.is_system_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="System admin privileges required",
)
return current_user
def require_permission(permission: str):
"""
Decorator factory to require specific permission.
Usage:
@router.get("/protected")
async def protected_route(user: User = Depends(require_permission("users.read"))):
...
"""
def permission_checker(current_user: User = Depends(get_current_user)) -> User:
# System admin has all permissions
if current_user.is_system_admin:
return current_user
# Check role permissions
if current_user.role is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No role assigned",
)
permissions = current_user.role.permissions or {}
# Check for "all" permission
if permissions.get("all"):
return current_user
# Check specific permission
if not permissions.get(permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission '{permission}' required",
)
return current_user
return permission_checker
def check_department_access(
user: User,
resource_department_id: Optional[str],
resource_security_level: str = "department",
) -> bool:
"""
Check if user has access to a resource based on department isolation.
Args:
user: The current user
resource_department_id: Department ID of the resource
resource_security_level: Security level ('public', 'department', 'confidential')
Returns:
bool: True if user has access, False otherwise
"""
# System admin bypasses department isolation
if user.is_system_admin:
return True
# Public resources are accessible to all
if resource_security_level == "public":
return True
# No department specified on resource means accessible to all
if resource_department_id is None:
return True
# User must be in the same department
if user.department_id == resource_department_id:
return True
return False

View File

@@ -0,0 +1,5 @@
from app.models.user import User
from app.models.role import Role
from app.models.department import Department
__all__ = ["User", "Role", "Department"]

View File

@@ -0,0 +1,17 @@
import uuid
from sqlalchemy import Column, String, ForeignKey, DateTime
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
class Department(Base):
__tablename__ = "pjctrl_departments"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(100), nullable=False)
parent_id = Column(String(36), ForeignKey("pjctrl_departments.id"), nullable=True)
created_at = Column(DateTime, server_default=func.now())
# Self-referential relationship
parent = relationship("Department", remote_side=[id], backref="children")

View File

@@ -0,0 +1,14 @@
import uuid
from sqlalchemy import Column, String, JSON, Boolean, DateTime
from sqlalchemy.sql import func
from app.core.database import Base
class Role(Base):
__tablename__ = "pjctrl_roles"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(50), unique=True, nullable=False)
permissions = Column(JSON, nullable=False, default=dict)
is_system_role = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())

View File

@@ -0,0 +1,25 @@
import uuid
from sqlalchemy import Column, String, ForeignKey, JSON, Boolean, DateTime, Numeric
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
class User(Base):
__tablename__ = "pjctrl_users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
email = Column(String(200), unique=True, nullable=False, index=True)
name = Column(String(200), nullable=False)
department_id = Column(String(36), ForeignKey("pjctrl_departments.id"), nullable=True)
role_id = Column(String(36), ForeignKey("pjctrl_roles.id"), nullable=True)
skills = Column(JSON, nullable=True)
capacity = Column(Numeric(5, 2), default=40.00)
is_active = Column(Boolean, default=True)
is_system_admin = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
department = relationship("Department", backref="users")
role = relationship("Role", backref="users")

View File

@@ -0,0 +1,18 @@
from app.schemas.auth import LoginRequest, LoginResponse, TokenPayload
from app.schemas.user import UserCreate, UserUpdate, UserResponse, UserInDB
from app.schemas.department import DepartmentCreate, DepartmentUpdate, DepartmentResponse
from app.schemas.role import RoleResponse
__all__ = [
"LoginRequest",
"LoginResponse",
"TokenPayload",
"UserCreate",
"UserUpdate",
"UserResponse",
"UserInDB",
"DepartmentCreate",
"DepartmentUpdate",
"DepartmentResponse",
"RoleResponse",
]

View File

@@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import Optional
class LoginRequest(BaseModel):
email: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: "UserInfo"
class UserInfo(BaseModel):
id: str
email: str
name: str
role: Optional[str] = None
department_id: Optional[str] = None
is_system_admin: bool = False
class TokenPayload(BaseModel):
sub: str
email: str
role: Optional[str] = None
department_id: Optional[str] = None
is_system_admin: bool = False
exp: int
iat: int
# Update forward reference
LoginResponse.model_rebuild()

View File

@@ -0,0 +1,25 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class DepartmentBase(BaseModel):
name: str
parent_id: Optional[str] = None
class DepartmentCreate(DepartmentBase):
pass
class DepartmentUpdate(BaseModel):
name: Optional[str] = None
parent_id: Optional[str] = None
class DepartmentResponse(DepartmentBase):
id: str
created_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,14 @@
from pydantic import BaseModel
from typing import Dict, Any
from datetime import datetime
class RoleResponse(BaseModel):
id: str
name: str
permissions: Dict[str, Any]
is_system_role: bool
created_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,41 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
class UserBase(BaseModel):
email: str
name: str
department_id: Optional[str] = None
role_id: Optional[str] = None
skills: Optional[List[str]] = None
capacity: Optional[Decimal] = Decimal("40.00")
class UserCreate(UserBase):
pass
class UserUpdate(BaseModel):
name: Optional[str] = None
department_id: Optional[str] = None
role_id: Optional[str] = None
skills: Optional[List[str]] = None
capacity: Optional[Decimal] = None
is_active: Optional[bool] = None
class UserResponse(UserBase):
id: str
is_active: bool
is_system_admin: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class UserInDB(UserResponse):
pass

View File

View File

@@ -0,0 +1,77 @@
import httpx
from typing import Optional
from app.core.config import settings
class AuthAPIError(Exception):
"""Exception raised when external auth API returns an error."""
def __init__(self, message: str, status_code: int = 400):
self.message = message
self.status_code = status_code
super().__init__(self.message)
class AuthAPIConnectionError(Exception):
"""Exception raised when unable to connect to auth API."""
pass
async def verify_credentials(email: str, password: str) -> dict:
"""
Verify user credentials against the external auth API.
Args:
email: User's email address
password: User's password
Returns:
dict: User info from auth API if successful
Raises:
AuthAPIError: If credentials are invalid
AuthAPIConnectionError: If unable to connect to auth API
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.AUTH_API_URL}/api/auth/login",
json={"email": email, "password": password},
)
if response.status_code == 200:
return response.json()
elif response.status_code == 401:
raise AuthAPIError("Invalid credentials", 401)
else:
raise AuthAPIError(
f"Authentication failed: {response.text}",
response.status_code
)
except httpx.ConnectError:
raise AuthAPIConnectionError("Unable to connect to authentication service")
except httpx.TimeoutException:
raise AuthAPIConnectionError("Authentication service timeout")
async def validate_token(token: str) -> Optional[dict]:
"""
Validate a token with the external auth API.
Args:
token: The auth token to validate
Returns:
dict: Token payload if valid, None if invalid
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{settings.AUTH_API_URL}/api/auth/validate",
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code == 200:
return response.json()
return None
except (httpx.ConnectError, httpx.TimeoutException):
return None

24
backend/environment.yml Normal file
View File

@@ -0,0 +1,24 @@
name: pjctrl
channels:
- defaults
- conda-forge
dependencies:
- python=3.11
- pip
- pip:
- fastapi==0.109.0
- uvicorn[standard]==0.27.0
- sqlalchemy==2.0.25
- pymysql==1.1.0
- cryptography==42.0.0
- alembic==1.13.1
- redis==5.0.1
- python-jose[cryptography]==3.3.0
- passlib[bcrypt]==1.7.4
- python-dotenv==1.0.0
- httpx==0.26.0
- pydantic==2.5.3
- pydantic-settings==2.1.0
- pytest==7.4.4
- pytest-asyncio==0.23.3
- pytest-cov==4.1.0

67
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,67 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import sys
import os
# Add the backend directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core.config import settings
from app.core.database import Base
from app.models import User, Role, Department
config = context.config
# Override sqlalchemy.url with our settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
# Project-specific version table to avoid conflicts with other projects
VERSION_TABLE = "pjctrl_alembic_version"
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
version_table=VERSION_TABLE,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table=VERSION_TABLE,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,85 @@
"""Initial auth tables
Revision ID: 001
Revises:
Create Date: 2024-01-01
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create pjctrl_roles table
op.create_table(
'pjctrl_roles',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('name', sa.String(50), unique=True, nullable=False),
sa.Column('permissions', sa.JSON, nullable=False),
sa.Column('is_system_role', sa.Boolean, default=False),
sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
)
# Create pjctrl_departments table
op.create_table(
'pjctrl_departments',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('parent_id', sa.String(36), sa.ForeignKey('pjctrl_departments.id'), nullable=True),
sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
)
# Create pjctrl_users table
op.create_table(
'pjctrl_users',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('email', sa.String(200), unique=True, nullable=False, index=True),
sa.Column('name', sa.String(200), nullable=False),
sa.Column('department_id', sa.String(36), sa.ForeignKey('pjctrl_departments.id'), nullable=True),
sa.Column('role_id', sa.String(36), sa.ForeignKey('pjctrl_roles.id'), nullable=True),
sa.Column('skills', sa.JSON, nullable=True),
sa.Column('capacity', sa.Numeric(5, 2), default=40.00),
sa.Column('is_active', sa.Boolean, default=True),
sa.Column('is_system_admin', sa.Boolean, default=False),
sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), onupdate=sa.func.now()),
)
# Insert default super_admin role
op.execute("""
INSERT INTO pjctrl_roles (id, name, permissions, is_system_role)
VALUES ('00000000-0000-0000-0000-000000000001', 'super_admin', '{"all": true}', true)
""")
# Insert default system administrator
op.execute("""
INSERT INTO pjctrl_users (id, email, name, role_id, is_active, is_system_admin)
VALUES (
'00000000-0000-0000-0000-000000000001',
'ymirliu@panjit.com.tw',
'System Administrator',
'00000000-0000-0000-0000-000000000001',
true,
true
)
""")
# Insert default roles
op.execute("""
INSERT INTO pjctrl_roles (id, name, permissions, is_system_role) VALUES
('00000000-0000-0000-0000-000000000002', 'manager', '{"users.read": true, "users.write": true, "projects.read": true, "projects.write": true, "tasks.read": true, "tasks.write": true}', false),
('00000000-0000-0000-0000-000000000003', 'engineer', '{"projects.read": true, "tasks.read": true, "tasks.write": true}', false),
('00000000-0000-0000-0000-000000000004', 'pmo', '{"projects.read": true, "projects.write": true, "tasks.read": true, "reports.read": true}', false)
""")
def downgrade() -> None:
op.drop_table('pjctrl_users')
op.drop_table('pjctrl_departments')
op.drop_table('pjctrl_roles')

6
backend/pytest.ini Normal file
View File

@@ -0,0 +1,6 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto

16
backend/requirements.txt Normal file
View File

@@ -0,0 +1,16 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
pymysql==1.1.0
cryptography==42.0.0
alembic==1.13.1
redis==5.0.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.0
httpx==0.26.0
pydantic==2.5.3
pydantic-settings==2.1.0
pytest==7.4.4
pytest-asyncio==0.23.3
pytest-cov==4.1.0

View File

128
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,128 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.core.database import Base, get_db
from app.core.redis import get_redis
from app.models import User, Role, Department
# Use in-memory SQLite for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class MockRedis:
"""Mock Redis client for testing."""
def __init__(self):
self.store = {}
def get(self, key):
return self.store.get(key)
def setex(self, key, seconds, value):
self.store[key] = value
def delete(self, key):
if key in self.store:
del self.store[key]
@pytest.fixture(scope="function")
def db():
"""Create a fresh database for each test."""
Base.metadata.create_all(bind=engine)
db = TestingSessionLocal()
# Create default role
admin_role = Role(
id="00000000-0000-0000-0000-000000000001",
name="super_admin",
permissions={"all": True},
is_system_role=True,
)
db.add(admin_role)
engineer_role = Role(
id="00000000-0000-0000-0000-000000000003",
name="engineer",
permissions={"projects.read": True, "tasks.read": True, "tasks.write": True},
is_system_role=False,
)
db.add(engineer_role)
# Create system admin user
admin_user = User(
id="00000000-0000-0000-0000-000000000001",
email="ymirliu@panjit.com.tw",
name="System Administrator",
role_id="00000000-0000-0000-0000-000000000001",
is_active=True,
is_system_admin=True,
)
db.add(admin_user)
db.commit()
try:
yield db
finally:
db.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def mock_redis():
"""Create mock Redis for testing."""
return MockRedis()
@pytest.fixture(scope="function")
def client(db, mock_redis):
"""Create test client with overridden dependencies."""
def override_get_db():
try:
yield db
finally:
pass
def override_get_redis():
return mock_redis
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_redis] = override_get_redis
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def admin_token(client, mock_redis):
"""Get an admin token for testing."""
from app.core.security import create_access_token, create_token_payload
token_data = create_token_payload(
user_id="00000000-0000-0000-0000-000000000001",
email="ymirliu@panjit.com.tw",
role="super_admin",
department_id=None,
is_system_admin=True,
)
token = create_access_token(token_data)
# Store in mock Redis
mock_redis.setex("session:00000000-0000-0000-0000-000000000001", 900, token)
return token

View File

@@ -0,0 +1,84 @@
import pytest
from app.core.security import create_access_token, decode_access_token, create_token_payload
class TestJWT:
"""Test JWT token creation and validation."""
def test_create_access_token(self):
"""Test creating an access token."""
data = {"sub": "user123", "email": "test@example.com"}
token = create_access_token(data)
assert token is not None
assert isinstance(token, str)
def test_decode_valid_token(self):
"""Test decoding a valid token."""
data = create_token_payload(
user_id="user123",
email="test@example.com",
role="engineer",
department_id="dept123",
is_system_admin=False,
)
token = create_access_token(data)
payload = decode_access_token(token)
assert payload is not None
assert payload["sub"] == "user123"
assert payload["email"] == "test@example.com"
assert payload["role"] == "engineer"
assert payload["is_system_admin"] is False
def test_decode_invalid_token(self):
"""Test decoding an invalid token."""
payload = decode_access_token("invalid.token.here")
assert payload is None
def test_token_payload_structure(self):
"""Test token payload has correct structure."""
payload = create_token_payload(
user_id="user123",
email="test@example.com",
role="engineer",
department_id="dept123",
is_system_admin=False,
)
assert "sub" in payload
assert "email" in payload
assert "role" in payload
assert "department_id" in payload
assert "is_system_admin" in payload
class TestAuthEndpoints:
"""Test authentication API endpoints."""
def test_get_me_without_auth(self, client):
"""Test accessing /me without authentication."""
response = client.get("/api/auth/me")
assert response.status_code == 403
def test_get_me_with_auth(self, client, admin_token):
"""Test accessing /me with valid authentication."""
response = client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["email"] == "ymirliu@panjit.com.tw"
assert data["is_system_admin"] is True
def test_logout(self, client, admin_token, mock_redis):
"""Test logout endpoint."""
response = client.post(
"/api/auth/logout",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
# Verify session is removed
assert mock_redis.get("session:00000000-0000-0000-0000-000000000001") is None

159
backend/tests/test_users.py Normal file
View File

@@ -0,0 +1,159 @@
import pytest
from app.models.user import User
from app.models.department import Department
class TestUserEndpoints:
"""Test user management API endpoints."""
def test_list_users_as_admin(self, client, admin_token):
"""Test listing users as admin."""
response = client.get(
"/api/users",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1 # At least admin user exists
def test_get_user_by_id(self, client, admin_token):
"""Test getting a specific user."""
response = client.get(
"/api/users/00000000-0000-0000-0000-000000000001",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["email"] == "ymirliu@panjit.com.tw"
def test_get_nonexistent_user(self, client, admin_token):
"""Test getting a user that doesn't exist."""
response = client.get(
"/api/users/nonexistent-id",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 404
def test_update_user(self, client, admin_token, db):
"""Test updating a user."""
# Create a test user
test_user = User(
id="test-user-001",
email="test@example.com",
name="Test User",
is_active=True,
)
db.add(test_user)
db.commit()
response = client.patch(
"/api/users/test-user-001",
headers={"Authorization": f"Bearer {admin_token}"},
json={"name": "Updated Name"},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Name"
def test_cannot_modify_system_admin_as_non_admin(self, client, db, mock_redis):
"""Test that non-admin cannot modify system admin."""
from app.core.security import create_access_token, create_token_payload
# Create a non-admin user
non_admin = User(
id="non-admin-001",
email="nonadmin@example.com",
name="Non Admin",
role_id="00000000-0000-0000-0000-000000000003", # engineer role
is_active=True,
is_system_admin=False,
)
db.add(non_admin)
db.commit()
# Create token for non-admin
token_data = create_token_payload(
user_id="non-admin-001",
email="nonadmin@example.com",
role="engineer",
department_id=None,
is_system_admin=False,
)
token = create_access_token(token_data)
mock_redis.setex("session:non-admin-001", 900, token)
# Try to modify system admin - should fail with 403
response = client.patch(
"/api/users/00000000-0000-0000-0000-000000000001",
headers={"Authorization": f"Bearer {token}"},
json={"name": "Hacked Name"},
)
# Engineer role doesn't have users.write permission
assert response.status_code == 403
class TestDepartmentIsolation:
"""Test department-based access control."""
def test_department_isolation(self, client, db, mock_redis):
"""Test that users can only see users in their department."""
from app.core.security import create_access_token, create_token_payload
# Create departments
dept_a = Department(id="dept-a", name="Department A")
dept_b = Department(id="dept-b", name="Department B")
db.add_all([dept_a, dept_b])
# Create manager role
from app.models.role import Role
manager_role = Role(
id="manager-role",
name="manager",
permissions={"users.read": True, "users.write": True},
)
db.add(manager_role)
# Create users in different departments
user_a = User(
id="user-a",
email="usera@example.com",
name="User A",
department_id="dept-a",
role_id="manager-role",
is_active=True,
)
user_b = User(
id="user-b",
email="userb@example.com",
name="User B",
department_id="dept-b",
role_id="manager-role",
is_active=True,
)
db.add_all([user_a, user_b])
db.commit()
# Create token for user A
token_data = create_token_payload(
user_id="user-a",
email="usera@example.com",
role="manager",
department_id="dept-a",
is_system_admin=False,
)
token = create_access_token(token_data)
mock_redis.setex("session:user-a", 900, token)
# User A should only see users in dept-a
response = client.get(
"/api/users",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
data = response.json()
# Should only contain user A (filtered by department)
emails = [u["email"] for u in data]
assert "usera@example.com" in emails
assert "userb@example.com" not in emails