Split frontend and backend into separate submodule repos

This commit is contained in:
Chris
2026-04-03 03:19:48 +08:00
parent 528b988207
commit 1d2a57fada
104 changed files with 13 additions and 10847 deletions

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "backend"]
path = backend
url = ../member-backend
[submodule "frontend"]
path = frontend
url = ../member-frontend

1
backend Submodule

Submodule backend added at ade60bdbaa

View File

@@ -1,13 +0,0 @@
.git
.gitignore
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.ruff_cache
tests
.env
.env.development
*.log

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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`

View File

@@ -1 +0,0 @@
"""memberapi backend package."""

View File

@@ -1 +0,0 @@
"""API routers."""

File diff suppressed because it is too large Load Diff

View File

@@ -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"),
)

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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

View File

@@ -1 +0,0 @@
"""Core settings and constants."""

View File

@@ -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()

View File

@@ -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}"

View File

@@ -1 +0,0 @@
"""Database wiring."""

View File

@@ -1,5 +0,0 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@@ -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()

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -1 +0,0 @@
"""Repository layer."""

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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())

View File

@@ -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()

View File

@@ -1 +0,0 @@
"""Pydantic schemas."""

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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]

View File

@@ -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"

View File

@@ -1 +0,0 @@
"""Security dependencies and guards."""

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -1 +0,0 @@
"""Service layer."""

View File

@@ -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")

View File

@@ -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()

View File

@@ -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
],
)

View File

@@ -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()

View File

@@ -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"

View File

@@ -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()

View File

@@ -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;

View File

@@ -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 $$;

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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)

View File

@@ -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

Submodule frontend added at cf54146606

View File

@@ -1,2 +0,0 @@
VITE_APP_TITLE=member.ose.tw
VITE_API_BASE_URL=https://memberapi.ose.tw

View File

@@ -1,2 +0,0 @@
VITE_APP_TITLE=member.ose.tw (dev)
VITE_API_BASE_URL=http://127.0.0.1:8000

View File

@@ -1,3 +0,0 @@
# member.ose.tw frontend env
VITE_APP_TITLE=member.ose.tw
VITE_API_BASE_URL=https://memberapi.ose.tw

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

View File

@@ -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>

View File

@@ -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}`)

View File

@@ -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
})

View File

@@ -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`)

View File

@@ -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)
}
)

View File

@@ -1,4 +0,0 @@
import { userHttp } from './http'
export const getMe = () => userHttp.get('/me')
export const getMyPermissionSnapshot = () => userHttp.get('/me/permissions/snapshot')

View File

@@ -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`)

View File

@@ -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 })

View File

@@ -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`)

View File

@@ -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`)

View File

@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -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')

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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