Compare commits
105 Commits
ad6d16c97e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fd81ffbf2 | ||
|
|
cf39ea2b0c | ||
|
|
560f40ae8a | ||
|
|
838c0afc0b | ||
|
|
0666b8683e | ||
|
|
428b6292ea | ||
|
|
cd7feec38a | ||
|
|
01a4580faf | ||
|
|
649af715e2 | ||
|
|
3571cdf2ee | ||
|
|
099ed03be7 | ||
|
|
f62ed97e2b | ||
|
|
760902f53c | ||
|
|
998771bc11 | ||
|
|
576ba9b2fe | ||
|
|
b7b312e69a | ||
|
|
865be73d06 | ||
|
|
ed4b22a564 | ||
|
|
2da43cf027 | ||
|
|
200c86c924 | ||
|
|
e8058d1240 | ||
|
|
6dabc2eab6 | ||
|
|
8609d61f82 | ||
|
|
f01a228026 | ||
|
|
a6e5fbbb24 | ||
|
|
21dc3ea56f | ||
|
|
fdf17ecf85 | ||
|
|
a45aa5a6c7 | ||
|
|
c394e9153e | ||
|
|
0e248db1bf | ||
|
|
1d2a57fada | ||
|
|
528b988207 | ||
|
|
40d9fb8dcf | ||
|
|
fc81696abf | ||
|
|
daa21e81a9 | ||
|
|
955019e8d7 | ||
|
|
ed413ce39d | ||
|
|
fa624127c8 | ||
|
|
418a7b7099 | ||
|
|
223edd49b5 | ||
|
|
d59407d04c | ||
|
|
2004203758 | ||
|
|
1ff0589b29 | ||
|
|
6e43a3b2c8 | ||
|
|
21167659f8 | ||
|
|
f351fe6454 | ||
|
|
6adca8c229 | ||
|
|
ef27162903 | ||
|
|
467f2b4867 | ||
|
|
7660c662a5 | ||
|
|
7986160d9e | ||
|
|
6ae907d649 | ||
|
|
2ce9630a5e | ||
|
|
5837582c0f | ||
|
|
1e1d913103 | ||
|
|
e2dd3ce106 | ||
|
|
16bbfdba24 | ||
|
|
7cdf2b5a51 | ||
|
|
a9c7cb5f39 | ||
|
|
f0fd5d6e68 | ||
|
|
b0de6ad94a | ||
|
|
a1eb7ef41b | ||
|
|
07195e7efc | ||
|
|
dc2811ec61 | ||
|
|
0b61975c81 | ||
|
|
34ba57034d | ||
|
|
febfafc55c | ||
|
|
80a571d227 | ||
|
|
fe6453f6f8 | ||
|
|
316d17027b | ||
|
|
15da8a5341 | ||
|
|
671e27447b | ||
|
|
322db6ee1a | ||
|
|
f6f86d4bfb | ||
|
|
c4492a3072 | ||
|
|
1d9bdb7daa | ||
|
|
ccb99683b8 | ||
|
|
e1a6bbd844 | ||
|
|
15eee2fc9a | ||
|
|
fb515c6c44 | ||
|
|
b4c02835bd | ||
|
|
62776ac27e | ||
|
|
ea5285501a | ||
|
|
37a69081e3 | ||
|
|
f884f1043d | ||
|
|
31fff92e19 | ||
|
|
f85d3de5c5 | ||
|
|
2dd70dceff | ||
|
|
94441a4037 | ||
|
|
76fd22826b | ||
|
|
f33134ff53 | ||
|
|
c85109e09b | ||
|
|
4bb6ecf887 | ||
|
|
c3f6293c83 | ||
|
|
70b5f34a74 | ||
|
|
23baceed71 | ||
|
|
c4b9789df7 | ||
|
|
d79ed7c6fc | ||
|
|
42f9124f77 | ||
|
|
f9ad9417ba | ||
|
|
f5848a360f | ||
|
|
c6cb9d6818 | ||
|
|
1ec132184f | ||
|
|
42f04ef961 | ||
|
|
096136e9d5 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,3 +22,7 @@ dist/
|
|||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Local deployment files
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.override.yml
|
||||||
|
|||||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "backend"]
|
||||||
|
path = backend
|
||||||
|
url = http://127.0.0.1:8888/member/member-backend.git
|
||||||
|
[submodule "frontend"]
|
||||||
|
path = frontend
|
||||||
|
url = http://127.0.0.1:8888/member/member-frontend.git
|
||||||
1
backend
Submodule
1
backend
Submodule
Submodule backend added at 405000ded5
19
backend/.env
19
backend/.env
@@ -1,19 +0,0 @@
|
|||||||
# memberapi.ose.tw backend env (development)
|
|
||||||
APP_ENV=development
|
|
||||||
PORT=8000
|
|
||||||
|
|
||||||
DB_HOST=127.0.0.1
|
|
||||||
DB_PORT=54321
|
|
||||||
DB_NAME=member_center
|
|
||||||
DB_USER=member_ose
|
|
||||||
DB_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
AUTHENTIK_BASE_URL=
|
|
||||||
AUTHENTIK_ADMIN_TOKEN=
|
|
||||||
AUTHENTIK_VERIFY_TLS=false
|
|
||||||
AUTHENTIK_ISSUER=
|
|
||||||
AUTHENTIK_JWKS_URL=
|
|
||||||
AUTHENTIK_AUDIENCE=
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# memberapi.ose.tw backend env (local development)
|
|
||||||
APP_ENV=development
|
|
||||||
PORT=8000
|
|
||||||
|
|
||||||
DB_HOST=127.0.0.1
|
|
||||||
DB_PORT=54321
|
|
||||||
DB_NAME=member.ose.tw
|
|
||||||
DB_USER=member_ose
|
|
||||||
DB_PASSWORD=Dmrax5bKDf
|
|
||||||
|
|
||||||
AUTHENTIK_BASE_URL=https://auth.ose.tw
|
|
||||||
AUTHENTIK_ADMIN_TOKEN=L7RspewJSjm3i7Y3eovYb49vr8jvEJ6oZzCm3X79spGNapbo3RqWilBrTDz3
|
|
||||||
AUTHENTIK_VERIFY_TLS=true
|
|
||||||
AUTHENTIK_ISSUER=https://auth.ose.tw/application/o/member-ose-frontend/
|
|
||||||
AUTHENTIK_JWKS_URL=https://auth.ose.tw/application/o/member-ose-frontend/jwks/
|
|
||||||
AUTHENTIK_AUDIENCE=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN
|
|
||||||
AUTHENTIK_CLIENT_ID=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN
|
|
||||||
AUTHENTIK_CLIENT_SECRET=MHTv0SHkIuic9Quk8Br9jB9gzT2bERvRfhHU4ogPlUtY3eBEXJj80RTEp3zpFBUXQ8PAwYrihWfNqKawWUOmKpQd8SwuyiAuVwLJTS7vB3LGvx1XtXqgMhR76EL2mLnP
|
|
||||||
AUTHENTIK_TOKEN_ENDPOINT=https://auth.ose.tw/application/o/token/
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# memberapi.ose.tw backend env (development)
|
|
||||||
APP_ENV=development
|
|
||||||
PORT=8000
|
|
||||||
|
|
||||||
DB_HOST=127.0.0.1
|
|
||||||
DB_PORT=54321
|
|
||||||
DB_NAME=member_center
|
|
||||||
DB_USER=member_ose
|
|
||||||
DB_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
AUTHENTIK_BASE_URL=
|
|
||||||
AUTHENTIK_ADMIN_TOKEN=
|
|
||||||
AUTHENTIK_VERIFY_TLS=false
|
|
||||||
AUTHENTIK_ISSUER=
|
|
||||||
AUTHENTIK_JWKS_URL=
|
|
||||||
AUTHENTIK_AUDIENCE=
|
|
||||||
AUTHENTIK_CLIENT_ID=
|
|
||||||
AUTHENTIK_CLIENT_SECRET=
|
|
||||||
AUTHENTIK_TOKEN_ENDPOINT=
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# memberapi.ose.tw backend env (production)
|
|
||||||
APP_ENV=production
|
|
||||||
PORT=8000
|
|
||||||
|
|
||||||
DB_HOST=postgresql
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_NAME=member_center
|
|
||||||
DB_USER=member_ose
|
|
||||||
DB_PASSWORD=CHANGE_ME
|
|
||||||
|
|
||||||
AUTHENTIK_BASE_URL=
|
|
||||||
AUTHENTIK_ADMIN_TOKEN=
|
|
||||||
AUTHENTIK_VERIFY_TLS=false
|
|
||||||
AUTHENTIK_ISSUER=
|
|
||||||
AUTHENTIK_JWKS_URL=
|
|
||||||
AUTHENTIK_AUDIENCE=
|
|
||||||
AUTHENTIK_CLIENT_ID=
|
|
||||||
AUTHENTIK_CLIENT_SECRET=
|
|
||||||
AUTHENTIK_TOKEN_ENDPOINT=
|
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# memberapi.ose.tw backend
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -e .
|
|
||||||
cp .env.example .env
|
|
||||||
./scripts/start_dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Required DB setup
|
|
||||||
|
|
||||||
1. Initialize API client whitelist table with `docs/API_CLIENTS_SQL.sql`.
|
|
||||||
2. Initialize core tables with `backend/scripts/init_schema.sql`.
|
|
||||||
3. Generate `api_key_hash` and update `api_clients` records, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authentik JWT setup
|
|
||||||
|
|
||||||
- Configure at least one of:
|
|
||||||
- `AUTHENTIK_JWKS_URL`
|
|
||||||
- `AUTHENTIK_ISSUER` (the service infers `<issuer>/jwks/`)
|
|
||||||
- Optional:
|
|
||||||
- `AUTHENTIK_AUDIENCE` (enables audience claim validation)
|
|
||||||
- `AUTHENTIK_CLIENT_ID` (used by `/auth/login`, fallback to `AUTHENTIK_AUDIENCE`)
|
|
||||||
- `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing)
|
|
||||||
- `AUTHENTIK_TOKEN_ENDPOINT` (default: `<AUTHENTIK_BASE_URL>/application/o/token/`)
|
|
||||||
|
|
||||||
## Authentik Admin API setup
|
|
||||||
|
|
||||||
- Required for `/internal/authentik/users/ensure`:
|
|
||||||
- `AUTHENTIK_BASE_URL`
|
|
||||||
- `AUTHENTIK_ADMIN_TOKEN`
|
|
||||||
- `AUTHENTIK_VERIFY_TLS`
|
|
||||||
|
|
||||||
## Main APIs
|
|
||||||
|
|
||||||
- `GET /healthz`
|
|
||||||
- `POST /auth/login`
|
|
||||||
- `GET /me` (Bearer token required)
|
|
||||||
- `GET /me/permissions/snapshot` (Bearer token required)
|
|
||||||
- `POST /internal/users/upsert-by-sub`
|
|
||||||
- `GET /internal/permissions/{authentik_sub}/snapshot`
|
|
||||||
- `POST /internal/authentik/users/ensure`
|
|
||||||
- `POST /admin/permissions/grant`
|
|
||||||
- `POST /admin/permissions/revoke`
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""memberapi backend package."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""API routers."""
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.models.api_client import ApiClient
|
|
||||||
from app.repositories.permissions_repo import PermissionsRepository
|
|
||||||
from app.repositories.users_repo import UsersRepository
|
|
||||||
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
|
|
||||||
from app.security.api_client_auth import require_api_client
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/permissions/grant")
|
|
||||||
def grant_permission(
|
|
||||||
payload: PermissionGrantRequest,
|
|
||||||
_: ApiClient = Depends(require_api_client),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> dict[str, str]:
|
|
||||||
users_repo = UsersRepository(db)
|
|
||||||
perms_repo = PermissionsRepository(db)
|
|
||||||
|
|
||||||
user = users_repo.upsert_by_sub(
|
|
||||||
authentik_sub=payload.authentik_sub,
|
|
||||||
email=payload.email,
|
|
||||||
display_name=payload.display_name,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
permission = perms_repo.create_if_not_exists(
|
|
||||||
user_id=user.id,
|
|
||||||
scope_type=payload.scope_type,
|
|
||||||
scope_id=payload.scope_id,
|
|
||||||
module=payload.module,
|
|
||||||
action=payload.action,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"permission_id": permission.id, "result": "granted"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/permissions/revoke")
|
|
||||||
def revoke_permission(
|
|
||||||
payload: PermissionRevokeRequest,
|
|
||||||
_: ApiClient = Depends(require_api_client),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> dict[str, int | str]:
|
|
||||||
users_repo = UsersRepository(db)
|
|
||||||
perms_repo = PermissionsRepository(db)
|
|
||||||
|
|
||||||
user = users_repo.get_by_sub(payload.authentik_sub)
|
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
|
|
||||||
|
|
||||||
deleted = perms_repo.revoke(
|
|
||||||
user_id=user.id,
|
|
||||||
scope_type=payload.scope_type,
|
|
||||||
scope_id=payload.scope_id,
|
|
||||||
module=payload.module,
|
|
||||||
action=payload.action,
|
|
||||||
)
|
|
||||||
return {"deleted": deleted, "result": "revoked"}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.schemas.login import LoginRequest, LoginResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
|
||||||
def login(payload: LoginRequest) -> 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": "password",
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": settings.authentik_client_secret,
|
|
||||||
"username": payload.username,
|
|
||||||
"password": payload.password,
|
|
||||||
"scope": "openid profile email",
|
|
||||||
}
|
|
||||||
|
|
||||||
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:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password")
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
token = data.get("access_token")
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="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"),
|
|
||||||
)
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.repositories.permissions_repo import PermissionsRepository
|
|
||||||
from app.repositories.users_repo import UsersRepository
|
|
||||||
from app.schemas.authentik_admin import AuthentikEnsureUserRequest, AuthentikEnsureUserResponse
|
|
||||||
from app.schemas.permissions import PermissionSnapshotResponse
|
|
||||||
from app.schemas.users import UserUpsertBySubRequest
|
|
||||||
from app.services.authentik_admin_service import AuthentikAdminService
|
|
||||||
from app.services.permission_service import PermissionService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
|
||||||
|
|
||||||
|
|
||||||
def verify_internal_secret(x_internal_secret: str = Header(alias="X-Internal-Secret")) -> None:
|
|
||||||
settings = get_settings()
|
|
||||||
if not settings.internal_shared_secret:
|
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="internal_secret_not_configured")
|
|
||||||
if x_internal_secret != settings.internal_shared_secret:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_internal_secret")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/upsert-by-sub")
|
|
||||||
def upsert_user_by_sub(
|
|
||||||
payload: UserUpsertBySubRequest,
|
|
||||||
_: None = Depends(verify_internal_secret),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> dict[str, str | bool | None]:
|
|
||||||
repo = UsersRepository(db)
|
|
||||||
user = repo.upsert_by_sub(
|
|
||||||
authentik_sub=payload.sub,
|
|
||||||
email=payload.email,
|
|
||||||
display_name=payload.display_name,
|
|
||||||
is_active=payload.is_active,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"id": user.id,
|
|
||||||
"sub": user.authentik_sub,
|
|
||||||
"authentik_user_id": user.authentik_user_id,
|
|
||||||
"email": user.email,
|
|
||||||
"display_name": user.display_name,
|
|
||||||
"is_active": user.is_active,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/permissions/{authentik_sub}/snapshot", response_model=PermissionSnapshotResponse)
|
|
||||||
def get_permission_snapshot(
|
|
||||||
authentik_sub: str,
|
|
||||||
_: None = Depends(verify_internal_secret),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> PermissionSnapshotResponse:
|
|
||||||
users_repo = UsersRepository(db)
|
|
||||||
perms_repo = PermissionsRepository(db)
|
|
||||||
|
|
||||||
user = users_repo.get_by_sub(authentik_sub)
|
|
||||||
if user is None:
|
|
||||||
return PermissionSnapshotResponse(authentik_sub=authentik_sub, permissions=[])
|
|
||||||
|
|
||||||
permissions = perms_repo.list_by_user_id(user.id)
|
|
||||||
tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions]
|
|
||||||
return PermissionService.build_snapshot(authentik_sub=authentik_sub, permissions=tuples)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse)
|
|
||||||
def ensure_authentik_user(
|
|
||||||
payload: AuthentikEnsureUserRequest,
|
|
||||||
_: None = Depends(verify_internal_secret),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> AuthentikEnsureUserResponse:
|
|
||||||
settings = get_settings()
|
|
||||||
authentik_service = AuthentikAdminService(settings=settings)
|
|
||||||
sync_result = authentik_service.ensure_user(
|
|
||||||
sub=payload.sub,
|
|
||||||
email=payload.email,
|
|
||||||
display_name=payload.display_name,
|
|
||||||
is_active=payload.is_active,
|
|
||||||
)
|
|
||||||
|
|
||||||
users_repo = UsersRepository(db)
|
|
||||||
users_repo.upsert_by_sub(
|
|
||||||
authentik_sub=payload.sub,
|
|
||||||
email=payload.email,
|
|
||||||
display_name=payload.display_name,
|
|
||||||
is_active=payload.is_active,
|
|
||||||
authentik_user_id=sync_result.user_id,
|
|
||||||
)
|
|
||||||
return AuthentikEnsureUserResponse(authentik_user_id=sync_result.user_id, action=sync_result.action)
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.repositories.permissions_repo import PermissionsRepository
|
|
||||||
from app.repositories.users_repo import UsersRepository
|
|
||||||
from app.schemas.auth import AuthentikPrincipal, MeSummaryResponse
|
|
||||||
from app.schemas.permissions import PermissionSnapshotResponse
|
|
||||||
from app.security.authentik_jwt import require_authenticated_principal
|
|
||||||
from app.services.permission_service import PermissionService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/me", tags=["me"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=MeSummaryResponse)
|
|
||||||
def get_me(
|
|
||||||
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> MeSummaryResponse:
|
|
||||||
try:
|
|
||||||
users_repo = UsersRepository(db)
|
|
||||||
user = users_repo.upsert_by_sub(
|
|
||||||
authentik_sub=principal.sub,
|
|
||||||
email=principal.email,
|
|
||||||
display_name=principal.name or principal.preferred_username,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
return MeSummaryResponse(sub=user.authentik_sub, email=user.email, display_name=user.display_name)
|
|
||||||
except SQLAlchemyError:
|
|
||||||
# DB schema compatibility fallback for local bring-up.
|
|
||||||
return MeSummaryResponse(
|
|
||||||
sub=principal.sub,
|
|
||||||
email=principal.email,
|
|
||||||
display_name=principal.name or principal.preferred_username,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/permissions/snapshot", response_model=PermissionSnapshotResponse)
|
|
||||||
def get_my_permission_snapshot(
|
|
||||||
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> PermissionSnapshotResponse:
|
|
||||||
try:
|
|
||||||
users_repo = UsersRepository(db)
|
|
||||||
perms_repo = PermissionsRepository(db)
|
|
||||||
|
|
||||||
user = users_repo.upsert_by_sub(
|
|
||||||
authentik_sub=principal.sub,
|
|
||||||
email=principal.email,
|
|
||||||
display_name=principal.name or principal.preferred_username,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
permissions = perms_repo.list_by_user_id(user.id)
|
|
||||||
tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions]
|
|
||||||
return PermissionService.build_snapshot(authentik_sub=principal.sub, permissions=tuples)
|
|
||||||
except SQLAlchemyError:
|
|
||||||
return PermissionSnapshotResponse(authentik_sub=principal.sub, permissions=[])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Core settings and constants."""
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
from functools import lru_cache
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from pydantic import field_validator
|
|
||||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
||||||
|
|
||||||
app_env: str = "development"
|
|
||||||
port: int = 8000
|
|
||||||
|
|
||||||
db_host: str = "127.0.0.1"
|
|
||||||
db_port: int = 54321
|
|
||||||
db_name: str = "member_center"
|
|
||||||
db_user: str = "member_ose"
|
|
||||||
db_password: str = ""
|
|
||||||
|
|
||||||
authentik_base_url: str = ""
|
|
||||||
authentik_admin_token: str = ""
|
|
||||||
authentik_verify_tls: bool = False
|
|
||||||
authentik_issuer: str = ""
|
|
||||||
authentik_jwks_url: str = ""
|
|
||||||
authentik_audience: str = ""
|
|
||||||
authentik_client_id: str = ""
|
|
||||||
authentik_client_secret: str = ""
|
|
||||||
authentik_token_endpoint: str = ""
|
|
||||||
|
|
||||||
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
|
||||||
internal_shared_secret: str = ""
|
|
||||||
|
|
||||||
@field_validator("public_frontend_origins", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def parse_origins(cls, value: str | list[str]) -> list[str]:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return value
|
|
||||||
if not value:
|
|
||||||
return []
|
|
||||||
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def database_url(self) -> str:
|
|
||||||
return (
|
|
||||||
"postgresql+psycopg://"
|
|
||||||
f"{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
return Settings()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Database wiring."""
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
from collections.abc import Generator
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Generator[Session, None, None]:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from app.api.admin import router as admin_router
|
|
||||||
from app.api.auth import router as auth_router
|
|
||||||
from app.api.internal import router as internal_router
|
|
||||||
from app.api.me import router as me_router
|
|
||||||
from app.core.config import get_settings
|
|
||||||
|
|
||||||
app = FastAPI(title="memberapi.ose.tw", version="0.1.0")
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=settings.public_frontend_origins,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz", tags=["health"])
|
|
||||||
def healthz() -> dict[str, str]:
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
app.include_router(internal_router)
|
|
||||||
app.include_router(admin_router)
|
|
||||||
app.include_router(me_router)
|
|
||||||
app.include_router(auth_router)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from app.models.api_client import ApiClient
|
|
||||||
from app.models.permission import Permission
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
__all__ = ["ApiClient", "Permission", "User"]
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ApiClient(Base):
|
|
||||||
__tablename__ = "api_clients"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
|
||||||
client_key: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
|
||||||
api_key_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
|
|
||||||
allowed_origins: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
|
||||||
allowed_ips: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
|
||||||
allowed_paths: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
|
||||||
|
|
||||||
rate_limit_per_min: Mapped[int | None] = mapped_column(Integer)
|
|
||||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
||||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Permission(Base):
|
|
||||||
__tablename__ = "permissions"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"user_id",
|
|
||||||
"scope_type",
|
|
||||||
"scope_id",
|
|
||||||
"module",
|
|
||||||
"action",
|
|
||||||
name="uq_permissions_user_scope_module_action",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
scope_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
||||||
scope_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
module: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
|
||||||
authentik_sub: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
|
||||||
authentik_user_id: Mapped[int | None] = mapped_column(Integer)
|
|
||||||
email: Mapped[str | None] = mapped_column(String(320))
|
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Repository layer."""
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
from sqlalchemy import delete, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.permission import Permission
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionsRepository:
|
|
||||||
def __init__(self, db: Session) -> None:
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
def list_by_user_id(self, user_id: str) -> list[Permission]:
|
|
||||||
stmt = select(Permission).where(Permission.user_id == user_id)
|
|
||||||
return list(self.db.scalars(stmt).all())
|
|
||||||
|
|
||||||
def create_if_not_exists(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
scope_type: str,
|
|
||||||
scope_id: str,
|
|
||||||
module: str,
|
|
||||||
action: str,
|
|
||||||
) -> Permission:
|
|
||||||
stmt = select(Permission).where(
|
|
||||||
Permission.user_id == user_id,
|
|
||||||
Permission.scope_type == scope_type,
|
|
||||||
Permission.scope_id == scope_id,
|
|
||||||
Permission.module == module,
|
|
||||||
Permission.action == action,
|
|
||||||
)
|
|
||||||
existing = self.db.scalar(stmt)
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
item = Permission(
|
|
||||||
user_id=user_id,
|
|
||||||
scope_type=scope_type,
|
|
||||||
scope_id=scope_id,
|
|
||||||
module=module,
|
|
||||||
action=action,
|
|
||||||
)
|
|
||||||
self.db.add(item)
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
def revoke(
|
|
||||||
self,
|
|
||||||
user_id: str,
|
|
||||||
scope_type: str,
|
|
||||||
scope_id: str,
|
|
||||||
module: str,
|
|
||||||
action: str,
|
|
||||||
) -> int:
|
|
||||||
stmt = delete(Permission).where(
|
|
||||||
Permission.user_id == user_id,
|
|
||||||
Permission.scope_type == scope_type,
|
|
||||||
Permission.scope_id == scope_id,
|
|
||||||
Permission.module == module,
|
|
||||||
Permission.action == action,
|
|
||||||
)
|
|
||||||
result = self.db.execute(stmt)
|
|
||||||
self.db.commit()
|
|
||||||
return int(result.rowcount or 0)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
class UsersRepository:
|
|
||||||
def __init__(self, db: Session) -> None:
|
|
||||||
self.db = db
|
|
||||||
|
|
||||||
def get_by_sub(self, authentik_sub: str) -> User | None:
|
|
||||||
stmt = select(User).where(User.authentik_sub == authentik_sub)
|
|
||||||
return self.db.scalar(stmt)
|
|
||||||
|
|
||||||
def upsert_by_sub(
|
|
||||||
self,
|
|
||||||
authentik_sub: str,
|
|
||||||
email: str | None,
|
|
||||||
display_name: str | None,
|
|
||||||
is_active: bool,
|
|
||||||
authentik_user_id: int | None = None,
|
|
||||||
) -> User:
|
|
||||||
user = self.get_by_sub(authentik_sub)
|
|
||||||
if user is None:
|
|
||||||
user = User(
|
|
||||||
authentik_sub=authentik_sub,
|
|
||||||
authentik_user_id=authentik_user_id,
|
|
||||||
email=email,
|
|
||||||
display_name=display_name,
|
|
||||||
is_active=is_active,
|
|
||||||
)
|
|
||||||
self.db.add(user)
|
|
||||||
else:
|
|
||||||
if authentik_user_id is not None:
|
|
||||||
user.authentik_user_id = authentik_user_id
|
|
||||||
user.email = email
|
|
||||||
user.display_name = display_name
|
|
||||||
user.is_active = is_active
|
|
||||||
|
|
||||||
self.db.commit()
|
|
||||||
self.db.refresh(user)
|
|
||||||
return user
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Pydantic schemas."""
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikPrincipal(BaseModel):
|
|
||||||
sub: str
|
|
||||||
email: str | None = None
|
|
||||||
name: str | None = None
|
|
||||||
preferred_username: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MeSummaryResponse(BaseModel):
|
|
||||||
sub: str
|
|
||||||
email: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikEnsureUserRequest(BaseModel):
|
|
||||||
sub: str
|
|
||||||
email: str
|
|
||||||
display_name: str | None = None
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikEnsureUserResponse(BaseModel):
|
|
||||||
authentik_user_id: int
|
|
||||||
action: str
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
token_type: str = "Bearer"
|
|
||||||
expires_in: int | None = None
|
|
||||||
scope: str | None = None
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionGrantRequest(BaseModel):
|
|
||||||
authentik_sub: str
|
|
||||||
email: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
scope_type: str
|
|
||||||
scope_id: str
|
|
||||||
module: str
|
|
||||||
action: str
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionRevokeRequest(BaseModel):
|
|
||||||
authentik_sub: str
|
|
||||||
scope_type: str
|
|
||||||
scope_id: str
|
|
||||||
module: str
|
|
||||||
action: str
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionItem(BaseModel):
|
|
||||||
scope_type: str
|
|
||||||
scope_id: str
|
|
||||||
module: str
|
|
||||||
action: str
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionSnapshotResponse(BaseModel):
|
|
||||||
authentik_sub: str
|
|
||||||
permissions: list[PermissionItem]
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpsertBySubRequest(BaseModel):
|
|
||||||
sub: str
|
|
||||||
email: str | None = None
|
|
||||||
display_name: str | None = None
|
|
||||||
is_active: bool = True
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Security dependencies and guards."""
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request, status
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.db.session import get_db
|
|
||||||
from app.models.api_client import ApiClient
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_api_key(plain_key: str, stored_hash: str) -> bool:
|
|
||||||
# Support sha256:<hex> for bootstrap, and bcrypt/argon2 for production.
|
|
||||||
if stored_hash.startswith("sha256:"):
|
|
||||||
hex_hash = hashlib.sha256(plain_key.encode("utf-8")).hexdigest()
|
|
||||||
return hmac.compare_digest(stored_hash.removeprefix("sha256:"), hex_hash)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return pwd_context.verify(plain_key, stored_hash)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_expired(expires_at: datetime | None) -> bool:
|
|
||||||
if expires_at is None:
|
|
||||||
return False
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
if expires_at.tzinfo is None:
|
|
||||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
||||||
return expires_at <= now
|
|
||||||
|
|
||||||
|
|
||||||
def _check_request_whitelist(client: ApiClient, request: Request) -> None:
|
|
||||||
origin = request.headers.get("origin")
|
|
||||||
client_ip = request.client.host if request.client else None
|
|
||||||
path = request.url.path
|
|
||||||
|
|
||||||
if client.allowed_origins and origin and origin not in client.allowed_origins:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="origin_not_allowed")
|
|
||||||
|
|
||||||
if client.allowed_ips and client_ip and client_ip not in client.allowed_ips:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ip_not_allowed")
|
|
||||||
|
|
||||||
if client.allowed_paths and not any(path.startswith(prefix) for prefix in client.allowed_paths):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="path_not_allowed")
|
|
||||||
|
|
||||||
|
|
||||||
def require_api_client(
|
|
||||||
request: Request,
|
|
||||||
x_client_key: str = Header(alias="X-Client-Key"),
|
|
||||||
x_api_key: str = Header(alias="X-API-Key"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> ApiClient:
|
|
||||||
stmt = select(ApiClient).where(ApiClient.client_key == x_client_key)
|
|
||||||
client = db.scalar(stmt)
|
|
||||||
|
|
||||||
if client is None or client.status != "active":
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_client")
|
|
||||||
|
|
||||||
if _is_expired(client.expires_at):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="client_expired")
|
|
||||||
|
|
||||||
if not _verify_api_key(x_api_key, client.api_key_hash):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_api_key")
|
|
||||||
|
|
||||||
_check_request_whitelist(client, request)
|
|
||||||
|
|
||||||
client.last_used_at = datetime.now(timezone.utc)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return client
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
|
|
||||||
from app.core.config import get_settings
|
|
||||||
from app.schemas.auth import AuthentikPrincipal
|
|
||||||
|
|
||||||
bearer_scheme = HTTPBearer(auto_error=False)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikTokenVerifier:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
issuer: str | None,
|
|
||||||
jwks_url: str | None,
|
|
||||||
audience: str | None,
|
|
||||||
client_secret: str | None,
|
|
||||||
) -> None:
|
|
||||||
self.issuer = issuer.strip() if issuer else None
|
|
||||||
self.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
|
|
||||||
self.audience = audience.strip() if audience else None
|
|
||||||
self.client_secret = client_secret.strip() if client_secret else None
|
|
||||||
|
|
||||||
if not self.jwks_url:
|
|
||||||
raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
|
|
||||||
|
|
||||||
self._jwk_client = jwt.PyJWKClient(self.jwks_url)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _infer_jwks_url(issuer: str | None) -> str | None:
|
|
||||||
if not issuer:
|
|
||||||
return None
|
|
||||||
normalized = issuer.rstrip("/") + "/"
|
|
||||||
if normalized.endswith("/jwks/"):
|
|
||||||
return normalized
|
|
||||||
return normalized + "jwks/"
|
|
||||||
|
|
||||||
def verify_access_token(self, token: str) -> AuthentikPrincipal:
|
|
||||||
try:
|
|
||||||
header = jwt.get_unverified_header(token)
|
|
||||||
algorithm = str(header.get("alg", "")).upper()
|
|
||||||
options = {
|
|
||||||
"verify_signature": True,
|
|
||||||
"verify_exp": True,
|
|
||||||
"verify_aud": bool(self.audience),
|
|
||||||
"verify_iss": bool(self.issuer),
|
|
||||||
}
|
|
||||||
|
|
||||||
if algorithm.startswith("HS"):
|
|
||||||
if not self.client_secret:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="missing_authentik_client_secret",
|
|
||||||
)
|
|
||||||
key = self.client_secret
|
|
||||||
allowed_algorithms = ["HS256", "HS384", "HS512"]
|
|
||||||
else:
|
|
||||||
signing_key = self._jwk_client.get_signing_key_from_jwt(token)
|
|
||||||
key = signing_key.key
|
|
||||||
allowed_algorithms = ["RS256", "RS384", "RS512"]
|
|
||||||
|
|
||||||
claims = jwt.decode(
|
|
||||||
token,
|
|
||||||
key,
|
|
||||||
algorithms=allowed_algorithms,
|
|
||||||
audience=self.audience,
|
|
||||||
issuer=self.issuer,
|
|
||||||
options=options,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc
|
|
||||||
|
|
||||||
sub = claims.get("sub")
|
|
||||||
if not sub:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub")
|
|
||||||
|
|
||||||
return AuthentikPrincipal(
|
|
||||||
sub=sub,
|
|
||||||
email=claims.get("email"),
|
|
||||||
name=claims.get("name"),
|
|
||||||
preferred_username=claims.get("preferred_username"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def _get_verifier() -> AuthentikTokenVerifier:
|
|
||||||
settings = get_settings()
|
|
||||||
return AuthentikTokenVerifier(
|
|
||||||
issuer=settings.authentik_issuer,
|
|
||||||
jwks_url=settings.authentik_jwks_url,
|
|
||||||
audience=settings.authentik_audience,
|
|
||||||
client_secret=settings.authentik_client_secret,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def require_authenticated_principal(
|
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
|
||||||
) -> AuthentikPrincipal:
|
|
||||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_bearer_token")
|
|
||||||
|
|
||||||
verifier = _get_verifier()
|
|
||||||
return verifier.verify_access_token(credentials.credentials)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Service layer."""
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
from app.core.config import Settings
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AuthentikSyncResult:
|
|
||||||
user_id: int
|
|
||||||
action: str
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikAdminService:
|
|
||||||
def __init__(self, settings: Settings) -> None:
|
|
||||||
self.base_url = settings.authentik_base_url.rstrip("/")
|
|
||||||
self.admin_token = settings.authentik_admin_token
|
|
||||||
self.verify_tls = settings.authentik_verify_tls
|
|
||||||
|
|
||||||
if not self.base_url or not self.admin_token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="authentik_admin_not_configured",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _client(self) -> httpx.Client:
|
|
||||||
return httpx.Client(
|
|
||||||
base_url=self.base_url,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {self.admin_token}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
timeout=10,
|
|
||||||
verify=self.verify_tls,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _safe_username(sub: str, email: str) -> str:
|
|
||||||
if email and "@" in email:
|
|
||||||
return email.split("@", 1)[0]
|
|
||||||
return sub.replace("|", "_")[:150]
|
|
||||||
|
|
||||||
def ensure_user(self, sub: str, email: str, display_name: str | None, is_active: bool = True) -> AuthentikSyncResult:
|
|
||||||
payload = {
|
|
||||||
"username": self._safe_username(sub=sub, email=email),
|
|
||||||
"name": display_name or email,
|
|
||||||
"email": email,
|
|
||||||
"is_active": is_active,
|
|
||||||
}
|
|
||||||
|
|
||||||
with self._client() as client:
|
|
||||||
resp = client.get("/api/v3/core/users/", params={"email": email})
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="authentik_lookup_failed")
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
results = data.get("results") if isinstance(data, dict) else None
|
|
||||||
existing = results[0] if isinstance(results, list) and results else None
|
|
||||||
|
|
||||||
if existing and existing.get("pk") is not None:
|
|
||||||
user_pk = int(existing["pk"])
|
|
||||||
patch_resp = client.patch(f"/api/v3/core/users/{user_pk}/", json=payload)
|
|
||||||
if patch_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="authentik_update_failed")
|
|
||||||
return AuthentikSyncResult(user_id=user_pk, action="updated")
|
|
||||||
|
|
||||||
create_resp = client.post("/api/v3/core/users/", json=payload)
|
|
||||||
if create_resp.status_code >= 400:
|
|
||||||
raise HTTPException(status_code=502, detail="authentik_create_failed")
|
|
||||||
created = create_resp.json()
|
|
||||||
return AuthentikSyncResult(user_id=int(created["pk"]), action="created")
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from app.schemas.permissions import PermissionItem, PermissionSnapshotResponse
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionService:
|
|
||||||
@staticmethod
|
|
||||||
def build_snapshot(authentik_sub: str, permissions: list[tuple[str, str, str, str]]) -> PermissionSnapshotResponse:
|
|
||||||
return PermissionSnapshotResponse(
|
|
||||||
authentik_sub=authentik_sub,
|
|
||||||
permissions=[
|
|
||||||
PermissionItem(scope_type=s_type, scope_id=s_id, module=module, action=action)
|
|
||||||
for s_type, s_id, module, action in permissions
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "memberapi-backend"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "memberapi.ose.tw backend"
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
dependencies = [
|
|
||||||
"fastapi>=0.116.0",
|
|
||||||
"uvicorn[standard]>=0.35.0",
|
|
||||||
"sqlalchemy>=2.0.44",
|
|
||||||
"psycopg[binary]>=3.2.9",
|
|
||||||
"pydantic-settings>=2.11.0",
|
|
||||||
"python-dotenv>=1.1.1",
|
|
||||||
"passlib[bcrypt]>=1.7.4",
|
|
||||||
"pyjwt[crypto]>=2.10.1",
|
|
||||||
"httpx>=0.28.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.4.2",
|
|
||||||
"ruff>=0.13.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
pythonpath = ["."]
|
|
||||||
addopts = "-q"
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["setuptools>=68", "wheel"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="Generate API key hash for api_clients table")
|
|
||||||
parser.add_argument("api_key", help="Plain API key")
|
|
||||||
parser.add_argument(
|
|
||||||
"--algo",
|
|
||||||
choices=["argon2", "bcrypt", "sha256"],
|
|
||||||
default="argon2",
|
|
||||||
help="Hash algorithm (default: argon2)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.algo == "sha256":
|
|
||||||
print("sha256:" + hashlib.sha256(args.api_key.encode("utf-8")).hexdigest())
|
|
||||||
return
|
|
||||||
|
|
||||||
print(pwd_context.hash(args.api_key, scheme=args.algo))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
authentik_sub VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
authentik_user_id INTEGER,
|
|
||||||
email VARCHAR(320),
|
|
||||||
display_name VARCHAR(255),
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS permissions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
scope_type VARCHAR(32) NOT NULL,
|
|
||||||
scope_id VARCHAR(128) NOT NULL,
|
|
||||||
module VARCHAR(128) NOT NULL,
|
|
||||||
action VARCHAR(32) NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT uq_permissions_user_scope_module_action
|
|
||||||
UNIQUE (user_id, scope_type, scope_id, module, action)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE users
|
|
||||||
ADD COLUMN IF NOT EXISTS authentik_user_id INTEGER;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
source .venv/bin/activate
|
|
||||||
exec uvicorn app.main:app --env-file .env.development --host 127.0.0.1 --port 8000 --reload
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
from app.security.authentik_jwt import AuthentikTokenVerifier
|
|
||||||
|
|
||||||
|
|
||||||
def test_infer_jwks_url() -> None:
|
|
||||||
assert AuthentikTokenVerifier._infer_jwks_url("https://auth.ose.tw/application/o/member/") == (
|
|
||||||
"https://auth.ose.tw/application/o/member/jwks/"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_me_requires_bearer_token() -> None:
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.get("/me")
|
|
||||||
assert resp.status_code == 401
|
|
||||||
assert resp.json()["detail"] == "missing_bearer_token"
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
|
|
||||||
def test_healthz() -> None:
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.get("/healthz")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json()["status"] == "ok"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
|
|
||||||
def test_internal_authentik_ensure_requires_config() -> None:
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.post(
|
|
||||||
"/internal/authentik/users/ensure",
|
|
||||||
headers={"X-Internal-Secret": "CHANGE_ME"},
|
|
||||||
json={
|
|
||||||
"sub": "authentik-sub-1",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"display_name": "User Example",
|
|
||||||
"is_active": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 503
|
|
||||||
assert resp.json()["detail"] == "authentik_admin_not_configured"
|
|
||||||
29
docker-compose.example.yml
Normal file
29
docker-compose.example.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: memberapi_ose_tw
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- postgres
|
||||||
|
- ose-cache
|
||||||
|
- nginx
|
||||||
|
|
||||||
|
networks:
|
||||||
|
postgres:
|
||||||
|
external: true
|
||||||
|
ose-cache:
|
||||||
|
external: true
|
||||||
|
nginx:
|
||||||
|
external: true
|
||||||
@@ -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 個 client(api_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;
|
|
||||||
44
docs/ARCHITECTURE.md
Normal file
44
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# member-platform 架構總覽(Keycloak 版)
|
||||||
|
|
||||||
|
## 核心模型
|
||||||
|
- 業務層:`companies -> sites`
|
||||||
|
- 身分層:`users <-> sites`(多對多,透過 `user_sites`)
|
||||||
|
- 能力層:`systems -> roles`
|
||||||
|
- 授權層:`sites <-> roles`(多對多,透過 `site_roles`)
|
||||||
|
|
||||||
|
## 權限模型(已定版)
|
||||||
|
- `permission` 正式改名為 `role`。
|
||||||
|
- `role` 僅能指派給 `site`,不可直接指派給 `user`。
|
||||||
|
- `system` / `role` 以 Keycloak 為唯一建立來源;member 後台只做同步顯示與關聯。
|
||||||
|
- `user` 的有效角色由以下關聯推導:
|
||||||
|
- `user_sites`(使用者屬於哪些 site)
|
||||||
|
- `site_roles`(site 擁有哪些 role)
|
||||||
|
- 不再使用舊的 `permission_groups` 主流程。
|
||||||
|
|
||||||
|
## Key 規則
|
||||||
|
- `system_key`: `SYyyyyMMddX####`
|
||||||
|
- `company_key`: `CPyyyyMMddX####`
|
||||||
|
- `site_key`: `STyyyyMMddX####`
|
||||||
|
- `role_key`: `RLyyyyMMddX####`
|
||||||
|
|
||||||
|
## Keycloak 同步策略
|
||||||
|
- Keycloak 為唯一 IdP。
|
||||||
|
- 群組階層:`Company Group -> Site SubGroup`。
|
||||||
|
- 系統角色:以 Keycloak client role 表示,對應 DB `roles`。
|
||||||
|
- `site_roles` 代表某 Site 擁有的 Keycloak role 集合。
|
||||||
|
- 同步策略改為手動觸發:不在列表讀取 (`R`) 時自動同步。
|
||||||
|
- 補齊策略:僅在手動同步按鈕(`POST /admin/sync/from-provider`)或 CUD 流程時同步。
|
||||||
|
- 站台角色指派(`PUT /admin/sites/{site_key}/roles`、`PUT /admin/roles/{role_key}/sites`)會即時同步到 Keycloak Group Role Mapping。
|
||||||
|
- 使用者加入 Site 時,透過同步邏輯使其在 IdP 端取得對應角色能力。
|
||||||
|
- 讀取效能:後端採用 memory cache(後續可換 Redis),`GET` 先讀快取;`POST/PUT/PATCH/DELETE` 成功後自動失效快取。
|
||||||
|
- 快取後端可由 `.env` 切換:`CACHE_BACKEND=memory|redis`(無需改程式)。
|
||||||
|
|
||||||
|
## 後台安全線
|
||||||
|
- `/admin/*` 必須 Bearer token。
|
||||||
|
- 後端以 Keycloak realm role 判定是否可進站與後台。
|
||||||
|
- 未具備 `MEMBER_REQUIRED_REALM_ROLES` 的帳號,`/me` 與 `/admin/*` 皆拒絕。
|
||||||
|
- 未具備 `ADMIN_REQUIRED_REALM_ROLES` 的帳號,`/admin/*` 拒絕。
|
||||||
|
|
||||||
|
## API 白名單
|
||||||
|
- 保留 `api_clients` 做系統對系統呼叫控管。
|
||||||
|
- 管理後台登入控管與 API client 白名單是兩條獨立安全線。
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# member 系統文件入口(Architecture & Config)
|
|
||||||
|
|
||||||
## 入口說明
|
|
||||||
這份文件是入口索引。若你只要快速開始,先看:
|
|
||||||
1. `docs/BACKEND_BOOTSTRAP.md`
|
|
||||||
2. `docs/BACKEND_ARCHITECTURE.md`
|
|
||||||
3. `docs/FRONTEND_ARCHITECTURE.md`
|
|
||||||
|
|
||||||
## 文件地圖
|
|
||||||
- `docs/index.md`
|
|
||||||
- 前端開工入口(給前端 AI 的第一份)
|
|
||||||
- `docs/BACKEND_BOOTSTRAP.md`
|
|
||||||
- 後端啟動步驟(環境、安裝、建表、啟動)
|
|
||||||
- `docs/BACKEND_ARCHITECTURE.md`
|
|
||||||
- memberapi 後端模組、資料流、API、安全策略
|
|
||||||
- `docs/FRONTEND_ARCHITECTURE.md`
|
|
||||||
- member 前端架構(可直接開工版)
|
|
||||||
- `docs/FRONTEND_API_CONTRACT.md`
|
|
||||||
- 前端 API request/response 契約
|
|
||||||
- `docs/FRONTEND_IMPLEMENTATION_CHECKLIST.md`
|
|
||||||
- 前端實作與交付核對清單
|
|
||||||
- `docs/TASKPLAN_FRONTEND.md`
|
|
||||||
- 前端任務進度與驗收條件
|
|
||||||
- `docs/TASKPLAN_BACKEND.md`
|
|
||||||
- 後端任務進度與驗收條件
|
|
||||||
- `docs/API_CLIENTS_SQL.sql`
|
|
||||||
- `api_clients` 白名單表與初始資料 SQL
|
|
||||||
- `docs/DB_SCHEMA_SNAPSHOT.md`
|
|
||||||
- 目前資料庫 schema 快照(欄位/索引/約束)
|
|
||||||
|
|
||||||
## 目前狀態(2026-03-29)
|
|
||||||
- 後端骨架:已建立(FastAPI + SQLAlchemy)
|
|
||||||
- 核心 API:已建立(health/internal/admin)
|
|
||||||
- API key 驗證:已建立(`X-Client-Key` + `X-API-Key`)
|
|
||||||
- Authentik JWT 驗證:已建立(`/me` 路由 + JWKS 驗簽)
|
|
||||||
- Authentik Admin API(建立/更新使用者):已建立(`/internal/authentik/users/ensure`)
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# memberapi.ose.tw 後端架構(FastAPI)
|
|
||||||
|
|
||||||
## 1. 目標與邊界
|
|
||||||
- 網域:`memberapi.ose.tw`
|
|
||||||
- 角色:會員中心後端真相來源(User + Permission)
|
|
||||||
- 範圍:
|
|
||||||
- user upsert(以 `authentik_sub` 為跨系統主鍵)
|
|
||||||
- permission grant/revoke
|
|
||||||
- permission snapshot 提供給其他系統
|
|
||||||
- 不在本服務處理:
|
|
||||||
- Authentik OIDC 流程頁與 UI
|
|
||||||
- 前端互動邏輯
|
|
||||||
|
|
||||||
## 2. 技術棧
|
|
||||||
- Python 3.12
|
|
||||||
- FastAPI
|
|
||||||
- SQLAlchemy 2.0
|
|
||||||
- PostgreSQL(psycopg)
|
|
||||||
- Pydantic Settings
|
|
||||||
|
|
||||||
## 3. 後端目錄(已建立)
|
|
||||||
- `backend/app/main.py`
|
|
||||||
- `backend/app/api/`
|
|
||||||
- `internal.py`
|
|
||||||
- `admin.py`
|
|
||||||
- `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. 資料模型
|
|
||||||
- `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/permissions/snapshot`
|
|
||||||
- Bearer token 由 Authentik JWT + JWKS 驗證,並以 `sub` 自動 upsert user
|
|
||||||
- 內部路由(系統對系統)
|
|
||||||
- `POST /internal/users/upsert-by-sub`
|
|
||||||
- `GET /internal/permissions/{authentik_sub}/snapshot`
|
|
||||||
- `POST /internal/authentik/users/ensure`
|
|
||||||
- header: `X-Internal-Secret`
|
|
||||||
- 管理路由(後台/API client)
|
|
||||||
- `POST /admin/permissions/grant`
|
|
||||||
- `POST /admin/permissions/revoke`
|
|
||||||
- headers: `X-Client-Key`, `X-API-Key`
|
|
||||||
|
|
||||||
## 6. 安全策略
|
|
||||||
- `admin` 路由強制 API client 驗證:
|
|
||||||
- client 必須存在且 `status=active`
|
|
||||||
- `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)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Backend Bootstrap(memberapi)
|
|
||||||
|
|
||||||
## 1. 環境準備
|
|
||||||
```bash
|
|
||||||
cd member.ose.tw/backend
|
|
||||||
python -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -e .
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. 建立資料表
|
|
||||||
1. 先執行 `member.ose.tw/docs/API_CLIENTS_SQL.sql`
|
|
||||||
2. 再執行 `member.ose.tw/backend/scripts/init_schema.sql`
|
|
||||||
3. 若是舊資料庫,補跑 `member.ose.tw/backend/scripts/migrate_add_authentik_user_id.sql`
|
|
||||||
|
|
||||||
## 3. 啟動服務
|
|
||||||
```bash
|
|
||||||
cd member.ose.tw/backend
|
|
||||||
./scripts/start_dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
說明:
|
|
||||||
- `start_dev.sh` 會用 `--env-file .env.development` 啟動,不需要每次手改 `.env`
|
|
||||||
|
|
||||||
## 4. Authentik JWT 最小設定
|
|
||||||
`.env` 至少填一種:
|
|
||||||
- `AUTHENTIK_JWKS_URL=<jwks endpoint>`
|
|
||||||
- `AUTHENTIK_ISSUER=<issuer url>`(系統會推導 `<issuer>/jwks/`)
|
|
||||||
|
|
||||||
可選:
|
|
||||||
- `AUTHENTIK_AUDIENCE=<audience>`
|
|
||||||
- `AUTHENTIK_CLIENT_SECRET=<client_secret>`(token 為 HS256 時需要)
|
|
||||||
|
|
||||||
## 5. 快速驗證
|
|
||||||
```bash
|
|
||||||
curl -sS http://127.0.0.1:8000/healthz
|
|
||||||
```
|
|
||||||
27
docs/BACKEND_TASKPLAN.md
Normal file
27
docs/BACKEND_TASKPLAN.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Backend TaskPlan
|
||||||
|
|
||||||
|
## 待辦
|
||||||
|
- [ ] Keycloak 同步器優化:Company/Site group 同步、System client role 同步、Site 角色套用同步(含效能與重複同步抑制)。
|
||||||
|
- [ ] 補齊 pytest API 測試(CRUD、關聯、同步、刪除、錯誤碼)。
|
||||||
|
- [ ] 補一支「一鍵重建 schema」腳本(串 `init_schema.sql`)。
|
||||||
|
|
||||||
|
## 進行中
|
||||||
|
- [ ] Swagger response model 與前端實際畫面持續對齊。
|
||||||
|
|
||||||
|
## 已完成
|
||||||
|
- [x] schema 重建:清除舊表,改為 `roles`、`site_roles`、`user_sites` 主流程。
|
||||||
|
- [x] 移除舊後端程式:`module/permission_group/user_scope_permissions` 相關 model/repo/api。
|
||||||
|
- [x] Admin API 改版:`Company/Site/System/Role/User` CRUD 與關聯 API。
|
||||||
|
- [x] Role 指派只允許綁 Site,不提供 user direct role API。
|
||||||
|
- [x] `/me/permissions/snapshot` 改為 role 聚合格式。
|
||||||
|
- [x] Internal API 改版:可取 `users/{user_sub}/roles` 聚合結果。
|
||||||
|
- [x] 保留 `api_clients` 白名單管理 API。
|
||||||
|
- [x] Keycloak OIDC 登入主流程。
|
||||||
|
- [x] `/admin/*` Bearer + admin 群組白名單安全線。
|
||||||
|
- [x] 公司/站台 CRUD 同步 Keycloak Group(create/update/delete)。
|
||||||
|
- [x] Keycloak -> DB 補齊同步(公司/站台/系統/角色/使用者)。
|
||||||
|
- [x] 系統改為 Keycloak 唯一來源(後台停用 system CRUD)。
|
||||||
|
- [x] Role CRUD 同步 Provider Client Role(新增/修改/刪除會同步到 Provider)。
|
||||||
|
- [x] Site/Role 關聯指派同步 Keycloak Group Role Mapping(雙向指派入口皆同步)。
|
||||||
|
- [x] 後端讀取快取(memory)與 CUD 自動失效機制(可後續切 Redis)。
|
||||||
|
- [x] 快取後端抽象完成:`.env` 可切換 `memory` / `redis`。
|
||||||
104
docs/DB_SCHEMA.md
Normal file
104
docs/DB_SCHEMA.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# DB Schema(新架構目標版)
|
||||||
|
|
||||||
|
> 本文件是新架構的目標資料模型,供後端 schema 重建與 migration 依據。
|
||||||
|
> DB 真實來源仍以 [backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql) 為準。
|
||||||
|
|
||||||
|
## 1) companies
|
||||||
|
- `id` UUID PK default `gen_random_uuid()`
|
||||||
|
- `company_key` TEXT NOT NULL UNIQUE
|
||||||
|
- `name` TEXT NOT NULL
|
||||||
|
- `provider_group_id` TEXT
|
||||||
|
- `status` VARCHAR(16) NOT NULL default `'active'`
|
||||||
|
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
|
||||||
|
## 2) sites
|
||||||
|
- `id` UUID PK default `gen_random_uuid()`
|
||||||
|
- `site_key` TEXT NOT NULL UNIQUE
|
||||||
|
- `company_id` UUID NOT NULL FK -> `companies(id)` ON DELETE CASCADE
|
||||||
|
- `display_name` TEXT NOT NULL
|
||||||
|
- `domain` TEXT
|
||||||
|
- `provider_group_id` TEXT
|
||||||
|
- `status` VARCHAR(16) NOT NULL default `'active'`
|
||||||
|
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
|
||||||
|
## 3) systems
|
||||||
|
- `id` UUID PK 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()`
|
||||||
|
|
||||||
|
## 4) roles
|
||||||
|
- `id` UUID PK default `gen_random_uuid()`
|
||||||
|
- `role_key` TEXT NOT NULL UNIQUE
|
||||||
|
- `role_code` TEXT NOT NULL(語意代碼,建議格式:`<system>:<module>:<action>`,例如 `mkt:marketing_card:edit`)
|
||||||
|
- `system_id` UUID NOT NULL FK -> `systems(id)` ON DELETE CASCADE
|
||||||
|
- `name` TEXT NOT NULL
|
||||||
|
- `description` TEXT
|
||||||
|
- `status` VARCHAR(16) NOT NULL default `'active'`
|
||||||
|
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
- UNIQUE(`system_id`, `name`)
|
||||||
|
- UNIQUE(`system_id`, `role_code`)
|
||||||
|
|
||||||
|
## 5) site_roles
|
||||||
|
- `id` UUID PK default `gen_random_uuid()`
|
||||||
|
- `site_id` UUID NOT NULL FK -> `sites(id)` ON DELETE CASCADE
|
||||||
|
- `role_id` UUID NOT NULL FK -> `roles(id)` ON DELETE CASCADE
|
||||||
|
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
- UNIQUE(`site_id`, `role_id`)
|
||||||
|
|
||||||
|
## 6) users
|
||||||
|
- `id` UUID PK default `gen_random_uuid()`
|
||||||
|
- `user_sub` TEXT NOT NULL UNIQUE
|
||||||
|
- `provider_user_id` TEXT UNIQUE
|
||||||
|
- `username` TEXT UNIQUE
|
||||||
|
- `email` TEXT UNIQUE
|
||||||
|
- `display_name` TEXT
|
||||||
|
- `status` VARCHAR(16) NOT NULL default `'active'`
|
||||||
|
- `is_active` BOOLEAN NOT NULL default `true`
|
||||||
|
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
|
||||||
|
## 7) user_sites
|
||||||
|
- `id` UUID PK default `gen_random_uuid()`
|
||||||
|
- `user_id` UUID NOT NULL FK -> `users(id)` ON DELETE CASCADE
|
||||||
|
- `site_id` UUID NOT NULL FK -> `sites(id)` ON DELETE CASCADE
|
||||||
|
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
- UNIQUE(`user_id`, `site_id`)
|
||||||
|
|
||||||
|
## 8) auth_sync_state
|
||||||
|
- `id` UUID PK default `gen_random_uuid()`
|
||||||
|
- `entity_type` VARCHAR(32) NOT NULL
|
||||||
|
- `entity_id` UUID NOT NULL
|
||||||
|
- `last_synced_at` TIMESTAMPTZ
|
||||||
|
- `source_version` TEXT
|
||||||
|
- `last_error` TEXT
|
||||||
|
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
|
||||||
|
- UNIQUE(`entity_type`, `entity_id`)
|
||||||
|
|
||||||
|
## 9) api_clients
|
||||||
|
- `id` UUID PK 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()`
|
||||||
|
|
||||||
|
## 關聯總結
|
||||||
|
- Company 1:N Site
|
||||||
|
- System 1:N Role
|
||||||
|
- Site M:N Role(`site_roles`)
|
||||||
|
- User M:N Site(`user_sites`)
|
||||||
|
- User 最終角色由 Site 推導,不做 user direct role 指派。
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
# DB Schema Snapshot
|
|
||||||
|
|
||||||
- Date: 2026-03-30
|
|
||||||
- Database: `member.ose.tw`
|
|
||||||
- Schema: `public`
|
|
||||||
|
|
||||||
## Tables
|
|
||||||
|
|
||||||
- `api_clients`
|
|
||||||
- `auth_sync_state`
|
|
||||||
- `companies`
|
|
||||||
- `modules`
|
|
||||||
- `permissions`
|
|
||||||
- `sites`
|
|
||||||
- `user_scope_permissions`
|
|
||||||
- `users`
|
|
||||||
|
|
||||||
## `api_clients`
|
|
||||||
|
|
||||||
### Columns
|
|
||||||
|
|
||||||
- `id`: `uuid` (not null: `true`)
|
|
||||||
- `client_key`: `text` (not null: `true`)
|
|
||||||
- `name`: `text` (not null: `true`)
|
|
||||||
- `status`: `client_status` (not null: `true`)
|
|
||||||
- `api_key_hash`: `text` (not null: `true`)
|
|
||||||
- `allowed_origins`: `jsonb` (not null: `true`)
|
|
||||||
- `allowed_ips`: `jsonb` (not null: `true`)
|
|
||||||
- `allowed_paths`: `jsonb` (not null: `true`)
|
|
||||||
- `rate_limit_per_min`: `integer` (not null: `false`)
|
|
||||||
- `expires_at`: `timestamp with time zone` (not null: `false`)
|
|
||||||
- `last_used_at`: `timestamp with time zone` (not null: `false`)
|
|
||||||
- `created_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
- `updated_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
|
|
||||||
### Constraints
|
|
||||||
|
|
||||||
- `api_clients_client_key_key` (`u`): UNIQUE (client_key)
|
|
||||||
- `api_clients_pkey` (`p`): PRIMARY KEY (id)
|
|
||||||
|
|
||||||
### Indexes
|
|
||||||
|
|
||||||
- `api_clients_client_key_key`: `CREATE UNIQUE INDEX api_clients_client_key_key ON public.api_clients USING btree (client_key)`
|
|
||||||
- `api_clients_pkey`: `CREATE UNIQUE INDEX api_clients_pkey ON public.api_clients USING btree (id)`
|
|
||||||
- `idx_api_clients_expires_at`: `CREATE INDEX idx_api_clients_expires_at ON public.api_clients USING btree (expires_at)`
|
|
||||||
- `idx_api_clients_status`: `CREATE INDEX idx_api_clients_status ON public.api_clients USING btree (status)`
|
|
||||||
|
|
||||||
## `auth_sync_state`
|
|
||||||
|
|
||||||
### Columns
|
|
||||||
|
|
||||||
- `user_id`: `uuid` (not null: `true`)
|
|
||||||
- `last_synced_at`: `timestamp with time zone` (not null: `false`)
|
|
||||||
- `source_version`: `text` (not null: `false`)
|
|
||||||
- `last_error`: `text` (not null: `false`)
|
|
||||||
- `updated_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
|
|
||||||
### Constraints
|
|
||||||
|
|
||||||
- `auth_sync_state_pkey` (`p`): PRIMARY KEY (user_id)
|
|
||||||
- `auth_sync_state_user_id_fkey` (`f`): FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
|
|
||||||
### Indexes
|
|
||||||
|
|
||||||
- `auth_sync_state_pkey`: `CREATE UNIQUE INDEX auth_sync_state_pkey ON public.auth_sync_state USING btree (user_id)`
|
|
||||||
|
|
||||||
## `companies`
|
|
||||||
|
|
||||||
### Columns
|
|
||||||
|
|
||||||
- `id`: `uuid` (not null: `true`)
|
|
||||||
- `company_key`: `text` (not null: `true`)
|
|
||||||
- `name`: `text` (not null: `true`)
|
|
||||||
- `status`: `record_status` (not null: `true`)
|
|
||||||
- `created_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
- `updated_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
|
|
||||||
### Constraints
|
|
||||||
|
|
||||||
- `companies_company_key_key` (`u`): UNIQUE (company_key)
|
|
||||||
- `companies_pkey` (`p`): PRIMARY KEY (id)
|
|
||||||
|
|
||||||
### Indexes
|
|
||||||
|
|
||||||
- `companies_company_key_key`: `CREATE UNIQUE INDEX companies_company_key_key ON public.companies USING btree (company_key)`
|
|
||||||
- `companies_pkey`: `CREATE UNIQUE INDEX companies_pkey ON public.companies USING btree (id)`
|
|
||||||
|
|
||||||
## `modules`
|
|
||||||
|
|
||||||
### Columns
|
|
||||||
|
|
||||||
- `id`: `uuid` (not null: `true`)
|
|
||||||
- `module_key`: `text` (not null: `true`)
|
|
||||||
- `name`: `text` (not null: `true`)
|
|
||||||
- `status`: `record_status` (not null: `true`)
|
|
||||||
- `created_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
- `updated_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
|
|
||||||
### Constraints
|
|
||||||
|
|
||||||
- `modules_module_key_key` (`u`): UNIQUE (module_key)
|
|
||||||
- `modules_pkey` (`p`): PRIMARY KEY (id)
|
|
||||||
|
|
||||||
### Indexes
|
|
||||||
|
|
||||||
- `modules_module_key_key`: `CREATE UNIQUE INDEX modules_module_key_key ON public.modules USING btree (module_key)`
|
|
||||||
- `modules_pkey`: `CREATE UNIQUE INDEX modules_pkey ON public.modules USING btree (id)`
|
|
||||||
|
|
||||||
## `permissions`
|
|
||||||
|
|
||||||
### Columns
|
|
||||||
|
|
||||||
- `id`: `uuid` (not null: `true`)
|
|
||||||
- `user_id`: `uuid` (not null: `true`)
|
|
||||||
- `scope_type`: `character varying(32)` (not null: `true`)
|
|
||||||
- `scope_id`: `character varying(128)` (not null: `true`)
|
|
||||||
- `module`: `character varying(128)` (not null: `true`)
|
|
||||||
- `action`: `character varying(32)` (not null: `true`)
|
|
||||||
- `created_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
|
|
||||||
### Constraints
|
|
||||||
|
|
||||||
- `permissions_pkey` (`p`): PRIMARY KEY (id)
|
|
||||||
- `permissions_user_id_fkey` (`f`): FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
- `uq_permissions_user_scope_module_action` (`u`): UNIQUE (user_id, scope_type, scope_id, module, action)
|
|
||||||
|
|
||||||
### Indexes
|
|
||||||
|
|
||||||
- `idx_permissions_user_id`: `CREATE INDEX idx_permissions_user_id ON public.permissions USING btree (user_id)`
|
|
||||||
- `permissions_pkey`: `CREATE UNIQUE INDEX permissions_pkey ON public.permissions USING btree (id)`
|
|
||||||
- `uq_permissions_user_scope_module_action`: `CREATE UNIQUE INDEX uq_permissions_user_scope_module_action ON public.permissions USING btree (user_id, scope_type, scope_id, module, action)`
|
|
||||||
|
|
||||||
## `sites`
|
|
||||||
|
|
||||||
### Columns
|
|
||||||
|
|
||||||
- `id`: `uuid` (not null: `true`)
|
|
||||||
- `site_key`: `text` (not null: `true`)
|
|
||||||
- `company_id`: `uuid` (not null: `true`)
|
|
||||||
- `name`: `text` (not null: `true`)
|
|
||||||
- `status`: `record_status` (not null: `true`)
|
|
||||||
- `created_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
- `updated_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
|
|
||||||
### Constraints
|
|
||||||
|
|
||||||
- `sites_company_id_fkey` (`f`): FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
|
||||||
- `sites_pkey` (`p`): PRIMARY KEY (id)
|
|
||||||
- `sites_site_key_key` (`u`): UNIQUE (site_key)
|
|
||||||
|
|
||||||
### Indexes
|
|
||||||
|
|
||||||
- `idx_sites_company_id`: `CREATE INDEX idx_sites_company_id ON public.sites USING btree (company_id)`
|
|
||||||
- `sites_pkey`: `CREATE UNIQUE INDEX sites_pkey ON public.sites USING btree (id)`
|
|
||||||
- `sites_site_key_key`: `CREATE UNIQUE INDEX sites_site_key_key ON public.sites USING btree (site_key)`
|
|
||||||
|
|
||||||
## `user_scope_permissions`
|
|
||||||
|
|
||||||
### Columns
|
|
||||||
|
|
||||||
- `id`: `uuid` (not null: `true`)
|
|
||||||
- `user_id`: `uuid` (not null: `true`)
|
|
||||||
- `module_id`: `uuid` (not null: `true`)
|
|
||||||
- `action`: `permission_action` (not null: `true`)
|
|
||||||
- `scope_type`: `scope_type` (not null: `true`)
|
|
||||||
- `company_id`: `uuid` (not null: `false`)
|
|
||||||
- `site_id`: `uuid` (not null: `false`)
|
|
||||||
- `created_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
- `updated_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
|
|
||||||
### Constraints
|
|
||||||
|
|
||||||
- `user_scope_permissions_check` (`c`): CHECK ((((scope_type = 'company'::scope_type) AND (company_id IS NOT NULL) AND (site_id IS NULL)) OR ((scope_type = 'site'::scope_type) AND (site_id IS NOT NULL) AND (company_id IS NULL))))
|
|
||||||
- `user_scope_permissions_company_id_fkey` (`f`): FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
|
||||||
- `user_scope_permissions_module_id_fkey` (`f`): FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
|
|
||||||
- `user_scope_permissions_pkey` (`p`): PRIMARY KEY (id)
|
|
||||||
- `user_scope_permissions_site_id_fkey` (`f`): FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
|
|
||||||
- `user_scope_permissions_user_id_fkey` (`f`): FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
|
|
||||||
### Indexes
|
|
||||||
|
|
||||||
- `idx_usp_company_id`: `CREATE INDEX idx_usp_company_id ON public.user_scope_permissions USING btree (company_id)`
|
|
||||||
- `idx_usp_module_id`: `CREATE INDEX idx_usp_module_id ON public.user_scope_permissions USING btree (module_id)`
|
|
||||||
- `idx_usp_site_id`: `CREATE INDEX idx_usp_site_id ON public.user_scope_permissions USING btree (site_id)`
|
|
||||||
- `idx_usp_user_id`: `CREATE INDEX idx_usp_user_id ON public.user_scope_permissions USING btree (user_id)`
|
|
||||||
- `uq_usp_company`: `CREATE UNIQUE INDEX uq_usp_company ON public.user_scope_permissions USING btree (user_id, module_id, action, scope_type, company_id) WHERE (scope_type = 'company'::scope_type)`
|
|
||||||
- `uq_usp_site`: `CREATE UNIQUE INDEX uq_usp_site ON public.user_scope_permissions USING btree (user_id, module_id, action, scope_type, site_id) WHERE (scope_type = 'site'::scope_type)`
|
|
||||||
- `user_scope_permissions_pkey`: `CREATE UNIQUE INDEX user_scope_permissions_pkey ON public.user_scope_permissions USING btree (id)`
|
|
||||||
|
|
||||||
## `users`
|
|
||||||
|
|
||||||
### Columns
|
|
||||||
|
|
||||||
- `id`: `uuid` (not null: `true`)
|
|
||||||
- `authentik_sub`: `text` (not null: `true`)
|
|
||||||
- `email`: `text` (not null: `false`)
|
|
||||||
- `display_name`: `text` (not null: `false`)
|
|
||||||
- `status`: `record_status` (not null: `true`)
|
|
||||||
- `created_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
- `updated_at`: `timestamp with time zone` (not null: `true`)
|
|
||||||
- `authentik_user_id`: `integer` (not null: `false`)
|
|
||||||
- `is_active`: `boolean` (not null: `true`)
|
|
||||||
|
|
||||||
### Constraints
|
|
||||||
|
|
||||||
- `users_authentik_sub_key` (`u`): UNIQUE (authentik_sub)
|
|
||||||
- `users_email_key` (`u`): UNIQUE (email)
|
|
||||||
- `users_pkey` (`p`): PRIMARY KEY (id)
|
|
||||||
|
|
||||||
### Indexes
|
|
||||||
|
|
||||||
- `idx_users_authentik_sub`: `CREATE INDEX idx_users_authentik_sub ON public.users USING btree (authentik_sub)`
|
|
||||||
- `users_authentik_sub_key`: `CREATE UNIQUE INDEX users_authentik_sub_key ON public.users USING btree (authentik_sub)`
|
|
||||||
- `users_email_key`: `CREATE UNIQUE INDEX users_email_key ON public.users USING btree (email)`
|
|
||||||
- `users_pkey`: `CREATE UNIQUE INDEX users_pkey ON public.users USING btree (id)`
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# Frontend API Contract(memberapi)
|
|
||||||
|
|
||||||
Base URL:`https://memberapi.ose.tw`
|
|
||||||
|
|
||||||
## 0. 帳號密碼登入
|
|
||||||
### POST `/auth/login`
|
|
||||||
Request:
|
|
||||||
```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. 使用者資訊
|
|
||||||
### GET `/me`
|
|
||||||
Headers:
|
|
||||||
- `Authorization: Bearer <access_token>`
|
|
||||||
|
|
||||||
200 Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sub": "authentik-sub-123",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"display_name": "User Name"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Grant 權限
|
|
||||||
### POST `/admin/permissions/grant`
|
|
||||||
Headers:
|
|
||||||
- `X-Client-Key: <client_key>`
|
|
||||||
- `X-API-Key: <plain_api_key>`
|
|
||||||
|
|
||||||
Request:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"authentik_sub": "authentik-sub-123",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"display_name": "User Name",
|
|
||||||
"scope_type": "site",
|
|
||||||
"scope_id": "tw-main",
|
|
||||||
"module": "campaign",
|
|
||||||
"action": "view"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
200 Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"permission_id": "uuid",
|
|
||||||
"result": "granted"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Revoke 權限
|
|
||||||
### POST `/admin/permissions/revoke`
|
|
||||||
Headers:
|
|
||||||
- `X-Client-Key: <client_key>`
|
|
||||||
- `X-API-Key: <plain_api_key>`
|
|
||||||
|
|
||||||
Request:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"authentik_sub": "authentik-sub-123",
|
|
||||||
"scope_type": "site",
|
|
||||||
"scope_id": "tw-main",
|
|
||||||
"module": "campaign",
|
|
||||||
"action": "view"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
200 Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deleted": 1,
|
|
||||||
"result": "revoked"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
404 Response:
|
|
||||||
```json
|
|
||||||
{ "detail": "user_not_found" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Health Check
|
|
||||||
### GET `/healthz`
|
|
||||||
200 Response:
|
|
||||||
```json
|
|
||||||
{ "status": "ok" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 常見錯誤碼
|
|
||||||
- `401 invalid_client`
|
|
||||||
- `401 invalid_api_key`
|
|
||||||
- `401 client_expired`
|
|
||||||
- `403 origin_not_allowed`
|
|
||||||
- `403 ip_not_allowed`
|
|
||||||
- `403 path_not_allowed`
|
|
||||||
- `503 internal_secret_not_configured`
|
|
||||||
- `503 authentik_admin_not_configured`
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# member.ose.tw 前端架構(可直接開工版)
|
|
||||||
|
|
||||||
## 1. 前端責任邊界
|
|
||||||
- 站點:`member.ose.tw`
|
|
||||||
- 主要責任:
|
|
||||||
- 顯示目前登入使用者資訊
|
|
||||||
- 顯示目前使用者權限快照
|
|
||||||
- 透過管理 API 執行 grant/revoke
|
|
||||||
- 不處理:
|
|
||||||
- Authentik 管理 API 直連
|
|
||||||
- 密碼重設流程(導向 Authentik)
|
|
||||||
|
|
||||||
## 2. 建議技術堆疊
|
|
||||||
- Vue 3 + Vite + JavaScript
|
|
||||||
- Vue Router
|
|
||||||
- Pinia
|
|
||||||
- Axios
|
|
||||||
- Element Plus + Tailwind
|
|
||||||
|
|
||||||
## 3. 建議目錄結構
|
|
||||||
- `frontend/src/main.js`
|
|
||||||
- `frontend/src/router/index.js`
|
|
||||||
- `frontend/src/stores/`
|
|
||||||
- `auth.js`:token、me、登入狀態
|
|
||||||
- `permission.js`:snapshot 與快取
|
|
||||||
- `frontend/src/api/`
|
|
||||||
- `http.js`:axios instance + interceptor
|
|
||||||
- `me.js`
|
|
||||||
- `permission-admin.js`
|
|
||||||
- `frontend/src/pages/`
|
|
||||||
- `profile/MePage.vue`
|
|
||||||
- `permissions/PermissionSnapshotPage.vue`
|
|
||||||
- `permissions/PermissionAdminPage.vue`
|
|
||||||
- `frontend/src/types/`
|
|
||||||
- `api.js`
|
|
||||||
|
|
||||||
## 4. 路由規劃(第一版)
|
|
||||||
- `/me`
|
|
||||||
- 顯示 `GET /me` 資料
|
|
||||||
- `/me/permissions`
|
|
||||||
- 顯示 `GET /me/permissions/snapshot`
|
|
||||||
- `/admin/permissions`
|
|
||||||
- grant/revoke 表單與結果顯示
|
|
||||||
|
|
||||||
## 5. Token 與 Header 策略
|
|
||||||
- 使用者路由(`/me*`)
|
|
||||||
- 登入用 `POST /auth/login`(帳號密碼)取得 access token
|
|
||||||
- header: `Authorization: Bearer <access_token>`
|
|
||||||
- 管理路由(`/admin*`)
|
|
||||||
- headers:
|
|
||||||
- `X-Client-Key`
|
|
||||||
- `X-API-Key`
|
|
||||||
- 建議:
|
|
||||||
- 使用 axios request interceptor 統一注入 header
|
|
||||||
- 401 時統一導向登入或刷新流程
|
|
||||||
|
|
||||||
## 6. 狀態管理最小模型
|
|
||||||
- `auth store`
|
|
||||||
- `accessToken: string | null`
|
|
||||||
- `me: { sub, email, display_name } | null`
|
|
||||||
- actions: `setToken`, `fetchMe`, `logout`
|
|
||||||
- `permission store`
|
|
||||||
- `snapshot: { authentik_sub, permissions[] } | null`
|
|
||||||
- actions: `fetchMySnapshot`, `grantPermission`, `revokePermission`
|
|
||||||
|
|
||||||
## 7. 錯誤處理規則
|
|
||||||
- `401`
|
|
||||||
- 使用者 token 過期或無效,導回登入
|
|
||||||
- `403`
|
|
||||||
- API client 白名單限制(origin/ip/path)
|
|
||||||
- `404`
|
|
||||||
- revoke 時 user 不存在
|
|
||||||
- `503`
|
|
||||||
- 後端必要設定缺失(例如 internal secret / authentik admin)
|
|
||||||
|
|
||||||
## 8. 開工順序(建議)
|
|
||||||
1. 建立 axios client 與 interceptor
|
|
||||||
2. 完成 `/me` 與 `/me/permissions` 畫面
|
|
||||||
3. 完成 grant/revoke 表單
|
|
||||||
4. 補通知、loading、錯誤提示
|
|
||||||
|
|
||||||
詳細 request/response 契約請看 `docs/FRONTEND_API_CONTRACT.md`。
|
|
||||||
|
|
||||||
## 9. 本地開發環境
|
|
||||||
- Vite 會自動讀取 `frontend/.env.development`
|
|
||||||
- 已預設:
|
|
||||||
- `VITE_API_BASE_URL=http://127.0.0.1:8000`
|
|
||||||
46
docs/FRONTEND_HANDOFF.md
Normal file
46
docs/FRONTEND_HANDOFF.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Frontend Handoff(Role-Site 模型)
|
||||||
|
|
||||||
|
## 目標
|
||||||
|
前端只實作新模型,不再使用舊 `permission_groups` / `module-action` 流程。
|
||||||
|
|
||||||
|
## 主要頁面
|
||||||
|
1. 公司管理(CRUD)
|
||||||
|
- 欄位:`company_key`, `name`, `status`
|
||||||
|
- 詳情頁需顯示底下 `sites` 列表
|
||||||
|
|
||||||
|
2. 站台管理(CRUD)
|
||||||
|
- 欄位:`site_key`, `company_key`, `display_name`, `domain`, `status`
|
||||||
|
- 站台詳情需顯示:
|
||||||
|
- 此站台綁定的 `roles`
|
||||||
|
- 此站台包含的 `users`
|
||||||
|
|
||||||
|
3. 系統管理(唯讀 + 同步)
|
||||||
|
- 欄位:`system_key`, `name`, `status`
|
||||||
|
- 系統詳情需顯示底下 `roles` 列表
|
||||||
|
- 建立/修改/刪除在 Keycloak 處理,member 後台提供「同步 Keycloak」按鈕
|
||||||
|
- 所有資料列表頁不自動同步;需由使用者按下「同步」按鈕才觸發。
|
||||||
|
|
||||||
|
4. 角色管理(DB 關聯為主)
|
||||||
|
- 欄位:`role_key`, `system_key`, `name`, `description`, `status`
|
||||||
|
- 關聯操作:指派到 Site(新增/刪除 `site_roles`)
|
||||||
|
- 需支援在「角色頁」直接多選站台並儲存(不必切到站台頁)。
|
||||||
|
|
||||||
|
5. 會員管理(CRUD)
|
||||||
|
- 欄位:`user_sub`, `username`, `email`, `display_name`, `is_active`, `status`
|
||||||
|
- 關聯操作:加入/移除 Site(新增/刪除 `user_sites`)
|
||||||
|
- 顯示推導角色(唯讀)
|
||||||
|
|
||||||
|
6. API Clients 管理(CRUD)
|
||||||
|
- 欄位:`client_key`, `name`, `status`, `allowed_origins`, `allowed_ips`, `allowed_paths`, `rate_limit_per_min`, `expires_at`
|
||||||
|
|
||||||
|
## 前端互動規則
|
||||||
|
- 角色不可直接綁會員(UI 不提供此操作)。
|
||||||
|
- Site 與 User 的關聯調整後,角色顯示即時刷新。
|
||||||
|
- 刪除操作一律二次確認,顯示影響提示。
|
||||||
|
|
||||||
|
## 驗收重點
|
||||||
|
- 看不到舊 permission group 流程。
|
||||||
|
- 可以完整做:
|
||||||
|
- Site 綁 Role
|
||||||
|
- User 綁 Site
|
||||||
|
- 顯示 User 推導角色
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Frontend Implementation Checklist
|
|
||||||
|
|
||||||
## A. 專案初始化
|
|
||||||
- [ ] 建立 `src/api/http.js`
|
|
||||||
- [ ] 建立 `src/stores/auth.js`
|
|
||||||
- [ ] 建立 `src/stores/permission.js`
|
|
||||||
- [ ] 配置 `VITE_API_BASE_URL`
|
|
||||||
|
|
||||||
## B. API 對接
|
|
||||||
- [ ] `GET /me`
|
|
||||||
- [ ] `GET /me/permissions/snapshot`
|
|
||||||
- [ ] `POST /admin/permissions/grant`
|
|
||||||
- [ ] `POST /admin/permissions/revoke`
|
|
||||||
|
|
||||||
## C. 頁面
|
|
||||||
- [ ] Me 頁面(顯示 sub/email/display_name)
|
|
||||||
- [ ] 我的權限頁(表格)
|
|
||||||
- [ ] 權限管理頁(grant/revoke)
|
|
||||||
|
|
||||||
## D. 行為驗證
|
|
||||||
- [ ] Bearer token 遺失時顯示重新登入
|
|
||||||
- [ ] grant 成功後自動刷新 snapshot
|
|
||||||
- [ ] revoke 成功後自動刷新 snapshot
|
|
||||||
- [ ] 404 user_not_found 顯示可讀錯誤訊息
|
|
||||||
|
|
||||||
## E. 交付條件
|
|
||||||
- [ ] 三個頁面都可獨立刷新
|
|
||||||
- [ ] 所有 API 錯誤碼都有 UI 提示
|
|
||||||
- [ ] 主要操作有 loading 與成功提示
|
|
||||||
20
docs/FRONTEND_TASKPLAN.md
Normal file
20
docs/FRONTEND_TASKPLAN.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Frontend TaskPlan
|
||||||
|
|
||||||
|
## 待辦
|
||||||
|
- [ ] API 失敗狀態頁統一(401/403/409/422/500)。
|
||||||
|
|
||||||
|
## 進行中
|
||||||
|
- [ ] OIDC callback 與 token 持久化穩定性檢查(含 token 到期後重新登入行為)。
|
||||||
|
|
||||||
|
## 已完成
|
||||||
|
- [x] Vue3 + JS + Vite + Element Plus + Tailwind 基礎框架。
|
||||||
|
- [x] OIDC 登入按鈕導轉與 callback 路由骨架。
|
||||||
|
- [x] 後台導覽改版:`公司 / 站台 / 系統 / 角色 / 會員 / API Clients`。
|
||||||
|
- [x] 公司頁:CRUD + 公司底下站台列表。
|
||||||
|
- [x] 站台頁:CRUD + 站台角色指派 + 站台會員列表。
|
||||||
|
- [x] 系統頁:改為 Keycloak 唯一來源(唯讀 + 手動同步按鈕 + 角色列表)。
|
||||||
|
- [x] 角色頁:CRUD + 角色綁定站台列表。
|
||||||
|
- [x] 會員頁:CRUD + User 綁 Site + 顯示推導角色 + 重設密碼。
|
||||||
|
- [x] 刪除流程補齊(公司/站台/系統/角色/會員/API client)。
|
||||||
|
- [x] 移除舊流程前端檔案:`modules` / `permission_groups` / `permission_admin` 頁面與 API。
|
||||||
|
- [x] 我的角色頁改為 role snapshot 顯示(不再顯示舊 permission 欄位)。
|
||||||
49
docs/INTERNAL_API_HANDOFF.md
Normal file
49
docs/INTERNAL_API_HANDOFF.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Internal API Handoff(新模型)
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
- Local: `http://127.0.0.1:8000`
|
||||||
|
- Prod: 由部署環境提供
|
||||||
|
|
||||||
|
## Auth Headers(`/internal/*`)
|
||||||
|
- `X-Client-Key: <client_key>`
|
||||||
|
- `X-API-Key: <api_key>`
|
||||||
|
|
||||||
|
## Common Error Response
|
||||||
|
```json
|
||||||
|
{ "detail": "error_code" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已實作端點
|
||||||
|
1. `GET /internal/companies`
|
||||||
|
2. `GET /internal/sites`
|
||||||
|
3. `GET /internal/systems`
|
||||||
|
4. `GET /internal/roles`
|
||||||
|
5. `GET /internal/members`
|
||||||
|
6. `POST /internal/users/upsert-by-sub`
|
||||||
|
7. `GET /internal/users/{user_sub}/roles`
|
||||||
|
8. `POST /internal/provider/users/ensure`
|
||||||
|
|
||||||
|
## 角色聚合回應(`GET /internal/users/{user_sub}/roles`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_sub": "xxxxxxxx",
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"site_key": "ST20260402X1234",
|
||||||
|
"site_display_name": "OSE Main",
|
||||||
|
"company_key": "CP20260402X5678",
|
||||||
|
"company_display_name": "OSE",
|
||||||
|
"system_key": "SY20260402X0001",
|
||||||
|
"system_name": "Marketing",
|
||||||
|
"role_key": "RL20260402X0002",
|
||||||
|
"role_code": "mkt:marketing_card:edit",
|
||||||
|
"role_name": "campaign_edit"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事項
|
||||||
|
- 不提供 user direct role 寫入 API。
|
||||||
|
- User 最終角色由 `user_sites` + `site_roles` 推導。
|
||||||
|
- `role_key` 是唯一識別鍵;業務語意解析請使用 `role_code`。
|
||||||
83
docs/LOCAL_DEV_RUNBOOK.md
Normal file
83
docs/LOCAL_DEV_RUNBOOK.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Local Dev Runbook(Keycloak)
|
||||||
|
|
||||||
|
## 1) 先重建 DB schema(本次改版必做)
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
psql "$DATABASE_URL" -f scripts/init_schema.sql
|
||||||
|
```
|
||||||
|
- DB schema 檔案:[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql)
|
||||||
|
|
||||||
|
## 2) 啟動後端
|
||||||
|
本地開發使用 `.env.development`:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
# edit .env.development directly
|
||||||
|
```
|
||||||
|
|
||||||
|
本機開發固定使用 `backend/.env.development`。
|
||||||
|
|
||||||
|
再啟動:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
./scripts/start_dev.sh
|
||||||
|
```
|
||||||
|
- 專案路徑:[backend](../backend)
|
||||||
|
- 啟動腳本:[backend/scripts/start_dev.sh](../backend/scripts/start_dev.sh)
|
||||||
|
|
||||||
|
## 3) 啟動前端
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
- 本地開發固定使用 `frontend/.env.development`。
|
||||||
|
- production build 讀取 `frontend/.env.production`。
|
||||||
|
- 專案路徑:[frontend](../frontend)
|
||||||
|
|
||||||
|
## 4) 必要環境變數([backend/.env.development](../backend/.env.development))
|
||||||
|
- `KEYCLOAK_BASE_URL`
|
||||||
|
- `KEYCLOAK_REALM`
|
||||||
|
- `KEYCLOAK_CLIENT_ID`
|
||||||
|
- `KEYCLOAK_CLIENT_SECRET`
|
||||||
|
- `KEYCLOAK_ADMIN_CLIENT_ID`
|
||||||
|
- `KEYCLOAK_ADMIN_CLIENT_SECRET`
|
||||||
|
- `MEMBER_REQUIRED_REALM_ROLES`
|
||||||
|
- `ADMIN_REQUIRED_REALM_ROLES`
|
||||||
|
- `CACHE_BACKEND`(`memory` 或 `redis`)
|
||||||
|
- `CACHE_REDIS_URL`
|
||||||
|
- `CACHE_PREFIX`
|
||||||
|
- `CACHE_DEFAULT_TTL_SECONDS`
|
||||||
|
|
||||||
|
### Cache 切換範例
|
||||||
|
- 本地(預設):
|
||||||
|
- `CACHE_BACKEND=memory`
|
||||||
|
- 切 Redis:
|
||||||
|
- `CACHE_BACKEND=redis`
|
||||||
|
- `CACHE_REDIS_URL=redis://127.0.0.1:6379/0`
|
||||||
|
|
||||||
|
調整後重啟後端生效。
|
||||||
|
|
||||||
|
## 5) 基本檢查
|
||||||
|
1. `GET http://127.0.0.1:8000/healthz` 應為 200。
|
||||||
|
2. 前端按「前往 Keycloak 登入」應可成功導轉與回跳。
|
||||||
|
3. `GET /me` 登入後應有資料。
|
||||||
|
4. 非 admin realm role 帳號打 `/admin/*` 應為 403。
|
||||||
|
5. `POST /admin/sync/from-provider?force=true` 可手動觸發全量補齊同步。
|
||||||
|
6. 列表 API 不會自動同步 IdP(避免高負載),需手動按同步按鈕或呼叫同步 API。
|
||||||
|
|
||||||
|
## 6) 新模型驗收路徑
|
||||||
|
1. 新增 Company、Site。
|
||||||
|
2. 在 Keycloak 建立 System(Client)與 Role(Client Role)。
|
||||||
|
3. 在後台按「同步 Keycloak」,確認 DB 補齊 System/Role。
|
||||||
|
4. 對 Site 指派 Role。
|
||||||
|
5. 新增 User,加入 Site。
|
||||||
|
6. 驗證 User 的角色是由 Site 推導,不是 direct assign。
|
||||||
|
|
||||||
|
## 7) API 白名單驗收
|
||||||
|
1. 建立 `api_client`。
|
||||||
|
2. 用 `X-Client-Key` + `X-API-Key` 呼叫 `/internal/*`。
|
||||||
|
3. 驗證未授權 key 會被拒絕。
|
||||||
|
|
||||||
|
## 8) VPS Docker 啟動(Backend)
|
||||||
|
- Dockerfile: [backend/Dockerfile](../backend/Dockerfile)
|
||||||
|
- 建置與啟動指令:參考 [backend/README.md](../backend/README.md) 的 `Docker (VPS / Production)` 章節。
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Backend TaskPlan
|
|
||||||
|
|
||||||
## 目標
|
|
||||||
提供可被前端穩定串接的 memberapi,包含身份驗證、權限快照、權限管理與 Authentik 同步能力。
|
|
||||||
|
|
||||||
## 已完成
|
|
||||||
- [x] FastAPI 專案骨架與分層(api/service/repository/model)
|
|
||||||
- [x] `GET /healthz`
|
|
||||||
- [x] `GET /me`
|
|
||||||
- [x] `GET /me/permissions/snapshot`
|
|
||||||
- [x] `POST /admin/permissions/grant`
|
|
||||||
- [x] `POST /admin/permissions/revoke`
|
|
||||||
- [x] `POST /internal/users/upsert-by-sub`
|
|
||||||
- [x] `GET /internal/permissions/{authentik_sub}/snapshot`
|
|
||||||
- [x] `POST /internal/authentik/users/ensure`
|
|
||||||
- [x] API client 驗證(`X-Client-Key` + `X-API-Key`)
|
|
||||||
- [x] Authentik JWT 驗證(JWKS)
|
|
||||||
- [x] DB schema 初始化 SQL + migration SQL
|
|
||||||
- [x] 基本測試與 lint
|
|
||||||
|
|
||||||
## 進行中(建議近期)
|
|
||||||
- [ ] 增加 Alembic migration 正式流程
|
|
||||||
- [ ] 補齊更多 API 測試(admin/internal success + fail cases)
|
|
||||||
- [ ] 增加 request id / logging 格式
|
|
||||||
|
|
||||||
## 待辦(上線前)
|
|
||||||
- [ ] 加入 rate limit(依 client 或 IP)
|
|
||||||
- [ ] 針對 admin API 加 audit log(授權/撤銷行為)
|
|
||||||
- [ ] 加上 CORS 白名單與反向代理配置驗證
|
|
||||||
- [ ] 製作部署腳本(systemd / container)
|
|
||||||
|
|
||||||
## 驗收條件
|
|
||||||
- [ ] 前端可完成 `/me` + `/me/permissions/snapshot` + grant/revoke 全流程
|
|
||||||
- [ ] 401/403/404/503 錯誤碼與文件一致
|
|
||||||
- [ ] 至少一輪 staging smoke test 通過
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Frontend TaskPlan
|
|
||||||
|
|
||||||
## 目標
|
|
||||||
完成 member.ose.tw 前端(Vue3 + JS + Vite + Element Plus + Tailwind),可獨立完成登入、查看個人資料、查看權限、管理授權。
|
|
||||||
|
|
||||||
## 已完成(依目前程式)
|
|
||||||
- [x] Vite + Vue3 專案結構
|
|
||||||
- [x] Element Plus + Tailwind 基礎接入
|
|
||||||
- [x] Router 與頁面骨架
|
|
||||||
- [x] `LoginPage`(token 輸入)
|
|
||||||
- [x] `MePage`(`GET /me`)
|
|
||||||
- [x] `PermissionSnapshotPage`(`GET /me/permissions/snapshot`)
|
|
||||||
- [x] `PermissionAdminPage`(grant/revoke)
|
|
||||||
- [x] Pinia store(auth + permission)
|
|
||||||
- [x] Axios 分離 user/admin client
|
|
||||||
- [x] Production build 可通過
|
|
||||||
|
|
||||||
## 進行中(建議近期)
|
|
||||||
- [ ] 補路由守衛策略(是否限制 `/admin/permissions` 需登入)
|
|
||||||
- [ ] 錯誤訊息 i18n 與統一顯示格式
|
|
||||||
- [ ] 新增操作完成後自動刷新快照的 UX
|
|
||||||
|
|
||||||
## 待辦(上線前)
|
|
||||||
- [ ] 增加 e2e / UI smoke 測試
|
|
||||||
- [ ] 優化 bundle size(目前 main chunk 偏大)
|
|
||||||
- [ ] 加入環境切換策略(dev/staging/prod)
|
|
||||||
- [ ] 加入登入來源與 token 取得說明頁
|
|
||||||
|
|
||||||
## 驗收條件
|
|
||||||
- [ ] 未登入時導向登入頁行為正確
|
|
||||||
- [ ] 登入後可穩定讀取 `/me` 與快照
|
|
||||||
- [ ] 管理頁 grant/revoke 成功與錯誤提示完整
|
|
||||||
- [ ] 與後端契約文件一致(`FRONTEND_API_CONTRACT.md`)
|
|
||||||
109
docs/VPS_DEPLOY_RUNBOOK.md
Normal file
109
docs/VPS_DEPLOY_RUNBOOK.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# VPS Deploy Runbook
|
||||||
|
|
||||||
|
## 1) 拉整合層 + 子模組
|
||||||
|
```bash
|
||||||
|
cd /opt
|
||||||
|
git clone --recurse-submodules http://127.0.0.1:8888/member/member-platform.git
|
||||||
|
cd member-platform
|
||||||
|
git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) 後端部署(Docker)
|
||||||
|
```bash
|
||||||
|
cd /opt/member-platform/backend
|
||||||
|
cp .env.production .env
|
||||||
|
```
|
||||||
|
編輯 `.env`(DB、Keycloak、Realm Roles、Cache)。
|
||||||
|
|
||||||
|
首次建表:
|
||||||
|
```bash
|
||||||
|
psql "postgresql://<user>:<pass>@<host>:<port>/<db>" -f scripts/init_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
啟動:
|
||||||
|
```bash
|
||||||
|
docker build -t memberapi-backend:latest .
|
||||||
|
docker rm -f memberapi-backend 2>/dev/null || true
|
||||||
|
docker run -d \
|
||||||
|
--name memberapi-backend \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 127.0.0.1:8000:8000 \
|
||||||
|
--env-file .env \
|
||||||
|
memberapi-backend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
檢查:
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/healthz
|
||||||
|
docker logs -f memberapi-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用 docker compose(建議)
|
||||||
|
Compose 檔案:[docker-compose.example.yml](../docker-compose.example.yml)
|
||||||
|
|
||||||
|
啟動:
|
||||||
|
```bash
|
||||||
|
cd /opt/member-platform
|
||||||
|
cp docker-compose.example.yml docker-compose.yml
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
檢查:
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
停止:
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) 前端部署(Nginx)
|
||||||
|
```bash
|
||||||
|
cd /opt/member-platform/frontend
|
||||||
|
```
|
||||||
|
production build 會自動讀取 `.env.production`,請先確認設定:
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=https://memberapi.ose.tw
|
||||||
|
```
|
||||||
|
|
||||||
|
Build:
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx root 指向 `frontend/dist`,並加 SPA rewrite:
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) 更新流程
|
||||||
|
```bash
|
||||||
|
cd /opt/member-platform
|
||||||
|
git pull
|
||||||
|
git submodule update --init --recursive --remote
|
||||||
|
```
|
||||||
|
|
||||||
|
後端更新:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
docker build -t memberapi-backend:latest .
|
||||||
|
docker rm -f memberapi-backend
|
||||||
|
docker run -d --name memberapi-backend --restart unless-stopped -p 127.0.0.1:8000:8000 --env-file .env memberapi-backend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
前端更新:
|
||||||
|
```bash
|
||||||
|
cd ../frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5) 建議網域
|
||||||
|
- Frontend: `member.ose.tw`
|
||||||
|
- API: `memberapi.ose.tw`(反代 `127.0.0.1:8000`)
|
||||||
81
docs/directus/directus_key_autogen.sql
Normal file
81
docs/directus/directus_key_autogen.sql
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
-- Directus key auto-generation triggers
|
||||||
|
-- Target tables: companies, sites, users, roles
|
||||||
|
-- Key format: PREFIX + yyyymmdd + 'X' + 4 digits
|
||||||
|
-- Example: CP20260404X1234
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.directus_autogen_entity_key()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_column_name text := TG_ARGV[0];
|
||||||
|
v_prefix text := TG_ARGV[1];
|
||||||
|
v_current_value text;
|
||||||
|
v_candidate text;
|
||||||
|
v_exists boolean;
|
||||||
|
v_attempt int;
|
||||||
|
v_day text;
|
||||||
|
v_suffix text;
|
||||||
|
BEGIN
|
||||||
|
v_current_value := to_jsonb(NEW) ->> v_column_name;
|
||||||
|
IF v_current_value IS NOT NULL AND btrim(v_current_value) <> '' THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_day := to_char(clock_timestamp(), 'YYYYMMDD');
|
||||||
|
|
||||||
|
FOR v_attempt IN 0..9999 LOOP
|
||||||
|
v_suffix := lpad((((extract(epoch FROM clock_timestamp()) * 1000)::bigint + v_attempt) % 10000)::text, 4, '0');
|
||||||
|
v_candidate := v_prefix || v_day || 'X' || v_suffix;
|
||||||
|
|
||||||
|
EXECUTE format(
|
||||||
|
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I = $1)',
|
||||||
|
TG_TABLE_SCHEMA,
|
||||||
|
TG_TABLE_NAME,
|
||||||
|
v_column_name
|
||||||
|
) INTO v_exists USING v_candidate;
|
||||||
|
|
||||||
|
IF NOT v_exists THEN
|
||||||
|
NEW := jsonb_populate_record(NEW, jsonb_build_object(v_column_name, v_candidate));
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE EXCEPTION 'Failed to generate unique key for %.% (column=%)', TG_TABLE_SCHEMA, TG_TABLE_NAME, v_column_name;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_companies_key_autogen ON public.companies;
|
||||||
|
CREATE TRIGGER trg_companies_key_autogen
|
||||||
|
BEFORE INSERT ON public.companies
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'CP');
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_sites_key_autogen ON public.sites;
|
||||||
|
CREATE TRIGGER trg_sites_key_autogen
|
||||||
|
BEFORE INSERT ON public.sites
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'ST');
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_users_key_autogen ON public.users;
|
||||||
|
CREATE TRIGGER trg_users_key_autogen
|
||||||
|
BEFORE INSERT ON public.users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'UE');
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_roles_key_autogen ON public.roles;
|
||||||
|
CREATE TRIGGER trg_roles_key_autogen
|
||||||
|
BEFORE INSERT ON public.roles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'RL');
|
||||||
|
|
||||||
|
-- cleanup old trigger names to avoid duplicates from previous scripts
|
||||||
|
DROP TRIGGER IF EXISTS trg_companies_company_key_autogen ON public.companies;
|
||||||
|
DROP TRIGGER IF EXISTS trg_sites_site_key_autogen ON public.sites;
|
||||||
|
DROP TRIGGER IF EXISTS trg_users_user_key_autogen ON public.users;
|
||||||
|
DROP TRIGGER IF EXISTS trg_systems_system_key_autogen ON public.systems;
|
||||||
|
DROP TRIGGER IF EXISTS trg_roles_role_key_autogen ON public.roles;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
3657
docs/directus/member-schema.directus.json
Normal file
3657
docs/directus/member-schema.directus.json
Normal file
File diff suppressed because it is too large
Load Diff
3656
docs/directus/member-schema.directus.uuid.json
Normal file
3656
docs/directus/member-schema.directus.uuid.json
Normal file
File diff suppressed because it is too large
Load Diff
3702
docs/directus/member-schema.directus.uuid.keys.json
Normal file
3702
docs/directus/member-schema.directus.uuid.keys.json
Normal file
File diff suppressed because it is too large
Load Diff
182
docs/directus/member-schema.snapshot.json
Normal file
182
docs/directus/member-schema.snapshot.json
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"directus": "11.0.0",
|
||||||
|
"vendor": "postgres",
|
||||||
|
"collections": [
|
||||||
|
{ "collection": "users", "meta": null, "schema": { "name": "users" } },
|
||||||
|
{ "collection": "companies", "meta": null, "schema": { "name": "companies" } },
|
||||||
|
{ "collection": "sites", "meta": null, "schema": { "name": "sites" } },
|
||||||
|
{ "collection": "systems", "meta": null, "schema": { "name": "systems" } },
|
||||||
|
{ "collection": "roles", "meta": null, "schema": { "name": "roles" } },
|
||||||
|
{ "collection": "site_roles", "meta": null, "schema": { "name": "site_roles" } },
|
||||||
|
{ "collection": "user_sites", "meta": null, "schema": { "name": "user_sites" } },
|
||||||
|
{ "collection": "auth_sync_state", "meta": null, "schema": { "name": "auth_sync_state" } },
|
||||||
|
{ "collection": "api_clients", "meta": null, "schema": { "name": "api_clients" } }
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{ "collection": "users", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "has_auto_increment": false, "default_value": "gen_random_uuid()" } },
|
||||||
|
{ "collection": "users", "field": "user_sub", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||||
|
{ "collection": "users", "field": "provider_user_id", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
|
||||||
|
{ "collection": "users", "field": "username", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
|
||||||
|
{ "collection": "users", "field": "email", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
|
||||||
|
{ "collection": "users", "field": "display_name", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||||
|
{ "collection": "users", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||||
|
{ "collection": "users", "field": "is_active", "type": "boolean", "meta": null, "schema": { "is_nullable": false, "default_value": true } },
|
||||||
|
{ "collection": "users", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
{ "collection": "users", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
|
||||||
|
{ "collection": "companies", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||||
|
{ "collection": "companies", "field": "company_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||||
|
{ "collection": "companies", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "companies", "field": "provider_group_id", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||||
|
{ "collection": "companies", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||||
|
{ "collection": "companies", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
{ "collection": "companies", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
|
||||||
|
{ "collection": "sites", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||||
|
{ "collection": "sites", "field": "site_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||||
|
{ "collection": "sites", "field": "company_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "companies", "foreign_key_column": "id" } },
|
||||||
|
{ "collection": "sites", "field": "display_name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "sites", "field": "domain", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||||
|
{ "collection": "sites", "field": "provider_group_id", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||||
|
{ "collection": "sites", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||||
|
{ "collection": "sites", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
{ "collection": "sites", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
|
||||||
|
{ "collection": "systems", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||||
|
{ "collection": "systems", "field": "system_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||||
|
{ "collection": "systems", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "systems", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||||
|
{ "collection": "systems", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
{ "collection": "systems", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
|
||||||
|
{ "collection": "roles", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||||
|
{ "collection": "roles", "field": "role_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||||
|
{ "collection": "roles", "field": "role_code", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "roles", "field": "system_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "systems", "foreign_key_column": "id" } },
|
||||||
|
{ "collection": "roles", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "roles", "field": "description", "type": "text", "meta": null, "schema": { "is_nullable": true } },
|
||||||
|
{ "collection": "roles", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||||
|
{ "collection": "roles", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
{ "collection": "roles", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
|
||||||
|
{ "collection": "site_roles", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||||
|
{ "collection": "site_roles", "field": "site_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "sites", "foreign_key_column": "id" } },
|
||||||
|
{ "collection": "site_roles", "field": "role_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "roles", "foreign_key_column": "id" } },
|
||||||
|
{ "collection": "site_roles", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
|
||||||
|
{ "collection": "user_sites", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||||
|
{ "collection": "user_sites", "field": "user_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "users", "foreign_key_column": "id" } },
|
||||||
|
{ "collection": "user_sites", "field": "site_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "sites", "foreign_key_column": "id" } },
|
||||||
|
{ "collection": "user_sites", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
{ "collection": "user_sites", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
|
||||||
|
{ "collection": "auth_sync_state", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||||
|
{ "collection": "auth_sync_state", "field": "entity_type", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "auth_sync_state", "field": "entity_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "auth_sync_state", "field": "last_synced_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
|
||||||
|
{ "collection": "auth_sync_state", "field": "source_version", "type": "string", "meta": null, "schema": { "is_nullable": true } },
|
||||||
|
{ "collection": "auth_sync_state", "field": "last_error", "type": "text", "meta": null, "schema": { "is_nullable": true } },
|
||||||
|
{ "collection": "auth_sync_state", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
|
||||||
|
{ "collection": "api_clients", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
|
||||||
|
{ "collection": "api_clients", "field": "client_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
|
||||||
|
{ "collection": "api_clients", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "api_clients", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
|
||||||
|
{ "collection": "api_clients", "field": "api_key_hash", "type": "text", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "api_clients", "field": "allowed_origins", "type": "json", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "api_clients", "field": "allowed_ips", "type": "json", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "api_clients", "field": "allowed_paths", "type": "json", "meta": null, "schema": { "is_nullable": false } },
|
||||||
|
{ "collection": "api_clients", "field": "rate_limit_per_min", "type": "integer", "meta": null, "schema": { "is_nullable": true } },
|
||||||
|
{ "collection": "api_clients", "field": "expires_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
|
||||||
|
{ "collection": "api_clients", "field": "last_used_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
|
||||||
|
{ "collection": "api_clients", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
|
||||||
|
{ "collection": "api_clients", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } }
|
||||||
|
],
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"collection": "sites",
|
||||||
|
"field": "company_id",
|
||||||
|
"related_collection": "companies",
|
||||||
|
"schema": {
|
||||||
|
"table": "sites",
|
||||||
|
"column": "company_id",
|
||||||
|
"foreign_key_table": "companies",
|
||||||
|
"foreign_key_column": "id",
|
||||||
|
"on_update": "NO ACTION",
|
||||||
|
"on_delete": "CASCADE"
|
||||||
|
},
|
||||||
|
"meta": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "roles",
|
||||||
|
"field": "system_id",
|
||||||
|
"related_collection": "systems",
|
||||||
|
"schema": {
|
||||||
|
"table": "roles",
|
||||||
|
"column": "system_id",
|
||||||
|
"foreign_key_table": "systems",
|
||||||
|
"foreign_key_column": "id",
|
||||||
|
"on_update": "NO ACTION",
|
||||||
|
"on_delete": "CASCADE"
|
||||||
|
},
|
||||||
|
"meta": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "site_roles",
|
||||||
|
"field": "site_id",
|
||||||
|
"related_collection": "sites",
|
||||||
|
"schema": {
|
||||||
|
"table": "site_roles",
|
||||||
|
"column": "site_id",
|
||||||
|
"foreign_key_table": "sites",
|
||||||
|
"foreign_key_column": "id",
|
||||||
|
"on_update": "NO ACTION",
|
||||||
|
"on_delete": "CASCADE"
|
||||||
|
},
|
||||||
|
"meta": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "site_roles",
|
||||||
|
"field": "role_id",
|
||||||
|
"related_collection": "roles",
|
||||||
|
"schema": {
|
||||||
|
"table": "site_roles",
|
||||||
|
"column": "role_id",
|
||||||
|
"foreign_key_table": "roles",
|
||||||
|
"foreign_key_column": "id",
|
||||||
|
"on_update": "NO ACTION",
|
||||||
|
"on_delete": "CASCADE"
|
||||||
|
},
|
||||||
|
"meta": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "user_sites",
|
||||||
|
"field": "user_id",
|
||||||
|
"related_collection": "users",
|
||||||
|
"schema": {
|
||||||
|
"table": "user_sites",
|
||||||
|
"column": "user_id",
|
||||||
|
"foreign_key_table": "users",
|
||||||
|
"foreign_key_column": "id",
|
||||||
|
"on_update": "NO ACTION",
|
||||||
|
"on_delete": "CASCADE"
|
||||||
|
},
|
||||||
|
"meta": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "user_sites",
|
||||||
|
"field": "site_id",
|
||||||
|
"related_collection": "sites",
|
||||||
|
"schema": {
|
||||||
|
"table": "user_sites",
|
||||||
|
"column": "site_id",
|
||||||
|
"foreign_key_table": "sites",
|
||||||
|
"foreign_key_column": "id",
|
||||||
|
"on_update": "NO ACTION",
|
||||||
|
"on_delete": "CASCADE"
|
||||||
|
},
|
||||||
|
"meta": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,22 +1,51 @@
|
|||||||
# member docs index
|
# member-platform 文件入口(新架構)
|
||||||
|
|
||||||
## 先看這三份
|
## 閱讀順序
|
||||||
1. `docs/FRONTEND_ARCHITECTURE.md`
|
1. [docs/ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||||
2. `docs/FRONTEND_API_CONTRACT.md`
|
2. [docs/DB_SCHEMA.md](./DB_SCHEMA.md)
|
||||||
3. `docs/FRONTEND_IMPLEMENTATION_CHECKLIST.md`
|
3. [docs/BACKEND_TASKPLAN.md](./BACKEND_TASKPLAN.md)
|
||||||
|
4. [docs/FRONTEND_TASKPLAN.md](./FRONTEND_TASKPLAN.md)
|
||||||
|
5. [docs/FRONTEND_HANDOFF.md](./FRONTEND_HANDOFF.md)
|
||||||
|
6. [docs/INTERNAL_API_HANDOFF.md](./INTERNAL_API_HANDOFF.md)
|
||||||
|
7. [docs/LOCAL_DEV_RUNBOOK.md](./LOCAL_DEV_RUNBOOK.md)
|
||||||
|
8. [docs/VPS_DEPLOY_RUNBOOK.md](./VPS_DEPLOY_RUNBOOK.md)
|
||||||
|
|
||||||
## 系統架構與後端
|
## 交辦順序(建議)
|
||||||
- `docs/ARCHITECTURE_AND_CONFIG.md`
|
1. 先看 [ARCHITECTURE.md](./ARCHITECTURE.md) 鎖定資料模型與權限模型。
|
||||||
- `docs/BACKEND_ARCHITECTURE.md`
|
2. 再看 [DB_SCHEMA.md](./DB_SCHEMA.md) 對齊 table/欄位/關聯。
|
||||||
- `docs/BACKEND_BOOTSTRAP.md`
|
3. 後端依 [BACKEND_TASKPLAN.md](./BACKEND_TASKPLAN.md) 執行 schema/API/Keycloak 同步調整。
|
||||||
|
4. 前端依 [FRONTEND_TASKPLAN.md](./FRONTEND_TASKPLAN.md) + [FRONTEND_HANDOFF.md](./FRONTEND_HANDOFF.md) 開工。
|
||||||
|
5. 其他系統串接時看 [INTERNAL_API_HANDOFF.md](./INTERNAL_API_HANDOFF.md)。
|
||||||
|
|
||||||
## 任務管理
|
## 目前狀態
|
||||||
- `docs/TASKPLAN_FRONTEND.md`
|
- 架構定版:`Company -> Site`、`System -> Role`。
|
||||||
- `docs/TASKPLAN_BACKEND.md`
|
- 權限定版:`Role` 只能指派給 `Site`(透過 `site_roles`)。
|
||||||
|
- 成員授權定版:`User` 不直接綁 `Role`,僅透過 `user_sites` 取得 Site 角色。
|
||||||
|
- IdP 定版:Keycloak 為唯一 IdP。
|
||||||
|
- 系統定版:`System`/`Role` 由 Keycloak 管理,member 後台僅同步與顯示。
|
||||||
|
- API 白名單:保留 `api_clients`。
|
||||||
|
- 後端:新 schema 與 admin/internal API 已切到 role-site 模型。
|
||||||
|
- 前端:管理頁已切到新模型(公司/站台/系統/角色/會員/API Clients)。
|
||||||
|
|
||||||
## SQL 與配置
|
## 單一真實來源
|
||||||
- `docs/API_CLIENTS_SQL.sql`
|
- DB SQL:[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql)
|
||||||
- `docs/DB_SCHEMA_SNAPSHOT.md`
|
|
||||||
|
|
||||||
## 給前端 AI 的一句話交接
|
## Repo 結構(已拆分)
|
||||||
請先完成 `/me`、`/me/permissions/snapshot`、`/admin/permissions/grant|revoke` 三組 API 對接,並依 `FRONTEND_IMPLEMENTATION_CHECKLIST.md` 逐項完成。
|
- 整合層(本 repo):`member-platform`(docs / 部署 / 整合)
|
||||||
|
- 後端子模組:[backend](../backend)(submodule: `../member-backend`)
|
||||||
|
- 前端子模組:[frontend](../frontend)(submodule: `../member-frontend`)
|
||||||
|
|
||||||
|
## 開發工作目錄(防呆)
|
||||||
|
- 只在 `member-platform` 內開發與提交。
|
||||||
|
- 後端只改 `member-platform/backend`。
|
||||||
|
- 前端只改 `member-platform/frontend`。
|
||||||
|
- 根目錄外的 `member-backend` / `member-frontend` 若有另一份 clone,視為非主要工作副本,避免混用。
|
||||||
|
|
||||||
|
## 提交順序(固定)
|
||||||
|
1. 先在 `member-platform/backend` 或 `member-platform/frontend` 各自 commit / push。
|
||||||
|
2. 再回 `member-platform` 根目錄,提交子模組版本指標變更(submodule pointer)並 push。
|
||||||
|
3. 部署端只需要更新 `member-platform`,再執行 `git submodule update --init --recursive`。
|
||||||
|
|
||||||
|
## 文件邊界
|
||||||
|
- 本輪只保留可開發、可交辦、可驗收文件。
|
||||||
|
- 最終規格白皮書延後到專案完成後再產出。
|
||||||
|
|||||||
1
frontend
Submodule
1
frontend
Submodule
Submodule frontend added at ed63eaffc6
@@ -1,2 +0,0 @@
|
|||||||
VITE_APP_TITLE=member.ose.tw
|
|
||||||
VITE_API_BASE_URL=https://memberapi.ose.tw
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
VITE_APP_TITLE=member.ose.tw (dev)
|
|
||||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# member.ose.tw frontend env
|
|
||||||
VITE_APP_TITLE=member.ose.tw
|
|
||||||
VITE_API_BASE_URL=https://memberapi.ose.tw
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-TW">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>member.ose.tw</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
2922
frontend/package-lock.json
generated
2922
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "member-ose-tw",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
|
||||||
"axios": "^1.7.9",
|
|
||||||
"element-plus": "^2.9.1",
|
|
||||||
"pinia": "^2.3.0",
|
|
||||||
"vue": "^3.5.13",
|
|
||||||
"vue-router": "^4.5.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^3.4.17",
|
|
||||||
"vite": "^6.0.11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
|
||||||
<nav v-if="!isLoginPage" class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between shadow-sm">
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<span class="font-bold text-gray-800 text-base">member.ose.tw</span>
|
|
||||||
<router-link
|
|
||||||
to="/me"
|
|
||||||
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
|
|
||||||
active-class="text-blue-600 font-medium"
|
|
||||||
>
|
|
||||||
我的資料
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
to="/me/permissions"
|
|
||||||
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
|
|
||||||
active-class="text-blue-600 font-medium"
|
|
||||||
>
|
|
||||||
我的權限
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
to="/admin/permissions"
|
|
||||||
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
|
|
||||||
active-class="text-blue-600 font-medium"
|
|
||||||
>
|
|
||||||
權限管理
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<el-button v-if="authStore.isLoggedIn" size="small" @click="logout">登出</el-button>
|
|
||||||
</nav>
|
|
||||||
<main class="p-6 max-w-4xl mx-auto">
|
|
||||||
<router-view />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const isLoginPage = computed(() => route.name === 'login')
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
authStore.logout()
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { userHttp } from './http'
|
|
||||||
|
|
||||||
export const loginWithPassword = (username, password) =>
|
|
||||||
userHttp.post('/auth/login', { username, password })
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import axios from 'axios'
|
|
||||||
import router from '@/router'
|
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL
|
|
||||||
|
|
||||||
// 使用者 API:帶 Bearer token
|
|
||||||
export const userHttp = axios.create({ baseURL: BASE_URL })
|
|
||||||
|
|
||||||
userHttp.interceptors.request.use(config => {
|
|
||||||
const token = localStorage.getItem('access_token')
|
|
||||||
if (token) {
|
|
||||||
config.headers['Authorization'] = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
})
|
|
||||||
|
|
||||||
userHttp.interceptors.response.use(
|
|
||||||
res => res,
|
|
||||||
err => {
|
|
||||||
if (err.response?.status === 401) {
|
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
return Promise.reject(err)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 管理員 API:帶 X-Client-Key / X-API-Key
|
|
||||||
export const adminHttp = axios.create({ baseURL: BASE_URL })
|
|
||||||
|
|
||||||
adminHttp.interceptors.request.use(config => {
|
|
||||||
const clientKey = sessionStorage.getItem('admin_client_key')
|
|
||||||
const apiKey = sessionStorage.getItem('admin_api_key')
|
|
||||||
if (clientKey) config.headers['X-Client-Key'] = clientKey
|
|
||||||
if (apiKey) config.headers['X-API-Key'] = apiKey
|
|
||||||
return config
|
|
||||||
})
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { userHttp } from './http'
|
|
||||||
|
|
||||||
export const getMe = () => userHttp.get('/me')
|
|
||||||
export const getMyPermissionSnapshot = () => userHttp.get('/me/permissions/snapshot')
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { adminHttp } from './http'
|
|
||||||
|
|
||||||
export const grantPermission = (data) => adminHttp.post('/admin/permissions/grant', data)
|
|
||||||
export const revokePermission = (data) => adminHttp.post('/admin/permissions/revoke', data)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { createApp } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import ElementPlus from 'element-plus'
|
|
||||||
import 'element-plus/dist/index.css'
|
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
|
||||||
import zhTw from 'element-plus/es/locale/lang/zh-tw'
|
|
||||||
import App from './App.vue'
|
|
||||||
import router from './router'
|
|
||||||
import './assets/main.css'
|
|
||||||
|
|
||||||
const app = createApp(App)
|
|
||||||
|
|
||||||
app.use(createPinia())
|
|
||||||
app.use(router)
|
|
||||||
app.use(ElementPlus, { locale: zhTw })
|
|
||||||
|
|
||||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
|
||||||
app.component(key, component)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.mount('#app')
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-center justify-center min-h-[70vh]">
|
|
||||||
<el-card class="w-full max-w-md shadow-md">
|
|
||||||
<template #header>
|
|
||||||
<div class="text-center">
|
|
||||||
<h1 class="text-xl font-bold text-gray-800">member.ose.tw</h1>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">使用 Authentik 帳號密碼登入</p>
|
|
||||||
</div>
|
|
||||||
</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
|
|
||||||
v-if="error"
|
|
||||||
:title="error"
|
|
||||||
type="error"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-form-item>
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
native-type="submit"
|
|
||||||
class="w-full"
|
|
||||||
:loading="loading"
|
|
||||||
:disabled="!username.trim() || !password.trim()"
|
|
||||||
>
|
|
||||||
登入
|
|
||||||
</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<p class="text-xs text-gray-400 text-center mt-2">登入成功後 access token 會存於本機 localStorage</p>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { loginWithPassword } from '@/api/auth'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const username = ref('')
|
|
||||||
const password = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
async function handleLogin() {
|
|
||||||
if (!username.value.trim() || !password.value.trim()) return
|
|
||||||
loading.value = true
|
|
||||||
error.value = ''
|
|
||||||
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'
|
|
||||||
router.push(redirect)
|
|
||||||
} catch (err) {
|
|
||||||
authStore.logout()
|
|
||||||
const detail = err.response?.data?.detail
|
|
||||||
if (detail === 'invalid_username_or_password') {
|
|
||||||
error.value = '帳號或密碼錯誤'
|
|
||||||
} else if (detail === 'authentik_login_not_configured') {
|
|
||||||
error.value = '後端尚未設定 Authentik 登入參數'
|
|
||||||
} else {
|
|
||||||
error.value = '登入失敗,請稍後再試'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
<!-- Grant / Revoke -->
|
|
||||||
<el-tabs v-model="activeTab" type="border-card" class="shadow-sm">
|
|
||||||
<!-- Grant Tab -->
|
|
||||||
<el-tab-pane label="Grant 授權" name="grant">
|
|
||||||
<el-form
|
|
||||||
ref="grantFormRef"
|
|
||||||
:model="grantForm"
|
|
||||||
:rules="grantRules"
|
|
||||||
label-width="130px"
|
|
||||||
class="max-w-xl mt-4"
|
|
||||||
@submit.prevent="handleGrant"
|
|
||||||
>
|
|
||||||
<el-form-item label="Authentik Sub" prop="authentik_sub">
|
|
||||||
<el-input v-model="grantForm.authentik_sub" placeholder="authentik-sub-xxx" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Email" prop="email">
|
|
||||||
<el-input v-model="grantForm.email" placeholder="user@example.com" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="顯示名稱" prop="display_name">
|
|
||||||
<el-input v-model="grantForm.display_name" placeholder="User Name" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Scope 類型" prop="scope_type">
|
|
||||||
<el-input v-model="grantForm.scope_type" placeholder="site" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Scope ID" prop="scope_id">
|
|
||||||
<el-input v-model="grantForm.scope_id" placeholder="tw-main" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="模組" prop="module">
|
|
||||||
<el-input v-model="grantForm.module" placeholder="campaign" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="操作" prop="action">
|
|
||||||
<el-input v-model="grantForm.action" placeholder="view" />
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="grantError"
|
|
||||||
:title="grantError"
|
|
||||||
type="error"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
<el-alert
|
|
||||||
v-if="grantSuccess"
|
|
||||||
:title="grantSuccess"
|
|
||||||
type="success"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-form-item>
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
native-type="submit"
|
|
||||||
:loading="grantLoading"
|
|
||||||
:disabled="!credsSaved"
|
|
||||||
>
|
|
||||||
Grant 授權
|
|
||||||
</el-button>
|
|
||||||
<el-button @click="resetGrant">清除</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
<p v-if="!credsSaved" class="text-xs text-yellow-600 ml-2">請先設定管理員認證</p>
|
|
||||||
</el-form>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<!-- Revoke Tab -->
|
|
||||||
<el-tab-pane label="Revoke 撤銷" name="revoke">
|
|
||||||
<el-form
|
|
||||||
ref="revokeFormRef"
|
|
||||||
:model="revokeForm"
|
|
||||||
:rules="revokeRules"
|
|
||||||
label-width="130px"
|
|
||||||
class="max-w-xl mt-4"
|
|
||||||
@submit.prevent="handleRevoke"
|
|
||||||
>
|
|
||||||
<el-form-item label="Authentik Sub" prop="authentik_sub">
|
|
||||||
<el-input v-model="revokeForm.authentik_sub" placeholder="authentik-sub-xxx" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Scope 類型" prop="scope_type">
|
|
||||||
<el-input v-model="revokeForm.scope_type" placeholder="site" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="Scope ID" prop="scope_id">
|
|
||||||
<el-input v-model="revokeForm.scope_id" placeholder="tw-main" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="模組" prop="module">
|
|
||||||
<el-input v-model="revokeForm.module" placeholder="campaign" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="操作" prop="action">
|
|
||||||
<el-input v-model="revokeForm.action" placeholder="view" />
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="revokeError"
|
|
||||||
:title="revokeError"
|
|
||||||
type="error"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
<el-alert
|
|
||||||
v-if="revokeSuccess"
|
|
||||||
:title="revokeSuccess"
|
|
||||||
type="success"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-form-item>
|
|
||||||
<el-button
|
|
||||||
type="danger"
|
|
||||||
native-type="submit"
|
|
||||||
:loading="revokeLoading"
|
|
||||||
:disabled="!credsSaved"
|
|
||||||
>
|
|
||||||
Revoke 撤銷
|
|
||||||
</el-button>
|
|
||||||
<el-button @click="resetRevoke">清除</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
<p v-if="!credsSaved" class="text-xs text-yellow-600 ml-2">請先設定管理員認證</p>
|
|
||||||
</el-form>
|
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, computed } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { usePermissionStore } from '@/stores/permission'
|
|
||||||
|
|
||||||
const permissionStore = usePermissionStore()
|
|
||||||
|
|
||||||
const activeTab = ref('grant')
|
|
||||||
|
|
||||||
// 認證
|
|
||||||
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('認證已清除')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grant
|
|
||||||
const grantFormRef = ref()
|
|
||||||
const grantLoading = ref(false)
|
|
||||||
const grantError = ref('')
|
|
||||||
const grantSuccess = ref('')
|
|
||||||
|
|
||||||
const grantForm = reactive({
|
|
||||||
authentik_sub: '',
|
|
||||||
email: '',
|
|
||||||
display_name: '',
|
|
||||||
scope_type: '',
|
|
||||||
scope_id: '',
|
|
||||||
module: '',
|
|
||||||
action: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const required = { required: true, message: '必填', trigger: 'blur' }
|
|
||||||
const grantRules = {
|
|
||||||
authentik_sub: [required],
|
|
||||||
email: [required],
|
|
||||||
display_name: [required],
|
|
||||||
scope_type: [required],
|
|
||||||
scope_id: [required],
|
|
||||||
module: [required],
|
|
||||||
action: [required]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGrant() {
|
|
||||||
const valid = await grantFormRef.value.validate().catch(() => false)
|
|
||||||
if (!valid) return
|
|
||||||
grantLoading.value = true
|
|
||||||
grantError.value = ''
|
|
||||||
grantSuccess.value = ''
|
|
||||||
try {
|
|
||||||
const result = await permissionStore.grant({ ...grantForm })
|
|
||||||
grantSuccess.value = `授權成功(ID: ${result.permission_id})`
|
|
||||||
ElMessage.success('Grant 成功')
|
|
||||||
} catch (err) {
|
|
||||||
grantError.value = formatAdminError(err)
|
|
||||||
} finally {
|
|
||||||
grantLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetGrant() {
|
|
||||||
grantFormRef.value?.resetFields()
|
|
||||||
grantError.value = ''
|
|
||||||
grantSuccess.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoke
|
|
||||||
const revokeFormRef = ref()
|
|
||||||
const revokeLoading = ref(false)
|
|
||||||
const revokeError = ref('')
|
|
||||||
const revokeSuccess = ref('')
|
|
||||||
|
|
||||||
const revokeForm = reactive({
|
|
||||||
authentik_sub: '',
|
|
||||||
scope_type: '',
|
|
||||||
scope_id: '',
|
|
||||||
module: '',
|
|
||||||
action: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const revokeRules = {
|
|
||||||
authentik_sub: [required],
|
|
||||||
scope_type: [required],
|
|
||||||
scope_id: [required],
|
|
||||||
module: [required],
|
|
||||||
action: [required]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRevoke() {
|
|
||||||
const valid = await revokeFormRef.value.validate().catch(() => false)
|
|
||||||
if (!valid) return
|
|
||||||
revokeLoading.value = true
|
|
||||||
revokeError.value = ''
|
|
||||||
revokeSuccess.value = ''
|
|
||||||
try {
|
|
||||||
const result = await permissionStore.revoke({ ...revokeForm })
|
|
||||||
revokeSuccess.value = `撤銷成功(共刪除 ${result.deleted} 筆)`
|
|
||||||
ElMessage.success('Revoke 成功')
|
|
||||||
} catch (err) {
|
|
||||||
revokeError.value = formatAdminError(err)
|
|
||||||
} finally {
|
|
||||||
revokeLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetRevoke() {
|
|
||||||
revokeFormRef.value?.resetFields()
|
|
||||||
revokeError.value = ''
|
|
||||||
revokeSuccess.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAdminError(err) {
|
|
||||||
const status = err.response?.status
|
|
||||||
const detail = err.response?.data?.detail
|
|
||||||
const map = {
|
|
||||||
invalid_client: '無效的 Client Key',
|
|
||||||
invalid_api_key: '無效的 API Key',
|
|
||||||
client_expired: 'Client 已過期',
|
|
||||||
origin_not_allowed: '來源 Origin 不允許',
|
|
||||||
ip_not_allowed: 'IP 不在白名單',
|
|
||||||
path_not_allowed: '路徑不允許',
|
|
||||||
internal_secret_not_configured: '後端設定缺失(internal secret)',
|
|
||||||
authentik_admin_not_configured: '後端設定缺失(authentik admin)',
|
|
||||||
user_not_found: '找不到該使用者'
|
|
||||||
}
|
|
||||||
if (detail && map[detail]) return map[detail]
|
|
||||||
if (detail) return `錯誤:${detail}`
|
|
||||||
if (status === 401) return '認證失敗,請檢查 Client Key / API Key'
|
|
||||||
if (status === 403) return '存取被拒(IP 或 Origin 限制)'
|
|
||||||
if (status === 404) return '找不到該使用者'
|
|
||||||
if (status === 503) return '後端設定不完整,請聯絡管理員'
|
|
||||||
return '操作失敗,請稍後再試'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">我的權限快照</h2>
|
|
||||||
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="error"
|
|
||||||
:title="errorMessage"
|
|
||||||
type="error"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-skeleton v-if="loading && !snapshot" :rows="4" animated />
|
|
||||||
|
|
||||||
<template v-if="snapshot">
|
|
||||||
<p class="text-sm text-gray-500 mb-3">
|
|
||||||
Sub:<span class="font-mono">{{ snapshot.authentik_sub }}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<el-empty
|
|
||||||
v-if="snapshot.permissions.length === 0"
|
|
||||||
description="目前沒有任何權限"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-table
|
|
||||||
v-else
|
|
||||||
:data="snapshot.permissions"
|
|
||||||
stripe
|
|
||||||
border
|
|
||||||
class="w-full shadow-sm"
|
|
||||||
>
|
|
||||||
<el-table-column prop="scope_type" label="Scope 類型" width="130" />
|
|
||||||
<el-table-column prop="scope_id" label="Scope ID" min-width="160" />
|
|
||||||
<el-table-column prop="module" label="模組" width="140" />
|
|
||||||
<el-table-column prop="action" label="操作" width="100" />
|
|
||||||
</el-table>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
|
||||||
import { usePermissionStore } from '@/stores/permission'
|
|
||||||
|
|
||||||
const permissionStore = usePermissionStore()
|
|
||||||
|
|
||||||
const snapshot = ref(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref(null)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
await permissionStore.fetchMySnapshot()
|
|
||||||
snapshot.value = permissionStore.snapshot
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err
|
|
||||||
const status = err.response?.status
|
|
||||||
const detail = err.response?.data?.detail
|
|
||||||
if (status === 401) {
|
|
||||||
errorMessage.value = 'Token 已過期,請重新登入'
|
|
||||||
} else if (detail) {
|
|
||||||
errorMessage.value = `錯誤:${detail}`
|
|
||||||
} else {
|
|
||||||
errorMessage.value = '載入失敗,請稍後再試'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800">我的資料</h2>
|
|
||||||
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="error"
|
|
||||||
:title="errorMessage"
|
|
||||||
type="error"
|
|
||||||
show-icon
|
|
||||||
:closable="false"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-skeleton v-if="loading && !me" :rows="3" animated />
|
|
||||||
|
|
||||||
<el-card v-if="me && !loading" class="shadow-sm">
|
|
||||||
<el-descriptions :column="1" border>
|
|
||||||
<el-descriptions-item label="Sub">
|
|
||||||
<span class="font-mono text-sm text-gray-700">{{ me.sub }}</span>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="Email">
|
|
||||||
{{ me.email }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="顯示名稱">
|
|
||||||
{{ me.display_name }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const me = ref(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref(null)
|
|
||||||
|
|
||||||
const errorMessage = ref('')
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
await authStore.fetchMe()
|
|
||||||
me.value = authStore.me
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err
|
|
||||||
const status = err.response?.status
|
|
||||||
const detail = err.response?.data?.detail
|
|
||||||
if (status === 401) {
|
|
||||||
errorMessage.value = 'Token 已過期,請重新登入'
|
|
||||||
} else if (detail) {
|
|
||||||
errorMessage.value = `錯誤:${detail}`
|
|
||||||
} else {
|
|
||||||
errorMessage.value = '載入失敗,請稍後再試'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const routes = [
|
|
||||||
{ path: '/', redirect: '/me' },
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
name: 'login',
|
|
||||||
component: () => import('@/pages/LoginPage.vue')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/me',
|
|
||||||
name: 'me',
|
|
||||||
component: () => import('@/pages/profile/MePage.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/me/permissions',
|
|
||||||
name: 'my-permissions',
|
|
||||||
component: () => import('@/pages/permissions/PermissionSnapshotPage.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin/permissions',
|
|
||||||
name: 'admin-permissions',
|
|
||||||
component: () => import('@/pages/permissions/PermissionAdminPage.vue')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(),
|
|
||||||
routes
|
|
||||||
})
|
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
|
||||||
return { name: 'login', query: { redirect: to.fullPath } }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { getMe } from '@/api/me'
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
|
||||||
const accessToken = ref(localStorage.getItem('access_token') || null)
|
|
||||||
const me = ref(null)
|
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!accessToken.value)
|
|
||||||
|
|
||||||
function setToken(token) {
|
|
||||||
accessToken.value = token
|
|
||||||
localStorage.setItem('access_token', token)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchMe() {
|
|
||||||
const res = await getMe()
|
|
||||||
me.value = res.data
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
accessToken.value = null
|
|
||||||
me.value = null
|
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
}
|
|
||||||
|
|
||||||
return { accessToken, me, isLoggedIn, setToken, fetchMe, logout }
|
|
||||||
})
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { getMyPermissionSnapshot } from '@/api/me'
|
|
||||||
import { grantPermission, revokePermission } from '@/api/permission-admin'
|
|
||||||
|
|
||||||
export const usePermissionStore = defineStore('permission', () => {
|
|
||||||
const snapshot = ref(null)
|
|
||||||
const adminClientKey = ref(sessionStorage.getItem('admin_client_key') || '')
|
|
||||||
const adminApiKey = ref(sessionStorage.getItem('admin_api_key') || '')
|
|
||||||
|
|
||||||
const hasAdminCreds = () => !!(adminClientKey.value && adminApiKey.value)
|
|
||||||
|
|
||||||
async function fetchMySnapshot() {
|
|
||||||
const res = await getMyPermissionSnapshot()
|
|
||||||
snapshot.value = res.data
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAdminCreds(clientKey, apiKey) {
|
|
||||||
adminClientKey.value = clientKey
|
|
||||||
adminApiKey.value = apiKey
|
|
||||||
sessionStorage.setItem('admin_client_key', clientKey)
|
|
||||||
sessionStorage.setItem('admin_api_key', apiKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAdminCreds() {
|
|
||||||
adminClientKey.value = ''
|
|
||||||
adminApiKey.value = ''
|
|
||||||
sessionStorage.removeItem('admin_client_key')
|
|
||||||
sessionStorage.removeItem('admin_api_key')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function grant(data) {
|
|
||||||
const res = await grantPermission(data)
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revoke(data) {
|
|
||||||
const res = await revokePermission(data)
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
snapshot,
|
|
||||||
adminClientKey,
|
|
||||||
adminApiKey,
|
|
||||||
hasAdminCreds,
|
|
||||||
fetchMySnapshot,
|
|
||||||
setAdminCreds,
|
|
||||||
clearAdminCreds,
|
|
||||||
grant,
|
|
||||||
revoke
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ['./index.html', './src/**/*.{vue,js}'],
|
|
||||||
theme: {
|
|
||||||
extend: {}
|
|
||||||
},
|
|
||||||
plugins: []
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
44
scripts/push-backend.sh
Executable file
44
scripts/push-backend.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -lt 1 ]; then
|
||||||
|
echo "用法: scripts/push-backend.sh \"後端 commit 訊息\" [根目錄 commit 訊息]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUB_MSG="$1"
|
||||||
|
ROOT_MSG="${2:-chore: bump backend submodule}"
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
SUB_DIR="$ROOT_DIR/backend"
|
||||||
|
|
||||||
|
current_branch="$(git -C "$SUB_DIR" branch --show-current)"
|
||||||
|
if [ -z "$current_branch" ]; then
|
||||||
|
echo "backend 目前是 detached HEAD,請先執行: cd backend && git checkout master"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$current_branch" != "master" ]; then
|
||||||
|
echo "backend 目前分支是 $current_branch,建議先切到 master 再執行。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$(git -C "$SUB_DIR" status --porcelain)" ]; then
|
||||||
|
git -C "$SUB_DIR" add -A
|
||||||
|
git -C "$SUB_DIR" commit -m "$SUB_MSG"
|
||||||
|
else
|
||||||
|
echo "backend 無本地變更,略過 backend commit。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$SUB_DIR" push origin master
|
||||||
|
|
||||||
|
git -C "$ROOT_DIR" add backend
|
||||||
|
if ! git -C "$ROOT_DIR" diff --cached --quiet; then
|
||||||
|
git -C "$ROOT_DIR" commit -m "$ROOT_MSG"
|
||||||
|
else
|
||||||
|
echo "根目錄無 submodule pointer 變更,略過根目錄 commit。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$ROOT_DIR" push origin master
|
||||||
|
|
||||||
|
echo "完成: backend 與根目錄已 push。"
|
||||||
44
scripts/push-frontend.sh
Executable file
44
scripts/push-frontend.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -lt 1 ]; then
|
||||||
|
echo "用法: scripts/push-frontend.sh \"前端 commit 訊息\" [根目錄 commit 訊息]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUB_MSG="$1"
|
||||||
|
ROOT_MSG="${2:-chore: bump frontend submodule}"
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
SUB_DIR="$ROOT_DIR/frontend"
|
||||||
|
|
||||||
|
current_branch="$(git -C "$SUB_DIR" branch --show-current)"
|
||||||
|
if [ -z "$current_branch" ]; then
|
||||||
|
echo "frontend 目前是 detached HEAD,請先執行: cd frontend && git checkout master"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$current_branch" != "master" ]; then
|
||||||
|
echo "frontend 目前分支是 $current_branch,建議先切到 master 再執行。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$(git -C "$SUB_DIR" status --porcelain)" ]; then
|
||||||
|
git -C "$SUB_DIR" add -A
|
||||||
|
git -C "$SUB_DIR" commit -m "$SUB_MSG"
|
||||||
|
else
|
||||||
|
echo "frontend 無本地變更,略過 frontend commit。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$SUB_DIR" push origin master
|
||||||
|
|
||||||
|
git -C "$ROOT_DIR" add frontend
|
||||||
|
if ! git -C "$ROOT_DIR" diff --cached --quiet; then
|
||||||
|
git -C "$ROOT_DIR" commit -m "$ROOT_MSG"
|
||||||
|
else
|
||||||
|
echo "根目錄無 submodule pointer 變更,略過根目錄 commit。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$ROOT_DIR" push origin master
|
||||||
|
|
||||||
|
echo "完成: frontend 與根目錄已 push。"
|
||||||
Reference in New Issue
Block a user