Split frontend and backend into separate submodule repos
This commit is contained in:
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "backend"]
|
||||||
|
path = backend
|
||||||
|
url = ../member-backend
|
||||||
|
[submodule "frontend"]
|
||||||
|
path = frontend
|
||||||
|
url = ../member-frontend
|
||||||
1
backend
Submodule
1
backend
Submodule
Submodule backend added at ade60bdbaa
@@ -1,13 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.venv
|
|
||||||
__pycache__
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
*.pyd
|
|
||||||
.pytest_cache
|
|
||||||
.ruff_cache
|
|
||||||
tests
|
|
||||||
.env
|
|
||||||
.env.development
|
|
||||||
*.log
|
|
||||||
19
backend/.env
19
backend/.env
@@ -1,19 +0,0 @@
|
|||||||
# memberapi.ose.tw backend env (development)
|
|
||||||
APP_ENV=development
|
|
||||||
PORT=8000
|
|
||||||
|
|
||||||
DB_HOST=127.0.0.1
|
|
||||||
DB_PORT=54321
|
|
||||||
DB_NAME=member_center
|
|
||||||
DB_USER=member_ose
|
|
||||||
DB_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
KEYCLOAK_BASE_URL=
|
|
||||||
KEYCLOAK_REALM=
|
|
||||||
KEYCLOAK_VERIFY_TLS=true
|
|
||||||
KEYCLOAK_ISSUER=
|
|
||||||
KEYCLOAK_JWKS_URL=
|
|
||||||
KEYCLOAK_AUDIENCE=
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# memberapi.ose.tw backend env (local development)
|
|
||||||
APP_ENV=development
|
|
||||||
PORT=8000
|
|
||||||
|
|
||||||
DB_HOST=127.0.0.1
|
|
||||||
DB_PORT=54321
|
|
||||||
DB_NAME=member.ose.tw
|
|
||||||
DB_USER=member_ose
|
|
||||||
DB_PASSWORD=Dmrax5bKDf
|
|
||||||
|
|
||||||
KEYCLOAK_BASE_URL=https://auth.ose.tw
|
|
||||||
KEYCLOAK_REALM=master
|
|
||||||
KEYCLOAK_VERIFY_TLS=true
|
|
||||||
KEYCLOAK_CLIENT_ID=member-frontend
|
|
||||||
KEYCLOAK_CLIENT_SECRET=bp2I0HWyz5cjcu5RGnBPXNC2vjCdckkv
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_ID=member-backend
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_SECRET=hat8BmxlP0eZ7CXuKbV4HwQ3abLHzAJ9
|
|
||||||
KEYCLOAK_ADMIN_REALM=master
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
|
||||||
MEMBER_REQUIRED_REALM_ROLES=admin,manager
|
|
||||||
ADMIN_REQUIRED_REALM_ROLES=admin,manager
|
|
||||||
|
|
||||||
CACHE_BACKEND=memory
|
|
||||||
CACHE_REDIS_URL=redis://127.0.0.1:6379/0
|
|
||||||
CACHE_PREFIX=memberapi
|
|
||||||
CACHE_DEFAULT_TTL_SECONDS=30
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# memberapi.ose.tw backend env (development)
|
|
||||||
APP_ENV=development
|
|
||||||
PORT=8000
|
|
||||||
|
|
||||||
DB_HOST=127.0.0.1
|
|
||||||
DB_PORT=54321
|
|
||||||
DB_NAME=member_center
|
|
||||||
DB_USER=member_ose
|
|
||||||
DB_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set)
|
|
||||||
KEYCLOAK_BASE_URL=
|
|
||||||
KEYCLOAK_REALM=
|
|
||||||
KEYCLOAK_VERIFY_TLS=true
|
|
||||||
KEYCLOAK_ISSUER=
|
|
||||||
KEYCLOAK_JWKS_URL=
|
|
||||||
KEYCLOAK_AUDIENCE=
|
|
||||||
KEYCLOAK_CLIENT_ID=
|
|
||||||
KEYCLOAK_CLIENT_SECRET=
|
|
||||||
KEYCLOAK_TOKEN_ENDPOINT=
|
|
||||||
KEYCLOAK_USERINFO_ENDPOINT=
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_ID=
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_SECRET=
|
|
||||||
KEYCLOAK_ADMIN_REALM=
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
|
||||||
MEMBER_REQUIRED_REALM_ROLES=admin,manager
|
|
||||||
ADMIN_REQUIRED_REALM_ROLES=admin,manager
|
|
||||||
|
|
||||||
# Cache backend: memory | redis
|
|
||||||
CACHE_BACKEND=memory
|
|
||||||
CACHE_REDIS_URL=redis://127.0.0.1:6379/0
|
|
||||||
CACHE_PREFIX=memberapi
|
|
||||||
CACHE_DEFAULT_TTL_SECONDS=30
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# memberapi.ose.tw backend env (production)
|
|
||||||
APP_ENV=production
|
|
||||||
PORT=8000
|
|
||||||
|
|
||||||
DB_HOST=postgresql
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_NAME=member_center
|
|
||||||
DB_USER=member_ose
|
|
||||||
DB_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set)
|
|
||||||
KEYCLOAK_BASE_URL=
|
|
||||||
KEYCLOAK_REALM=
|
|
||||||
KEYCLOAK_VERIFY_TLS=true
|
|
||||||
KEYCLOAK_ISSUER=
|
|
||||||
KEYCLOAK_JWKS_URL=
|
|
||||||
KEYCLOAK_AUDIENCE=
|
|
||||||
KEYCLOAK_CLIENT_ID=
|
|
||||||
KEYCLOAK_CLIENT_SECRET=
|
|
||||||
KEYCLOAK_TOKEN_ENDPOINT=
|
|
||||||
KEYCLOAK_USERINFO_ENDPOINT=
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_ID=
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_SECRET=
|
|
||||||
KEYCLOAK_ADMIN_REALM=
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
|
||||||
MEMBER_REQUIRED_REALM_ROLES=admin,manager
|
|
||||||
ADMIN_REQUIRED_REALM_ROLES=admin,manager
|
|
||||||
|
|
||||||
# Cache backend: memory | redis
|
|
||||||
CACHE_BACKEND=redis
|
|
||||||
CACHE_REDIS_URL=redis://redis:6379/0
|
|
||||||
CACHE_PREFIX=memberapi
|
|
||||||
CACHE_DEFAULT_TTL_SECONDS=30
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
FROM python:3.12-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apk add --no-cache build-base libffi-dev openssl-dev cargo
|
|
||||||
|
|
||||||
COPY pyproject.toml /app/pyproject.toml
|
|
||||||
COPY app /app/app
|
|
||||||
COPY scripts /app/scripts
|
|
||||||
COPY README.md /app/README.md
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
|
||||||
pip wheel --no-cache-dir --wheel-dir /wheels .
|
|
||||||
|
|
||||||
FROM python:3.12-alpine
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apk add --no-cache libstdc++ libffi openssl
|
|
||||||
|
|
||||||
COPY --from=builder /wheels /wheels
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
|
||||||
pip install --no-cache-dir /wheels/*
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# memberapi.ose.tw backend
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -e .
|
|
||||||
cp .env.example .env
|
|
||||||
psql "$DATABASE_URL" -f scripts/init_schema.sql
|
|
||||||
./scripts/start_dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker (VPS / Production)
|
|
||||||
> 目前 Dockerfile 為 Alpine 多階段建置(較小體積)。
|
|
||||||
|
|
||||||
Build image:
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
docker build -t memberapi-backend:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
Run container:
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name memberapi-backend \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-p 127.0.0.1:8000:8000 \
|
|
||||||
--env-file .env \
|
|
||||||
memberapi-backend:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Health check:
|
|
||||||
```bash
|
|
||||||
curl http://127.0.0.1:8000/healthz
|
|
||||||
```
|
|
||||||
|
|
||||||
## Keycloak env
|
|
||||||
|
|
||||||
- Required:
|
|
||||||
- `KEYCLOAK_BASE_URL`
|
|
||||||
- `KEYCLOAK_REALM`
|
|
||||||
- `KEYCLOAK_CLIENT_ID`
|
|
||||||
- `KEYCLOAK_CLIENT_SECRET`
|
|
||||||
- `KEYCLOAK_ADMIN_CLIENT_ID`
|
|
||||||
- `KEYCLOAK_ADMIN_CLIENT_SECRET`
|
|
||||||
- Optional:
|
|
||||||
- `KEYCLOAK_ADMIN_REALM` (default = `KEYCLOAK_REALM`)
|
|
||||||
- `KEYCLOAK_ISSUER`
|
|
||||||
- `KEYCLOAK_JWKS_URL`
|
|
||||||
- `KEYCLOAK_TOKEN_ENDPOINT`
|
|
||||||
- `KEYCLOAK_USERINFO_ENDPOINT`
|
|
||||||
- `KEYCLOAK_AUDIENCE`
|
|
||||||
- `KEYCLOAK_VERIFY_TLS`
|
|
||||||
- `MEMBER_REQUIRED_REALM_ROLES` (default: `admin,manager`)
|
|
||||||
- `ADMIN_REQUIRED_REALM_ROLES` (default: `admin,manager`)
|
|
||||||
|
|
||||||
## Main APIs
|
|
||||||
|
|
||||||
- `GET /healthz`
|
|
||||||
- `GET /auth/oidc/url`
|
|
||||||
- `POST /auth/oidc/exchange`
|
|
||||||
- `GET /me` (Bearer token required)
|
|
||||||
- `GET /me/permissions/snapshot` (Bearer token required)
|
|
||||||
|
|
||||||
### Admin APIs (Bearer + admin realm role required)
|
|
||||||
- `GET/POST/PATCH/DELETE /admin/companies`
|
|
||||||
- `GET/POST/PATCH/DELETE /admin/sites`
|
|
||||||
- `GET/POST/PATCH/DELETE /admin/systems`
|
|
||||||
- `GET/POST/PATCH/DELETE /admin/roles`
|
|
||||||
- `GET/POST/PATCH/DELETE /admin/members`
|
|
||||||
- `PUT /admin/sites/{site_key}/roles`
|
|
||||||
- `PUT /admin/members/{user_sub}/sites`
|
|
||||||
- `GET /admin/members/{user_sub}/roles`
|
|
||||||
- `GET/POST/PATCH/DELETE /admin/api-clients`
|
|
||||||
|
|
||||||
### Internal APIs (`X-Client-Key` + `X-API-Key`)
|
|
||||||
- `GET /internal/companies`
|
|
||||||
- `GET /internal/sites`
|
|
||||||
- `GET /internal/systems`
|
|
||||||
- `GET /internal/roles`
|
|
||||||
- `GET /internal/members`
|
|
||||||
- `POST /internal/users/upsert-by-sub`
|
|
||||||
- `GET /internal/users/{user_sub}/roles`
|
|
||||||
- `POST /internal/idp/users/ensure`
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""memberapi backend package."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""API routers."""
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,167 +0,0 @@
|
|||||||
import logging
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.schemas.login import LoginRequest, LoginResponse, OIDCAuthUrlResponse, OIDCCodeExchangeRequest, RefreshTokenRequest
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
|
||||||
def login(payload: LoginRequest) -> LoginResponse:
|
|
||||||
settings = get_settings()
|
|
||||||
client_id = settings.idp_client_id or settings.idp_audience
|
|
||||||
if not settings.idp_base_url or not client_id or not settings.idp_client_secret:
|
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured")
|
|
||||||
|
|
||||||
form = {
|
|
||||||
"grant_type": "password",
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": settings.idp_client_secret,
|
|
||||||
"username": payload.username,
|
|
||||||
"password": payload.password,
|
|
||||||
"scope": "openid profile email",
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
resp = httpx.post(
|
|
||||||
settings.idp_token_endpoint,
|
|
||||||
data=form,
|
|
||||||
timeout=10,
|
|
||||||
verify=settings.idp_verify_tls,
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_unreachable") from exc
|
|
||||||
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
logger.warning("idp password grant failed: status=%s body=%s", resp.status_code, resp.text)
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password")
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
token = data.get("access_token")
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token")
|
|
||||||
return _build_login_response(data)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/oidc/url", response_model=OIDCAuthUrlResponse)
|
|
||||||
def get_oidc_authorize_url(
|
|
||||||
redirect_uri: str,
|
|
||||||
login_hint: str | None = None,
|
|
||||||
prompt: str = "login",
|
|
||||||
idp_hint: str | None = None,
|
|
||||||
code_challenge: str | None = None,
|
|
||||||
code_challenge_method: str | None = None,
|
|
||||||
) -> OIDCAuthUrlResponse:
|
|
||||||
settings = get_settings()
|
|
||||||
client_id = settings.idp_client_id or settings.idp_audience
|
|
||||||
if not settings.idp_base_url or not client_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured")
|
|
||||||
|
|
||||||
query = {
|
|
||||||
"client_id": client_id,
|
|
||||||
"response_type": "code",
|
|
||||||
"scope": "openid profile email",
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
"state": secrets.token_urlsafe(24),
|
|
||||||
"prompt": prompt or "login",
|
|
||||||
}
|
|
||||||
if login_hint:
|
|
||||||
query["login_hint"] = login_hint
|
|
||||||
if idp_hint:
|
|
||||||
query["kc_idp_hint"] = idp_hint
|
|
||||||
if code_challenge:
|
|
||||||
query["code_challenge"] = code_challenge
|
|
||||||
query["code_challenge_method"] = code_challenge_method or "S256"
|
|
||||||
|
|
||||||
params = httpx.QueryParams(query)
|
|
||||||
return OIDCAuthUrlResponse(authorize_url=f"{settings.idp_authorize_endpoint}?{params}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/oidc/exchange", response_model=LoginResponse)
|
|
||||||
def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse:
|
|
||||||
settings = get_settings()
|
|
||||||
client_id = settings.idp_client_id or settings.idp_audience
|
|
||||||
if not settings.idp_base_url or not client_id or not settings.idp_client_secret:
|
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured")
|
|
||||||
|
|
||||||
form = {
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": settings.idp_client_secret,
|
|
||||||
"code": payload.code,
|
|
||||||
"redirect_uri": payload.redirect_uri,
|
|
||||||
}
|
|
||||||
if payload.code_verifier:
|
|
||||||
form["code_verifier"] = payload.code_verifier
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = httpx.post(
|
|
||||||
settings.idp_token_endpoint,
|
|
||||||
data=form,
|
|
||||||
timeout=10,
|
|
||||||
verify=settings.idp_verify_tls,
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_unreachable") from exc
|
|
||||||
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
logger.warning("idp auth-code exchange failed: status=%s body=%s", resp.status_code, resp.text)
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="idp_code_exchange_failed")
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
token = data.get("access_token")
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token")
|
|
||||||
return _build_login_response(data)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=LoginResponse)
|
|
||||||
def refresh_access_token(payload: RefreshTokenRequest) -> LoginResponse:
|
|
||||||
settings = get_settings()
|
|
||||||
client_id = settings.idp_client_id or settings.idp_audience
|
|
||||||
if not settings.idp_base_url or not client_id or not settings.idp_client_secret:
|
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured")
|
|
||||||
|
|
||||||
form = {
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": settings.idp_client_secret,
|
|
||||||
"refresh_token": payload.refresh_token,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
resp = httpx.post(
|
|
||||||
settings.idp_token_endpoint,
|
|
||||||
data=form,
|
|
||||||
timeout=10,
|
|
||||||
verify=settings.idp_verify_tls,
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_unreachable") from exc
|
|
||||||
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
logger.warning("idp refresh-token grant failed: status=%s body=%s", resp.status_code, resp.text)
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_refresh_token")
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
token = data.get("access_token")
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token")
|
|
||||||
return _build_login_response(data)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_login_response(data: dict) -> LoginResponse:
|
|
||||||
return LoginResponse(
|
|
||||||
access_token=data.get("access_token", ""),
|
|
||||||
refresh_token=data.get("refresh_token"),
|
|
||||||
token_type=data.get("token_type", "Bearer"),
|
|
||||||
expires_in=data.get("expires_in"),
|
|
||||||
refresh_expires_in=data.get("refresh_expires_in"),
|
|
||||||
scope=data.get("scope"),
|
|
||||||
)
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.repositories.users_repo import UsersRepository
|
|
||||||
from app.repositories.user_sites_repo import UserSitesRepository
|
|
||||||
from app.schemas.idp_admin import ProviderEnsureUserRequest, ProviderEnsureUserResponse
|
|
||||||
from app.schemas.internal import InternalUpsertUserBySubResponse, InternalUserRoleItem, InternalUserRoleResponse
|
|
||||||
from app.schemas.users import UserUpsertBySubRequest
|
|
||||||
from app.security.api_client_auth import require_api_client
|
|
||||||
from app.services.idp_admin_service import ProviderAdminService
|
|
||||||
from app.services.runtime_cache import runtime_cache
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/internal", tags=["internal"], dependencies=[Depends(require_api_client)])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/upsert-by-sub", response_model=InternalUpsertUserBySubResponse)
|
|
||||||
def upsert_user_by_sub(
|
|
||||||
payload: UserUpsertBySubRequest,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> InternalUpsertUserBySubResponse:
|
|
||||||
repo = UsersRepository(db)
|
|
||||||
user = repo.upsert_by_sub(
|
|
||||||
user_sub=payload.user_sub,
|
|
||||||
username=payload.username,
|
|
||||||
email=payload.email,
|
|
||||||
display_name=payload.display_name,
|
|
||||||
is_active=payload.is_active,
|
|
||||||
status=payload.status,
|
|
||||||
)
|
|
||||||
return InternalUpsertUserBySubResponse(
|
|
||||||
id=user.id,
|
|
||||||
user_sub=user.user_sub,
|
|
||||||
provider_user_id=user.provider_user_id,
|
|
||||||
username=user.username,
|
|
||||||
email=user.email,
|
|
||||||
display_name=user.display_name,
|
|
||||||
is_active=user.is_active,
|
|
||||||
status=user.status,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_user_role_rows(db: Session, user_sub: str) -> list[tuple[str, str, str, str, str, str, str, str]]:
|
|
||||||
users_repo = UsersRepository(db)
|
|
||||||
user_sites_repo = UserSitesRepository(db)
|
|
||||||
|
|
||||||
user = users_repo.get_by_sub(user_sub)
|
|
||||||
if user is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
rows = user_sites_repo.get_user_role_rows(user.id)
|
|
||||||
return [
|
|
||||||
(
|
|
||||||
site.site_key,
|
|
||||||
site.display_name,
|
|
||||||
company.company_key,
|
|
||||||
company.name,
|
|
||||||
system.system_key,
|
|
||||||
system.name,
|
|
||||||
role.role_key,
|
|
||||||
role.name,
|
|
||||||
)
|
|
||||||
for site, company, role, system in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/{user_sub}/roles", response_model=InternalUserRoleResponse)
|
|
||||||
def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUserRoleResponse:
|
|
||||||
cache_key = f"internal:user_roles:{user_sub}"
|
|
||||||
cached = runtime_cache.get(cache_key)
|
|
||||||
if isinstance(cached, InternalUserRoleResponse):
|
|
||||||
return cached
|
|
||||||
|
|
||||||
rows = _build_user_role_rows(db, user_sub)
|
|
||||||
result = InternalUserRoleResponse(
|
|
||||||
user_sub=user_sub,
|
|
||||||
roles=[
|
|
||||||
InternalUserRoleItem(
|
|
||||||
site_key=site_key,
|
|
||||||
site_display_name=site_display_name,
|
|
||||||
company_key=company_key,
|
|
||||||
company_display_name=company_display_name,
|
|
||||||
system_key=system_key,
|
|
||||||
system_name=system_name,
|
|
||||||
role_key=role_key,
|
|
||||||
role_name=role_name,
|
|
||||||
)
|
|
||||||
for (
|
|
||||||
site_key,
|
|
||||||
site_display_name,
|
|
||||||
company_key,
|
|
||||||
company_display_name,
|
|
||||||
system_key,
|
|
||||||
system_name,
|
|
||||||
role_key,
|
|
||||||
role_name,
|
|
||||||
) in rows
|
|
||||||
],
|
|
||||||
)
|
|
||||||
runtime_cache.set(cache_key, result, ttl_seconds=30)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/provider/users/ensure", response_model=ProviderEnsureUserResponse)
|
|
||||||
@router.post("/idp/users/ensure", response_model=ProviderEnsureUserResponse, include_in_schema=False)
|
|
||||||
@router.post("/keycloak/users/ensure", response_model=ProviderEnsureUserResponse, include_in_schema=False)
|
|
||||||
def ensure_idp_user(
|
|
||||||
payload: ProviderEnsureUserRequest,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> ProviderEnsureUserResponse:
|
|
||||||
settings = get_settings()
|
|
||||||
idp_service = ProviderAdminService(settings=settings)
|
|
||||||
sync_result = idp_service.ensure_user(
|
|
||||||
sub=payload.user_sub,
|
|
||||||
email=payload.email,
|
|
||||||
username=payload.username,
|
|
||||||
display_name=payload.display_name,
|
|
||||||
is_active=payload.is_active,
|
|
||||||
)
|
|
||||||
|
|
||||||
users_repo = UsersRepository(db)
|
|
||||||
resolved_sub = payload.user_sub or sync_result.user_sub or ""
|
|
||||||
if not resolved_sub:
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_sub")
|
|
||||||
|
|
||||||
users_repo.upsert_by_sub(
|
|
||||||
user_sub=resolved_sub,
|
|
||||||
username=payload.username,
|
|
||||||
email=payload.email,
|
|
||||||
display_name=payload.display_name,
|
|
||||||
is_active=payload.is_active,
|
|
||||||
status="active",
|
|
||||||
provider_user_id=sync_result.user_id,
|
|
||||||
)
|
|
||||||
return ProviderEnsureUserResponse(provider_user_id=sync_result.user_id, action=sync_result.action)
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.repositories.companies_repo import CompaniesRepository
|
|
||||||
from app.repositories.roles_repo import RolesRepository
|
|
||||||
from app.repositories.sites_repo import SitesRepository
|
|
||||||
from app.repositories.systems_repo import SystemsRepository
|
|
||||||
from app.repositories.users_repo import UsersRepository
|
|
||||||
from app.schemas.internal import (
|
|
||||||
InternalCompanyListResponse,
|
|
||||||
InternalMemberListResponse,
|
|
||||||
InternalRoleItem,
|
|
||||||
InternalRoleListResponse,
|
|
||||||
InternalSiteListResponse,
|
|
||||||
InternalSystemListResponse,
|
|
||||||
)
|
|
||||||
from app.security.api_client_auth import require_api_client
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/internal", tags=["internal"], dependencies=[Depends(require_api_client)])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/systems", response_model=InternalSystemListResponse)
|
|
||||||
def internal_list_systems(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
limit: int = Query(default=200, ge=1, le=1000),
|
|
||||||
offset: int = Query(default=0, ge=0),
|
|
||||||
) -> InternalSystemListResponse:
|
|
||||||
repo = SystemsRepository(db)
|
|
||||||
items, total = repo.list(limit=limit, offset=offset)
|
|
||||||
return {
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": i.id,
|
|
||||||
"system_key": i.system_key,
|
|
||||||
"name": i.name,
|
|
||||||
"status": i.status,
|
|
||||||
}
|
|
||||||
for i in items
|
|
||||||
],
|
|
||||||
"total": total,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/roles", response_model=InternalRoleListResponse)
|
|
||||||
def internal_list_roles(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
system_key: str | None = Query(default=None),
|
|
||||||
limit: int = Query(default=500, ge=1, le=2000),
|
|
||||||
offset: int = Query(default=0, ge=0),
|
|
||||||
) -> InternalRoleListResponse:
|
|
||||||
systems_repo = SystemsRepository(db)
|
|
||||||
roles_repo = RolesRepository(db)
|
|
||||||
|
|
||||||
system_id = None
|
|
||||||
systems, _ = systems_repo.list(limit=5000, offset=0)
|
|
||||||
system_map = {s.id: s for s in systems}
|
|
||||||
if system_key:
|
|
||||||
system = systems_repo.get_by_key(system_key)
|
|
||||||
if not system:
|
|
||||||
return InternalRoleListResponse(items=[], total=0, limit=limit, offset=offset)
|
|
||||||
system_id = system.id
|
|
||||||
|
|
||||||
items, total = roles_repo.list(system_id=system_id, limit=limit, offset=offset)
|
|
||||||
rows = [
|
|
||||||
InternalRoleItem(
|
|
||||||
id=i.id,
|
|
||||||
role_key=i.role_key,
|
|
||||||
system_key=system_map[i.system_id].system_key,
|
|
||||||
system_name=system_map[i.system_id].name,
|
|
||||||
name=i.name,
|
|
||||||
description=i.description,
|
|
||||||
status=i.status,
|
|
||||||
)
|
|
||||||
for i in items
|
|
||||||
if i.system_id in system_map
|
|
||||||
]
|
|
||||||
return InternalRoleListResponse(items=rows, total=total, limit=limit, offset=offset)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/companies", response_model=InternalCompanyListResponse)
|
|
||||||
def internal_list_companies(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
keyword: str | None = Query(default=None),
|
|
||||||
limit: int = Query(default=500, ge=1, le=2000),
|
|
||||||
offset: int = Query(default=0, ge=0),
|
|
||||||
) -> InternalCompanyListResponse:
|
|
||||||
repo = CompaniesRepository(db)
|
|
||||||
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
|
|
||||||
return {
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": i.id,
|
|
||||||
"company_key": i.company_key,
|
|
||||||
"name": i.name,
|
|
||||||
"status": i.status,
|
|
||||||
}
|
|
||||||
for i in items
|
|
||||||
],
|
|
||||||
"total": total,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sites", response_model=InternalSiteListResponse)
|
|
||||||
def internal_list_sites(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
company_key: str | None = Query(default=None),
|
|
||||||
limit: int = Query(default=500, ge=1, le=2000),
|
|
||||||
offset: int = Query(default=0, ge=0),
|
|
||||||
) -> InternalSiteListResponse:
|
|
||||||
companies_repo = CompaniesRepository(db)
|
|
||||||
sites_repo = SitesRepository(db)
|
|
||||||
company_id = None
|
|
||||||
if company_key:
|
|
||||||
company = companies_repo.get_by_key(company_key)
|
|
||||||
if company:
|
|
||||||
company_id = company.id
|
|
||||||
companies, _ = companies_repo.list(limit=5000, offset=0)
|
|
||||||
mapping = {c.id: c for c in companies}
|
|
||||||
items, total = sites_repo.list(company_id=company_id, limit=limit, offset=offset)
|
|
||||||
return {
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": i.id,
|
|
||||||
"site_key": i.site_key,
|
|
||||||
"company_key": mapping[i.company_id].company_key,
|
|
||||||
"company_display_name": mapping[i.company_id].name,
|
|
||||||
"display_name": i.display_name,
|
|
||||||
"domain": i.domain,
|
|
||||||
"status": i.status,
|
|
||||||
}
|
|
||||||
for i in items
|
|
||||||
if i.company_id in mapping
|
|
||||||
],
|
|
||||||
"total": total,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/members", response_model=InternalMemberListResponse)
|
|
||||||
def internal_list_members(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
keyword: str | None = Query(default=None),
|
|
||||||
limit: int = Query(default=500, ge=1, le=2000),
|
|
||||||
offset: int = Query(default=0, ge=0),
|
|
||||||
) -> InternalMemberListResponse:
|
|
||||||
repo = UsersRepository(db)
|
|
||||||
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
|
|
||||||
return {
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": i.id,
|
|
||||||
"user_sub": i.user_sub,
|
|
||||||
"username": i.username,
|
|
||||||
"email": i.email,
|
|
||||||
"display_name": i.display_name,
|
|
||||||
"is_active": i.is_active,
|
|
||||||
"status": i.status,
|
|
||||||
}
|
|
||||||
for i in items
|
|
||||||
],
|
|
||||||
"total": total,
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.repositories.users_repo import UsersRepository
|
|
||||||
from app.repositories.user_sites_repo import UserSitesRepository
|
|
||||||
from app.schemas.auth import ProviderPrincipal, MeSummaryResponse
|
|
||||||
from app.schemas.permissions import RoleSnapshotResponse
|
|
||||||
from app.security.idp_jwt import require_authenticated_principal
|
|
||||||
from app.services.permission_service import PermissionService
|
|
||||||
from app.services.runtime_cache import runtime_cache
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/me", tags=["me"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=MeSummaryResponse)
|
|
||||||
def get_me(
|
|
||||||
principal: ProviderPrincipal = Depends(require_authenticated_principal),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> MeSummaryResponse:
|
|
||||||
cache_key = f"me:{principal.sub}"
|
|
||||||
cached = runtime_cache.get(cache_key)
|
|
||||||
if isinstance(cached, MeSummaryResponse):
|
|
||||||
return cached
|
|
||||||
try:
|
|
||||||
users_repo = UsersRepository(db)
|
|
||||||
user = users_repo.upsert_by_sub(
|
|
||||||
user_sub=principal.sub,
|
|
||||||
username=principal.preferred_username,
|
|
||||||
email=principal.email,
|
|
||||||
display_name=principal.name or principal.preferred_username,
|
|
||||||
is_active=True,
|
|
||||||
status="active",
|
|
||||||
)
|
|
||||||
result = MeSummaryResponse(sub=user.user_sub, email=user.email, display_name=user.display_name)
|
|
||||||
runtime_cache.set(cache_key, result, ttl_seconds=30)
|
|
||||||
return result
|
|
||||||
except SQLAlchemyError:
|
|
||||||
result = MeSummaryResponse(
|
|
||||||
sub=principal.sub,
|
|
||||||
email=principal.email,
|
|
||||||
display_name=principal.name or principal.preferred_username,
|
|
||||||
)
|
|
||||||
runtime_cache.set(cache_key, result, ttl_seconds=15)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/permissions/snapshot", response_model=RoleSnapshotResponse)
|
|
||||||
def get_my_permission_snapshot(
|
|
||||||
principal: ProviderPrincipal = Depends(require_authenticated_principal),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> RoleSnapshotResponse:
|
|
||||||
cache_key = f"me:permissions_snapshot:{principal.sub}"
|
|
||||||
cached = runtime_cache.get(cache_key)
|
|
||||||
if isinstance(cached, RoleSnapshotResponse):
|
|
||||||
return cached
|
|
||||||
try:
|
|
||||||
users_repo = UsersRepository(db)
|
|
||||||
user_sites_repo = UserSitesRepository(db)
|
|
||||||
|
|
||||||
user = users_repo.upsert_by_sub(
|
|
||||||
user_sub=principal.sub,
|
|
||||||
username=principal.preferred_username,
|
|
||||||
email=principal.email,
|
|
||||||
display_name=principal.name or principal.preferred_username,
|
|
||||||
is_active=True,
|
|
||||||
status="active",
|
|
||||||
)
|
|
||||||
rows = user_sites_repo.get_user_role_rows(user.id)
|
|
||||||
serialized = [
|
|
||||||
(
|
|
||||||
site.site_key,
|
|
||||||
site.display_name,
|
|
||||||
company.company_key,
|
|
||||||
company.name,
|
|
||||||
system.system_key,
|
|
||||||
system.name,
|
|
||||||
role.role_key,
|
|
||||||
role.name,
|
|
||||||
)
|
|
||||||
for site, company, role, system in rows
|
|
||||||
]
|
|
||||||
result = PermissionService.build_role_snapshot(user_sub=principal.sub, rows=serialized)
|
|
||||||
runtime_cache.set(cache_key, result, ttl_seconds=30)
|
|
||||||
return result
|
|
||||||
except SQLAlchemyError:
|
|
||||||
result = RoleSnapshotResponse(user_sub=principal.sub, roles=[])
|
|
||||||
runtime_cache.set(cache_key, result, ttl_seconds=10)
|
|
||||||
return result
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Core settings and constants."""
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
from functools import lru_cache
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from pydantic import field_validator
|
|
||||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
||||||
|
|
||||||
app_env: str = "development"
|
|
||||||
port: int = 8000
|
|
||||||
|
|
||||||
db_host: str = "127.0.0.1"
|
|
||||||
db_port: int = 54321
|
|
||||||
db_name: str = "member_center"
|
|
||||||
db_user: str = "member_ose"
|
|
||||||
db_password: str = ""
|
|
||||||
|
|
||||||
# Keycloak only
|
|
||||||
keycloak_base_url: str = ""
|
|
||||||
keycloak_realm: str = ""
|
|
||||||
keycloak_verify_tls: bool = True
|
|
||||||
keycloak_issuer: str = ""
|
|
||||||
keycloak_jwks_url: str = ""
|
|
||||||
keycloak_audience: str = ""
|
|
||||||
keycloak_client_id: str = ""
|
|
||||||
keycloak_client_secret: str = ""
|
|
||||||
keycloak_token_endpoint: str = ""
|
|
||||||
keycloak_userinfo_endpoint: str = ""
|
|
||||||
keycloak_admin_client_id: str = ""
|
|
||||||
keycloak_admin_client_secret: str = ""
|
|
||||||
keycloak_admin_realm: str = ""
|
|
||||||
|
|
||||||
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
|
||||||
internal_shared_secret: str = ""
|
|
||||||
admin_required_groups: Annotated[list[str], NoDecode] = []
|
|
||||||
member_required_realm_roles: Annotated[list[str], NoDecode] = ["admin", "manager"]
|
|
||||||
admin_required_realm_roles: Annotated[list[str], NoDecode] = ["admin", "manager"]
|
|
||||||
cache_backend: str = "memory"
|
|
||||||
cache_redis_url: str = "redis://127.0.0.1:6379/0"
|
|
||||||
cache_prefix: str = "memberapi"
|
|
||||||
cache_default_ttl_seconds: int = 30
|
|
||||||
|
|
||||||
@field_validator("public_frontend_origins", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def parse_origins(cls, value: str | list[str]) -> list[str]:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return value
|
|
||||||
if not value:
|
|
||||||
return []
|
|
||||||
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
|
||||||
|
|
||||||
@field_validator("admin_required_groups", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def parse_csv(cls, value: str | list[str]) -> list[str]:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [str(v).strip() for v in value if str(v).strip()]
|
|
||||||
if not value:
|
|
||||||
return []
|
|
||||||
return [part.strip() for part in value.split(",") if part.strip()]
|
|
||||||
|
|
||||||
@field_validator("member_required_realm_roles", "admin_required_realm_roles", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def parse_roles_csv(cls, value: str | list[str]) -> list[str]:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [str(v).strip() for v in value if str(v).strip()]
|
|
||||||
if not value:
|
|
||||||
return []
|
|
||||||
return [part.strip() for part in value.split(",") if part.strip()]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def database_url(self) -> str:
|
|
||||||
return (
|
|
||||||
"postgresql+psycopg://"
|
|
||||||
f"{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def idp_base_url(self) -> str:
|
|
||||||
return self.keycloak_base_url.rstrip("/")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def idp_verify_tls(self) -> bool:
|
|
||||||
return self.keycloak_verify_tls
|
|
||||||
|
|
||||||
@property
|
|
||||||
def idp_issuer(self) -> str:
|
|
||||||
if self.keycloak_issuer:
|
|
||||||
return self.keycloak_issuer.rstrip("/")
|
|
||||||
return f"{self.idp_base_url}/realms/{self.keycloak_realm}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def idp_jwks_url(self) -> str:
|
|
||||||
if self.keycloak_jwks_url:
|
|
||||||
return self.keycloak_jwks_url
|
|
||||||
return f"{self.idp_issuer}/protocol/openid-connect/certs"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def idp_audience(self) -> str:
|
|
||||||
return self.keycloak_audience
|
|
||||||
|
|
||||||
@property
|
|
||||||
def idp_client_id(self) -> str:
|
|
||||||
return self.keycloak_client_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def idp_client_secret(self) -> str:
|
|
||||||
return self.keycloak_client_secret
|
|
||||||
|
|
||||||
@property
|
|
||||||
def idp_token_endpoint(self) -> str:
|
|
||||||
if self.keycloak_token_endpoint:
|
|
||||||
return self.keycloak_token_endpoint
|
|
||||||
return f"{self.idp_issuer}/protocol/openid-connect/token"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def idp_userinfo_endpoint(self) -> str:
|
|
||||||
if self.keycloak_userinfo_endpoint:
|
|
||||||
return self.keycloak_userinfo_endpoint
|
|
||||||
return f"{self.idp_issuer}/protocol/openid-connect/userinfo"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def idp_authorize_endpoint(self) -> str:
|
|
||||||
return f"{self.idp_issuer}/protocol/openid-connect/auth"
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
return Settings()
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
def generate_key(prefix: str, salt: int = 0) -> str:
|
|
||||||
date_str = datetime.now().strftime("%Y%m%d")
|
|
||||||
tail = (int(time.time() * 1000) + salt) % 10000
|
|
||||||
return f"{prefix}{date_str}X{tail:04d}"
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Database wiring."""
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
from collections.abc import Generator
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Generator[Session, None, None]:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from app.api.admin_catalog import router as admin_catalog_router
|
|
||||||
from app.api.auth import router as auth_router
|
|
||||||
from app.api.internal_catalog import router as internal_catalog_router
|
|
||||||
from app.api.internal import router as internal_router
|
|
||||||
from app.api.me import router as me_router
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.services.runtime_cache import runtime_cache
|
|
||||||
|
|
||||||
app = FastAPI(title="memberapi.ose.tw", version="0.1.0")
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=settings.public_frontend_origins,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def invalidate_runtime_cache_on_cud(request, call_next):
|
|
||||||
response = await call_next(request)
|
|
||||||
if (
|
|
||||||
request.method in {"POST", "PUT", "PATCH", "DELETE"}
|
|
||||||
and request.url.path.startswith(("/admin", "/internal"))
|
|
||||||
and response.status_code < 400
|
|
||||||
):
|
|
||||||
runtime_cache.bump_revision()
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz", tags=["health"])
|
|
||||||
def healthz() -> dict[str, str]:
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
app.include_router(internal_router)
|
|
||||||
app.include_router(internal_catalog_router)
|
|
||||||
app.include_router(admin_catalog_router)
|
|
||||||
app.include_router(me_router)
|
|
||||||
app.include_router(auth_router)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from app.models.api_client import ApiClient
|
|
||||||
from app.models.auth_sync_state import AuthSyncState
|
|
||||||
from app.models.company import Company
|
|
||||||
from app.models.role import Role
|
|
||||||
from app.models.site import Site
|
|
||||||
from app.models.site_role import SiteRole
|
|
||||||
from app.models.system import System
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.user_site import UserSite
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ApiClient",
|
|
||||||
"AuthSyncState",
|
|
||||||
"Company",
|
|
||||||
"Role",
|
|
||||||
"Site",
|
|
||||||
"SiteRole",
|
|
||||||
"System",
|
|
||||||
"User",
|
|
||||||
"UserSite",
|
|
||||||
]
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClient(Base):
|
|
||||||
__tablename__ = "api_clients"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
|
||||||
client_key: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
|
||||||
api_key_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
|
|
||||||
allowed_origins: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
|
||||||
allowed_ips: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
|
||||||
allowed_paths: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
|
||||||
|
|
||||||
rate_limit_per_min: Mapped[int | None] = mapped_column(Integer)
|
|
||||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
||||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, String, UniqueConstraint, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class AuthSyncState(Base):
|
|
||||||
__tablename__ = "auth_sync_state"
|
|
||||||
__table_args__ = (UniqueConstraint("entity_type", "entity_id", name="uq_auth_sync_state_entity"),)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
|
||||||
entity_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
||||||
entity_id: Mapped[str] = mapped_column(UUID(as_uuid=False), nullable=False)
|
|
||||||
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
||||||
source_version: Mapped[str | None] = mapped_column(String(255))
|
|
||||||
last_error: Mapped[str | None] = mapped_column(String)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, String, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Company(Base):
|
|
||||||
__tablename__ = "companies"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
|
||||||
company_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
provider_group_id: Mapped[str | None] = mapped_column(String(128))
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_name(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
@display_name.setter
|
|
||||||
def display_name(self, value: str) -> None:
|
|
||||||
self.name = value
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Role(Base):
|
|
||||||
__tablename__ = "roles"
|
|
||||||
__table_args__ = (UniqueConstraint("system_id", "name", name="uq_roles_system_name"),)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
|
||||||
role_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
|
||||||
system_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("systems.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
description: Mapped[str | None] = mapped_column(String(1024))
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def provider_role_name(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
@provider_role_name.setter
|
|
||||||
def provider_role_name(self, value: str) -> None:
|
|
||||||
self.name = value
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Site(Base):
|
|
||||||
__tablename__ = "sites"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
|
||||||
site_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
|
||||||
company_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
domain: Mapped[str | None] = mapped_column(String(255))
|
|
||||||
provider_group_id: Mapped[str | None] = mapped_column(String(128))
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class SiteRole(Base):
|
|
||||||
__tablename__ = "site_roles"
|
|
||||||
__table_args__ = (UniqueConstraint("site_id", "role_id", name="uq_site_roles_site_role"),)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
|
||||||
site_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("sites.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
role_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("roles.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, String, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class System(Base):
|
|
||||||
__tablename__ = "systems"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
|
||||||
system_key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def provider_client_id(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
@provider_client_id.setter
|
|
||||||
def provider_client_id(self, value: str) -> None:
|
|
||||||
self.name = value
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, String, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
|
||||||
user_sub: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
|
||||||
provider_user_id: Mapped[str | None] = mapped_column(String(128), unique=True)
|
|
||||||
username: Mapped[str | None] = mapped_column(String(255), unique=True)
|
|
||||||
email: Mapped[str | None] = mapped_column(String(320), unique=True)
|
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class UserSite(Base):
|
|
||||||
__tablename__ = "user_sites"
|
|
||||||
__table_args__ = (UniqueConstraint("user_id", "site_id", name="uq_user_sites_user_site"),)
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
|
||||||
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
site_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("sites.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Repository layer."""
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.api_client import ApiClient
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClientsRepository:
|
|
||||||
def __init__(self, db: Session) -> None:
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
def get_by_key(self, client_key: str) -> ApiClient | None:
|
|
||||||
return self.db.scalar(select(ApiClient).where(ApiClient.client_key == client_key))
|
|
||||||
|
|
||||||
def list(self, *, keyword: str | None = None, status: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[ApiClient], int]:
|
|
||||||
stmt = select(ApiClient)
|
|
||||||
count_stmt = select(func.count()).select_from(ApiClient)
|
|
||||||
if keyword:
|
|
||||||
pattern = f"%{keyword}%"
|
|
||||||
cond = or_(ApiClient.client_key.ilike(pattern), ApiClient.name.ilike(pattern))
|
|
||||||
stmt = stmt.where(cond)
|
|
||||||
count_stmt = count_stmt.where(cond)
|
|
||||||
if status:
|
|
||||||
stmt = stmt.where(ApiClient.status == status)
|
|
||||||
count_stmt = count_stmt.where(ApiClient.status == status)
|
|
||||||
|
|
||||||
stmt = stmt.order_by(ApiClient.created_at.desc()).limit(limit).offset(offset)
|
|
||||||
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
client_key: str,
|
|
||||||
name: str,
|
|
||||||
status: str,
|
|
||||||
api_key_hash: str,
|
|
||||||
allowed_origins: list[str],
|
|
||||||
allowed_ips: list[str],
|
|
||||||
allowed_paths: list[str],
|
|
||||||
rate_limit_per_min: int | None,
|
|
||||||
expires_at,
|
|
||||||
) -> ApiClient:
|
|
||||||
item = ApiClient(
|
|
||||||
client_key=client_key,
|
|
||||||
name=name,
|
|
||||||
status=status,
|
|
||||||
api_key_hash=api_key_hash,
|
|
||||||
allowed_origins=allowed_origins,
|
|
||||||
allowed_ips=allowed_ips,
|
|
||||||
allowed_paths=allowed_paths,
|
|
||||||
rate_limit_per_min=rate_limit_per_min,
|
|
||||||
expires_at=expires_at,
|
|
||||||
)
|
|
||||||
self.db.add(item)
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def update(
|
|
||||||
self,
|
|
||||||
item: ApiClient,
|
|
||||||
*,
|
|
||||||
name: str | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
api_key_hash: str | None = None,
|
|
||||||
allowed_origins: list[str] | None = None,
|
|
||||||
allowed_ips: list[str] | None = None,
|
|
||||||
allowed_paths: list[str] | None = None,
|
|
||||||
rate_limit_per_min: int | None = None,
|
|
||||||
expires_at=None,
|
|
||||||
) -> ApiClient:
|
|
||||||
if name is not None:
|
|
||||||
item.name = name
|
|
||||||
if status is not None:
|
|
||||||
item.status = status
|
|
||||||
if api_key_hash is not None:
|
|
||||||
item.api_key_hash = api_key_hash
|
|
||||||
if allowed_origins is not None:
|
|
||||||
item.allowed_origins = allowed_origins
|
|
||||||
if allowed_ips is not None:
|
|
||||||
item.allowed_ips = allowed_ips
|
|
||||||
if allowed_paths is not None:
|
|
||||||
item.allowed_paths = allowed_paths
|
|
||||||
if rate_limit_per_min is not None:
|
|
||||||
item.rate_limit_per_min = rate_limit_per_min
|
|
||||||
if expires_at is not None:
|
|
||||||
item.expires_at = expires_at
|
|
||||||
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def delete(self, item: ApiClient) -> None:
|
|
||||||
self.db.delete(item)
|
|
||||||
self.db.commit()
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
from sqlalchemy import func, or_, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.company import Company
|
|
||||||
|
|
||||||
|
|
||||||
class CompaniesRepository:
|
|
||||||
def __init__(self, db: Session) -> None:
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
def get_by_key(self, company_key: str) -> Company | None:
|
|
||||||
return self.db.scalar(select(Company).where(Company.company_key == company_key))
|
|
||||||
|
|
||||||
def get_by_id(self, company_id: str) -> Company | None:
|
|
||||||
return self.db.scalar(select(Company).where(Company.id == company_id))
|
|
||||||
|
|
||||||
def list(self, keyword: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[Company], int]:
|
|
||||||
stmt = select(Company)
|
|
||||||
count_stmt = select(func.count()).select_from(Company)
|
|
||||||
if keyword:
|
|
||||||
pattern = f"%{keyword}%"
|
|
||||||
cond = or_(
|
|
||||||
Company.company_key.ilike(pattern),
|
|
||||||
Company.name.ilike(pattern),
|
|
||||||
)
|
|
||||||
stmt = stmt.where(cond)
|
|
||||||
count_stmt = count_stmt.where(cond)
|
|
||||||
|
|
||||||
stmt = stmt.order_by(Company.created_at.desc()).limit(limit).offset(offset)
|
|
||||||
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
company_key: str,
|
|
||||||
name: str,
|
|
||||||
provider_group_id: str | None = None,
|
|
||||||
status: str = "active",
|
|
||||||
) -> Company:
|
|
||||||
item = Company(
|
|
||||||
company_key=company_key,
|
|
||||||
name=name,
|
|
||||||
provider_group_id=provider_group_id,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
self.db.add(item)
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def update(
|
|
||||||
self,
|
|
||||||
item: Company,
|
|
||||||
*,
|
|
||||||
name: str | None = None,
|
|
||||||
provider_group_id: str | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
) -> Company:
|
|
||||||
if name is not None:
|
|
||||||
item.name = name
|
|
||||||
if provider_group_id is not None:
|
|
||||||
item.provider_group_id = provider_group_id
|
|
||||||
if status is not None:
|
|
||||||
item.status = status
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def delete(self, item: Company) -> None:
|
|
||||||
self.db.delete(item)
|
|
||||||
self.db.commit()
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
from sqlalchemy import func, or_, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.role import Role
|
|
||||||
|
|
||||||
|
|
||||||
class RolesRepository:
|
|
||||||
def __init__(self, db: Session) -> None:
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
def get_by_key(self, role_key: str) -> Role | None:
|
|
||||||
return self.db.scalar(select(Role).where(Role.role_key == role_key))
|
|
||||||
|
|
||||||
def get_by_id(self, role_id: str) -> Role | None:
|
|
||||||
return self.db.scalar(select(Role).where(Role.id == role_id))
|
|
||||||
|
|
||||||
def list(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
keyword: str | None = None,
|
|
||||||
system_id: str | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> tuple[list[Role], int]:
|
|
||||||
stmt = select(Role)
|
|
||||||
count_stmt = select(func.count()).select_from(Role)
|
|
||||||
if keyword:
|
|
||||||
pattern = f"%{keyword}%"
|
|
||||||
cond = or_(
|
|
||||||
Role.role_key.ilike(pattern),
|
|
||||||
Role.name.ilike(pattern),
|
|
||||||
Role.description.ilike(pattern),
|
|
||||||
)
|
|
||||||
stmt = stmt.where(cond)
|
|
||||||
count_stmt = count_stmt.where(cond)
|
|
||||||
if system_id:
|
|
||||||
stmt = stmt.where(Role.system_id == system_id)
|
|
||||||
count_stmt = count_stmt.where(Role.system_id == system_id)
|
|
||||||
if status:
|
|
||||||
stmt = stmt.where(Role.status == status)
|
|
||||||
count_stmt = count_stmt.where(Role.status == status)
|
|
||||||
|
|
||||||
stmt = stmt.order_by(Role.created_at.desc()).limit(limit).offset(offset)
|
|
||||||
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
role_key: str,
|
|
||||||
system_id: str,
|
|
||||||
name: str,
|
|
||||||
description: str | None,
|
|
||||||
status: str = "active",
|
|
||||||
) -> Role:
|
|
||||||
item = Role(
|
|
||||||
role_key=role_key,
|
|
||||||
system_id=system_id,
|
|
||||||
name=name,
|
|
||||||
description=description,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
self.db.add(item)
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def update(
|
|
||||||
self,
|
|
||||||
item: Role,
|
|
||||||
*,
|
|
||||||
system_id: str | None = None,
|
|
||||||
name: str | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
) -> Role:
|
|
||||||
if system_id is not None:
|
|
||||||
item.system_id = system_id
|
|
||||||
if name is not None:
|
|
||||||
item.name = name
|
|
||||||
if description is not None:
|
|
||||||
item.description = description
|
|
||||||
if status is not None:
|
|
||||||
item.status = status
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def delete(self, item: Role) -> None:
|
|
||||||
self.db.delete(item)
|
|
||||||
self.db.commit()
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
from sqlalchemy import delete, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.role import Role
|
|
||||||
from app.models.site import Site
|
|
||||||
from app.models.site_role import SiteRole
|
|
||||||
from app.models.system import System
|
|
||||||
|
|
||||||
|
|
||||||
class SiteRolesRepository:
|
|
||||||
def __init__(self, db: Session) -> None:
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
def list_site_role_rows(self, site_id: str) -> list[tuple[SiteRole, Role, System]]:
|
|
||||||
stmt = (
|
|
||||||
select(SiteRole, Role, System)
|
|
||||||
.join(Role, Role.id == SiteRole.role_id)
|
|
||||||
.join(System, System.id == Role.system_id)
|
|
||||||
.where(SiteRole.site_id == site_id)
|
|
||||||
.order_by(System.name.asc(), Role.name.asc())
|
|
||||||
)
|
|
||||||
return list(self.db.execute(stmt).all())
|
|
||||||
|
|
||||||
def list_role_site_rows(self, role_id: str) -> list[tuple[SiteRole, Site]]:
|
|
||||||
stmt = (
|
|
||||||
select(SiteRole, Site)
|
|
||||||
.join(Site, Site.id == SiteRole.site_id)
|
|
||||||
.where(SiteRole.role_id == role_id)
|
|
||||||
.order_by(Site.display_name.asc())
|
|
||||||
)
|
|
||||||
return list(self.db.execute(stmt).all())
|
|
||||||
|
|
||||||
def set_site_roles(self, *, site_id: str, role_ids: list[str], commit: bool = True) -> None:
|
|
||||||
self.db.execute(delete(SiteRole).where(SiteRole.site_id == site_id))
|
|
||||||
for role_id in role_ids:
|
|
||||||
self.db.add(SiteRole(site_id=site_id, role_id=role_id))
|
|
||||||
if commit:
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
def set_role_sites(self, *, role_id: str, site_ids: list[str], commit: bool = True) -> None:
|
|
||||||
self.db.execute(delete(SiteRole).where(SiteRole.role_id == role_id))
|
|
||||||
for site_id in site_ids:
|
|
||||||
self.db.add(SiteRole(site_id=site_id, role_id=role_id))
|
|
||||||
if commit:
|
|
||||||
self.db.commit()
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
from sqlalchemy import func, or_, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.site import Site
|
|
||||||
|
|
||||||
|
|
||||||
class SitesRepository:
|
|
||||||
def __init__(self, db: Session) -> None:
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
def get_by_key(self, site_key: str) -> Site | None:
|
|
||||||
return self.db.scalar(select(Site).where(Site.site_key == site_key))
|
|
||||||
|
|
||||||
def get_by_id(self, site_id: str) -> Site | None:
|
|
||||||
return self.db.scalar(select(Site).where(Site.id == site_id))
|
|
||||||
|
|
||||||
def list(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
keyword: str | None = None,
|
|
||||||
company_id: str | None = None,
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> tuple[list[Site], int]:
|
|
||||||
stmt = select(Site)
|
|
||||||
count_stmt = select(func.count()).select_from(Site)
|
|
||||||
|
|
||||||
if keyword:
|
|
||||||
pattern = f"%{keyword}%"
|
|
||||||
cond = or_(Site.site_key.ilike(pattern), Site.display_name.ilike(pattern), Site.domain.ilike(pattern))
|
|
||||||
stmt = stmt.where(cond)
|
|
||||||
count_stmt = count_stmt.where(cond)
|
|
||||||
|
|
||||||
if company_id:
|
|
||||||
stmt = stmt.where(Site.company_id == company_id)
|
|
||||||
count_stmt = count_stmt.where(Site.company_id == company_id)
|
|
||||||
|
|
||||||
stmt = stmt.order_by(Site.created_at.desc()).limit(limit).offset(offset)
|
|
||||||
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
site_key: str,
|
|
||||||
company_id: str,
|
|
||||||
display_name: str,
|
|
||||||
domain: str | None,
|
|
||||||
provider_group_id: str | None = None,
|
|
||||||
status: str = "active",
|
|
||||||
) -> Site:
|
|
||||||
item = Site(
|
|
||||||
site_key=site_key,
|
|
||||||
company_id=company_id,
|
|
||||||
display_name=display_name,
|
|
||||||
domain=domain,
|
|
||||||
provider_group_id=provider_group_id,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
self.db.add(item)
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def update(
|
|
||||||
self,
|
|
||||||
item: Site,
|
|
||||||
*,
|
|
||||||
company_id: str | None = None,
|
|
||||||
display_name: str | None = None,
|
|
||||||
domain: str | None = None,
|
|
||||||
provider_group_id: str | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
) -> Site:
|
|
||||||
if company_id is not None:
|
|
||||||
item.company_id = company_id
|
|
||||||
if display_name is not None:
|
|
||||||
item.display_name = display_name
|
|
||||||
if domain is not None:
|
|
||||||
item.domain = domain
|
|
||||||
if provider_group_id is not None:
|
|
||||||
item.provider_group_id = provider_group_id
|
|
||||||
if status is not None:
|
|
||||||
item.status = status
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def delete(self, item: Site) -> None:
|
|
||||||
self.db.delete(item)
|
|
||||||
self.db.commit()
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
from sqlalchemy import func, or_, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.system import System
|
|
||||||
|
|
||||||
|
|
||||||
class SystemsRepository:
|
|
||||||
def __init__(self, db: Session) -> None:
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
def get_by_key(self, system_key: str) -> System | None:
|
|
||||||
return self.db.scalar(select(System).where(System.system_key == system_key))
|
|
||||||
|
|
||||||
def get_by_id(self, system_id: str) -> System | None:
|
|
||||||
return self.db.scalar(select(System).where(System.id == system_id))
|
|
||||||
|
|
||||||
def list(self, *, keyword: str | None = None, status: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[System], int]:
|
|
||||||
stmt = select(System)
|
|
||||||
count_stmt = select(func.count()).select_from(System)
|
|
||||||
if keyword:
|
|
||||||
pattern = f"%{keyword}%"
|
|
||||||
cond = or_(System.system_key.ilike(pattern), System.name.ilike(pattern))
|
|
||||||
stmt = stmt.where(cond)
|
|
||||||
count_stmt = count_stmt.where(cond)
|
|
||||||
if status:
|
|
||||||
stmt = stmt.where(System.status == status)
|
|
||||||
count_stmt = count_stmt.where(System.status == status)
|
|
||||||
|
|
||||||
stmt = stmt.order_by(System.created_at.desc()).limit(limit).offset(offset)
|
|
||||||
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
|
|
||||||
|
|
||||||
def create(self, *, system_key: str, name: str, status: str = "active") -> System:
|
|
||||||
item = System(system_key=system_key, name=name, status=status)
|
|
||||||
self.db.add(item)
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def update(
|
|
||||||
self,
|
|
||||||
item: System,
|
|
||||||
*,
|
|
||||||
name: str | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
) -> System:
|
|
||||||
if name is not None:
|
|
||||||
item.name = name
|
|
||||||
if status is not None:
|
|
||||||
item.status = status
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def delete(self, item: System) -> None:
|
|
||||||
self.db.delete(item)
|
|
||||||
self.db.commit()
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
from sqlalchemy import delete, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.company import Company
|
|
||||||
from app.models.role import Role
|
|
||||||
from app.models.site import Site
|
|
||||||
from app.models.site_role import SiteRole
|
|
||||||
from app.models.system import System
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.user_site import UserSite
|
|
||||||
|
|
||||||
|
|
||||||
class UserSitesRepository:
|
|
||||||
def __init__(self, db: Session) -> None:
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
def list_user_site_rows(self, user_id: str) -> list[tuple[UserSite, Site, Company]]:
|
|
||||||
stmt = (
|
|
||||||
select(UserSite, Site, Company)
|
|
||||||
.join(Site, Site.id == UserSite.site_id)
|
|
||||||
.join(Company, Company.id == Site.company_id)
|
|
||||||
.where(UserSite.user_id == user_id)
|
|
||||||
.order_by(Company.name.asc(), Site.display_name.asc())
|
|
||||||
)
|
|
||||||
return list(self.db.execute(stmt).all())
|
|
||||||
|
|
||||||
def list_site_member_rows(self, site_id: str) -> list[tuple[UserSite, User]]:
|
|
||||||
stmt = (
|
|
||||||
select(UserSite, User)
|
|
||||||
.join(User, User.id == UserSite.user_id)
|
|
||||||
.where(UserSite.site_id == site_id)
|
|
||||||
.order_by(User.display_name.asc().nulls_last(), User.username.asc().nulls_last(), User.user_sub.asc())
|
|
||||||
)
|
|
||||||
return list(self.db.execute(stmt).all())
|
|
||||||
|
|
||||||
def set_user_sites(self, *, user_id: str, site_ids: list[str]) -> None:
|
|
||||||
self.db.execute(delete(UserSite).where(UserSite.user_id == user_id))
|
|
||||||
for site_id in site_ids:
|
|
||||||
self.db.add(UserSite(user_id=user_id, site_id=site_id))
|
|
||||||
self.db.commit()
|
|
||||||
|
|
||||||
def get_user_role_rows(self, user_id: str) -> list[tuple[Site, Company, Role, System]]:
|
|
||||||
stmt = (
|
|
||||||
select(Site, Company, Role, System)
|
|
||||||
.select_from(UserSite)
|
|
||||||
.join(Site, Site.id == UserSite.site_id)
|
|
||||||
.join(Company, Company.id == Site.company_id)
|
|
||||||
.join(SiteRole, SiteRole.site_id == Site.id)
|
|
||||||
.join(Role, Role.id == SiteRole.role_id)
|
|
||||||
.join(System, System.id == Role.system_id)
|
|
||||||
.where(UserSite.user_id == user_id)
|
|
||||||
.order_by(Company.name.asc(), Site.display_name.asc(), System.name.asc(), Role.name.asc())
|
|
||||||
)
|
|
||||||
return list(self.db.execute(stmt).all())
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
from sqlalchemy import func, or_, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
class UsersRepository:
|
|
||||||
def __init__(self, db: Session) -> None:
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
def get_by_sub(self, user_sub: str) -> User | None:
|
|
||||||
return self.db.scalar(select(User).where(User.user_sub == user_sub))
|
|
||||||
|
|
||||||
def get_by_id(self, user_id: str) -> User | None:
|
|
||||||
return self.db.scalar(select(User).where(User.id == user_id))
|
|
||||||
|
|
||||||
def list(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
keyword: str | None = None,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
limit: int = 50,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> tuple[list[User], int]:
|
|
||||||
stmt = select(User)
|
|
||||||
count_stmt = select(func.count()).select_from(User)
|
|
||||||
|
|
||||||
if keyword:
|
|
||||||
pattern = f"%{keyword}%"
|
|
||||||
cond = or_(
|
|
||||||
User.user_sub.ilike(pattern),
|
|
||||||
User.username.ilike(pattern),
|
|
||||||
User.email.ilike(pattern),
|
|
||||||
User.display_name.ilike(pattern),
|
|
||||||
)
|
|
||||||
stmt = stmt.where(cond)
|
|
||||||
count_stmt = count_stmt.where(cond)
|
|
||||||
|
|
||||||
if is_active is not None:
|
|
||||||
stmt = stmt.where(User.is_active == is_active)
|
|
||||||
count_stmt = count_stmt.where(User.is_active == is_active)
|
|
||||||
|
|
||||||
stmt = stmt.order_by(User.created_at.desc()).limit(limit).offset(offset)
|
|
||||||
items = list(self.db.scalars(stmt).all())
|
|
||||||
total = int(self.db.scalar(count_stmt) or 0)
|
|
||||||
return items, total
|
|
||||||
|
|
||||||
def upsert_by_sub(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
user_sub: str,
|
|
||||||
username: str | None,
|
|
||||||
email: str | None,
|
|
||||||
display_name: str | None,
|
|
||||||
is_active: bool,
|
|
||||||
status: str = "active",
|
|
||||||
provider_user_id: str | None = None,
|
|
||||||
) -> User:
|
|
||||||
user = self.get_by_sub(user_sub)
|
|
||||||
changed = False
|
|
||||||
if user is None:
|
|
||||||
user = User(
|
|
||||||
user_sub=user_sub,
|
|
||||||
provider_user_id=provider_user_id,
|
|
||||||
username=username,
|
|
||||||
email=email,
|
|
||||||
display_name=display_name,
|
|
||||||
is_active=is_active,
|
|
||||||
status=status,
|
|
||||||
)
|
|
||||||
self.db.add(user)
|
|
||||||
changed = True
|
|
||||||
else:
|
|
||||||
if provider_user_id is not None and user.provider_user_id != provider_user_id:
|
|
||||||
user.provider_user_id = provider_user_id
|
|
||||||
changed = True
|
|
||||||
if user.username != username:
|
|
||||||
user.username = username
|
|
||||||
changed = True
|
|
||||||
if user.email != email:
|
|
||||||
user.email = email
|
|
||||||
changed = True
|
|
||||||
if user.display_name != display_name:
|
|
||||||
user.display_name = display_name
|
|
||||||
changed = True
|
|
||||||
if user.is_active != is_active:
|
|
||||||
user.is_active = is_active
|
|
||||||
changed = True
|
|
||||||
if user.status != status:
|
|
||||||
user.status = status
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if changed:
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
def update_member(
|
|
||||||
self,
|
|
||||||
user: User,
|
|
||||||
*,
|
|
||||||
username: str | None = None,
|
|
||||||
email: str | None = None,
|
|
||||||
display_name: str | None = None,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
) -> User:
|
|
||||||
if username is not None:
|
|
||||||
user.username = username
|
|
||||||
if email is not None:
|
|
||||||
user.email = email
|
|
||||||
if display_name is not None:
|
|
||||||
user.display_name = display_name
|
|
||||||
if is_active is not None:
|
|
||||||
user.is_active = is_active
|
|
||||||
if status is not None:
|
|
||||||
user.status = status
|
|
||||||
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
def delete(self, user: User) -> None:
|
|
||||||
self.db.delete(user)
|
|
||||||
self.db.commit()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Pydantic schemas."""
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClientItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
client_key: str
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
allowed_origins: list[str] = Field(default_factory=list)
|
|
||||||
allowed_ips: list[str] = Field(default_factory=list)
|
|
||||||
allowed_paths: list[str] = Field(default_factory=list)
|
|
||||||
rate_limit_per_min: int | None = None
|
|
||||||
expires_at: datetime | None = None
|
|
||||||
last_used_at: datetime | None = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClientCreateRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
client_key: str | None = None
|
|
||||||
status: str = "active"
|
|
||||||
allowed_origins: list[str] = Field(default_factory=list)
|
|
||||||
allowed_ips: list[str] = Field(default_factory=list)
|
|
||||||
allowed_paths: list[str] = Field(default_factory=list)
|
|
||||||
rate_limit_per_min: int | None = None
|
|
||||||
expires_at: datetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClientUpdateRequest(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
status: str | None = None
|
|
||||||
allowed_origins: list[str] | None = None
|
|
||||||
allowed_ips: list[str] | None = None
|
|
||||||
allowed_paths: list[str] | None = None
|
|
||||||
rate_limit_per_min: int | None = None
|
|
||||||
expires_at: datetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClientCreateResponse(BaseModel):
|
|
||||||
item: ApiClientItem
|
|
||||||
api_key: str
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClientRotateKeyResponse(BaseModel):
|
|
||||||
client_key: str
|
|
||||||
api_key: str
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderPrincipal(BaseModel):
|
|
||||||
sub: str
|
|
||||||
email: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
preferred_username: str | None = None
|
|
||||||
groups: list[str] = Field(default_factory=list)
|
|
||||||
realm_roles: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class MeSummaryResponse(BaseModel):
|
|
||||||
sub: str
|
|
||||||
email: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ListResponse(BaseModel):
|
|
||||||
items: list
|
|
||||||
total: int
|
|
||||||
limit: int
|
|
||||||
offset: int
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyCreateRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
status: str = "active"
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyUpdateRequest(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
provider_group_id: str | None = None
|
|
||||||
status: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
company_key: str
|
|
||||||
name: str
|
|
||||||
provider_group_id: str | None = None
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class SiteCreateRequest(BaseModel):
|
|
||||||
company_key: str
|
|
||||||
display_name: str
|
|
||||||
domain: str | None = None
|
|
||||||
status: str = "active"
|
|
||||||
|
|
||||||
|
|
||||||
class SiteUpdateRequest(BaseModel):
|
|
||||||
company_key: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
domain: str | None = None
|
|
||||||
provider_group_id: str | None = None
|
|
||||||
status: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SiteItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
site_key: str
|
|
||||||
company_key: str
|
|
||||||
company_display_name: str
|
|
||||||
display_name: str
|
|
||||||
domain: str | None = None
|
|
||||||
provider_group_id: str | None = None
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class SystemCreateRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
status: str = "active"
|
|
||||||
|
|
||||||
|
|
||||||
class SystemUpdateRequest(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
status: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SystemItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
system_key: str
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class RoleCreateRequest(BaseModel):
|
|
||||||
system_key: str
|
|
||||||
name: str
|
|
||||||
description: str | None = None
|
|
||||||
status: str = "active"
|
|
||||||
|
|
||||||
|
|
||||||
class RoleUpdateRequest(BaseModel):
|
|
||||||
system_key: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
status: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RoleItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
role_key: str
|
|
||||||
system_key: str
|
|
||||||
system_name: str
|
|
||||||
name: str
|
|
||||||
description: str | None = None
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class MemberItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_sub: str
|
|
||||||
provider_user_id: str | None = None
|
|
||||||
username: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
is_active: bool
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class MemberUpsertRequest(BaseModel):
|
|
||||||
user_sub: str | None = None
|
|
||||||
username: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
is_active: bool = True
|
|
||||||
status: str = "active"
|
|
||||||
sync_to_idp: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class MemberUpdateRequest(BaseModel):
|
|
||||||
username: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
is_active: bool | None = None
|
|
||||||
status: str | None = None
|
|
||||||
sync_to_idp: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class MemberPasswordResetResponse(BaseModel):
|
|
||||||
user_sub: str
|
|
||||||
temporary_password: str
|
|
||||||
|
|
||||||
|
|
||||||
class SiteRoleAssignRequest(BaseModel):
|
|
||||||
role_keys: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class SiteRoleItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
role_key: str
|
|
||||||
role_name: str
|
|
||||||
system_key: str
|
|
||||||
system_name: str
|
|
||||||
|
|
||||||
|
|
||||||
class UserSiteAssignRequest(BaseModel):
|
|
||||||
site_keys: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class UserSiteItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
site_key: str
|
|
||||||
site_display_name: str
|
|
||||||
company_key: str
|
|
||||||
company_display_name: str
|
|
||||||
|
|
||||||
|
|
||||||
class UserEffectiveRoleItem(BaseModel):
|
|
||||||
site_key: str
|
|
||||||
site_display_name: str
|
|
||||||
company_key: str
|
|
||||||
company_display_name: str
|
|
||||||
system_key: str
|
|
||||||
system_name: str
|
|
||||||
role_key: str
|
|
||||||
role_name: str
|
|
||||||
|
|
||||||
|
|
||||||
class UserEffectiveRolesResponse(BaseModel):
|
|
||||||
user_sub: str
|
|
||||||
roles: list[UserEffectiveRoleItem]
|
|
||||||
|
|
||||||
|
|
||||||
class SiteMembersResponse(BaseModel):
|
|
||||||
site_key: str
|
|
||||||
members: list[MemberItem]
|
|
||||||
|
|
||||||
|
|
||||||
class SiteRolesResponse(BaseModel):
|
|
||||||
site_key: str
|
|
||||||
roles: list[SiteRoleItem]
|
|
||||||
|
|
||||||
|
|
||||||
class UserSitesResponse(BaseModel):
|
|
||||||
user_sub: str
|
|
||||||
sites: list[UserSiteItem]
|
|
||||||
|
|
||||||
|
|
||||||
class CompanySitesResponse(BaseModel):
|
|
||||||
company_key: str
|
|
||||||
sites: list[SiteItem]
|
|
||||||
|
|
||||||
|
|
||||||
class SystemRolesResponse(BaseModel):
|
|
||||||
system_key: str
|
|
||||||
roles: list[RoleItem]
|
|
||||||
|
|
||||||
|
|
||||||
class RoleSitesResponse(BaseModel):
|
|
||||||
role_key: str
|
|
||||||
sites: list[UserSiteItem]
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClientItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
client_key: str
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
allowed_origins: list[str] = Field(default_factory=list)
|
|
||||||
allowed_ips: list[str] = Field(default_factory=list)
|
|
||||||
allowed_paths: list[str] = Field(default_factory=list)
|
|
||||||
rate_limit_per_min: int | None = None
|
|
||||||
expires_at: datetime | None = None
|
|
||||||
last_used_at: datetime | None = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from pydantic import AliasChoices, BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderEnsureUserRequest(BaseModel):
|
|
||||||
user_sub: str | None = Field(default=None, validation_alias=AliasChoices("user_sub", "sub"))
|
|
||||||
username: str | None = None
|
|
||||||
email: str
|
|
||||||
display_name: str | None = None
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderEnsureUserResponse(BaseModel):
|
|
||||||
provider_user_id: str
|
|
||||||
action: str
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class InternalSystemItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
system_key: str
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class InternalSystemListResponse(BaseModel):
|
|
||||||
items: list[InternalSystemItem]
|
|
||||||
total: int
|
|
||||||
limit: int
|
|
||||||
offset: int
|
|
||||||
|
|
||||||
|
|
||||||
class InternalRoleItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
role_key: str
|
|
||||||
system_key: str
|
|
||||||
system_name: str
|
|
||||||
name: str
|
|
||||||
description: str | None = None
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class InternalRoleListResponse(BaseModel):
|
|
||||||
items: list[InternalRoleItem]
|
|
||||||
total: int
|
|
||||||
limit: int
|
|
||||||
offset: int
|
|
||||||
|
|
||||||
|
|
||||||
class InternalCompanyItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
company_key: str
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class InternalCompanyListResponse(BaseModel):
|
|
||||||
items: list[InternalCompanyItem]
|
|
||||||
total: int
|
|
||||||
limit: int
|
|
||||||
offset: int
|
|
||||||
|
|
||||||
|
|
||||||
class InternalSiteItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
site_key: str
|
|
||||||
company_key: str
|
|
||||||
company_display_name: str
|
|
||||||
display_name: str
|
|
||||||
domain: str | None = None
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class InternalSiteListResponse(BaseModel):
|
|
||||||
items: list[InternalSiteItem]
|
|
||||||
total: int
|
|
||||||
limit: int
|
|
||||||
offset: int
|
|
||||||
|
|
||||||
|
|
||||||
class InternalMemberItem(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_sub: str
|
|
||||||
username: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
is_active: bool
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class InternalMemberListResponse(BaseModel):
|
|
||||||
items: list[InternalMemberItem]
|
|
||||||
total: int
|
|
||||||
limit: int
|
|
||||||
offset: int
|
|
||||||
|
|
||||||
|
|
||||||
class InternalUpsertUserBySubResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_sub: str
|
|
||||||
provider_user_id: str | None = None
|
|
||||||
username: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
is_active: bool
|
|
||||||
status: str
|
|
||||||
|
|
||||||
|
|
||||||
class InternalUserRoleItem(BaseModel):
|
|
||||||
site_key: str
|
|
||||||
site_display_name: str
|
|
||||||
company_key: str
|
|
||||||
company_display_name: str
|
|
||||||
system_key: str
|
|
||||||
system_name: str
|
|
||||||
role_key: str
|
|
||||||
role_name: str
|
|
||||||
|
|
||||||
|
|
||||||
class InternalUserRoleResponse(BaseModel):
|
|
||||||
user_sub: str
|
|
||||||
roles: list[InternalUserRoleItem]
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
refresh_token: str | None = None
|
|
||||||
token_type: str = "Bearer"
|
|
||||||
expires_in: int | None = None
|
|
||||||
refresh_expires_in: int | None = None
|
|
||||||
scope: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthUrlResponse(BaseModel):
|
|
||||||
authorize_url: str
|
|
||||||
|
|
||||||
|
|
||||||
class OIDCCodeExchangeRequest(BaseModel):
|
|
||||||
code: str
|
|
||||||
redirect_uri: str
|
|
||||||
code_verifier: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RefreshTokenRequest(BaseModel):
|
|
||||||
refresh_token: str
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class RoleSnapshotItem(BaseModel):
|
|
||||||
site_key: str
|
|
||||||
site_display_name: str
|
|
||||||
company_key: str
|
|
||||||
company_display_name: str
|
|
||||||
system_key: str
|
|
||||||
system_name: str
|
|
||||||
role_key: str
|
|
||||||
role_name: str
|
|
||||||
|
|
||||||
|
|
||||||
class RoleSnapshotResponse(BaseModel):
|
|
||||||
user_sub: str
|
|
||||||
roles: list[RoleSnapshotItem]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from pydantic import AliasChoices, BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpsertBySubRequest(BaseModel):
|
|
||||||
user_sub: str = Field(validation_alias=AliasChoices("user_sub", "sub"))
|
|
||||||
username: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
is_active: bool = True
|
|
||||||
status: str = "active"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Security dependencies and guards."""
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.schemas.auth import ProviderPrincipal
|
|
||||||
from app.security.idp_jwt import require_authenticated_principal
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_roles(values: set[str]) -> set[str]:
|
|
||||||
normalized: set[str] = set()
|
|
||||||
for value in values:
|
|
||||||
role = value.strip().lower()
|
|
||||||
if role:
|
|
||||||
normalized.add(role)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin_principal(
|
|
||||||
principal: ProviderPrincipal = Depends(require_authenticated_principal),
|
|
||||||
) -> ProviderPrincipal:
|
|
||||||
settings = get_settings()
|
|
||||||
required_roles = _normalize_roles(set(settings.admin_required_realm_roles))
|
|
||||||
|
|
||||||
if not required_roles:
|
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="admin_policy_not_configured")
|
|
||||||
|
|
||||||
principal_roles = _normalize_roles(set(principal.realm_roles))
|
|
||||||
role_ok = bool(required_roles.intersection(principal_roles))
|
|
||||||
|
|
||||||
if not role_ok:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_forbidden")
|
|
||||||
return principal
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request, status
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.models.api_client import ApiClient
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def hash_api_key(plain_key: str) -> str:
|
|
||||||
try:
|
|
||||||
return pwd_context.hash(plain_key)
|
|
||||||
except Exception:
|
|
||||||
# Fallback for environments missing argon2 backend.
|
|
||||||
return f"sha256:{hashlib.sha256(plain_key.encode('utf-8')).hexdigest()}"
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_api_key(plain_key: str, stored_hash: str) -> bool:
|
|
||||||
# Support sha256:<hex> for bootstrap, and bcrypt/argon2 for production.
|
|
||||||
if stored_hash.startswith("sha256:"):
|
|
||||||
hex_hash = hashlib.sha256(plain_key.encode("utf-8")).hexdigest()
|
|
||||||
return hmac.compare_digest(stored_hash.removeprefix("sha256:"), hex_hash)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return pwd_context.verify(plain_key, stored_hash)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_expired(expires_at: datetime | None) -> bool:
|
|
||||||
if expires_at is None:
|
|
||||||
return False
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
if expires_at.tzinfo is None:
|
|
||||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
||||||
return expires_at <= now
|
|
||||||
|
|
||||||
|
|
||||||
def _check_request_whitelist(client: ApiClient, request: Request) -> None:
|
|
||||||
origin = request.headers.get("origin")
|
|
||||||
client_ip = request.client.host if request.client else None
|
|
||||||
path = request.url.path
|
|
||||||
|
|
||||||
if client.allowed_origins and origin and origin not in client.allowed_origins:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="origin_not_allowed")
|
|
||||||
|
|
||||||
if client.allowed_ips and client_ip and client_ip not in client.allowed_ips:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ip_not_allowed")
|
|
||||||
|
|
||||||
if client.allowed_paths and not any(path.startswith(prefix) for prefix in client.allowed_paths):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="path_not_allowed")
|
|
||||||
|
|
||||||
|
|
||||||
def require_api_client(
|
|
||||||
request: Request,
|
|
||||||
x_client_key: str = Header(alias="X-Client-Key"),
|
|
||||||
x_api_key: str = Header(alias="X-API-Key"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> ApiClient:
|
|
||||||
stmt = select(ApiClient).where(ApiClient.client_key == x_client_key)
|
|
||||||
client = db.scalar(stmt)
|
|
||||||
|
|
||||||
if client is None or client.status != "active":
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_client")
|
|
||||||
|
|
||||||
if _is_expired(client.expires_at):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="client_expired")
|
|
||||||
|
|
||||||
if not _verify_api_key(x_api_key, client.api_key_hash):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_api_key")
|
|
||||||
|
|
||||||
_check_request_whitelist(client, request)
|
|
||||||
|
|
||||||
client.last_used_at = datetime.now(timezone.utc)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return client
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import jwt
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.schemas.auth import ProviderPrincipal
|
|
||||||
|
|
||||||
bearer_scheme = HTTPBearer(auto_error=False)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderTokenVerifier:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
issuer: str | None,
|
|
||||||
jwks_url: str | None,
|
|
||||||
audience: str | None,
|
|
||||||
client_id: str | None,
|
|
||||||
client_secret: str | None,
|
|
||||||
base_url: str | None,
|
|
||||||
userinfo_endpoint: str | None,
|
|
||||||
verify_tls: bool,
|
|
||||||
realm: str | None,
|
|
||||||
admin_realm: str | None,
|
|
||||||
admin_client_id: str | None,
|
|
||||||
admin_client_secret: str | None,
|
|
||||||
member_required_realm_roles: list[str],
|
|
||||||
) -> None:
|
|
||||||
self.issuer = issuer.strip() if issuer else None
|
|
||||||
self.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
|
|
||||||
self.audience = audience.strip() if audience else None
|
|
||||||
self.client_id = client_id.strip() if client_id else None
|
|
||||||
self.client_secret = client_secret.strip() if client_secret else None
|
|
||||||
self.base_url = base_url.strip() if base_url else None
|
|
||||||
self.realm = realm.strip() if realm else None
|
|
||||||
self.admin_realm = admin_realm.strip() if admin_realm else self.realm
|
|
||||||
self.admin_client_id = admin_client_id.strip() if admin_client_id else None
|
|
||||||
self.admin_client_secret = admin_client_secret.strip() if admin_client_secret else None
|
|
||||||
self.userinfo_endpoint = (
|
|
||||||
userinfo_endpoint.strip() if userinfo_endpoint else self._infer_userinfo_endpoint(self.issuer, self.base_url)
|
|
||||||
)
|
|
||||||
self.verify_tls = verify_tls
|
|
||||||
|
|
||||||
if not self.jwks_url:
|
|
||||||
raise ValueError("KEYCLOAK_JWKS_URL or KEYCLOAK_ISSUER is required")
|
|
||||||
|
|
||||||
self._jwk_client = jwt.PyJWKClient(
|
|
||||||
self.jwks_url,
|
|
||||||
cache_jwk_set=True,
|
|
||||||
lifespan=600,
|
|
||||||
headers={
|
|
||||||
"Accept": "application/json",
|
|
||||||
"User-Agent": "member-ose-backend/1.0",
|
|
||||||
},
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
self._admin_token_cached: str | None = None
|
|
||||||
self._admin_token_expires_at: float = 0
|
|
||||||
self._principal_cache: dict[str, tuple[float, ProviderPrincipal]] = {}
|
|
||||||
self.member_required_realm_roles = {r.strip().lower() for r in member_required_realm_roles if r and r.strip()}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _infer_introspection_endpoint(issuer: str | None) -> str | None:
|
|
||||||
if not issuer:
|
|
||||||
return None
|
|
||||||
normalized = issuer.rstrip("/")
|
|
||||||
if "/realms/" in normalized:
|
|
||||||
return normalized + "/protocol/openid-connect/token/introspect"
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _introspect_token(self, token: str) -> dict | None:
|
|
||||||
endpoint = self._infer_introspection_endpoint(self.issuer)
|
|
||||||
if not endpoint or not self.client_id or not self.client_secret:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
resp = httpx.post(
|
|
||||||
endpoint,
|
|
||||||
timeout=8,
|
|
||||||
verify=self.verify_tls,
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
||||||
data={
|
|
||||||
"token": token,
|
|
||||||
"client_id": self.client_id,
|
|
||||||
"client_secret": self.client_secret,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
return None
|
|
||||||
data = resp.json() if resp.content else {}
|
|
||||||
if not isinstance(data, dict) or not data.get("active"):
|
|
||||||
return None
|
|
||||||
return data
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _infer_jwks_url(issuer: str | None) -> str | None:
|
|
||||||
if not issuer:
|
|
||||||
return None
|
|
||||||
return issuer.rstrip("/") + "/protocol/openid-connect/certs"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None:
|
|
||||||
if issuer:
|
|
||||||
return issuer.rstrip("/") + "/protocol/openid-connect/userinfo"
|
|
||||||
if base_url:
|
|
||||||
return base_url.rstrip("/") + "/realms/master/protocol/openid-connect/userinfo"
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _enrich_from_userinfo(self, principal: ProviderPrincipal, token: str) -> ProviderPrincipal:
|
|
||||||
if principal.email and (principal.name or principal.preferred_username) and principal.groups:
|
|
||||||
return principal
|
|
||||||
if not self.userinfo_endpoint:
|
|
||||||
return self._enrich_groups_from_admin(principal)
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = httpx.get(
|
|
||||||
self.userinfo_endpoint,
|
|
||||||
timeout=5,
|
|
||||||
verify=self.verify_tls,
|
|
||||||
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return self._enrich_groups_from_admin(principal)
|
|
||||||
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
return self._enrich_groups_from_admin(principal)
|
|
||||||
|
|
||||||
data = resp.json() if resp.content else {}
|
|
||||||
sub = data.get("sub")
|
|
||||||
if isinstance(sub, str) and sub and sub != principal.sub:
|
|
||||||
return self._enrich_groups_from_admin(principal)
|
|
||||||
|
|
||||||
email = principal.email or (data.get("email") if isinstance(data.get("email"), str) else None)
|
|
||||||
name = principal.name or (data.get("name") if isinstance(data.get("name"), str) else None)
|
|
||||||
preferred_username = principal.preferred_username or (
|
|
||||||
data.get("preferred_username") if isinstance(data.get("preferred_username"), str) else None
|
|
||||||
)
|
|
||||||
groups = principal.groups
|
|
||||||
payload_groups = data.get("groups")
|
|
||||||
if isinstance(payload_groups, list):
|
|
||||||
groups = [str(g) for g in payload_groups if str(g)]
|
|
||||||
enriched = ProviderPrincipal(
|
|
||||||
sub=principal.sub,
|
|
||||||
email=email,
|
|
||||||
name=name,
|
|
||||||
preferred_username=preferred_username,
|
|
||||||
groups=groups,
|
|
||||||
realm_roles=principal.realm_roles,
|
|
||||||
)
|
|
||||||
return self._enrich_groups_from_admin(enriched)
|
|
||||||
|
|
||||||
def _get_admin_token(self) -> str | None:
|
|
||||||
now = time.time()
|
|
||||||
if self._admin_token_cached and now < self._admin_token_expires_at:
|
|
||||||
return self._admin_token_cached
|
|
||||||
if (
|
|
||||||
not self.base_url
|
|
||||||
or not self.admin_realm
|
|
||||||
or not self.admin_client_id
|
|
||||||
or not self.admin_client_secret
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
token_endpoint = f"{self.base_url}/realms/{self.admin_realm}/protocol/openid-connect/token"
|
|
||||||
try:
|
|
||||||
resp = httpx.post(
|
|
||||||
token_endpoint,
|
|
||||||
data={
|
|
||||||
"grant_type": "client_credentials",
|
|
||||||
"client_id": self.admin_client_id,
|
|
||||||
"client_secret": self.admin_client_secret,
|
|
||||||
},
|
|
||||||
timeout=6,
|
|
||||||
verify=self.verify_tls,
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
return None
|
|
||||||
token = resp.json().get("access_token")
|
|
||||||
expires_in = resp.json().get("expires_in")
|
|
||||||
if token:
|
|
||||||
ttl = int(expires_in) if isinstance(expires_in, int) else 30
|
|
||||||
# Keep a small buffer to avoid using near-expiry admin token.
|
|
||||||
self._admin_token_cached = str(token)
|
|
||||||
self._admin_token_expires_at = max(now + ttl - 15, now + 5)
|
|
||||||
return self._admin_token_cached
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _enrich_groups_from_admin(self, principal: ProviderPrincipal) -> ProviderPrincipal:
|
|
||||||
if principal.groups:
|
|
||||||
return principal
|
|
||||||
if not self.base_url or not self.realm:
|
|
||||||
return principal
|
|
||||||
admin_token = self._get_admin_token()
|
|
||||||
if not admin_token:
|
|
||||||
return principal
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = httpx.get(
|
|
||||||
f"{self.base_url}/admin/realms/{self.realm}/users/{principal.sub}/groups",
|
|
||||||
timeout=6,
|
|
||||||
verify=self.verify_tls,
|
|
||||||
headers={"Authorization": f"Bearer {admin_token}", "Accept": "application/json"},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return principal
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
return principal
|
|
||||||
|
|
||||||
payload = resp.json() if resp.content else []
|
|
||||||
groups: list[str] = []
|
|
||||||
if isinstance(payload, list):
|
|
||||||
for item in payload:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
path = item.get("path")
|
|
||||||
name = item.get("name")
|
|
||||||
if isinstance(path, str) and path:
|
|
||||||
groups.append(path)
|
|
||||||
elif isinstance(name, str) and name:
|
|
||||||
groups.append(name)
|
|
||||||
if not groups:
|
|
||||||
return principal
|
|
||||||
return ProviderPrincipal(
|
|
||||||
sub=principal.sub,
|
|
||||||
email=principal.email,
|
|
||||||
name=principal.name,
|
|
||||||
preferred_username=principal.preferred_username,
|
|
||||||
groups=groups,
|
|
||||||
realm_roles=principal.realm_roles,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _require_member_role(self, principal: ProviderPrincipal) -> None:
|
|
||||||
if not self.member_required_realm_roles:
|
|
||||||
return
|
|
||||||
user_roles = {r.strip().lower() for r in principal.realm_roles if isinstance(r, str) and r.strip()}
|
|
||||||
if not user_roles.intersection(self.member_required_realm_roles):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="member_forbidden")
|
|
||||||
|
|
||||||
def verify_access_token(self, token: str) -> ProviderPrincipal:
|
|
||||||
now = time.time()
|
|
||||||
cached = self._principal_cache.get(token)
|
|
||||||
if cached and now < cached[0]:
|
|
||||||
return cached[1]
|
|
||||||
|
|
||||||
try:
|
|
||||||
header = jwt.get_unverified_header(token)
|
|
||||||
algorithm = str(header.get("alg", "")).upper()
|
|
||||||
options = {
|
|
||||||
"verify_signature": True,
|
|
||||||
"verify_exp": True,
|
|
||||||
"verify_aud": bool(self.audience),
|
|
||||||
"verify_iss": bool(self.issuer),
|
|
||||||
}
|
|
||||||
|
|
||||||
if algorithm.startswith("HS"):
|
|
||||||
if not self.client_secret:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="missing_idp_client_secret",
|
|
||||||
)
|
|
||||||
key = self.client_secret
|
|
||||||
allowed_algorithms = ["HS256", "HS384", "HS512"]
|
|
||||||
else:
|
|
||||||
signing_key = self._jwk_client.get_signing_key_from_jwt(token)
|
|
||||||
key = signing_key.key
|
|
||||||
allowed_algorithms = ["RS256", "RS384", "RS512"]
|
|
||||||
|
|
||||||
claims = jwt.decode(
|
|
||||||
token,
|
|
||||||
key,
|
|
||||||
algorithms=allowed_algorithms,
|
|
||||||
audience=self.audience,
|
|
||||||
issuer=self.issuer,
|
|
||||||
options=options,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
claims = self._introspect_token(token)
|
|
||||||
if claims:
|
|
||||||
logger.debug("jwt verify failed, used introspection fallback: %s", exc)
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc
|
|
||||||
|
|
||||||
sub = claims.get("sub")
|
|
||||||
if not sub:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub")
|
|
||||||
|
|
||||||
principal = ProviderPrincipal(
|
|
||||||
sub=sub,
|
|
||||||
email=claims.get("email"),
|
|
||||||
name=claims.get("name"),
|
|
||||||
preferred_username=claims.get("preferred_username"),
|
|
||||||
groups=[str(g) for g in claims.get("groups", []) if str(g)] if isinstance(claims.get("groups"), list) else [],
|
|
||||||
realm_roles=[
|
|
||||||
str(r)
|
|
||||||
for r in (
|
|
||||||
claims.get("realm_access", {}).get("roles", [])
|
|
||||||
if isinstance(claims.get("realm_access"), dict)
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
if str(r)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
enriched = self._enrich_from_userinfo(principal, token)
|
|
||||||
self._require_member_role(enriched)
|
|
||||||
|
|
||||||
exp = claims.get("exp")
|
|
||||||
if isinstance(exp, int):
|
|
||||||
cache_until = min(float(exp), now + 60)
|
|
||||||
else:
|
|
||||||
cache_until = now + 30
|
|
||||||
if cache_until > now:
|
|
||||||
self._principal_cache[token] = (cache_until, enriched)
|
|
||||||
if len(self._principal_cache) > 512:
|
|
||||||
# Simple bound to avoid unbounded memory growth.
|
|
||||||
self._principal_cache.clear()
|
|
||||||
|
|
||||||
return enriched
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def _get_verifier() -> ProviderTokenVerifier:
|
|
||||||
settings = get_settings()
|
|
||||||
return ProviderTokenVerifier(
|
|
||||||
issuer=settings.idp_issuer,
|
|
||||||
jwks_url=settings.idp_jwks_url,
|
|
||||||
audience=settings.idp_audience,
|
|
||||||
client_id=settings.idp_client_id,
|
|
||||||
client_secret=settings.idp_client_secret,
|
|
||||||
base_url=settings.idp_base_url,
|
|
||||||
userinfo_endpoint=settings.idp_userinfo_endpoint,
|
|
||||||
verify_tls=settings.idp_verify_tls,
|
|
||||||
realm=settings.keycloak_realm,
|
|
||||||
admin_realm=settings.keycloak_admin_realm,
|
|
||||||
admin_client_id=settings.keycloak_admin_client_id,
|
|
||||||
admin_client_secret=settings.keycloak_admin_client_secret,
|
|
||||||
member_required_realm_roles=settings.member_required_realm_roles,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def require_authenticated_principal(
|
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
|
||||||
) -> ProviderPrincipal:
|
|
||||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_bearer_token")
|
|
||||||
|
|
||||||
verifier = _get_verifier()
|
|
||||||
return verifier.verify_access_token(credentials.credentials)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Service layer."""
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import secrets
|
|
||||||
import string
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
from app.core.config import Settings
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProviderSyncResult:
|
|
||||||
user_id: str
|
|
||||||
action: str
|
|
||||||
user_sub: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProviderPasswordResetResult:
|
|
||||||
user_id: str
|
|
||||||
temporary_password: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProviderDeleteResult:
|
|
||||||
action: str
|
|
||||||
user_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProviderGroupSyncResult:
|
|
||||||
group_id: str
|
|
||||||
action: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProviderRoleSyncResult:
|
|
||||||
role_name: str
|
|
||||||
action: str
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderAdminService:
|
|
||||||
def __init__(self, settings: Settings) -> None:
|
|
||||||
self.base_url = settings.keycloak_base_url.rstrip("/")
|
|
||||||
self.realm = settings.keycloak_realm
|
|
||||||
self.admin_realm = settings.keycloak_admin_realm or settings.keycloak_realm
|
|
||||||
self.admin_client_id = settings.keycloak_admin_client_id
|
|
||||||
self.admin_client_secret = settings.keycloak_admin_client_secret
|
|
||||||
self.verify_tls = settings.keycloak_verify_tls
|
|
||||||
|
|
||||||
if not self.base_url or not self.realm or not self.admin_client_id or not self.admin_client_secret:
|
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_admin_not_configured")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _safe_username(sub: str | None, email: str) -> str:
|
|
||||||
if email and "@" in email:
|
|
||||||
return email.split("@", 1)[0]
|
|
||||||
if sub:
|
|
||||||
return sub.replace("|", "_")[:150]
|
|
||||||
return "member-user"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _generate_temporary_password(length: int = 14) -> str:
|
|
||||||
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
|
|
||||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
||||||
|
|
||||||
def _get_admin_token(self) -> str:
|
|
||||||
token_endpoint = f"{self.base_url}/realms/{self.admin_realm}/protocol/openid-connect/token"
|
|
||||||
try:
|
|
||||||
resp = httpx.post(
|
|
||||||
token_endpoint,
|
|
||||||
data={
|
|
||||||
"grant_type": "client_credentials",
|
|
||||||
"client_id": self.admin_client_id,
|
|
||||||
"client_secret": self.admin_client_secret,
|
|
||||||
},
|
|
||||||
timeout=10,
|
|
||||||
verify=self.verify_tls,
|
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed") from exc
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
token = resp.json().get("access_token")
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
return str(token)
|
|
||||||
|
|
||||||
def _client(self) -> httpx.Client:
|
|
||||||
return httpx.Client(
|
|
||||||
base_url=self.base_url,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {self._get_admin_token()}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
timeout=10,
|
|
||||||
verify=self.verify_tls,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _lookup_user_by_id(self, client: httpx.Client, user_id: str) -> dict | None:
|
|
||||||
resp = client.get(f"/admin/realms/{self.realm}/users/{user_id}")
|
|
||||||
if resp.status_code == 404:
|
|
||||||
return None
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
def _lookup_group_by_id(self, client: httpx.Client, group_id: str) -> dict | None:
|
|
||||||
resp = client.get(f"/admin/realms/{self.realm}/groups/{group_id}")
|
|
||||||
if resp.status_code == 404:
|
|
||||||
return None
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_group_lookup_failed")
|
|
||||||
payload = resp.json() if resp.content else {}
|
|
||||||
return payload if isinstance(payload, dict) else None
|
|
||||||
|
|
||||||
def _lookup_group_by_name(self, client: httpx.Client, *, name: str, parent_group_id: str | None) -> dict | None:
|
|
||||||
if parent_group_id:
|
|
||||||
resp = client.get(
|
|
||||||
f"/admin/realms/{self.realm}/groups/{parent_group_id}/children",
|
|
||||||
params={"search": name, "briefRepresentation": "false"},
|
|
||||||
)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_group_lookup_failed")
|
|
||||||
matches = resp.json() if isinstance(resp.json(), list) else []
|
|
||||||
for row in matches:
|
|
||||||
if isinstance(row, dict) and str(row.get("name", "")).strip() == name:
|
|
||||||
return row
|
|
||||||
return None
|
|
||||||
|
|
||||||
resp = client.get(
|
|
||||||
f"/admin/realms/{self.realm}/groups",
|
|
||||||
params={"search": name, "exact": "true", "briefRepresentation": "false"},
|
|
||||||
)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_group_lookup_failed")
|
|
||||||
matches = resp.json() if isinstance(resp.json(), list) else []
|
|
||||||
for row in matches:
|
|
||||||
if not isinstance(row, dict):
|
|
||||||
continue
|
|
||||||
if str(row.get("name", "")).strip() != name:
|
|
||||||
continue
|
|
||||||
parent_id = row.get("parentId")
|
|
||||||
if parent_group_id:
|
|
||||||
if str(parent_id or "") == parent_group_id:
|
|
||||||
return row
|
|
||||||
elif not parent_id:
|
|
||||||
return row
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_group_attributes(attributes: dict[str, str | list[str]] | None) -> dict[str, list[str]]:
|
|
||||||
if not attributes:
|
|
||||||
return {}
|
|
||||||
output: dict[str, list[str]] = {}
|
|
||||||
for key, value in attributes.items():
|
|
||||||
normalized_key = str(key).strip()
|
|
||||||
if not normalized_key:
|
|
||||||
continue
|
|
||||||
if isinstance(value, list):
|
|
||||||
output[normalized_key] = [str(v) for v in value if str(v)]
|
|
||||||
elif value is not None and str(value):
|
|
||||||
output[normalized_key] = [str(value)]
|
|
||||||
return output
|
|
||||||
|
|
||||||
def _lookup_user_by_email_or_username(
|
|
||||||
self, client: httpx.Client, *, email: str | None, username: str | None
|
|
||||||
) -> dict | None:
|
|
||||||
if email:
|
|
||||||
resp = client.get(f"/admin/realms/{self.realm}/users", params={"email": email, "exact": "true"})
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
matches = resp.json() if isinstance(resp.json(), list) else []
|
|
||||||
if matches:
|
|
||||||
return matches[0]
|
|
||||||
if username:
|
|
||||||
resp = client.get(f"/admin/realms/{self.realm}/users", params={"username": username, "exact": "true"})
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
matches = resp.json() if isinstance(resp.json(), list) else []
|
|
||||||
if matches:
|
|
||||||
return matches[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def ensure_user(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
sub: str | None,
|
|
||||||
email: str,
|
|
||||||
username: str | None,
|
|
||||||
display_name: str | None,
|
|
||||||
is_active: bool = True,
|
|
||||||
provider_user_id: str | None = None,
|
|
||||||
) -> ProviderSyncResult:
|
|
||||||
resolved_username = username or self._safe_username(sub=sub, email=email)
|
|
||||||
first_name = display_name or resolved_username
|
|
||||||
payload = {
|
|
||||||
"username": resolved_username,
|
|
||||||
"email": email,
|
|
||||||
"enabled": is_active,
|
|
||||||
"emailVerified": True,
|
|
||||||
"firstName": first_name,
|
|
||||||
"attributes": {"user_sub": [sub]} if sub else {},
|
|
||||||
}
|
|
||||||
|
|
||||||
with self._client() as client:
|
|
||||||
existing = self._lookup_user_by_id(client, provider_user_id) if provider_user_id else None
|
|
||||||
if existing is None:
|
|
||||||
existing = self._lookup_user_by_email_or_username(client, email=email, username=resolved_username)
|
|
||||||
|
|
||||||
if existing and existing.get("id"):
|
|
||||||
user_id = str(existing["id"])
|
|
||||||
put_resp = client.put(f"/admin/realms/{self.realm}/users/{user_id}", json=payload)
|
|
||||||
if put_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_update_failed")
|
|
||||||
return ProviderSyncResult(user_id=user_id, action="updated", user_sub=user_id)
|
|
||||||
|
|
||||||
create_resp = client.post(f"/admin/realms/{self.realm}/users", json=payload)
|
|
||||||
if create_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_create_failed")
|
|
||||||
|
|
||||||
location = create_resp.headers.get("Location", "")
|
|
||||||
user_id = location.rstrip("/").split("/")[-1] if location and "/" in location else ""
|
|
||||||
if not user_id:
|
|
||||||
found = self._lookup_user_by_email_or_username(client, email=email, username=resolved_username)
|
|
||||||
user_id = str(found["id"]) if found and found.get("id") else ""
|
|
||||||
if not user_id:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_create_failed")
|
|
||||||
return ProviderSyncResult(user_id=user_id, action="created", user_sub=user_id)
|
|
||||||
|
|
||||||
def ensure_group(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
group_id: str | None = None,
|
|
||||||
parent_group_id: str | None = None,
|
|
||||||
attributes: dict[str, str | list[str]] | None = None,
|
|
||||||
) -> ProviderGroupSyncResult:
|
|
||||||
if not name:
|
|
||||||
raise HTTPException(status_code=400, detail="idp_group_name_required")
|
|
||||||
normalized_attrs = self._normalize_group_attributes(attributes)
|
|
||||||
|
|
||||||
with self._client() as client:
|
|
||||||
existing = self._lookup_group_by_id(client, group_id) if group_id else None
|
|
||||||
if existing is None:
|
|
||||||
existing = self._lookup_group_by_name(client, name=name, parent_group_id=parent_group_id)
|
|
||||||
|
|
||||||
if existing and existing.get("id"):
|
|
||||||
resolved_id = str(existing["id"])
|
|
||||||
payload = {"name": name, "attributes": normalized_attrs}
|
|
||||||
put_resp = client.put(f"/admin/realms/{self.realm}/groups/{resolved_id}", json=payload)
|
|
||||||
if put_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_group_update_failed")
|
|
||||||
return ProviderGroupSyncResult(group_id=resolved_id, action="updated")
|
|
||||||
|
|
||||||
payload = {"name": name, "attributes": normalized_attrs}
|
|
||||||
if parent_group_id:
|
|
||||||
create_resp = client.post(f"/admin/realms/{self.realm}/groups/{parent_group_id}/children", json=payload)
|
|
||||||
else:
|
|
||||||
create_resp = client.post(f"/admin/realms/{self.realm}/groups", json=payload)
|
|
||||||
if create_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_group_create_failed")
|
|
||||||
|
|
||||||
location = create_resp.headers.get("Location", "")
|
|
||||||
resolved_id = location.rstrip("/").split("/")[-1] if location and "/" in location else ""
|
|
||||||
if not resolved_id:
|
|
||||||
found = self._lookup_group_by_name(client, name=name, parent_group_id=parent_group_id)
|
|
||||||
resolved_id = str(found.get("id")) if found and found.get("id") else ""
|
|
||||||
if not resolved_id:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_group_create_failed")
|
|
||||||
return ProviderGroupSyncResult(group_id=resolved_id, action="created")
|
|
||||||
|
|
||||||
def delete_group(self, *, group_id: str | None) -> ProviderDeleteResult:
|
|
||||||
if not group_id:
|
|
||||||
return ProviderDeleteResult(action="not_found")
|
|
||||||
with self._client() as client:
|
|
||||||
resp = client.delete(f"/admin/realms/{self.realm}/groups/{group_id}")
|
|
||||||
if resp.status_code in {204, 404}:
|
|
||||||
return ProviderDeleteResult(action="deleted" if resp.status_code == 204 else "not_found")
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_group_delete_failed")
|
|
||||||
return ProviderDeleteResult(action="deleted")
|
|
||||||
|
|
||||||
def reset_password(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_user_id: str | None,
|
|
||||||
email: str | None,
|
|
||||||
username: str | None,
|
|
||||||
) -> ProviderPasswordResetResult:
|
|
||||||
with self._client() as client:
|
|
||||||
existing = self._lookup_user_by_id(client, provider_user_id) if provider_user_id else None
|
|
||||||
if existing is None:
|
|
||||||
existing = self._lookup_user_by_email_or_username(client, email=email, username=username)
|
|
||||||
if not existing or not existing.get("id"):
|
|
||||||
raise HTTPException(status_code=404, detail="idp_user_not_found")
|
|
||||||
|
|
||||||
user_id = str(existing["id"])
|
|
||||||
temp_password = self._generate_temporary_password()
|
|
||||||
resp = client.put(
|
|
||||||
f"/admin/realms/{self.realm}/users/{user_id}/reset-password",
|
|
||||||
json={"type": "password", "value": temp_password, "temporary": True},
|
|
||||||
)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_set_password_failed")
|
|
||||||
return ProviderPasswordResetResult(user_id=user_id, temporary_password=temp_password)
|
|
||||||
|
|
||||||
def delete_user(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_user_id: str | None,
|
|
||||||
email: str | None,
|
|
||||||
username: str | None,
|
|
||||||
) -> ProviderDeleteResult:
|
|
||||||
with self._client() as client:
|
|
||||||
existing = self._lookup_user_by_id(client, provider_user_id) if provider_user_id else None
|
|
||||||
if existing is None:
|
|
||||||
existing = self._lookup_user_by_email_or_username(client, email=email, username=username)
|
|
||||||
if not existing or not existing.get("id"):
|
|
||||||
return ProviderDeleteResult(action="not_found")
|
|
||||||
|
|
||||||
user_id = str(existing["id"])
|
|
||||||
resp = client.delete(f"/admin/realms/{self.realm}/users/{user_id}")
|
|
||||||
if resp.status_code in {204, 404}:
|
|
||||||
return ProviderDeleteResult(action="deleted" if resp.status_code == 204 else "not_found", user_id=user_id)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_delete_failed")
|
|
||||||
return ProviderDeleteResult(action="deleted", user_id=user_id)
|
|
||||||
|
|
||||||
def list_groups_tree(self) -> list[dict]:
|
|
||||||
with self._client() as client:
|
|
||||||
resp = client.get(
|
|
||||||
f"/admin/realms/{self.realm}/groups",
|
|
||||||
params={"first": 0, "max": 5000, "briefRepresentation": "false"},
|
|
||||||
)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_group_lookup_failed")
|
|
||||||
payload = resp.json() if resp.content else []
|
|
||||||
return payload if isinstance(payload, list) else []
|
|
||||||
|
|
||||||
def list_users(self) -> list[dict]:
|
|
||||||
users: list[dict] = []
|
|
||||||
first = 0
|
|
||||||
page_size = 200
|
|
||||||
with self._client() as client:
|
|
||||||
while True:
|
|
||||||
resp = client.get(
|
|
||||||
f"/admin/realms/{self.realm}/users",
|
|
||||||
params={"first": first, "max": page_size},
|
|
||||||
)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
batch = resp.json() if isinstance(resp.json(), list) else []
|
|
||||||
users.extend([row for row in batch if isinstance(row, dict)])
|
|
||||||
if len(batch) < page_size:
|
|
||||||
break
|
|
||||||
first += page_size
|
|
||||||
return users
|
|
||||||
|
|
||||||
def list_clients(self) -> list[dict]:
|
|
||||||
clients: list[dict] = []
|
|
||||||
first = 0
|
|
||||||
page_size = 200
|
|
||||||
with self._client() as client:
|
|
||||||
while True:
|
|
||||||
resp = client.get(
|
|
||||||
f"/admin/realms/{self.realm}/clients",
|
|
||||||
params={"first": first, "max": page_size},
|
|
||||||
)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
batch = resp.json() if isinstance(resp.json(), list) else []
|
|
||||||
clients.extend([row for row in batch if isinstance(row, dict)])
|
|
||||||
if len(batch) < page_size:
|
|
||||||
break
|
|
||||||
first += page_size
|
|
||||||
return clients
|
|
||||||
|
|
||||||
def list_client_roles(self, client_uuid: str) -> list[dict]:
|
|
||||||
with self._client() as client:
|
|
||||||
resp = client.get(
|
|
||||||
f"/admin/realms/{self.realm}/clients/{client_uuid}/roles",
|
|
||||||
params={"first": 0, "max": 5000},
|
|
||||||
)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
payload = resp.json() if resp.content else []
|
|
||||||
return payload if isinstance(payload, list) else []
|
|
||||||
|
|
||||||
def _resolve_client_uuid(self, client: httpx.Client, provider_client_id: str) -> str:
|
|
||||||
if not provider_client_id:
|
|
||||||
raise HTTPException(status_code=400, detail="provider_client_id_required")
|
|
||||||
# provider_client_id stores Keycloak clientId (not internal UUID).
|
|
||||||
resp = client.get(
|
|
||||||
f"/admin/realms/{self.realm}/clients",
|
|
||||||
params={"clientId": provider_client_id},
|
|
||||||
)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
payload = resp.json() if resp.content else []
|
|
||||||
rows = payload if isinstance(payload, list) else []
|
|
||||||
for row in rows:
|
|
||||||
if not isinstance(row, dict):
|
|
||||||
continue
|
|
||||||
if str(row.get("clientId", "")).strip() != provider_client_id:
|
|
||||||
continue
|
|
||||||
client_uuid = str(row.get("id", "")).strip()
|
|
||||||
if client_uuid:
|
|
||||||
return client_uuid
|
|
||||||
raise HTTPException(status_code=404, detail="provider_client_not_found")
|
|
||||||
|
|
||||||
def _get_client_role_representation(self, client: httpx.Client, *, client_uuid: str, role_name: str) -> dict:
|
|
||||||
resp = client.get(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}")
|
|
||||||
if resp.status_code == 404:
|
|
||||||
raise HTTPException(status_code=404, detail=f"provider_role_not_found:{role_name}")
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
payload = resp.json() if resp.content else {}
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def set_group_client_roles(self, *, group_id: str, provider_client_id: str, role_names: list[str]) -> None:
|
|
||||||
if not group_id:
|
|
||||||
raise HTTPException(status_code=400, detail="provider_group_id_required")
|
|
||||||
|
|
||||||
desired_names = [name.strip() for name in role_names if isinstance(name, str) and name.strip()]
|
|
||||||
desired_name_set = set(desired_names)
|
|
||||||
|
|
||||||
with self._client() as client:
|
|
||||||
client_uuid = self._resolve_client_uuid(client, provider_client_id)
|
|
||||||
|
|
||||||
current_resp = client.get(f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}")
|
|
||||||
if current_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_group_role_mapping_lookup_failed")
|
|
||||||
current_payload = current_resp.json() if current_resp.content else []
|
|
||||||
current_rows = current_payload if isinstance(current_payload, list) else []
|
|
||||||
current_map: dict[str, dict] = {}
|
|
||||||
for row in current_rows:
|
|
||||||
if not isinstance(row, dict):
|
|
||||||
continue
|
|
||||||
name = str(row.get("name", "")).strip()
|
|
||||||
if name:
|
|
||||||
current_map[name] = row
|
|
||||||
|
|
||||||
to_add_names = sorted(desired_name_set - set(current_map.keys()))
|
|
||||||
to_remove_names = sorted(set(current_map.keys()) - desired_name_set)
|
|
||||||
|
|
||||||
if to_add_names:
|
|
||||||
add_payload = [
|
|
||||||
self._get_client_role_representation(client, client_uuid=client_uuid, role_name=role_name)
|
|
||||||
for role_name in to_add_names
|
|
||||||
]
|
|
||||||
add_resp = client.post(
|
|
||||||
f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}",
|
|
||||||
json=add_payload,
|
|
||||||
)
|
|
||||||
if add_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_group_role_mapping_add_failed")
|
|
||||||
|
|
||||||
if to_remove_names:
|
|
||||||
remove_payload = [current_map[name] for name in to_remove_names]
|
|
||||||
remove_resp = client.request(
|
|
||||||
"DELETE",
|
|
||||||
f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}",
|
|
||||||
json=remove_payload,
|
|
||||||
)
|
|
||||||
if remove_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_group_role_mapping_remove_failed")
|
|
||||||
|
|
||||||
def ensure_client_role(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_client_id: str,
|
|
||||||
provider_role_name: str,
|
|
||||||
description: str | None = None,
|
|
||||||
) -> ProviderRoleSyncResult:
|
|
||||||
role_name = provider_role_name.strip()
|
|
||||||
if not role_name:
|
|
||||||
raise HTTPException(status_code=400, detail="provider_role_name_required")
|
|
||||||
|
|
||||||
with self._client() as client:
|
|
||||||
client_uuid = self._resolve_client_uuid(client, provider_client_id)
|
|
||||||
get_resp = client.get(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}")
|
|
||||||
if get_resp.status_code == 404:
|
|
||||||
create_resp = client.post(
|
|
||||||
f"/admin/realms/{self.realm}/clients/{client_uuid}/roles",
|
|
||||||
json={"name": role_name, "description": description or ""},
|
|
||||||
)
|
|
||||||
if create_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_role_create_failed")
|
|
||||||
return ProviderRoleSyncResult(role_name=role_name, action="created")
|
|
||||||
if get_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
|
|
||||||
payload = get_resp.json() if get_resp.content else {}
|
|
||||||
update_payload = {
|
|
||||||
"name": role_name,
|
|
||||||
"description": description if description is not None else payload.get("description", ""),
|
|
||||||
"attributes": payload.get("attributes") if isinstance(payload, dict) else {},
|
|
||||||
}
|
|
||||||
put_resp = client.put(
|
|
||||||
f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}",
|
|
||||||
json=update_payload,
|
|
||||||
)
|
|
||||||
if put_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_role_update_failed")
|
|
||||||
return ProviderRoleSyncResult(role_name=role_name, action="updated")
|
|
||||||
|
|
||||||
def update_client_role(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_client_id: str,
|
|
||||||
old_provider_role_name: str,
|
|
||||||
new_provider_role_name: str,
|
|
||||||
description: str | None = None,
|
|
||||||
) -> ProviderRoleSyncResult:
|
|
||||||
old_name = old_provider_role_name.strip()
|
|
||||||
new_name = new_provider_role_name.strip()
|
|
||||||
if not old_name or not new_name:
|
|
||||||
raise HTTPException(status_code=400, detail="provider_role_name_required")
|
|
||||||
|
|
||||||
with self._client() as client:
|
|
||||||
client_uuid = self._resolve_client_uuid(client, provider_client_id)
|
|
||||||
get_resp = client.get(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{old_name}")
|
|
||||||
if get_resp.status_code == 404:
|
|
||||||
# If old role missing, create the new one to self-heal drift.
|
|
||||||
create_resp = client.post(
|
|
||||||
f"/admin/realms/{self.realm}/clients/{client_uuid}/roles",
|
|
||||||
json={"name": new_name, "description": description or ""},
|
|
||||||
)
|
|
||||||
if create_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_role_create_failed")
|
|
||||||
return ProviderRoleSyncResult(role_name=new_name, action="created")
|
|
||||||
if get_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_lookup_failed")
|
|
||||||
|
|
||||||
payload = get_resp.json() if get_resp.content else {}
|
|
||||||
update_payload = {
|
|
||||||
"name": new_name,
|
|
||||||
"description": description if description is not None else payload.get("description", ""),
|
|
||||||
"attributes": payload.get("attributes") if isinstance(payload, dict) else {},
|
|
||||||
}
|
|
||||||
put_resp = client.put(
|
|
||||||
f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{old_name}",
|
|
||||||
json=update_payload,
|
|
||||||
)
|
|
||||||
if put_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_role_update_failed")
|
|
||||||
return ProviderRoleSyncResult(role_name=new_name, action="updated")
|
|
||||||
|
|
||||||
def delete_client_role(self, *, provider_client_id: str, provider_role_name: str) -> ProviderDeleteResult:
|
|
||||||
role_name = provider_role_name.strip()
|
|
||||||
if not role_name:
|
|
||||||
return ProviderDeleteResult(action="not_found")
|
|
||||||
with self._client() as client:
|
|
||||||
client_uuid = self._resolve_client_uuid(client, provider_client_id)
|
|
||||||
resp = client.delete(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}")
|
|
||||||
if resp.status_code in {204, 404}:
|
|
||||||
return ProviderDeleteResult(action="deleted" if resp.status_code == 204 else "not_found")
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="idp_role_delete_failed")
|
|
||||||
return ProviderDeleteResult(action="deleted")
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.core.keygen import generate_key
|
|
||||||
from app.models.company import Company
|
|
||||||
from app.models.role import Role
|
|
||||||
from app.models.site import Site
|
|
||||||
from app.models.system import System
|
|
||||||
from app.repositories.companies_repo import CompaniesRepository
|
|
||||||
from app.repositories.roles_repo import RolesRepository
|
|
||||||
from app.repositories.sites_repo import SitesRepository
|
|
||||||
from app.repositories.systems_repo import SystemsRepository
|
|
||||||
from app.repositories.users_repo import UsersRepository
|
|
||||||
from app.services.idp_admin_service import ProviderAdminService
|
|
||||||
|
|
||||||
BUILTIN_CLIENT_IDS = {
|
|
||||||
"account",
|
|
||||||
"account-console",
|
|
||||||
"admin-cli",
|
|
||||||
"broker",
|
|
||||||
"realm-management",
|
|
||||||
"security-admin-console",
|
|
||||||
"master-realm",
|
|
||||||
}
|
|
||||||
|
|
||||||
_sync_lock = threading.Lock()
|
|
||||||
_last_synced_at = 0.0
|
|
||||||
_last_systems_synced_at = 0.0
|
|
||||||
_min_sync_interval_sec = 30.0
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_unique_key(prefix: str, exists_check) -> str:
|
|
||||||
for salt in range(5000):
|
|
||||||
key = generate_key(prefix, salt)
|
|
||||||
if not exists_check(key):
|
|
||||||
return key
|
|
||||||
raise RuntimeError(f"failed_generate_{prefix.lower()}_key")
|
|
||||||
|
|
||||||
|
|
||||||
def _first_attr(attrs: dict | None, key: str) -> str | None:
|
|
||||||
if not isinstance(attrs, dict):
|
|
||||||
return None
|
|
||||||
raw = attrs.get(key)
|
|
||||||
if isinstance(raw, list) and raw:
|
|
||||||
value = str(raw[0]).strip()
|
|
||||||
return value or None
|
|
||||||
if isinstance(raw, str):
|
|
||||||
value = raw.strip()
|
|
||||||
return value or None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _flatten_groups(nodes: list[dict], inherited_company_key: str | None = None) -> tuple[dict[str, dict], dict[str, dict]]:
|
|
||||||
companies: dict[str, dict] = {}
|
|
||||||
sites: dict[str, dict] = {}
|
|
||||||
|
|
||||||
for node in nodes:
|
|
||||||
if not isinstance(node, dict):
|
|
||||||
continue
|
|
||||||
attrs = node.get("attributes")
|
|
||||||
group_id = str(node.get("id", "")).strip() or None
|
|
||||||
name = str(node.get("name", "")).strip()
|
|
||||||
children = node.get("subGroups") if isinstance(node.get("subGroups"), list) else []
|
|
||||||
|
|
||||||
company_key = _first_attr(attrs, "company_key")
|
|
||||||
if not company_key and name.startswith("CP"):
|
|
||||||
company_key = name
|
|
||||||
if _first_attr(attrs, "member_entity_type") == "company" and not company_key:
|
|
||||||
company_key = name or None
|
|
||||||
|
|
||||||
current_company_key = company_key or inherited_company_key
|
|
||||||
|
|
||||||
if company_key:
|
|
||||||
companies[company_key] = {
|
|
||||||
"company_key": company_key,
|
|
||||||
"name": _first_attr(attrs, "name") or _first_attr(attrs, "display_name") or name or company_key,
|
|
||||||
"status": _first_attr(attrs, "status") or "active",
|
|
||||||
"provider_group_id": group_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
site_key = _first_attr(attrs, "site_key")
|
|
||||||
if not site_key and name.startswith("ST"):
|
|
||||||
site_key = name
|
|
||||||
if _first_attr(attrs, "member_entity_type") == "site" and not site_key:
|
|
||||||
site_key = name or None
|
|
||||||
if site_key:
|
|
||||||
sites[site_key] = {
|
|
||||||
"site_key": site_key,
|
|
||||||
"company_key": _first_attr(attrs, "company_key") or current_company_key,
|
|
||||||
"display_name": _first_attr(attrs, "display_name") or name or site_key,
|
|
||||||
"domain": _first_attr(attrs, "domain"),
|
|
||||||
"status": _first_attr(attrs, "status") or "active",
|
|
||||||
"provider_group_id": group_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
child_companies, child_sites = _flatten_groups(children, current_company_key)
|
|
||||||
companies.update(child_companies)
|
|
||||||
sites.update(child_sites)
|
|
||||||
|
|
||||||
return companies, sites
|
|
||||||
|
|
||||||
|
|
||||||
def sync_from_provider(db: Session, *, force: bool = False) -> dict[str, int]:
|
|
||||||
global _last_synced_at
|
|
||||||
now = time.time()
|
|
||||||
if not force and now - _last_synced_at < _min_sync_interval_sec:
|
|
||||||
return {"synced": 0}
|
|
||||||
|
|
||||||
if not _sync_lock.acquire(blocking=False):
|
|
||||||
return {"synced": 0}
|
|
||||||
|
|
||||||
try:
|
|
||||||
now = time.time()
|
|
||||||
if not force and now - _last_synced_at < _min_sync_interval_sec:
|
|
||||||
return {"synced": 0}
|
|
||||||
|
|
||||||
idp = ProviderAdminService(get_settings())
|
|
||||||
companies_repo = CompaniesRepository(db)
|
|
||||||
sites_repo = SitesRepository(db)
|
|
||||||
systems_repo = SystemsRepository(db)
|
|
||||||
roles_repo = RolesRepository(db)
|
|
||||||
users_repo = UsersRepository(db)
|
|
||||||
|
|
||||||
companies_created = 0
|
|
||||||
companies_updated = 0
|
|
||||||
sites_created = 0
|
|
||||||
sites_updated = 0
|
|
||||||
systems_created = 0
|
|
||||||
systems_updated = 0
|
|
||||||
roles_created = 0
|
|
||||||
roles_updated = 0
|
|
||||||
users_created_or_updated = 0
|
|
||||||
|
|
||||||
group_tree = idp.list_groups_tree()
|
|
||||||
company_records, site_records = _flatten_groups(group_tree)
|
|
||||||
|
|
||||||
company_id_map: dict[str, str] = {}
|
|
||||||
for company_key, row in company_records.items():
|
|
||||||
company = companies_repo.get_by_key(company_key)
|
|
||||||
if company is None:
|
|
||||||
company = companies_repo.create(
|
|
||||||
company_key=company_key,
|
|
||||||
name=row["name"],
|
|
||||||
provider_group_id=row["provider_group_id"],
|
|
||||||
status=row["status"],
|
|
||||||
)
|
|
||||||
companies_created += 1
|
|
||||||
else:
|
|
||||||
company = companies_repo.update(
|
|
||||||
company,
|
|
||||||
name=row["name"],
|
|
||||||
provider_group_id=row["provider_group_id"],
|
|
||||||
status=row["status"],
|
|
||||||
)
|
|
||||||
companies_updated += 1
|
|
||||||
company_id_map[company_key] = company.id
|
|
||||||
|
|
||||||
for site_key, row in site_records.items():
|
|
||||||
company_key = row.get("company_key")
|
|
||||||
if not company_key:
|
|
||||||
continue
|
|
||||||
company_id = company_id_map.get(company_key)
|
|
||||||
if not company_id:
|
|
||||||
placeholder = companies_repo.get_by_key(company_key)
|
|
||||||
if placeholder is None:
|
|
||||||
placeholder = companies_repo.create(
|
|
||||||
company_key=company_key,
|
|
||||||
name=company_key,
|
|
||||||
provider_group_id=None,
|
|
||||||
status="active",
|
|
||||||
)
|
|
||||||
companies_created += 1
|
|
||||||
company_id = placeholder.id
|
|
||||||
company_id_map[company_key] = company_id
|
|
||||||
|
|
||||||
site = sites_repo.get_by_key(site_key)
|
|
||||||
if site is None:
|
|
||||||
sites_repo.create(
|
|
||||||
site_key=site_key,
|
|
||||||
company_id=company_id,
|
|
||||||
display_name=row["display_name"],
|
|
||||||
domain=row["domain"],
|
|
||||||
provider_group_id=row["provider_group_id"],
|
|
||||||
status=row["status"],
|
|
||||||
)
|
|
||||||
sites_created += 1
|
|
||||||
else:
|
|
||||||
sites_repo.update(
|
|
||||||
site,
|
|
||||||
company_id=company_id,
|
|
||||||
display_name=row["display_name"],
|
|
||||||
domain=row["domain"],
|
|
||||||
provider_group_id=row["provider_group_id"],
|
|
||||||
status=row["status"],
|
|
||||||
)
|
|
||||||
sites_updated += 1
|
|
||||||
|
|
||||||
client_rows = idp.list_clients()
|
|
||||||
system_map_by_client_id: dict[str, System] = {}
|
|
||||||
for client in client_rows:
|
|
||||||
client_uuid = str(client.get("id", "")).strip()
|
|
||||||
client_id = str(client.get("clientId", "")).strip()
|
|
||||||
if not client_uuid or not client_id:
|
|
||||||
continue
|
|
||||||
if client_id in BUILTIN_CLIENT_IDS:
|
|
||||||
continue
|
|
||||||
|
|
||||||
system = db.scalar(select(System).where(System.name == client_id))
|
|
||||||
system_name = str(client.get("name", "")).strip() or client_id
|
|
||||||
system_status = "active" if client.get("enabled", True) else "inactive"
|
|
||||||
if system is None:
|
|
||||||
system_key = _generate_unique_key("SY", lambda key: systems_repo.get_by_key(key) is not None)
|
|
||||||
system = systems_repo.create(
|
|
||||||
system_key=system_key,
|
|
||||||
name=system_name,
|
|
||||||
status=system_status,
|
|
||||||
)
|
|
||||||
systems_created += 1
|
|
||||||
else:
|
|
||||||
system = systems_repo.update(
|
|
||||||
system,
|
|
||||||
name=system_name,
|
|
||||||
status=system_status,
|
|
||||||
)
|
|
||||||
systems_updated += 1
|
|
||||||
system_map_by_client_id[client_id] = system
|
|
||||||
|
|
||||||
client_roles = idp.list_client_roles(client_uuid)
|
|
||||||
for role_row in client_roles:
|
|
||||||
if not isinstance(role_row, dict):
|
|
||||||
continue
|
|
||||||
role_name = str(role_row.get("name", "")).strip()
|
|
||||||
if not role_name:
|
|
||||||
continue
|
|
||||||
role_desc = str(role_row.get("description", "")).strip() or None
|
|
||||||
role_status = "active" if not role_row.get("composite", False) else "active"
|
|
||||||
role = db.scalar(
|
|
||||||
select(Role).where(
|
|
||||||
Role.system_id == system.id,
|
|
||||||
Role.name == role_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if role is None:
|
|
||||||
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
|
|
||||||
roles_repo.create(
|
|
||||||
role_key=role_key,
|
|
||||||
system_id=system.id,
|
|
||||||
name=role_name,
|
|
||||||
description=role_desc,
|
|
||||||
status=role_status,
|
|
||||||
)
|
|
||||||
roles_created += 1
|
|
||||||
else:
|
|
||||||
roles_repo.update(
|
|
||||||
role,
|
|
||||||
name=role_name,
|
|
||||||
description=role_desc,
|
|
||||||
status=role_status,
|
|
||||||
)
|
|
||||||
roles_updated += 1
|
|
||||||
|
|
||||||
for user in idp.list_users():
|
|
||||||
user_id = str(user.get("id", "")).strip()
|
|
||||||
if not user_id:
|
|
||||||
continue
|
|
||||||
display_name = (
|
|
||||||
str(user.get("firstName", "")).strip()
|
|
||||||
or str(user.get("username", "")).strip()
|
|
||||||
or str(user.get("email", "")).strip()
|
|
||||||
or user_id
|
|
||||||
)
|
|
||||||
users_repo.upsert_by_sub(
|
|
||||||
user_sub=user_id,
|
|
||||||
provider_user_id=user_id,
|
|
||||||
username=str(user.get("username", "")).strip() or None,
|
|
||||||
email=str(user.get("email", "")).strip() or None,
|
|
||||||
display_name=display_name,
|
|
||||||
is_active=bool(user.get("enabled", True)),
|
|
||||||
status="active" if user.get("enabled", True) else "inactive",
|
|
||||||
)
|
|
||||||
users_created_or_updated += 1
|
|
||||||
|
|
||||||
_last_synced_at = time.time()
|
|
||||||
return {
|
|
||||||
"synced": 1,
|
|
||||||
"companies_created": companies_created,
|
|
||||||
"companies_updated": companies_updated,
|
|
||||||
"sites_created": sites_created,
|
|
||||||
"sites_updated": sites_updated,
|
|
||||||
"systems_created": systems_created,
|
|
||||||
"systems_updated": systems_updated,
|
|
||||||
"roles_created": roles_created,
|
|
||||||
"roles_updated": roles_updated,
|
|
||||||
"users_upserted": users_created_or_updated,
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
_sync_lock.release()
|
|
||||||
|
|
||||||
|
|
||||||
def sync_systems_from_provider(db: Session, *, force: bool = False) -> dict[str, int]:
|
|
||||||
global _last_systems_synced_at
|
|
||||||
now = time.time()
|
|
||||||
if not force and now - _last_systems_synced_at < _min_sync_interval_sec:
|
|
||||||
return {"synced": 0}
|
|
||||||
|
|
||||||
if not _sync_lock.acquire(blocking=False):
|
|
||||||
return {"synced": 0}
|
|
||||||
|
|
||||||
try:
|
|
||||||
now = time.time()
|
|
||||||
if not force and now - _last_systems_synced_at < _min_sync_interval_sec:
|
|
||||||
return {"synced": 0}
|
|
||||||
|
|
||||||
idp = ProviderAdminService(get_settings())
|
|
||||||
systems_repo = SystemsRepository(db)
|
|
||||||
roles_repo = RolesRepository(db)
|
|
||||||
|
|
||||||
systems_created = 0
|
|
||||||
systems_updated = 0
|
|
||||||
roles_created = 0
|
|
||||||
roles_updated = 0
|
|
||||||
|
|
||||||
client_rows = idp.list_clients()
|
|
||||||
for client in client_rows:
|
|
||||||
client_uuid = str(client.get("id", "")).strip()
|
|
||||||
client_id = str(client.get("clientId", "")).strip()
|
|
||||||
if not client_uuid or not client_id:
|
|
||||||
continue
|
|
||||||
if client_id in BUILTIN_CLIENT_IDS:
|
|
||||||
continue
|
|
||||||
|
|
||||||
system = db.scalar(select(System).where(System.name == client_id))
|
|
||||||
system_name = str(client.get("name", "")).strip() or client_id
|
|
||||||
system_status = "active" if client.get("enabled", True) else "inactive"
|
|
||||||
if system is None:
|
|
||||||
system_key = _generate_unique_key("SY", lambda key: systems_repo.get_by_key(key) is not None)
|
|
||||||
system = systems_repo.create(
|
|
||||||
system_key=system_key,
|
|
||||||
name=system_name,
|
|
||||||
status=system_status,
|
|
||||||
)
|
|
||||||
systems_created += 1
|
|
||||||
else:
|
|
||||||
system = systems_repo.update(
|
|
||||||
system,
|
|
||||||
name=system_name,
|
|
||||||
status=system_status,
|
|
||||||
)
|
|
||||||
systems_updated += 1
|
|
||||||
|
|
||||||
client_roles = idp.list_client_roles(client_uuid)
|
|
||||||
for role_row in client_roles:
|
|
||||||
if not isinstance(role_row, dict):
|
|
||||||
continue
|
|
||||||
role_name = str(role_row.get("name", "")).strip()
|
|
||||||
if not role_name:
|
|
||||||
continue
|
|
||||||
role_desc = str(role_row.get("description", "")).strip() or None
|
|
||||||
role_status = "active" if not role_row.get("composite", False) else "active"
|
|
||||||
role = db.scalar(
|
|
||||||
select(Role).where(
|
|
||||||
Role.system_id == system.id,
|
|
||||||
Role.name == role_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if role is None:
|
|
||||||
role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None)
|
|
||||||
roles_repo.create(
|
|
||||||
role_key=role_key,
|
|
||||||
system_id=system.id,
|
|
||||||
name=role_name,
|
|
||||||
description=role_desc,
|
|
||||||
status=role_status,
|
|
||||||
)
|
|
||||||
roles_created += 1
|
|
||||||
else:
|
|
||||||
roles_repo.update(
|
|
||||||
role,
|
|
||||||
name=role_name,
|
|
||||||
description=role_desc,
|
|
||||||
status=role_status,
|
|
||||||
)
|
|
||||||
roles_updated += 1
|
|
||||||
|
|
||||||
_last_systems_synced_at = time.time()
|
|
||||||
return {
|
|
||||||
"synced": 1,
|
|
||||||
"systems_created": systems_created,
|
|
||||||
"systems_updated": systems_updated,
|
|
||||||
"roles_created": roles_created,
|
|
||||||
"roles_updated": roles_updated,
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
_sync_lock.release()
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from app.schemas.permissions import RoleSnapshotItem, RoleSnapshotResponse
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionService:
|
|
||||||
@staticmethod
|
|
||||||
def build_role_snapshot(user_sub: str, rows: list[tuple[str, str, str, str, str, str, str, str]]) -> RoleSnapshotResponse:
|
|
||||||
return RoleSnapshotResponse(
|
|
||||||
user_sub=user_sub,
|
|
||||||
roles=[
|
|
||||||
RoleSnapshotItem(
|
|
||||||
site_key=site_key,
|
|
||||||
site_display_name=site_display_name,
|
|
||||||
company_key=company_key,
|
|
||||||
company_display_name=company_display_name,
|
|
||||||
system_key=system_key,
|
|
||||||
system_name=system_name,
|
|
||||||
role_key=role_key,
|
|
||||||
role_name=role_name,
|
|
||||||
)
|
|
||||||
for (
|
|
||||||
site_key,
|
|
||||||
site_display_name,
|
|
||||||
company_key,
|
|
||||||
company_display_name,
|
|
||||||
system_key,
|
|
||||||
system_name,
|
|
||||||
role_key,
|
|
||||||
role_name,
|
|
||||||
) in rows
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from hashlib import sha256
|
|
||||||
import logging
|
|
||||||
import pickle
|
|
||||||
from threading import RLock
|
|
||||||
import time
|
|
||||||
from typing import Callable, Protocol, TypeVar
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
|
|
||||||
try:
|
|
||||||
import redis
|
|
||||||
except Exception: # pragma: no cover - optional dependency in local dev
|
|
||||||
redis = None
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CacheBackend(Protocol):
|
|
||||||
def get(self, key: str) -> object | None: ...
|
|
||||||
def set(self, key: str, value: object, ttl_seconds: int = 30) -> object: ...
|
|
||||||
def get_or_set(self, key: str, factory: Callable[[], T], ttl_seconds: int = 30) -> T: ...
|
|
||||||
def bump_revision(self) -> int: ...
|
|
||||||
def revision(self) -> int: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _CacheEntry:
|
|
||||||
value: object
|
|
||||||
expires_at: float
|
|
||||||
revision: int
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryRuntimeCache:
|
|
||||||
"""Simple in-memory cache for local/single-instance environments."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._lock = RLock()
|
|
||||||
self._revision = 0
|
|
||||||
self._entries: dict[str, _CacheEntry] = {}
|
|
||||||
|
|
||||||
def get(self, key: str) -> object | None:
|
|
||||||
now = time.time()
|
|
||||||
with self._lock:
|
|
||||||
entry = self._entries.get(key)
|
|
||||||
if not entry:
|
|
||||||
return None
|
|
||||||
if entry.expires_at <= now or entry.revision != self._revision:
|
|
||||||
self._entries.pop(key, None)
|
|
||||||
return None
|
|
||||||
return entry.value
|
|
||||||
|
|
||||||
def set(self, key: str, value: object, ttl_seconds: int = 30) -> object:
|
|
||||||
now = time.time()
|
|
||||||
with self._lock:
|
|
||||||
self._entries[key] = _CacheEntry(
|
|
||||||
value=value,
|
|
||||||
expires_at=now + max(ttl_seconds, 1),
|
|
||||||
revision=self._revision,
|
|
||||||
)
|
|
||||||
if len(self._entries) > 2000:
|
|
||||||
self._entries.clear()
|
|
||||||
return value
|
|
||||||
|
|
||||||
def get_or_set(self, key: str, factory: Callable[[], T], ttl_seconds: int = 30) -> T:
|
|
||||||
cached = self.get(key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached # type: ignore[return-value]
|
|
||||||
return self.set(key, factory(), ttl_seconds=ttl_seconds) # type: ignore[return-value]
|
|
||||||
|
|
||||||
def bump_revision(self) -> int:
|
|
||||||
with self._lock:
|
|
||||||
self._revision += 1
|
|
||||||
if self._revision > 1_000_000_000:
|
|
||||||
self._revision = 1
|
|
||||||
self._entries.clear()
|
|
||||||
return self._revision
|
|
||||||
|
|
||||||
def revision(self) -> int:
|
|
||||||
with self._lock:
|
|
||||||
return self._revision
|
|
||||||
|
|
||||||
|
|
||||||
class RedisRuntimeCache:
|
|
||||||
"""Redis-backed cache for multi-instance deployments."""
|
|
||||||
|
|
||||||
def __init__(self, *, redis_url: str, prefix: str, default_ttl_seconds: int = 30) -> None:
|
|
||||||
if redis is None:
|
|
||||||
raise RuntimeError("redis_package_not_installed")
|
|
||||||
self._redis = redis.Redis.from_url(redis_url, decode_responses=False)
|
|
||||||
self._prefix = prefix.strip() or "memberapi"
|
|
||||||
self._default_ttl_seconds = max(int(default_ttl_seconds), 1)
|
|
||||||
self._revision_key = f"{self._prefix}:cache:revision"
|
|
||||||
self._rev_cache_value = 0
|
|
||||||
self._rev_cache_expires_at = 0.0
|
|
||||||
|
|
||||||
def _cache_key(self, key: str, revision: int) -> str:
|
|
||||||
key_hash = sha256(key.encode("utf-8")).hexdigest()
|
|
||||||
return f"{self._prefix}:cache:{revision}:{key_hash}"
|
|
||||||
|
|
||||||
def _get_revision_cached(self) -> int:
|
|
||||||
now = time.time()
|
|
||||||
if now < self._rev_cache_expires_at:
|
|
||||||
return self._rev_cache_value
|
|
||||||
try:
|
|
||||||
raw = self._redis.get(self._revision_key)
|
|
||||||
value = int(raw) if raw else 0
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
self._rev_cache_value = value
|
|
||||||
self._rev_cache_expires_at = now + 1.0
|
|
||||||
return value
|
|
||||||
|
|
||||||
def get(self, key: str) -> object | None:
|
|
||||||
try:
|
|
||||||
revision = self._get_revision_cached()
|
|
||||||
raw = self._redis.get(self._cache_key(key, revision))
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
return pickle.loads(raw)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def set(self, key: str, value: object, ttl_seconds: int = 30) -> object:
|
|
||||||
ttl = max(int(ttl_seconds), 1) if ttl_seconds else self._default_ttl_seconds
|
|
||||||
try:
|
|
||||||
revision = self._get_revision_cached()
|
|
||||||
self._redis.setex(self._cache_key(key, revision), ttl, pickle.dumps(value))
|
|
||||||
except Exception:
|
|
||||||
# Keep request path healthy even when Redis has issues.
|
|
||||||
pass
|
|
||||||
return value
|
|
||||||
|
|
||||||
def get_or_set(self, key: str, factory: Callable[[], T], ttl_seconds: int = 30) -> T:
|
|
||||||
cached = self.get(key)
|
|
||||||
if cached is not None:
|
|
||||||
return cached # type: ignore[return-value]
|
|
||||||
return self.set(key, factory(), ttl_seconds=ttl_seconds) # type: ignore[return-value]
|
|
||||||
|
|
||||||
def bump_revision(self) -> int:
|
|
||||||
try:
|
|
||||||
value = int(self._redis.incr(self._revision_key))
|
|
||||||
self._rev_cache_value = value
|
|
||||||
self._rev_cache_expires_at = time.time() + 1.0
|
|
||||||
return value
|
|
||||||
except Exception:
|
|
||||||
# Fail-open: keep app usable; caller still succeeds.
|
|
||||||
return self._get_revision_cached()
|
|
||||||
|
|
||||||
def revision(self) -> int:
|
|
||||||
return self._get_revision_cached()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_runtime_cache() -> CacheBackend:
|
|
||||||
settings = get_settings()
|
|
||||||
backend = (settings.cache_backend or "memory").strip().lower()
|
|
||||||
if backend == "redis":
|
|
||||||
try:
|
|
||||||
cache = RedisRuntimeCache(
|
|
||||||
redis_url=settings.cache_redis_url,
|
|
||||||
prefix=settings.cache_prefix,
|
|
||||||
default_ttl_seconds=settings.cache_default_ttl_seconds,
|
|
||||||
)
|
|
||||||
logger.info("runtime cache backend: redis")
|
|
||||||
return cache
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("redis cache unavailable, fallback to memory: %s", exc)
|
|
||||||
logger.info("runtime cache backend: memory")
|
|
||||||
return MemoryRuntimeCache()
|
|
||||||
|
|
||||||
|
|
||||||
runtime_cache: CacheBackend = _build_runtime_cache()
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "memberapi-backend"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "memberapi.ose.tw backend"
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
dependencies = [
|
|
||||||
"fastapi>=0.116.0",
|
|
||||||
"uvicorn[standard]>=0.35.0",
|
|
||||||
"sqlalchemy>=2.0.44",
|
|
||||||
"psycopg[binary]>=3.2.9",
|
|
||||||
"pydantic-settings>=2.11.0",
|
|
||||||
"python-dotenv>=1.1.1",
|
|
||||||
"passlib[bcrypt]>=1.7.4",
|
|
||||||
"pyjwt[crypto]>=2.10.1",
|
|
||||||
"httpx>=0.28.1",
|
|
||||||
"redis>=5.2.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.4.2",
|
|
||||||
"ruff>=0.13.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
pythonpath = ["."]
|
|
||||||
addopts = "-q"
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["setuptools>=68", "wheel"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="Generate API key hash for api_clients table")
|
|
||||||
parser.add_argument("api_key", help="Plain API key")
|
|
||||||
parser.add_argument(
|
|
||||||
"--algo",
|
|
||||||
choices=["argon2", "bcrypt", "sha256"],
|
|
||||||
default="argon2",
|
|
||||||
help="Hash algorithm (default: argon2)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.algo == "sha256":
|
|
||||||
print("sha256:" + hashlib.sha256(args.api_key.encode("utf-8")).hexdigest())
|
|
||||||
return
|
|
||||||
|
|
||||||
print(pwd_context.hash(args.api_key, scheme=args.algo))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
-- Drop legacy/managed tables for clean rebuild
|
|
||||||
DROP TABLE IF EXISTS auth_sync_state CASCADE;
|
|
||||||
DROP TABLE IF EXISTS user_sites CASCADE;
|
|
||||||
DROP TABLE IF EXISTS site_roles CASCADE;
|
|
||||||
DROP TABLE IF EXISTS roles CASCADE;
|
|
||||||
DROP TABLE IF EXISTS api_clients CASCADE;
|
|
||||||
DROP TABLE IF EXISTS sites CASCADE;
|
|
||||||
DROP TABLE IF EXISTS companies CASCADE;
|
|
||||||
DROP TABLE IF EXISTS systems CASCADE;
|
|
||||||
DROP TABLE IF EXISTS users CASCADE;
|
|
||||||
|
|
||||||
-- legacy tables
|
|
||||||
DROP TABLE IF EXISTS permissions CASCADE;
|
|
||||||
DROP TABLE IF EXISTS modules CASCADE;
|
|
||||||
DROP TABLE IF EXISTS user_scope_permissions CASCADE;
|
|
||||||
DROP TABLE IF EXISTS permission_group_permissions CASCADE;
|
|
||||||
DROP TABLE IF EXISTS permission_group_members CASCADE;
|
|
||||||
DROP TABLE IF EXISTS permission_groups CASCADE;
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_sub TEXT NOT NULL UNIQUE,
|
|
||||||
provider_user_id VARCHAR(128) UNIQUE,
|
|
||||||
username TEXT UNIQUE,
|
|
||||||
email TEXT UNIQUE,
|
|
||||||
display_name TEXT,
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE companies (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
company_key TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
provider_group_id TEXT,
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE sites (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
site_key TEXT NOT NULL UNIQUE,
|
|
||||||
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
domain TEXT,
|
|
||||||
provider_group_id TEXT,
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE systems (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
system_key TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE roles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
role_key TEXT NOT NULL UNIQUE,
|
|
||||||
system_id UUID NOT NULL REFERENCES systems(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT uq_roles_system_name UNIQUE (system_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE site_roles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
|
||||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT uq_site_roles_site_role UNIQUE (site_id, role_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_sites (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT uq_user_sites_user_site UNIQUE (user_id, site_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE auth_sync_state (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
entity_type VARCHAR(32) NOT NULL,
|
|
||||||
entity_id UUID NOT NULL,
|
|
||||||
last_synced_at TIMESTAMPTZ,
|
|
||||||
source_version TEXT,
|
|
||||||
last_error TEXT,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT uq_auth_sync_state_entity UNIQUE (entity_type, entity_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE api_clients (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
client_key TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
||||||
api_key_hash TEXT NOT NULL,
|
|
||||||
allowed_origins JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
allowed_ips JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
allowed_paths JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
||||||
rate_limit_per_min INTEGER,
|
|
||||||
expires_at TIMESTAMPTZ,
|
|
||||||
last_used_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_users_user_sub ON users(user_sub);
|
|
||||||
CREATE INDEX idx_users_username ON users(username);
|
|
||||||
CREATE INDEX idx_users_email ON users(email);
|
|
||||||
CREATE INDEX idx_sites_company_id ON sites(company_id);
|
|
||||||
CREATE INDEX idx_roles_system_id ON roles(system_id);
|
|
||||||
CREATE INDEX idx_site_roles_site_id ON site_roles(site_id);
|
|
||||||
CREATE INDEX idx_site_roles_role_id ON site_roles(role_id);
|
|
||||||
CREATE INDEX idx_user_sites_user_id ON user_sites(user_id);
|
|
||||||
CREATE INDEX idx_user_sites_site_id ON user_sites(site_id);
|
|
||||||
CREATE INDEX idx_auth_sync_entity ON auth_sync_state(entity_type, entity_id);
|
|
||||||
CREATE INDEX idx_api_clients_status ON api_clients(status);
|
|
||||||
CREATE INDEX idx_api_clients_expires_at ON api_clients(expires_at);
|
|
||||||
CREATE INDEX idx_systems_system_key ON systems(system_key);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
-- Rename legacy IdP column names to provider_* naming.
|
|
||||||
-- Safe to run multiple times.
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'idp_group_id'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'provider_group_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.companies RENAME COLUMN idp_group_id TO provider_group_id;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- companies.display_name -> companies.name
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'display_name'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.companies RENAME COLUMN display_name TO name;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'legal_name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.companies DROP COLUMN legal_name;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'provider_client_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.systems DROP COLUMN provider_client_id;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'provider_role_name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.roles DROP COLUMN provider_role_name;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
|
||||||
WHERE table_schema='public' AND table_name='roles' AND constraint_name='uq_roles_system_provider_role_name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.roles DROP CONSTRAINT uq_roles_system_provider_role_name;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
|
||||||
WHERE table_schema='public' AND table_name='roles' AND constraint_name='uq_roles_system_name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.roles ADD CONSTRAINT uq_roles_system_name UNIQUE (system_id, name);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'sites' AND column_name = 'idp_group_id'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'sites' AND column_name = 'provider_group_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.sites RENAME COLUMN idp_group_id TO provider_group_id;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'idp_client_id'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'provider_client_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.systems RENAME COLUMN idp_client_id TO provider_client_id;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'idp_role_name'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'provider_role_name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.roles RENAME COLUMN idp_role_name TO provider_role_name;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'idp_user_id'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'provider_user_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.users RENAME COLUMN idp_user_id TO provider_user_id;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
source .venv/bin/activate
|
|
||||||
exec uvicorn app.main:app --env-file .env.development --host 127.0.0.1 --port 8000 --reload
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
|
|
||||||
def test_healthz() -> None:
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.get("/healthz")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json()["status"] == "ok"
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
from app.security.idp_jwt import ProviderTokenVerifier
|
|
||||||
|
|
||||||
|
|
||||||
def test_infer_jwks_url() -> None:
|
|
||||||
assert ProviderTokenVerifier._infer_jwks_url("https://auth.ose.tw/application/o/member/") == (
|
|
||||||
"https://auth.ose.tw/application/o/member/protocol/openid-connect/certs"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_me_requires_bearer_token() -> None:
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.get("/me")
|
|
||||||
assert resp.status_code == 401
|
|
||||||
assert resp.json()["detail"] == "missing_bearer_token"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
from app.security.api_client_auth import require_api_client
|
|
||||||
|
|
||||||
|
|
||||||
def test_internal_idp_ensure_requires_config() -> None:
|
|
||||||
app.dependency_overrides[require_api_client] = lambda: None
|
|
||||||
client = TestClient(app)
|
|
||||||
try:
|
|
||||||
resp = client.post(
|
|
||||||
"/internal/idp/users/ensure",
|
|
||||||
json={
|
|
||||||
"sub": "idp-sub-1",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"display_name": "User Example",
|
|
||||||
"is_active": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 503
|
|
||||||
assert resp.json()["detail"] == "idp_admin_not_configured"
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.pop(require_api_client, None)
|
|
||||||
@@ -29,6 +29,11 @@
|
|||||||
## 單一真實來源
|
## 單一真實來源
|
||||||
- DB SQL:[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql)
|
- DB SQL:[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql)
|
||||||
|
|
||||||
|
## Repo 結構(已拆分)
|
||||||
|
- 整合層(本 repo):`member.ose.tw`(docs / 部署 / 整合)
|
||||||
|
- 後端子模組:[backend](../backend)(submodule: `../member-backend`)
|
||||||
|
- 前端子模組:[frontend](../frontend)(submodule: `../member-frontend`)
|
||||||
|
|
||||||
## 文件邊界
|
## 文件邊界
|
||||||
- 本輪只保留可開發、可交辦、可驗收文件。
|
- 本輪只保留可開發、可交辦、可驗收文件。
|
||||||
- 最終規格白皮書延後到專案完成後再產出。
|
- 最終規格白皮書延後到專案完成後再產出。
|
||||||
|
|||||||
1
frontend
Submodule
1
frontend
Submodule
Submodule frontend added at cf54146606
@@ -1,2 +0,0 @@
|
|||||||
VITE_APP_TITLE=member.ose.tw
|
|
||||||
VITE_API_BASE_URL=https://memberapi.ose.tw
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
VITE_APP_TITLE=member.ose.tw (dev)
|
|
||||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# member.ose.tw frontend env
|
|
||||||
VITE_APP_TITLE=member.ose.tw
|
|
||||||
VITE_API_BASE_URL=https://memberapi.ose.tw
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-TW">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>member.ose.tw</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
2922
frontend/package-lock.json
generated
2922
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "member-ose-tw",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
|
||||||
"axios": "^1.7.9",
|
|
||||||
"element-plus": "^2.9.1",
|
|
||||||
"pinia": "^2.3.0",
|
|
||||||
"vue": "^3.5.13",
|
|
||||||
"vue-router": "^4.5.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"vite": "^6.0.11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
|
||||||
<header v-if="showNav" class="bg-white border-b border-gray-200 sticky top-0 z-50 shadow-sm">
|
|
||||||
<div class="max-w-7xl mx-auto px-6 flex items-center justify-between h-14">
|
|
||||||
<div class="flex-shrink-0 w-36">
|
|
||||||
<span class="text-sm font-semibold text-gray-700 tracking-wide">member.ose.tw</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="flex items-stretch h-full overflow-x-auto gap-0 flex-1 min-w-0">
|
|
||||||
<NavTab v-for="tab in userTabs" :key="tab.to" :to="tab.to">{{ tab.label }}</NavTab>
|
|
||||||
<span class="self-center mx-3 text-gray-200 select-none text-lg">|</span>
|
|
||||||
<NavTab v-for="tab in adminTabs" :key="tab.to" :to="tab.to">{{ tab.label }}</NavTab>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="flex-shrink-0 ml-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
v-if="showManualSync"
|
|
||||||
@click="handleManualSync"
|
|
||||||
:disabled="syncing"
|
|
||||||
class="text-xs text-blue-600 border border-blue-200 hover:border-blue-300 hover:bg-blue-50 transition-colors px-2 py-1 rounded disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{{ syncing ? '同步中...' : '手動同步' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="logout"
|
|
||||||
class="text-xs text-gray-400 hover:text-gray-600 transition-colors px-2 py-1 rounded hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
登出
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="p-6 max-w-6xl mx-auto">
|
|
||||||
<router-view />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, defineComponent, h, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { adminHttp } from '@/api/http'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const syncing = ref(false)
|
|
||||||
|
|
||||||
const showNav = computed(() => {
|
|
||||||
const onAuthPage = route.name === 'login' || route.name === 'auth-callback'
|
|
||||||
return authStore.isLoggedIn && !onAuthPage
|
|
||||||
})
|
|
||||||
|
|
||||||
const userTabs = [
|
|
||||||
{ to: '/me', label: '我的資料' },
|
|
||||||
{ to: '/me/permissions', label: '我的角色' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const showManualSync = computed(() => route.path.startsWith('/admin'))
|
|
||||||
|
|
||||||
const adminTabs = [
|
|
||||||
{ to: '/admin/companies', label: '公司' },
|
|
||||||
{ to: '/admin/sites', label: '站台' },
|
|
||||||
{ to: '/admin/systems', label: '系統' },
|
|
||||||
{ to: '/admin/roles', label: '角色' },
|
|
||||||
{ to: '/admin/members', label: '會員' },
|
|
||||||
{ to: '/admin/api-clients', label: 'API Clients' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const NavTab = defineComponent({
|
|
||||||
props: { to: String },
|
|
||||||
setup(props, { slots }) {
|
|
||||||
return () => h(RouterLink, { to: props.to, custom: true }, {
|
|
||||||
default: ({ isActive, navigate }) => h('button', {
|
|
||||||
onClick: navigate,
|
|
||||||
class: [
|
|
||||||
'px-3 h-full text-sm whitespace-nowrap border-b-2 transition-all duration-150 flex items-center',
|
|
||||||
isActive
|
|
||||||
? 'border-blue-500 text-blue-600 font-medium bg-blue-50/40'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-800 hover:border-gray-300'
|
|
||||||
].join(' ')
|
|
||||||
}, slots.default?.())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
authStore.logout()
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleManualSync() {
|
|
||||||
if (syncing.value) return
|
|
||||||
syncing.value = true
|
|
||||||
try {
|
|
||||||
const res = await adminHttp.post('/admin/sync/from-provider', null, { params: { force: true } })
|
|
||||||
const data = res?.data || {}
|
|
||||||
ElMessage.success(
|
|
||||||
`同步完成: 系統+${data.systems_created || 0}/${data.systems_updated || 0}, 角色+${data.roles_created || 0}/${data.roles_updated || 0}`
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
const detail = err?.response?.data?.detail || err?.message || 'sync_failed'
|
|
||||||
ElMessage.error(`同步失敗:${detail}`)
|
|
||||||
} finally {
|
|
||||||
syncing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { adminHttp } from './http'
|
|
||||||
|
|
||||||
export const getApiClients = (params) => adminHttp.get('/admin/api-clients', { params })
|
|
||||||
export const createApiClient = (data) => adminHttp.post('/admin/api-clients', data)
|
|
||||||
export const updateApiClient = (clientKey, data) => adminHttp.patch(`/admin/api-clients/${clientKey}`, data)
|
|
||||||
export const rotateApiClientKey = (clientKey) => adminHttp.post(`/admin/api-clients/${clientKey}/rotate-key`)
|
|
||||||
export const deleteApiClient = (clientKey) => adminHttp.delete(`/admin/api-clients/${clientKey}`)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { userHttp } from './http'
|
|
||||||
|
|
||||||
export const getOidcAuthorizeUrl = (redirectUri, options = {}) =>
|
|
||||||
userHttp.get('/auth/oidc/url', {
|
|
||||||
params: {
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
login_hint: options.loginHint || undefined,
|
|
||||||
prompt: options.prompt || undefined,
|
|
||||||
idp_hint: options.idpHint || undefined,
|
|
||||||
code_challenge: options.codeChallenge || undefined,
|
|
||||||
code_challenge_method: options.codeChallengeMethod || undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const exchangeOidcCode = (code, redirectUri, codeVerifier) =>
|
|
||||||
userHttp.post('/auth/oidc/exchange', {
|
|
||||||
code,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
code_verifier: codeVerifier || undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
export const refreshOidcToken = (refreshToken) =>
|
|
||||||
userHttp.post('/auth/refresh', {
|
|
||||||
refresh_token: refreshToken
|
|
||||||
})
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { adminHttp } from './http'
|
|
||||||
|
|
||||||
export const getCompanies = (params) => adminHttp.get('/admin/companies', { params })
|
|
||||||
export const createCompany = (data) => adminHttp.post('/admin/companies', data)
|
|
||||||
export const updateCompany = (companyKey, data) => adminHttp.patch(`/admin/companies/${companyKey}`, data)
|
|
||||||
export const deleteCompany = (companyKey) => adminHttp.delete(`/admin/companies/${companyKey}`)
|
|
||||||
export const getCompanySites = (companyKey) => adminHttp.get(`/admin/companies/${companyKey}/sites`)
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import axios from 'axios'
|
|
||||||
import router from '@/router'
|
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL
|
|
||||||
let refreshPromise = null
|
|
||||||
|
|
||||||
async function refreshAccessToken() {
|
|
||||||
if (refreshPromise) return refreshPromise
|
|
||||||
const refreshToken = localStorage.getItem('refresh_token')
|
|
||||||
if (!refreshToken) throw new Error('missing_refresh_token')
|
|
||||||
|
|
||||||
refreshPromise = axios
|
|
||||||
.post(`${BASE_URL}/auth/refresh`, { refresh_token: refreshToken })
|
|
||||||
.then((res) => {
|
|
||||||
const nextAccessToken = res.data?.access_token
|
|
||||||
const nextRefreshToken = res.data?.refresh_token || refreshToken
|
|
||||||
if (!nextAccessToken) {
|
|
||||||
throw new Error('missing_access_token')
|
|
||||||
}
|
|
||||||
localStorage.setItem('access_token', nextAccessToken)
|
|
||||||
localStorage.setItem('refresh_token', nextRefreshToken)
|
|
||||||
return nextAccessToken
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
refreshPromise = null
|
|
||||||
})
|
|
||||||
|
|
||||||
return refreshPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
function hardLogoutToLogin() {
|
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
localStorage.removeItem('refresh_token')
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用者 API:帶 Bearer token
|
|
||||||
export const userHttp = axios.create({ baseURL: BASE_URL })
|
|
||||||
|
|
||||||
userHttp.interceptors.request.use(config => {
|
|
||||||
const token = localStorage.getItem('access_token')
|
|
||||||
if (token) {
|
|
||||||
config.headers['Authorization'] = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
})
|
|
||||||
|
|
||||||
userHttp.interceptors.response.use(
|
|
||||||
res => res,
|
|
||||||
async err => {
|
|
||||||
const original = err.config || {}
|
|
||||||
if (err.response?.status === 401 && !original._retriedByRefresh) {
|
|
||||||
original._retriedByRefresh = true
|
|
||||||
try {
|
|
||||||
const nextToken = await refreshAccessToken()
|
|
||||||
original.headers = original.headers || {}
|
|
||||||
original.headers['Authorization'] = `Bearer ${nextToken}`
|
|
||||||
return userHttp.request(original)
|
|
||||||
} catch (_refreshErr) {
|
|
||||||
hardLogoutToLogin()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.reject(err)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 管理員 API:只帶 Bearer token(後端再檢查 admin 群組)
|
|
||||||
export const adminHttp = axios.create({ baseURL: BASE_URL })
|
|
||||||
|
|
||||||
adminHttp.interceptors.request.use(config => {
|
|
||||||
const token = localStorage.getItem('access_token')
|
|
||||||
if (token) {
|
|
||||||
config.headers['Authorization'] = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
})
|
|
||||||
|
|
||||||
adminHttp.interceptors.response.use(
|
|
||||||
res => res,
|
|
||||||
async err => {
|
|
||||||
const original = err.config || {}
|
|
||||||
if (err.response?.status === 401 && !original._retriedByRefresh) {
|
|
||||||
original._retriedByRefresh = true
|
|
||||||
try {
|
|
||||||
const nextToken = await refreshAccessToken()
|
|
||||||
original.headers = original.headers || {}
|
|
||||||
original.headers['Authorization'] = `Bearer ${nextToken}`
|
|
||||||
return adminHttp.request(original)
|
|
||||||
} catch (_refreshErr) {
|
|
||||||
hardLogoutToLogin()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.reject(err)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { userHttp } from './http'
|
|
||||||
|
|
||||||
export const getMe = () => userHttp.get('/me')
|
|
||||||
export const getMyPermissionSnapshot = () => userHttp.get('/me/permissions/snapshot')
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { adminHttp } from './http'
|
|
||||||
|
|
||||||
export const getMembers = (params) => adminHttp.get('/admin/members', { params })
|
|
||||||
export const createMember = (data) => adminHttp.post('/admin/members', data)
|
|
||||||
export const updateMember = (userSub, data) => adminHttp.patch(`/admin/members/${userSub}`, data)
|
|
||||||
export const deleteMember = (userSub, syncToIdp = true) => adminHttp.delete(`/admin/members/${userSub}`, { params: { sync_to_idp: syncToIdp } })
|
|
||||||
export const resetMemberPassword = (userSub) => adminHttp.post(`/admin/members/${userSub}/password/reset`)
|
|
||||||
export const getMemberSites = (userSub) => adminHttp.get(`/admin/members/${userSub}/sites`)
|
|
||||||
export const setMemberSites = (userSub, siteKeys) => adminHttp.put(`/admin/members/${userSub}/sites`, { site_keys: siteKeys })
|
|
||||||
export const getMemberRoles = (userSub) => adminHttp.get(`/admin/members/${userSub}/roles`)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { adminHttp } from './http'
|
|
||||||
|
|
||||||
export const getRoles = (params) => adminHttp.get('/admin/roles', { params })
|
|
||||||
export const createRole = (data) => adminHttp.post('/admin/roles', data)
|
|
||||||
export const updateRole = (roleKey, data) => adminHttp.patch(`/admin/roles/${roleKey}`, data)
|
|
||||||
export const deleteRole = (roleKey) => adminHttp.delete(`/admin/roles/${roleKey}`)
|
|
||||||
export const getRoleSites = (roleKey) => adminHttp.get(`/admin/roles/${roleKey}/sites`)
|
|
||||||
export const setRoleSites = (roleKey, siteKeys) => adminHttp.put(`/admin/roles/${roleKey}/sites`, { site_keys: siteKeys })
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { adminHttp } from './http'
|
|
||||||
|
|
||||||
export const getSites = (params) => adminHttp.get('/admin/sites', { params })
|
|
||||||
export const createSite = (data) => adminHttp.post('/admin/sites', data)
|
|
||||||
export const updateSite = (siteKey, data) => adminHttp.patch(`/admin/sites/${siteKey}`, data)
|
|
||||||
export const deleteSite = (siteKey) => adminHttp.delete(`/admin/sites/${siteKey}`)
|
|
||||||
export const getSiteRoles = (siteKey) => adminHttp.get(`/admin/sites/${siteKey}/roles`)
|
|
||||||
export const setSiteRoles = (siteKey, roleKeys) => adminHttp.put(`/admin/sites/${siteKey}/roles`, { role_keys: roleKeys })
|
|
||||||
export const getSiteMembers = (siteKey) => adminHttp.get(`/admin/sites/${siteKey}/members`)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { adminHttp } from './http'
|
|
||||||
|
|
||||||
export const getSystems = (params) => adminHttp.get('/admin/systems', { params })
|
|
||||||
export const createSystem = (data) => adminHttp.post('/admin/systems', data)
|
|
||||||
export const updateSystem = (systemKey, data) => adminHttp.patch(`/admin/systems/${systemKey}`, data)
|
|
||||||
export const deleteSystem = (systemKey) => adminHttp.delete(`/admin/systems/${systemKey}`)
|
|
||||||
export const getSystemRoles = (systemKey) => adminHttp.get(`/admin/systems/${systemKey}/roles`)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { createApp } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import ElementPlus from 'element-plus'
|
|
||||||
import 'element-plus/dist/index.css'
|
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
|
||||||
import zhTw from 'element-plus/es/locale/lang/zh-tw'
|
|
||||||
import App from './App.vue'
|
|
||||||
import router from './router'
|
|
||||||
import './assets/main.css'
|
|
||||||
|
|
||||||
const app = createApp(App)
|
|
||||||
|
|
||||||
app.use(createPinia())
|
|
||||||
app.use(router)
|
|
||||||
app.use(ElementPlus, { locale: zhTw })
|
|
||||||
|
|
||||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
|
||||||
app.component(key, component)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.mount('#app')
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-center justify-center min-h-[70vh]">
|
|
||||||
<el-card class="w-full max-w-md shadow-md">
|
|
||||||
<div class="text-center">
|
|
||||||
<el-icon class="text-3xl text-blue-600 mb-3">
|
|
||||||
<Loading />
|
|
||||||
</el-icon>
|
|
||||||
<h2 class="text-lg font-bold text-gray-800 mb-2">正在登入...</h2>
|
|
||||||
<p v-if="!error" class="text-sm text-gray-500">
|
|
||||||
正在驗證身份,請稍候
|
|
||||||
</p>
|
|
||||||
<p v-if="error" class="text-sm text-red-600 font-medium">
|
|
||||||
{{ error }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { exchangeOidcCode } from '@/api/auth'
|
|
||||||
import { Loading } from '@element-plus/icons-vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const code = route.query.code
|
|
||||||
const state = route.query.state
|
|
||||||
const oauthError = route.query.error
|
|
||||||
const oauthErrorDesc = route.query.error_description
|
|
||||||
const expectedState = sessionStorage.getItem('oidc_expected_state')
|
|
||||||
const codeVerifier = sessionStorage.getItem('oidc_pkce_verifier')
|
|
||||||
|
|
||||||
if (oauthError) {
|
|
||||||
const reason = typeof oauthErrorDesc === 'string' && oauthErrorDesc
|
|
||||||
? oauthErrorDesc
|
|
||||||
: String(oauthError)
|
|
||||||
error.value = `登入失敗:${reason}`
|
|
||||||
setTimeout(() => router.push('/login'), 3000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!code) {
|
|
||||||
error.value = '缺少驗證代碼,登入失敗'
|
|
||||||
setTimeout(() => router.push('/login'), 2000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state || !expectedState || state !== expectedState) {
|
|
||||||
sessionStorage.removeItem('oidc_expected_state')
|
|
||||||
sessionStorage.removeItem('oidc_pkce_verifier')
|
|
||||||
error.value = '登入驗證失敗,請重新登入'
|
|
||||||
setTimeout(() => router.push('/login'), 3000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectUri = `${window.location.origin}/auth/callback`
|
|
||||||
const res = await exchangeOidcCode(code, redirectUri, codeVerifier)
|
|
||||||
const { access_token, refresh_token } = res.data
|
|
||||||
|
|
||||||
if (!access_token) {
|
|
||||||
error.value = '無法取得 access token'
|
|
||||||
setTimeout(() => router.push('/login'), 2000)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 存 token 並取得使用者資料
|
|
||||||
authStore.setTokens(access_token, refresh_token || null)
|
|
||||||
await authStore.fetchMe()
|
|
||||||
|
|
||||||
// 導向原頁面或預設的 /me
|
|
||||||
sessionStorage.removeItem('oidc_expected_state')
|
|
||||||
sessionStorage.removeItem('oidc_pkce_verifier')
|
|
||||||
const redirect = sessionStorage.getItem('post_login_redirect') || '/me'
|
|
||||||
sessionStorage.removeItem('post_login_redirect')
|
|
||||||
router.replace(redirect)
|
|
||||||
} catch (err) {
|
|
||||||
sessionStorage.removeItem('oidc_expected_state')
|
|
||||||
sessionStorage.removeItem('oidc_pkce_verifier')
|
|
||||||
const detail = err.response?.data?.detail
|
|
||||||
if (detail === 'invalid_authorization_code') {
|
|
||||||
error.value = '授權代碼無效,請重新登入'
|
|
||||||
} else if (detail) {
|
|
||||||
error.value = `登入失敗:${detail}`
|
|
||||||
} else {
|
|
||||||
error.value = '登入過程出錯,請重新登入'
|
|
||||||
}
|
|
||||||
setTimeout(() => router.push('/login'), 3000)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-center justify-center min-h-[70vh]">
|
|
||||||
<el-card class="w-full max-w-md shadow-md">
|
|
||||||
<template #header>
|
|
||||||
<div class="text-center">
|
|
||||||
<h1 class="text-xl font-bold text-gray-800">member.ose.tw</h1>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">按下按鈕前往身分提供者登入</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="error"
|
|
||||||
:title="error"
|
|
||||||
type="error"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
class="w-full"
|
|
||||||
:loading="loginLoading"
|
|
||||||
@click="handleLogin"
|
|
||||||
>
|
|
||||||
前往登入
|
|
||||||
</el-button>
|
|
||||||
|
|
||||||
<div class="mt-4 text-xs text-gray-400 text-center space-y-1">
|
|
||||||
<p>登入會統一跳轉到身分提供者登入頁,完成後自動返回。</p>
|
|
||||||
<p>登入成功後 access token 會存於本機 localStorage。</p>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { getOidcAuthorizeUrl } from '@/api/auth'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const loginLoading = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
function getPostLoginRedirect() {
|
|
||||||
const redirect = route.query.redirect || '/me'
|
|
||||||
return typeof redirect === 'string' ? redirect : '/me'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!authStore.isLoggedIn) return
|
|
||||||
try {
|
|
||||||
await authStore.fetchMe()
|
|
||||||
router.replace(getPostLoginRedirect())
|
|
||||||
} catch (_err) {
|
|
||||||
authStore.logout()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleLogin() {
|
|
||||||
loginLoading.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
await redirectToOidc({
|
|
||||||
prompt: 'login'
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err.message || '登入失敗,請稍後再試'
|
|
||||||
} finally {
|
|
||||||
loginLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function redirectToOidc(options = {}) {
|
|
||||||
const pkce = await generatePkcePair()
|
|
||||||
sessionStorage.setItem('oidc_pkce_verifier', pkce.codeVerifier)
|
|
||||||
sessionStorage.setItem('post_login_redirect', getPostLoginRedirect())
|
|
||||||
const callbackUrl = `${window.location.origin}/auth/callback`
|
|
||||||
const res = await getOidcAuthorizeUrl(callbackUrl, {
|
|
||||||
...options,
|
|
||||||
codeChallenge: pkce.codeChallenge,
|
|
||||||
codeChallengeMethod: 'S256'
|
|
||||||
})
|
|
||||||
const authorizeUrl = res.data.authorize_url
|
|
||||||
const parsed = new URL(authorizeUrl)
|
|
||||||
const state = parsed.searchParams.get('state')
|
|
||||||
if (state) {
|
|
||||||
sessionStorage.setItem('oidc_expected_state', state)
|
|
||||||
}
|
|
||||||
window.location.href = authorizeUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generatePkcePair() {
|
|
||||||
const randomBytes = new Uint8Array(32)
|
|
||||||
window.crypto.getRandomValues(randomBytes)
|
|
||||||
const codeVerifier = toBase64Url(randomBytes)
|
|
||||||
const digest = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
|
|
||||||
const codeChallenge = toBase64Url(new Uint8Array(digest))
|
|
||||||
return { codeVerifier, codeChallenge }
|
|
||||||
}
|
|
||||||
|
|
||||||
function toBase64Url(bytes) {
|
|
||||||
let binary = ''
|
|
||||||
for (let i = 0; i < bytes.length; i += 1) {
|
|
||||||
binary += String.fromCharCode(bytes[i])
|
|
||||||
}
|
|
||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">API Clients</h2>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<el-button type="primary" @click="openCreate">新增 Client</el-button>
|
|
||||||
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert v-if="error" :title="errorMsg" type="error" show-icon :closable="false" class="mb-4" />
|
|
||||||
|
|
||||||
<el-table :data="items" stripe border class="w-full shadow-sm" v-loading="loading">
|
|
||||||
<template #empty><el-empty description="目前無 API Client" /></template>
|
|
||||||
<el-table-column prop="client_key" label="Client Key" min-width="180" />
|
|
||||||
<el-table-column prop="name" label="名稱" min-width="160" />
|
|
||||||
<el-table-column label="狀態" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ row.status }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="Allowed Paths" min-width="220">
|
|
||||||
<template #default="{ row }">{{ (row.allowed_paths || []).join(', ') || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="last_used_at" label="最後使用" min-width="170" />
|
|
||||||
<el-table-column prop="expires_at" label="到期日" min-width="170" />
|
|
||||||
<el-table-column label="操作" width="260" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" @click="openEdit(row)">編輯</el-button>
|
|
||||||
<el-button size="small" type="warning" @click="handleRotate(row)">重置 Key</el-button>
|
|
||||||
<el-button size="small" :type="row.status === 'active' ? 'danger' : 'success'" @click="toggleStatus(row)">
|
|
||||||
{{ row.status === 'active' ? '停用' : '啟用' }}
|
|
||||||
</el-button>
|
|
||||||
<el-button size="small" type="danger" @click="handleDelete(row)">刪除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-dialog v-model="showCreateDialog" title="新增 API Client" width="700px" @close="resetCreate">
|
|
||||||
<el-form ref="createFormRef" :model="createForm" :rules="rules" label-width="130px">
|
|
||||||
<el-form-item label="名稱" prop="name"><el-input v-model="createForm.name" /></el-form-item>
|
|
||||||
<el-form-item label="Client Key">
|
|
||||||
<el-input v-model="createForm.client_key" placeholder="留空自動產生" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Allowed Origins">
|
|
||||||
<el-input v-model="createAllowedOrigins" placeholder="逗號分隔,例如 https://erp.ose.tw" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Allowed IPs">
|
|
||||||
<el-input v-model="createAllowedIps" placeholder="逗號分隔,例如 10.0.0.1,10.0.0.2" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Allowed Paths">
|
|
||||||
<el-input v-model="createAllowedPaths" placeholder="逗號分隔,例如 /internal/" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Rate Limit/min">
|
|
||||||
<el-input-number v-model="createForm.rate_limit_per_min" :min="0" :step="10" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="到期日">
|
|
||||||
<el-date-picker v-model="createExpiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss[Z]" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="creating" @click="handleCreate">建立</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="showEditDialog" title="編輯 API Client" width="700px" @close="resetEdit">
|
|
||||||
<el-form :model="editForm" label-width="130px">
|
|
||||||
<el-form-item label="Client Key"><el-input :model-value="editForm.client_key" disabled /></el-form-item>
|
|
||||||
<el-form-item label="名稱"><el-input v-model="editForm.name" /></el-form-item>
|
|
||||||
<el-form-item label="狀態">
|
|
||||||
<el-select v-model="editForm.status">
|
|
||||||
<el-option label="active" value="active" />
|
|
||||||
<el-option label="inactive" value="inactive" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Allowed Origins">
|
|
||||||
<el-input v-model="editAllowedOrigins" placeholder="逗號分隔" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Allowed IPs">
|
|
||||||
<el-input v-model="editAllowedIps" placeholder="逗號分隔" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Allowed Paths">
|
|
||||||
<el-input v-model="editAllowedPaths" placeholder="逗號分隔" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Rate Limit/min">
|
|
||||||
<el-input-number v-model="editForm.rate_limit_per_min" :min="0" :step="10" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="到期日">
|
|
||||||
<el-date-picker v-model="editExpiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss[Z]" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showEditDialog = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="saving" @click="handleSave">儲存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
|
||||||
import { createApiClient, getApiClients, rotateApiClientKey, updateApiClient, deleteApiClient } from '@/api/api-clients'
|
|
||||||
|
|
||||||
const items = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
|
|
||||||
const showCreateDialog = ref(false)
|
|
||||||
const showEditDialog = ref(false)
|
|
||||||
const creating = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
const createFormRef = ref()
|
|
||||||
const rules = {
|
|
||||||
name: [{ required: true, message: '請輸入名稱', trigger: 'blur' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
const createForm = ref({
|
|
||||||
name: '',
|
|
||||||
client_key: '',
|
|
||||||
rate_limit_per_min: null
|
|
||||||
})
|
|
||||||
const createAllowedOrigins = ref('')
|
|
||||||
const createAllowedIps = ref('')
|
|
||||||
const createAllowedPaths = ref('/internal/')
|
|
||||||
const createExpiresAt = ref(null)
|
|
||||||
|
|
||||||
const editForm = ref({
|
|
||||||
client_key: '',
|
|
||||||
name: '',
|
|
||||||
status: 'active',
|
|
||||||
rate_limit_per_min: null
|
|
||||||
})
|
|
||||||
const editAllowedOrigins = ref('')
|
|
||||||
const editAllowedIps = ref('')
|
|
||||||
const editAllowedPaths = ref('')
|
|
||||||
const editExpiresAt = ref(null)
|
|
||||||
|
|
||||||
function toList(value) {
|
|
||||||
return String(value || '')
|
|
||||||
.split(',')
|
|
||||||
.map(v => v.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCreate() {
|
|
||||||
createForm.value = { name: '', client_key: '', rate_limit_per_min: null }
|
|
||||||
createAllowedOrigins.value = ''
|
|
||||||
createAllowedIps.value = ''
|
|
||||||
createAllowedPaths.value = '/internal/'
|
|
||||||
createExpiresAt.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetEdit() {
|
|
||||||
editForm.value = { client_key: '', name: '', status: 'active', rate_limit_per_min: null }
|
|
||||||
editAllowedOrigins.value = ''
|
|
||||||
editAllowedIps.value = ''
|
|
||||||
editAllowedPaths.value = ''
|
|
||||||
editExpiresAt.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
resetCreate()
|
|
||||||
showCreateDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(row) {
|
|
||||||
editForm.value = {
|
|
||||||
client_key: row.client_key,
|
|
||||||
name: row.name,
|
|
||||||
status: row.status,
|
|
||||||
rate_limit_per_min: row.rate_limit_per_min
|
|
||||||
}
|
|
||||||
editAllowedOrigins.value = (row.allowed_origins || []).join(', ')
|
|
||||||
editAllowedIps.value = (row.allowed_ips || []).join(', ')
|
|
||||||
editAllowedPaths.value = (row.allowed_paths || []).join(', ')
|
|
||||||
editExpiresAt.value = row.expires_at
|
|
||||||
showEditDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = false
|
|
||||||
try {
|
|
||||||
const res = await getApiClients()
|
|
||||||
items.value = res.data?.items || []
|
|
||||||
} catch (err) {
|
|
||||||
error.value = true
|
|
||||||
errorMsg.value = err.response?.data?.detail || '載入失敗'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
const valid = await createFormRef.value.validate().catch(() => false)
|
|
||||||
if (!valid) return
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
name: createForm.value.name,
|
|
||||||
client_key: createForm.value.client_key || null,
|
|
||||||
allowed_origins: toList(createAllowedOrigins.value),
|
|
||||||
allowed_ips: toList(createAllowedIps.value),
|
|
||||||
allowed_paths: toList(createAllowedPaths.value),
|
|
||||||
rate_limit_per_min: createForm.value.rate_limit_per_min,
|
|
||||||
expires_at: createExpiresAt.value
|
|
||||||
}
|
|
||||||
const res = await createApiClient(payload)
|
|
||||||
const apiKey = res.data?.api_key || ''
|
|
||||||
if (apiKey) {
|
|
||||||
await navigator.clipboard.writeText(apiKey)
|
|
||||||
ElMessage.success(`建立成功,API Key 已複製:${apiKey}`)
|
|
||||||
} else {
|
|
||||||
ElMessage.success('建立成功')
|
|
||||||
}
|
|
||||||
showCreateDialog.value = false
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '建立失敗')
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
name: editForm.value.name,
|
|
||||||
status: editForm.value.status,
|
|
||||||
allowed_origins: toList(editAllowedOrigins.value),
|
|
||||||
allowed_ips: toList(editAllowedIps.value),
|
|
||||||
allowed_paths: toList(editAllowedPaths.value),
|
|
||||||
rate_limit_per_min: editForm.value.rate_limit_per_min,
|
|
||||||
expires_at: editExpiresAt.value
|
|
||||||
}
|
|
||||||
await updateApiClient(editForm.value.client_key, payload)
|
|
||||||
ElMessage.success('更新成功')
|
|
||||||
showEditDialog.value = false
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '更新失敗')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRotate(row) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`確定要重置 ${row.client_key} 的 API Key 嗎?`, '重置確認', { type: 'warning' })
|
|
||||||
const res = await rotateApiClientKey(row.client_key)
|
|
||||||
const apiKey = res.data?.api_key || ''
|
|
||||||
if (apiKey) {
|
|
||||||
await navigator.clipboard.writeText(apiKey)
|
|
||||||
ElMessage.success(`重置成功,API Key 已複製:${apiKey}`)
|
|
||||||
} else {
|
|
||||||
ElMessage.success('重置成功')
|
|
||||||
}
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
if (err === 'cancel') return
|
|
||||||
ElMessage.error(err.response?.data?.detail || '重置失敗')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleStatus(row) {
|
|
||||||
try {
|
|
||||||
const next = row.status === 'active' ? 'inactive' : 'active'
|
|
||||||
await updateApiClient(row.client_key, { status: next })
|
|
||||||
ElMessage.success(next === 'active' ? '已啟用' : '已停用')
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '更新狀態失敗')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(row) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`確認刪除 API Client ${row.name}(${row.client_key})?`, '刪除確認', { type: 'warning' })
|
|
||||||
await deleteApiClient(row.client_key)
|
|
||||||
ElMessage.success('刪除成功')
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
if (err === 'cancel') return
|
|
||||||
ElMessage.error(err.response?.data?.detail || '刪除失敗')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">公司管理</h2>
|
|
||||||
<el-button type="primary" @click="showCreateDialog = true" :icon="Plus">新增公司</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert v-if="error" :title="errorMsg" type="error" show-icon :closable="false" class="mb-4" />
|
|
||||||
<el-skeleton v-if="loading" :rows="4" animated />
|
|
||||||
|
|
||||||
<el-table v-else :data="companies" stripe border class="w-full shadow-sm">
|
|
||||||
<template #empty><el-empty description="目前無公司" /></template>
|
|
||||||
<el-table-column prop="company_key" label="Company Key" width="220" />
|
|
||||||
<el-table-column prop="name" label="公司名稱" min-width="220" />
|
|
||||||
<el-table-column prop="status" label="狀態" width="110" />
|
|
||||||
<el-table-column label="操作" width="280">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" @click="openEdit(row)">編輯</el-button>
|
|
||||||
<el-button size="small" @click="openSites(row)">查看站台</el-button>
|
|
||||||
<el-button size="small" type="danger" @click="handleDelete(row)">刪除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-dialog v-model="showCreateDialog" title="新增公司" width="560px" @close="resetCreateForm">
|
|
||||||
<el-form ref="createFormRef" :model="createForm" :rules="rules" label-width="120px">
|
|
||||||
<el-form-item label="公司名稱" prop="name"><el-input v-model="createForm.name" /></el-form-item>
|
|
||||||
<el-form-item label="狀態">
|
|
||||||
<el-select v-model="createForm.status" style="width: 100%">
|
|
||||||
<el-option label="active" value="active" />
|
|
||||||
<el-option label="inactive" value="inactive" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="creating" @click="handleCreate">建立</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="showEditDialog" title="編輯公司" width="560px" @close="resetEditForm">
|
|
||||||
<el-form :model="editForm" label-width="120px">
|
|
||||||
<el-form-item label="Company Key"><el-input :model-value="editForm.company_key" disabled /></el-form-item>
|
|
||||||
<el-form-item label="公司名稱"><el-input v-model="editForm.name" /></el-form-item>
|
|
||||||
<el-form-item label="狀態">
|
|
||||||
<el-select v-model="editForm.status" style="width: 100%">
|
|
||||||
<el-option label="active" value="active" />
|
|
||||||
<el-option label="inactive" value="inactive" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showEditDialog = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="saving" @click="handleEdit">儲存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="showSitesDialog" :title="`站台列表:${selectedCompanyDisplayName}`" width="960px">
|
|
||||||
<el-table :data="companySites" border stripe v-loading="sitesLoading">
|
|
||||||
<template #empty><el-empty description="此公司目前沒有站台" /></template>
|
|
||||||
<el-table-column prop="site_key" label="Site Key" width="210" />
|
|
||||||
<el-table-column prop="display_name" label="站台名稱" min-width="200" />
|
|
||||||
<el-table-column prop="domain" label="Domain" min-width="220" />
|
|
||||||
<el-table-column prop="status" label="狀態" width="110" />
|
|
||||||
</el-table>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showSitesDialog = false">關閉</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import { Plus } from '@element-plus/icons-vue'
|
|
||||||
import { getCompanies, createCompany, updateCompany, deleteCompany, getCompanySites } from '@/api/companies'
|
|
||||||
|
|
||||||
const companies = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
|
|
||||||
const showCreateDialog = ref(false)
|
|
||||||
const showEditDialog = ref(false)
|
|
||||||
const creating = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const createFormRef = ref()
|
|
||||||
|
|
||||||
const createForm = ref({ name: '', status: 'active' })
|
|
||||||
const editForm = ref({ company_key: '', name: '', status: 'active' })
|
|
||||||
const rules = {
|
|
||||||
name: [{ required: true, message: '請輸入公司名稱', trigger: 'blur' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
const showSitesDialog = ref(false)
|
|
||||||
const sitesLoading = ref(false)
|
|
||||||
const selectedCompanyDisplayName = ref('')
|
|
||||||
const companySites = ref([])
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = false
|
|
||||||
try {
|
|
||||||
const res = await getCompanies()
|
|
||||||
companies.value = res.data?.items || []
|
|
||||||
} catch (err) {
|
|
||||||
error.value = true
|
|
||||||
errorMsg.value = err.response?.data?.detail || '載入公司失敗'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCreateForm() {
|
|
||||||
createForm.value = { name: '', status: 'active' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(row) {
|
|
||||||
editForm.value = {
|
|
||||||
company_key: row.company_key,
|
|
||||||
name: row.name || '',
|
|
||||||
status: row.status || 'active'
|
|
||||||
}
|
|
||||||
showEditDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetEditForm() {
|
|
||||||
editForm.value = { company_key: '', name: '', status: 'active' }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
const valid = await createFormRef.value.validate().catch(() => false)
|
|
||||||
if (!valid) return
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
const res = await createCompany(createForm.value)
|
|
||||||
ElMessage.success(`新增成功:${res.data?.company_key || ''}`)
|
|
||||||
showCreateDialog.value = false
|
|
||||||
resetCreateForm()
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '新增公司失敗')
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEdit() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
await updateCompany(editForm.value.company_key, {
|
|
||||||
name: editForm.value.name,
|
|
||||||
status: editForm.value.status
|
|
||||||
})
|
|
||||||
ElMessage.success('更新成功')
|
|
||||||
showEditDialog.value = false
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '更新公司失敗')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openSites(row) {
|
|
||||||
showSitesDialog.value = true
|
|
||||||
selectedCompanyDisplayName.value = `${row.name || row.company_key} (${row.company_key})`
|
|
||||||
sitesLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await getCompanySites(row.company_key)
|
|
||||||
companySites.value = res.data?.sites || []
|
|
||||||
} catch (_err) {
|
|
||||||
ElMessage.error('載入站台列表失敗')
|
|
||||||
companySites.value = []
|
|
||||||
} finally {
|
|
||||||
sitesLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(row) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
`確認刪除公司 ${row.name || row.company_key}(${row.company_key})?`,
|
|
||||||
'刪除確認',
|
|
||||||
{ type: 'warning' }
|
|
||||||
)
|
|
||||||
await deleteCompany(row.company_key)
|
|
||||||
ElMessage.success('刪除成功')
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
if (err === 'cancel') return
|
|
||||||
ElMessage.error(err.response?.data?.detail || '刪除公司失敗')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">會員列表</h2>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<el-button type="primary" @click="showCreateDialog = true">新增會員</el-button>
|
|
||||||
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert v-if="error" :title="errorMsg" type="error" show-icon :closable="false" class="mb-4" />
|
|
||||||
<el-skeleton v-if="loading" :rows="4" animated />
|
|
||||||
|
|
||||||
<el-table v-else :data="members" stripe border class="w-full shadow-sm">
|
|
||||||
<template #empty><el-empty description="目前無會員" /></template>
|
|
||||||
<el-table-column prop="user_sub" label="User Sub" min-width="260" />
|
|
||||||
<el-table-column prop="username" label="Username" min-width="150" />
|
|
||||||
<el-table-column prop="email" label="Email" min-width="220" />
|
|
||||||
<el-table-column prop="display_name" label="顯示名稱" min-width="170" />
|
|
||||||
<el-table-column label="啟用" width="80">
|
|
||||||
<template #default="{ row }">{{ row.is_active ? '是' : '否' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="360">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" @click="openEdit(row)">編輯</el-button>
|
|
||||||
<el-button size="small" @click="openRoles(row)">角色</el-button>
|
|
||||||
<el-button size="small" type="warning" @click="handleResetPassword(row)">重設密碼</el-button>
|
|
||||||
<el-button size="small" type="danger" @click="handleDelete(row)">刪除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-dialog v-model="showCreateDialog" title="新增會員" width="760px" @close="resetCreateForm">
|
|
||||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="130px">
|
|
||||||
<el-form-item label="Username" prop="username"><el-input v-model="createForm.username" /></el-form-item>
|
|
||||||
<el-form-item label="Email" prop="email"><el-input v-model="createForm.email" /></el-form-item>
|
|
||||||
<el-form-item label="顯示名稱"><el-input v-model="createForm.display_name" /></el-form-item>
|
|
||||||
<el-form-item label="所屬站台">
|
|
||||||
<el-select v-model="createForm.site_keys" multiple filterable clearable style="width: 100%">
|
|
||||||
<el-option
|
|
||||||
v-for="site in siteOptions"
|
|
||||||
:key="site.site_key"
|
|
||||||
:label="`${site.company_display_name} / ${site.display_name} (${site.site_key})`"
|
|
||||||
:value="site.site_key"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="啟用"><el-switch v-model="createForm.is_active" /></el-form-item>
|
|
||||||
<el-form-item label="同步 Provider"><el-switch v-model="createForm.sync_to_idp" /></el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="creating" @click="handleCreate">建立</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="showEditDialog" title="編輯會員" width="760px" @close="resetEditForm">
|
|
||||||
<el-form :model="editForm" label-width="130px">
|
|
||||||
<el-form-item label="User Sub"><el-input :model-value="editForm.user_sub" disabled /></el-form-item>
|
|
||||||
<el-form-item label="Username"><el-input v-model="editForm.username" /></el-form-item>
|
|
||||||
<el-form-item label="Email"><el-input v-model="editForm.email" /></el-form-item>
|
|
||||||
<el-form-item label="顯示名稱"><el-input v-model="editForm.display_name" /></el-form-item>
|
|
||||||
<el-form-item label="所屬站台">
|
|
||||||
<el-select v-model="editForm.site_keys" multiple filterable clearable style="width: 100%">
|
|
||||||
<el-option
|
|
||||||
v-for="site in siteOptions"
|
|
||||||
:key="site.site_key"
|
|
||||||
:label="`${site.company_display_name} / ${site.display_name} (${site.site_key})`"
|
|
||||||
:value="site.site_key"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="啟用"><el-switch v-model="editForm.is_active" /></el-form-item>
|
|
||||||
<el-form-item label="同步 Provider"><el-switch v-model="editForm.sync_to_idp" /></el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showEditDialog = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="saving" @click="handleEdit">儲存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="showRolesDialog" :title="`會員角色:${selectedUserLabel}`" width="1080px">
|
|
||||||
<el-table :data="effectiveRoles" border stripe v-loading="rolesLoading">
|
|
||||||
<template #empty><el-empty description="此會員目前沒有角色" /></template>
|
|
||||||
<el-table-column prop="company_display_name" label="公司" min-width="160" />
|
|
||||||
<el-table-column prop="site_display_name" label="站台" min-width="170" />
|
|
||||||
<el-table-column prop="system_name" label="系統" min-width="150" />
|
|
||||||
<el-table-column prop="role_name" label="角色" min-width="160" />
|
|
||||||
</el-table>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showRolesDialog = false">關閉</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
|
||||||
import { getSites } from '@/api/sites'
|
|
||||||
import {
|
|
||||||
getMembers,
|
|
||||||
createMember,
|
|
||||||
updateMember,
|
|
||||||
deleteMember,
|
|
||||||
resetMemberPassword,
|
|
||||||
getMemberSites,
|
|
||||||
setMemberSites,
|
|
||||||
getMemberRoles
|
|
||||||
} from '@/api/members'
|
|
||||||
|
|
||||||
const members = ref([])
|
|
||||||
const siteOptions = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
|
|
||||||
const showCreateDialog = ref(false)
|
|
||||||
const creating = ref(false)
|
|
||||||
const createFormRef = ref()
|
|
||||||
const createForm = ref({
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
display_name: '',
|
|
||||||
site_keys: [],
|
|
||||||
is_active: true,
|
|
||||||
sync_to_idp: true
|
|
||||||
})
|
|
||||||
const createRules = {
|
|
||||||
username: [{ required: true, message: '請輸入 Username', trigger: 'blur' }],
|
|
||||||
email: [{ required: true, message: '請輸入 Email', trigger: 'blur' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
const showEditDialog = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const editForm = ref({
|
|
||||||
user_sub: '',
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
display_name: '',
|
|
||||||
site_keys: [],
|
|
||||||
is_active: true,
|
|
||||||
sync_to_idp: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const showRolesDialog = ref(false)
|
|
||||||
const selectedUserLabel = ref('')
|
|
||||||
const effectiveRoles = ref([])
|
|
||||||
const rolesLoading = ref(false)
|
|
||||||
|
|
||||||
async function loadCatalogs() {
|
|
||||||
const res = await getSites({ limit: 500, offset: 0 })
|
|
||||||
siteOptions.value = res.data?.items || []
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = false
|
|
||||||
try {
|
|
||||||
const [membersRes] = await Promise.all([getMembers(), loadCatalogs()])
|
|
||||||
members.value = membersRes.data?.items || []
|
|
||||||
} catch (err) {
|
|
||||||
error.value = true
|
|
||||||
errorMsg.value = err.response?.data?.detail || '載入會員失敗'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCreateForm() {
|
|
||||||
createForm.value = {
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
display_name: '',
|
|
||||||
site_keys: [],
|
|
||||||
is_active: true,
|
|
||||||
sync_to_idp: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
const valid = await createFormRef.value.validate().catch(() => false)
|
|
||||||
if (!valid) return
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
username: createForm.value.username || null,
|
|
||||||
email: createForm.value.email || null,
|
|
||||||
display_name: createForm.value.display_name || null,
|
|
||||||
is_active: createForm.value.is_active,
|
|
||||||
sync_to_idp: createForm.value.sync_to_idp
|
|
||||||
}
|
|
||||||
const res = await createMember(payload)
|
|
||||||
const userSub = res.data?.user_sub
|
|
||||||
if (userSub) {
|
|
||||||
await setMemberSites(userSub, createForm.value.site_keys || [])
|
|
||||||
}
|
|
||||||
ElMessage.success('新增會員成功')
|
|
||||||
showCreateDialog.value = false
|
|
||||||
resetCreateForm()
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '新增會員失敗')
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEdit(row) {
|
|
||||||
editForm.value = {
|
|
||||||
user_sub: row.user_sub,
|
|
||||||
username: row.username || '',
|
|
||||||
email: row.email || '',
|
|
||||||
display_name: row.display_name || '',
|
|
||||||
site_keys: [],
|
|
||||||
is_active: !!row.is_active,
|
|
||||||
sync_to_idp: true
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await getMemberSites(row.user_sub)
|
|
||||||
editForm.value.site_keys = (res.data?.sites || []).map((site) => site.site_key)
|
|
||||||
} catch (_err) {
|
|
||||||
ElMessage.warning('讀取會員站台失敗,仍可編輯基本資料')
|
|
||||||
}
|
|
||||||
showEditDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetEditForm() {
|
|
||||||
editForm.value = {
|
|
||||||
user_sub: '',
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
display_name: '',
|
|
||||||
site_keys: [],
|
|
||||||
is_active: true,
|
|
||||||
sync_to_idp: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEdit() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
await updateMember(editForm.value.user_sub, {
|
|
||||||
username: editForm.value.username || null,
|
|
||||||
email: editForm.value.email || null,
|
|
||||||
display_name: editForm.value.display_name || null,
|
|
||||||
is_active: editForm.value.is_active,
|
|
||||||
sync_to_idp: editForm.value.sync_to_idp
|
|
||||||
})
|
|
||||||
await setMemberSites(editForm.value.user_sub, editForm.value.site_keys || [])
|
|
||||||
ElMessage.success('更新會員成功')
|
|
||||||
showEditDialog.value = false
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '更新會員失敗')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResetPassword(row) {
|
|
||||||
try {
|
|
||||||
const res = await resetMemberPassword(row.user_sub)
|
|
||||||
const tempPassword = res.data?.temporary_password
|
|
||||||
if (tempPassword) {
|
|
||||||
await navigator.clipboard.writeText(tempPassword)
|
|
||||||
ElMessage.success('已重設密碼,臨時密碼已複製')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ElMessage.success('已重設密碼')
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '重設密碼失敗')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(row) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
`確認刪除會員 ${row.display_name || row.email || row.username || row.user_sub}?`,
|
|
||||||
'刪除確認',
|
|
||||||
{ type: 'warning' }
|
|
||||||
)
|
|
||||||
await deleteMember(row.user_sub, true)
|
|
||||||
ElMessage.success('刪除成功')
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
if (err === 'cancel') return
|
|
||||||
ElMessage.error(err.response?.data?.detail || '刪除會員失敗')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openRoles(row) {
|
|
||||||
selectedUserLabel.value = `${row.display_name || row.username || row.user_sub}`
|
|
||||||
showRolesDialog.value = true
|
|
||||||
rolesLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await getMemberRoles(row.user_sub)
|
|
||||||
effectiveRoles.value = res.data?.roles || []
|
|
||||||
} catch (_err) {
|
|
||||||
ElMessage.error('載入會員角色失敗')
|
|
||||||
effectiveRoles.value = []
|
|
||||||
} finally {
|
|
||||||
rolesLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">角色管理</h2>
|
|
||||||
<el-button type="primary" @click="showCreateDialog = true" :icon="Plus">新增角色</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert v-if="error" :title="errorMsg" type="error" show-icon :closable="false" class="mb-4" />
|
|
||||||
<el-skeleton v-if="loading" :rows="4" animated />
|
|
||||||
|
|
||||||
<el-table v-else :data="roles" stripe border class="w-full shadow-sm">
|
|
||||||
<template #empty><el-empty description="目前無角色" /></template>
|
|
||||||
<el-table-column prop="role_key" label="Role Key" width="200" />
|
|
||||||
<el-table-column prop="system_name" label="系統" min-width="150" />
|
|
||||||
<el-table-column prop="name" label="角色名稱" min-width="180" />
|
|
||||||
<el-table-column prop="status" label="狀態" width="110" />
|
|
||||||
<el-table-column label="操作" width="280">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" @click="openEdit(row)">編輯</el-button>
|
|
||||||
<el-button size="small" @click="openSites(row)">站台</el-button>
|
|
||||||
<el-button size="small" type="danger" @click="handleDelete(row)">刪除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-dialog v-model="showCreateDialog" title="新增角色" width="660px" @close="resetCreateForm">
|
|
||||||
<el-form ref="createFormRef" :model="createForm" :rules="rules" label-width="150px">
|
|
||||||
<el-form-item label="系統" prop="system_key">
|
|
||||||
<el-select v-model="createForm.system_key" filterable style="width: 100%">
|
|
||||||
<el-option
|
|
||||||
v-for="system in systems"
|
|
||||||
:key="system.system_key"
|
|
||||||
:label="`${system.name} (${system.system_key})`"
|
|
||||||
:value="system.system_key"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="角色名稱" prop="name"><el-input v-model="createForm.name" /></el-form-item>
|
|
||||||
<el-form-item label="描述"><el-input v-model="createForm.description" type="textarea" :rows="2" /></el-form-item>
|
|
||||||
<el-form-item label="狀態">
|
|
||||||
<el-select v-model="createForm.status" style="width: 100%">
|
|
||||||
<el-option label="active" value="active" />
|
|
||||||
<el-option label="inactive" value="inactive" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="creating" @click="handleCreate">建立</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="showEditDialog" title="編輯角色" width="660px" @close="resetEditForm">
|
|
||||||
<el-form :model="editForm" label-width="150px">
|
|
||||||
<el-form-item label="Role Key"><el-input :model-value="editForm.role_key" disabled /></el-form-item>
|
|
||||||
<el-form-item label="系統">
|
|
||||||
<el-select v-model="editForm.system_key" filterable style="width: 100%">
|
|
||||||
<el-option
|
|
||||||
v-for="system in systems"
|
|
||||||
:key="system.system_key"
|
|
||||||
:label="`${system.name} (${system.system_key})`"
|
|
||||||
:value="system.system_key"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="角色名稱"><el-input v-model="editForm.name" /></el-form-item>
|
|
||||||
<el-form-item label="描述"><el-input v-model="editForm.description" type="textarea" :rows="2" /></el-form-item>
|
|
||||||
<el-form-item label="狀態">
|
|
||||||
<el-select v-model="editForm.status" style="width: 100%">
|
|
||||||
<el-option label="active" value="active" />
|
|
||||||
<el-option label="inactive" value="inactive" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showEditDialog = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="saving" @click="handleEdit">儲存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="showSitesDialog" :title="`角色綁定站台:${selectedRoleLabel}`" width="980px">
|
|
||||||
<el-form label-width="120px" class="mb-4">
|
|
||||||
<el-form-item label="指派站台">
|
|
||||||
<el-select v-model="selectedSiteKeys" multiple filterable style="width: 100%" placeholder="請選擇站台">
|
|
||||||
<el-option
|
|
||||||
v-for="site in siteOptions"
|
|
||||||
:key="site.site_key"
|
|
||||||
:label="`${site.company_display_name} / ${site.display_name} (${site.site_key})`"
|
|
||||||
:value="site.site_key"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<el-table :data="roleSites" border stripe v-loading="sitesLoading">
|
|
||||||
<template #empty><el-empty description="此角色尚未綁定站台" /></template>
|
|
||||||
<el-table-column prop="company_display_name" label="公司" min-width="160" />
|
|
||||||
<el-table-column prop="site_display_name" label="站台" min-width="170" />
|
|
||||||
<el-table-column prop="site_key" label="Site Key" min-width="180" />
|
|
||||||
</el-table>
|
|
||||||
<template #footer>
|
|
||||||
<el-button type="primary" :loading="savingSites" @click="handleSaveRoleSites">儲存站台綁定</el-button>
|
|
||||||
<el-button @click="showSitesDialog = false">關閉</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import { Plus } from '@element-plus/icons-vue'
|
|
||||||
import { getRoles, createRole, updateRole, deleteRole, getRoleSites, setRoleSites } from '@/api/roles'
|
|
||||||
import { getSystems } from '@/api/systems'
|
|
||||||
import { getSites } from '@/api/sites'
|
|
||||||
|
|
||||||
const roles = ref([])
|
|
||||||
const systems = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
|
|
||||||
const showCreateDialog = ref(false)
|
|
||||||
const showEditDialog = ref(false)
|
|
||||||
const creating = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const createFormRef = ref()
|
|
||||||
|
|
||||||
const createForm = ref({
|
|
||||||
system_key: '',
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
status: 'active'
|
|
||||||
})
|
|
||||||
const editForm = ref({
|
|
||||||
role_key: '',
|
|
||||||
system_key: '',
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
status: 'active'
|
|
||||||
})
|
|
||||||
const rules = {
|
|
||||||
system_key: [{ required: true, message: '請選擇系統', trigger: 'change' }],
|
|
||||||
name: [{ required: true, message: '請輸入角色名稱', trigger: 'blur' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
const showSitesDialog = ref(false)
|
|
||||||
const selectedRoleLabel = ref('')
|
|
||||||
const selectedRoleKey = ref('')
|
|
||||||
const roleSites = ref([])
|
|
||||||
const sitesLoading = ref(false)
|
|
||||||
const savingSites = ref(false)
|
|
||||||
const siteOptions = ref([])
|
|
||||||
const selectedSiteKeys = ref([])
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = false
|
|
||||||
try {
|
|
||||||
const [rolesRes, systemsRes] = await Promise.all([
|
|
||||||
getRoles({ limit: 500, offset: 0 }),
|
|
||||||
getSystems({ limit: 500, offset: 0 })
|
|
||||||
])
|
|
||||||
roles.value = rolesRes.data?.items || []
|
|
||||||
systems.value = systemsRes.data?.items || []
|
|
||||||
const sitesRes = await getSites({ limit: 500, offset: 0 })
|
|
||||||
siteOptions.value = sitesRes.data?.items || []
|
|
||||||
} catch (err) {
|
|
||||||
error.value = true
|
|
||||||
errorMsg.value = err.response?.data?.detail || '載入角色失敗'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCreateForm() {
|
|
||||||
createForm.value = {
|
|
||||||
system_key: '',
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
status: 'active'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(row) {
|
|
||||||
editForm.value = {
|
|
||||||
role_key: row.role_key,
|
|
||||||
system_key: row.system_key,
|
|
||||||
name: row.name,
|
|
||||||
description: row.description || '',
|
|
||||||
status: row.status || 'active'
|
|
||||||
}
|
|
||||||
showEditDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetEditForm() {
|
|
||||||
editForm.value = {
|
|
||||||
role_key: '',
|
|
||||||
system_key: '',
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
status: 'active'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
const valid = await createFormRef.value.validate().catch(() => false)
|
|
||||||
if (!valid) return
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
await createRole({
|
|
||||||
system_key: createForm.value.system_key,
|
|
||||||
name: createForm.value.name,
|
|
||||||
description: createForm.value.description || null,
|
|
||||||
status: createForm.value.status
|
|
||||||
})
|
|
||||||
ElMessage.success('新增角色成功')
|
|
||||||
showCreateDialog.value = false
|
|
||||||
resetCreateForm()
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '新增角色失敗')
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEdit() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
await updateRole(editForm.value.role_key, {
|
|
||||||
system_key: editForm.value.system_key,
|
|
||||||
name: editForm.value.name,
|
|
||||||
description: editForm.value.description || null,
|
|
||||||
status: editForm.value.status
|
|
||||||
})
|
|
||||||
ElMessage.success('更新成功')
|
|
||||||
showEditDialog.value = false
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '更新角色失敗')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(row) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
`確認刪除角色 ${row.name}(${row.role_key})?`,
|
|
||||||
'刪除確認',
|
|
||||||
{ type: 'warning' }
|
|
||||||
)
|
|
||||||
await deleteRole(row.role_key)
|
|
||||||
ElMessage.success('刪除成功')
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
if (err === 'cancel') return
|
|
||||||
ElMessage.error(err.response?.data?.detail || '刪除角色失敗')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openSites(row) {
|
|
||||||
selectedRoleKey.value = row.role_key
|
|
||||||
selectedRoleLabel.value = `${row.system_name} / ${row.name}`
|
|
||||||
showSitesDialog.value = true
|
|
||||||
sitesLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await getRoleSites(row.role_key)
|
|
||||||
roleSites.value = res.data?.sites || []
|
|
||||||
selectedSiteKeys.value = roleSites.value.map(item => item.site_key)
|
|
||||||
} catch (_err) {
|
|
||||||
ElMessage.error('載入角色站台失敗')
|
|
||||||
roleSites.value = []
|
|
||||||
selectedSiteKeys.value = []
|
|
||||||
} finally {
|
|
||||||
sitesLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveRoleSites() {
|
|
||||||
if (!selectedRoleKey.value) return
|
|
||||||
savingSites.value = true
|
|
||||||
try {
|
|
||||||
await setRoleSites(selectedRoleKey.value, selectedSiteKeys.value)
|
|
||||||
const res = await getRoleSites(selectedRoleKey.value)
|
|
||||||
roleSites.value = res.data?.sites || []
|
|
||||||
ElMessage.success('角色站台綁定已更新')
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '儲存角色站台失敗')
|
|
||||||
} finally {
|
|
||||||
savingSites.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">站台管理</h2>
|
|
||||||
<el-button type="primary" @click="showCreateDialog = true" :icon="Plus">新增站台</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert v-if="error" :title="errorMsg" type="error" show-icon :closable="false" class="mb-4" />
|
|
||||||
<el-skeleton v-if="loading" :rows="4" animated />
|
|
||||||
|
|
||||||
<el-table v-else :data="sites" stripe border class="w-full shadow-sm">
|
|
||||||
<template #empty><el-empty description="目前無站台" /></template>
|
|
||||||
<el-table-column prop="site_key" label="Site Key" width="180" />
|
|
||||||
<el-table-column prop="company_display_name" label="公司" min-width="180" />
|
|
||||||
<el-table-column prop="display_name" label="站台名稱" min-width="180" />
|
|
||||||
<el-table-column prop="domain" label="Domain" min-width="220" />
|
|
||||||
<el-table-column prop="status" label="狀態" width="110" />
|
|
||||||
<el-table-column label="操作" width="350">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" @click="openEdit(row)">編輯</el-button>
|
|
||||||
<el-button size="small" @click="openRoles(row)">角色</el-button>
|
|
||||||
<el-button size="small" @click="openMembers(row)">會員</el-button>
|
|
||||||
<el-button size="small" type="danger" @click="handleDelete(row)">刪除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-dialog v-model="showCreateDialog" title="新增站台" width="620px" @close="resetCreateForm">
|
|
||||||
<el-form ref="createFormRef" :model="createForm" :rules="rules" label-width="120px">
|
|
||||||
<el-form-item label="公司" prop="company_key">
|
|
||||||
<el-select v-model="createForm.company_key" filterable style="width: 100%">
|
|
||||||
<el-option
|
|
||||||
v-for="company in companies"
|
|
||||||
:key="company.company_key"
|
|
||||||
:label="`${company.name} (${company.company_key})`"
|
|
||||||
:value="company.company_key"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="站台名稱" prop="display_name"><el-input v-model="createForm.display_name" /></el-form-item>
|
|
||||||
<el-form-item label="Domain"><el-input v-model="createForm.domain" /></el-form-item>
|
|
||||||
<el-form-item label="狀態">
|
|
||||||
<el-select v-model="createForm.status" style="width: 100%">
|
|
||||||
<el-option label="active" value="active" />
|
|
||||||
<el-option label="inactive" value="inactive" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="creating" @click="handleCreate">建立</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="showEditDialog" title="編輯站台" width="620px" @close="resetEditForm">
|
|
||||||
<el-form :model="editForm" label-width="120px">
|
|
||||||
<el-form-item label="Site Key"><el-input :model-value="editForm.site_key" disabled /></el-form-item>
|
|
||||||
<el-form-item label="公司">
|
|
||||||
<el-select v-model="editForm.company_key" filterable style="width: 100%">
|
|
||||||
<el-option
|
|
||||||
v-for="company in companies"
|
|
||||||
:key="company.company_key"
|
|
||||||
:label="`${company.name} (${company.company_key})`"
|
|
||||||
:value="company.company_key"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="站台名稱"><el-input v-model="editForm.display_name" /></el-form-item>
|
|
||||||
<el-form-item label="Domain"><el-input v-model="editForm.domain" /></el-form-item>
|
|
||||||
<el-form-item label="狀態">
|
|
||||||
<el-select v-model="editForm.status" style="width: 100%">
|
|
||||||
<el-option label="active" value="active" />
|
|
||||||
<el-option label="inactive" value="inactive" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showEditDialog = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="saving" @click="handleEdit">儲存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="showRolesDialog" :title="`站台角色:${selectedSiteLabel}`" width="1000px">
|
|
||||||
<div class="mb-4">
|
|
||||||
<el-select
|
|
||||||
v-model="selectedRoleKeys"
|
|
||||||
multiple
|
|
||||||
filterable
|
|
||||||
clearable
|
|
||||||
style="width: 100%"
|
|
||||||
placeholder="可多選,儲存後覆蓋站台角色"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="role in roleOptions"
|
|
||||||
:key="role.role_key"
|
|
||||||
:label="`${role.system_name} / ${role.name} (${role.role_key})`"
|
|
||||||
:value="role.role_key"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
<el-table :data="siteRoles" border stripe v-loading="rolesLoading">
|
|
||||||
<template #empty><el-empty description="目前無站台角色" /></template>
|
|
||||||
<el-table-column prop="role_key" label="Role Key" width="200" />
|
|
||||||
<el-table-column prop="system_name" label="系統" min-width="160" />
|
|
||||||
<el-table-column prop="role_name" label="角色" min-width="180" />
|
|
||||||
</el-table>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showRolesDialog = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="rolesSaving" @click="handleSaveSiteRoles">儲存角色</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model="showMembersDialog" :title="`站台會員:${selectedSiteLabel}`" width="920px">
|
|
||||||
<el-table :data="siteMembers" border stripe v-loading="membersLoading">
|
|
||||||
<template #empty><el-empty description="此站台目前沒有會員" /></template>
|
|
||||||
<el-table-column prop="user_sub" label="User Sub" min-width="250" />
|
|
||||||
<el-table-column prop="username" label="Username" min-width="150" />
|
|
||||||
<el-table-column prop="email" label="Email" min-width="220" />
|
|
||||||
<el-table-column prop="display_name" label="顯示名稱" min-width="170" />
|
|
||||||
<el-table-column label="啟用" width="80">
|
|
||||||
<template #default="{ row }">{{ row.is_active ? '是' : '否' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showMembersDialog = false">關閉</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import { Plus } from '@element-plus/icons-vue'
|
|
||||||
import { getSites, createSite, updateSite, deleteSite, getSiteRoles, setSiteRoles, getSiteMembers } from '@/api/sites'
|
|
||||||
import { getCompanies } from '@/api/companies'
|
|
||||||
import { getRoles } from '@/api/roles'
|
|
||||||
|
|
||||||
const sites = ref([])
|
|
||||||
const companies = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
|
|
||||||
const showCreateDialog = ref(false)
|
|
||||||
const showEditDialog = ref(false)
|
|
||||||
const creating = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const createFormRef = ref()
|
|
||||||
|
|
||||||
const createForm = ref({ company_key: '', display_name: '', domain: '', status: 'active' })
|
|
||||||
const editForm = ref({ site_key: '', company_key: '', display_name: '', domain: '', status: 'active' })
|
|
||||||
const rules = {
|
|
||||||
company_key: [{ required: true, message: '請選擇公司', trigger: 'change' }],
|
|
||||||
display_name: [{ required: true, message: '請輸入站台名稱', trigger: 'blur' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
const showRolesDialog = ref(false)
|
|
||||||
const rolesLoading = ref(false)
|
|
||||||
const rolesSaving = ref(false)
|
|
||||||
const selectedSiteKey = ref('')
|
|
||||||
const selectedSiteLabel = ref('')
|
|
||||||
const siteRoles = ref([])
|
|
||||||
const roleOptions = ref([])
|
|
||||||
const selectedRoleKeys = ref([])
|
|
||||||
|
|
||||||
const showMembersDialog = ref(false)
|
|
||||||
const membersLoading = ref(false)
|
|
||||||
const siteMembers = ref([])
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = false
|
|
||||||
try {
|
|
||||||
const [sitesRes, companiesRes] = await Promise.all([getSites(), getCompanies()])
|
|
||||||
sites.value = sitesRes.data?.items || []
|
|
||||||
companies.value = companiesRes.data?.items || []
|
|
||||||
} catch (err) {
|
|
||||||
error.value = true
|
|
||||||
errorMsg.value = err.response?.data?.detail || '載入站台失敗'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCreateForm() {
|
|
||||||
createForm.value = { company_key: '', display_name: '', domain: '', status: 'active' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(row) {
|
|
||||||
editForm.value = {
|
|
||||||
site_key: row.site_key,
|
|
||||||
company_key: row.company_key,
|
|
||||||
display_name: row.display_name,
|
|
||||||
domain: row.domain || '',
|
|
||||||
status: row.status || 'active'
|
|
||||||
}
|
|
||||||
showEditDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetEditForm() {
|
|
||||||
editForm.value = { site_key: '', company_key: '', display_name: '', domain: '', status: 'active' }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
const valid = await createFormRef.value.validate().catch(() => false)
|
|
||||||
if (!valid) return
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
company_key: createForm.value.company_key,
|
|
||||||
display_name: createForm.value.display_name,
|
|
||||||
domain: createForm.value.domain || null,
|
|
||||||
status: createForm.value.status
|
|
||||||
}
|
|
||||||
await createSite(payload)
|
|
||||||
ElMessage.success('新增站台成功')
|
|
||||||
showCreateDialog.value = false
|
|
||||||
resetCreateForm()
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '新增站台失敗')
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEdit() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
company_key: editForm.value.company_key,
|
|
||||||
display_name: editForm.value.display_name,
|
|
||||||
domain: editForm.value.domain || null,
|
|
||||||
status: editForm.value.status
|
|
||||||
}
|
|
||||||
await updateSite(editForm.value.site_key, payload)
|
|
||||||
ElMessage.success('更新成功')
|
|
||||||
showEditDialog.value = false
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '更新站台失敗')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(row) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
`確認刪除站台 ${row.display_name}(${row.site_key})?`,
|
|
||||||
'刪除確認',
|
|
||||||
{ type: 'warning' }
|
|
||||||
)
|
|
||||||
await deleteSite(row.site_key)
|
|
||||||
ElMessage.success('刪除成功')
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
if (err === 'cancel') return
|
|
||||||
ElMessage.error(err.response?.data?.detail || '刪除站台失敗')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openRoles(row) {
|
|
||||||
selectedSiteKey.value = row.site_key
|
|
||||||
selectedSiteLabel.value = `${row.company_display_name} / ${row.display_name}`
|
|
||||||
showRolesDialog.value = true
|
|
||||||
rolesLoading.value = true
|
|
||||||
try {
|
|
||||||
const [siteRolesRes, allRolesRes] = await Promise.all([
|
|
||||||
getSiteRoles(row.site_key),
|
|
||||||
getRoles({ limit: 500, offset: 0 })
|
|
||||||
])
|
|
||||||
siteRoles.value = siteRolesRes.data?.roles || []
|
|
||||||
roleOptions.value = allRolesRes.data?.items || []
|
|
||||||
selectedRoleKeys.value = siteRoles.value.map((role) => role.role_key)
|
|
||||||
} catch (_err) {
|
|
||||||
ElMessage.error('載入站台角色失敗')
|
|
||||||
siteRoles.value = []
|
|
||||||
roleOptions.value = []
|
|
||||||
selectedRoleKeys.value = []
|
|
||||||
} finally {
|
|
||||||
rolesLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveSiteRoles() {
|
|
||||||
if (!selectedSiteKey.value) return
|
|
||||||
rolesSaving.value = true
|
|
||||||
try {
|
|
||||||
const deduped = [...new Set(selectedRoleKeys.value)]
|
|
||||||
const res = await setSiteRoles(selectedSiteKey.value, deduped)
|
|
||||||
siteRoles.value = res.data?.roles || []
|
|
||||||
selectedRoleKeys.value = siteRoles.value.map((role) => role.role_key)
|
|
||||||
ElMessage.success('站台角色已更新')
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '儲存站台角色失敗')
|
|
||||||
} finally {
|
|
||||||
rolesSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openMembers(row) {
|
|
||||||
selectedSiteLabel.value = `${row.company_display_name} / ${row.display_name}`
|
|
||||||
showMembersDialog.value = true
|
|
||||||
membersLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await getSiteMembers(row.site_key)
|
|
||||||
siteMembers.value = res.data?.members || []
|
|
||||||
} catch (_err) {
|
|
||||||
ElMessage.error('載入站台會員失敗')
|
|
||||||
siteMembers.value = []
|
|
||||||
} finally {
|
|
||||||
membersLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">系統管理(身分提供者唯一來源)</h2>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<el-button :loading="syncing" @click="handleSync">同步 Provider</el-button>
|
|
||||||
<el-button :loading="loading" @click="load">重新整理</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert type="info" :closable="false" show-icon class="mb-4">
|
|
||||||
<template #title>
|
|
||||||
系統與角色請在身分提供者建立與調整,member 後台只做顯示與關聯。
|
|
||||||
</template>
|
|
||||||
</el-alert>
|
|
||||||
|
|
||||||
<el-alert v-if="error" :title="errorMsg" type="error" show-icon :closable="false" class="mb-4" />
|
|
||||||
<el-skeleton v-if="loading" :rows="4" animated />
|
|
||||||
|
|
||||||
<el-table v-else :data="systems" stripe border class="w-full shadow-sm">
|
|
||||||
<template #empty><el-empty description="目前無系統" /></template>
|
|
||||||
<el-table-column prop="system_key" label="System Key" width="200" />
|
|
||||||
<el-table-column prop="name" label="系統名稱" min-width="180" />
|
|
||||||
<el-table-column prop="status" label="狀態" width="110" />
|
|
||||||
<el-table-column label="操作" width="120">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" @click="openRoles(row)">角色</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-dialog v-model="showRolesDialog" :title="`系統角色:${selectedSystemLabel}`" width="980px">
|
|
||||||
<el-table :data="systemRoles" border stripe v-loading="rolesLoading">
|
|
||||||
<template #empty><el-empty description="此系統目前沒有角色" /></template>
|
|
||||||
<el-table-column prop="role_key" label="Role Key" width="200" />
|
|
||||||
<el-table-column prop="name" label="角色名稱" min-width="200" />
|
|
||||||
<el-table-column prop="status" label="狀態" width="110" />
|
|
||||||
</el-table>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="showRolesDialog = false">關閉</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { adminHttp } from '@/api/http'
|
|
||||||
import { getSystems, getSystemRoles } from '@/api/systems'
|
|
||||||
|
|
||||||
const systems = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const syncing = ref(false)
|
|
||||||
const error = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
|
|
||||||
const showRolesDialog = ref(false)
|
|
||||||
const selectedSystemLabel = ref('')
|
|
||||||
const systemRoles = ref([])
|
|
||||||
const rolesLoading = ref(false)
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = false
|
|
||||||
try {
|
|
||||||
const res = await getSystems()
|
|
||||||
systems.value = res.data?.items || []
|
|
||||||
} catch (err) {
|
|
||||||
error.value = true
|
|
||||||
errorMsg.value = err.response?.data?.detail || '載入系統失敗'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSync() {
|
|
||||||
syncing.value = true
|
|
||||||
try {
|
|
||||||
const res = await adminHttp.post('/admin/sync/from-provider', null, { params: { force: true } })
|
|
||||||
const summary = [
|
|
||||||
`systems +${res.data?.systems_created ?? 0}`,
|
|
||||||
`roles +${res.data?.roles_created ?? 0}`
|
|
||||||
].join(' / ')
|
|
||||||
ElMessage.success(`同步完成:${summary}`)
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.error(err.response?.data?.detail || '同步失敗')
|
|
||||||
} finally {
|
|
||||||
syncing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openRoles(row) {
|
|
||||||
selectedSystemLabel.value = `${row.name} (${row.system_key})`
|
|
||||||
showRolesDialog.value = true
|
|
||||||
rolesLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await getSystemRoles(row.system_key)
|
|
||||||
systemRoles.value = res.data?.roles || []
|
|
||||||
} catch (_err) {
|
|
||||||
ElMessage.error('載入系統角色失敗')
|
|
||||||
systemRoles.value = []
|
|
||||||
} finally {
|
|
||||||
rolesLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">我的權限快照</h2>
|
|
||||||
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="error"
|
|
||||||
:title="errorMessage"
|
|
||||||
type="error"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-skeleton v-if="loading && !snapshot" :rows="4" animated />
|
|
||||||
|
|
||||||
<template v-if="snapshot">
|
|
||||||
<p class="text-sm text-gray-500 mb-3">
|
|
||||||
Sub:<span class="font-mono">{{ snapshot.user_sub }}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<el-empty v-if="snapshot.roles.length === 0" description="目前沒有任何角色" />
|
|
||||||
|
|
||||||
<el-table
|
|
||||||
v-else
|
|
||||||
:data="snapshot.roles"
|
|
||||||
stripe
|
|
||||||
border
|
|
||||||
class="w-full shadow-sm"
|
|
||||||
>
|
|
||||||
<el-table-column prop="company_display_name" label="公司" min-width="150" />
|
|
||||||
<el-table-column prop="site_display_name" label="站台" min-width="160" />
|
|
||||||
<el-table-column prop="system_name" label="系統" min-width="150" />
|
|
||||||
<el-table-column prop="role_name" label="角色" min-width="160" />
|
|
||||||
<el-table-column prop="provider_role_name" label="Provider Role" min-width="180" />
|
|
||||||
</el-table>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
|
||||||
import { usePermissionStore } from '@/stores/permission'
|
|
||||||
|
|
||||||
const permissionStore = usePermissionStore()
|
|
||||||
|
|
||||||
const snapshot = ref(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref(null)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
await permissionStore.fetchMySnapshot()
|
|
||||||
snapshot.value = permissionStore.snapshot
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err
|
|
||||||
const status = err.response?.status
|
|
||||||
const detail = err.response?.data?.detail
|
|
||||||
if (status === 401) {
|
|
||||||
errorMessage.value = 'Token 已過期,請重新登入'
|
|
||||||
} else if (detail) {
|
|
||||||
errorMessage.value = `錯誤:${detail}`
|
|
||||||
} else {
|
|
||||||
errorMessage.value = '載入失敗,請稍後再試'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">我的資料</h2>
|
|
||||||
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="error"
|
|
||||||
:title="errorMessage"
|
|
||||||
type="error"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-skeleton v-if="loading && !me" :rows="3" animated />
|
|
||||||
|
|
||||||
<el-card v-if="me && !loading" class="shadow-sm">
|
|
||||||
<el-descriptions :column="1" border>
|
|
||||||
<el-descriptions-item label="Sub">
|
|
||||||
<span class="font-mono text-sm text-gray-700">{{ me.sub }}</span>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="Email">
|
|
||||||
{{ me.email }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="顯示名稱">
|
|
||||||
{{ me.display_name }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const me = ref(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref(null)
|
|
||||||
|
|
||||||
const errorMessage = ref('')
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
await authStore.fetchMe()
|
|
||||||
me.value = authStore.me
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err
|
|
||||||
const status = err.response?.status
|
|
||||||
const detail = err.response?.data?.detail
|
|
||||||
if (status === 401) {
|
|
||||||
errorMessage.value = 'Token 已過期,請重新登入'
|
|
||||||
} else if (detail) {
|
|
||||||
errorMessage.value = `錯誤:${detail}`
|
|
||||||
} else {
|
|
||||||
errorMessage.value = '載入失敗,請稍後再試'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const routes = [
|
|
||||||
{ path: '/', redirect: '/me' },
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
name: 'login',
|
|
||||||
component: () => import('@/pages/LoginPage.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/auth/callback',
|
|
||||||
name: 'auth-callback',
|
|
||||||
component: () => import('@/pages/AuthCallbackPage.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/me',
|
|
||||||
name: 'me',
|
|
||||||
component: () => import('@/pages/profile/MePage.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/me/permissions',
|
|
||||||
name: 'my-permissions',
|
|
||||||
component: () => import('@/pages/permissions/PermissionSnapshotPage.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin/companies',
|
|
||||||
name: 'admin-companies',
|
|
||||||
component: () => import('@/pages/admin/CompaniesPage.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin/sites',
|
|
||||||
name: 'admin-sites',
|
|
||||||
component: () => import('@/pages/admin/SitesPage.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin/systems',
|
|
||||||
name: 'admin-systems',
|
|
||||||
component: () => import('@/pages/admin/SystemsPage.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin/roles',
|
|
||||||
name: 'admin-roles',
|
|
||||||
component: () => import('@/pages/admin/RolesPage.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin/members',
|
|
||||||
name: 'admin-members',
|
|
||||||
component: () => import('@/pages/admin/MembersPage.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin/api-clients',
|
|
||||||
name: 'admin-api-clients',
|
|
||||||
component: () => import('@/pages/admin/ApiClientsPage.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(),
|
|
||||||
routes
|
|
||||||
})
|
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
|
||||||
return { name: 'login', query: { redirect: to.fullPath } }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user