feat: add CI/CD workflow and sync latest workspace changes
This commit is contained in:
@@ -2,3 +2,18 @@ NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
DATABASE_URL=postgresql+psycopg://fquiz:fquiz@db:5432/fquiz
|
||||
JWT_SECRET_KEY=change-this-in-production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
REFRESH_COOKIE_SECURE=false
|
||||
REFRESH_COOKIE_SAMESITE=lax
|
||||
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_USERNAME=admin
|
||||
INITIAL_ADMIN_PASSWORD=change-me-strong-password
|
||||
POSTGRES_DB=fquiz
|
||||
POSTGRES_USER=fquiz
|
||||
POSTGRES_PASSWORD=fquiz
|
||||
POSTGRES_IMAGE=docker.m.daocloud.io/library/postgres:16-alpine
|
||||
PYTHON_BASE_IMAGE=docker.m.daocloud.io/library/python:3.11-slim
|
||||
NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
name: fquiz 镜像构建与部署
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: fquiz-build-and-deploy
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
api_image: ${{ steps.vars.outputs.api_image }}
|
||||
web_image: ${{ steps.vars.outputs.web_image }}
|
||||
image_tag: ${{ steps.vars.outputs.image_tag }}
|
||||
steps:
|
||||
- name: 拉取代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 生成镜像变量
|
||||
id: vars
|
||||
shell: bash
|
||||
run: |
|
||||
OWNER_LC="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
echo "api_image=${{ env.REGISTRY }}/${OWNER_LC}/fquiz-api" >> "$GITHUB_OUTPUT"
|
||||
echo "web_image=${{ env.REGISTRY }}/${OWNER_LC}/fquiz-web" >> "$GITHUB_OUTPUT"
|
||||
echo "image_tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 安装 Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 登录 GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 构建并推送 API 镜像
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./api
|
||||
file: ./api/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.vars.outputs.api_image }}:${{ steps.vars.outputs.image_tag }}
|
||||
${{ steps.vars.outputs.api_image }}:latest
|
||||
cache-from: type=gha,scope=fquiz-api
|
||||
cache-to: type=gha,mode=max,scope=fquiz-api
|
||||
|
||||
- name: 构建并推送 Web 镜像
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./web
|
||||
file: ./web/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8000' }}
|
||||
tags: |
|
||||
${{ steps.vars.outputs.web_image }}:${{ steps.vars.outputs.image_tag }}
|
||||
${{ steps.vars.outputs.web_image }}:latest
|
||||
cache-from: type=gha,scope=fquiz-web
|
||||
cache-to: type=gha,mode=max,scope=fquiz-web
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: 通过 SSH 拉取并更新容器
|
||||
uses: appleboy/ssh-action@v1.2.0
|
||||
env:
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
API_IMAGE: ${{ needs.build-and-push.outputs.api_image }}
|
||||
WEB_IMAGE: ${{ needs.build-and-push.outputs.web_image }}
|
||||
IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:8000' }}
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
port: 22
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
password: ${{ secrets.SERVER_PASSWORD }}
|
||||
script_stop: true
|
||||
envs: DEPLOY_PATH,API_IMAGE,WEB_IMAGE,IMAGE_TAG,NEXT_PUBLIC_API_BASE_URL,GHCR_USERNAME,GHCR_TOKEN
|
||||
script: |
|
||||
set -euo pipefail
|
||||
|
||||
DEPLOY_DIR="${DEPLOY_PATH:-/opt/fquiz}"
|
||||
mkdir -p "${DEPLOY_DIR}"
|
||||
cd "${DEPLOY_DIR}"
|
||||
|
||||
cat > docker-compose.prod.yml <<'YAML'
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: fquiz-db
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-fquiz}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-fquiz}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fquiz}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- fquiz_db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fquiz} -d ${POSTGRES_DB:-fquiz}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
image: ${API_IMAGE}
|
||||
container_name: fquiz-api
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
API_HOST: ${API_HOST:-0.0.0.0}
|
||||
API_PORT: ${API_PORT:-8000}
|
||||
API_CORS_ORIGINS: ${API_CORS_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://fquiz:fquiz@db:5432/fquiz}
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-this-in-production}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15}
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
|
||||
REFRESH_COOKIE_SECURE: ${REFRESH_COOKIE_SECURE:-false}
|
||||
REFRESH_COOKIE_SAMESITE: ${REFRESH_COOKIE_SAMESITE:-lax}
|
||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@example.com}
|
||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-change-me-strong-password}
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
image: ${WEB_IMAGE}
|
||||
container_name: fquiz-web
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000}
|
||||
NODE_ENV: production
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
fquiz_db_data:
|
||||
YAML
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
cat > .env <<'ENV'
|
||||
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
DATABASE_URL=postgresql+psycopg://fquiz:fquiz@db:5432/fquiz
|
||||
JWT_SECRET_KEY=change-this-in-production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
REFRESH_COOKIE_SECURE=false
|
||||
REFRESH_COOKIE_SAMESITE=lax
|
||||
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_USERNAME=admin
|
||||
INITIAL_ADMIN_PASSWORD=change-me-strong-password
|
||||
POSTGRES_DB=fquiz
|
||||
POSTGRES_USER=fquiz
|
||||
POSTGRES_PASSWORD=fquiz
|
||||
ENV
|
||||
echo "[warn] .env 不存在,已写入默认模板,请尽快改成生产配置。"
|
||||
fi
|
||||
|
||||
cat > .images.env <<ENV
|
||||
API_IMAGE=${API_IMAGE}:${IMAGE_TAG}
|
||||
WEB_IMAGE=${WEB_IMAGE}:${IMAGE_TAG}
|
||||
NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
|
||||
ENV
|
||||
|
||||
echo "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USERNAME}" --password-stdin
|
||||
|
||||
COMPOSE_CMD="docker compose"
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
fi
|
||||
|
||||
${COMPOSE_CMD} --env-file .env --env-file .images.env -f docker-compose.prod.yml pull
|
||||
${COMPOSE_CMD} --env-file .env --env-file .images.env -f docker-compose.prod.yml up -d --remove-orphans
|
||||
${COMPOSE_CMD} --env-file .env --env-file .images.env -f docker-compose.prod.yml ps
|
||||
@@ -19,6 +19,7 @@ venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.db
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# fquiz
|
||||
|
||||
基于 Next.js + Python(FastAPI)的全栈 Monorepo 初始化工程。
|
||||
基于 Next.js + Python(FastAPI)的全栈 Monorepo,内置用户管理与登录认证。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── web/ # Next.js 16 + TypeScript + App Router
|
||||
├── api/ # FastAPI 服务
|
||||
├── web/ # Next.js 16 + TypeScript + App Router(登录态与用户管理页)
|
||||
├── api/ # FastAPI 服务(JWT + Refresh Session + RBAC)
|
||||
├── scripts/dev.sh # 前后端一键并行启动脚本
|
||||
├── .env.example # 根环境变量模板
|
||||
└── package.json # Monorepo 根脚本
|
||||
@@ -16,8 +16,9 @@
|
||||
## 技术栈
|
||||
|
||||
- 前端:Next.js 16、React 19、TypeScript
|
||||
- 后端:FastAPI、Uvicorn、Pydantic Settings
|
||||
- 协议:REST(默认 `/health`、`/api/v1/ping`)
|
||||
- 后端:FastAPI、SQLAlchemy、PostgreSQL/SQLite、Pydantic Settings
|
||||
- 认证:JWT Access Token(15m)+ Refresh Session(HttpOnly Cookie, 轮换)
|
||||
- 权限:RBAC(roles / permissions / user_roles / role_permissions)
|
||||
|
||||
## 环境要求
|
||||
|
||||
@@ -71,6 +72,22 @@ npm run build:web
|
||||
npm run lint:web
|
||||
```
|
||||
|
||||
## 认证接口
|
||||
|
||||
- `POST /api/v1/auth/register`
|
||||
- `POST /api/v1/auth/login`
|
||||
- `POST /api/v1/auth/refresh`
|
||||
- `POST /api/v1/auth/logout`
|
||||
- `GET /api/v1/auth/me`
|
||||
- `GET /api/v1/users`(需要 `user.manage`)
|
||||
- `GET /api/v1/users/{id}`(本人或 `user.manage`)
|
||||
- `PATCH /api/v1/users/{id}`(需要 `user.manage`)
|
||||
- `POST /api/v1/users/{id}/roles`(需要 `user.manage`)
|
||||
|
||||
初始化管理员(可选):
|
||||
- 在 `.env` 设置 `INITIAL_ADMIN_EMAIL`、`INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`
|
||||
- API 启动时会自动创建并赋予 `admin` 角色
|
||||
|
||||
## Docker Compose 部署
|
||||
|
||||
1. 准备环境变量:
|
||||
@@ -96,6 +113,7 @@ npm run lint:web
|
||||
|
||||
- 前端:`http://localhost:3000`
|
||||
- 后端:`http://localhost:8000/health`
|
||||
- PostgreSQL:`localhost:5432`
|
||||
|
||||
5. 停止并清理:
|
||||
|
||||
@@ -105,3 +123,4 @@ npm run lint:web
|
||||
|
||||
说明:
|
||||
- `NEXT_PUBLIC_API_BASE_URL` 在 Next.js 中是构建期注入;如果修改该值,需要重新执行 `docker compose up --build`。
|
||||
- 若使用 Docker Compose,默认 `DATABASE_URL` 指向容器内 `db` 服务(PostgreSQL)。
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
API_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
DATABASE_URL=sqlite:///./fquiz.db
|
||||
JWT_SECRET_KEY=change-this-in-production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
REFRESH_COOKIE_SECURE=false
|
||||
REFRESH_COOKIE_SAMESITE=lax
|
||||
INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_USERNAME=admin
|
||||
INITIAL_ADMIN_PASSWORD=change-me-strong-password
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
FROM python:3.11-slim
|
||||
ARG PYTHON_BASE_IMAGE=docker.m.daocloud.io/library/python:3.11-slim
|
||||
FROM ${PYTHON_BASE_IMAGE}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+15
-2
@@ -1,6 +1,13 @@
|
||||
# API Service
|
||||
|
||||
Python FastAPI 后端服务。
|
||||
FastAPI 后端服务,包含用户认证和 RBAC 权限控制。
|
||||
|
||||
## 核心能力
|
||||
|
||||
- JWT Access Token(默认 15 分钟)
|
||||
- Refresh Session(HttpOnly Cookie,默认 30 天,刷新轮换)
|
||||
- RBAC(用户-角色-权限)
|
||||
- 用户管理接口(需 `user.manage`)
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -11,7 +18,13 @@ python -m pip install -r api/requirements.txt
|
||||
python -m uvicorn api.app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## 默认接口
|
||||
## 主要接口
|
||||
|
||||
- `GET /health`
|
||||
- `GET /api/v1/ping`
|
||||
- `POST /api/v1/auth/register`
|
||||
- `POST /api/v1/auth/login`
|
||||
- `POST /api/v1/auth/refresh`
|
||||
- `POST /api/v1/auth/logout`
|
||||
- `GET /api/v1/auth/me`
|
||||
- `GET /api/v1/users`
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""API router package."""
|
||||
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .v1.auth import router as auth_router
|
||||
from .v1.users import router as users_router
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(auth_router)
|
||||
api_router.include_router(users_router)
|
||||
|
||||
|
||||
@api_router.get("/ping")
|
||||
def ping() -> dict[str, str]:
|
||||
return {"message": "pong"}
|
||||
@@ -0,0 +1 @@
|
||||
"""Versioned API routes."""
|
||||
@@ -0,0 +1,128 @@
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.config import get_settings
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import CurrentUser, get_current_user
|
||||
from ...schemas.auth import AuthTokenResponse, LoginRequest, MessageResponse, RegisterRequest
|
||||
from ...schemas.user import UserPublic
|
||||
from ...services.auth_service import (
|
||||
AuthResult,
|
||||
login_user,
|
||||
logout_user_session,
|
||||
refresh_user_session,
|
||||
register_user,
|
||||
)
|
||||
from ...services.user_service import serialize_user
|
||||
|
||||
settings = get_settings()
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str | None:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return None
|
||||
|
||||
|
||||
def _set_refresh_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=settings.refresh_cookie_name,
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=settings.refresh_cookie_secure,
|
||||
samesite=settings.refresh_cookie_samesite,
|
||||
max_age=settings.refresh_token_expire_days * 24 * 60 * 60,
|
||||
path="/api/v1/auth",
|
||||
)
|
||||
|
||||
|
||||
def _clear_refresh_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
key=settings.refresh_cookie_name,
|
||||
path="/api/v1/auth",
|
||||
httponly=True,
|
||||
secure=settings.refresh_cookie_secure,
|
||||
samesite=settings.refresh_cookie_samesite,
|
||||
)
|
||||
|
||||
|
||||
def _to_auth_response(result: AuthResult) -> AuthTokenResponse:
|
||||
return AuthTokenResponse(
|
||||
access_token=result.access_token,
|
||||
expires_in=result.expires_in,
|
||||
user=serialize_user(result.user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=AuthTokenResponse)
|
||||
def register(
|
||||
payload: RegisterRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthTokenResponse:
|
||||
result = register_user(
|
||||
db,
|
||||
payload,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
ip_address=_client_ip(request),
|
||||
)
|
||||
_set_refresh_cookie(response, result.refresh_token)
|
||||
return _to_auth_response(result)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthTokenResponse)
|
||||
def login(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthTokenResponse:
|
||||
result = login_user(
|
||||
db,
|
||||
payload,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
ip_address=_client_ip(request),
|
||||
)
|
||||
_set_refresh_cookie(response, result.refresh_token)
|
||||
return _to_auth_response(result)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=AuthTokenResponse)
|
||||
def refresh(
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthTokenResponse:
|
||||
result = refresh_user_session(
|
||||
db,
|
||||
request.cookies.get(settings.refresh_cookie_name),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
ip_address=_client_ip(request),
|
||||
)
|
||||
_set_refresh_cookie(response, result.refresh_token)
|
||||
return _to_auth_response(result)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=MessageResponse)
|
||||
def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> MessageResponse:
|
||||
logout_user_session(
|
||||
db,
|
||||
request.cookies.get(settings.refresh_cookie_name),
|
||||
user_id=None,
|
||||
)
|
||||
_clear_refresh_cookie(response)
|
||||
return MessageResponse(message="Logged out")
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserPublic)
|
||||
def me(current_user: CurrentUser = Depends(get_current_user)) -> UserPublic:
|
||||
return serialize_user(current_user.user)
|
||||
@@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import CurrentUser, get_current_user, require_permission
|
||||
from ...schemas.user import UserListResponse, UserPublic, UserRoleUpdateRequest, UserUpdateRequest
|
||||
from ...services.user_service import (
|
||||
get_user_by_id,
|
||||
list_users,
|
||||
serialize_user,
|
||||
set_user_roles,
|
||||
update_user,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("", response_model=UserListResponse)
|
||||
def list_all_users(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
_: CurrentUser = Depends(require_permission("user.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserListResponse:
|
||||
return list_users(db, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserPublic)
|
||||
def get_user_detail(
|
||||
user_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserPublic:
|
||||
can_manage = "admin" in current_user.role_codes or "user.manage" in current_user.permission_codes
|
||||
if current_user.user.id != user_id and not can_manage:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions",
|
||||
)
|
||||
|
||||
user = get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return serialize_user(user)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserPublic)
|
||||
def update_user_profile(
|
||||
user_id: str,
|
||||
payload: UserUpdateRequest,
|
||||
_: CurrentUser = Depends(require_permission("user.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserPublic:
|
||||
updated = update_user(db, user_id, payload)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found or username exists",
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
@router.post("/{user_id}/roles", response_model=UserPublic)
|
||||
def assign_roles(
|
||||
user_id: str,
|
||||
payload: UserRoleUpdateRequest,
|
||||
_: CurrentUser = Depends(require_permission("user.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserPublic:
|
||||
updated = set_user_roles(db, user_id, payload)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found or invalid roles",
|
||||
)
|
||||
return updated
|
||||
+2
-29
@@ -1,30 +1,3 @@
|
||||
from functools import lru_cache
|
||||
from .core.config import Settings, get_settings
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
api_name: str = "fquiz-api"
|
||||
api_version: str = "0.1.0"
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
api_cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
return [
|
||||
origin.strip()
|
||||
for origin in self.api_cors_origins.split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Core infrastructure for config, database, and security."""
|
||||
@@ -0,0 +1,56 @@
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
api_name: str = "fquiz-api"
|
||||
api_version: str = "0.1.0"
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
api_cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000"
|
||||
|
||||
database_url: str = "sqlite:///./fquiz.db"
|
||||
|
||||
jwt_secret_key: str = "change-this-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 30
|
||||
|
||||
refresh_cookie_name: str = "refresh_token"
|
||||
refresh_cookie_secure: bool = False
|
||||
refresh_cookie_samesite: Literal["lax", "strict", "none"] = "lax"
|
||||
|
||||
initial_admin_email: str | None = None
|
||||
initial_admin_username: str = "admin"
|
||||
initial_admin_password: str | None = None
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@field_validator("access_token_expire_minutes", "refresh_token_expire_days")
|
||||
@classmethod
|
||||
def validate_positive_numbers(cls, value: int) -> int:
|
||||
if value <= 0:
|
||||
msg = "Value must be greater than 0"
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
return [
|
||||
origin.strip()
|
||||
for origin in self.api_cors_origins.split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,47 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
connect_args: dict[str, bool] = {}
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(
|
||||
bind=engine,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
# Import models so metadata includes every table before create_all.
|
||||
from ..models import audit_log, auth_session, rbac, user # noqa: F401
|
||||
from ..services.seed_service import seed_defaults
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with SessionLocal() as db:
|
||||
seed_defaults(db)
|
||||
@@ -0,0 +1,73 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from ..models.rbac import Role
|
||||
from ..models.user import User
|
||||
from .database import get_db
|
||||
from .security import decode_access_token
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CurrentUser:
|
||||
user: User
|
||||
role_codes: set[str]
|
||||
permission_codes: set[str]
|
||||
|
||||
|
||||
def _load_user_with_rbac(db: Session, user_id: str) -> User | None:
|
||||
stmt = (
|
||||
select(User)
|
||||
.options(joinedload(User.roles).joinedload(Role.permissions))
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
return db.execute(stmt).unique().scalar_one_or_none()
|
||||
|
||||
|
||||
def _get_user_permissions(user: User) -> set[str]:
|
||||
return {permission.code for role in user.roles for permission in role.permissions}
|
||||
|
||||
|
||||
def get_current_user(
|
||||
db: Session = Depends(get_db),
|
||||
token: str = Depends(oauth2_scheme),
|
||||
) -> CurrentUser:
|
||||
payload = decode_access_token(token)
|
||||
user_id = str(payload["sub"])
|
||||
user = _load_user_with_rbac(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User is disabled",
|
||||
)
|
||||
|
||||
return CurrentUser(
|
||||
user=user,
|
||||
role_codes={role.code for role in user.roles},
|
||||
permission_codes=_get_user_permissions(user),
|
||||
)
|
||||
|
||||
|
||||
def require_permission(permission_code: str):
|
||||
def dependency(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
if "admin" in current_user.role_codes:
|
||||
return current_user
|
||||
if permission_code not in current_user.permission_codes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing permission: {permission_code}",
|
||||
)
|
||||
return current_user
|
||||
|
||||
return dependency
|
||||
@@ -0,0 +1,81 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHash, VerifyMismatchError
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
password_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return password_hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return password_hasher.verify(password_hash, password)
|
||||
except (VerifyMismatchError, InvalidHash):
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(
|
||||
*,
|
||||
user_id: str,
|
||||
role_codes: list[str],
|
||||
permission_codes: list[str],
|
||||
expires_minutes: int | None = None,
|
||||
) -> tuple[str, int]:
|
||||
settings = get_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
minutes = expires_minutes or settings.access_token_expire_minutes
|
||||
expires_at = now + timedelta(minutes=minutes)
|
||||
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"roles": role_codes,
|
||||
"permissions": permission_codes,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expires_at.timestamp()),
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.jwt_secret_key,
|
||||
algorithm=settings.jwt_algorithm,
|
||||
)
|
||||
return token, int((expires_at - now).total_seconds())
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.jwt_secret_key,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired access token",
|
||||
) from exc
|
||||
|
||||
if not payload.get("sub"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token payload",
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def create_refresh_token() -> str:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
+14
-8
@@ -1,13 +1,25 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import get_settings
|
||||
from .api.router import api_router
|
||||
from .core.config import get_settings
|
||||
from .core.database import init_db
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.api_name,
|
||||
version=settings.api_version,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
@@ -27,10 +39,4 @@ def health() -> dict[str, str]:
|
||||
"version": settings.api_version,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/ping")
|
||||
def ping() -> dict[str, str]:
|
||||
return {
|
||||
"message": "pong",
|
||||
"service": settings.api_name,
|
||||
}
|
||||
app.include_router(api_router)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Database models."""
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.database import Base
|
||||
from .base import utcnow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
index=True,
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||
detail: Mapped[str | None] = mapped_column(Text())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
user: Mapped[User | None] = relationship("User", back_populates="audit_logs")
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.database import Base
|
||||
from .base import utcnow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
|
||||
|
||||
class AuthSession(Base):
|
||||
__tablename__ = "auth_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid4()),
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
refresh_token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512))
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64))
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="sessions")
|
||||
@@ -0,0 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
|
||||
users: Mapped[list[User]] = relationship(
|
||||
"User",
|
||||
secondary="user_roles",
|
||||
back_populates="roles",
|
||||
lazy="selectin",
|
||||
)
|
||||
permissions: Mapped[list[Permission]] = relationship(
|
||||
"Permission",
|
||||
secondary="role_permissions",
|
||||
back_populates="roles",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
|
||||
roles: Mapped[list[Role]] = relationship(
|
||||
"Role",
|
||||
secondary="role_permissions",
|
||||
back_populates="permissions",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
role_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
role_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
permission_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("permissions.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.database import Base
|
||||
from .base import utcnow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .auth_session import AuthSession
|
||||
from .audit_log import AuditLog
|
||||
from .rbac import Role
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid4()),
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
status: Mapped[str] = mapped_column(String(32), default="active", index=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
)
|
||||
|
||||
roles: Mapped[list[Role]] = relationship(
|
||||
"Role",
|
||||
secondary="user_roles",
|
||||
back_populates="users",
|
||||
lazy="selectin",
|
||||
)
|
||||
sessions: Mapped[list[AuthSession]] = relationship(
|
||||
"AuthSession",
|
||||
back_populates="user",
|
||||
lazy="selectin",
|
||||
)
|
||||
audit_logs: Mapped[list[AuditLog]] = relationship(
|
||||
"AuditLog",
|
||||
back_populates="user",
|
||||
lazy="selectin",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic schemas."""
|
||||
@@ -0,0 +1,25 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from .user import UserPublic
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class AuthTokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: UserPublic
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
@@ -0,0 +1,29 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class UserPublic(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
username: str
|
||||
status: str
|
||||
role_codes: list[str]
|
||||
permission_codes: list[str]
|
||||
created_at: datetime
|
||||
last_login_at: datetime | None = None
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
items: list[UserPublic]
|
||||
total: int
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
username: str | None = Field(default=None, min_length=3, max_length=64)
|
||||
status: Literal["active", "disabled"] | None = None
|
||||
|
||||
|
||||
class UserRoleUpdateRequest(BaseModel):
|
||||
role_codes: list[str] = Field(min_length=1)
|
||||
@@ -0,0 +1 @@
|
||||
"""Business services."""
|
||||
@@ -0,0 +1,238 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.audit_log import AuditLog
|
||||
from ..models.auth_session import AuthSession
|
||||
from ..models.base import utcnow
|
||||
from ..models.rbac import Role
|
||||
from ..models.user import User
|
||||
from ..schemas.auth import LoginRequest, RegisterRequest
|
||||
from .user_service import get_user_by_email
|
||||
from ..core.config import get_settings
|
||||
from ..core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
hash_password,
|
||||
hash_token,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthResult:
|
||||
access_token: str
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
user: User
|
||||
|
||||
|
||||
def register_user(
|
||||
db: Session,
|
||||
payload: RegisterRequest,
|
||||
*,
|
||||
user_agent: str | None,
|
||||
ip_address: str | None,
|
||||
) -> AuthResult:
|
||||
email = payload.email.lower()
|
||||
|
||||
duplicate = db.scalar(
|
||||
select(User.id).where(or_(User.email == email, User.username == payload.username))
|
||||
)
|
||||
if duplicate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Email or username already exists",
|
||||
)
|
||||
|
||||
role = db.scalar(select(Role).where(Role.code == "user"))
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Default role not initialized",
|
||||
)
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
username=payload.username,
|
||||
password_hash=hash_password(payload.password),
|
||||
status="active",
|
||||
)
|
||||
user.roles.append(role)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
db.add(AuditLog(user_id=user.id, action="auth.register", detail="User registered"))
|
||||
db.commit()
|
||||
|
||||
return issue_auth_result_for_user(
|
||||
db,
|
||||
user_id=user.id,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
action="auth.login_after_register",
|
||||
)
|
||||
|
||||
|
||||
def login_user(
|
||||
db: Session,
|
||||
payload: LoginRequest,
|
||||
*,
|
||||
user_agent: str | None,
|
||||
ip_address: str | None,
|
||||
) -> AuthResult:
|
||||
user = get_user_by_email(db, payload.email.lower())
|
||||
if not user or not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User is disabled",
|
||||
)
|
||||
|
||||
return issue_auth_result_for_user(
|
||||
db,
|
||||
user_id=user.id,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
action="auth.login",
|
||||
)
|
||||
|
||||
|
||||
def refresh_user_session(
|
||||
db: Session,
|
||||
refresh_token: str | None,
|
||||
*,
|
||||
user_agent: str | None,
|
||||
ip_address: str | None,
|
||||
) -> AuthResult:
|
||||
if not refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing refresh token",
|
||||
)
|
||||
|
||||
now = utcnow()
|
||||
token_hash = hash_token(refresh_token)
|
||||
session = db.scalar(
|
||||
select(AuthSession).where(
|
||||
and_(
|
||||
AuthSession.refresh_token_hash == token_hash,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
AuthSession.expires_at > now,
|
||||
)
|
||||
)
|
||||
)
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh session",
|
||||
)
|
||||
|
||||
session.revoked_at = now
|
||||
db.add(AuditLog(user_id=session.user_id, action="auth.refresh", detail="Session rotated"))
|
||||
db.commit()
|
||||
|
||||
return issue_auth_result_for_user(
|
||||
db,
|
||||
user_id=session.user_id,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
action="auth.refresh_issued",
|
||||
)
|
||||
|
||||
|
||||
def logout_user_session(db: Session, refresh_token: str | None, *, user_id: str | None) -> None:
|
||||
if not refresh_token:
|
||||
return
|
||||
|
||||
token_hash = hash_token(refresh_token)
|
||||
now = utcnow()
|
||||
session = db.scalar(
|
||||
select(AuthSession).where(
|
||||
and_(
|
||||
AuthSession.refresh_token_hash == token_hash,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
if not session:
|
||||
return
|
||||
|
||||
if user_id and session.user_id != user_id:
|
||||
return
|
||||
|
||||
session.revoked_at = now
|
||||
db.add(AuditLog(user_id=session.user_id, action="auth.logout", detail="Session revoked"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def issue_auth_result_for_user(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
user_agent: str | None,
|
||||
ip_address: str | None,
|
||||
action: str,
|
||||
) -> AuthResult:
|
||||
user = get_user_by_id_with_rbac(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User is disabled",
|
||||
)
|
||||
|
||||
refresh_token = create_refresh_token()
|
||||
refresh_expires_at = utcnow() + timedelta(days=settings.refresh_token_expire_days)
|
||||
|
||||
db.add(
|
||||
AuthSession(
|
||||
user_id=user.id,
|
||||
refresh_token_hash=hash_token(refresh_token),
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
expires_at=refresh_expires_at,
|
||||
)
|
||||
)
|
||||
|
||||
user.last_login_at = utcnow()
|
||||
db.add(AuditLog(user_id=user.id, action=action, detail="Access token issued"))
|
||||
db.commit()
|
||||
|
||||
user = get_user_by_id_with_rbac(db, user_id)
|
||||
role_codes = sorted({role.code for role in user.roles})
|
||||
permission_codes = sorted(
|
||||
{permission.code for role in user.roles for permission in role.permissions}
|
||||
)
|
||||
access_token, expires_in = create_access_token(
|
||||
user_id=user.id,
|
||||
role_codes=role_codes,
|
||||
permission_codes=permission_codes,
|
||||
)
|
||||
|
||||
return AuthResult(
|
||||
access_token=access_token,
|
||||
expires_in=expires_in,
|
||||
refresh_token=refresh_token,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_id_with_rbac(db: Session, user_id: str) -> User | None:
|
||||
from .user_service import get_user_by_id
|
||||
|
||||
return get_user_by_id(db, user_id)
|
||||
@@ -0,0 +1,90 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.config import get_settings
|
||||
from ..core.security import hash_password
|
||||
from ..models.rbac import Permission, Role
|
||||
from ..models.user import User
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
DEFAULT_PERMISSIONS: dict[str, str] = {
|
||||
"user.read": "Read user profile",
|
||||
"user.write": "Update user profile",
|
||||
"user.manage": "Manage all users and roles",
|
||||
}
|
||||
|
||||
DEFAULT_ROLES: dict[str, dict[str, object]] = {
|
||||
"admin": {
|
||||
"name": "Administrator",
|
||||
"permissions": ["user.read", "user.write", "user.manage"],
|
||||
},
|
||||
"user": {
|
||||
"name": "User",
|
||||
"permissions": ["user.read"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def seed_defaults(db: Session) -> None:
|
||||
permissions = _seed_permissions(db)
|
||||
_seed_roles(db, permissions)
|
||||
_seed_initial_admin(db)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_permissions(db: Session) -> dict[str, Permission]:
|
||||
permission_map: dict[str, Permission] = {}
|
||||
for code, name in DEFAULT_PERMISSIONS.items():
|
||||
permission = db.scalar(select(Permission).where(Permission.code == code))
|
||||
if not permission:
|
||||
permission = Permission(code=code, name=name)
|
||||
db.add(permission)
|
||||
permission_map[code] = permission
|
||||
|
||||
db.flush()
|
||||
# Refresh map with persisted entities.
|
||||
for code in DEFAULT_PERMISSIONS:
|
||||
permission = db.scalar(select(Permission).where(Permission.code == code))
|
||||
if not permission:
|
||||
msg = f"Permission not found after seeding: {code}"
|
||||
raise RuntimeError(msg)
|
||||
permission_map[code] = permission
|
||||
return permission_map
|
||||
|
||||
|
||||
def _seed_roles(db: Session, permission_map: dict[str, Permission]) -> None:
|
||||
for code, role_info in DEFAULT_ROLES.items():
|
||||
role = db.scalar(select(Role).where(Role.code == code))
|
||||
if not role:
|
||||
role = Role(code=code, name=str(role_info["name"]))
|
||||
db.add(role)
|
||||
db.flush()
|
||||
|
||||
role.permissions = [permission_map[p] for p in role_info["permissions"]]
|
||||
db.flush()
|
||||
|
||||
|
||||
def _seed_initial_admin(db: Session) -> None:
|
||||
if not settings.initial_admin_email or not settings.initial_admin_password:
|
||||
return
|
||||
|
||||
admin_role = db.scalar(select(Role).where(Role.code == "admin"))
|
||||
if not admin_role:
|
||||
return
|
||||
|
||||
admin_email = settings.initial_admin_email.lower()
|
||||
user = db.scalar(select(User).where(User.email == admin_email))
|
||||
if not user:
|
||||
user = User(
|
||||
email=admin_email,
|
||||
username=settings.initial_admin_username,
|
||||
password_hash=hash_password(settings.initial_admin_password),
|
||||
status="active",
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
role_codes = {role.code for role in user.roles}
|
||||
if "admin" not in role_codes:
|
||||
user.roles.append(admin_role)
|
||||
@@ -0,0 +1,98 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from ..models.rbac import Role
|
||||
from ..models.user import User
|
||||
from ..schemas.user import UserListResponse, UserPublic, UserRoleUpdateRequest, UserUpdateRequest
|
||||
|
||||
|
||||
def _user_with_rbac_stmt():
|
||||
return select(User).options(joinedload(User.roles).joinedload(Role.permissions))
|
||||
|
||||
|
||||
def list_users(db: Session, *, limit: int, offset: int) -> UserListResponse:
|
||||
total = db.scalar(select(func.count()).select_from(User)) or 0
|
||||
stmt = (
|
||||
_user_with_rbac_stmt()
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
users = db.execute(stmt).unique().scalars().all()
|
||||
return UserListResponse(items=[serialize_user(user) for user in users], total=total)
|
||||
|
||||
|
||||
def get_user_by_id(db: Session, user_id: str) -> User | None:
|
||||
stmt = _user_with_rbac_stmt().where(User.id == user_id)
|
||||
return db.execute(stmt).unique().scalar_one_or_none()
|
||||
|
||||
|
||||
def get_user_by_email(db: Session, email: str) -> User | None:
|
||||
stmt = _user_with_rbac_stmt().where(User.email == email)
|
||||
return db.execute(stmt).unique().scalar_one_or_none()
|
||||
|
||||
|
||||
def update_user(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
payload: UserUpdateRequest,
|
||||
) -> UserPublic | None:
|
||||
user = get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if payload.username and payload.username != user.username:
|
||||
duplicate = db.scalar(
|
||||
select(User.id).where(User.username == payload.username, User.id != user.id)
|
||||
)
|
||||
if duplicate:
|
||||
return None
|
||||
user.username = payload.username
|
||||
|
||||
if payload.status:
|
||||
user.status = payload.status
|
||||
|
||||
db.commit()
|
||||
updated = get_user_by_id(db, user_id)
|
||||
return serialize_user(updated) if updated else None
|
||||
|
||||
|
||||
def set_user_roles(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
payload: UserRoleUpdateRequest,
|
||||
) -> UserPublic | None:
|
||||
user = get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
role_codes = sorted(set(payload.role_codes))
|
||||
roles = db.execute(select(Role).where(Role.code.in_(role_codes))).scalars().all()
|
||||
if len(roles) != len(role_codes):
|
||||
return None
|
||||
|
||||
user.roles = roles
|
||||
db.commit()
|
||||
updated = get_user_by_id(db, user_id)
|
||||
return serialize_user(updated)
|
||||
|
||||
|
||||
def serialize_user(user: User | None) -> UserPublic:
|
||||
if user is None:
|
||||
msg = "User is required"
|
||||
raise ValueError(msg)
|
||||
|
||||
role_codes = sorted({role.code for role in user.roles})
|
||||
permission_codes = sorted(
|
||||
{permission.code for role in user.roles for permission in role.permissions}
|
||||
)
|
||||
return UserPublic(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
status=user.status,
|
||||
role_codes=role_codes,
|
||||
permission_codes=permission_codes,
|
||||
created_at=user.created_at,
|
||||
last_login_at=user.last_login_at,
|
||||
)
|
||||
@@ -7,4 +7,9 @@ dependencies = [
|
||||
"fastapi>=0.116.0,<1.0.0",
|
||||
"uvicorn[standard]>=0.35.0,<1.0.0",
|
||||
"pydantic-settings>=2.10.0,<3.0.0",
|
||||
"sqlalchemy>=2.0.36,<3.0.0",
|
||||
"psycopg[binary]>=3.2.3,<4.0.0",
|
||||
"PyJWT>=2.10.1,<3.0.0",
|
||||
"argon2-cffi>=23.1.0,<24.0.0",
|
||||
"email-validator>=2.2.0,<3.0.0",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
fastapi>=0.116.0,<1.0.0
|
||||
uvicorn[standard]>=0.35.0,<1.0.0
|
||||
pydantic-settings>=2.10.0,<3.0.0
|
||||
sqlalchemy>=2.0.36,<3.0.0
|
||||
psycopg[binary]>=3.2.3,<4.0.0
|
||||
PyJWT>=2.10.1,<3.0.0
|
||||
argon2-cffi>=23.1.0,<24.0.0
|
||||
email-validator>=2.2.0,<3.0.0
|
||||
|
||||
@@ -1,13 +1,50 @@
|
||||
services:
|
||||
db:
|
||||
image: ${POSTGRES_IMAGE:-docker.m.daocloud.io/library/postgres:16-alpine}
|
||||
container_name: fquiz-db
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-fquiz}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-fquiz}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fquiz}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- fquiz_db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-fquiz} -d ${POSTGRES_DB:-fquiz}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
PYTHON_BASE_IMAGE: ${PYTHON_BASE_IMAGE:-docker.m.daocloud.io/library/python:3.11-slim}
|
||||
container_name: fquiz-api
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
API_HOST: ${API_HOST:-0.0.0.0}
|
||||
API_PORT: ${API_PORT:-8000}
|
||||
API_CORS_ORIGINS: ${API_CORS_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://fquiz:fquiz@db:5432/fquiz}
|
||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-this-in-production}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15}
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
|
||||
REFRESH_COOKIE_SECURE: ${REFRESH_COOKIE_SECURE:-false}
|
||||
REFRESH_COOKIE_SAMESITE: ${REFRESH_COOKIE_SAMESITE:-lax}
|
||||
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@example.com}
|
||||
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
|
||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-change-me-strong-password}
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
healthcheck:
|
||||
@@ -29,6 +66,7 @@ services:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NODE_BASE_IMAGE: ${NODE_BASE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000}
|
||||
container_name: fquiz-web
|
||||
depends_on:
|
||||
@@ -40,3 +78,6 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
fquiz_db_data:
|
||||
|
||||
+5
-3
@@ -1,10 +1,12 @@
|
||||
FROM node:22-alpine AS deps
|
||||
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
|
||||
|
||||
FROM ${NODE_BASE_IMAGE} AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
FROM ${NODE_BASE_IMAGE} AS builder
|
||||
WORKDIR /app
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
@@ -14,7 +16,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
FROM ${NODE_BASE_IMAGE} AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import type { UserListResponse, UserPublic } from "@/types/auth";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
||||
const [users, setUsers] = useState<UserPublic[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!user || !hasPermission("user.manage")) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetchWithAuth("/api/v1/users?limit=200&offset=0");
|
||||
if (!response.ok) {
|
||||
setError(await readApiError(response));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as UserListResponse;
|
||||
setUsers(payload.items);
|
||||
setLoading(false);
|
||||
};
|
||||
void load();
|
||||
}, [fetchWithAuth, hasPermission, user]);
|
||||
|
||||
if (initializing || loading) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-5xl items-center justify-center px-6 py-20">
|
||||
<p className="text-sm text-zinc-500">Loading users...</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||
请先登录后再访问用户管理页面。
|
||||
</p>
|
||||
<Link href="/" className="text-sm underline">
|
||||
返回首页
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasPermission("user.manage")) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col justify-center gap-4 px-6 py-20">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||
你没有访问该页面的权限(需要 `user.manage`)。
|
||||
</p>
|
||||
<Link href="/" className="text-sm underline">
|
||||
返回首页
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-5xl flex-col gap-6 px-6 py-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">用户管理</h1>
|
||||
<Link href="/" className="text-sm underline">
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<pre className="overflow-auto rounded-xl border border-red-500/30 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-300">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-black/10 dark:border-white/15">
|
||||
<table className="min-w-full divide-y divide-black/10 text-left text-sm dark:divide-white/15">
|
||||
<thead className="bg-black/[0.03] dark:bg-white/[0.06]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">ID</th>
|
||||
<th className="px-4 py-3 font-medium">Email</th>
|
||||
<th className="px-4 py-3 font-medium">Username</th>
|
||||
<th className="px-4 py-3 font-medium">Status</th>
|
||||
<th className="px-4 py-3 font-medium">Roles</th>
|
||||
<th className="px-4 py-3 font-medium">Permissions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-black/10 dark:divide-white/15">
|
||||
{users.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs">
|
||||
{item.id}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">{item.email}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">{item.username}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">{item.status}</td>
|
||||
<td className="px-4 py-3">{item.role_codes.join(", ") || "-"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.permission_codes.join(", ") || "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
|
||||
import { AuthProvider } from "@/components/auth-provider";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -27,7 +30,9 @@ export default function RootLayout({
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+160
-43
@@ -1,71 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
|
||||
type PingResponse = {
|
||||
message: string;
|
||||
service: string;
|
||||
};
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { API_BASE_URL, readApiError } from "@/lib/api";
|
||||
|
||||
const FALLBACK_API_BASE_URL = "http://127.0.0.1:8000";
|
||||
type Mode = "login" | "register";
|
||||
type PingResponse = { message: string };
|
||||
|
||||
export default function Home() {
|
||||
const apiBaseUrl = useMemo(
|
||||
() => process.env.NEXT_PUBLIC_API_BASE_URL ?? FALLBACK_API_BASE_URL,
|
||||
[],
|
||||
);
|
||||
const { user, initializing, login, register, logout, hasPermission } = useAuth();
|
||||
|
||||
const [mode, setMode] = useState<Mode>("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [pingResult, setPingResult] = useState<PingResponse | null>(null);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handlePing = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setPingResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/ping`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PingResponse;
|
||||
setPingResult(data);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
setError(
|
||||
`Failed to connect API at ${apiBaseUrl}/api/v1/ping (${message})`,
|
||||
const title = useMemo(
|
||||
() => (mode === "login" ? "登录账号" : "注册新账号"),
|
||||
[mode],
|
||||
);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
if (mode === "login") {
|
||||
await login(email, password);
|
||||
} else {
|
||||
await register(email, username, password);
|
||||
}
|
||||
setPassword("");
|
||||
} catch (submitError) {
|
||||
const message =
|
||||
submitError instanceof Error ? submitError.message : "Unknown error";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePing = async () => {
|
||||
setError("");
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/ping`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
setError(await readApiError(response));
|
||||
return;
|
||||
}
|
||||
setPingResult((await response.json()) as PingResponse);
|
||||
};
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-6 px-6 py-20 sm:px-10">
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-20">
|
||||
<p className="text-sm text-zinc-500">Initializing session...</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-6 px-6 py-20 sm:px-10">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">fquiz</h1>
|
||||
<p className="text-base text-zinc-600 dark:text-zinc-300">
|
||||
Next.js + FastAPI full-stack starter is ready.
|
||||
用户管理与登录认证已就绪(JWT + Refresh Session + RBAC)。
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/15 dark:bg-black">
|
||||
<section className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/15 dark:bg-black">
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">API Base URL</p>
|
||||
<p className="mt-1 font-mono text-sm">{apiBaseUrl}</p>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-sm">{API_BASE_URL}</p>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{user ? (
|
||||
<section className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/15 dark:bg-black">
|
||||
<p className="text-lg font-medium">欢迎,{user.username}</p>
|
||||
<p className="mt-1 text-sm text-zinc-500">{user.email}</p>
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
Roles: {user.role_codes.join(", ") || "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Permissions: {user.permission_codes.join(", ") || "-"}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
|
||||
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
|
||||
onClick={handlePing}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
{loading ? "Pinging..." : "Ping Backend"}
|
||||
Ping Backend
|
||||
</button>
|
||||
{hasPermission("user.manage") && (
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="rounded-md border border-black/15 px-4 py-2 text-sm font-medium transition hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
|
||||
>
|
||||
管理用户
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
className="rounded-md border border-black/15 px-4 py-2 text-sm font-medium transition hover:bg-black/5 dark:border-white/20 dark:hover:bg-white/10"
|
||||
onClick={() => void logout()}
|
||||
type="button"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/15 dark:bg-black">
|
||||
<div className="mb-4 flex gap-2">
|
||||
<button
|
||||
className={`rounded-md px-3 py-1 text-sm ${
|
||||
mode === "login"
|
||||
? "bg-black text-white dark:bg-white dark:text-black"
|
||||
: "border border-black/15 dark:border-white/20"
|
||||
}`}
|
||||
onClick={() => setMode("login")}
|
||||
type="button"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-md px-3 py-1 text-sm ${
|
||||
mode === "register"
|
||||
? "bg-black text-white dark:bg-white dark:text-black"
|
||||
: "border border-black/15 dark:border-white/20"
|
||||
}`}
|
||||
onClick={() => setMode("register")}
|
||||
type="button"
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="space-y-3" onSubmit={handleSubmit}>
|
||||
<h2 className="text-base font-medium">{title}</h2>
|
||||
<input
|
||||
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
{mode === "register" && (
|
||||
<input
|
||||
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
|
||||
placeholder="Username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
minLength={3}
|
||||
maxLength={64}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
className="w-full rounded-md border border-black/15 bg-transparent px-3 py-2 text-sm outline-none focus:border-black/40 dark:border-white/20 dark:focus:border-white/40"
|
||||
placeholder="Password (>= 8 chars)"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-60 dark:bg-white dark:text-black dark:hover:bg-zinc-200"
|
||||
disabled={busy}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? "Submitting..." : mode === "login" ? "登录" : "注册并登录"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{pingResult && (
|
||||
<pre className="overflow-auto rounded-xl border border-emerald-500/30 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-950/30 dark:text-emerald-300">
|
||||
@@ -78,6 +195,6 @@ export default function Home() {
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { API_BASE_URL, readApiError } from "@/lib/api";
|
||||
import type { AuthTokenResponse, UserPublic } from "@/types/auth";
|
||||
|
||||
type AuthContextValue = {
|
||||
user: UserPublic | null;
|
||||
accessToken: string | null;
|
||||
initializing: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshAccessToken: () => Promise<boolean>;
|
||||
fetchWithAuth: (
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
retryOnUnauthorized?: boolean,
|
||||
) => Promise<Response>;
|
||||
hasRole: (roleCode: string) => boolean;
|
||||
hasPermission: (permissionCode: string) => boolean;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
function withApiPath(path: string): string {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path;
|
||||
}
|
||||
return `${API_BASE_URL}${path}`;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<UserPublic | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const accessTokenRef = useRef<string | null>(null);
|
||||
|
||||
const applyToken = useCallback((token: string | null) => {
|
||||
accessTokenRef.current = token;
|
||||
setAccessToken(token);
|
||||
}, []);
|
||||
|
||||
const clearAuth = useCallback(() => {
|
||||
setUser(null);
|
||||
applyToken(null);
|
||||
}, [applyToken]);
|
||||
|
||||
const applyAuthPayload = useCallback(
|
||||
(payload: AuthTokenResponse) => {
|
||||
setUser(payload.user);
|
||||
applyToken(payload.access_token);
|
||||
},
|
||||
[applyToken],
|
||||
);
|
||||
|
||||
const refreshAccessToken = useCallback(async (): Promise<boolean> => {
|
||||
const response = await fetch(withApiPath("/api/v1/auth/refresh"), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
clearAuth();
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AuthTokenResponse;
|
||||
applyAuthPayload(payload);
|
||||
return true;
|
||||
}, [applyAuthPayload, clearAuth]);
|
||||
|
||||
const fetchWithAuth = useCallback(
|
||||
async (
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
retryOnUnauthorized = true,
|
||||
): Promise<Response> => {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = accessTokenRef.current;
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(withApiPath(path), {
|
||||
...init,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.status !== 401 || !retryOnUnauthorized) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (!refreshed) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const retryHeaders = new Headers(init.headers);
|
||||
const nextToken = accessTokenRef.current;
|
||||
if (nextToken) {
|
||||
retryHeaders.set("Authorization", `Bearer ${nextToken}`);
|
||||
}
|
||||
|
||||
return fetch(withApiPath(path), {
|
||||
...init,
|
||||
headers: retryHeaders,
|
||||
credentials: "include",
|
||||
});
|
||||
},
|
||||
[refreshAccessToken],
|
||||
);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
const response = await fetch(withApiPath("/api/v1/auth/login"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AuthTokenResponse;
|
||||
applyAuthPayload(payload);
|
||||
},
|
||||
[applyAuthPayload],
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (email: string, username: string, password: string) => {
|
||||
const response = await fetch(withApiPath("/api/v1/auth/register"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, username, password }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AuthTokenResponse;
|
||||
applyAuthPayload(payload);
|
||||
},
|
||||
[applyAuthPayload],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await fetch(withApiPath("/api/v1/auth/logout"), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
clearAuth();
|
||||
}, [clearAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
const bootstrap = async () => {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
}
|
||||
};
|
||||
void bootstrap();
|
||||
}, [refreshAccessToken]);
|
||||
|
||||
const hasRole = useCallback(
|
||||
(roleCode: string) => (user ? user.role_codes.includes(roleCode) : false),
|
||||
[user],
|
||||
);
|
||||
const hasPermission = useCallback(
|
||||
(permissionCode: string) =>
|
||||
user ? user.permission_codes.includes(permissionCode) : false,
|
||||
[user],
|
||||
);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
accessToken,
|
||||
initializing,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
fetchWithAuth,
|
||||
hasRole,
|
||||
hasPermission,
|
||||
}),
|
||||
[
|
||||
user,
|
||||
accessToken,
|
||||
initializing,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
fetchWithAuth,
|
||||
hasRole,
|
||||
hasPermission,
|
||||
],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used inside AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000";
|
||||
|
||||
export async function readApiError(response: Response): Promise<string> {
|
||||
try {
|
||||
const data = (await response.json()) as { detail?: string };
|
||||
return data.detail ?? `HTTP ${response.status}`;
|
||||
} catch {
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type UserPublic = {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
status: string;
|
||||
role_codes: string[];
|
||||
permission_codes: string[];
|
||||
created_at: string;
|
||||
last_login_at: string | null;
|
||||
};
|
||||
|
||||
export type AuthTokenResponse = {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user: UserPublic;
|
||||
};
|
||||
|
||||
export type UserListResponse = {
|
||||
items: UserPublic[];
|
||||
total: number;
|
||||
};
|
||||
Reference in New Issue
Block a user