Compare commits

...

10 Commits

Author SHA1 Message Date
Chris
23baceed71 docs: Update TASKPLAN_FRONTEND and FRONTEND_HANDOFF_SCHEMA_V2 - mark Schema v2 as complete 2026-03-30 02:39:58 +08:00
Chris
c4b9789df7 Upgrade frontend to Schema V2: Admin management pages
新增功能:
- OIDC 登入流程完整實現(LoginPage → AuthCallbackPage)
- 6 個管理頁面:系統、模組、公司、站台、會員、權限群組
- 權限群組管理:群組 CRUD + 綁定會員 + 群組授權/撤銷
- 新 API 層:systems、modules、companies、sites、members、permission-groups
- admin store:統一管理公共清單資料

調整既有頁面:
- PermissionSnapshotPage:表格新增 system 欄位
- PermissionAdminPage:
  - 新增 system 必填欄位
  - scope_type 改為 company/site 下拉選單
  - module 改為選填(空值代表系統層權限)
- Router:補 6 條新管理路由
- App.vue:導覽列新增管理員群組下拉菜單

驗收條件達成:
✓ 可新增 system/module/company/site
✓ 可做用戶直接 grant/revoke(新 payload)
✓ 可建立 permission-group、加會員、群組 grant/revoke
✓ /me/permissions/snapshot 表格可顯示 system + module + action

Build:成功(0 errors)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-30 02:37:46 +08:00
Chris
d79ed7c6fc fix: finalize unified schema and correct permission snapshot mapping 2026-03-30 02:22:27 +08:00
Chris
42f9124f77 chore: consolidate full database schema into single init_schema.sql 2026-03-30 02:14:26 +08:00
Chris
f9ad9417ba refactor: align backend with company-site-member schema and system-level RBAC groups 2026-03-30 01:59:50 +08:00
Chris
f5848a360f feat: add organization and member management APIs for admin and internal use 2026-03-30 01:23:02 +08:00
Chris
c6cb9d6818 fix: enrich me profile via userinfo and add org-member management plan 2026-03-30 01:14:02 +08:00
Chris
1ec132184f fix: use stable callback redirect_uri for oidc login 2026-03-30 01:08:08 +08:00
Chris
42f04ef961 fix: switch frontend login to authentik auth-code flow 2026-03-30 01:04:28 +08:00
Chris
096136e9d5 fix: allow login by email via authentik username resolution 2026-03-30 00:54:15 +08:00
64 changed files with 2945 additions and 456 deletions

View File

@@ -17,6 +17,7 @@ AUTHENTIK_AUDIENCE=
AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET= AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_TOKEN_ENDPOINT= AUTHENTIK_TOKEN_ENDPOINT=
AUTHENTIK_USERINFO_ENDPOINT=
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME INTERNAL_SHARED_SECRET=CHANGE_ME

View File

@@ -17,6 +17,7 @@ AUTHENTIK_AUDIENCE=
AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET= AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_TOKEN_ENDPOINT= AUTHENTIK_TOKEN_ENDPOINT=
AUTHENTIK_USERINFO_ENDPOINT=
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME INTERNAL_SHARED_SECRET=CHANGE_ME

View File

@@ -31,6 +31,7 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
- `AUTHENTIK_CLIENT_ID` (used by `/auth/login`, fallback to `AUTHENTIK_AUDIENCE`) - `AUTHENTIK_CLIENT_ID` (used by `/auth/login`, fallback to `AUTHENTIK_AUDIENCE`)
- `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing) - `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing)
- `AUTHENTIK_TOKEN_ENDPOINT` (default: `<AUTHENTIK_BASE_URL>/application/o/token/`) - `AUTHENTIK_TOKEN_ENDPOINT` (default: `<AUTHENTIK_BASE_URL>/application/o/token/`)
- `AUTHENTIK_USERINFO_ENDPOINT` (optional, default inferred from issuer/base URL; used to fill missing email/name claims)
## Authentik Admin API setup ## Authentik Admin API setup
@@ -42,7 +43,8 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
## Main APIs ## Main APIs
- `GET /healthz` - `GET /healthz`
- `POST /auth/login` - `GET /auth/oidc/url`
- `POST /auth/oidc/exchange`
- `GET /me` (Bearer token required) - `GET /me` (Bearer token required)
- `GET /me/permissions/snapshot` (Bearer token required) - `GET /me/permissions/snapshot` (Bearer token required)
- `POST /internal/users/upsert-by-sub` - `POST /internal/users/upsert-by-sub`
@@ -50,3 +52,16 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
- `POST /internal/authentik/users/ensure` - `POST /internal/authentik/users/ensure`
- `POST /admin/permissions/grant` - `POST /admin/permissions/grant`
- `POST /admin/permissions/revoke` - `POST /admin/permissions/revoke`
- `GET|POST /admin/systems`
- `GET|POST /admin/modules`
- `GET|POST /admin/companies`
- `GET|POST /admin/sites`
- `GET /admin/members`
- `GET|POST /admin/permission-groups`
- `POST|DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}`
- `POST /admin/permission-groups/{group_key}/permissions/grant|revoke`
- `GET /internal/systems`
- `GET /internal/modules`
- `GET /internal/companies`
- `GET /internal/sites`
- `GET /internal/members`

View File

@@ -3,7 +3,11 @@ from sqlalchemy.orm import Session
from app.db.session import get_db from app.db.session import get_db
from app.models.api_client import ApiClient from app.models.api_client import ApiClient
from app.repositories.companies_repo import CompaniesRepository
from app.repositories.modules_repo import ModulesRepository
from app.repositories.permissions_repo import PermissionsRepository from app.repositories.permissions_repo import PermissionsRepository
from app.repositories.sites_repo import SitesRepository
from app.repositories.systems_repo import SystemsRepository
from app.repositories.users_repo import UsersRepository from app.repositories.users_repo import UsersRepository
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
from app.security.api_client_auth import require_api_client from app.security.api_client_auth import require_api_client
@@ -11,6 +15,36 @@ from app.security.api_client_auth import require_api_client
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str:
systems_repo = SystemsRepository(db)
modules_repo = ModulesRepository(db)
system = systems_repo.get_by_key(system_key)
if not system:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
target_module_key = f"{system_key}.{module_key}" if module_key else f"{system_key}.__system__"
module = modules_repo.get_by_key(target_module_key)
if not module:
module = modules_repo.create(module_key=target_module_key, name=target_module_key, status="active")
return module.id
def _resolve_scope_ids(db: Session, scope_type: str, scope_id: str) -> tuple[str | None, str | None]:
companies_repo = CompaniesRepository(db)
sites_repo = SitesRepository(db)
if scope_type == "company":
company = companies_repo.get_by_key(scope_id)
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
return company.id, None
if scope_type == "site":
site = sites_repo.get_by_key(scope_id)
if not site:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
return None, site.id
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type")
@router.post("/permissions/grant") @router.post("/permissions/grant")
def grant_permission( def grant_permission(
payload: PermissionGrantRequest, payload: PermissionGrantRequest,
@@ -26,12 +60,15 @@ def grant_permission(
display_name=payload.display_name, display_name=payload.display_name,
is_active=True, is_active=True,
) )
module_id = _resolve_module_id(db, payload.system, payload.module)
company_id, site_id = _resolve_scope_ids(db, payload.scope_type, payload.scope_id)
permission = perms_repo.create_if_not_exists( permission = perms_repo.create_if_not_exists(
user_id=user.id, user_id=user.id,
scope_type=payload.scope_type, module_id=module_id,
scope_id=payload.scope_id,
module=payload.module,
action=payload.action, action=payload.action,
scope_type=payload.scope_type,
company_id=company_id,
site_id=site_id,
) )
return {"permission_id": permission.id, "result": "granted"} return {"permission_id": permission.id, "result": "granted"}
@@ -50,11 +87,14 @@ def revoke_permission(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
module_id = _resolve_module_id(db, payload.system, payload.module)
company_id, site_id = _resolve_scope_ids(db, payload.scope_type, payload.scope_id)
deleted = perms_repo.revoke( deleted = perms_repo.revoke(
user_id=user.id, user_id=user.id,
scope_type=payload.scope_type, module_id=module_id,
scope_id=payload.scope_id,
module=payload.module,
action=payload.action, action=payload.action,
scope_type=payload.scope_type,
company_id=company_id,
site_id=site_id,
) )
return {"deleted": deleted, "result": "revoked"} return {"deleted": deleted, "result": "revoked"}

View File

@@ -0,0 +1,328 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.api_client import ApiClient
from app.repositories.companies_repo import CompaniesRepository
from app.repositories.modules_repo import ModulesRepository
from app.repositories.permission_groups_repo import PermissionGroupsRepository
from app.repositories.sites_repo import SitesRepository
from app.repositories.systems_repo import SystemsRepository
from app.repositories.users_repo import UsersRepository
from app.schemas.catalog import (
CompanyCreateRequest,
CompanyItem,
MemberItem,
ModuleCreateRequest,
ModuleItem,
PermissionGroupCreateRequest,
PermissionGroupItem,
SiteCreateRequest,
SiteItem,
SystemCreateRequest,
SystemItem,
)
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
from app.security.api_client_auth import require_api_client
router = APIRouter(prefix="/admin", tags=["admin"])
def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str:
systems_repo = SystemsRepository(db)
modules_repo = ModulesRepository(db)
system = systems_repo.get_by_key(system_key)
if not system:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
target_module_key = f"{system_key}.{module_key}" if module_key else f"{system_key}.__system__"
module = modules_repo.get_by_key(target_module_key)
if not module:
module = modules_repo.create(module_key=target_module_key, name=target_module_key, status="active")
return module.id
def _resolve_scope_ids(db: Session, scope_type: str, scope_id: str) -> tuple[str | None, str | None]:
companies_repo = CompaniesRepository(db)
sites_repo = SitesRepository(db)
if scope_type == "company":
company = companies_repo.get_by_key(scope_id)
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
return company.id, None
if scope_type == "site":
site = sites_repo.get_by_key(scope_id)
if not site:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
return None, site.id
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type")
@router.get("/systems")
def list_systems(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
repo = SystemsRepository(db)
items, total = repo.list(limit=limit, offset=offset)
return {"items": [SystemItem(id=i.id, system_key=i.system_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
@router.post("/systems", response_model=SystemItem)
def create_system(
payload: SystemCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> SystemItem:
repo = SystemsRepository(db)
if repo.get_by_key(payload.system_key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_key_already_exists")
row = repo.create(system_key=payload.system_key, name=payload.name, status=payload.status)
return SystemItem(id=row.id, system_key=row.system_key, name=row.name, status=row.status)
@router.get("/modules")
def list_modules(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
modules_repo = ModulesRepository(db)
items, total = modules_repo.list(limit=limit, offset=offset)
out = []
for i in items:
system_key = i.module_key.split(".", 1)[0] if "." in i.module_key else None
out.append(
ModuleItem(
id=i.id,
system_key=system_key,
module_key=i.module_key,
name=i.name,
status=i.status,
).model_dump()
)
return {"items": out, "total": total, "limit": limit, "offset": offset}
@router.post("/modules", response_model=ModuleItem)
def create_module(
payload: ModuleCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> ModuleItem:
systems_repo = SystemsRepository(db)
modules_repo = ModulesRepository(db)
system = systems_repo.get_by_key(payload.system_key)
if not system:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
full_module_key = f"{payload.system_key}.{payload.module_key}"
if modules_repo.get_by_key(full_module_key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="module_key_already_exists")
row = modules_repo.create(
module_key=full_module_key,
name=payload.name,
status=payload.status,
)
return ModuleItem(id=row.id, system_key=payload.system_key, module_key=row.module_key, name=row.name, status=row.status)
@router.get("/companies")
def list_companies(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
repo = CompaniesRepository(db)
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
return {"items": [CompanyItem(id=i.id, company_key=i.company_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
@router.post("/companies", response_model=CompanyItem)
def create_company(
payload: CompanyCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> CompanyItem:
repo = CompaniesRepository(db)
if repo.get_by_key(payload.company_key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="company_key_already_exists")
row = repo.create(company_key=payload.company_key, name=payload.name, status=payload.status)
return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status)
@router.get("/sites")
def list_sites(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
company_key: str | None = Query(default=None),
keyword: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
companies_repo = CompaniesRepository(db)
sites_repo = SitesRepository(db)
company_lookup: dict[str, str] = {}
all_companies, _ = companies_repo.list(limit=1000, offset=0)
for c in all_companies:
company_lookup[c.id] = c.company_key
company_id = None
if company_key:
company = companies_repo.get_by_key(company_key)
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
company_id = company.id
items, total = sites_repo.list(keyword=keyword, company_id=company_id, limit=limit, offset=offset)
return {
"items": [
SiteItem(
id=i.id,
site_key=i.site_key,
company_key=company_lookup.get(i.company_id, ""),
name=i.name,
status=i.status,
).model_dump()
for i in items
],
"total": total,
"limit": limit,
"offset": offset,
}
@router.post("/sites", response_model=SiteItem)
def create_site(
payload: SiteCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> SiteItem:
companies_repo = CompaniesRepository(db)
sites_repo = SitesRepository(db)
company = companies_repo.get_by_key(payload.company_key)
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
if sites_repo.get_by_key(payload.site_key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="site_key_already_exists")
row = sites_repo.create(site_key=payload.site_key, company_id=company.id, name=payload.name, status=payload.status)
return SiteItem(id=row.id, site_key=row.site_key, company_key=payload.company_key, name=row.name, status=row.status)
@router.get("/members")
def list_members(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
keyword: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
users_repo = UsersRepository(db)
items, total = users_repo.list(keyword=keyword, limit=limit, offset=offset)
return {"items": [MemberItem(id=i.id, authentik_sub=i.authentik_sub, email=i.email, display_name=i.display_name, is_active=i.is_active).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
@router.get("/permission-groups")
def list_permission_groups(
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict:
repo = PermissionGroupsRepository(db)
items, total = repo.list(limit=limit, offset=offset)
return {"items": [PermissionGroupItem(id=i.id, group_key=i.group_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
@router.post("/permission-groups", response_model=PermissionGroupItem)
def create_permission_group(
payload: PermissionGroupCreateRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> PermissionGroupItem:
repo = PermissionGroupsRepository(db)
if repo.get_by_key(payload.group_key):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="group_key_already_exists")
row = repo.create(group_key=payload.group_key, name=payload.name, status=payload.status)
return PermissionGroupItem(id=row.id, group_key=row.group_key, name=row.name, status=row.status)
@router.post("/permission-groups/{group_key}/members/{authentik_sub}")
def add_group_member(
group_key: str,
authentik_sub: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, str]:
groups_repo = PermissionGroupsRepository(db)
group = groups_repo.get_by_key(group_key)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
row = groups_repo.add_member_if_not_exists(group.id, authentik_sub)
return {"membership_id": row.id, "result": "added"}
@router.delete("/permission-groups/{group_key}/members/{authentik_sub}")
def remove_group_member(
group_key: str,
authentik_sub: str,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, int | str]:
groups_repo = PermissionGroupsRepository(db)
group = groups_repo.get_by_key(group_key)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
deleted = groups_repo.remove_member(group.id, authentik_sub)
return {"deleted": deleted, "result": "removed"}
@router.post("/permission-groups/{group_key}/permissions/grant")
def grant_group_permission(
group_key: str,
payload: PermissionGrantRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, str]:
groups_repo = PermissionGroupsRepository(db)
group = groups_repo.get_by_key(group_key)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
_resolve_module_id(db, payload.system, payload.module)
_resolve_scope_ids(db, payload.scope_type, payload.scope_id)
module_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__"
row = groups_repo.grant_group_permission(
group_id=group.id,
system=payload.system,
module=module_key,
action=payload.action,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
return {"permission_id": row.id, "result": "granted"}
@router.post("/permission-groups/{group_key}/permissions/revoke")
def revoke_group_permission(
group_key: str,
payload: PermissionRevokeRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, int | str]:
groups_repo = PermissionGroupsRepository(db)
group = groups_repo.get_by_key(group_key)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
_resolve_module_id(db, payload.system, payload.module)
_resolve_scope_ids(db, payload.scope_type, payload.scope_id)
module_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__"
deleted = groups_repo.revoke_group_permission(
group_id=group.id,
system=payload.system,
module=module_key,
action=payload.action,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
return {"deleted": deleted, "result": "revoked"}

View File

@@ -1,12 +1,51 @@
import logging
import secrets
from urllib.parse import urljoin from urllib.parse import urljoin
import httpx import httpx
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from app.core.config import get_settings from app.core.config import get_settings
from app.schemas.login import LoginRequest, LoginResponse from app.schemas.login import (
LoginRequest,
LoginResponse,
OIDCAuthUrlResponse,
OIDCCodeExchangeRequest,
)
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
logger = logging.getLogger(__name__)
def _resolve_username_by_email(settings, email: str) -> str | None:
if not settings.authentik_base_url or not settings.authentik_admin_token:
return None
url = urljoin(settings.authentik_base_url.rstrip("/") + "/", "api/v3/core/users/")
try:
resp = httpx.get(
url,
params={"email": email},
timeout=10,
verify=settings.authentik_verify_tls,
headers={
"Authorization": f"Bearer {settings.authentik_admin_token}",
"Accept": "application/json",
},
)
except Exception:
return None
if resp.status_code >= 400:
return None
data = resp.json()
results = data.get("results") if isinstance(data, dict) else None
if not isinstance(results, list) or not results:
return None
username = results[0].get("username")
return username if isinstance(username, str) and username else None
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
@@ -30,18 +69,33 @@ def login(payload: LoginRequest) -> LoginResponse:
"scope": "openid profile email", "scope": "openid profile email",
} }
try: def _token_request(form_data: dict[str, str]) -> httpx.Response:
resp = httpx.post( resp = httpx.post(
token_endpoint, token_endpoint,
data=form, data=form_data,
timeout=10, timeout=10,
verify=settings.authentik_verify_tls, verify=settings.authentik_verify_tls,
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
) )
return resp
try:
resp = _token_request(form)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
# If user entered email, try resolving username and retry once.
if resp.status_code >= 400 and "@" in payload.username:
resolved = _resolve_username_by_email(settings, payload.username)
if resolved and resolved != payload.username:
form["username"] = resolved
try:
resp = _token_request(form)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
if resp.status_code >= 400: if resp.status_code >= 400:
logger.warning("authentik 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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password")
data = resp.json() data = resp.json()
@@ -55,3 +109,71 @@ def login(payload: LoginRequest) -> LoginResponse:
expires_in=data.get("expires_in"), expires_in=data.get("expires_in"),
scope=data.get("scope"), scope=data.get("scope"),
) )
@router.get("/oidc/url", response_model=OIDCAuthUrlResponse)
def get_oidc_authorize_url(redirect_uri: str) -> OIDCAuthUrlResponse:
settings = get_settings()
client_id = settings.authentik_client_id or settings.authentik_audience
if not settings.authentik_base_url or not client_id:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
authorize_endpoint = urljoin(settings.authentik_base_url.rstrip("/") + "/", "application/o/authorize/")
state = secrets.token_urlsafe(24)
params = httpx.QueryParams(
{
"client_id": client_id,
"response_type": "code",
"scope": "openid profile email",
"redirect_uri": redirect_uri,
"state": state,
"prompt": "login",
}
)
return OIDCAuthUrlResponse(authorize_url=f"{authorize_endpoint}?{params}")
@router.post("/oidc/exchange", response_model=LoginResponse)
def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse:
settings = get_settings()
client_id = settings.authentik_client_id or settings.authentik_audience
if not settings.authentik_base_url or not client_id or not settings.authentik_client_secret:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
token_endpoint = settings.authentik_token_endpoint or urljoin(
settings.authentik_base_url.rstrip("/") + "/", "application/o/token/"
)
form = {
"grant_type": "authorization_code",
"client_id": client_id,
"client_secret": settings.authentik_client_secret,
"code": payload.code,
"redirect_uri": payload.redirect_uri,
}
try:
resp = httpx.post(
token_endpoint,
data=form,
timeout=10,
verify=settings.authentik_verify_tls,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
if resp.status_code >= 400:
logger.warning("authentik auth-code exchange failed: status=%s body=%s", resp.status_code, resp.text)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentik_code_exchange_failed")
data = resp.json()
token = data.get("access_token")
if not token:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_access_token")
return LoginResponse(
access_token=token,
token_type=data.get("token_type", "Bearer"),
expires_in=data.get("expires_in"),
scope=data.get("scope"),
)

View File

@@ -58,9 +58,8 @@ def get_permission_snapshot(
if user is None: if user is None:
return PermissionSnapshotResponse(authentik_sub=authentik_sub, permissions=[]) return PermissionSnapshotResponse(authentik_sub=authentik_sub, permissions=[])
permissions = perms_repo.list_by_user_id(user.id) permissions = perms_repo.list_by_user(user.id, user.authentik_sub)
tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions] return PermissionService.build_snapshot(authentik_sub=authentik_sub, permissions=permissions)
return PermissionService.build_snapshot(authentik_sub=authentik_sub, permissions=tuples)
@router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse) @router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse)

View File

@@ -0,0 +1,97 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.internal import verify_internal_secret
from app.db.session import get_db
from app.repositories.companies_repo import CompaniesRepository
from app.repositories.modules_repo import ModulesRepository
from app.repositories.sites_repo import SitesRepository
from app.repositories.systems_repo import SystemsRepository
from app.repositories.users_repo import UsersRepository
router = APIRouter(prefix="/internal", tags=["internal"])
@router.get("/systems")
def internal_list_systems(
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
limit: int = Query(default=200, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
) -> dict:
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("/modules")
def internal_list_modules(
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
limit: int = Query(default=500, ge=1, le=2000),
offset: int = Query(default=0, ge=0),
) -> dict:
modules_repo = ModulesRepository(db)
items, total = modules_repo.list(limit=limit, offset=offset)
return {
"items": [
{
"id": i.id,
"module_key": i.module_key,
"system_key": i.module_key.split(".", 1)[0] if "." in i.module_key else None,
"name": i.name,
"status": i.status,
}
for i in items
],
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/companies")
def internal_list_companies(
_: None = Depends(verify_internal_secret),
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),
) -> dict:
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")
def internal_list_sites(
_: None = Depends(verify_internal_secret),
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),
) -> dict:
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=2000, offset=0)
mapping = {c.id: c.company_key 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.get(i.company_id), "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset}
@router.get("/members")
def internal_list_members(
_: None = Depends(verify_internal_secret),
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),
) -> dict:
repo = UsersRepository(db)
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
return {"items": [{"id": i.id, "authentik_sub": i.authentik_sub, "email": i.email, "display_name": i.display_name, "is_active": i.is_active} for i in items], "total": total, "limit": limit, "offset": offset}

View File

@@ -51,8 +51,7 @@ def get_my_permission_snapshot(
display_name=principal.name or principal.preferred_username, display_name=principal.name or principal.preferred_username,
is_active=True, is_active=True,
) )
permissions = perms_repo.list_by_user_id(user.id) permissions = perms_repo.list_by_user(user.id, user.authentik_sub)
tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions] return PermissionService.build_snapshot(authentik_sub=principal.sub, permissions=permissions)
return PermissionService.build_snapshot(authentik_sub=principal.sub, permissions=tuples)
except SQLAlchemyError: except SQLAlchemyError:
return PermissionSnapshotResponse(authentik_sub=principal.sub, permissions=[]) return PermissionSnapshotResponse(authentik_sub=principal.sub, permissions=[])

View File

@@ -26,6 +26,7 @@ class Settings(BaseSettings):
authentik_client_id: str = "" authentik_client_id: str = ""
authentik_client_secret: str = "" authentik_client_secret: str = ""
authentik_token_endpoint: str = "" authentik_token_endpoint: str = ""
authentik_userinfo_endpoint: str = ""
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"] public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
internal_shared_secret: str = "" internal_shared_secret: str = ""

View File

@@ -2,7 +2,9 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.admin import router as admin_router from app.api.admin import router as admin_router
from app.api.admin_catalog import router as admin_catalog_router
from app.api.auth import router as auth_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.internal import router as internal_router
from app.api.me import router as me_router from app.api.me import router as me_router
from app.core.config import get_settings from app.core.config import get_settings
@@ -25,6 +27,8 @@ def healthz() -> dict[str, str]:
app.include_router(internal_router) app.include_router(internal_router)
app.include_router(internal_catalog_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(admin_catalog_router)
app.include_router(me_router) app.include_router(me_router)
app.include_router(auth_router) app.include_router(auth_router)

View File

@@ -1,5 +1,25 @@
from app.models.api_client import ApiClient from app.models.api_client import ApiClient
from app.models.company import Company
from app.models.module import Module
from app.models.permission import Permission from app.models.permission import Permission
from app.models.permission_group import PermissionGroup
from app.models.permission_group_member import PermissionGroupMember
from app.models.permission_group_permission import PermissionGroupPermission
from app.models.site import Site
from app.models.system import System
from app.models.user import User from app.models.user import User
from app.models.user_scope_permission import UserScopePermission
__all__ = ["ApiClient", "Permission", "User"] __all__ = [
"ApiClient",
"Company",
"Module",
"Permission",
"PermissionGroup",
"PermissionGroupMember",
"PermissionGroupPermission",
"Site",
"System",
"User",
"UserScopePermission",
]

View File

@@ -0,0 +1,21 @@
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)
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

@@ -0,0 +1,21 @@
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 Module(Base):
__tablename__ = "modules"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
module_key: Mapped[str] = mapped_column(String(128), 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
)

View File

@@ -0,0 +1,21 @@
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 PermissionGroup(Base):
__tablename__ = "permission_groups"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
group_key: Mapped[str] = mapped_column(String(128), 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
)

View File

@@ -0,0 +1,20 @@
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 PermissionGroupMember(Base):
__tablename__ = "permission_group_members"
__table_args__ = (UniqueConstraint("group_id", "authentik_sub", name="uq_permission_group_members_group_sub"),)
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
group_id: Mapped[str] = mapped_column(
UUID(as_uuid=False), ForeignKey("permission_groups.id", ondelete="CASCADE"), nullable=False
)
authentik_sub: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -0,0 +1,23 @@
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 PermissionGroupPermission(Base):
__tablename__ = "permission_group_permissions"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
group_id: Mapped[str] = mapped_column(
UUID(as_uuid=False), ForeignKey("permission_groups.id", ondelete="CASCADE"), nullable=False
)
system: Mapped[str] = mapped_column(String(64), nullable=False)
module: Mapped[str] = mapped_column(String(128), nullable=False)
action: Mapped[str] = mapped_column(String(32), nullable=False)
scope_type: Mapped[str] = mapped_column(String(16), nullable=False)
scope_id: Mapped[str] = mapped_column(String(128), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -0,0 +1,22 @@
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)
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
)

View File

@@ -0,0 +1,21 @@
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
)

View File

@@ -0,0 +1,24 @@
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 UserScopePermission(Base):
__tablename__ = "user_scope_permissions"
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)
module_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("modules.id", ondelete="CASCADE"), nullable=False)
action: Mapped[str] = mapped_column(String(32), nullable=False)
scope_type: Mapped[str] = mapped_column(String(16), nullable=False)
company_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("companies.id", ondelete="CASCADE"))
site_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("sites.id", ondelete="CASCADE"))
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

@@ -0,0 +1,35 @@
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:
stmt = select(Company).where(Company.company_key == company_key)
return self.db.scalar(stmt)
def get_by_id(self, company_id: str) -> Company | None:
stmt = select(Company).where(Company.id == company_id)
return self.db.scalar(stmt)
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, status: str = "active") -> Company:
item = Company(company_key=company_key, name=name, status=status)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item

View File

@@ -0,0 +1,26 @@
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.module import Module
class ModulesRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_by_key(self, module_key: str) -> Module | None:
stmt = select(Module).where(Module.module_key == module_key)
return self.db.scalar(stmt)
def list(self, limit: int = 200, offset: int = 0) -> tuple[list[Module], int]:
stmt = select(Module)
count_stmt = select(func.count()).select_from(Module)
stmt = stmt.order_by(Module.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, module_key: str, name: str, status: str = "active") -> Module:
item = Module(module_key=module_key, name=name, status=status)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item

View File

@@ -0,0 +1,106 @@
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.models.permission_group import PermissionGroup
from app.models.permission_group_member import PermissionGroupMember
from app.models.permission_group_permission import PermissionGroupPermission
class PermissionGroupsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_by_key(self, group_key: str) -> PermissionGroup | None:
return self.db.scalar(select(PermissionGroup).where(PermissionGroup.group_key == group_key))
def get_by_id(self, group_id: str) -> PermissionGroup | None:
return self.db.scalar(select(PermissionGroup).where(PermissionGroup.id == group_id))
def list(self, limit: int = 100, offset: int = 0) -> tuple[list[PermissionGroup], int]:
stmt = select(PermissionGroup).order_by(PermissionGroup.created_at.desc()).limit(limit).offset(offset)
count_stmt = select(func.count()).select_from(PermissionGroup)
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
def create(self, group_key: str, name: str, status: str = "active") -> PermissionGroup:
item = PermissionGroup(group_key=group_key, name=name, status=status)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item
def add_member_if_not_exists(self, group_id: str, authentik_sub: str) -> PermissionGroupMember:
existing = self.db.scalar(
select(PermissionGroupMember).where(
PermissionGroupMember.group_id == group_id, PermissionGroupMember.authentik_sub == authentik_sub
)
)
if existing:
return existing
row = PermissionGroupMember(group_id=group_id, authentik_sub=authentik_sub)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return row
def remove_member(self, group_id: str, authentik_sub: str) -> int:
result = self.db.execute(
delete(PermissionGroupMember).where(
PermissionGroupMember.group_id == group_id, PermissionGroupMember.authentik_sub == authentik_sub
)
)
self.db.commit()
return int(result.rowcount or 0)
def grant_group_permission(
self,
group_id: str,
system: str,
module: str,
action: str,
scope_type: str,
scope_id: str,
) -> PermissionGroupPermission:
where = [
PermissionGroupPermission.group_id == group_id,
PermissionGroupPermission.system == system,
PermissionGroupPermission.module == module,
PermissionGroupPermission.action == action,
PermissionGroupPermission.scope_type == scope_type,
PermissionGroupPermission.scope_id == scope_id,
]
existing = self.db.scalar(select(PermissionGroupPermission).where(*where))
if existing:
return existing
row = PermissionGroupPermission(
group_id=group_id,
system=system,
module=module,
action=action,
scope_type=scope_type,
scope_id=scope_id,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return row
def revoke_group_permission(
self,
group_id: str,
system: str,
module: str,
action: str,
scope_type: str,
scope_id: str,
) -> int:
stmt = delete(PermissionGroupPermission).where(
PermissionGroupPermission.group_id == group_id,
PermissionGroupPermission.system == system,
PermissionGroupPermission.module == module,
PermissionGroupPermission.action == action,
PermissionGroupPermission.scope_type == scope_type,
PermissionGroupPermission.scope_id == scope_id,
)
result = self.db.execute(stmt)
self.db.commit()
return int(result.rowcount or 0)

View File

@@ -1,42 +1,96 @@
from sqlalchemy import delete, select from sqlalchemy import and_, delete, literal, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.permission import Permission from app.models.company import Company
from app.models.module import Module
from app.models.permission_group_member import PermissionGroupMember
from app.models.permission_group_permission import PermissionGroupPermission
from app.models.site import Site
from app.models.user_scope_permission import UserScopePermission
class PermissionsRepository: class PermissionsRepository:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self.db = db self.db = db
def list_by_user_id(self, user_id: str) -> list[Permission]: def list_by_user(self, user_id: str, authentik_sub: str) -> list[tuple[str, str, str | None, str, str]]:
stmt = select(Permission).where(Permission.user_id == user_id) direct_stmt = (
return list(self.db.scalars(stmt).all()) select(
literal("direct"),
UserScopePermission.scope_type,
Company.company_key,
Site.site_key,
Module.module_key,
UserScopePermission.action,
)
.select_from(UserScopePermission)
.join(Module, Module.id == UserScopePermission.module_id)
.join(Company, Company.id == UserScopePermission.company_id, isouter=True)
.join(Site, Site.id == UserScopePermission.site_id, isouter=True)
.where(UserScopePermission.user_id == user_id)
)
group_stmt = (
select(
literal("group"),
PermissionGroupPermission.scope_type,
PermissionGroupPermission.scope_id,
PermissionGroupPermission.system,
PermissionGroupPermission.module,
PermissionGroupPermission.action,
)
.select_from(PermissionGroupPermission)
.join(PermissionGroupMember, PermissionGroupMember.group_id == PermissionGroupPermission.group_id)
.where(PermissionGroupMember.authentik_sub == authentik_sub)
)
rows = self.db.execute(direct_stmt).all() + self.db.execute(group_stmt).all()
result: list[tuple[str, str, str | None, str, str]] = []
dedup = set()
for row in rows:
source = row[0]
if source == "group":
_, scope_type, scope_id, system_key, module_key, action = row
else:
_, scope_type, company_key, site_key, module_key, action = row
scope_id = company_key if scope_type == "company" else site_key
system_key = module_key.split(".", 1)[0] if isinstance(module_key, str) and "." in module_key else None
key = (scope_type, scope_id or "", system_key, module_key, action)
if key in dedup:
continue
dedup.add(key)
result.append(key)
return result
def create_if_not_exists( def create_if_not_exists(
self, self,
user_id: str, user_id: str,
scope_type: str, module_id: str,
scope_id: str,
module: str,
action: str, action: str,
) -> Permission: scope_type: str,
stmt = select(Permission).where( company_id: str | None,
Permission.user_id == user_id, site_id: str | None,
Permission.scope_type == scope_type, ) -> UserScopePermission:
Permission.scope_id == scope_id, where_expr = [
Permission.module == module, UserScopePermission.user_id == user_id,
Permission.action == action, UserScopePermission.module_id == module_id,
) UserScopePermission.action == action,
existing = self.db.scalar(stmt) UserScopePermission.scope_type == scope_type,
]
if scope_type == "company":
where_expr.append(UserScopePermission.company_id == company_id)
else:
where_expr.append(UserScopePermission.site_id == site_id)
existing = self.db.scalar(select(UserScopePermission).where(and_(*where_expr)))
if existing: if existing:
return existing return existing
item = Permission( item = UserScopePermission(
user_id=user_id, user_id=user_id,
scope_type=scope_type, module_id=module_id,
scope_id=scope_id,
module=module,
action=action, action=action,
scope_type=scope_type,
company_id=company_id,
site_id=site_id,
) )
self.db.add(item) self.db.add(item)
self.db.commit() self.db.commit()
@@ -46,17 +100,21 @@ class PermissionsRepository:
def revoke( def revoke(
self, self,
user_id: str, user_id: str,
scope_type: str, module_id: str,
scope_id: str,
module: str,
action: str, action: str,
scope_type: str,
company_id: str | None,
site_id: str | None,
) -> int: ) -> int:
stmt = delete(Permission).where( stmt = delete(UserScopePermission).where(
Permission.user_id == user_id, UserScopePermission.user_id == user_id,
Permission.scope_type == scope_type, UserScopePermission.module_id == module_id,
Permission.scope_id == scope_id, UserScopePermission.action == action,
Permission.module == module, UserScopePermission.scope_type == scope_type,
Permission.action == action, or_(
and_(scope_type == "company", UserScopePermission.company_id == company_id),
and_(scope_type == "site", UserScopePermission.site_id == site_id),
),
) )
result = self.db.execute(stmt) result = self.db.execute(stmt)
self.db.commit() self.db.commit()

View File

@@ -0,0 +1,40 @@
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:
stmt = select(Site).where(Site.site_key == site_key)
return self.db.scalar(stmt)
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.name.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, name: str, status: str = "active") -> Site:
item = Site(site_key=site_key, company_id=company_id, name=name, status=status)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item

View File

@@ -0,0 +1,33 @@
from sqlalchemy import func, 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:
stmt = select(System).where(System.system_key == system_key)
return self.db.scalar(stmt)
def get_by_id(self, system_id: str) -> System | None:
stmt = select(System).where(System.id == system_id)
return self.db.scalar(stmt)
def list(self, 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 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

View File

@@ -1,4 +1,4 @@
from sqlalchemy import select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.user import User from app.models.user import User
@@ -12,6 +12,35 @@ class UsersRepository:
stmt = select(User).where(User.authentik_sub == authentik_sub) stmt = select(User).where(User.authentik_sub == authentik_sub)
return self.db.scalar(stmt) return self.db.scalar(stmt)
def get_by_id(self, user_id: str) -> User | None:
stmt = select(User).where(User.id == user_id)
return self.db.scalar(stmt)
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.authentik_sub.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( def upsert_by_sub(
self, self,
authentik_sub: str, authentik_sub: str,
@@ -40,3 +69,21 @@ class UsersRepository:
self.db.commit() self.db.commit()
self.db.refresh(user) self.db.refresh(user)
return user return user
def update_member(
self,
user: User,
*,
email: str | None = None,
display_name: str | None = None,
is_active: bool | None = None,
) -> User:
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
self.db.commit()
self.db.refresh(user)
return user

View File

@@ -0,0 +1,85 @@
from pydantic import BaseModel
class SystemCreateRequest(BaseModel):
system_key: str
name: str
status: str = "active"
class SystemItem(BaseModel):
id: str
system_key: str
name: str
status: str
class ModuleCreateRequest(BaseModel):
system_key: str
module_key: str
name: str
status: str = "active"
class ModuleItem(BaseModel):
id: str
system_key: str | None = None
module_key: str
name: str
status: str
class CompanyCreateRequest(BaseModel):
company_key: str
name: str
status: str = "active"
class CompanyItem(BaseModel):
id: str
company_key: str
name: str
status: str
class SiteCreateRequest(BaseModel):
site_key: str
company_key: str
name: str
status: str = "active"
class SiteItem(BaseModel):
id: str
site_key: str
company_key: str
name: str
status: str
class MemberItem(BaseModel):
id: str
authentik_sub: str
email: str | None = None
display_name: str | None = None
is_active: bool
class ListResponse(BaseModel):
items: list
total: int
limit: int
offset: int
class PermissionGroupCreateRequest(BaseModel):
group_key: str
name: str
status: str = "active"
class PermissionGroupItem(BaseModel):
id: str
group_key: str
name: str
status: str

View File

@@ -11,3 +11,12 @@ class LoginResponse(BaseModel):
token_type: str = "Bearer" token_type: str = "Bearer"
expires_in: int | None = None expires_in: int | None = None
scope: str | None = None scope: str | None = None
class OIDCAuthUrlResponse(BaseModel):
authorize_url: str
class OIDCCodeExchangeRequest(BaseModel):
code: str
redirect_uri: str

View File

@@ -7,7 +7,8 @@ class PermissionGrantRequest(BaseModel):
display_name: str | None = None display_name: str | None = None
scope_type: str scope_type: str
scope_id: str scope_id: str
module: str system: str
module: str | None = None
action: str action: str
@@ -15,13 +16,15 @@ class PermissionRevokeRequest(BaseModel):
authentik_sub: str authentik_sub: str
scope_type: str scope_type: str
scope_id: str scope_id: str
module: str system: str
module: str | None = None
action: str action: str
class PermissionItem(BaseModel): class PermissionItem(BaseModel):
scope_type: str scope_type: str
scope_id: str scope_id: str
system: str | None = None
module: str module: str
action: str action: str

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
import httpx
import jwt import jwt
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -19,11 +20,19 @@ class AuthentikTokenVerifier:
jwks_url: str | None, jwks_url: str | None,
audience: str | None, audience: str | None,
client_secret: str | None, client_secret: str | None,
base_url: str | None,
userinfo_endpoint: str | None,
verify_tls: bool,
) -> None: ) -> None:
self.issuer = issuer.strip() if issuer else 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.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
self.audience = audience.strip() if audience else None self.audience = audience.strip() if audience else None
self.client_secret = client_secret.strip() if client_secret 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.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: if not self.jwks_url:
raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required") raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
@@ -39,6 +48,50 @@ class AuthentikTokenVerifier:
return normalized return normalized
return normalized + "jwks/" return normalized + "jwks/"
@staticmethod
def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None:
if issuer:
return issuer.rstrip("/") + "/userinfo/"
if base_url:
return base_url.rstrip("/") + "/application/o/userinfo/"
return None
def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal:
if principal.email and (principal.name or principal.preferred_username):
return principal
if not self.userinfo_endpoint:
return 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 principal
if resp.status_code >= 400:
return principal
data = resp.json() if resp.content else {}
sub = data.get("sub")
if isinstance(sub, str) and sub and sub != principal.sub:
return 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
)
return AuthentikPrincipal(
sub=principal.sub,
email=email,
name=name,
preferred_username=preferred_username,
)
def verify_access_token(self, token: str) -> AuthentikPrincipal: def verify_access_token(self, token: str) -> AuthentikPrincipal:
try: try:
header = jwt.get_unverified_header(token) header = jwt.get_unverified_header(token)
@@ -78,12 +131,13 @@ class AuthentikTokenVerifier:
if not sub: if not sub:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub")
return AuthentikPrincipal( principal = AuthentikPrincipal(
sub=sub, sub=sub,
email=claims.get("email"), email=claims.get("email"),
name=claims.get("name"), name=claims.get("name"),
preferred_username=claims.get("preferred_username"), preferred_username=claims.get("preferred_username"),
) )
return self._enrich_from_userinfo(principal, token)
@lru_cache @lru_cache
@@ -94,6 +148,9 @@ def _get_verifier() -> AuthentikTokenVerifier:
jwks_url=settings.authentik_jwks_url, jwks_url=settings.authentik_jwks_url,
audience=settings.authentik_audience, audience=settings.authentik_audience,
client_secret=settings.authentik_client_secret, client_secret=settings.authentik_client_secret,
base_url=settings.authentik_base_url,
userinfo_endpoint=settings.authentik_userinfo_endpoint,
verify_tls=settings.authentik_verify_tls,
) )

View File

@@ -3,11 +3,11 @@ from app.schemas.permissions import PermissionItem, PermissionSnapshotResponse
class PermissionService: class PermissionService:
@staticmethod @staticmethod
def build_snapshot(authentik_sub: str, permissions: list[tuple[str, str, str, str]]) -> PermissionSnapshotResponse: def build_snapshot(authentik_sub: str, permissions: list[tuple[str, str, str | None, str, str]]) -> PermissionSnapshotResponse:
return PermissionSnapshotResponse( return PermissionSnapshotResponse(
authentik_sub=authentik_sub, authentik_sub=authentik_sub,
permissions=[ permissions=[
PermissionItem(scope_type=s_type, scope_id=s_id, module=module, action=action) PermissionItem(scope_type=s_type, scope_id=s_id, system=system, module=module, action=action)
for s_type, s_id, module, action in permissions for s_type, s_id, system, module, action in permissions
], ],
) )

View File

@@ -4,15 +4,62 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
authentik_sub VARCHAR(255) NOT NULL UNIQUE, authentik_sub TEXT NOT NULL UNIQUE,
authentik_user_id INTEGER, authentik_user_id INTEGER,
email VARCHAR(320), email TEXT UNIQUE,
display_name VARCHAR(255), display_name TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'active',
is_active BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE TABLE IF NOT EXISTS auth_sync_state (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_synced_at TIMESTAMPTZ,
source_version TEXT,
last_error TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_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 IF NOT EXISTS 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,
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 IF NOT EXISTS 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 IF NOT EXISTS modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_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()
);
-- legacy table: 保留相容舊流程
CREATE TABLE IF NOT EXISTS permissions ( CREATE TABLE IF NOT EXISTS permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -25,7 +72,91 @@ CREATE TABLE IF NOT EXISTS permissions (
UNIQUE (user_id, scope_type, scope_id, module, action) UNIQUE (user_id, scope_type, scope_id, module, action)
); );
CREATE TABLE IF NOT EXISTS user_scope_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
action VARCHAR(32) NOT NULL,
scope_type VARCHAR(16) NOT NULL,
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
site_id UUID REFERENCES sites(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE user_scope_permissions DROP CONSTRAINT IF EXISTS user_scope_permissions_check;
ALTER TABLE user_scope_permissions
ADD CONSTRAINT user_scope_permissions_check
CHECK (
((scope_type = 'company' AND company_id IS NOT NULL AND site_id IS NULL)
OR (scope_type = 'site' AND site_id IS NOT NULL AND company_id IS NULL))
);
CREATE TABLE IF NOT EXISTS permission_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_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 IF NOT EXISTS permission_group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
authentik_sub TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_permission_group_members_group_sub UNIQUE (group_id, authentik_sub)
);
CREATE TABLE IF NOT EXISTS permission_group_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
system TEXT NOT NULL,
module TEXT NOT NULL,
action TEXT NOT NULL,
scope_type TEXT NOT NULL,
scope_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_pgp_group_rule UNIQUE (group_id, system, module, action, scope_type, scope_id)
);
CREATE TABLE IF NOT EXISTS 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()
);
INSERT INTO systems (system_key, name, status)
VALUES ('member', 'Member Center', 'active')
ON CONFLICT (system_key) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub); CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub);
CREATE INDEX IF NOT EXISTS idx_sites_company_id ON sites(company_id);
CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id); CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_usp_user_id ON user_scope_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_usp_module_id ON user_scope_permissions(module_id);
CREATE INDEX IF NOT EXISTS idx_usp_company_id ON user_scope_permissions(company_id);
CREATE INDEX IF NOT EXISTS idx_usp_site_id ON user_scope_permissions(site_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_company
ON user_scope_permissions(user_id, module_id, action, scope_type, company_id)
WHERE scope_type = 'company';
CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_site
ON user_scope_permissions(user_id, module_id, action, scope_type, site_id)
WHERE scope_type = 'site';
CREATE INDEX IF NOT EXISTS idx_api_clients_status ON api_clients(status);
CREATE INDEX IF NOT EXISTS idx_api_clients_expires_at ON api_clients(expires_at);
CREATE INDEX IF NOT EXISTS idx_systems_system_key ON systems(system_key);
CREATE INDEX IF NOT EXISTS idx_modules_module_key ON modules(module_key);
COMMIT; COMMIT;

View File

@@ -0,0 +1,73 @@
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'record_status') THEN
CREATE TYPE record_status AS ENUM ('active','inactive');
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS systems (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
system_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status record_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO systems (system_key, name, status)
VALUES ('member', 'Member Center', 'active')
ON CONFLICT (system_key) DO NOTHING;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'scope_type') THEN
CREATE TYPE scope_type AS ENUM ('company','site');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'permission_action') THEN
CREATE TYPE permission_action AS ENUM ('view','create','update','delete','manage');
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS permission_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status record_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS permission_group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
authentik_sub TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_permission_group_members_group_sub UNIQUE (group_id, authentik_sub)
);
CREATE TABLE IF NOT EXISTS permission_group_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
system TEXT NOT NULL,
module TEXT NOT NULL,
action TEXT NOT NULL,
scope_type TEXT NOT NULL,
scope_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_systems_system_key ON systems(system_key);
CREATE INDEX IF NOT EXISTS idx_pgm_group_id ON permission_group_members(group_id);
CREATE INDEX IF NOT EXISTS idx_pgm_authentik_sub ON permission_group_members(authentik_sub);
CREATE INDEX IF NOT EXISTS idx_pgp_group_id ON permission_group_permissions(group_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_pgp_group_rule
ON permission_group_permissions(group_id, system, module, action, scope_type, scope_id);
COMMIT;

View File

@@ -0,0 +1,27 @@
BEGIN;
-- users / master tables
ALTER TABLE users ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
ALTER TABLE companies ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
ALTER TABLE sites ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
ALTER TABLE systems ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
ALTER TABLE modules ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
ALTER TABLE permission_groups ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
-- api_clients
ALTER TABLE api_clients ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
-- user scoped permissions
ALTER TABLE user_scope_permissions ALTER COLUMN action TYPE VARCHAR(32) USING action::text;
ALTER TABLE user_scope_permissions ALTER COLUMN scope_type TYPE VARCHAR(16) USING scope_type::text;
-- keep check constraint compatible with varchar
ALTER TABLE user_scope_permissions DROP CONSTRAINT IF EXISTS user_scope_permissions_check;
ALTER TABLE user_scope_permissions
ADD CONSTRAINT user_scope_permissions_check
CHECK (
((scope_type = 'company' AND company_id IS NOT NULL AND site_id IS NULL)
OR (scope_type = 'site' AND site_id IS NOT NULL AND company_id IS NULL))
);
COMMIT;

View File

@@ -1,106 +0,0 @@
-- member_center: API 呼叫方白名單表
-- 位置: public schema
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'client_status') THEN
CREATE TYPE client_status AS ENUM ('active', 'inactive');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS api_clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status client_status NOT NULL DEFAULT 'active',
-- 只存 hash不存明文 key
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 IF NOT EXISTS idx_api_clients_status ON api_clients(status);
CREATE INDEX IF NOT EXISTS idx_api_clients_expires_at ON api_clients(expires_at);
CREATE OR REPLACE FUNCTION set_updated_at_api_clients()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_api_clients_set_updated_at'
) THEN
CREATE TRIGGER trg_api_clients_set_updated_at
BEFORE UPDATE ON api_clients
FOR EACH ROW EXECUTE FUNCTION set_updated_at_api_clients();
END IF;
END $$;
-- 建議初始化 2~3 個 clientapi_key_hash 先放占位,後續再更新)
INSERT INTO api_clients (
client_key,
name,
status,
api_key_hash,
allowed_origins,
allowed_ips,
allowed_paths,
rate_limit_per_min
)
VALUES
(
'mkt-backend',
'MKT Backend Service',
'active',
'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH',
'[]'::jsonb,
'[]'::jsonb,
'["/internal/users/upsert-by-sub", "/internal/permissions"]'::jsonb,
600
),
(
'admin-frontend',
'Admin Frontend',
'active',
'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH',
'["https://admin.ose.tw", "https://member.ose.tw"]'::jsonb,
'[]'::jsonb,
'["/admin"]'::jsonb,
300
),
(
'ops-local',
'Ops Local Tooling',
'inactive',
'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH',
'[]'::jsonb,
'["127.0.0.1"]'::jsonb,
'["/internal", "/admin"]'::jsonb,
120
)
ON CONFLICT (client_key) DO NOTHING;
COMMIT;
-- 快速檢查
-- SELECT client_key, status, expires_at, created_at FROM api_clients ORDER BY client_key;

View File

@@ -23,8 +23,8 @@
- 前端任務進度與驗收條件 - 前端任務進度與驗收條件
- `docs/TASKPLAN_BACKEND.md` - `docs/TASKPLAN_BACKEND.md`
- 後端任務進度與驗收條件 - 後端任務進度與驗收條件
- `docs/API_CLIENTS_SQL.sql` - `backend/scripts/init_schema.sql`
- `api_clients` 白名單表與初始資料 SQL - 一次建立完整 schema `api_clients`
- `docs/DB_SCHEMA_SNAPSHOT.md` - `docs/DB_SCHEMA_SNAPSHOT.md`
- 目前資料庫 schema 快照(欄位/索引/約束) - 目前資料庫 schema 快照(欄位/索引/約束)

View File

@@ -1,93 +1,37 @@
# memberapi.ose.tw 後端架構(FastAPI # memberapi.ose.tw 後端架構(公司/品牌站台/會員
## 1. 目標與邊界 ## 核心主檔(對齊 DB Schema
- 網域:`memberapi.ose.tw` - `users`:會員
- 角色會員中心後端真相來源User + Permission - `companies`:公司
- 範圍: - `sites`:品牌站台(隸屬 company
- user upsert`authentik_sub` 為跨系統主鍵 - `systems`系統層member/mkt/...
- permission grant/revoke - `modules`:模組(使用 `system.module` key
- permission snapshot 提供給其他系統
- 不在本服務處理:
- Authentik OIDC 流程頁與 UI
- 前端互動邏輯
## 2. 技術棧 ## 權限模型
- Python 3.12 - 直接權限:`user_scope_permissions`
- FastAPI - 群組權限:`permission_groups` + `permission_group_members` + `permission_group_permissions`
- SQLAlchemy 2.0 - Snapshot 回傳合併「user 直接 + group」去重
- PostgreSQLpsycopg
- Pydantic Settings
## 3. 後端目錄(已建立) ## 授權層級
- `backend/app/main.py` - `system` 必填
- `backend/app/api/` - `module` 選填
- `internal.py` - 有值:`{system}.{module}`(例:`mkt.campaign`
- `admin.py` - 無值:系統層權限,使用 `system.__system__`
- `backend/app/core/config.py`
- `backend/app/db/session.py`
- `backend/app/models/`
- `user.py`
- `permission.py`
- `api_client.py`
- `backend/app/repositories/`
- `users_repo.py`
- `permissions_repo.py`
- `backend/app/security/api_client_auth.py`
- `backend/scripts/init_schema.sql`
- `backend/.env.example`
- `backend/.env.production.example`
## 4. 資料模型 ## 主要 API
- `users`
- `id`, `authentik_sub`(unique), `email`, `display_name`, `is_active`, timestamps
- `permissions`
- `id`, `user_id`, `scope_type`, `scope_id`, `module`, `action`, `created_at`
- unique constraint: `(user_id, scope_type, scope_id, module, action)`
- `api_clients`(由 `docs/API_CLIENTS_SQL.sql` 建立)
- `client_key`, `api_key_hash`, `status`, allowlist, expires/rate-limit 欄位
## 5. API 設計MVP
- 健康檢查
- `GET /healthz`
- 使用者路由Bearer token
- `GET /me` - `GET /me`
- `GET /me/permissions/snapshot` - `GET /me/permissions/snapshot`
- Bearer token 由 Authentik JWT + JWKS 驗證,並以 `sub` 自動 upsert user - `POST /admin/permissions/grant|revoke`
- 內部路由(系統對系統) - `GET|POST /admin/systems`
- `POST /internal/users/upsert-by-sub` - `GET|POST /admin/modules`
- `GET /internal/permissions/{authentik_sub}/snapshot` - `GET|POST /admin/companies`
- `POST /internal/authentik/users/ensure` - `GET|POST /admin/sites`
- header: `X-Internal-Secret` - `GET /admin/members`
- 管理路由(後台/API client - `GET|POST /admin/permission-groups`
- `POST /admin/permissions/grant` - `POST|DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}`
- `POST /admin/permissions/revoke` - `POST /admin/permission-groups/{group_key}/permissions/grant|revoke`
- headers: `X-Client-Key`, `X-API-Key` - `GET /internal/systems|modules|companies|sites|members`
## 6. 安全策略 ## DB Migration
- `admin` 路由強制 API client 驗證: - 初始化:`backend/scripts/init_schema.sql`
- client 必須存在且 `status=active` - 舊庫補齊:`backend/scripts/migrate_align_company_site_member_system.sql`
- `expires_at` 未過期
- `api_key_hash` 驗證(支援 `sha256:<hex>` 與 bcrypt/argon2
- allowlist 驗證origin/ip/path
- `internal` 路由使用 `X-Internal-Secret` 做服務間驗證
- `me` 路由使用 Authentik Access Token 驗證:
- 使用 `AUTHENTIK_JWKS_URL``AUTHENTIK_ISSUER` 推導 JWKS
- 可選 `AUTHENTIK_AUDIENCE` 驗證 aud claim
- Authentik Admin 整合:
- 使用 `AUTHENTIK_BASE_URL + AUTHENTIK_ADMIN_TOKEN`
- 可透過 `/internal/authentik/users/ensure` 建立或更新 Authentik user
- 建議上線前:
-`.env` 範本中的明文密碼改為部署平台 secret
- API key 全部改為 argon2/bcrypt hash
## 7. 與其他系統資料流
1. mkt/admin 後端登入後,以 token `sub` 呼叫 `/internal/users/upsert-by-sub`
2. 權限調整走 `/admin/permissions/grant|revoke`
3. 需要授權判斷時,呼叫 `/internal/permissions/{sub}/snapshot`
4. mkt 系統可本地快取 snapshot並做定時補償
## 8. 下一階段(建議)
- 加入 Alembic migration
- 為 permission/action 加 enum 與驗證規則
- 增加 audit log誰在何時授權/撤銷)
- 加入 rate-limit 與可觀測性metrics + request id

View File

@@ -10,9 +10,9 @@ cp .env.example .env
``` ```
## 2. 建立資料表 ## 2. 建立資料表
1. 先執行 `member.ose.tw/docs/API_CLIENTS_SQL.sql` 1. 先執行 `member.ose.tw/backend/scripts/init_schema.sql`(已含 `api_clients`
2. 再執行 `member.ose.tw/backend/scripts/init_schema.sql` 2. 若是舊資料庫,補跑 `member.ose.tw/backend/scripts/migrate_align_company_site_member_system.sql`
3. 若是舊資料庫,補 `member.ose.tw/backend/scripts/migrate_add_authentik_user_id.sql` 3. 若是舊資料庫,`member.ose.tw/backend/scripts/migrate_add_authentik_user_id.sql`
## 3. 啟動服務 ## 3. 啟動服務
```bash ```bash

View File

@@ -2,144 +2,99 @@
Base URL`https://memberapi.ose.tw` Base URL`https://memberapi.ose.tw`
## 0. 帳號密碼登入 ## 0. OIDC 登入
### POST `/auth/login` - `GET /auth/oidc/url?redirect_uri=...`
Request: - `POST /auth/oidc/exchange`
```json
{
"username": "your-authentik-username",
"password": "your-password"
}
```
200 Response:
```json
{
"access_token": "<jwt>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email"
}
```
401 Response:
```json
{ "detail": "invalid_username_or_password" }
```
## 1. 使用者資訊 ## 1. 使用者資訊
### GET `/me` - `GET /me`
Headers: - `GET /me/permissions/snapshot`
- `Authorization: Bearer <access_token>`
200 Response: `permissions` item:
```json ```json
{ {
"sub": "authentik-sub-123", "scope_type": "company|site",
"email": "user@example.com", "scope_id": "company_key_or_site_key",
"display_name": "User Name" "system": "mkt",
} "module": "mkt.campaign",
```
401 Error:
```json
{ "detail": "missing_bearer_token" }
```
```json
{ "detail": "invalid_bearer_token" }
```
## 2. 我的權限快照
### GET `/me/permissions/snapshot`
Headers:
- `Authorization: Bearer <access_token>`
200 Response:
```json
{
"authentik_sub": "authentik-sub-123",
"permissions": [
{
"scope_type": "site",
"scope_id": "tw-main",
"module": "campaign",
"action": "view" "action": "view"
} }
]
}
``` ```
## 3. Grant 權限 ## 2. 權限User 直接授權)
Headers:
- `X-Client-Key`
- `X-API-Key`
### POST `/admin/permissions/grant` ### POST `/admin/permissions/grant`
Headers:
- `X-Client-Key: <client_key>`
- `X-API-Key: <plain_api_key>`
Request:
```json ```json
{ {
"authentik_sub": "authentik-sub-123", "authentik_sub": "authentik-sub",
"email": "user@example.com", "email": "user@example.com",
"display_name": "User Name", "display_name": "User",
"scope_type": "site", "scope_type": "company",
"scope_id": "tw-main", "scope_id": "ose-main",
"system": "mkt",
"module": "campaign", "module": "campaign",
"action": "view" "action": "view"
} }
``` ```
200 Response:
```json
{
"permission_id": "uuid",
"result": "granted"
}
```
## 4. Revoke 權限
### POST `/admin/permissions/revoke` ### POST `/admin/permissions/revoke`
Headers:
- `X-Client-Key: <client_key>`
- `X-API-Key: <plain_api_key>`
Request:
```json ```json
{ {
"authentik_sub": "authentik-sub-123", "authentik_sub": "authentik-sub",
"scope_type": "site", "scope_type": "site",
"scope_id": "tw-main", "scope_id": "tw-main",
"system": "mkt",
"module": "campaign", "module": "campaign",
"action": "view" "action": "view"
} }
``` ```
200 Response: 說明:
```json - `module` 可省略,代表系統層權限,後端會使用 `system.__system__`
{ - `module` 有值時會組成 `{system}.{module}` 存入(例如 `mkt.campaign`)。
"deleted": 1,
"result": "revoked"
}
```
404 Response: ## 3. 主資料管理admin
```json Headers:
{ "detail": "user_not_found" } - `X-Client-Key`
``` - `X-API-Key`
## 5. Health Check - `GET/POST /admin/systems`
### GET `/healthz` - `GET/POST /admin/modules`
200 Response: - `GET/POST /admin/companies`
```json - `GET/POST /admin/sites`
{ "status": "ok" } - `GET /admin/members`
```
## 6. 常見錯誤碼 ## 4. 權限群組(一組權限綁多個 user
Headers:
- `X-Client-Key`
- `X-API-Key`
- `GET/POST /admin/permission-groups`
- `POST /admin/permission-groups/{group_key}/members/{authentik_sub}`
- `DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}`
- `POST /admin/permission-groups/{group_key}/permissions/grant`
- `POST /admin/permission-groups/{group_key}/permissions/revoke`
群組授權 payload 與 user 授權 payload 相同(用 `system/module/scope/action`)。
## 5. Internal 查詢 API其他系統
Headers:
- `X-Internal-Secret`
- `GET /internal/systems`
- `GET /internal/modules`
- `GET /internal/companies`
- `GET /internal/sites`
- `GET /internal/members`
- `GET /internal/permissions/{authentik_sub}/snapshot`
## 6. 常見錯誤
- `401 invalid_client` - `401 invalid_client`
- `401 invalid_api_key` - `401 invalid_api_key`
- `401 client_expired` - `401 invalid_internal_secret`
- `403 origin_not_allowed` - `404 system_not_found`
- `403 ip_not_allowed` - `404 company_not_found`
- `403 path_not_allowed` - `404 site_not_found`
- `503 internal_secret_not_configured`
- `503 authentik_admin_not_configured`

View File

@@ -0,0 +1,101 @@
# Frontend 交辦清單Schema v2✅ 已完成
## 目標
前端實現對應後端新模型:
- 公司companies
- 品牌站台sites
- 會員users
- 系統/模組systems/modules
- 權限群組permission-groups
## 既有頁面調整
### 1) 權限管理頁 `/admin/permissions` ✅
- [x] Grant/Revoke payload 改為:
- [x] `scope_type`: `company``site`(下拉選單)
- [x] `scope_id`: `company_key``site_key`
- [x] `system`: 必填(例如 `mkt`
- [x] `module`: 選填(空值代表系統層權限)
- [x] `action`
- [x] 表單新增 `system` 欄位
- [x] `module` 改成可選
### 2) 我的權限頁 `/me/permissions` ✅
- [x] 表格新增顯示欄位:
- [x] `scope_type`
- [x] `scope_id`
- [x] `system`
- [x] `module`
- [x] `action`
## 新增頁面 ✅
### 3) 系統管理 `/admin/systems` ✅
- [x] 列表:`GET /admin/systems`
- [x] 新增:`POST /admin/systems`
- [x] 表格顯示 system_key 與 name
- [x] Dialog 表單新增系統
### 4) 模組管理 `/admin/modules` ✅
- [x] 列表:`GET /admin/modules`
- [x] 新增:`POST /admin/modules`
- [x] `system_key`
- [x] `module_key`
- [x] `name`
- [x] 表格顯示三個欄位
- [x] Dialog 表單新增模組
### 5) 公司管理 `/admin/companies` ✅
- [x] 列表:`GET /admin/companies`
- [x] 新增:`POST /admin/companies`
- [x] 表格顯示 company_key 與 name
- [x] Dialog 表單新增公司
### 6) 站台管理 `/admin/sites` ✅
- [x] 列表:`GET /admin/sites`
- [x] 新增:`POST /admin/sites`
- [x] `site_key`
- [x] `company_key`
- [x] `name`
- [x] 表格顯示三個欄位
- [x] Dialog 表單新增站台
### 7) 會員列表 `/admin/members` ✅
- [x] 列表:`GET /admin/members`
- [x] 表格顯示 authentik_sub、email、display_name
- [x] 可重新整理
### 8) 權限群組 `/admin/permission-groups` ✅
- [x] 群組管理 Tab
- [x] 列表:`GET /admin/permission-groups`
- [x] 新增:`POST /admin/permission-groups`
- [x] Dialog 表單新增群組
- [x] 綁定會員 Tab
- [x] `POST /admin/permission-groups/{group_key}/members/{authentik_sub}`
- [x] UI 支援群組選擇 + authentik_sub 輸入 + 加入按鈕
- [x] 群組授權 Tab
- [x] `POST /admin/permission-groups/{group_key}/permissions/grant`
- [x] `POST /admin/permission-groups/{group_key}/permissions/revoke`
- [x] UI 支援選擇群組、輸入權限資訊、grant/revoke 按鈕
## 共用資料管理 ✅
- [x] admin.js store 實現:
- [x] 統一載入 systems、modules、companies、sites
- [x] 供各管理頁使用,避免重複 API 呼叫
## 認證(管理 API
- [x] 所有 `/admin/*` API 一律帶:
- [x] `X-Client-Key`
- [x] `X-API-Key`
- [x] axios adminHttp client 自動注入 headers
## 驗收條件 ✅
- [x] 可以新增 system/module/company/site
- [x] 可以做 user 直接 grant/revoke新 payload
- [x] 可以建立 permission-group、加會員、做群組 grant/revoke
- [x] `/me/permissions/snapshot` 能看到所有權限欄位scope_type/scope_id/system/module/action
## 完成日期
- 開始2026-03-29
- 完成2026-03-30
- 提交 Commit`c4b9789`

View File

@@ -0,0 +1,94 @@
# 組織與會員管理規劃(給前端/後端協作)
## 1. 目前狀態(你現在看到空白是正常)
- 已完成:
- Authentik 登入
- `/me` 基本個人資料
- 權限 grant/revoke 與 snapshot
- 尚未完成:
- 公司組織OrganizationCRUD
- 會員Member清單/新增/編輯/停用
- 會員與組織關聯管理
## 2. 建議產品資訊架構IA
- `我的資料`:目前登入者基本資訊、登出
- `我的權限`:目前登入者權限快照
- `組織管理`:公司清單、建立公司、編輯公司、狀態切換
- `會員管理`:會員清單、邀請/建立會員、編輯會員、停用會員、指派組織
- `權限管理`:保留現有 grant/revoke可作為管理員進階頁
## 3. 後端 APIv1已開
### Organizationadmin
- `GET /admin/organizations`
- `POST /admin/organizations`
- `GET /admin/organizations/{org_id}`
- `PATCH /admin/organizations/{org_id}`
- `POST /admin/organizations/{org_id}/activate`
- `POST /admin/organizations/{org_id}/deactivate`
### Memberadmin
- `GET /admin/members`
- `POST /admin/members`
- `GET /admin/members/{member_id}`
- `PATCH /admin/members/{member_id}`
- `POST /admin/members/{member_id}/activate`
- `POST /admin/members/{member_id}/deactivate`
### Member x Organizationadmin
- `GET /admin/members/{member_id}/organizations`
- `POST /admin/members/{member_id}/organizations/{org_id}`
- `DELETE /admin/members/{member_id}/organizations/{org_id}`
### Internal 查詢 API給其他系統
- `GET /internal/members`
- `GET /internal/members/by-sub/{authentik_sub}`
- `GET /internal/organizations`
- `GET /internal/organizations/by-code/{org_code}`
- `GET /internal/members/{member_id}/organizations`
## 4. 建議資料表(最小可行)
- `organizations`
- `id` (uuid)
- `org_code` (unique)
- `name`
- `tax_id` (nullable)
- `status` (`active|inactive`)
- `created_at`, `updated_at`
- `members`
- `id` (uuid)
- `authentik_sub` (unique)
- `email`
- `display_name`
- `status` (`active|inactive`)
- `created_at`, `updated_at`
- `member_organizations`
- `member_id`
- `organization_id`
- unique(`member_id`, `organization_id`)
## 5. 前端頁面需求(給另一個 AI
- `/admin/organizations`
- 表格 + 查詢 + 新增 Dialog + 編輯 Dialog + 啟停用
- `/admin/members`
- 表格 + 查詢 + 新增 Dialog + 編輯 Dialog + 啟停用
- `/admin/members/:id/organizations`
- 左側會員資訊,右側組織綁定清單 + 加入/移除
## 6. 權限模型(建議)
- `org.manage`:組織管理
- `member.manage`:會員管理
- `permission.manage`:權限管理
可映射到現有權限欄位:
- `scope_type=global`
- `scope_id=member-center`
- `module=organization|member|permission`
- `action=view|create|update|deactivate|grant|revoke`
## 7. 驗收標準
- 可以建立/修改/停用組織
- 可以建立/修改/停用會員
- 可以將會員加入/移出組織
- UI 顯示成功/失敗訊息,並可重新整理資料
- 所有管理 API 都有管理員金鑰驗證(`X-Client-Key` + `X-API-Key`

View File

@@ -1,33 +1,60 @@
# Frontend TaskPlan # Frontend TaskPlan
## 目標 ## 目標
完成 member.ose.tw 前端Vue3 + JS + Vite + Element Plus + Tailwind可獨立完成登入、查看個人資料查看權限管理授權 完成 member.ose.tw 前端Vue3 + JS + Vite + Element Plus + Tailwind支援 OIDC 登入、個人資料查看權限管理Schema v2
## 已完成(依目前程式) ## Phase 1: 基礎框架 ✅
- [x] Vite + Vue3 專案結構 - [x] Vite + Vue3 專案結構
- [x] Element Plus + Tailwind 基礎接入 - [x] Element Plus + Tailwind 基礎接入
- [x] Router 與頁面骨架 - [x] Router 與頁面骨架
- [x] `LoginPage`token 輸入)
- [x] `MePage``GET /me`
- [x] `PermissionSnapshotPage``GET /me/permissions/snapshot`
- [x] `PermissionAdminPage`grant/revoke
- [x] Pinia storeauth + permission - [x] Pinia storeauth + permission
- [x] Axios 分離 user/admin client - [x] Axios 分離 user/admin client
- [x] Production build 可通過 - [x] Production build 可通過
## 進行中(建議近期) ## Phase 2: OIDC 登入流程 ✅
- [ ] 補路由守衛策略(是否限制 `/admin/permissions` 需登入 - [x] `LoginPage`OIDC 前往按鈕,跳轉 Authentik
- [x] `AuthCallbackPage`(接收 code交換 access_token
- [x] Token 自動存儲與路由守衛
- [x] 401 時自動導向重新登入
## Phase 3: 用戶資訊與權限 ✅
- [x] `MePage``GET /me` 顯示個人資料)
- [x] `PermissionSnapshotPage``GET /me/permissions/snapshot`
- [x] 表格新增 `system` 欄位Schema v2
## Phase 4: 管理員授權v1
- [x] `PermissionAdminPage`(直接 grant/revoke 使用者)
- [x] Payload 新增 `system` 必填、`module` 改為選填
- [x] `scope_type` 改為 company/site 下拉選單
## Phase 5: Schema v2 管理頁面 ✅
- [x] API 層systems、modules、companies、sites、members、permission-groups
- [x] Storeadmin.js統一管理公共清單
- [x] 6 個新管理頁面:
- [x] `/admin/systems`(系統 CRUD
- [x] `/admin/modules`(模組 CRUD
- [x] `/admin/companies`(公司 CRUD
- [x] `/admin/sites`(站台 CRUD
- [x] `/admin/members`(會員列表)
- [x] `/admin/permission-groups`(群組 CRUD + 綁會員 + 群組授權)
- [x] 導覽列加入管理員群組下拉菜單
## 進行中(下一階段)
- [ ] 組織與會員管理(`ORG_MEMBER_MANAGEMENT_PLAN.md`
- [ ] 路由守衛策略完善(是否限制某些管理頁)
- [ ] 錯誤訊息 i18n 與統一顯示格式 - [ ] 錯誤訊息 i18n 與統一顯示格式
- [ ] 新增操作完成後自動刷新快照的 UX
## 待辦(上線前) ## 待辦(上線前)
- [ ] 增加 e2e / UI smoke 測試 - [ ] 增加 e2e / UI smoke 測試
- [ ] 優化 bundle size目前 main chunk 偏大 - [ ] 優化 bundle size目前 main chunk 1.2MB,需考慮 lazy loading
- [ ] 加入環境切換策略dev/staging/prod - [ ] 加入環境切換策略dev/staging/prod
- [ ] 加入登入來源與 token 取得說明頁 - [ ] 加入登入來源與 token 取得說明頁
## 驗收條件 ## 驗收條件Schema v2
- [ ] 未登入時導向登入頁行為正確 - [x] 未登入時導向登入頁 → OIDC 流程 ✅
- [ ] 登入後可穩定讀取 `/me` 與快照 - [x] 登入後可穩定讀取 `/me` 與快照
- [ ] 管理頁 grant/revoke 成功與錯誤提示完整 - [x] 可新增 system/module/company/site ✅
- [ ] 與後端契約文件一致(`FRONTEND_API_CONTRACT.md` - [x] 可做用戶直接 grant/revoke新 payload
- [x] 可建立 permission-group、加會員、群組 grant/revoke ✅
- [x] `/me/permissions/snapshot` 表格可顯示 system + module + action ✅
- [x] 與後端契約文件一致 ✅

View File

@@ -13,9 +13,11 @@
## 任務管理 ## 任務管理
- `docs/TASKPLAN_FRONTEND.md` - `docs/TASKPLAN_FRONTEND.md`
- `docs/TASKPLAN_BACKEND.md` - `docs/TASKPLAN_BACKEND.md`
- `docs/ORG_MEMBER_MANAGEMENT_PLAN.md`(公司組織/會員管理規劃)
- `docs/FRONTEND_HANDOFF_SCHEMA_V2.md`(前端交辦清單,直接給另一隻 AI
## SQL 與配置 ## SQL 與配置
- `docs/API_CLIENTS_SQL.sql` - `backend/scripts/init_schema.sql`
- `docs/DB_SCHEMA_SNAPSHOT.md` - `docs/DB_SCHEMA_SNAPSHOT.md`
## 給前端 AI 的一句話交接 ## 給前端 AI 的一句話交接

View File

@@ -17,13 +17,26 @@
> >
我的權限 我的權限
</router-link> </router-link>
<router-link
to="/admin/permissions" <div class="flex items-center gap-4 border-l border-gray-300 pl-6">
class="text-sm text-gray-600 hover:text-blue-600 transition-colors" <el-dropdown @command="handleAdminNav">
active-class="text-blue-600 font-medium" <span class="text-sm text-gray-600 hover:text-blue-600 cursor-pointer transition-colors">
> 管理員 <el-icon class="el-icon--right"><arrow-down /></el-icon>
權限管理 </span>
</router-link> <template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="permissions">權限管理</el-dropdown-item>
<el-dropdown-divider />
<el-dropdown-item command="systems">系統管理</el-dropdown-item>
<el-dropdown-item command="modules">模組管理</el-dropdown-item>
<el-dropdown-item command="companies">公司管理</el-dropdown-item>
<el-dropdown-item command="sites">站台管理</el-dropdown-item>
<el-dropdown-item command="members">會員列表</el-dropdown-item>
<el-dropdown-item command="groups">權限群組</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div> </div>
<el-button v-if="authStore.isLoggedIn" size="small" @click="logout">登出</el-button> <el-button v-if="authStore.isLoggedIn" size="small" @click="logout">登出</el-button>
</nav> </nav>
@@ -37,6 +50,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { ArrowDown } from '@element-plus/icons-vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -44,6 +58,19 @@ const authStore = useAuthStore()
const isLoginPage = computed(() => route.name === 'login') const isLoginPage = computed(() => route.name === 'login')
function handleAdminNav(command) {
const routes = {
permissions: '/admin/permissions',
systems: '/admin/systems',
modules: '/admin/modules',
companies: '/admin/companies',
sites: '/admin/sites',
members: '/admin/members',
groups: '/admin/permission-groups'
}
router.push(routes[command])
}
function logout() { function logout() {
authStore.logout() authStore.logout()
router.push('/login') router.push('/login')

View File

@@ -2,3 +2,9 @@ import { userHttp } from './http'
export const loginWithPassword = (username, password) => export const loginWithPassword = (username, password) =>
userHttp.post('/auth/login', { username, password }) userHttp.post('/auth/login', { username, password })
export const getOidcAuthorizeUrl = (redirectUri) =>
userHttp.get('/auth/oidc/url', { params: { redirect_uri: redirectUri } })
export const exchangeOidcCode = (code, redirectUri) =>
userHttp.post('/auth/oidc/exchange', { code, redirect_uri: redirectUri })

View File

@@ -0,0 +1,4 @@
import { adminHttp } from './http'
export const getCompanies = () => adminHttp.get('/admin/companies')
export const createCompany = (data) => adminHttp.post('/admin/companies', data)

View File

@@ -0,0 +1,3 @@
import { adminHttp } from './http'
export const getMembers = () => adminHttp.get('/admin/members')

View File

@@ -0,0 +1,4 @@
import { adminHttp } from './http'
export const getModules = () => adminHttp.get('/admin/modules')
export const createModule = (data) => adminHttp.post('/admin/modules', data)

View File

@@ -0,0 +1,16 @@
import { adminHttp } from './http'
export const getPermissionGroups = () => adminHttp.get('/admin/permission-groups')
export const createPermissionGroup = (data) => adminHttp.post('/admin/permission-groups', data)
export const addMemberToGroup = (groupKey, authentikSub) =>
adminHttp.post(`/admin/permission-groups/${groupKey}/members/${authentikSub}`)
export const removeMemberFromGroup = (groupKey, authentikSub) =>
adminHttp.delete(`/admin/permission-groups/${groupKey}/members/${authentikSub}`)
export const groupGrant = (groupKey, data) =>
adminHttp.post(`/admin/permission-groups/${groupKey}/permissions/grant`, data)
export const groupRevoke = (groupKey, data) =>
adminHttp.post(`/admin/permission-groups/${groupKey}/permissions/revoke`, data)

View File

@@ -0,0 +1,4 @@
import { adminHttp } from './http'
export const getSites = () => adminHttp.get('/admin/sites')
export const createSite = (data) => adminHttp.post('/admin/sites', data)

View File

@@ -0,0 +1,4 @@
import { adminHttp } from './http'
export const getSystems = () => adminHttp.get('/admin/systems')
export const createSystem = (data) => adminHttp.post('/admin/systems', data)

View File

@@ -0,0 +1,73 @@
<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
if (!code) {
error.value = '缺少驗證代碼,登入失敗'
setTimeout(() => router.push('/login'), 2000)
return
}
const redirectUri = `${window.location.origin}/auth/callback`
const res = await exchangeOidcCode(code, redirectUri)
const { access_token } = res.data
if (!access_token) {
error.value = '無法取得 access token'
setTimeout(() => router.push('/login'), 2000)
return
}
// 存 token 並取得使用者資料
authStore.setToken(access_token)
await authStore.fetchMe()
// 導向原頁面或預設的 /me
const redirect = sessionStorage.getItem('post_login_redirect') || '/me'
sessionStorage.removeItem('post_login_redirect')
router.push(redirect)
} catch (err) {
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

@@ -8,24 +8,6 @@
</div> </div>
</template> </template>
<el-form @submit.prevent="handleLogin">
<el-form-item label="帳號">
<el-input
v-model="username"
placeholder="請輸入 Authentik username / email"
clearable
/>
</el-form-item>
<el-form-item label="密碼">
<el-input
v-model="password"
type="password"
placeholder="請輸入密碼"
clearable
show-password
/>
</el-form-item>
<el-alert <el-alert
v-if="error" v-if="error"
:title="error" :title="error"
@@ -35,55 +17,44 @@
class="mb-4" class="mb-4"
/> />
<el-form-item>
<el-button <el-button
type="primary" type="primary"
native-type="submit"
class="w-full" class="w-full"
:loading="loading" :loading="loading"
:disabled="!username.trim() || !password.trim()" @click="handleOidcLogin"
> >
登入 前往 Authentik 登入
</el-button> </el-button>
</el-form-item>
</el-form>
<p class="text-xs text-gray-400 text-center mt-2">登入成功後 access token 會存於本機 localStorage</p> <div class="mt-4 text-xs text-gray-400 text-center space-y-1">
<p>會跳轉到 Authentik 輸入帳號密碼成功後自動回來</p>
<p>登入成功後 access token 會存於本機 localStorage</p>
</div>
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { getOidcAuthorizeUrl } from '@/api/auth'
import { loginWithPassword } from '@/api/auth'
const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
async function handleLogin() { async function handleOidcLogin() {
if (!username.value.trim() || !password.value.trim()) return
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const loginRes = await loginWithPassword(username.value.trim(), password.value)
authStore.setToken(loginRes.data.access_token)
await authStore.fetchMe()
const redirect = route.query.redirect || '/me' const redirect = route.query.redirect || '/me'
router.push(redirect) sessionStorage.setItem('post_login_redirect', typeof redirect === 'string' ? redirect : '/me')
const callbackUrl = `${window.location.origin}/auth/callback`
const res = await getOidcAuthorizeUrl(callbackUrl)
window.location.href = res.data.authorize_url
} catch (err) { } catch (err) {
authStore.logout()
const detail = err.response?.data?.detail const detail = err.response?.data?.detail
if (detail === 'invalid_username_or_password') { if (detail === 'authentik_login_not_configured') {
error.value = '帳號或密碼錯誤'
} else if (detail === 'authentik_login_not_configured') {
error.value = '後端尚未設定 Authentik 登入參數' error.value = '後端尚未設定 Authentik 登入參數'
} else { } else {
error.value = '登入失敗,請稍後再試' error.value = '登入失敗,請稍後再試'

View File

@@ -0,0 +1,96 @@
<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="showDialog = 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">
<el-empty v-if="companies.length === 0" slot="empty" description="目前無公司" />
<el-table-column prop="company_key" label="Company Key" width="200" />
<el-table-column prop="name" label="名稱" min-width="180" />
</el-table>
<!-- 新增 Dialog -->
<el-dialog v-model="showDialog" title="新增公司" @close="resetForm">
<el-form :model="form" label-width="100px">
<el-form-item label="Company Key">
<el-input v-model="form.company_key" placeholder="company-001" />
</el-form-item>
<el-form-item label="名稱">
<el-input v-model="form.name" placeholder="公司名稱" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { getCompanies, createCompany } from '@/api/companies'
const companies = ref([])
const loading = ref(false)
const error = ref(false)
const errorMsg = ref('')
const showDialog = ref(false)
const submitting = ref(false)
const form = ref({ company_key: '', name: '' })
async function load() {
loading.value = true
error.value = false
try {
const res = await getCompanies()
companies.value = res.data || []
} catch (err) {
error.value = true
errorMsg.value = '載入失敗,請稍後再試'
} finally {
loading.value = false
}
}
function resetForm() {
form.value = { company_key: '', name: '' }
}
async function handleCreate() {
if (!form.value.company_key || !form.value.name) {
ElMessage.warning('請填寫完整資訊')
return
}
submitting.value = true
try {
await createCompany(form.value)
ElMessage.success('新增成功')
showDialog.value = false
resetForm()
await load()
} catch (err) {
ElMessage.error('新增失敗,請稍後再試')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>

View File

@@ -0,0 +1,53 @@
<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="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">
<el-empty v-if="members.length === 0" slot="empty" description="目前無會員" />
<el-table-column prop="authentik_sub" label="Authentik Sub" min-width="200" />
<el-table-column prop="email" label="Email" min-width="200" />
<el-table-column prop="display_name" label="顯示名稱" width="150" />
</el-table>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { getMembers } from '@/api/members'
const members = ref([])
const loading = ref(false)
const error = ref(false)
const errorMsg = ref('')
async function load() {
loading.value = true
error.value = false
try {
const res = await getMembers()
members.value = res.data || []
} catch (err) {
error.value = true
errorMsg.value = '載入失敗,請稍後再試'
} finally {
loading.value = false
}
}
onMounted(load)
</script>

View File

@@ -0,0 +1,100 @@
<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="showDialog = 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="modules" stripe border class="w-full shadow-sm">
<el-empty v-if="modules.length === 0" slot="empty" description="目前無模組" />
<el-table-column prop="system_key" label="System Key" width="140" />
<el-table-column prop="module_key" label="Module Key" width="160" />
<el-table-column prop="name" label="名稱" min-width="180" />
</el-table>
<!-- 新增 Dialog -->
<el-dialog v-model="showDialog" title="新增模組" @close="resetForm">
<el-form :model="form" label-width="120px">
<el-form-item label="System Key">
<el-input v-model="form.system_key" placeholder="mkt" />
</el-form-item>
<el-form-item label="Module Key">
<el-input v-model="form.module_key" placeholder="campaign" />
</el-form-item>
<el-form-item label="名稱">
<el-input v-model="form.name" placeholder="行銷活動" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { getModules, createModule } from '@/api/modules'
const modules = ref([])
const loading = ref(false)
const error = ref(false)
const errorMsg = ref('')
const showDialog = ref(false)
const submitting = ref(false)
const form = ref({ system_key: '', module_key: '', name: '' })
async function load() {
loading.value = true
error.value = false
try {
const res = await getModules()
modules.value = res.data || []
} catch (err) {
error.value = true
errorMsg.value = '載入失敗,請稍後再試'
} finally {
loading.value = false
}
}
function resetForm() {
form.value = { system_key: '', module_key: '', name: '' }
}
async function handleCreate() {
if (!form.value.system_key || !form.value.module_key || !form.value.name) {
ElMessage.warning('請填寫完整資訊')
return
}
submitting.value = true
try {
await createModule(form.value)
ElMessage.success('新增成功')
showDialog.value = false
resetForm()
await load()
} catch (err) {
ElMessage.error('新增失敗,請稍後再試')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>

View File

@@ -0,0 +1,296 @@
<template>
<div>
<h2 class="text-xl font-bold text-gray-800 mb-6">權限群組管理</h2>
<!-- 認證 -->
<el-card class="mb-6 shadow-sm">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium text-gray-700">管理員認證</span>
<el-tag v-if="credsSaved" type="success" size="small">已儲存session</el-tag>
<el-tag v-else type="warning" size="small">未設定</el-tag>
</div>
</template>
<el-form :model="credsForm" inline>
<el-form-item label="X-Client-Key">
<el-input v-model="credsForm.clientKey" placeholder="client key" style="width: 220px" show-password />
</el-form-item>
<el-form-item label="X-API-Key">
<el-input v-model="credsForm.apiKey" placeholder="api key" style="width: 220px" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveCreds">儲存認證</el-button>
<el-button v-if="credsSaved" @click="clearCreds" class="ml-2">清除</el-button>
</el-form-item>
</el-form>
</el-card>
<el-tabs v-model="activeTab" type="border-card" class="shadow-sm">
<!-- Groups Tab -->
<el-tab-pane label="群組管理" name="groups">
<div class="mt-4">
<el-button v-if="credsSaved" type="primary" @click="showCreateGroup = true" :icon="Plus" class="mb-4">
新增群組
</el-button>
<p v-if="!credsSaved" class="text-xs text-yellow-600 mb-4">請先設定管理員認證</p>
<el-skeleton v-if="loadingGroups" :rows="4" animated />
<el-table v-else :data="groups" stripe border class="w-full">
<el-empty v-if="groups.length === 0" slot="empty" description="目前無群組" />
<el-table-column prop="group_key" label="Group Key" width="180" />
<el-table-column prop="name" label="群組名稱" min-width="200" />
</el-table>
</div>
</el-tab-pane>
<!-- Members Tab -->
<el-tab-pane label="綁定會員" name="members" :disabled="!credsSaved">
<div class="mt-4">
<el-form :model="memberForm" label-width="120px" class="max-w-xl mb-4">
<el-form-item label="Group Key">
<el-select v-model="memberForm.groupKey" placeholder="選擇群組">
<el-option v-for="g in groups" :key="g.group_key" :label="`${g.name} (${g.group_key})`" :value="g.group_key" />
</el-select>
</el-form-item>
<el-form-item label="Authentik Sub">
<el-input v-model="memberForm.authentikSub" placeholder="authentik-sub-xxx" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="addingMember" @click="handleAddMember" :disabled="!memberForm.groupKey || !memberForm.authentikSub">
加入群組
</el-button>
</el-form-item>
</el-form>
<p v-if="memberError" class="text-red-600 text-sm mb-2">{{ memberError }}</p>
<p v-if="memberSuccess" class="text-green-600 text-sm mb-2">{{ memberSuccess }}</p>
</div>
</el-tab-pane>
<!-- Permissions Tab -->
<el-tab-pane label="群組授權" name="permissions" :disabled="!credsSaved">
<div class="mt-4">
<el-form :model="groupPermForm" label-width="120px" class="max-w-xl mb-4">
<el-form-item label="Group Key">
<el-select v-model="groupPermForm.groupKey" placeholder="選擇群組">
<el-option v-for="g in groups" :key="g.group_key" :label="`${g.name} (${g.group_key})`" :value="g.group_key" />
</el-select>
</el-form-item>
<el-form-item label="Scope Type">
<el-select v-model="groupPermForm.scope_type" placeholder="company or site">
<el-option label="Company" value="company" />
<el-option label="Site" value="site" />
</el-select>
</el-form-item>
<el-form-item label="Scope ID">
<el-input v-model="groupPermForm.scope_id" placeholder="company_key or site_key" />
</el-form-item>
<el-form-item label="系統">
<el-input v-model="groupPermForm.system" placeholder="mkt" />
</el-form-item>
<el-form-item label="模組(選填)">
<el-input v-model="groupPermForm.module" placeholder="campaign" clearable />
</el-form-item>
<el-form-item label="操作">
<el-input v-model="groupPermForm.action" placeholder="view" />
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="grantingGroupPerm"
@click="handleGroupGrant"
:disabled="!groupPermForm.groupKey || !groupPermForm.scope_type || !groupPermForm.scope_id || !groupPermForm.system || !groupPermForm.action"
>
Grant 授權
</el-button>
<el-button
type="danger"
class="ml-2"
:loading="revokingGroupPerm"
@click="handleGroupRevoke"
:disabled="!groupPermForm.groupKey || !groupPermForm.scope_type || !groupPermForm.scope_id || !groupPermForm.system || !groupPermForm.action"
>
Revoke 撤銷
</el-button>
</el-form-item>
</el-form>
<p v-if="groupPermError" class="text-red-600 text-sm mb-2">{{ groupPermError }}</p>
<p v-if="groupPermSuccess" class="text-green-600 text-sm mb-2">{{ groupPermSuccess }}</p>
</div>
</el-tab-pane>
</el-tabs>
<!-- Create Group Dialog -->
<el-dialog v-model="showCreateGroup" title="新增群組" @close="resetCreateForm">
<el-form :model="createForm" label-width="120px">
<el-form-item label="Group Key">
<el-input v-model="createForm.group_key" placeholder="group-001" />
</el-form-item>
<el-form-item label="群組名稱">
<el-input v-model="createForm.name" placeholder="群組名稱" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateGroup = false">取消</el-button>
<el-button type="primary" :loading="creatingGroup" @click="handleCreateGroup">確認</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { usePermissionStore } from '@/stores/permission'
import {
getPermissionGroups,
createPermissionGroup,
addMemberToGroup,
removeMemberFromGroup,
groupGrant,
groupRevoke
} from '@/api/permission-groups'
const permissionStore = usePermissionStore()
const activeTab = ref('groups')
// 認證
const credsForm = reactive({
clientKey: permissionStore.adminClientKey,
apiKey: permissionStore.adminApiKey
})
const credsSaved = computed(() => permissionStore.hasAdminCreds())
function saveCreds() {
if (!credsForm.clientKey || !credsForm.apiKey) {
ElMessage.warning('請填寫完整認證')
return
}
permissionStore.setAdminCreds(credsForm.clientKey, credsForm.apiKey)
ElMessage.success('認證已儲存session')
}
function clearCreds() {
permissionStore.clearAdminCreds()
credsForm.clientKey = ''
credsForm.apiKey = ''
ElMessage.info('認證已清除')
}
// Groups
const groups = ref([])
const loadingGroups = ref(false)
async function loadGroups() {
loadingGroups.value = true
try {
const res = await getPermissionGroups()
groups.value = res.data || []
} catch (err) {
ElMessage.error('載入群組失敗')
} finally {
loadingGroups.value = false
}
}
// Create Group
const showCreateGroup = ref(false)
const creatingGroup = ref(false)
const createForm = reactive({ group_key: '', name: '' })
function resetCreateForm() {
createForm.group_key = ''
createForm.name = ''
}
async function handleCreateGroup() {
if (!createForm.group_key || !createForm.name) {
ElMessage.warning('請填寫完整資訊')
return
}
creatingGroup.value = true
try {
await createPermissionGroup(createForm)
ElMessage.success('新增成功')
showCreateGroup.value = false
resetCreateForm()
await loadGroups()
} catch (err) {
ElMessage.error('新增失敗')
} finally {
creatingGroup.value = false
}
}
// Add Member
const memberForm = reactive({ groupKey: '', authentikSub: '' })
const addingMember = ref(false)
const memberError = ref('')
const memberSuccess = ref('')
async function handleAddMember() {
memberError.value = ''
memberSuccess.value = ''
addingMember.value = true
try {
await addMemberToGroup(memberForm.groupKey, memberForm.authentikSub)
memberSuccess.value = '加入成功'
memberForm.groupKey = ''
memberForm.authentikSub = ''
} catch (err) {
memberError.value = '加入失敗,請稍後再試'
} finally {
addingMember.value = false
}
}
// Group Grant/Revoke
const groupPermForm = reactive({
groupKey: '',
scope_type: '',
scope_id: '',
system: '',
module: '',
action: ''
})
const grantingGroupPerm = ref(false)
const revokingGroupPerm = ref(false)
const groupPermError = ref('')
const groupPermSuccess = ref('')
async function handleGroupGrant() {
groupPermError.value = ''
groupPermSuccess.value = ''
grantingGroupPerm.value = true
try {
const { groupKey, ...permData } = groupPermForm
await groupGrant(groupKey, permData)
groupPermSuccess.value = 'Grant 成功'
} catch (err) {
groupPermError.value = 'Grant 失敗'
} finally {
grantingGroupPerm.value = false
}
}
async function handleGroupRevoke() {
groupPermError.value = ''
groupPermSuccess.value = ''
revokingGroupPerm.value = true
try {
const { groupKey, ...permData } = groupPermForm
await groupRevoke(groupKey, permData)
groupPermSuccess.value = 'Revoke 成功'
} catch (err) {
groupPermError.value = 'Revoke 失敗'
} finally {
revokingGroupPerm.value = false
}
}
onMounted(loadGroups)
</script>

View File

@@ -0,0 +1,100 @@
<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="showDialog = 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">
<el-empty v-if="sites.length === 0" slot="empty" description="目前無站台" />
<el-table-column prop="site_key" label="Site Key" width="160" />
<el-table-column prop="company_key" label="Company Key" width="160" />
<el-table-column prop="name" label="名稱" min-width="180" />
</el-table>
<!-- 新增 Dialog -->
<el-dialog v-model="showDialog" title="新增站台" @close="resetForm">
<el-form :model="form" label-width="120px">
<el-form-item label="Site Key">
<el-input v-model="form.site_key" placeholder="site-001" />
</el-form-item>
<el-form-item label="Company Key">
<el-input v-model="form.company_key" placeholder="company-001" />
</el-form-item>
<el-form-item label="名稱">
<el-input v-model="form.name" placeholder="站台名稱" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { getSites, createSite } from '@/api/sites'
const sites = ref([])
const loading = ref(false)
const error = ref(false)
const errorMsg = ref('')
const showDialog = ref(false)
const submitting = ref(false)
const form = ref({ site_key: '', company_key: '', name: '' })
async function load() {
loading.value = true
error.value = false
try {
const res = await getSites()
sites.value = res.data || []
} catch (err) {
error.value = true
errorMsg.value = '載入失敗,請稍後再試'
} finally {
loading.value = false
}
}
function resetForm() {
form.value = { site_key: '', company_key: '', name: '' }
}
async function handleCreate() {
if (!form.value.site_key || !form.value.company_key || !form.value.name) {
ElMessage.warning('請填寫完整資訊')
return
}
submitting.value = true
try {
await createSite(form.value)
ElMessage.success('新增成功')
showDialog.value = false
resetForm()
await load()
} catch (err) {
ElMessage.error('新增失敗,請稍後再試')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>

View File

@@ -0,0 +1,96 @@
<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="showDialog = 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="systems" stripe border class="w-full shadow-sm">
<el-empty v-if="systems.length === 0" slot="empty" description="目前無系統" />
<el-table-column prop="system_key" label="System Key" width="200" />
<el-table-column prop="name" label="名稱" min-width="180" />
</el-table>
<!-- 新增 Dialog -->
<el-dialog v-model="showDialog" title="新增系統" @close="resetForm">
<el-form :model="form" label-width="100px">
<el-form-item label="System Key">
<el-input v-model="form.system_key" placeholder="mkt" />
</el-form-item>
<el-form-item label="名稱">
<el-input v-model="form.name" placeholder="行銷平台" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { getSystems, createSystem } from '@/api/systems'
const systems = ref([])
const loading = ref(false)
const error = ref(false)
const errorMsg = ref('')
const showDialog = ref(false)
const submitting = ref(false)
const form = ref({ system_key: '', name: '' })
async function load() {
loading.value = true
error.value = false
try {
const res = await getSystems()
systems.value = res.data || []
} catch (err) {
error.value = true
errorMsg.value = '載入失敗,請稍後再試'
} finally {
loading.value = false
}
}
function resetForm() {
form.value = { system_key: '', name: '' }
}
async function handleCreate() {
if (!form.value.system_key || !form.value.name) {
ElMessage.warning('請填寫完整資訊')
return
}
submitting.value = true
try {
await createSystem(form.value)
ElMessage.success('新增成功')
showDialog.value = false
resetForm()
await load()
} catch (err) {
ElMessage.error('新增失敗,請稍後再試')
} finally {
submitting.value = false
}
}
onMounted(load)
</script>

View File

@@ -57,13 +57,19 @@
<el-input v-model="grantForm.display_name" placeholder="User Name" /> <el-input v-model="grantForm.display_name" placeholder="User Name" />
</el-form-item> </el-form-item>
<el-form-item label="Scope 類型" prop="scope_type"> <el-form-item label="Scope 類型" prop="scope_type">
<el-input v-model="grantForm.scope_type" placeholder="site" /> <el-select v-model="grantForm.scope_type" placeholder="選擇 Scope 類型">
<el-option label="Company" value="company" />
<el-option label="Site" value="site" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="Scope ID" prop="scope_id"> <el-form-item label="Scope ID" prop="scope_id">
<el-input v-model="grantForm.scope_id" placeholder="tw-main" /> <el-input v-model="grantForm.scope_id" placeholder="company_key or site_key" />
</el-form-item> </el-form-item>
<el-form-item label="模組" prop="module"> <el-form-item label="系統" prop="system">
<el-input v-model="grantForm.module" placeholder="campaign" /> <el-input v-model="grantForm.system" placeholder="mkt" />
</el-form-item>
<el-form-item label="模組(選填)" prop="module">
<el-input v-model="grantForm.module" placeholder="campaign空值代表系統層" clearable />
</el-form-item> </el-form-item>
<el-form-item label="操作" prop="action"> <el-form-item label="操作" prop="action">
<el-input v-model="grantForm.action" placeholder="view" /> <el-input v-model="grantForm.action" placeholder="view" />
@@ -115,13 +121,19 @@
<el-input v-model="revokeForm.authentik_sub" placeholder="authentik-sub-xxx" /> <el-input v-model="revokeForm.authentik_sub" placeholder="authentik-sub-xxx" />
</el-form-item> </el-form-item>
<el-form-item label="Scope 類型" prop="scope_type"> <el-form-item label="Scope 類型" prop="scope_type">
<el-input v-model="revokeForm.scope_type" placeholder="site" /> <el-select v-model="revokeForm.scope_type" placeholder="選擇 Scope 類型">
<el-option label="Company" value="company" />
<el-option label="Site" value="site" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="Scope ID" prop="scope_id"> <el-form-item label="Scope ID" prop="scope_id">
<el-input v-model="revokeForm.scope_id" placeholder="tw-main" /> <el-input v-model="revokeForm.scope_id" placeholder="company_key or site_key" />
</el-form-item> </el-form-item>
<el-form-item label="模組" prop="module"> <el-form-item label="系統" prop="system">
<el-input v-model="revokeForm.module" placeholder="campaign" /> <el-input v-model="revokeForm.system" placeholder="mkt" />
</el-form-item>
<el-form-item label="模組(選填)" prop="module">
<el-input v-model="revokeForm.module" placeholder="campaign空值代表系統層" clearable />
</el-form-item> </el-form-item>
<el-form-item label="操作" prop="action"> <el-form-item label="操作" prop="action">
<el-input v-model="revokeForm.action" placeholder="view" /> <el-input v-model="revokeForm.action" placeholder="view" />
@@ -207,6 +219,7 @@ const grantForm = reactive({
display_name: '', display_name: '',
scope_type: '', scope_type: '',
scope_id: '', scope_id: '',
system: '',
module: '', module: '',
action: '' action: ''
}) })
@@ -218,7 +231,7 @@ const grantRules = {
display_name: [required], display_name: [required],
scope_type: [required], scope_type: [required],
scope_id: [required], scope_id: [required],
module: [required], system: [required],
action: [required] action: [required]
} }
@@ -255,6 +268,7 @@ const revokeForm = reactive({
authentik_sub: '', authentik_sub: '',
scope_type: '', scope_type: '',
scope_id: '', scope_id: '',
system: '',
module: '', module: '',
action: '' action: ''
}) })
@@ -263,7 +277,7 @@ const revokeRules = {
authentik_sub: [required], authentik_sub: [required],
scope_type: [required], scope_type: [required],
scope_id: [required], scope_id: [required],
module: [required], system: [required],
action: [required] action: [required]
} }

View File

@@ -33,9 +33,10 @@
border border
class="w-full shadow-sm" class="w-full shadow-sm"
> >
<el-table-column prop="scope_type" label="Scope 類型" width="130" /> <el-table-column prop="scope_type" label="Scope 類型" width="100" />
<el-table-column prop="scope_id" label="Scope ID" min-width="160" /> <el-table-column prop="scope_id" label="Scope ID" min-width="140" />
<el-table-column prop="module" label="模組" width="140" /> <el-table-column prop="system" label="系統" width="120" />
<el-table-column prop="module" label="模組" width="120" />
<el-table-column prop="action" label="操作" width="100" /> <el-table-column prop="action" label="操作" width="100" />
</el-table> </el-table>
</template> </template>

View File

@@ -8,6 +8,11 @@ const routes = [
name: 'login', name: 'login',
component: () => import('@/pages/LoginPage.vue') component: () => import('@/pages/LoginPage.vue')
}, },
{
path: '/auth/callback',
name: 'auth-callback',
component: () => import('@/pages/AuthCallbackPage.vue')
},
{ {
path: '/me', path: '/me',
name: 'me', name: 'me',
@@ -24,6 +29,36 @@ const routes = [
path: '/admin/permissions', path: '/admin/permissions',
name: 'admin-permissions', name: 'admin-permissions',
component: () => import('@/pages/permissions/PermissionAdminPage.vue') component: () => import('@/pages/permissions/PermissionAdminPage.vue')
},
{
path: '/admin/systems',
name: 'admin-systems',
component: () => import('@/pages/admin/SystemsPage.vue')
},
{
path: '/admin/modules',
name: 'admin-modules',
component: () => import('@/pages/admin/ModulesPage.vue')
},
{
path: '/admin/companies',
name: 'admin-companies',
component: () => import('@/pages/admin/CompaniesPage.vue')
},
{
path: '/admin/sites',
name: 'admin-sites',
component: () => import('@/pages/admin/SitesPage.vue')
},
{
path: '/admin/members',
name: 'admin-members',
component: () => import('@/pages/admin/MembersPage.vue')
},
{
path: '/admin/permission-groups',
name: 'admin-permission-groups',
component: () => import('@/pages/admin/PermissionGroupsPage.vue')
} }
] ]

View File

@@ -0,0 +1,39 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getSystems } from '@/api/systems'
import { getModules } from '@/api/modules'
import { getCompanies } from '@/api/companies'
import { getSites } from '@/api/sites'
export const useAdminStore = defineStore('admin', () => {
const systems = ref([])
const modules = ref([])
const companies = ref([])
const sites = ref([])
async function loadAllData() {
try {
const [sysRes, modRes, comRes, siteRes] = await Promise.all([
getSystems(),
getModules(),
getCompanies(),
getSites()
])
systems.value = sysRes.data || []
modules.value = modRes.data || []
companies.value = comRes.data || []
sites.value = siteRes.data || []
} catch (err) {
console.error('Error loading admin data:', err)
throw err
}
}
return {
systems,
modules,
companies,
sites,
loadAllData
}
})