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:
23
.claude/commands/openspec/apply.md
Normal file
23
.claude/commands/openspec/apply.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: OpenSpec: Apply
|
||||||
|
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||||
|
category: OpenSpec
|
||||||
|
tags: [openspec, apply]
|
||||||
|
---
|
||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
**Guardrails**
|
||||||
|
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||||
|
- Keep changes tightly scoped to the requested outcome.
|
||||||
|
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
Track these steps as TODOs and complete them one by one.
|
||||||
|
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
|
||||||
|
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
|
||||||
|
3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished.
|
||||||
|
4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality.
|
||||||
|
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
|
||||||
|
|
||||||
|
**Reference**
|
||||||
|
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
27
.claude/commands/openspec/archive.md
Normal file
27
.claude/commands/openspec/archive.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: OpenSpec: Archive
|
||||||
|
description: Archive a deployed OpenSpec change and update specs.
|
||||||
|
category: OpenSpec
|
||||||
|
tags: [openspec, archive]
|
||||||
|
---
|
||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
**Guardrails**
|
||||||
|
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||||
|
- Keep changes tightly scoped to the requested outcome.
|
||||||
|
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. Determine the change ID to archive:
|
||||||
|
- If this prompt already includes a specific change ID (for example inside a `<ChangeId>` block populated by slash-command arguments), use that value after trimming whitespace.
|
||||||
|
- If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
|
||||||
|
- Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding.
|
||||||
|
- If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet.
|
||||||
|
2. Validate the change ID by running `openspec list` (or `openspec show <id>`) and stop if the change is missing, already archived, or otherwise not ready to archive.
|
||||||
|
3. Run `openspec archive <id> --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work).
|
||||||
|
4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`.
|
||||||
|
5. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
|
||||||
|
|
||||||
|
**Reference**
|
||||||
|
- Use `openspec list` to confirm change IDs before archiving.
|
||||||
|
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
28
.claude/commands/openspec/proposal.md
Normal file
28
.claude/commands/openspec/proposal.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: OpenSpec: Proposal
|
||||||
|
description: Scaffold a new OpenSpec change and validate strictly.
|
||||||
|
category: OpenSpec
|
||||||
|
tags: [openspec, change]
|
||||||
|
---
|
||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
**Guardrails**
|
||||||
|
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||||
|
- Keep changes tightly scoped to the requested outcome.
|
||||||
|
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||||
|
- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
|
||||||
|
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
|
||||||
|
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes/<id>/`.
|
||||||
|
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
|
||||||
|
4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
|
||||||
|
5. Draft spec deltas in `changes/<id>/specs/<capability>/spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant.
|
||||||
|
6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
|
||||||
|
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
|
||||||
|
|
||||||
|
**Reference**
|
||||||
|
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
|
||||||
|
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
|
||||||
|
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
18
AGENTS.md
Normal file
18
AGENTS.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
# OpenSpec Instructions
|
||||||
|
|
||||||
|
These instructions are for AI assistants working in this project.
|
||||||
|
|
||||||
|
Always open `@/openspec/AGENTS.md` when the request:
|
||||||
|
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||||
|
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||||
|
- Sounds ambiguous and you need the authoritative spec before coding
|
||||||
|
|
||||||
|
Use `@/openspec/AGENTS.md` to learn:
|
||||||
|
- How to create and apply change proposals
|
||||||
|
- Spec format and conventions
|
||||||
|
- Project structure and guidelines
|
||||||
|
|
||||||
|
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||||
|
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
18
CLAUDE.md
Normal file
18
CLAUDE.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- OPENSPEC:START -->
|
||||||
|
# OpenSpec Instructions
|
||||||
|
|
||||||
|
These instructions are for AI assistants working in this project.
|
||||||
|
|
||||||
|
Always open `@/openspec/AGENTS.md` when the request:
|
||||||
|
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||||
|
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||||
|
- Sounds ambiguous and you need the authoritative spec before coding
|
||||||
|
|
||||||
|
Use `@/openspec/AGENTS.md` to learn:
|
||||||
|
- How to create and apply change proposals
|
||||||
|
- Spec format and conventions
|
||||||
|
- Project structure and guidelines
|
||||||
|
|
||||||
|
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||||
|
|
||||||
|
<!-- OPENSPEC:END -->
|
||||||
29
Technical Specifications.txt
Normal file
29
Technical Specifications.txt
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
1. 系統架構 (System Architecture)
|
||||||
|
採用三層式架構 (3-Tier Architecture) 以確保未來擴展性(如未來可對接 MES/ERP 數據)。
|
||||||
|
|
||||||
|
前端 (Presentation Layer): React.js 或 Vue.js (推薦 React,其生態系在處理複雜管理後台較成熟)。
|
||||||
|
|
||||||
|
後端 (Application Layer): Python (FastAPI) ;Python 方便未來導入 AI 風險預測與數據分析。
|
||||||
|
|
||||||
|
數據層 (Data Layer): * 主資料庫: mysql (關聯式結構最適合處理專案間的複雜層級與相依性)。
|
||||||
|
|
||||||
|
快取與即時通訊: Redis (用於即時通知推播與任務狀態鎖定)。
|
||||||
|
|
||||||
|
2. 核心數據模型設計 (ERD Concept)
|
||||||
|
User: ID, Name, Department, Role, Skills, Capacity.
|
||||||
|
|
||||||
|
Project: ID, Title, Owner, Budget, Timeline, Security_Level.
|
||||||
|
|
||||||
|
Task: ID, Project_ID, Assignee, Priority, Status, Original_Estimate, Time_Spent, Blocker_Flag.
|
||||||
|
|
||||||
|
Attachment: ID, Task_ID, Version, File_Path (支援地端 NAS 存儲)。
|
||||||
|
|
||||||
|
3. 關鍵技術功能設計
|
||||||
|
即時同步 (Real-time Sync): 使用 WebSocket,當多人同時編輯同一個專案看板時,狀態能即時更新而不需刷新頁面。
|
||||||
|
|
||||||
|
文件加密: 針對半導體敏感圖檔,存儲層進行 AES-256 加密,且下載時自動加上使用者浮水印。
|
||||||
|
|
||||||
|
整合介面 (Integrations):
|
||||||
|
|
||||||
|
SSO: 整合企業內部 Windows AD。(使用https://pj-auth-api.vercel.app)
|
||||||
|
|
||||||
22
backend/.env.example
Normal file
22
backend/.env.example
Normal 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
45
backend/.gitignore
vendored
Normal 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
42
backend/alembic.ini
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
3
backend/app/api/auth/__init__.py
Normal file
3
backend/app/api/auth/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.api.auth import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
127
backend/app/api/auth/router.py
Normal file
127
backend/app/api/auth/router.py
Normal 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,
|
||||||
|
)
|
||||||
3
backend/app/api/departments/__init__.py
Normal file
3
backend/app/api/departments/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.api.departments import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
152
backend/app/api/departments/router.py
Normal file
152
backend/app/api/departments/router.py
Normal 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()
|
||||||
3
backend/app/api/users/__init__.py
Normal file
3
backend/app/api/users/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.api.users import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
148
backend/app/api/users/router.py
Normal file
148
backend/app/api/users/router.py
Normal 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
|
||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
46
backend/app/core/config.py
Normal file
46
backend/app/core/config.py
Normal 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()
|
||||||
24
backend/app/core/database.py
Normal file
24
backend/app/core/database.py
Normal 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
14
backend/app/core/redis.py
Normal 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
|
||||||
82
backend/app/core/security.py
Normal file
82
backend/app/core/security.py
Normal 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
32
backend/app/main.py
Normal 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"}
|
||||||
0
backend/app/middleware/__init__.py
Normal file
0
backend/app/middleware/__init__.py
Normal file
169
backend/app/middleware/auth.py
Normal file
169
backend/app/middleware/auth.py
Normal 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
|
||||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal 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"]
|
||||||
17
backend/app/models/department.py
Normal file
17
backend/app/models/department.py
Normal 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")
|
||||||
14
backend/app/models/role.py
Normal file
14
backend/app/models/role.py
Normal 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())
|
||||||
25
backend/app/models/user.py
Normal file
25
backend/app/models/user.py
Normal 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")
|
||||||
18
backend/app/schemas/__init__.py
Normal file
18
backend/app/schemas/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
36
backend/app/schemas/auth.py
Normal file
36
backend/app/schemas/auth.py
Normal 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()
|
||||||
25
backend/app/schemas/department.py
Normal file
25
backend/app/schemas/department.py
Normal 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
|
||||||
14
backend/app/schemas/role.py
Normal file
14
backend/app/schemas/role.py
Normal 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
|
||||||
41
backend/app/schemas/user.py
Normal file
41
backend/app/schemas/user.py
Normal 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
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
77
backend/app/services/auth_client.py
Normal file
77
backend/app/services/auth_client.py
Normal 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
24
backend/environment.yml
Normal 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
67
backend/migrations/env.py
Normal 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()
|
||||||
26
backend/migrations/script.py.mako
Normal file
26
backend/migrations/script.py.mako
Normal 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"}
|
||||||
85
backend/migrations/versions/001_initial_auth_tables.py
Normal file
85
backend/migrations/versions/001_initial_auth_tables.py
Normal 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
6
backend/pytest.ini
Normal 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
16
backend/requirements.txt
Normal 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
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
128
backend/tests/conftest.py
Normal file
128
backend/tests/conftest.py
Normal 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
|
||||||
84
backend/tests/test_auth.py
Normal file
84
backend/tests/test_auth.py
Normal 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
159
backend/tests/test_users.py
Normal 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
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache/
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Project Control</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2009
frontend/package-lock.json
generated
Normal file
2009
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "pjctrl-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.0",
|
||||||
|
"axios": "^1.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
frontend/src/App.tsx
Normal file
32
frontend/src/App.tsx
Normal 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
|
||||||
21
frontend/src/components/ProtectedRoute.tsx
Normal file
21
frontend/src/components/ProtectedRoute.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
74
frontend/src/contexts/AuthContext.tsx
Normal file
74
frontend/src/contexts/AuthContext.tsx
Normal 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
18
frontend/src/index.css
Normal 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
16
frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
115
frontend/src/pages/Dashboard.tsx
Normal file
115
frontend/src/pages/Dashboard.tsx
Normal 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)',
|
||||||
|
},
|
||||||
|
}
|
||||||
151
frontend/src/pages/Login.tsx
Normal file
151
frontend/src/pages/Login.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
68
frontend/src/services/api.ts
Normal file
68
frontend/src/services/api.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
456
openspec/AGENTS.md
Normal file
456
openspec/AGENTS.md
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
# OpenSpec Instructions
|
||||||
|
|
||||||
|
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||||
|
|
||||||
|
## TL;DR Quick Checklist
|
||||||
|
|
||||||
|
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
|
||||||
|
- Decide scope: new capability vs modify existing capability
|
||||||
|
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
|
||||||
|
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
|
||||||
|
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
|
||||||
|
- Validate: `openspec validate [change-id] --strict` and fix issues
|
||||||
|
- Request approval: Do not start implementation until proposal is approved
|
||||||
|
|
||||||
|
## Three-Stage Workflow
|
||||||
|
|
||||||
|
### Stage 1: Creating Changes
|
||||||
|
Create proposal when you need to:
|
||||||
|
- Add features or functionality
|
||||||
|
- Make breaking changes (API, schema)
|
||||||
|
- Change architecture or patterns
|
||||||
|
- Optimize performance (changes behavior)
|
||||||
|
- Update security patterns
|
||||||
|
|
||||||
|
Triggers (examples):
|
||||||
|
- "Help me create a change proposal"
|
||||||
|
- "Help me plan a change"
|
||||||
|
- "Help me create a proposal"
|
||||||
|
- "I want to create a spec proposal"
|
||||||
|
- "I want to create a spec"
|
||||||
|
|
||||||
|
Loose matching guidance:
|
||||||
|
- Contains one of: `proposal`, `change`, `spec`
|
||||||
|
- With one of: `create`, `plan`, `make`, `start`, `help`
|
||||||
|
|
||||||
|
Skip proposal for:
|
||||||
|
- Bug fixes (restore intended behavior)
|
||||||
|
- Typos, formatting, comments
|
||||||
|
- Dependency updates (non-breaking)
|
||||||
|
- Configuration changes
|
||||||
|
- Tests for existing behavior
|
||||||
|
|
||||||
|
**Workflow**
|
||||||
|
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
|
||||||
|
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
|
||||||
|
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
|
||||||
|
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
|
||||||
|
|
||||||
|
### Stage 2: Implementing Changes
|
||||||
|
Track these steps as TODOs and complete them one by one.
|
||||||
|
1. **Read proposal.md** - Understand what's being built
|
||||||
|
2. **Read design.md** (if exists) - Review technical decisions
|
||||||
|
3. **Read tasks.md** - Get implementation checklist
|
||||||
|
4. **Implement tasks sequentially** - Complete in order
|
||||||
|
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
|
||||||
|
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
|
||||||
|
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||||
|
|
||||||
|
### Stage 3: Archiving Changes
|
||||||
|
After deployment, create separate PR to:
|
||||||
|
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||||
|
- Update `specs/` if capabilities changed
|
||||||
|
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
|
||||||
|
- Run `openspec validate --strict` to confirm the archived change passes checks
|
||||||
|
|
||||||
|
## Before Any Task
|
||||||
|
|
||||||
|
**Context Checklist:**
|
||||||
|
- [ ] Read relevant specs in `specs/[capability]/spec.md`
|
||||||
|
- [ ] Check pending changes in `changes/` for conflicts
|
||||||
|
- [ ] Read `openspec/project.md` for conventions
|
||||||
|
- [ ] Run `openspec list` to see active changes
|
||||||
|
- [ ] Run `openspec list --specs` to see existing capabilities
|
||||||
|
|
||||||
|
**Before Creating Specs:**
|
||||||
|
- Always check if capability already exists
|
||||||
|
- Prefer modifying existing specs over creating duplicates
|
||||||
|
- Use `openspec show [spec]` to review current state
|
||||||
|
- If request is ambiguous, ask 1–2 clarifying questions before scaffolding
|
||||||
|
|
||||||
|
### Search Guidance
|
||||||
|
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
|
||||||
|
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
|
||||||
|
- Show details:
|
||||||
|
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
|
||||||
|
- Change: `openspec show <change-id> --json --deltas-only`
|
||||||
|
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Essential commands
|
||||||
|
openspec list # List active changes
|
||||||
|
openspec list --specs # List specifications
|
||||||
|
openspec show [item] # Display change or spec
|
||||||
|
openspec validate [item] # Validate changes or specs
|
||||||
|
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||||
|
|
||||||
|
# Project management
|
||||||
|
openspec init [path] # Initialize OpenSpec
|
||||||
|
openspec update [path] # Update instruction files
|
||||||
|
|
||||||
|
# Interactive mode
|
||||||
|
openspec show # Prompts for selection
|
||||||
|
openspec validate # Bulk validation mode
|
||||||
|
|
||||||
|
# Debugging
|
||||||
|
openspec show [change] --json --deltas-only
|
||||||
|
openspec validate [change] --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Flags
|
||||||
|
|
||||||
|
- `--json` - Machine-readable output
|
||||||
|
- `--type change|spec` - Disambiguate items
|
||||||
|
- `--strict` - Comprehensive validation
|
||||||
|
- `--no-interactive` - Disable prompts
|
||||||
|
- `--skip-specs` - Archive without spec updates
|
||||||
|
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
openspec/
|
||||||
|
├── project.md # Project conventions
|
||||||
|
├── specs/ # Current truth - what IS built
|
||||||
|
│ └── [capability]/ # Single focused capability
|
||||||
|
│ ├── spec.md # Requirements and scenarios
|
||||||
|
│ └── design.md # Technical patterns
|
||||||
|
├── changes/ # Proposals - what SHOULD change
|
||||||
|
│ ├── [change-name]/
|
||||||
|
│ │ ├── proposal.md # Why, what, impact
|
||||||
|
│ │ ├── tasks.md # Implementation checklist
|
||||||
|
│ │ ├── design.md # Technical decisions (optional; see criteria)
|
||||||
|
│ │ └── specs/ # Delta changes
|
||||||
|
│ │ └── [capability]/
|
||||||
|
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
|
||||||
|
│ └── archive/ # Completed changes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Change Proposals
|
||||||
|
|
||||||
|
### Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
New request?
|
||||||
|
├─ Bug fix restoring spec behavior? → Fix directly
|
||||||
|
├─ Typo/format/comment? → Fix directly
|
||||||
|
├─ New feature/capability? → Create proposal
|
||||||
|
├─ Breaking change? → Create proposal
|
||||||
|
├─ Architecture change? → Create proposal
|
||||||
|
└─ Unclear? → Create proposal (safer)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proposal Structure
|
||||||
|
|
||||||
|
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
|
||||||
|
|
||||||
|
2. **Write proposal.md:**
|
||||||
|
```markdown
|
||||||
|
# Change: [Brief description of change]
|
||||||
|
|
||||||
|
## Why
|
||||||
|
[1-2 sentences on problem/opportunity]
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- [Bullet list of changes]
|
||||||
|
- [Mark breaking changes with **BREAKING**]
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Affected specs: [list capabilities]
|
||||||
|
- Affected code: [key files/systems]
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create spec deltas:** `specs/[capability]/spec.md`
|
||||||
|
```markdown
|
||||||
|
## ADDED Requirements
|
||||||
|
### Requirement: New Feature
|
||||||
|
The system SHALL provide...
|
||||||
|
|
||||||
|
#### Scenario: Success case
|
||||||
|
- **WHEN** user performs action
|
||||||
|
- **THEN** expected result
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
### Requirement: Existing Feature
|
||||||
|
[Complete modified requirement]
|
||||||
|
|
||||||
|
## REMOVED Requirements
|
||||||
|
### Requirement: Old Feature
|
||||||
|
**Reason**: [Why removing]
|
||||||
|
**Migration**: [How to handle]
|
||||||
|
```
|
||||||
|
If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability.
|
||||||
|
|
||||||
|
4. **Create tasks.md:**
|
||||||
|
```markdown
|
||||||
|
## 1. Implementation
|
||||||
|
- [ ] 1.1 Create database schema
|
||||||
|
- [ ] 1.2 Implement API endpoint
|
||||||
|
- [ ] 1.3 Add frontend component
|
||||||
|
- [ ] 1.4 Write tests
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Create design.md when needed:**
|
||||||
|
Create `design.md` if any of the following apply; otherwise omit it:
|
||||||
|
- Cross-cutting change (multiple services/modules) or a new architectural pattern
|
||||||
|
- New external dependency or significant data model changes
|
||||||
|
- Security, performance, or migration complexity
|
||||||
|
- Ambiguity that benefits from technical decisions before coding
|
||||||
|
|
||||||
|
Minimal `design.md` skeleton:
|
||||||
|
```markdown
|
||||||
|
## Context
|
||||||
|
[Background, constraints, stakeholders]
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
- Goals: [...]
|
||||||
|
- Non-Goals: [...]
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
- Decision: [What and why]
|
||||||
|
- Alternatives considered: [Options + rationale]
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
- [Risk] → Mitigation
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
[Steps, rollback]
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
- [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spec File Format
|
||||||
|
|
||||||
|
### Critical: Scenario Formatting
|
||||||
|
|
||||||
|
**CORRECT** (use #### headers):
|
||||||
|
```markdown
|
||||||
|
#### Scenario: User login success
|
||||||
|
- **WHEN** valid credentials provided
|
||||||
|
- **THEN** return JWT token
|
||||||
|
```
|
||||||
|
|
||||||
|
**WRONG** (don't use bullets or bold):
|
||||||
|
```markdown
|
||||||
|
- **Scenario: User login** ❌
|
||||||
|
**Scenario**: User login ❌
|
||||||
|
### Scenario: User login ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
Every requirement MUST have at least one scenario.
|
||||||
|
|
||||||
|
### Requirement Wording
|
||||||
|
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
|
||||||
|
|
||||||
|
### Delta Operations
|
||||||
|
|
||||||
|
- `## ADDED Requirements` - New capabilities
|
||||||
|
- `## MODIFIED Requirements` - Changed behavior
|
||||||
|
- `## REMOVED Requirements` - Deprecated features
|
||||||
|
- `## RENAMED Requirements` - Name changes
|
||||||
|
|
||||||
|
Headers matched with `trim(header)` - whitespace ignored.
|
||||||
|
|
||||||
|
#### When to use ADDED vs MODIFIED
|
||||||
|
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
|
||||||
|
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
|
||||||
|
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
|
||||||
|
|
||||||
|
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead.
|
||||||
|
|
||||||
|
Authoring a MODIFIED requirement correctly:
|
||||||
|
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
|
||||||
|
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
|
||||||
|
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
|
||||||
|
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
|
||||||
|
|
||||||
|
Example for RENAMED:
|
||||||
|
```markdown
|
||||||
|
## RENAMED Requirements
|
||||||
|
- FROM: `### Requirement: Login`
|
||||||
|
- TO: `### Requirement: User Authentication`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Errors
|
||||||
|
|
||||||
|
**"Change must have at least one delta"**
|
||||||
|
- Check `changes/[name]/specs/` exists with .md files
|
||||||
|
- Verify files have operation prefixes (## ADDED Requirements)
|
||||||
|
|
||||||
|
**"Requirement must have at least one scenario"**
|
||||||
|
- Check scenarios use `#### Scenario:` format (4 hashtags)
|
||||||
|
- Don't use bullet points or bold for scenario headers
|
||||||
|
|
||||||
|
**Silent scenario parsing failures**
|
||||||
|
- Exact format required: `#### Scenario: Name`
|
||||||
|
- Debug with: `openspec show [change] --json --deltas-only`
|
||||||
|
|
||||||
|
### Validation Tips
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Always use strict mode for comprehensive checks
|
||||||
|
openspec validate [change] --strict
|
||||||
|
|
||||||
|
# Debug delta parsing
|
||||||
|
openspec show [change] --json | jq '.deltas'
|
||||||
|
|
||||||
|
# Check specific requirement
|
||||||
|
openspec show [spec] --json -r 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Happy Path Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) Explore current state
|
||||||
|
openspec spec list --long
|
||||||
|
openspec list
|
||||||
|
# Optional full-text search:
|
||||||
|
# rg -n "Requirement:|Scenario:" openspec/specs
|
||||||
|
# rg -n "^#|Requirement:" openspec/changes
|
||||||
|
|
||||||
|
# 2) Choose change id and scaffold
|
||||||
|
CHANGE=add-two-factor-auth
|
||||||
|
mkdir -p openspec/changes/$CHANGE/{specs/auth}
|
||||||
|
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
|
||||||
|
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
|
||||||
|
|
||||||
|
# 3) Add deltas (example)
|
||||||
|
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
|
||||||
|
## ADDED Requirements
|
||||||
|
### Requirement: Two-Factor Authentication
|
||||||
|
Users MUST provide a second factor during login.
|
||||||
|
|
||||||
|
#### Scenario: OTP required
|
||||||
|
- **WHEN** valid credentials are provided
|
||||||
|
- **THEN** an OTP challenge is required
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 4) Validate
|
||||||
|
openspec validate $CHANGE --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Capability Example
|
||||||
|
|
||||||
|
```
|
||||||
|
openspec/changes/add-2fa-notify/
|
||||||
|
├── proposal.md
|
||||||
|
├── tasks.md
|
||||||
|
└── specs/
|
||||||
|
├── auth/
|
||||||
|
│ └── spec.md # ADDED: Two-Factor Authentication
|
||||||
|
└── notifications/
|
||||||
|
└── spec.md # ADDED: OTP email notification
|
||||||
|
```
|
||||||
|
|
||||||
|
auth/spec.md
|
||||||
|
```markdown
|
||||||
|
## ADDED Requirements
|
||||||
|
### Requirement: Two-Factor Authentication
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
notifications/spec.md
|
||||||
|
```markdown
|
||||||
|
## ADDED Requirements
|
||||||
|
### Requirement: OTP Email Notification
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Simplicity First
|
||||||
|
- Default to <100 lines of new code
|
||||||
|
- Single-file implementations until proven insufficient
|
||||||
|
- Avoid frameworks without clear justification
|
||||||
|
- Choose boring, proven patterns
|
||||||
|
|
||||||
|
### Complexity Triggers
|
||||||
|
Only add complexity with:
|
||||||
|
- Performance data showing current solution too slow
|
||||||
|
- Concrete scale requirements (>1000 users, >100MB data)
|
||||||
|
- Multiple proven use cases requiring abstraction
|
||||||
|
|
||||||
|
### Clear References
|
||||||
|
- Use `file.ts:42` format for code locations
|
||||||
|
- Reference specs as `specs/auth/spec.md`
|
||||||
|
- Link related changes and PRs
|
||||||
|
|
||||||
|
### Capability Naming
|
||||||
|
- Use verb-noun: `user-auth`, `payment-capture`
|
||||||
|
- Single purpose per capability
|
||||||
|
- 10-minute understandability rule
|
||||||
|
- Split if description needs "AND"
|
||||||
|
|
||||||
|
### Change ID Naming
|
||||||
|
- Use kebab-case, short and descriptive: `add-two-factor-auth`
|
||||||
|
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
|
||||||
|
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
|
||||||
|
|
||||||
|
## Tool Selection Guide
|
||||||
|
|
||||||
|
| Task | Tool | Why |
|
||||||
|
|------|------|-----|
|
||||||
|
| Find files by pattern | Glob | Fast pattern matching |
|
||||||
|
| Search code content | Grep | Optimized regex search |
|
||||||
|
| Read specific files | Read | Direct file access |
|
||||||
|
| Explore unknown scope | Task | Multi-step investigation |
|
||||||
|
|
||||||
|
## Error Recovery
|
||||||
|
|
||||||
|
### Change Conflicts
|
||||||
|
1. Run `openspec list` to see active changes
|
||||||
|
2. Check for overlapping specs
|
||||||
|
3. Coordinate with change owners
|
||||||
|
4. Consider combining proposals
|
||||||
|
|
||||||
|
### Validation Failures
|
||||||
|
1. Run with `--strict` flag
|
||||||
|
2. Check JSON output for details
|
||||||
|
3. Verify spec file format
|
||||||
|
4. Ensure scenarios properly formatted
|
||||||
|
|
||||||
|
### Missing Context
|
||||||
|
1. Read project.md first
|
||||||
|
2. Check related specs
|
||||||
|
3. Review recent archives
|
||||||
|
4. Ask for clarification
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Stage Indicators
|
||||||
|
- `changes/` - Proposed, not yet built
|
||||||
|
- `specs/` - Built and deployed
|
||||||
|
- `archive/` - Completed changes
|
||||||
|
|
||||||
|
### File Purposes
|
||||||
|
- `proposal.md` - Why and what
|
||||||
|
- `tasks.md` - Implementation steps
|
||||||
|
- `design.md` - Technical decisions
|
||||||
|
- `spec.md` - Requirements and behavior
|
||||||
|
|
||||||
|
### CLI Essentials
|
||||||
|
```bash
|
||||||
|
openspec list # What's in progress?
|
||||||
|
openspec show [item] # View details
|
||||||
|
openspec validate --strict # Is it correct?
|
||||||
|
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||||
|
```
|
||||||
|
|
||||||
|
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||||
199
openspec/changes/archive/2025-12-28-add-user-auth/design.md
Normal file
199
openspec/changes/archive/2025-12-28-add-user-auth/design.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# Design: add-user-auth
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
這是專案的第一個模組,需要建立認證與授權的基礎架構。系統使用外部認證 API 進行身份驗證,本地不儲存密碼。認證成功後由本地系統核發 JWT Token 並管理 Session。
|
||||||
|
|
||||||
|
### Stakeholders
|
||||||
|
- 所有系統使用者(工程師、主管、PMO)
|
||||||
|
- 系統管理員 (ymirliu@panjit.com.tw)
|
||||||
|
- 外部認證 API 維護者
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
- 必須使用外部 API 認證,不可本地繞過
|
||||||
|
- 資料表必須使用 `pjctrl_` 前綴
|
||||||
|
- 需支援部門級資料隔離
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
- 實現安全的外部 API 認證整合
|
||||||
|
- 建立 RBAC 權限控制框架
|
||||||
|
- 支援部門級資料隔離
|
||||||
|
- 預設系統管理員帳號
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
- 本地密碼儲存與驗證
|
||||||
|
- 多因素認證 (MFA) - 由外部 API 處理
|
||||||
|
- OAuth2 第三方登入
|
||||||
|
- 使用者自助註冊
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Decision 1: 認證流程架構
|
||||||
|
|
||||||
|
**選擇**: Frontend-to-External-API 模式
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ ┌─────────────┐ ┌──────────────────────────┐
|
||||||
|
│ User │────▶│ Frontend │────▶│ pj-auth-api.vercel.app │
|
||||||
|
└─────────┘ └─────────────┘ └──────────────────────────┘
|
||||||
|
│ │
|
||||||
|
│◀────── Auth Token ──────│
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Backend │
|
||||||
|
│ - 驗證 Token│
|
||||||
|
│ - 核發 JWT │
|
||||||
|
│ - 建立 Session
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 減少後端介入認證流程的攻擊面
|
||||||
|
- 外部 API 已處理密碼安全性
|
||||||
|
- 後端只負責驗證與授權
|
||||||
|
|
||||||
|
**替代方案考量**:
|
||||||
|
- Backend Proxy 模式:增加延遲與複雜度,無明顯優勢
|
||||||
|
|
||||||
|
### Decision 2: JWT Token 策略
|
||||||
|
|
||||||
|
**選擇**: 短期 Access Token + Redis Session
|
||||||
|
|
||||||
|
```python
|
||||||
|
JWT_PAYLOAD = {
|
||||||
|
"sub": "user_id",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"role": "engineer",
|
||||||
|
"department_id": "uuid",
|
||||||
|
"is_system_admin": false,
|
||||||
|
"exp": "15 minutes from now",
|
||||||
|
"iat": "now"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 短期 Token 減少被盜用風險
|
||||||
|
- Redis Session 可即時撤銷
|
||||||
|
- 權限資訊嵌入 Token 減少 DB 查詢
|
||||||
|
|
||||||
|
### Decision 3: 權限模型
|
||||||
|
|
||||||
|
**選擇**: RBAC + 部門隔離混合模式
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 權限檢查順序
|
||||||
|
def check_permission(user, resource, action):
|
||||||
|
# 1. 系統管理員 - 全權通過
|
||||||
|
if user.is_system_admin:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 2. 角色權限檢查
|
||||||
|
if not has_role_permission(user.role, action):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 3. 部門隔離檢查
|
||||||
|
if not is_same_department_or_allowed(user, resource):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
- 系統管理員需無限制存取(用於問題排查)
|
||||||
|
- RBAC 提供功能層面控制
|
||||||
|
- 部門隔離提供資料層面控制
|
||||||
|
|
||||||
|
### Decision 4: 資料表設計
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 使用 UUID 而非 auto-increment
|
||||||
|
-- 便於分散式環境與資料合併
|
||||||
|
|
||||||
|
CREATE TABLE pjctrl_roles (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
permissions JSON NOT NULL,
|
||||||
|
is_system_role BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE pjctrl_departments (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
parent_id CHAR(36) REFERENCES pjctrl_departments(id),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE pjctrl_users (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
email VARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
department_id CHAR(36) REFERENCES pjctrl_departments(id),
|
||||||
|
role_id CHAR(36) REFERENCES pjctrl_roles(id),
|
||||||
|
skills JSON,
|
||||||
|
capacity DECIMAL(5,2) DEFAULT 40.00,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_system_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
| 風險 | 影響 | 緩解措施 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 外部 API 不可用 | 所有使用者無法登入 | 顯示友善錯誤訊息、記錄事件、監控告警 |
|
||||||
|
| JWT Token 洩漏 | 短期內帳號被盜用 | 15 分鐘過期、Redis 可即時撤銷 |
|
||||||
|
| 部門隔離邏輯錯誤 | 資料外洩 | 完整測試案例、程式碼審查 |
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. 執行資料庫 migration 建立資料表
|
||||||
|
2. 執行 seed data 建立預設角色與管理員
|
||||||
|
3. 部署後端認證模組
|
||||||
|
4. 部署前端登入頁面
|
||||||
|
5. 驗證管理員可登入
|
||||||
|
|
||||||
|
**Rollback**:
|
||||||
|
- 資料庫:執行 down migration
|
||||||
|
- 程式碼:回復至前一版本
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
MYSQL_HOST=mysql.theaken.com
|
||||||
|
MYSQL_PORT=33306
|
||||||
|
MYSQL_USER=A060
|
||||||
|
MYSQL_PASSWORD=<見密碼管理>
|
||||||
|
MYSQL_DATABASE=db_A060
|
||||||
|
|
||||||
|
# Redis (待設定)
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=<生成隨機金鑰>
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_EXPIRE_MINUTES=15
|
||||||
|
|
||||||
|
# External Auth API
|
||||||
|
AUTH_API_URL=https://pj-auth-api.vercel.app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Credentials
|
||||||
|
|
||||||
|
- **Email**: `ymirliu@panjit.com.tw`
|
||||||
|
- **Password**: 由外部認證 API 管理(已設定於外部系統)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. ~~外部認證 API 的回應格式?~~ (需確認)
|
||||||
|
2. ~~Session 過期時間?~~ 建議 15 分鐘,可透過環境變數調整
|
||||||
|
3. ~~是否需要 Refresh Token 機制?~~ 暫不需要,視實際使用情況再評估
|
||||||
|
4. ~~MySQL 連線資訊?~~ 已提供
|
||||||
|
5. ~~Admin 帳號密碼?~~ 已提供 (ymirliu@panjit.com.tw)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Change: Add User Authentication & Authorization
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
系統需要使用者認證與授權機制作為所有功能的基礎。沒有認證系統,無法識別使用者身份、無法實施權限控制、無法追蹤操作記錄。這是整個專案管理系統的第一個必要模組。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **新增** 外部 API 認證整合 (https://pj-auth-api.vercel.app)
|
||||||
|
- **新增** JWT Token 驗證與 Session 管理
|
||||||
|
- **新增** 使用者資料表 (`pjctrl_users`)
|
||||||
|
- **新增** 部門資料表 (`pjctrl_departments`)
|
||||||
|
- **新增** 角色資料表 (`pjctrl_roles`)
|
||||||
|
- **新增** 預設系統管理員帳號 (`ymirliu@panjit.com.tw`)
|
||||||
|
- **新增** RBAC 權限檢查中間件
|
||||||
|
- **新增** 部門級資料隔離機制
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Affected specs**: `user-auth` (新增)
|
||||||
|
- **Affected code**:
|
||||||
|
- Backend: `app/api/auth/`, `app/models/user.py`, `app/core/security.py`
|
||||||
|
- Frontend: `src/contexts/AuthContext.tsx`, `src/pages/Login.tsx`
|
||||||
|
- Database: Migration for `pjctrl_users`, `pjctrl_departments`, `pjctrl_roles`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- 外部認證 API: https://pj-auth-api.vercel.app (必須可用)
|
||||||
|
- MySQL 資料庫連線
|
||||||
|
- Redis (用於 Session 儲存)
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. 使用者可透過外部 API 完成登入
|
||||||
|
2. JWT Token 正確核發與驗證
|
||||||
|
3. 系統管理員帳號可登入並存取所有資源
|
||||||
|
4. 非授權使用者無法存取受保護的 API
|
||||||
|
5. 部門隔離正確運作
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: API-Based Authentication
|
||||||
|
系統 SHALL 限定使用外部認證 API (https://pj-auth-api.vercel.app) 進行登入認證,不支援其他認證方式。
|
||||||
|
|
||||||
|
#### Scenario: API 登入成功
|
||||||
|
- **GIVEN** 使用者擁有有效的企業帳號
|
||||||
|
- **WHEN** 使用者透過前端提交憑證
|
||||||
|
- **THEN** 系統呼叫 https://pj-auth-api.vercel.app 驗證憑證
|
||||||
|
- **AND** 驗證成功後建立 session 並回傳 JWT token
|
||||||
|
|
||||||
|
#### Scenario: API 登入失敗
|
||||||
|
- **GIVEN** 使用者提供無效的憑證
|
||||||
|
- **WHEN** 使用者嘗試登入
|
||||||
|
- **THEN** 認證 API 回傳錯誤
|
||||||
|
- **AND** 系統拒絕登入並顯示錯誤訊息
|
||||||
|
- **AND** 記錄失敗的登入嘗試
|
||||||
|
|
||||||
|
#### Scenario: 認證 API 無法連線
|
||||||
|
- **GIVEN** 認證 API 服務無法連線
|
||||||
|
- **WHEN** 使用者嘗試登入
|
||||||
|
- **THEN** 系統顯示服務暫時無法使用的訊息
|
||||||
|
- **AND** 記錄連線失敗事件
|
||||||
|
|
||||||
|
### Requirement: System Administrator
|
||||||
|
系統 SHALL 預設一個系統管理員帳號,擁有所有權限。系統管理員帳號必須存在於外部認證系統,且登入流程仍需透過外部認證 API;不允許本地繞過認證。
|
||||||
|
|
||||||
|
#### Scenario: 預設管理員帳號
|
||||||
|
- **GIVEN** 系統初始化完成
|
||||||
|
- **WHEN** 系統啟動
|
||||||
|
- **THEN** 存在預設管理員帳號 ymirliu@panjit.com.tw
|
||||||
|
- **AND** 該帳號擁有 super_admin 角色
|
||||||
|
- **AND** 該帳號不可被刪除或降級
|
||||||
|
|
||||||
|
#### Scenario: 管理員登入流程
|
||||||
|
- **GIVEN** 管理員帳號 ymirliu@panjit.com.tw 需要登入
|
||||||
|
- **WHEN** 管理員提交憑證
|
||||||
|
- **THEN** 系統仍需呼叫 https://pj-auth-api.vercel.app 驗證
|
||||||
|
- **AND** 不存在任何本地繞過認證的機制
|
||||||
|
- **AND** 驗證成功後才授予 super_admin 權限
|
||||||
|
|
||||||
|
#### Scenario: 管理員全域權限
|
||||||
|
- **GIVEN** 管理員帳號 ymirliu@panjit.com.tw 已通過 API 認證並登入
|
||||||
|
- **WHEN** 管理員存取任何資源
|
||||||
|
- **THEN** 系統允許存取,無視部門隔離限制
|
||||||
|
|
||||||
|
### Requirement: Role-Based Access Control
|
||||||
|
系統 SHALL 支援基於角色的存取控制 (RBAC)。
|
||||||
|
|
||||||
|
#### Scenario: 角色權限檢查
|
||||||
|
- **GIVEN** 使用者被指派特定角色
|
||||||
|
- **WHEN** 使用者嘗試存取受保護的資源
|
||||||
|
- **THEN** 系統根據角色權限決定是否允許存取
|
||||||
|
|
||||||
|
#### Scenario: 角色指派
|
||||||
|
- **GIVEN** 管理員擁有使用者管理權限
|
||||||
|
- **WHEN** 管理員為使用者指派角色
|
||||||
|
- **THEN** 系統更新使用者的角色設定
|
||||||
|
- **AND** 新權限立即生效
|
||||||
|
|
||||||
|
### Requirement: Department Isolation
|
||||||
|
系統 SHALL 實施部門級別的資料隔離,確保跨部門資料安全。
|
||||||
|
|
||||||
|
#### Scenario: 部門資料隔離
|
||||||
|
- **GIVEN** 使用者屬於研發部門
|
||||||
|
- **WHEN** 使用者嘗試存取廠務部門的專案
|
||||||
|
- **THEN** 系統拒絕存取並顯示無權限訊息
|
||||||
|
|
||||||
|
#### Scenario: 跨部門專案存取
|
||||||
|
- **GIVEN** 專案被設定為跨部門可見
|
||||||
|
- **WHEN** 不同部門的使用者嘗試存取該專案
|
||||||
|
- **THEN** 系統根據專案的 Security_Level 設定決定是否允許存取
|
||||||
|
|
||||||
|
### Requirement: Session Management
|
||||||
|
系統 SHALL 管理使用者 session,包含過期與登出機制。
|
||||||
|
|
||||||
|
#### Scenario: Session 過期
|
||||||
|
- **GIVEN** 使用者已登入系統
|
||||||
|
- **WHEN** Session 超過設定的有效期限
|
||||||
|
- **THEN** 系統自動使 session 失效
|
||||||
|
- **AND** 使用者需重新登入
|
||||||
|
|
||||||
|
#### Scenario: 主動登出
|
||||||
|
- **GIVEN** 使用者已登入系統
|
||||||
|
- **WHEN** 使用者執行登出操作
|
||||||
|
- **THEN** 系統銷毀 session 並清除 token
|
||||||
138
openspec/changes/archive/2025-12-28-add-user-auth/tasks.md
Normal file
138
openspec/changes/archive/2025-12-28-add-user-auth/tasks.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Tasks: add-user-auth
|
||||||
|
|
||||||
|
## 1. 專案初始化
|
||||||
|
|
||||||
|
- [x] 1.1 建立 Conda 環境與 requirements.txt
|
||||||
|
- [x] 1.2 初始化 FastAPI 專案結構
|
||||||
|
- [x] 1.3 設定 MySQL 連線與 SQLAlchemy
|
||||||
|
- [x] 1.4 設定 Redis 連線
|
||||||
|
- [x] 1.5 建立環境變數配置 (.env.example)
|
||||||
|
|
||||||
|
## 2. 資料庫模型
|
||||||
|
|
||||||
|
- [x] 2.1 建立 `pjctrl_roles` 資料表 migration
|
||||||
|
- [x] 2.2 建立 `pjctrl_departments` 資料表 migration
|
||||||
|
- [x] 2.3 建立 `pjctrl_users` 資料表 migration
|
||||||
|
- [x] 2.4 建立 seed data (預設管理員與角色)
|
||||||
|
- [x] 2.5 驗證 migration 可正確執行與回滾
|
||||||
|
|
||||||
|
## 3. 認證模組
|
||||||
|
|
||||||
|
- [x] 3.1 實作外部 API 認證 client (`app/services/auth_client.py`)
|
||||||
|
- [x] 3.2 實作 JWT Token 驗證邏輯
|
||||||
|
- [x] 3.3 實作登入 API endpoint (`POST /api/auth/login`)
|
||||||
|
- [x] 3.4 實作登出 API endpoint (`POST /api/auth/logout`)
|
||||||
|
- [x] 3.5 實作取得當前使用者 API (`GET /api/auth/me`)
|
||||||
|
- [x] 3.6 處理認證 API 連線失敗情境
|
||||||
|
|
||||||
|
## 4. Session 管理
|
||||||
|
|
||||||
|
- [x] 4.1 實作 Redis Session 儲存
|
||||||
|
- [x] 4.2 實作 Session 過期機制
|
||||||
|
- [x] 4.3 實作 Token 刷新機制 (如需要) - 暫不需要
|
||||||
|
|
||||||
|
## 5. 權限控制
|
||||||
|
|
||||||
|
- [x] 5.1 實作認證中間件 (`app/middleware/auth.py`)
|
||||||
|
- [x] 5.2 實作 RBAC 權限檢查裝飾器
|
||||||
|
- [x] 5.3 實作部門隔離邏輯
|
||||||
|
- [x] 5.4 實作系統管理員全域權限判斷
|
||||||
|
|
||||||
|
## 6. 使用者管理 API
|
||||||
|
|
||||||
|
- [x] 6.1 實作使用者列表 API (`GET /api/users`)
|
||||||
|
- [x] 6.2 實作使用者詳情 API (`GET /api/users/{id}`)
|
||||||
|
- [x] 6.3 實作角色指派 API (`PATCH /api/users/{id}/role`)
|
||||||
|
- [x] 6.4 實作部門管理 API (CRUD)
|
||||||
|
|
||||||
|
## 7. 測試
|
||||||
|
|
||||||
|
- [x] 7.1 撰寫認證模組單元測試
|
||||||
|
- [x] 7.2 撰寫權限檢查單元測試
|
||||||
|
- [x] 7.3 撰寫 API 整合測試
|
||||||
|
- [x] 7.4 測試系統管理員權限
|
||||||
|
- [x] 7.5 測試部門隔離情境
|
||||||
|
|
||||||
|
## 8. 前端 (基礎)
|
||||||
|
|
||||||
|
- [x] 8.1 建立 React 專案結構
|
||||||
|
- [x] 8.2 實作 AuthContext (認證狀態管理)
|
||||||
|
- [x] 8.3 實作登入頁面
|
||||||
|
- [x] 8.4 實作 Protected Route 元件
|
||||||
|
- [x] 8.5 實作登出功能
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
1.x (專案初始化) → 2.x (資料庫) → 3.x (認證) → 4.x (Session)
|
||||||
|
↓
|
||||||
|
5.x (權限) → 6.x (使用者管理)
|
||||||
|
↓
|
||||||
|
7.x (測試)
|
||||||
|
|
||||||
|
8.x (前端) 可與 3.x-6.x 並行開發
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- 所有資料表使用 `pjctrl_` 前綴
|
||||||
|
- 認證必須透過外部 API,不可有本地繞過
|
||||||
|
- 系統管理員帳號在 seed data 中建立
|
||||||
|
|
||||||
|
## Implementation Summary
|
||||||
|
|
||||||
|
完成日期: 2024-01-XX
|
||||||
|
|
||||||
|
### 已建立的檔案結構
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── app/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── auth/router.py # 認證 API
|
||||||
|
│ │ ├── users/router.py # 使用者管理 API
|
||||||
|
│ │ └── departments/router.py # 部門管理 API
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── config.py # 環境配置
|
||||||
|
│ │ ├── database.py # 資料庫連線
|
||||||
|
│ │ ├── redis.py # Redis 連線
|
||||||
|
│ │ └── security.py # JWT 處理
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ └── auth.py # 認證與權限中間件
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── user.py # User model
|
||||||
|
│ │ ├── role.py # Role model
|
||||||
|
│ │ └── department.py # Department model
|
||||||
|
│ ├── schemas/ # Pydantic schemas
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── auth_client.py # 外部認證 API client
|
||||||
|
│ └── main.py # FastAPI 應用程式
|
||||||
|
├── migrations/
|
||||||
|
│ └── versions/
|
||||||
|
│ └── 001_initial_auth_tables.py
|
||||||
|
├── tests/
|
||||||
|
│ ├── conftest.py
|
||||||
|
│ ├── test_auth.py
|
||||||
|
│ └── test_users.py
|
||||||
|
├── .env
|
||||||
|
├── .env.example
|
||||||
|
├── requirements.txt
|
||||||
|
└── environment.yml
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ └── ProtectedRoute.tsx
|
||||||
|
│ ├── contexts/
|
||||||
|
│ │ └── AuthContext.tsx
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── Login.tsx
|
||||||
|
│ │ └── Dashboard.tsx
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── api.ts
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ └── main.tsx
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── vite.config.ts
|
||||||
|
```
|
||||||
78
openspec/project.md
Normal file
78
openspec/project.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Project Context
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Cross-departmental project management system serving as a Single Source of Truth to:
|
||||||
|
- Reduce engineering time-reporting burden
|
||||||
|
- Provide real-time resource load and project progress analysis
|
||||||
|
- Standardize task tracking across departments
|
||||||
|
|
||||||
|
Target users:
|
||||||
|
- **Engineers**: Simplified reporting, automated reminders, personal task dashboard
|
||||||
|
- **Unit Managers**: Team workload visibility, resource allocation heatmaps, multi-project health boards
|
||||||
|
- **Admin/PMO**: Centralized data, automated weekly reports, document version control
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Frontend**: React.js (recommended for complex admin dashboards)
|
||||||
|
- **Backend**: Python with FastAPI (enables future AI/ML integration for risk prediction)
|
||||||
|
- **Database**: MySQL (relational structure for complex project hierarchies and dependencies)
|
||||||
|
- **Cache & Real-time**: Redis (push notifications, task state locking)
|
||||||
|
- **Real-time Sync**: WebSocket for live collaboration
|
||||||
|
- **Authentication**: Enterprise Windows AD/LDAP via SSO (https://pj-auth-api.vercel.app)
|
||||||
|
- **Environment Management**: Conda (for Python dependency isolation)
|
||||||
|
|
||||||
|
## Project Conventions
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
- [To be defined - React/TypeScript conventions for frontend]
|
||||||
|
- [To be defined - Python/FastAPI conventions for backend]
|
||||||
|
|
||||||
|
### Database Naming
|
||||||
|
- **Table Prefix**: All tables must use `pjctrl_` prefix (e.g., `pjctrl_users`, `pjctrl_projects`, `pjctrl_tasks`)
|
||||||
|
- This ensures isolation in shared database environments and prevents migration conflicts
|
||||||
|
|
||||||
|
### Architecture Patterns
|
||||||
|
- 3-Tier Architecture (Presentation, Application, Data layers)
|
||||||
|
- Designed for future extensibility (MES/ERP integration capability)
|
||||||
|
- Multi-level task hierarchy: Space > Project > Task > Sub-task
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
- [To be defined]
|
||||||
|
|
||||||
|
### Git Workflow
|
||||||
|
- [To be defined]
|
||||||
|
|
||||||
|
## Domain Context
|
||||||
|
**Semiconductor/Manufacturing Industry**
|
||||||
|
- Custom fields support domain-specific data: package types, machine numbers, expected yield rates
|
||||||
|
- Document encryption (AES-256) for sensitive semiconductor drawings
|
||||||
|
- User watermarking on downloads for IP protection
|
||||||
|
- Department-level access control (e.g., factory ops cannot view R&D projects)
|
||||||
|
|
||||||
|
## Important Constraints
|
||||||
|
- **Security**: Enterprise AD/LDAP integration with fine-grained permissions
|
||||||
|
- **Audit Trail**: All changes logged (deadline modifications, file deletions, etc.)
|
||||||
|
- **Data Sensitivity**: Encrypted storage for sensitive technical documents
|
||||||
|
- **Real-time Requirements**: WebSocket-based live updates for concurrent editing
|
||||||
|
- **Database Isolation**: All tables MUST use a designated prefix (e.g., `pjctrl_`) to prevent conflicts during sync/migration with other tables in shared database environments
|
||||||
|
|
||||||
|
## External Dependencies
|
||||||
|
- **SSO Auth API**: https://pj-auth-api.vercel.app (Windows AD integration)
|
||||||
|
- **On-premise Storage**: NAS for file attachments
|
||||||
|
- Future: MES/ERP system integration
|
||||||
|
|
||||||
|
## Database Connection
|
||||||
|
- **Host**: mysql.theaken.com
|
||||||
|
- **Port**: 33306
|
||||||
|
- **User**: A060
|
||||||
|
- **Database**: db_A060
|
||||||
|
- **Table Prefix**: `pjctrl_`
|
||||||
|
|
||||||
|
## System Administrator
|
||||||
|
- **Email**: ymirliu@panjit.com.tw
|
||||||
|
- **Role**: super_admin (不可刪除或降級)
|
||||||
|
|
||||||
|
## Core Data Model (ERD)
|
||||||
|
- **User**: ID, Name, Department, Role, Skills, Capacity
|
||||||
|
- **Project**: ID, Title, Owner, Budget, Timeline, Security_Level
|
||||||
|
- **Task**: ID, Project_ID, Assignee, Priority, Status, Original_Estimate, Time_Spent, Blocker_Flag
|
||||||
|
- **Attachment**: ID, Task_ID, Version, File_Path
|
||||||
159
openspec/specs/audit-trail/spec.md
Normal file
159
openspec/specs/audit-trail/spec.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Audit Trail
|
||||||
|
|
||||||
|
系統級稽核追蹤,記錄所有關鍵變更操作供合規與追溯需求。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Change Logging
|
||||||
|
系統 SHALL 記錄所有關鍵變更操作,包含誰在何時改了什麼。
|
||||||
|
|
||||||
|
#### Scenario: 任務欄位變更記錄
|
||||||
|
- **GIVEN** 使用者修改任務的任何欄位(如截止日期、狀態、指派者)
|
||||||
|
- **WHEN** 變更儲存成功
|
||||||
|
- **THEN** 系統記錄變更前後的值
|
||||||
|
- **AND** 記錄操作者、時間、IP 位址
|
||||||
|
|
||||||
|
#### Scenario: 專案設定變更記錄
|
||||||
|
- **GIVEN** 管理者修改專案設定
|
||||||
|
- **WHEN** 設定變更儲存
|
||||||
|
- **THEN** 系統記錄所有變更的設定項目
|
||||||
|
- **AND** 記錄操作者與時間
|
||||||
|
|
||||||
|
#### Scenario: 權限變更記錄
|
||||||
|
- **GIVEN** 管理者修改使用者權限或角色
|
||||||
|
- **WHEN** 權限變更生效
|
||||||
|
- **THEN** 系統記錄權限變更詳情
|
||||||
|
- **AND** 標記為高敏感度操作
|
||||||
|
|
||||||
|
### Requirement: Delete Operations Tracking
|
||||||
|
系統 SHALL 追蹤所有刪除操作,支援軟刪除與追溯。
|
||||||
|
|
||||||
|
#### Scenario: 任務刪除記錄
|
||||||
|
- **GIVEN** 使用者刪除任務
|
||||||
|
- **WHEN** 刪除操作執行
|
||||||
|
- **THEN** 系統執行軟刪除(標記 is_deleted = true)
|
||||||
|
- **AND** 記錄刪除操作與原因
|
||||||
|
|
||||||
|
#### Scenario: 附件刪除記錄
|
||||||
|
- **GIVEN** 使用者刪除附件
|
||||||
|
- **WHEN** 刪除操作執行
|
||||||
|
- **THEN** 系統保留檔案於存檔區
|
||||||
|
- **AND** 記錄刪除操作詳情
|
||||||
|
|
||||||
|
### Requirement: Audit Log Immutability
|
||||||
|
系統 SHALL 確保稽核日誌不可竄改。
|
||||||
|
|
||||||
|
#### Scenario: 日誌寫入
|
||||||
|
- **GIVEN** 需要記錄稽核事件
|
||||||
|
- **WHEN** 日誌寫入
|
||||||
|
- **THEN** 日誌記錄不可被修改或刪除
|
||||||
|
- **AND** 包含校驗碼確保完整性
|
||||||
|
|
||||||
|
#### Scenario: 日誌完整性驗證
|
||||||
|
- **GIVEN** 稽核人員需要驗證日誌完整性
|
||||||
|
- **WHEN** 執行完整性檢查
|
||||||
|
- **THEN** 系統驗證所有日誌記錄的校驗碼
|
||||||
|
- **AND** 報告任何異常
|
||||||
|
|
||||||
|
### Requirement: Audit Query Interface
|
||||||
|
系統 SHALL 提供稽核查詢介面供授權人員使用。
|
||||||
|
|
||||||
|
#### Scenario: 依時間範圍查詢
|
||||||
|
- **GIVEN** 稽核人員需要查詢特定時間範圍的操作
|
||||||
|
- **WHEN** 設定時間範圍並執行查詢
|
||||||
|
- **THEN** 顯示該時間範圍內的所有稽核記錄
|
||||||
|
|
||||||
|
#### Scenario: 依操作者查詢
|
||||||
|
- **GIVEN** 稽核人員需要查詢特定使用者的操作歷史
|
||||||
|
- **WHEN** 選擇使用者並執行查詢
|
||||||
|
- **THEN** 顯示該使用者的所有操作記錄
|
||||||
|
|
||||||
|
#### Scenario: 依資源查詢
|
||||||
|
- **GIVEN** 稽核人員需要查詢特定任務或專案的變更歷史
|
||||||
|
- **WHEN** 選擇資源並執行查詢
|
||||||
|
- **THEN** 顯示該資源的完整變更歷程
|
||||||
|
|
||||||
|
#### Scenario: 稽核報告匯出
|
||||||
|
- **GIVEN** 稽核人員需要匯出稽核報告
|
||||||
|
- **WHEN** 選擇匯出格式(CSV/PDF)
|
||||||
|
- **THEN** 系統生成報告檔案供下載
|
||||||
|
|
||||||
|
### Requirement: Sensitive Operation Alerts
|
||||||
|
系統 SHALL 對高敏感度操作發送即時警示。
|
||||||
|
|
||||||
|
#### Scenario: 權限提升警示
|
||||||
|
- **GIVEN** 使用者被授予管理員權限
|
||||||
|
- **WHEN** 權限變更生效
|
||||||
|
- **THEN** 系統發送警示給安全管理員
|
||||||
|
|
||||||
|
#### Scenario: 大量刪除警示
|
||||||
|
- **GIVEN** 使用者在短時間內刪除大量資料
|
||||||
|
- **WHEN** 偵測到異常刪除模式
|
||||||
|
- **THEN** 系統發送警示並暫停操作
|
||||||
|
|
||||||
|
#### Scenario: 異常登入警示
|
||||||
|
- **GIVEN** 偵測到異常登入行為(如異地登入、非工作時間登入)
|
||||||
|
- **WHEN** 異常行為發生
|
||||||
|
- **THEN** 系統記錄並發送警示
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```
|
||||||
|
pjctrl_audit_logs
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── event_type: VARCHAR(50)
|
||||||
|
│ └── 'task.update', 'task.delete', 'project.update', 'user.permission_change', etc.
|
||||||
|
├── resource_type: VARCHAR(50)
|
||||||
|
│ └── 'task', 'project', 'user', 'attachment', 'trigger', etc.
|
||||||
|
├── resource_id: UUID
|
||||||
|
├── user_id: UUID (FK -> users)
|
||||||
|
├── action: ENUM('create', 'update', 'delete', 'restore', 'login', 'logout')
|
||||||
|
├── changes: JSON
|
||||||
|
│ └── { "field": "due_date", "old_value": "2024-01-15", "new_value": "2024-01-20" }
|
||||||
|
├── metadata: JSON
|
||||||
|
│ └── { "ip_address": "192.168.1.100", "user_agent": "...", "session_id": "..." }
|
||||||
|
├── sensitivity_level: ENUM('low', 'medium', 'high', 'critical')
|
||||||
|
├── checksum: VARCHAR(64) (SHA-256 of record content)
|
||||||
|
├── created_at: TIMESTAMP (immutable)
|
||||||
|
└── INDEX idx_audit_user (user_id, created_at)
|
||||||
|
└── INDEX idx_audit_resource (resource_type, resource_id, created_at)
|
||||||
|
└── INDEX idx_audit_time (created_at)
|
||||||
|
|
||||||
|
pjctrl_audit_alerts
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── audit_log_id: UUID (FK -> audit_logs)
|
||||||
|
├── alert_type: VARCHAR(50)
|
||||||
|
├── recipients: JSON (array of user IDs)
|
||||||
|
├── message: TEXT
|
||||||
|
├── is_acknowledged: BOOLEAN DEFAULT false
|
||||||
|
├── acknowledged_by: UUID (FK -> users)
|
||||||
|
├── acknowledged_at: TIMESTAMP
|
||||||
|
└── created_at: TIMESTAMP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Event Types Reference
|
||||||
|
|
||||||
|
| 事件類型 | 說明 | 敏感度 |
|
||||||
|
|---------|------|--------|
|
||||||
|
| task.create | 建立任務 | low |
|
||||||
|
| task.update | 更新任務 | low |
|
||||||
|
| task.delete | 刪除任務 | medium |
|
||||||
|
| task.assign | 指派任務 | low |
|
||||||
|
| task.blocker | 標記阻礙 | medium |
|
||||||
|
| project.create | 建立專案 | medium |
|
||||||
|
| project.update | 更新專案 | medium |
|
||||||
|
| project.delete | 刪除專案 | high |
|
||||||
|
| user.login | 使用者登入 | low |
|
||||||
|
| user.logout | 使用者登出 | low |
|
||||||
|
| user.permission_change | 權限變更 | critical |
|
||||||
|
| attachment.upload | 上傳附件 | low |
|
||||||
|
| attachment.download | 下載附件 | low |
|
||||||
|
| attachment.delete | 刪除附件 | medium |
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- 稽核日誌表設計為 append-only,不允許 UPDATE 或 DELETE
|
||||||
|
- 使用資料庫觸發器或應用層中間件自動記錄變更
|
||||||
|
- 日誌保留期限依法規要求設定(建議至少 7 年)
|
||||||
|
- 考慮使用時間序列資料庫(如 TimescaleDB)處理大量日誌
|
||||||
|
- Checksum 計算包含:event_type + resource_id + user_id + changes + created_at
|
||||||
152
openspec/specs/automation/spec.md
Normal file
152
openspec/specs/automation/spec.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Automation
|
||||||
|
|
||||||
|
自動化系統,提供觸發器與自動報告生成功能。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Trigger-Based Automation
|
||||||
|
系統 SHALL 支援觸發器 (Triggers),當特定條件滿足時自動執行動作。
|
||||||
|
|
||||||
|
#### Scenario: 狀態變更觸發通知
|
||||||
|
- **GIVEN** 專案設定了「當任務狀態變更為待測試時,通知設備工程師」的觸發器
|
||||||
|
- **WHEN** 任務狀態變更為「待測試」
|
||||||
|
- **THEN** 系統自動發送通知給指定的設備工程師群組
|
||||||
|
|
||||||
|
#### Scenario: 截止日期觸發提醒
|
||||||
|
- **GIVEN** 專案設定了「截止日前 3 天自動提醒」的觸發器
|
||||||
|
- **WHEN** 任務距離截止日還有 3 天
|
||||||
|
- **THEN** 系統自動發送提醒給任務指派者
|
||||||
|
|
||||||
|
#### Scenario: 建立觸發器
|
||||||
|
- **GIVEN** 專案管理者需要建立自動化規則
|
||||||
|
- **WHEN** 管理者設定觸發條件與動作
|
||||||
|
- **THEN** 系統儲存觸發器規則
|
||||||
|
- **AND** 規則立即生效
|
||||||
|
|
||||||
|
### Requirement: Trigger Conditions
|
||||||
|
系統 SHALL 支援多種觸發條件類型。
|
||||||
|
|
||||||
|
#### Scenario: 欄位變更條件
|
||||||
|
- **GIVEN** 觸發器設定為「當 Status 欄位變更為特定值」
|
||||||
|
- **WHEN** 任務的 Status 欄位變更為該值
|
||||||
|
- **THEN** 觸發器被觸發
|
||||||
|
|
||||||
|
#### Scenario: 時間條件
|
||||||
|
- **GIVEN** 觸發器設定為「每週五下午 4:00」
|
||||||
|
- **WHEN** 系統時間達到設定時間
|
||||||
|
- **THEN** 觸發器被觸發
|
||||||
|
|
||||||
|
#### Scenario: 複合條件
|
||||||
|
- **GIVEN** 觸發器設定為「當 Status = 完成 且 Priority = 高」
|
||||||
|
- **WHEN** 任務同時滿足兩個條件
|
||||||
|
- **THEN** 觸發器被觸發
|
||||||
|
|
||||||
|
### Requirement: Trigger Actions
|
||||||
|
系統 SHALL 支援多種觸發動作類型。
|
||||||
|
|
||||||
|
#### Scenario: 發送通知動作
|
||||||
|
- **GIVEN** 觸發器動作設定為發送通知
|
||||||
|
- **WHEN** 觸發器被觸發
|
||||||
|
- **THEN** 系統發送通知給指定對象
|
||||||
|
- **AND** 通知內容可使用變數(如任務名稱、指派者)
|
||||||
|
|
||||||
|
#### Scenario: 更新欄位動作
|
||||||
|
- **GIVEN** 觸發器動作設定為更新欄位
|
||||||
|
- **WHEN** 觸發器被觸發
|
||||||
|
- **THEN** 系統自動更新指定欄位的值
|
||||||
|
|
||||||
|
#### Scenario: 指派任務動作
|
||||||
|
- **GIVEN** 觸發器動作設定為自動指派
|
||||||
|
- **WHEN** 觸發器被觸發
|
||||||
|
- **THEN** 系統自動將任務指派給指定人員
|
||||||
|
|
||||||
|
### Requirement: Automated Weekly Report
|
||||||
|
系統 SHALL 每週五下午 4:00 自動彙整本週「已完成」與「進行中」的任務發送給主管。
|
||||||
|
|
||||||
|
#### Scenario: 週報自動生成
|
||||||
|
- **GIVEN** 系統排程設定為每週五 16:00
|
||||||
|
- **WHEN** 到達排程時間
|
||||||
|
- **THEN** 系統彙整每位主管所屬專案的任務狀態
|
||||||
|
- **AND** 生成週報並發送給該主管
|
||||||
|
|
||||||
|
#### Scenario: 週報內容
|
||||||
|
- **GIVEN** 週報生成中
|
||||||
|
- **WHEN** 系統彙整資料
|
||||||
|
- **THEN** 週報包含:
|
||||||
|
- 本週已完成任務清單
|
||||||
|
- 進行中任務清單
|
||||||
|
- 逾期任務警示
|
||||||
|
- 阻礙中任務清單
|
||||||
|
- 下週預計完成任務
|
||||||
|
|
||||||
|
#### Scenario: 週報發送方式
|
||||||
|
- **GIVEN** 週報已生成
|
||||||
|
- **WHEN** 系統發送週報
|
||||||
|
- **THEN** 透過系統內通知發送給收件者
|
||||||
|
- **AND** 週報可在系統內查閱歷史紀錄
|
||||||
|
|
||||||
|
#### Scenario: Email 發送(可選)
|
||||||
|
- **GIVEN** 週報已生成且系統已啟用 Email 功能
|
||||||
|
- **WHEN** 系統發送週報
|
||||||
|
- **THEN** 同時透過 Email 發送給收件者
|
||||||
|
- **AND** 記錄 Email 發送狀態
|
||||||
|
|
||||||
|
#### Scenario: Email 未啟用
|
||||||
|
- **GIVEN** 週報已生成但系統未啟用 Email 功能
|
||||||
|
- **WHEN** 系統發送週報
|
||||||
|
- **THEN** 僅透過系統內通知發送
|
||||||
|
- **AND** 記錄「Email 未啟用,僅發送系統通知」
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```
|
||||||
|
pjctrl_triggers
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── project_id: UUID (FK -> projects)
|
||||||
|
├── name: VARCHAR(200)
|
||||||
|
├── description: TEXT
|
||||||
|
├── trigger_type: ENUM('field_change', 'schedule', 'creation')
|
||||||
|
├── conditions: JSON
|
||||||
|
│ └── { "field": "status", "operator": "equals", "value": "testing" }
|
||||||
|
├── actions: JSON
|
||||||
|
│ └── [{ "type": "notify", "target": "group:equipment_engineers" }]
|
||||||
|
├── is_active: BOOLEAN DEFAULT true
|
||||||
|
├── created_by: UUID (FK -> users)
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── updated_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_trigger_logs
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── trigger_id: UUID (FK -> triggers)
|
||||||
|
├── task_id: UUID (FK -> tasks, nullable)
|
||||||
|
├── executed_at: TIMESTAMP
|
||||||
|
├── status: ENUM('success', 'failed')
|
||||||
|
└── error_message: TEXT
|
||||||
|
|
||||||
|
pjctrl_scheduled_reports
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── report_type: ENUM('weekly', 'monthly', 'custom')
|
||||||
|
├── recipient_id: UUID (FK -> users)
|
||||||
|
├── schedule_cron: VARCHAR(50)
|
||||||
|
├── last_sent_at: TIMESTAMP
|
||||||
|
├── next_run_at: TIMESTAMP
|
||||||
|
├── is_active: BOOLEAN DEFAULT true
|
||||||
|
├── email_enabled: BOOLEAN DEFAULT false (Email 發送開關)
|
||||||
|
└── created_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_report_history
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── scheduled_report_id: UUID (FK -> scheduled_reports)
|
||||||
|
├── generated_at: TIMESTAMP
|
||||||
|
├── content: JSON
|
||||||
|
├── sent_to: JSON (array of user IDs)
|
||||||
|
├── channels_used: JSON (e.g., ["in_app"] or ["in_app", "email"])
|
||||||
|
├── email_status: ENUM('sent', 'skipped', 'failed', null)
|
||||||
|
└── status: ENUM('sent', 'failed')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- 使用 Celery + Redis 處理排程任務
|
||||||
|
- 觸發器執行採用非同步處理,避免阻塞主流程
|
||||||
|
- 所有觸發器執行都記錄日誌供追蹤
|
||||||
126
openspec/specs/collaboration/spec.md
Normal file
126
openspec/specs/collaboration/spec.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Collaboration
|
||||||
|
|
||||||
|
協作功能系統,提供任務內討論、@提及通知與阻礙處理機制。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Task Comments
|
||||||
|
系統 SHALL 支援任務內部的討論線索,減少 Email 往返。
|
||||||
|
|
||||||
|
#### Scenario: 新增留言
|
||||||
|
- **GIVEN** 使用者擁有任務的存取權限
|
||||||
|
- **WHEN** 使用者在任務中新增留言
|
||||||
|
- **THEN** 系統儲存留言並顯示在討論區
|
||||||
|
- **AND** 記錄留言者與時間戳記
|
||||||
|
|
||||||
|
#### Scenario: 回覆留言
|
||||||
|
- **GIVEN** 任務已有留言
|
||||||
|
- **WHEN** 使用者回覆特定留言
|
||||||
|
- **THEN** 系統建立巢狀回覆結構
|
||||||
|
- **AND** 通知原留言者
|
||||||
|
|
||||||
|
#### Scenario: 編輯留言
|
||||||
|
- **GIVEN** 使用者是留言的作者
|
||||||
|
- **WHEN** 使用者編輯自己的留言
|
||||||
|
- **THEN** 系統更新留言內容
|
||||||
|
- **AND** 標示「已編輯」及編輯時間
|
||||||
|
|
||||||
|
### Requirement: User Mentions
|
||||||
|
系統 SHALL 支援 @相關人員 功能,提及時發送即時通知。
|
||||||
|
|
||||||
|
#### Scenario: @提及通知
|
||||||
|
- **GIVEN** 使用者在留言中使用 @username 提及某人
|
||||||
|
- **WHEN** 留言送出
|
||||||
|
- **THEN** 被提及者收到即時通知
|
||||||
|
- **AND** 通知包含任務連結與留言摘要
|
||||||
|
|
||||||
|
#### Scenario: @提及自動完成
|
||||||
|
- **GIVEN** 使用者輸入 @ 符號
|
||||||
|
- **WHEN** 使用者繼續輸入
|
||||||
|
- **THEN** 系統顯示符合的使用者名單供選擇
|
||||||
|
- **AND** 可用鍵盤或滑鼠選擇
|
||||||
|
|
||||||
|
### Requirement: Blocker Management
|
||||||
|
系統 SHALL 提供阻礙 (Blocker) 機制,強制要求主管介入排解。
|
||||||
|
|
||||||
|
#### Scenario: 標記阻礙
|
||||||
|
- **GIVEN** 工程師的任務遇到阻礙無法進行
|
||||||
|
- **WHEN** 工程師將任務標記為 "Blocked"
|
||||||
|
- **THEN** 系統設定 Blocker_Flag = true
|
||||||
|
- **AND** 強制發送即時通知給該任務所屬專案的主管
|
||||||
|
|
||||||
|
#### Scenario: 阻礙原因說明
|
||||||
|
- **GIVEN** 任務被標記為 Blocked
|
||||||
|
- **WHEN** 使用者標記阻礙
|
||||||
|
- **THEN** 系統要求填寫阻礙原因
|
||||||
|
- **AND** 原因顯示在任務詳情與通知中
|
||||||
|
|
||||||
|
#### Scenario: 解除阻礙
|
||||||
|
- **GIVEN** 主管或被指派者處理完阻礙
|
||||||
|
- **WHEN** 使用者解除 Blocked 狀態
|
||||||
|
- **THEN** 系統清除 Blocker_Flag
|
||||||
|
- **AND** 記錄解除時間與處理說明
|
||||||
|
|
||||||
|
### Requirement: Real-time Notifications
|
||||||
|
系統 SHALL 透過 Redis 推播即時通知。
|
||||||
|
|
||||||
|
#### Scenario: 即時通知推播
|
||||||
|
- **GIVEN** 發生需要通知的事件(如:被指派任務、被 @提及、阻礙標記)
|
||||||
|
- **WHEN** 事件發生
|
||||||
|
- **THEN** 系統透過 WebSocket 即時推播通知給相關使用者
|
||||||
|
- **AND** 未讀通知顯示數量標示
|
||||||
|
|
||||||
|
#### Scenario: 通知已讀標記
|
||||||
|
- **GIVEN** 使用者有未讀通知
|
||||||
|
- **WHEN** 使用者查看通知
|
||||||
|
- **THEN** 系統標記為已讀
|
||||||
|
- **AND** 更新未讀數量
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```
|
||||||
|
pjctrl_comments
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── task_id: UUID (FK -> tasks)
|
||||||
|
├── parent_comment_id: UUID (FK -> comments, nullable)
|
||||||
|
├── author_id: UUID (FK -> users)
|
||||||
|
├── content: TEXT
|
||||||
|
├── is_edited: BOOLEAN DEFAULT false
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── updated_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_mentions
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── comment_id: UUID (FK -> comments)
|
||||||
|
├── mentioned_user_id: UUID (FK -> users)
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── is_notified: BOOLEAN DEFAULT false
|
||||||
|
|
||||||
|
pjctrl_notifications
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── user_id: UUID (FK -> users)
|
||||||
|
├── type: ENUM('mention', 'assignment', 'blocker', 'status_change', 'comment')
|
||||||
|
├── reference_type: VARCHAR(50) (e.g., 'task', 'comment')
|
||||||
|
├── reference_id: UUID
|
||||||
|
├── title: VARCHAR(200)
|
||||||
|
├── message: TEXT
|
||||||
|
├── is_read: BOOLEAN DEFAULT false
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── read_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_blockers
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── task_id: UUID (FK -> tasks)
|
||||||
|
├── reported_by: UUID (FK -> users)
|
||||||
|
├── reason: TEXT
|
||||||
|
├── resolved_by: UUID (FK -> users, nullable)
|
||||||
|
├── resolution_note: TEXT
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── resolved_at: TIMESTAMP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- 使用 Redis Pub/Sub 處理即時通知推播
|
||||||
|
- WebSocket 連線管理確保訊息送達
|
||||||
|
- 離線使用者登入後補送未讀通知
|
||||||
164
openspec/specs/document-management/spec.md
Normal file
164
openspec/specs/document-management/spec.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Document Management
|
||||||
|
|
||||||
|
文件管理系統,提供檔案附件、版本控制、加密存儲與浮水印功能。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: File Attachments
|
||||||
|
系統 SHALL 支援任務層級的檔案附件,儲存於地端 NAS。
|
||||||
|
|
||||||
|
#### Scenario: 上傳附件
|
||||||
|
- **GIVEN** 使用者擁有任務的編輯權限
|
||||||
|
- **WHEN** 使用者上傳檔案至任務
|
||||||
|
- **THEN** 系統將檔案儲存至 NAS
|
||||||
|
- **AND** 建立附件記錄關聯至該任務
|
||||||
|
|
||||||
|
#### Scenario: 下載附件
|
||||||
|
- **GIVEN** 使用者擁有任務的存取權限
|
||||||
|
- **WHEN** 使用者下載附件
|
||||||
|
- **THEN** 系統驗證權限後提供檔案下載
|
||||||
|
- **AND** 記錄下載操作日誌
|
||||||
|
|
||||||
|
#### Scenario: 刪除附件
|
||||||
|
- **GIVEN** 使用者擁有任務的編輯權限
|
||||||
|
- **WHEN** 使用者刪除附件
|
||||||
|
- **THEN** 系統標記附件為已刪除(軟刪除)
|
||||||
|
- **AND** 保留檔案供稽核追溯
|
||||||
|
|
||||||
|
### Requirement: Version Control
|
||||||
|
系統 SHALL 支援檔案版本控制,追蹤所有版本變更。
|
||||||
|
|
||||||
|
#### Scenario: 上傳新版本
|
||||||
|
- **GIVEN** 任務已有同名附件
|
||||||
|
- **WHEN** 使用者上傳同名檔案
|
||||||
|
- **THEN** 系統建立新版本而非覆蓋
|
||||||
|
- **AND** 版本號自動遞增
|
||||||
|
|
||||||
|
#### Scenario: 查看版本歷史
|
||||||
|
- **GIVEN** 附件有多個版本
|
||||||
|
- **WHEN** 使用者查看版本歷史
|
||||||
|
- **THEN** 顯示所有版本清單
|
||||||
|
- **AND** 包含上傳者、上傳時間、檔案大小
|
||||||
|
|
||||||
|
#### Scenario: 回復舊版本
|
||||||
|
- **GIVEN** 使用者需要使用舊版本
|
||||||
|
- **WHEN** 使用者選擇回復特定版本
|
||||||
|
- **THEN** 系統將該版本設為當前版本
|
||||||
|
- **AND** 記錄回復操作
|
||||||
|
|
||||||
|
### Requirement: File Encryption
|
||||||
|
系統 SHALL 對半導體敏感圖檔進行 AES-256 加密存儲。
|
||||||
|
|
||||||
|
#### Scenario: 加密存儲
|
||||||
|
- **GIVEN** 專案設定為機密等級
|
||||||
|
- **WHEN** 使用者上傳檔案
|
||||||
|
- **THEN** 系統使用 AES-256 加密後存儲
|
||||||
|
- **AND** 加密金鑰安全管理
|
||||||
|
|
||||||
|
#### Scenario: 解密讀取
|
||||||
|
- **GIVEN** 使用者請求下載加密檔案
|
||||||
|
- **WHEN** 系統驗證權限通過
|
||||||
|
- **THEN** 系統解密檔案後提供下載
|
||||||
|
- **AND** 解密過程透明,使用者無感
|
||||||
|
|
||||||
|
#### Scenario: 加密金鑰輪換
|
||||||
|
- **GIVEN** 安全政策要求金鑰輪換
|
||||||
|
- **WHEN** 管理員執行金鑰輪換
|
||||||
|
- **THEN** 系統使用新金鑰重新加密所有檔案
|
||||||
|
- **AND** 舊金鑰安全銷毀
|
||||||
|
|
||||||
|
### Requirement: Dynamic Watermarking
|
||||||
|
系統 SHALL 在下載時自動為檔案加上使用者浮水印。
|
||||||
|
|
||||||
|
#### Scenario: 圖片浮水印
|
||||||
|
- **GIVEN** 使用者下載圖片類型附件
|
||||||
|
- **WHEN** 系統處理下載請求
|
||||||
|
- **THEN** 自動加上包含使用者姓名、工號、下載時間的浮水印
|
||||||
|
- **AND** 浮水印位置不影響主要內容
|
||||||
|
|
||||||
|
#### Scenario: PDF 浮水印
|
||||||
|
- **GIVEN** 使用者下載 PDF 類型附件
|
||||||
|
- **WHEN** 系統處理下載請求
|
||||||
|
- **THEN** 每頁加上浮水印
|
||||||
|
- **AND** 浮水印透明度適中
|
||||||
|
|
||||||
|
#### Scenario: 浮水印內容
|
||||||
|
- **GIVEN** 需要加上浮水印
|
||||||
|
- **WHEN** 系統生成浮水印
|
||||||
|
- **THEN** 浮水印包含:
|
||||||
|
- 使用者姓名
|
||||||
|
- 使用者工號
|
||||||
|
- 下載日期時間
|
||||||
|
- 機密等級標示(如適用)
|
||||||
|
|
||||||
|
### Requirement: Audit Trail
|
||||||
|
系統 SHALL 記錄所有文件操作供稽核追溯。
|
||||||
|
|
||||||
|
#### Scenario: 操作日誌記錄
|
||||||
|
- **GIVEN** 使用者對附件執行任何操作
|
||||||
|
- **WHEN** 操作完成
|
||||||
|
- **THEN** 系統記錄操作類型、操作者、時間、IP 位址
|
||||||
|
- **AND** 日誌不可竄改
|
||||||
|
|
||||||
|
#### Scenario: 稽核查詢
|
||||||
|
- **GIVEN** 稽核人員需要查詢文件操作歷史
|
||||||
|
- **WHEN** 稽核人員執行查詢
|
||||||
|
- **THEN** 顯示完整操作歷史
|
||||||
|
- **AND** 支援依時間、操作者、檔案篩選
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```
|
||||||
|
pjctrl_attachments
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── task_id: UUID (FK -> tasks)
|
||||||
|
├── filename: VARCHAR(500)
|
||||||
|
├── original_filename: VARCHAR(500)
|
||||||
|
├── file_path: VARCHAR(1000) (NAS path)
|
||||||
|
├── file_size: BIGINT
|
||||||
|
├── mime_type: VARCHAR(100)
|
||||||
|
├── version: INT DEFAULT 1
|
||||||
|
├── is_encrypted: BOOLEAN DEFAULT false
|
||||||
|
├── encryption_key_id: UUID (FK -> encryption_keys)
|
||||||
|
├── checksum: VARCHAR(64) (SHA-256)
|
||||||
|
├── uploaded_by: UUID (FK -> users)
|
||||||
|
├── is_deleted: BOOLEAN DEFAULT false
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── updated_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_attachment_versions
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── attachment_id: UUID (FK -> attachments)
|
||||||
|
├── version: INT
|
||||||
|
├── file_path: VARCHAR(1000)
|
||||||
|
├── file_size: BIGINT
|
||||||
|
├── checksum: VARCHAR(64)
|
||||||
|
├── uploaded_by: UUID (FK -> users)
|
||||||
|
├── is_current: BOOLEAN DEFAULT false
|
||||||
|
└── created_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_encryption_keys
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── key_hash: VARCHAR(64) (for verification)
|
||||||
|
├── algorithm: VARCHAR(20) DEFAULT 'AES-256'
|
||||||
|
├── is_active: BOOLEAN DEFAULT true
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── rotated_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_document_audit_logs
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── attachment_id: UUID (FK -> attachments)
|
||||||
|
├── user_id: UUID (FK -> users)
|
||||||
|
├── action: ENUM('upload', 'download', 'delete', 'restore', 'version_create', 'version_restore')
|
||||||
|
├── ip_address: VARCHAR(45)
|
||||||
|
├── user_agent: VARCHAR(500)
|
||||||
|
├── details: JSON
|
||||||
|
└── created_at: TIMESTAMP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- 加密金鑰存儲於獨立的 Key Management Service (KMS)
|
||||||
|
- 浮水印使用 Pillow (圖片) 和 PyPDF2 (PDF) 處理
|
||||||
|
- 大檔案使用串流處理避免記憶體溢出
|
||||||
|
- NAS 存儲路徑結構:`/{project_id}/{task_id}/{attachment_id}/{version}/`
|
||||||
100
openspec/specs/resource-management/spec.md
Normal file
100
openspec/specs/resource-management/spec.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Resource Management
|
||||||
|
|
||||||
|
資源管理系統,提供負載熱圖與人員容量追蹤,協助主管進行資源分配決策。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Workload Heatmap
|
||||||
|
系統 SHALL 提供負載熱圖,自動統計每人每週分配的任務總時數。
|
||||||
|
|
||||||
|
#### Scenario: 負載正常顯示
|
||||||
|
- **GIVEN** 某人員本週被指派的任務總時數低於其容量的 80%
|
||||||
|
- **WHEN** 主管查看負載熱圖
|
||||||
|
- **THEN** 該人員顯示為綠色(正常)
|
||||||
|
|
||||||
|
#### Scenario: 負載警告顯示
|
||||||
|
- **GIVEN** 某人員本週被指派的任務總時數達到其容量的 80%-100%
|
||||||
|
- **WHEN** 主管查看負載熱圖
|
||||||
|
- **THEN** 該人員顯示為黃色(警告)
|
||||||
|
|
||||||
|
#### Scenario: 負載超載顯示
|
||||||
|
- **GIVEN** 某人員本週被指派的任務總時數超過其容量的 100%
|
||||||
|
- **WHEN** 主管查看負載熱圖
|
||||||
|
- **THEN** 該人員顯示為紅色(超載)
|
||||||
|
- **AND** 可點擊查看詳細任務分配
|
||||||
|
|
||||||
|
### Requirement: Capacity Planning
|
||||||
|
系統 SHALL 支援人員容量規劃與追蹤。
|
||||||
|
|
||||||
|
#### Scenario: 設定人員容量
|
||||||
|
- **GIVEN** 管理者需要設定人員的週工時上限
|
||||||
|
- **WHEN** 管理者更新使用者的 Capacity 值
|
||||||
|
- **THEN** 系統儲存新的容量設定
|
||||||
|
- **AND** 重新計算該人員的負載百分比
|
||||||
|
|
||||||
|
#### Scenario: 容量調整(如請假)
|
||||||
|
- **GIVEN** 人員某週有請假安排
|
||||||
|
- **WHEN** 系統計算該週負載
|
||||||
|
- **THEN** 考慮實際可用工時進行計算
|
||||||
|
|
||||||
|
### Requirement: Multi-Project Health Dashboard
|
||||||
|
系統 SHALL 提供多專案健康看板,讓主管一覽所有專案狀態。
|
||||||
|
|
||||||
|
#### Scenario: 專案健康總覽
|
||||||
|
- **GIVEN** 主管負責多個專案
|
||||||
|
- **WHEN** 主管開啟健康看板
|
||||||
|
- **THEN** 顯示所有專案的進度、風險指標、延遲任務數
|
||||||
|
- **AND** 可依風險程度排序
|
||||||
|
|
||||||
|
#### Scenario: 專案延遲警示
|
||||||
|
- **GIVEN** 專案有任務超過截止日期
|
||||||
|
- **WHEN** 主管查看健康看板
|
||||||
|
- **THEN** 該專案標示為延遲狀態
|
||||||
|
- **AND** 顯示延遲任務數量與影響
|
||||||
|
|
||||||
|
### Requirement: Team Workload Distribution
|
||||||
|
系統 SHALL 視覺化呈現團隊工作分配狀況。
|
||||||
|
|
||||||
|
#### Scenario: 部門負載總覽
|
||||||
|
- **GIVEN** 主管需要了解部門整體負載
|
||||||
|
- **WHEN** 主管查看團隊負載視圖
|
||||||
|
- **THEN** 顯示部門內每位成員的負載狀況
|
||||||
|
- **AND** 可按專案或任務類型篩選
|
||||||
|
|
||||||
|
#### Scenario: 資源分配不均警示
|
||||||
|
- **GIVEN** 團隊中存在負載差異過大的情況
|
||||||
|
- **WHEN** 系統偵測到分配不均
|
||||||
|
- **THEN** 在看板上標示警示
|
||||||
|
- **AND** 建議可重新分配的任務
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```
|
||||||
|
pjctrl_workload_snapshots
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── user_id: UUID (FK -> users)
|
||||||
|
├── week_start: DATE
|
||||||
|
├── allocated_hours: DECIMAL
|
||||||
|
├── capacity_hours: DECIMAL
|
||||||
|
├── load_percentage: DECIMAL
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── updated_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_project_health
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── project_id: UUID (FK -> projects)
|
||||||
|
├── snapshot_date: DATE
|
||||||
|
├── total_tasks: INT
|
||||||
|
├── completed_tasks: INT
|
||||||
|
├── overdue_tasks: INT
|
||||||
|
├── blocked_tasks: INT
|
||||||
|
├── risk_score: DECIMAL
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── updated_at: TIMESTAMP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Calculation Rules
|
||||||
|
|
||||||
|
- **負載百分比** = (allocated_hours / capacity_hours) × 100
|
||||||
|
- **風險評分** = f(overdue_tasks, blocked_tasks, timeline_remaining)
|
||||||
|
- 快取計算結果於 Redis,每小時更新或任務變更時即時更新
|
||||||
164
openspec/specs/task-management/spec.md
Normal file
164
openspec/specs/task-management/spec.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Task Management
|
||||||
|
|
||||||
|
任務管理核心系統,支援多層級架構、自定義欄位與多維視角。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Hierarchical Task Structure
|
||||||
|
系統 SHALL 支援多層級任務架構:空間 (Space) > 專案 (Project) > 任務 (Task) > 子任務 (Sub-task)。
|
||||||
|
|
||||||
|
#### Scenario: 建立空間
|
||||||
|
- **GIVEN** 使用者擁有建立空間的權限
|
||||||
|
- **WHEN** 使用者建立新空間
|
||||||
|
- **THEN** 系統建立空間並設定擁有者
|
||||||
|
- **AND** 空間可包含多個專案
|
||||||
|
|
||||||
|
#### Scenario: 建立專案
|
||||||
|
- **GIVEN** 使用者在某空間內擁有建立專案的權限
|
||||||
|
- **WHEN** 使用者建立新專案
|
||||||
|
- **THEN** 系統建立專案並關聯至該空間
|
||||||
|
- **AND** 設定專案的 Owner、Budget、Timeline、Security_Level
|
||||||
|
|
||||||
|
#### Scenario: 建立任務與子任務
|
||||||
|
- **GIVEN** 使用者在專案內擁有建立任務的權限
|
||||||
|
- **WHEN** 使用者建立任務或子任務
|
||||||
|
- **THEN** 系統建立任務並維護父子關係
|
||||||
|
- **AND** 子任務繼承父任務的部分屬性
|
||||||
|
|
||||||
|
### Requirement: Custom Fields
|
||||||
|
系統 SHALL 支援自定義欄位,包含下拉選單、公式、人員標籤等類型。
|
||||||
|
|
||||||
|
#### Scenario: 新增自定義欄位
|
||||||
|
- **GIVEN** 專案管理者需要追蹤特定資料(如:封裝類型、機台編號、預計良率)
|
||||||
|
- **WHEN** 管理者在專案中新增自定義欄位
|
||||||
|
- **THEN** 系統建立欄位定義並套用至該專案所有任務
|
||||||
|
|
||||||
|
#### Scenario: 公式欄位計算
|
||||||
|
- **GIVEN** 任務包含公式類型的自定義欄位
|
||||||
|
- **WHEN** 相依欄位的值發生變更
|
||||||
|
- **THEN** 系統自動重新計算公式欄位的值
|
||||||
|
|
||||||
|
#### Scenario: 人員標籤欄位
|
||||||
|
- **GIVEN** 任務包含人員標籤類型的自定義欄位
|
||||||
|
- **WHEN** 使用者選擇人員
|
||||||
|
- **THEN** 系統驗證人員存在並建立關聯
|
||||||
|
- **AND** 被標籤的人員可收到相關通知
|
||||||
|
|
||||||
|
### Requirement: Multiple Views
|
||||||
|
系統 SHALL 支援多維視角:看板 (Kanban)、甘特圖 (Gantt)、列表 (List)、行事曆 (Calendar)。
|
||||||
|
|
||||||
|
#### Scenario: 看板視角
|
||||||
|
- **GIVEN** 使用者選擇看板視角
|
||||||
|
- **WHEN** 系統載入專案任務
|
||||||
|
- **THEN** 任務依狀態分組顯示為卡片
|
||||||
|
- **AND** 支援拖拉變更狀態
|
||||||
|
|
||||||
|
#### Scenario: 甘特圖視角
|
||||||
|
- **GIVEN** 使用者選擇甘特圖視角
|
||||||
|
- **WHEN** 系統載入專案任務
|
||||||
|
- **THEN** 任務依時間軸顯示
|
||||||
|
- **AND** 顯示任務相依關係與里程碑
|
||||||
|
|
||||||
|
#### Scenario: 列表視角
|
||||||
|
- **GIVEN** 使用者選擇列表視角
|
||||||
|
- **WHEN** 系統載入專案任務
|
||||||
|
- **THEN** 任務以表格形式顯示
|
||||||
|
- **AND** 支援欄位排序與篩選
|
||||||
|
|
||||||
|
#### Scenario: 行事曆視角
|
||||||
|
- **GIVEN** 使用者選擇行事曆視角
|
||||||
|
- **WHEN** 系統載入專案任務
|
||||||
|
- **THEN** 任務依截止日期顯示在行事曆上
|
||||||
|
|
||||||
|
### Requirement: Task Status Management
|
||||||
|
系統 SHALL 管理任務狀態,包含標準狀態與自定義狀態。
|
||||||
|
|
||||||
|
#### Scenario: 狀態變更
|
||||||
|
- **GIVEN** 使用者擁有更新任務的權限
|
||||||
|
- **WHEN** 使用者變更任務狀態
|
||||||
|
- **THEN** 系統更新狀態並記錄變更時間
|
||||||
|
- **AND** 觸發相關自動化規則(如有設定)
|
||||||
|
|
||||||
|
#### Scenario: 阻礙標記
|
||||||
|
- **GIVEN** 任務遇到阻礙無法進行
|
||||||
|
- **WHEN** 工程師將任務標記為 "Blocked"
|
||||||
|
- **THEN** 系統設定 Blocker_Flag = true
|
||||||
|
- **AND** 強制發送通知給主管要求介入排解
|
||||||
|
|
||||||
|
### Requirement: Task Assignment
|
||||||
|
系統 SHALL 支援任務指派與時間估算。
|
||||||
|
|
||||||
|
#### Scenario: 指派任務
|
||||||
|
- **GIVEN** 使用者擁有指派任務的權限
|
||||||
|
- **WHEN** 使用者將任務指派給某人
|
||||||
|
- **THEN** 系統更新 Assignee 並發送通知
|
||||||
|
- **AND** 任務計入被指派者的工作負載
|
||||||
|
|
||||||
|
#### Scenario: 時間估算與追蹤
|
||||||
|
- **GIVEN** 任務已被指派
|
||||||
|
- **WHEN** 使用者設定 Original_Estimate 與回報 Time_Spent
|
||||||
|
- **THEN** 系統記錄並計算剩餘時間
|
||||||
|
- **AND** 更新資源負載統計
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```
|
||||||
|
pjctrl_spaces
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── name: VARCHAR(200)
|
||||||
|
├── description: TEXT
|
||||||
|
├── owner_id: UUID (FK -> users)
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── updated_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_projects
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── space_id: UUID (FK -> spaces)
|
||||||
|
├── title: VARCHAR(200)
|
||||||
|
├── description: TEXT
|
||||||
|
├── owner_id: UUID (FK -> users)
|
||||||
|
├── budget: DECIMAL
|
||||||
|
├── start_date: DATE
|
||||||
|
├── end_date: DATE
|
||||||
|
├── security_level: ENUM('public', 'department', 'confidential')
|
||||||
|
├── status: VARCHAR(50)
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── updated_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_tasks
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── project_id: UUID (FK -> projects)
|
||||||
|
├── parent_task_id: UUID (FK -> tasks, nullable)
|
||||||
|
├── title: VARCHAR(500)
|
||||||
|
├── description: TEXT
|
||||||
|
├── assignee_id: UUID (FK -> users)
|
||||||
|
├── priority: ENUM('low', 'medium', 'high', 'urgent')
|
||||||
|
├── status: VARCHAR(50)
|
||||||
|
├── original_estimate: DECIMAL (hours)
|
||||||
|
├── time_spent: DECIMAL (hours)
|
||||||
|
├── blocker_flag: BOOLEAN DEFAULT false
|
||||||
|
├── due_date: DATETIME
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── updated_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_custom_fields
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── project_id: UUID (FK -> projects)
|
||||||
|
├── name: VARCHAR(100)
|
||||||
|
├── field_type: ENUM('text', 'number', 'dropdown', 'date', 'person', 'formula')
|
||||||
|
├── options: JSON (for dropdown)
|
||||||
|
├── formula: TEXT (for formula type)
|
||||||
|
├── is_required: BOOLEAN
|
||||||
|
└── created_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_task_custom_values
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── task_id: UUID (FK -> tasks)
|
||||||
|
├── field_id: UUID (FK -> custom_fields)
|
||||||
|
├── value: TEXT
|
||||||
|
└── updated_at: TIMESTAMP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Real-time Sync
|
||||||
|
|
||||||
|
系統使用 WebSocket 實現即時同步,當多人同時編輯同一個專案看板時,狀態能即時更新而不需刷新頁面。
|
||||||
154
openspec/specs/user-auth/spec.md
Normal file
154
openspec/specs/user-auth/spec.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# User Authentication & Authorization
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
使用者認證與授權系統,透過外部認證 API 進行身份驗證,提供細部權限控制。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: API-Based Authentication
|
||||||
|
系統 SHALL 限定使用外部認證 API (https://pj-auth-api.vercel.app) 進行登入認證,不支援其他認證方式。
|
||||||
|
|
||||||
|
#### Scenario: API 登入成功
|
||||||
|
- **GIVEN** 使用者擁有有效的企業帳號
|
||||||
|
- **WHEN** 使用者透過前端提交憑證
|
||||||
|
- **THEN** 系統呼叫 https://pj-auth-api.vercel.app 驗證憑證
|
||||||
|
- **AND** 驗證成功後建立 session 並回傳 JWT token
|
||||||
|
|
||||||
|
#### Scenario: API 登入失敗
|
||||||
|
- **GIVEN** 使用者提供無效的憑證
|
||||||
|
- **WHEN** 使用者嘗試登入
|
||||||
|
- **THEN** 認證 API 回傳錯誤
|
||||||
|
- **AND** 系統拒絕登入並顯示錯誤訊息
|
||||||
|
- **AND** 記錄失敗的登入嘗試
|
||||||
|
|
||||||
|
#### Scenario: 認證 API 無法連線
|
||||||
|
- **GIVEN** 認證 API 服務無法連線
|
||||||
|
- **WHEN** 使用者嘗試登入
|
||||||
|
- **THEN** 系統顯示服務暫時無法使用的訊息
|
||||||
|
- **AND** 記錄連線失敗事件
|
||||||
|
|
||||||
|
### Requirement: System Administrator
|
||||||
|
系統 SHALL 預設一個系統管理員帳號,擁有所有權限。系統管理員帳號必須存在於外部認證系統,且登入流程仍需透過外部認證 API;不允許本地繞過認證。
|
||||||
|
|
||||||
|
#### Scenario: 預設管理員帳號
|
||||||
|
- **GIVEN** 系統初始化完成
|
||||||
|
- **WHEN** 系統啟動
|
||||||
|
- **THEN** 存在預設管理員帳號 `ymirliu@panjit.com.tw`
|
||||||
|
- **AND** 該帳號擁有 `super_admin` 角色
|
||||||
|
- **AND** 該帳號不可被刪除或降級
|
||||||
|
|
||||||
|
#### Scenario: 管理員登入流程
|
||||||
|
- **GIVEN** 管理員帳號 `ymirliu@panjit.com.tw` 需要登入
|
||||||
|
- **WHEN** 管理員提交憑證
|
||||||
|
- **THEN** 系統仍需呼叫 https://pj-auth-api.vercel.app 驗證
|
||||||
|
- **AND** 不存在任何本地繞過認證的機制
|
||||||
|
- **AND** 驗證成功後才授予 `super_admin` 權限
|
||||||
|
|
||||||
|
#### Scenario: 管理員全域權限
|
||||||
|
- **GIVEN** 管理員帳號 `ymirliu@panjit.com.tw` 已通過 API 認證並登入
|
||||||
|
- **WHEN** 管理員存取任何資源
|
||||||
|
- **THEN** 系統允許存取,無視部門隔離限制
|
||||||
|
|
||||||
|
### Requirement: Role-Based Access Control
|
||||||
|
系統 SHALL 支援基於角色的存取控制 (RBAC)。
|
||||||
|
|
||||||
|
#### Scenario: 角色權限檢查
|
||||||
|
- **GIVEN** 使用者被指派特定角色 (如:工程師、主管、PMO)
|
||||||
|
- **WHEN** 使用者嘗試存取受保護的資源
|
||||||
|
- **THEN** 系統根據角色權限決定是否允許存取
|
||||||
|
|
||||||
|
#### Scenario: 角色指派
|
||||||
|
- **GIVEN** 管理員擁有使用者管理權限
|
||||||
|
- **WHEN** 管理員為使用者指派角色
|
||||||
|
- **THEN** 系統更新使用者的角色設定
|
||||||
|
- **AND** 新權限立即生效
|
||||||
|
|
||||||
|
### Requirement: Department Isolation
|
||||||
|
系統 SHALL 實施部門級別的資料隔離,確保跨部門資料安全。
|
||||||
|
|
||||||
|
#### Scenario: 部門資料隔離
|
||||||
|
- **GIVEN** 使用者屬於研發部門
|
||||||
|
- **WHEN** 使用者嘗試存取廠務部門的專案
|
||||||
|
- **THEN** 系統拒絕存取並顯示無權限訊息
|
||||||
|
|
||||||
|
#### Scenario: 跨部門專案存取
|
||||||
|
- **GIVEN** 專案被設定為跨部門可見
|
||||||
|
- **WHEN** 不同部門的使用者嘗試存取該專案
|
||||||
|
- **THEN** 系統根據專案的 Security_Level 設定決定是否允許存取
|
||||||
|
|
||||||
|
### Requirement: Session Management
|
||||||
|
系統 SHALL 管理使用者 session,包含過期與登出機制。
|
||||||
|
|
||||||
|
#### Scenario: Session 過期
|
||||||
|
- **GIVEN** 使用者已登入系統
|
||||||
|
- **WHEN** Session 超過設定的有效期限
|
||||||
|
- **THEN** 系統自動使 session 失效
|
||||||
|
- **AND** 使用者需重新登入
|
||||||
|
|
||||||
|
#### Scenario: 主動登出
|
||||||
|
- **GIVEN** 使用者已登入系統
|
||||||
|
- **WHEN** 使用者執行登出操作
|
||||||
|
- **THEN** 系統銷毀 session 並清除 token
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
```
|
||||||
|
pjctrl_users
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── email: VARCHAR(200) UNIQUE
|
||||||
|
├── name: VARCHAR(200)
|
||||||
|
├── department_id: UUID (FK)
|
||||||
|
├── role_id: UUID (FK)
|
||||||
|
├── skills: JSON
|
||||||
|
├── capacity: DECIMAL (週工時上限)
|
||||||
|
├── is_active: BOOLEAN
|
||||||
|
├── is_system_admin: BOOLEAN DEFAULT false (不可修改的系統管理員標記)
|
||||||
|
├── created_at: TIMESTAMP
|
||||||
|
└── updated_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_departments
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── name: VARCHAR(100)
|
||||||
|
├── parent_id: UUID (FK, self-reference)
|
||||||
|
└── created_at: TIMESTAMP
|
||||||
|
|
||||||
|
pjctrl_roles
|
||||||
|
├── id: UUID (PK)
|
||||||
|
├── name: VARCHAR(50)
|
||||||
|
├── permissions: JSON
|
||||||
|
├── is_system_role: BOOLEAN DEFAULT false
|
||||||
|
└── created_at: TIMESTAMP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Default Data (Seed)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 預設系統管理員角色
|
||||||
|
INSERT INTO pjctrl_roles (id, name, permissions, is_system_role) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000001', 'super_admin', '{"all": true}', true);
|
||||||
|
|
||||||
|
-- 預設系統管理員帳號
|
||||||
|
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);
|
||||||
|
```
|
||||||
|
|
||||||
|
## External Dependencies
|
||||||
|
|
||||||
|
- **Authentication API**: https://pj-auth-api.vercel.app (唯一認證方式)
|
||||||
|
|
||||||
|
## Authentication Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────┐ ┌─────────────┐ ┌──────────────────────────┐
|
||||||
|
│ User │────▶│ Frontend │────▶│ pj-auth-api.vercel.app │
|
||||||
|
└─────────┘ └─────────────┘ └──────────────────────────┘
|
||||||
|
│ │
|
||||||
|
│◀────── JWT Token ───────│
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Backend │ (驗證 JWT, 建立 Session)
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
1
prd.txt
Normal file
1
prd.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1. 產品目標 (Product Vision)建立一個跨部門的單一事實來源(Single Source of Truth),降低工程師報工負擔,並提供主管即時的資源負載與專案進度分析。2. 目標用戶與核心痛點用戶角色核心痛點系統解決方案基層工程師報工繁瑣、雜事多、不知優先順序自動化提醒、個人任務面板、一鍵回報阻礙單位主管不知道部屬在忙什麼、資源分配不均部署負荷熱圖 (Heatmap)、多專案健康看板行政/PMO資料散落在各處、進度更新不及時標準化範本、自動化週報、文件版控中心3. 功能需求 (Functional Requirements)A. 任務管理與階層 (Task & Hierarchy)多層級架構: 空間 (Space) > 專案 (Project) > 任務 (Task) > 子任務 (Sub-task)。自定義欄位 (Custom Fields): 支持下拉選單、公式、人員標籤。例如:封裝類型、機台編號、預計良率。多維視角: 支援看板 (Kanban)、甘特圖 (Gantt)、列表 (List) 與行事曆視角。B. 資源管理與協作 (Resource & Collaboration)負載熱圖: 自動統計每人每週分配的 Task 總時數,超過 80% 顯示黃色,超過 100% 顯示紅色。阻礙 (Blocker) 機制: 工程師可將任務標記為 "Blocked",強制要求主管介入排解,並發送即時通知。留言回覆: 任務內部的討論線索,支援 @相關人員,減少 Email 往返。C. 自動化與報告 (Automation & Reporting)觸發器 (Triggers): 當任務狀態變更為「待測試」時,自動通知設備工程師。自動週報: 每週五下午 4:00 自動彙整本週「已完成」與「進行中」的任務發送給主管。4. 非功能需求 (Non-functional Requirements)資訊安全: 支援企業 AD/LDAP 整合,設定細部權限(例如:廠務部不能看研發部的專案內容)。操作稽核: 所有的變更(誰改了死線、誰刪了文件)必須有 Log 紀錄。
|
||||||
Reference in New Issue
Block a user