diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3900377 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "backend"] + path = backend + url = ../member-backend +[submodule "frontend"] + path = frontend + url = ../member-frontend diff --git a/backend b/backend new file mode 160000 index 0000000..ade60bd --- /dev/null +++ b/backend @@ -0,0 +1 @@ +Subproject commit ade60bdbaa7adefdb19056bc4224445af6b4964d diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index 4dd4df8..0000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,13 +0,0 @@ -.git -.gitignore -.venv -__pycache__ -*.pyc -*.pyo -*.pyd -.pytest_cache -.ruff_cache -tests -.env -.env.development -*.log diff --git a/backend/.env b/backend/.env deleted file mode 100644 index ecef87d..0000000 --- a/backend/.env +++ /dev/null @@ -1,19 +0,0 @@ -# memberapi.ose.tw backend env (development) -APP_ENV=development -PORT=8000 - -DB_HOST=127.0.0.1 -DB_PORT=54321 -DB_NAME=member_center -DB_USER=member_ose -DB_PASSWORD=CHANGE_ME - -KEYCLOAK_BASE_URL= -KEYCLOAK_REALM= -KEYCLOAK_VERIFY_TLS=true -KEYCLOAK_ISSUER= -KEYCLOAK_JWKS_URL= -KEYCLOAK_AUDIENCE= - -PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw -INTERNAL_SHARED_SECRET=CHANGE_ME diff --git a/backend/.env.development b/backend/.env.development deleted file mode 100644 index 971dc18..0000000 --- a/backend/.env.development +++ /dev/null @@ -1,28 +0,0 @@ -# memberapi.ose.tw backend env (local development) -APP_ENV=development -PORT=8000 - -DB_HOST=127.0.0.1 -DB_PORT=54321 -DB_NAME=member.ose.tw -DB_USER=member_ose -DB_PASSWORD=Dmrax5bKDf - -KEYCLOAK_BASE_URL=https://auth.ose.tw -KEYCLOAK_REALM=master -KEYCLOAK_VERIFY_TLS=true -KEYCLOAK_CLIENT_ID=member-frontend -KEYCLOAK_CLIENT_SECRET=bp2I0HWyz5cjcu5RGnBPXNC2vjCdckkv -KEYCLOAK_ADMIN_CLIENT_ID=member-backend -KEYCLOAK_ADMIN_CLIENT_SECRET=hat8BmxlP0eZ7CXuKbV4HwQ3abLHzAJ9 -KEYCLOAK_ADMIN_REALM=master - -PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 -INTERNAL_SHARED_SECRET=CHANGE_ME -MEMBER_REQUIRED_REALM_ROLES=admin,manager -ADMIN_REQUIRED_REALM_ROLES=admin,manager - -CACHE_BACKEND=memory -CACHE_REDIS_URL=redis://127.0.0.1:6379/0 -CACHE_PREFIX=memberapi -CACHE_DEFAULT_TTL_SECONDS=30 diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index f0cb859..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,35 +0,0 @@ -# memberapi.ose.tw backend env (development) -APP_ENV=development -PORT=8000 - -DB_HOST=127.0.0.1 -DB_PORT=54321 -DB_NAME=member_center -DB_USER=member_ose -DB_PASSWORD=CHANGE_ME - -# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set) -KEYCLOAK_BASE_URL= -KEYCLOAK_REALM= -KEYCLOAK_VERIFY_TLS=true -KEYCLOAK_ISSUER= -KEYCLOAK_JWKS_URL= -KEYCLOAK_AUDIENCE= -KEYCLOAK_CLIENT_ID= -KEYCLOAK_CLIENT_SECRET= -KEYCLOAK_TOKEN_ENDPOINT= -KEYCLOAK_USERINFO_ENDPOINT= -KEYCLOAK_ADMIN_CLIENT_ID= -KEYCLOAK_ADMIN_CLIENT_SECRET= -KEYCLOAK_ADMIN_REALM= - -PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw -INTERNAL_SHARED_SECRET=CHANGE_ME -MEMBER_REQUIRED_REALM_ROLES=admin,manager -ADMIN_REQUIRED_REALM_ROLES=admin,manager - -# Cache backend: memory | redis -CACHE_BACKEND=memory -CACHE_REDIS_URL=redis://127.0.0.1:6379/0 -CACHE_PREFIX=memberapi -CACHE_DEFAULT_TTL_SECONDS=30 diff --git a/backend/.env.production.example b/backend/.env.production.example deleted file mode 100644 index 9535f53..0000000 --- a/backend/.env.production.example +++ /dev/null @@ -1,35 +0,0 @@ -# memberapi.ose.tw backend env (production) -APP_ENV=production -PORT=8000 - -DB_HOST=postgresql -DB_PORT=5432 -DB_NAME=member_center -DB_USER=member_ose -DB_PASSWORD=CHANGE_ME - -# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set) -KEYCLOAK_BASE_URL= -KEYCLOAK_REALM= -KEYCLOAK_VERIFY_TLS=true -KEYCLOAK_ISSUER= -KEYCLOAK_JWKS_URL= -KEYCLOAK_AUDIENCE= -KEYCLOAK_CLIENT_ID= -KEYCLOAK_CLIENT_SECRET= -KEYCLOAK_TOKEN_ENDPOINT= -KEYCLOAK_USERINFO_ENDPOINT= -KEYCLOAK_ADMIN_CLIENT_ID= -KEYCLOAK_ADMIN_CLIENT_SECRET= -KEYCLOAK_ADMIN_REALM= - -PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw -INTERNAL_SHARED_SECRET=CHANGE_ME -MEMBER_REQUIRED_REALM_ROLES=admin,manager -ADMIN_REQUIRED_REALM_ROLES=admin,manager - -# Cache backend: memory | redis -CACHE_BACKEND=redis -CACHE_REDIS_URL=redis://redis:6379/0 -CACHE_PREFIX=memberapi -CACHE_DEFAULT_TTL_SECONDS=30 diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index fa5dc09..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -FROM python:3.12-alpine AS builder - -WORKDIR /app - -RUN apk add --no-cache build-base libffi-dev openssl-dev cargo - -COPY pyproject.toml /app/pyproject.toml -COPY app /app/app -COPY scripts /app/scripts -COPY README.md /app/README.md - -RUN pip install --no-cache-dir --upgrade pip && \ - pip wheel --no-cache-dir --wheel-dir /wheels . - -FROM python:3.12-alpine - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -WORKDIR /app - -RUN apk add --no-cache libstdc++ libffi openssl - -COPY --from=builder /wheels /wheels -RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir /wheels/* - -EXPOSE 8000 - -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index b0a35b9..0000000 --- a/backend/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# memberapi.ose.tw backend - -## Quick start - -```bash -cd backend -python -m venv .venv -source .venv/bin/activate -pip install -e . -cp .env.example .env -psql "$DATABASE_URL" -f scripts/init_schema.sql -./scripts/start_dev.sh -``` - -## Docker (VPS / Production) -> 目前 Dockerfile 為 Alpine 多階段建置(較小體積)。 - -Build image: -```bash -cd backend -docker build -t memberapi-backend:latest . -``` - -Run container: -```bash -docker run -d \ - --name memberapi-backend \ - --restart unless-stopped \ - -p 127.0.0.1:8000:8000 \ - --env-file .env \ - memberapi-backend:latest -``` - -Health check: -```bash -curl http://127.0.0.1:8000/healthz -``` - -## Keycloak env - -- Required: - - `KEYCLOAK_BASE_URL` - - `KEYCLOAK_REALM` - - `KEYCLOAK_CLIENT_ID` - - `KEYCLOAK_CLIENT_SECRET` - - `KEYCLOAK_ADMIN_CLIENT_ID` - - `KEYCLOAK_ADMIN_CLIENT_SECRET` -- Optional: - - `KEYCLOAK_ADMIN_REALM` (default = `KEYCLOAK_REALM`) - - `KEYCLOAK_ISSUER` - - `KEYCLOAK_JWKS_URL` - - `KEYCLOAK_TOKEN_ENDPOINT` - - `KEYCLOAK_USERINFO_ENDPOINT` - - `KEYCLOAK_AUDIENCE` - - `KEYCLOAK_VERIFY_TLS` - - `MEMBER_REQUIRED_REALM_ROLES` (default: `admin,manager`) - - `ADMIN_REQUIRED_REALM_ROLES` (default: `admin,manager`) - -## Main APIs - -- `GET /healthz` -- `GET /auth/oidc/url` -- `POST /auth/oidc/exchange` -- `GET /me` (Bearer token required) -- `GET /me/permissions/snapshot` (Bearer token required) - -### Admin APIs (Bearer + admin realm role required) -- `GET/POST/PATCH/DELETE /admin/companies` -- `GET/POST/PATCH/DELETE /admin/sites` -- `GET/POST/PATCH/DELETE /admin/systems` -- `GET/POST/PATCH/DELETE /admin/roles` -- `GET/POST/PATCH/DELETE /admin/members` -- `PUT /admin/sites/{site_key}/roles` -- `PUT /admin/members/{user_sub}/sites` -- `GET /admin/members/{user_sub}/roles` -- `GET/POST/PATCH/DELETE /admin/api-clients` - -### Internal APIs (`X-Client-Key` + `X-API-Key`) -- `GET /internal/companies` -- `GET /internal/sites` -- `GET /internal/systems` -- `GET /internal/roles` -- `GET /internal/members` -- `POST /internal/users/upsert-by-sub` -- `GET /internal/users/{user_sub}/roles` -- `POST /internal/idp/users/ensure` diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index f00e47d..0000000 --- a/backend/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""memberapi backend package.""" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py deleted file mode 100644 index f7ec5ce..0000000 --- a/backend/app/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""API routers.""" diff --git a/backend/app/api/admin_catalog.py b/backend/app/api/admin_catalog.py deleted file mode 100644 index 5f70e9a..0000000 --- a/backend/app/api/admin_catalog.py +++ /dev/null @@ -1,1130 +0,0 @@ -from __future__ import annotations - -import secrets - -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session - -from app.core.keygen import generate_key -from app.db.session import get_db -from app.repositories.api_clients_repo import ApiClientsRepository -from app.repositories.companies_repo import CompaniesRepository -from app.repositories.roles_repo import RolesRepository -from app.repositories.site_roles_repo import SiteRolesRepository -from app.repositories.sites_repo import SitesRepository -from app.repositories.systems_repo import SystemsRepository -from app.repositories.users_repo import UsersRepository -from app.repositories.user_sites_repo import UserSitesRepository -from app.schemas.api_clients import ApiClientCreateRequest, ApiClientCreateResponse, ApiClientRotateKeyResponse, ApiClientUpdateRequest -from app.schemas.catalog import ( - ApiClientItem, - CompanyCreateRequest, - CompanyItem, - CompanySitesResponse, - CompanyUpdateRequest, - ListResponse, - MemberItem, - MemberPasswordResetResponse, - MemberUpsertRequest, - MemberUpdateRequest, - RoleCreateRequest, - RoleItem, - RoleSitesResponse, - RoleUpdateRequest, - SiteCreateRequest, - SiteItem, - SiteMembersResponse, - SiteRoleAssignRequest, - SiteRoleItem, - SiteRolesResponse, - SiteUpdateRequest, - SystemCreateRequest, - SystemItem, - SystemRolesResponse, - SystemUpdateRequest, - UserEffectiveRoleItem, - UserEffectiveRolesResponse, - UserSiteAssignRequest, - UserSiteItem, - UserSitesResponse, -) -from app.security.admin_guard import require_admin_principal -from app.security.api_client_auth import hash_api_key -from app.services.idp_admin_service import ProviderAdminService -from app.services.idp_catalog_sync import sync_from_provider -from app.services.runtime_cache import runtime_cache -from app.core.config import get_settings - -router = APIRouter( - prefix="/admin", - tags=["admin"], - dependencies=[Depends(require_admin_principal)], -) - - -def _generate_unique_key(prefix: str, exists_check) -> str: - for salt in range(5000): - key = generate_key(prefix, salt) - if not exists_check(key): - return key - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"failed_generate_{prefix.lower()}_key") - - -def _company_item(company) -> CompanyItem: - return CompanyItem( - id=company.id, - company_key=company.company_key, - name=company.name, - provider_group_id=company.provider_group_id, - status=company.status, - ) - - -def _site_item(site, company) -> SiteItem: - return SiteItem( - id=site.id, - site_key=site.site_key, - company_key=company.company_key, - company_display_name=company.name, - display_name=site.display_name, - domain=site.domain, - provider_group_id=site.provider_group_id, - status=site.status, - ) - - -def _system_item(system) -> SystemItem: - return SystemItem( - id=system.id, - system_key=system.system_key, - name=system.name, - status=system.status, - ) - - -def _member_item(user) -> MemberItem: - return MemberItem( - id=user.id, - user_sub=user.user_sub, - provider_user_id=user.provider_user_id, - username=user.username, - email=user.email, - display_name=user.display_name, - is_active=user.is_active, - status=user.status, - ) - - -def _company_group_name(name: str, company_key: str) -> str: - normalized = name.strip() if isinstance(name, str) else "" - if not normalized: - return company_key - return normalized - - -def _site_group_name(display_name: str, site_key: str) -> str: - normalized = display_name.strip() if isinstance(display_name, str) else "" - if not normalized: - return site_key - return normalized - - -def _sync_site_client_roles( - *, - idp: ProviderAdminService, - site, - site_role_rows, - provider_client_ids: set[str], -) -> None: - if not site.provider_group_id: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"site_provider_group_missing:{site.site_key}") - - role_names_by_client: dict[str, list[str]] = {} - for _, role, system in site_role_rows: - provider_client_id = str(system.name or "").strip() - if not provider_client_id: - continue - role_names_by_client.setdefault(provider_client_id, []).append(role.name) - - for provider_client_id in sorted(provider_client_ids): - idp.set_group_client_roles( - group_id=site.provider_group_id, - provider_client_id=provider_client_id, - role_names=role_names_by_client.get(provider_client_id, []), - ) - - -@router.get("/companies", response_model=ListResponse) -def list_companies( - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - limit: int = Query(default=100, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> ListResponse: - cache_key = f"admin:companies:{keyword or ''}:{limit}:{offset}" - cached = runtime_cache.get(cache_key) - if isinstance(cached, ListResponse): - return cached - - repo = CompaniesRepository(db) - items, total = repo.list(keyword=keyword, limit=limit, offset=offset) - result = ListResponse(items=[_company_item(i) for i in items], total=total, limit=limit, offset=offset) - runtime_cache.set(cache_key, result, ttl_seconds=20) - return result - - -@router.post("/companies", response_model=CompanyItem) -def create_company(payload: CompanyCreateRequest, db: Session = Depends(get_db)) -> CompanyItem: - repo = CompaniesRepository(db) - idp = ProviderAdminService(get_settings()) - company_key = _generate_unique_key("CP", lambda key: repo.get_by_key(key) is not None) - group_name = _company_group_name(payload.name, company_key) - group = idp.ensure_group( - name=group_name, - attributes={ - "member_entity_type": "company", - "company_key": company_key, - "name": payload.name, - "status": payload.status, - }, - ) - item = repo.create( - company_key=company_key, - name=payload.name, - provider_group_id=group.group_id, - status=payload.status, - ) - return _company_item(item) - - -@router.patch("/companies/{company_key}", response_model=CompanyItem) -def update_company(company_key: str, payload: CompanyUpdateRequest, db: Session = Depends(get_db)) -> CompanyItem: - repo = CompaniesRepository(db) - item = repo.get_by_key(company_key) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") - idp = ProviderAdminService(get_settings()) - resolved_name = payload.name if payload.name is not None else item.name - resolved_status = payload.status if payload.status is not None else item.status - resolved_group_id = payload.provider_group_id or item.provider_group_id - group_name = _company_group_name(resolved_name, company_key) - group = idp.ensure_group( - group_id=resolved_group_id, - name=group_name, - attributes={ - "member_entity_type": "company", - "company_key": company_key, - "name": resolved_name, - "status": resolved_status, - }, - ) - item = repo.update( - item, - name=payload.name, - provider_group_id=group.group_id, - status=payload.status, - ) - return _company_item(item) - - -@router.delete("/companies/{company_key}") -def delete_company(company_key: str, db: Session = Depends(get_db)) -> dict[str, str]: - repo = CompaniesRepository(db) - idp = ProviderAdminService(get_settings()) - item = repo.get_by_key(company_key) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") - idp.delete_group(group_id=item.provider_group_id) - repo.delete(item) - return {"deleted": company_key} - - -@router.get("/companies/{company_key}/sites", response_model=CompanySitesResponse) -def list_company_sites(company_key: str, db: Session = Depends(get_db)) -> CompanySitesResponse: - companies_repo = CompaniesRepository(db) - sites_repo = SitesRepository(db) - company = companies_repo.get_by_key(company_key) - if not company: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") - sites, _ = sites_repo.list(company_id=company.id, limit=2000, offset=0) - return CompanySitesResponse(company_key=company_key, sites=[_site_item(site, company) for site in sites]) - - -@router.get("/sites", response_model=ListResponse) -def list_sites( - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - company_key: str | None = Query(default=None), - limit: int = Query(default=100, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> ListResponse: - cache_key = f"admin:sites:{keyword or ''}:{company_key or ''}:{limit}:{offset}" - cached = runtime_cache.get(cache_key) - if isinstance(cached, ListResponse): - return cached - - companies_repo = CompaniesRepository(db) - sites_repo = SitesRepository(db) - company_id = None - if company_key: - company = companies_repo.get_by_key(company_key) - if not company: - return ListResponse(items=[], total=0, limit=limit, offset=offset) - company_id = company.id - - companies, _ = companies_repo.list(limit=5000, offset=0) - company_map = {c.id: c for c in companies} - items, total = sites_repo.list(keyword=keyword, company_id=company_id, limit=limit, offset=offset) - response_items = [_site_item(i, company_map[i.company_id]) for i in items if i.company_id in company_map] - result = ListResponse(items=response_items, total=total, limit=limit, offset=offset) - runtime_cache.set(cache_key, result, ttl_seconds=20) - return result - - -@router.post("/sites", response_model=SiteItem) -def create_site(payload: SiteCreateRequest, db: Session = Depends(get_db)) -> SiteItem: - companies_repo = CompaniesRepository(db) - sites_repo = SitesRepository(db) - idp = ProviderAdminService(get_settings()) - company = companies_repo.get_by_key(payload.company_key) - if not company: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") - - site_key = _generate_unique_key("ST", lambda key: sites_repo.get_by_key(key) is not None) - group_name = _site_group_name(payload.display_name, site_key) - group = idp.ensure_group( - group_id=None, - name=group_name, - parent_group_id=company.provider_group_id, - attributes={ - "member_entity_type": "site", - "site_key": site_key, - "company_key": company.company_key, - "display_name": payload.display_name, - "domain": payload.domain or "", - "status": payload.status, - }, - ) - item = sites_repo.create( - site_key=site_key, - company_id=company.id, - display_name=payload.display_name, - domain=payload.domain, - provider_group_id=group.group_id, - status=payload.status, - ) - return _site_item(item, company) - - -@router.patch("/sites/{site_key}", response_model=SiteItem) -def update_site(site_key: str, payload: SiteUpdateRequest, db: Session = Depends(get_db)) -> SiteItem: - companies_repo = CompaniesRepository(db) - sites_repo = SitesRepository(db) - idp = ProviderAdminService(get_settings()) - - item = sites_repo.get_by_key(site_key) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") - - target_company = companies_repo.get_by_id(item.company_id) - company_id = None - if payload.company_key: - target_company = companies_repo.get_by_key(payload.company_key) - if not target_company: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found") - company_id = target_company.id - if not target_company: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="company_reference_missing") - - resolved_display_name = payload.display_name if payload.display_name is not None else item.display_name - resolved_domain = payload.domain if payload.domain is not None else item.domain - resolved_status = payload.status if payload.status is not None else item.status - resolved_group_id = payload.provider_group_id or item.provider_group_id - group_name = _site_group_name(resolved_display_name, site_key) - group = idp.ensure_group( - group_id=resolved_group_id, - name=group_name, - parent_group_id=target_company.provider_group_id, - attributes={ - "member_entity_type": "site", - "site_key": site_key, - "company_key": target_company.company_key, - "display_name": resolved_display_name, - "domain": resolved_domain or "", - "status": resolved_status, - }, - ) - - item = sites_repo.update( - item, - company_id=company_id, - display_name=payload.display_name, - domain=payload.domain, - provider_group_id=group.group_id, - status=payload.status, - ) - company = companies_repo.get_by_id(item.company_id) - if not company: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="company_reference_missing") - return _site_item(item, company) - - -@router.delete("/sites/{site_key}") -def delete_site(site_key: str, db: Session = Depends(get_db)) -> dict[str, str]: - repo = SitesRepository(db) - idp = ProviderAdminService(get_settings()) - item = repo.get_by_key(site_key) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") - idp.delete_group(group_id=item.provider_group_id) - repo.delete(item) - return {"deleted": site_key} - - -@router.get("/systems", response_model=ListResponse) -def list_systems( - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - status_filter: str | None = Query(default=None, alias="status"), - limit: int = Query(default=100, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> ListResponse: - cache_key = f"admin:systems:{keyword or ''}:{status_filter or ''}:{limit}:{offset}" - cached = runtime_cache.get(cache_key) - if isinstance(cached, ListResponse): - return cached - - repo = SystemsRepository(db) - items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset) - result = ListResponse(items=[_system_item(i) for i in items], total=total, limit=limit, offset=offset) - runtime_cache.set(cache_key, result, ttl_seconds=20) - return result - - -@router.post("/systems", response_model=SystemItem) -def create_system(payload: SystemCreateRequest, db: Session = Depends(get_db)) -> SystemItem: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_manage_in_provider_only") - - -@router.patch("/systems/{system_key}", response_model=SystemItem) -def update_system(system_key: str, payload: SystemUpdateRequest, db: Session = Depends(get_db)) -> SystemItem: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_manage_in_provider_only") - - -@router.delete("/systems/{system_key}") -def delete_system(system_key: str, db: Session = Depends(get_db)) -> dict[str, str]: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_manage_in_provider_only") - - -@router.get("/roles", response_model=ListResponse) -def list_roles( - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - system_key: str | None = Query(default=None), - status_filter: str | None = Query(default=None, alias="status"), - limit: int = Query(default=100, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> ListResponse: - cache_key = f"admin:roles:{keyword or ''}:{system_key or ''}:{status_filter or ''}:{limit}:{offset}" - cached = runtime_cache.get(cache_key) - if isinstance(cached, ListResponse): - return cached - - systems_repo = SystemsRepository(db) - roles_repo = RolesRepository(db) - - system_id = None - system_map: dict[str, object] = {} - systems, _ = systems_repo.list(limit=5000, offset=0) - for system in systems: - system_map[system.id] = system - - if system_key: - system = systems_repo.get_by_key(system_key) - if not system: - return ListResponse(items=[], total=0, limit=limit, offset=offset) - system_id = system.id - - rows, total = roles_repo.list(keyword=keyword, system_id=system_id, status=status_filter, limit=limit, offset=offset) - items = [ - RoleItem( - id=row.id, - role_key=row.role_key, - system_key=system_map[row.system_id].system_key, - system_name=system_map[row.system_id].name, - name=row.name, - description=row.description, - status=row.status, - ) - for row in rows - if row.system_id in system_map - ] - result = ListResponse(items=items, total=total, limit=limit, offset=offset) - runtime_cache.set(cache_key, result, ttl_seconds=20) - return result - - -@router.post("/roles", response_model=RoleItem) -def create_role(payload: RoleCreateRequest, db: Session = Depends(get_db)) -> RoleItem: - systems_repo = SystemsRepository(db) - roles_repo = RolesRepository(db) - idp = ProviderAdminService(get_settings()) - - system = systems_repo.get_by_key(payload.system_key) - if not system: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") - idp.ensure_client_role( - provider_client_id=system.name, - provider_role_name=payload.name, - description=payload.description, - ) - - role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None) - try: - row = roles_repo.create( - role_key=role_key, - system_id=system.id, - name=payload.name, - description=payload.description, - status=payload.status, - ) - except IntegrityError: - db.rollback() - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="role_name_conflict") - - return RoleItem( - id=row.id, - role_key=row.role_key, - system_key=system.system_key, - system_name=system.name, - name=row.name, - description=row.description, - status=row.status, - ) - - -@router.patch("/roles/{role_key}", response_model=RoleItem) -def update_role(role_key: str, payload: RoleUpdateRequest, db: Session = Depends(get_db)) -> RoleItem: - systems_repo = SystemsRepository(db) - roles_repo = RolesRepository(db) - idp = ProviderAdminService(get_settings()) - - role = roles_repo.get_by_key(role_key) - if not role: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found") - - old_system = systems_repo.get_by_id(role.system_id) - if not old_system: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="system_reference_missing") - - target_system = old_system - system_id = None - if payload.system_key: - system = systems_repo.get_by_key(payload.system_key) - if not system: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") - system_id = system.id - target_system = system - next_provider_role_name = payload.name if payload.name is not None else role.name - next_description = payload.description if payload.description is not None else role.description - - if target_system.id != old_system.id: - idp.ensure_client_role( - provider_client_id=target_system.name, - provider_role_name=next_provider_role_name, - description=next_description, - ) - idp.delete_client_role( - provider_client_id=old_system.name, - provider_role_name=role.name, - ) - else: - idp.update_client_role( - provider_client_id=target_system.name, - old_provider_role_name=role.name, - new_provider_role_name=next_provider_role_name, - description=next_description, - ) - - try: - role = roles_repo.update( - role, - system_id=system_id, - name=payload.name, - description=payload.description, - status=payload.status, - ) - except IntegrityError: - db.rollback() - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="role_name_conflict") - - system = systems_repo.get_by_id(role.system_id) - if not system: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="system_reference_missing") - - return RoleItem( - id=role.id, - role_key=role.role_key, - system_key=system.system_key, - system_name=system.name, - name=role.name, - description=role.description, - status=role.status, - ) - - -@router.delete("/roles/{role_key}") -def delete_role(role_key: str, db: Session = Depends(get_db)) -> dict[str, str]: - roles_repo = RolesRepository(db) - systems_repo = SystemsRepository(db) - idp = ProviderAdminService(get_settings()) - - role = roles_repo.get_by_key(role_key) - if not role: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found") - system = systems_repo.get_by_id(role.system_id) - if not system: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="system_reference_missing") - idp.delete_client_role( - provider_client_id=system.name, - provider_role_name=role.name, - ) - roles_repo.delete(role) - return {"deleted": role_key} - - -@router.get("/systems/{system_key}/roles", response_model=SystemRolesResponse) -def list_system_roles(system_key: str, db: Session = Depends(get_db)) -> SystemRolesResponse: - systems_repo = SystemsRepository(db) - roles_repo = RolesRepository(db) - - system = systems_repo.get_by_key(system_key) - if not system: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") - - rows, _ = roles_repo.list(system_id=system.id, limit=5000, offset=0) - return SystemRolesResponse( - system_key=system_key, - roles=[ - RoleItem( - id=row.id, - role_key=row.role_key, - system_key=system.system_key, - system_name=system.name, - name=row.name, - description=row.description, - status=row.status, - ) - for row in rows - ], - ) - - -@router.get("/sites/{site_key}/roles", response_model=SiteRolesResponse) -def list_site_roles(site_key: str, db: Session = Depends(get_db)) -> SiteRolesResponse: - sites_repo = SitesRepository(db) - site_roles_repo = SiteRolesRepository(db) - - site = sites_repo.get_by_key(site_key) - if not site: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") - - rows = site_roles_repo.list_site_role_rows(site.id) - return SiteRolesResponse( - site_key=site_key, - roles=[ - SiteRoleItem( - id=site_role.id, - role_key=role.role_key, - role_name=role.name, - system_key=system.system_key, - system_name=system.name, - ) - for site_role, role, system in rows - ], - ) - - -@router.put("/sites/{site_key}/roles", response_model=SiteRolesResponse) -def assign_site_roles(site_key: str, payload: SiteRoleAssignRequest, db: Session = Depends(get_db)) -> SiteRolesResponse: - sites_repo = SitesRepository(db) - roles_repo = RolesRepository(db) - site_roles_repo = SiteRolesRepository(db) - idp = ProviderAdminService(get_settings()) - - site = sites_repo.get_by_key(site_key) - if not site: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") - - current_rows = site_roles_repo.list_site_role_rows(site.id) - current_client_ids = {str(system.name or "").strip() for _, _, system in current_rows if str(system.name or "").strip()} - - role_ids: list[str] = [] - for role_key in list(dict.fromkeys(payload.role_keys)): - role = roles_repo.get_by_key(role_key) - if not role: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"role_not_found:{role_key}") - role_ids.append(role.id) - - site_roles_repo.set_site_roles(site_id=site.id, role_ids=role_ids, commit=False) - updated_rows = site_roles_repo.list_site_role_rows(site.id) - updated_client_ids = {str(system.name or "").strip() for _, _, system in updated_rows if str(system.name or "").strip()} - clients_to_sync = current_client_ids | updated_client_ids - - try: - _sync_site_client_roles( - idp=idp, - site=site, - site_role_rows=updated_rows, - provider_client_ids=clients_to_sync, - ) - db.commit() - except Exception: - db.rollback() - raise - - return list_site_roles(site_key=site_key, db=db) - - -@router.get("/roles/{role_key}/sites", response_model=RoleSitesResponse) -def list_role_sites(role_key: str, db: Session = Depends(get_db)) -> RoleSitesResponse: - roles_repo = RolesRepository(db) - companies_repo = CompaniesRepository(db) - site_roles_repo = SiteRolesRepository(db) - - role = roles_repo.get_by_key(role_key) - if not role: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found") - - rows = site_roles_repo.list_role_site_rows(role.id) - company_cache = {} - result = [] - for _, site in rows: - company = company_cache.get(site.company_id) - if company is None: - company = companies_repo.get_by_id(site.company_id) - company_cache[site.company_id] = company - if not company: - continue - result.append( - UserSiteItem( - id=site.id, - site_key=site.site_key, - site_display_name=site.display_name, - company_key=company.company_key, - company_display_name=company.name, - ) - ) - - return RoleSitesResponse(role_key=role_key, sites=result) - - -@router.put("/roles/{role_key}/sites", response_model=RoleSitesResponse) -def assign_role_sites(role_key: str, payload: UserSiteAssignRequest, db: Session = Depends(get_db)) -> RoleSitesResponse: - roles_repo = RolesRepository(db) - sites_repo = SitesRepository(db) - systems_repo = SystemsRepository(db) - site_roles_repo = SiteRolesRepository(db) - idp = ProviderAdminService(get_settings()) - - role = roles_repo.get_by_key(role_key) - if not role: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="role_not_found") - - system = systems_repo.get_by_id(role.system_id) - if not system: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found") - provider_client_id = str(system.name or "").strip() - if not provider_client_id: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=f"provider_client_id_missing:{system.system_key}") - - previous_rows = site_roles_repo.list_role_site_rows(role.id) - previous_site_ids = {site.id for _, site in previous_rows} - - site_ids: list[str] = [] - for site_key in list(dict.fromkeys(payload.site_keys)): - site = sites_repo.get_by_key(site_key) - if not site: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"site_not_found:{site_key}") - site_ids.append(site.id) - - site_roles_repo.set_role_sites(role_id=role.id, site_ids=site_ids, commit=False) - - affected_site_ids = previous_site_ids | set(site_ids) - try: - for site_id in affected_site_ids: - site = sites_repo.get_by_id(site_id) - if not site: - continue - site_rows = site_roles_repo.list_site_role_rows(site.id) - _sync_site_client_roles( - idp=idp, - site=site, - site_role_rows=site_rows, - provider_client_ids={provider_client_id}, - ) - db.commit() - except Exception: - db.rollback() - raise - - return list_role_sites(role_key=role_key, db=db) - - -@router.get("/members", response_model=ListResponse) -def list_members( - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - is_active: bool | None = Query(default=None), - limit: int = Query(default=100, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> ListResponse: - is_active_key = "" if is_active is None else ("1" if is_active else "0") - cache_key = f"admin:members:{keyword or ''}:{is_active_key}:{limit}:{offset}" - cached = runtime_cache.get(cache_key) - if isinstance(cached, ListResponse): - return cached - - repo = UsersRepository(db) - rows, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset) - result = ListResponse(items=[_member_item(r) for r in rows], total=total, limit=limit, offset=offset) - runtime_cache.set(cache_key, result, ttl_seconds=20) - return result - - -@router.post("/members", response_model=MemberItem) -def create_member(payload: MemberUpsertRequest, db: Session = Depends(get_db)) -> MemberItem: - users_repo = UsersRepository(db) - - resolved_sub = payload.user_sub - provider_user_id: str | None = None - if payload.sync_to_idp: - if not payload.email: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_idp_sync") - idp = ProviderAdminService(get_settings()) - sync_result = idp.ensure_user( - sub=payload.user_sub, - email=payload.email, - username=payload.username, - display_name=payload.display_name, - is_active=payload.is_active, - ) - provider_user_id = sync_result.user_id - resolved_sub = resolved_sub or sync_result.user_sub - - if not resolved_sub: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_sub_required") - - user = users_repo.upsert_by_sub( - user_sub=resolved_sub, - username=payload.username, - email=payload.email, - display_name=payload.display_name, - is_active=payload.is_active, - status=payload.status, - provider_user_id=provider_user_id, - ) - return _member_item(user) - - -@router.patch("/members/{user_sub}", response_model=MemberItem) -def update_member(user_sub: str, payload: MemberUpdateRequest, db: Session = Depends(get_db)) -> MemberItem: - users_repo = UsersRepository(db) - user = users_repo.get_by_sub(user_sub) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - - next_username = payload.username if payload.username is not None else user.username - next_email = payload.email if payload.email is not None else user.email - next_display_name = payload.display_name if payload.display_name is not None else user.display_name - next_is_active = payload.is_active if payload.is_active is not None else user.is_active - - if payload.sync_to_idp: - if not next_email: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_idp_sync") - idp = ProviderAdminService(get_settings()) - sync_result = idp.ensure_user( - sub=user.user_sub, - email=next_email, - username=next_username, - display_name=next_display_name, - is_active=next_is_active, - provider_user_id=user.provider_user_id, - ) - user.provider_user_id = sync_result.user_id - - updated = users_repo.update_member( - user, - username=payload.username, - email=payload.email, - display_name=payload.display_name, - is_active=payload.is_active, - status=payload.status, - ) - return _member_item(updated) - - -@router.delete("/members/{user_sub}") -def delete_member(user_sub: str, db: Session = Depends(get_db), sync_to_idp: bool = Query(default=True)) -> dict[str, str]: - users_repo = UsersRepository(db) - user = users_repo.get_by_sub(user_sub) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - - if sync_to_idp: - idp = ProviderAdminService(get_settings()) - idp.delete_user(provider_user_id=user.provider_user_id, email=user.email, username=user.username) - - users_repo.delete(user) - return {"deleted": user_sub} - - -@router.post("/members/{user_sub}/password/reset", response_model=MemberPasswordResetResponse) -def reset_member_password(user_sub: str, db: Session = Depends(get_db)) -> MemberPasswordResetResponse: - users_repo = UsersRepository(db) - user = users_repo.get_by_sub(user_sub) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - - idp = ProviderAdminService(get_settings()) - result = idp.reset_password(provider_user_id=user.provider_user_id, email=user.email, username=user.username) - if user.provider_user_id != result.user_id: - user.provider_user_id = result.user_id - db.commit() - - return MemberPasswordResetResponse(user_sub=user_sub, temporary_password=result.temporary_password) - - -@router.get("/sites/{site_key}/members", response_model=SiteMembersResponse) -def list_site_members(site_key: str, db: Session = Depends(get_db)) -> SiteMembersResponse: - sites_repo = SitesRepository(db) - user_sites_repo = UserSitesRepository(db) - - site = sites_repo.get_by_key(site_key) - if not site: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found") - - rows = user_sites_repo.list_site_member_rows(site.id) - return SiteMembersResponse(site_key=site_key, members=[_member_item(user) for _, user in rows]) - - -@router.get("/members/{user_sub}/sites", response_model=UserSitesResponse) -def list_member_sites(user_sub: str, db: Session = Depends(get_db)) -> UserSitesResponse: - users_repo = UsersRepository(db) - user_sites_repo = UserSitesRepository(db) - - user = users_repo.get_by_sub(user_sub) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - - rows = user_sites_repo.list_user_site_rows(user.id) - items = [ - UserSiteItem( - id=user_site.id, - site_key=site.site_key, - site_display_name=site.display_name, - company_key=company.company_key, - company_display_name=company.name, - ) - for user_site, site, company in rows - ] - return UserSitesResponse(user_sub=user_sub, sites=items) - - -@router.put("/members/{user_sub}/sites", response_model=UserSitesResponse) -def set_member_sites(user_sub: str, payload: UserSiteAssignRequest, db: Session = Depends(get_db)) -> UserSitesResponse: - users_repo = UsersRepository(db) - sites_repo = SitesRepository(db) - user_sites_repo = UserSitesRepository(db) - - user = users_repo.get_by_sub(user_sub) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - - site_ids: list[str] = [] - for site_key in list(dict.fromkeys(payload.site_keys)): - site = sites_repo.get_by_key(site_key) - if not site: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"site_not_found:{site_key}") - site_ids.append(site.id) - - user_sites_repo.set_user_sites(user_id=user.id, site_ids=site_ids) - return list_member_sites(user_sub=user_sub, db=db) - - -@router.get("/members/{user_sub}/roles", response_model=UserEffectiveRolesResponse) -def list_member_effective_roles(user_sub: str, db: Session = Depends(get_db)) -> UserEffectiveRolesResponse: - users_repo = UsersRepository(db) - user_sites_repo = UserSitesRepository(db) - - user = users_repo.get_by_sub(user_sub) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") - - rows = user_sites_repo.get_user_role_rows(user.id) - items = [ - UserEffectiveRoleItem( - site_key=site.site_key, - site_display_name=site.display_name, - company_key=company.company_key, - company_display_name=company.name, - system_key=system.system_key, - system_name=system.name, - role_key=role.role_key, - role_name=role.name, - ) - for site, company, role, system in rows - ] - return UserEffectiveRolesResponse(user_sub=user_sub, roles=items) - - -@router.get("/api-clients", response_model=ListResponse) -def list_api_clients( - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - status_filter: str | None = Query(default=None, alias="status"), - limit: int = Query(default=100, ge=1, le=500), - offset: int = Query(default=0, ge=0), -) -> ListResponse: - cache_key = f"admin:api_clients:{keyword or ''}:{status_filter or ''}:{limit}:{offset}" - cached = runtime_cache.get(cache_key) - if isinstance(cached, ListResponse): - return cached - - repo = ApiClientsRepository(db) - items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset) - result = ListResponse( - items=[ApiClientItem.model_validate(i, from_attributes=True) for i in items], - total=total, - limit=limit, - offset=offset, - ) - runtime_cache.set(cache_key, result, ttl_seconds=20) - return result - - -@router.post("/sync/from-provider") -@router.post("/sync/from-keycloak", include_in_schema=False) -def sync_catalog_from_provider(db: Session = Depends(get_db), force: bool = Query(default=True)) -> dict[str, int]: - return sync_from_provider(db, force=force) - - -@router.post("/sync/provider-group-names") -@router.post("/sync/keycloak-group-names", include_in_schema=False) -def sync_provider_group_names(db: Session = Depends(get_db)) -> dict[str, int]: - companies_repo = CompaniesRepository(db) - sites_repo = SitesRepository(db) - idp = ProviderAdminService(get_settings()) - - companies, _ = companies_repo.list(limit=5000, offset=0) - company_count = 0 - for company in companies: - if not company.provider_group_id: - continue - group_name = _company_group_name(company.name, company.company_key) - idp.ensure_group( - group_id=company.provider_group_id, - name=group_name, - attributes={ - "member_entity_type": "company", - "company_key": company.company_key, - "name": company.name, - "status": company.status, - }, - ) - company_count += 1 - - sites, _ = sites_repo.list(limit=5000, offset=0) - site_count = 0 - company_map = {company.id: company for company in companies} - for site in sites: - if not site.provider_group_id: - continue - company = company_map.get(site.company_id) - if not company: - continue - group_name = _site_group_name(site.display_name, site.site_key) - idp.ensure_group( - group_id=site.provider_group_id, - name=group_name, - parent_group_id=company.provider_group_id, - attributes={ - "member_entity_type": "site", - "site_key": site.site_key, - "company_key": company.company_key, - "display_name": site.display_name, - "domain": site.domain or "", - "status": site.status, - }, - ) - site_count += 1 - - return {"companies_updated": company_count, "sites_updated": site_count} - - -@router.post("/api-clients", response_model=ApiClientCreateResponse) -def create_api_client(payload: ApiClientCreateRequest, db: Session = Depends(get_db)) -> ApiClientCreateResponse: - repo = ApiClientsRepository(db) - - client_key = payload.client_key or _generate_unique_key("AK", lambda key: repo.get_by_key(key) is not None) - if repo.get_by_key(client_key): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="client_key_exists") - - plain_key = secrets.token_urlsafe(32) - api_key_hash = hash_api_key(plain_key) - - item = repo.create( - client_key=client_key, - name=payload.name, - status=payload.status, - api_key_hash=api_key_hash, - allowed_origins=payload.allowed_origins, - allowed_ips=payload.allowed_ips, - allowed_paths=payload.allowed_paths, - rate_limit_per_min=payload.rate_limit_per_min, - expires_at=payload.expires_at, - ) - return ApiClientCreateResponse(item=ApiClientItem.model_validate(item, from_attributes=True), api_key=plain_key) - - -@router.patch("/api-clients/{client_key}", response_model=ApiClientItem) -def update_api_client(client_key: str, payload: ApiClientUpdateRequest, db: Session = Depends(get_db)) -> ApiClientItem: - repo = ApiClientsRepository(db) - item = repo.get_by_key(client_key) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found") - - repo.update( - item, - name=payload.name, - status=payload.status, - allowed_origins=payload.allowed_origins, - allowed_ips=payload.allowed_ips, - allowed_paths=payload.allowed_paths, - rate_limit_per_min=payload.rate_limit_per_min, - expires_at=payload.expires_at, - ) - return ApiClientItem.model_validate(item, from_attributes=True) - - -@router.post("/api-clients/{client_key}/rotate-key", response_model=ApiClientRotateKeyResponse) -def rotate_api_client_key(client_key: str, db: Session = Depends(get_db)) -> ApiClientRotateKeyResponse: - repo = ApiClientsRepository(db) - item = repo.get_by_key(client_key) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found") - - plain_key = secrets.token_urlsafe(32) - repo.update(item, api_key_hash=hash_api_key(plain_key)) - return ApiClientRotateKeyResponse(client_key=client_key, api_key=plain_key) - - -@router.delete("/api-clients/{client_key}") -def delete_api_client(client_key: str, db: Session = Depends(get_db)) -> dict[str, str]: - repo = ApiClientsRepository(db) - item = repo.get_by_key(client_key) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="api_client_not_found") - repo.delete(item) - return {"deleted": client_key} diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py deleted file mode 100644 index 3891e94..0000000 --- a/backend/app/api/auth.py +++ /dev/null @@ -1,167 +0,0 @@ -import logging -import secrets - -import httpx -from fastapi import APIRouter, HTTPException, status - -from app.core.config import get_settings -from app.schemas.login import LoginRequest, LoginResponse, OIDCAuthUrlResponse, OIDCCodeExchangeRequest, RefreshTokenRequest - -router = APIRouter(prefix="/auth", tags=["auth"]) -logger = logging.getLogger(__name__) - - -@router.post("/login", response_model=LoginResponse) -def login(payload: LoginRequest) -> LoginResponse: - settings = get_settings() - client_id = settings.idp_client_id or settings.idp_audience - if not settings.idp_base_url or not client_id or not settings.idp_client_secret: - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured") - - form = { - "grant_type": "password", - "client_id": client_id, - "client_secret": settings.idp_client_secret, - "username": payload.username, - "password": payload.password, - "scope": "openid profile email", - } - try: - resp = httpx.post( - settings.idp_token_endpoint, - data=form, - timeout=10, - verify=settings.idp_verify_tls, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - except Exception as exc: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_unreachable") from exc - - if resp.status_code >= 400: - logger.warning("idp password grant failed: status=%s body=%s", resp.status_code, resp.text) - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password") - - data = resp.json() - token = data.get("access_token") - if not token: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token") - return _build_login_response(data) - - -@router.get("/oidc/url", response_model=OIDCAuthUrlResponse) -def get_oidc_authorize_url( - redirect_uri: str, - login_hint: str | None = None, - prompt: str = "login", - idp_hint: str | None = None, - code_challenge: str | None = None, - code_challenge_method: str | None = None, -) -> OIDCAuthUrlResponse: - settings = get_settings() - client_id = settings.idp_client_id or settings.idp_audience - if not settings.idp_base_url or not client_id: - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured") - - query = { - "client_id": client_id, - "response_type": "code", - "scope": "openid profile email", - "redirect_uri": redirect_uri, - "state": secrets.token_urlsafe(24), - "prompt": prompt or "login", - } - if login_hint: - query["login_hint"] = login_hint - if idp_hint: - query["kc_idp_hint"] = idp_hint - if code_challenge: - query["code_challenge"] = code_challenge - query["code_challenge_method"] = code_challenge_method or "S256" - - params = httpx.QueryParams(query) - return OIDCAuthUrlResponse(authorize_url=f"{settings.idp_authorize_endpoint}?{params}") - - -@router.post("/oidc/exchange", response_model=LoginResponse) -def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse: - settings = get_settings() - client_id = settings.idp_client_id or settings.idp_audience - if not settings.idp_base_url or not client_id or not settings.idp_client_secret: - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured") - - form = { - "grant_type": "authorization_code", - "client_id": client_id, - "client_secret": settings.idp_client_secret, - "code": payload.code, - "redirect_uri": payload.redirect_uri, - } - if payload.code_verifier: - form["code_verifier"] = payload.code_verifier - - try: - resp = httpx.post( - settings.idp_token_endpoint, - data=form, - timeout=10, - verify=settings.idp_verify_tls, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - except Exception as exc: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_unreachable") from exc - - if resp.status_code >= 400: - logger.warning("idp auth-code exchange failed: status=%s body=%s", resp.status_code, resp.text) - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="idp_code_exchange_failed") - - data = resp.json() - token = data.get("access_token") - if not token: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token") - return _build_login_response(data) - - -@router.post("/refresh", response_model=LoginResponse) -def refresh_access_token(payload: RefreshTokenRequest) -> LoginResponse: - settings = get_settings() - client_id = settings.idp_client_id or settings.idp_audience - if not settings.idp_base_url or not client_id or not settings.idp_client_secret: - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_login_not_configured") - - form = { - "grant_type": "refresh_token", - "client_id": client_id, - "client_secret": settings.idp_client_secret, - "refresh_token": payload.refresh_token, - } - try: - resp = httpx.post( - settings.idp_token_endpoint, - data=form, - timeout=10, - verify=settings.idp_verify_tls, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - except Exception as exc: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_unreachable") from exc - - if resp.status_code >= 400: - logger.warning("idp refresh-token grant failed: status=%s body=%s", resp.status_code, resp.text) - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_refresh_token") - - data = resp.json() - token = data.get("access_token") - if not token: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_access_token") - return _build_login_response(data) - - -def _build_login_response(data: dict) -> LoginResponse: - return LoginResponse( - access_token=data.get("access_token", ""), - refresh_token=data.get("refresh_token"), - token_type=data.get("token_type", "Bearer"), - expires_in=data.get("expires_in"), - refresh_expires_in=data.get("refresh_expires_in"), - scope=data.get("scope"), - ) diff --git a/backend/app/api/internal.py b/backend/app/api/internal.py deleted file mode 100644 index f76f445..0000000 --- a/backend/app/api/internal.py +++ /dev/null @@ -1,136 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session - -from app.core.config import get_settings -from app.db.session import get_db -from app.repositories.users_repo import UsersRepository -from app.repositories.user_sites_repo import UserSitesRepository -from app.schemas.idp_admin import ProviderEnsureUserRequest, ProviderEnsureUserResponse -from app.schemas.internal import InternalUpsertUserBySubResponse, InternalUserRoleItem, InternalUserRoleResponse -from app.schemas.users import UserUpsertBySubRequest -from app.security.api_client_auth import require_api_client -from app.services.idp_admin_service import ProviderAdminService -from app.services.runtime_cache import runtime_cache - -router = APIRouter(prefix="/internal", tags=["internal"], dependencies=[Depends(require_api_client)]) - - -@router.post("/users/upsert-by-sub", response_model=InternalUpsertUserBySubResponse) -def upsert_user_by_sub( - payload: UserUpsertBySubRequest, - db: Session = Depends(get_db), -) -> InternalUpsertUserBySubResponse: - repo = UsersRepository(db) - user = repo.upsert_by_sub( - user_sub=payload.user_sub, - username=payload.username, - email=payload.email, - display_name=payload.display_name, - is_active=payload.is_active, - status=payload.status, - ) - return InternalUpsertUserBySubResponse( - id=user.id, - user_sub=user.user_sub, - provider_user_id=user.provider_user_id, - username=user.username, - email=user.email, - display_name=user.display_name, - is_active=user.is_active, - status=user.status, - ) - - -def _build_user_role_rows(db: Session, user_sub: str) -> list[tuple[str, str, str, str, str, str, str, str]]: - users_repo = UsersRepository(db) - user_sites_repo = UserSitesRepository(db) - - user = users_repo.get_by_sub(user_sub) - if user is None: - return [] - - rows = user_sites_repo.get_user_role_rows(user.id) - return [ - ( - site.site_key, - site.display_name, - company.company_key, - company.name, - system.system_key, - system.name, - role.role_key, - role.name, - ) - for site, company, role, system in rows - ] - - -@router.get("/users/{user_sub}/roles", response_model=InternalUserRoleResponse) -def get_user_roles(user_sub: str, db: Session = Depends(get_db)) -> InternalUserRoleResponse: - cache_key = f"internal:user_roles:{user_sub}" - cached = runtime_cache.get(cache_key) - if isinstance(cached, InternalUserRoleResponse): - return cached - - rows = _build_user_role_rows(db, user_sub) - result = InternalUserRoleResponse( - user_sub=user_sub, - roles=[ - InternalUserRoleItem( - site_key=site_key, - site_display_name=site_display_name, - company_key=company_key, - company_display_name=company_display_name, - system_key=system_key, - system_name=system_name, - role_key=role_key, - role_name=role_name, - ) - for ( - site_key, - site_display_name, - company_key, - company_display_name, - system_key, - system_name, - role_key, - role_name, - ) in rows - ], - ) - runtime_cache.set(cache_key, result, ttl_seconds=30) - return result - - -@router.post("/provider/users/ensure", response_model=ProviderEnsureUserResponse) -@router.post("/idp/users/ensure", response_model=ProviderEnsureUserResponse, include_in_schema=False) -@router.post("/keycloak/users/ensure", response_model=ProviderEnsureUserResponse, include_in_schema=False) -def ensure_idp_user( - payload: ProviderEnsureUserRequest, - db: Session = Depends(get_db), -) -> ProviderEnsureUserResponse: - settings = get_settings() - idp_service = ProviderAdminService(settings=settings) - sync_result = idp_service.ensure_user( - sub=payload.user_sub, - email=payload.email, - username=payload.username, - display_name=payload.display_name, - is_active=payload.is_active, - ) - - users_repo = UsersRepository(db) - resolved_sub = payload.user_sub or sync_result.user_sub or "" - if not resolved_sub: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="idp_missing_sub") - - users_repo.upsert_by_sub( - user_sub=resolved_sub, - username=payload.username, - email=payload.email, - display_name=payload.display_name, - is_active=payload.is_active, - status="active", - provider_user_id=sync_result.user_id, - ) - return ProviderEnsureUserResponse(provider_user_id=sync_result.user_id, action=sync_result.action) diff --git a/backend/app/api/internal_catalog.py b/backend/app/api/internal_catalog.py deleted file mode 100644 index 35bab52..0000000 --- a/backend/app/api/internal_catalog.py +++ /dev/null @@ -1,170 +0,0 @@ -from fastapi import APIRouter, Depends, Query -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.repositories.companies_repo import CompaniesRepository -from app.repositories.roles_repo import RolesRepository -from app.repositories.sites_repo import SitesRepository -from app.repositories.systems_repo import SystemsRepository -from app.repositories.users_repo import UsersRepository -from app.schemas.internal import ( - InternalCompanyListResponse, - InternalMemberListResponse, - InternalRoleItem, - InternalRoleListResponse, - InternalSiteListResponse, - InternalSystemListResponse, -) -from app.security.api_client_auth import require_api_client - -router = APIRouter(prefix="/internal", tags=["internal"], dependencies=[Depends(require_api_client)]) - - -@router.get("/systems", response_model=InternalSystemListResponse) -def internal_list_systems( - db: Session = Depends(get_db), - limit: int = Query(default=200, ge=1, le=1000), - offset: int = Query(default=0, ge=0), -) -> InternalSystemListResponse: - repo = SystemsRepository(db) - items, total = repo.list(limit=limit, offset=offset) - return { - "items": [ - { - "id": i.id, - "system_key": i.system_key, - "name": i.name, - "status": i.status, - } - for i in items - ], - "total": total, - "limit": limit, - "offset": offset, - } - - -@router.get("/roles", response_model=InternalRoleListResponse) -def internal_list_roles( - db: Session = Depends(get_db), - system_key: str | None = Query(default=None), - limit: int = Query(default=500, ge=1, le=2000), - offset: int = Query(default=0, ge=0), -) -> InternalRoleListResponse: - systems_repo = SystemsRepository(db) - roles_repo = RolesRepository(db) - - system_id = None - systems, _ = systems_repo.list(limit=5000, offset=0) - system_map = {s.id: s for s in systems} - if system_key: - system = systems_repo.get_by_key(system_key) - if not system: - return InternalRoleListResponse(items=[], total=0, limit=limit, offset=offset) - system_id = system.id - - items, total = roles_repo.list(system_id=system_id, limit=limit, offset=offset) - rows = [ - InternalRoleItem( - id=i.id, - role_key=i.role_key, - system_key=system_map[i.system_id].system_key, - system_name=system_map[i.system_id].name, - name=i.name, - description=i.description, - status=i.status, - ) - for i in items - if i.system_id in system_map - ] - return InternalRoleListResponse(items=rows, total=total, limit=limit, offset=offset) - - -@router.get("/companies", response_model=InternalCompanyListResponse) -def internal_list_companies( - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - limit: int = Query(default=500, ge=1, le=2000), - offset: int = Query(default=0, ge=0), -) -> InternalCompanyListResponse: - repo = CompaniesRepository(db) - items, total = repo.list(keyword=keyword, limit=limit, offset=offset) - return { - "items": [ - { - "id": i.id, - "company_key": i.company_key, - "name": i.name, - "status": i.status, - } - for i in items - ], - "total": total, - "limit": limit, - "offset": offset, - } - - -@router.get("/sites", response_model=InternalSiteListResponse) -def internal_list_sites( - db: Session = Depends(get_db), - company_key: str | None = Query(default=None), - limit: int = Query(default=500, ge=1, le=2000), - offset: int = Query(default=0, ge=0), -) -> InternalSiteListResponse: - companies_repo = CompaniesRepository(db) - sites_repo = SitesRepository(db) - company_id = None - if company_key: - company = companies_repo.get_by_key(company_key) - if company: - company_id = company.id - companies, _ = companies_repo.list(limit=5000, offset=0) - mapping = {c.id: c for c in companies} - items, total = sites_repo.list(company_id=company_id, limit=limit, offset=offset) - return { - "items": [ - { - "id": i.id, - "site_key": i.site_key, - "company_key": mapping[i.company_id].company_key, - "company_display_name": mapping[i.company_id].name, - "display_name": i.display_name, - "domain": i.domain, - "status": i.status, - } - for i in items - if i.company_id in mapping - ], - "total": total, - "limit": limit, - "offset": offset, - } - - -@router.get("/members", response_model=InternalMemberListResponse) -def internal_list_members( - db: Session = Depends(get_db), - keyword: str | None = Query(default=None), - limit: int = Query(default=500, ge=1, le=2000), - offset: int = Query(default=0, ge=0), -) -> InternalMemberListResponse: - repo = UsersRepository(db) - items, total = repo.list(keyword=keyword, limit=limit, offset=offset) - return { - "items": [ - { - "id": i.id, - "user_sub": i.user_sub, - "username": i.username, - "email": i.email, - "display_name": i.display_name, - "is_active": i.is_active, - "status": i.status, - } - for i in items - ], - "total": total, - "limit": limit, - "offset": offset, - } diff --git a/backend/app/api/me.py b/backend/app/api/me.py deleted file mode 100644 index 432ddd5..0000000 --- a/backend/app/api/me.py +++ /dev/null @@ -1,90 +0,0 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.repositories.users_repo import UsersRepository -from app.repositories.user_sites_repo import UserSitesRepository -from app.schemas.auth import ProviderPrincipal, MeSummaryResponse -from app.schemas.permissions import RoleSnapshotResponse -from app.security.idp_jwt import require_authenticated_principal -from app.services.permission_service import PermissionService -from app.services.runtime_cache import runtime_cache - -router = APIRouter(prefix="/me", tags=["me"]) - - -@router.get("", response_model=MeSummaryResponse) -def get_me( - principal: ProviderPrincipal = Depends(require_authenticated_principal), - db: Session = Depends(get_db), -) -> MeSummaryResponse: - cache_key = f"me:{principal.sub}" - cached = runtime_cache.get(cache_key) - if isinstance(cached, MeSummaryResponse): - return cached - try: - users_repo = UsersRepository(db) - user = users_repo.upsert_by_sub( - user_sub=principal.sub, - username=principal.preferred_username, - email=principal.email, - display_name=principal.name or principal.preferred_username, - is_active=True, - status="active", - ) - result = MeSummaryResponse(sub=user.user_sub, email=user.email, display_name=user.display_name) - runtime_cache.set(cache_key, result, ttl_seconds=30) - return result - except SQLAlchemyError: - result = MeSummaryResponse( - sub=principal.sub, - email=principal.email, - display_name=principal.name or principal.preferred_username, - ) - runtime_cache.set(cache_key, result, ttl_seconds=15) - return result - - -@router.get("/permissions/snapshot", response_model=RoleSnapshotResponse) -def get_my_permission_snapshot( - principal: ProviderPrincipal = Depends(require_authenticated_principal), - db: Session = Depends(get_db), -) -> RoleSnapshotResponse: - cache_key = f"me:permissions_snapshot:{principal.sub}" - cached = runtime_cache.get(cache_key) - if isinstance(cached, RoleSnapshotResponse): - return cached - try: - users_repo = UsersRepository(db) - user_sites_repo = UserSitesRepository(db) - - user = users_repo.upsert_by_sub( - user_sub=principal.sub, - username=principal.preferred_username, - email=principal.email, - display_name=principal.name or principal.preferred_username, - is_active=True, - status="active", - ) - rows = user_sites_repo.get_user_role_rows(user.id) - serialized = [ - ( - site.site_key, - site.display_name, - company.company_key, - company.name, - system.system_key, - system.name, - role.role_key, - role.name, - ) - for site, company, role, system in rows - ] - result = PermissionService.build_role_snapshot(user_sub=principal.sub, rows=serialized) - runtime_cache.set(cache_key, result, ttl_seconds=30) - return result - except SQLAlchemyError: - result = RoleSnapshotResponse(user_sub=principal.sub, roles=[]) - runtime_cache.set(cache_key, result, ttl_seconds=10) - return result diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py deleted file mode 100644 index d1d134a..0000000 --- a/backend/app/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Core settings and constants.""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py deleted file mode 100644 index d01fb81..0000000 --- a/backend/app/core/config.py +++ /dev/null @@ -1,130 +0,0 @@ -from functools import lru_cache -from typing import Annotated - -from pydantic import field_validator -from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict - - -class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", extra="ignore") - - app_env: str = "development" - port: int = 8000 - - db_host: str = "127.0.0.1" - db_port: int = 54321 - db_name: str = "member_center" - db_user: str = "member_ose" - db_password: str = "" - - # Keycloak only - keycloak_base_url: str = "" - keycloak_realm: str = "" - keycloak_verify_tls: bool = True - keycloak_issuer: str = "" - keycloak_jwks_url: str = "" - keycloak_audience: str = "" - keycloak_client_id: str = "" - keycloak_client_secret: str = "" - keycloak_token_endpoint: str = "" - keycloak_userinfo_endpoint: str = "" - keycloak_admin_client_id: str = "" - keycloak_admin_client_secret: str = "" - keycloak_admin_realm: str = "" - - public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"] - internal_shared_secret: str = "" - admin_required_groups: Annotated[list[str], NoDecode] = [] - member_required_realm_roles: Annotated[list[str], NoDecode] = ["admin", "manager"] - admin_required_realm_roles: Annotated[list[str], NoDecode] = ["admin", "manager"] - cache_backend: str = "memory" - cache_redis_url: str = "redis://127.0.0.1:6379/0" - cache_prefix: str = "memberapi" - cache_default_ttl_seconds: int = 30 - - @field_validator("public_frontend_origins", mode="before") - @classmethod - def parse_origins(cls, value: str | list[str]) -> list[str]: - if isinstance(value, list): - return value - if not value: - return [] - return [origin.strip() for origin in value.split(",") if origin.strip()] - - @field_validator("admin_required_groups", mode="before") - @classmethod - def parse_csv(cls, value: str | list[str]) -> list[str]: - if isinstance(value, list): - return [str(v).strip() for v in value if str(v).strip()] - if not value: - return [] - return [part.strip() for part in value.split(",") if part.strip()] - - @field_validator("member_required_realm_roles", "admin_required_realm_roles", mode="before") - @classmethod - def parse_roles_csv(cls, value: str | list[str]) -> list[str]: - if isinstance(value, list): - return [str(v).strip() for v in value if str(v).strip()] - if not value: - return [] - return [part.strip() for part in value.split(",") if part.strip()] - - @property - def database_url(self) -> str: - return ( - "postgresql+psycopg://" - f"{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" - ) - - @property - def idp_base_url(self) -> str: - return self.keycloak_base_url.rstrip("/") - - @property - def idp_verify_tls(self) -> bool: - return self.keycloak_verify_tls - - @property - def idp_issuer(self) -> str: - if self.keycloak_issuer: - return self.keycloak_issuer.rstrip("/") - return f"{self.idp_base_url}/realms/{self.keycloak_realm}" - - @property - def idp_jwks_url(self) -> str: - if self.keycloak_jwks_url: - return self.keycloak_jwks_url - return f"{self.idp_issuer}/protocol/openid-connect/certs" - - @property - def idp_audience(self) -> str: - return self.keycloak_audience - - @property - def idp_client_id(self) -> str: - return self.keycloak_client_id - - @property - def idp_client_secret(self) -> str: - return self.keycloak_client_secret - - @property - def idp_token_endpoint(self) -> str: - if self.keycloak_token_endpoint: - return self.keycloak_token_endpoint - return f"{self.idp_issuer}/protocol/openid-connect/token" - - @property - def idp_userinfo_endpoint(self) -> str: - if self.keycloak_userinfo_endpoint: - return self.keycloak_userinfo_endpoint - return f"{self.idp_issuer}/protocol/openid-connect/userinfo" - - @property - def idp_authorize_endpoint(self) -> str: - return f"{self.idp_issuer}/protocol/openid-connect/auth" - - -@lru_cache -def get_settings() -> Settings: - return Settings() diff --git a/backend/app/core/keygen.py b/backend/app/core/keygen.py deleted file mode 100644 index 44deb6b..0000000 --- a/backend/app/core/keygen.py +++ /dev/null @@ -1,9 +0,0 @@ -from datetime import datetime -import time - - -def generate_key(prefix: str, salt: int = 0) -> str: - date_str = datetime.now().strftime("%Y%m%d") - tail = (int(time.time() * 1000) + salt) % 10000 - return f"{prefix}{date_str}X{tail:04d}" - diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py deleted file mode 100644 index 9a2ecb3..0000000 --- a/backend/app/db/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Database wiring.""" diff --git a/backend/app/db/base.py b/backend/app/db/base.py deleted file mode 100644 index fa2b68a..0000000 --- a/backend/app/db/base.py +++ /dev/null @@ -1,5 +0,0 @@ -from sqlalchemy.orm import DeclarativeBase - - -class Base(DeclarativeBase): - pass diff --git a/backend/app/db/session.py b/backend/app/db/session.py deleted file mode 100644 index 83bc48a..0000000 --- a/backend/app/db/session.py +++ /dev/null @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index 623c046..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,45 +0,0 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -from app.api.admin_catalog import router as admin_catalog_router -from app.api.auth import router as auth_router -from app.api.internal_catalog import router as internal_catalog_router -from app.api.internal import router as internal_router -from app.api.me import router as me_router -from app.core.config import get_settings -from app.services.runtime_cache import runtime_cache - -app = FastAPI(title="memberapi.ose.tw", version="0.1.0") - -settings = get_settings() -app.add_middleware( - CORSMiddleware, - allow_origins=settings.public_frontend_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.middleware("http") -async def invalidate_runtime_cache_on_cud(request, call_next): - response = await call_next(request) - if ( - request.method in {"POST", "PUT", "PATCH", "DELETE"} - and request.url.path.startswith(("/admin", "/internal")) - and response.status_code < 400 - ): - runtime_cache.bump_revision() - return response - - -@app.get("/healthz", tags=["health"]) -def healthz() -> dict[str, str]: - return {"status": "ok"} - - -app.include_router(internal_router) -app.include_router(internal_catalog_router) -app.include_router(admin_catalog_router) -app.include_router(me_router) -app.include_router(auth_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 4f0777f..0000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from app.models.api_client import ApiClient -from app.models.auth_sync_state import AuthSyncState -from app.models.company import Company -from app.models.role import Role -from app.models.site import Site -from app.models.site_role import SiteRole -from app.models.system import System -from app.models.user import User -from app.models.user_site import UserSite - -__all__ = [ - "ApiClient", - "AuthSyncState", - "Company", - "Role", - "Site", - "SiteRole", - "System", - "User", - "UserSite", -] diff --git a/backend/app/models/api_client.py b/backend/app/models/api_client.py deleted file mode 100644 index e865cd1..0000000 --- a/backend/app/models/api_client.py +++ /dev/null @@ -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 - ) diff --git a/backend/app/models/auth_sync_state.py b/backend/app/models/auth_sync_state.py deleted file mode 100644 index 0174eb7..0000000 --- a/backend/app/models/auth_sync_state.py +++ /dev/null @@ -1,21 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, String, UniqueConstraint, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class AuthSyncState(Base): - __tablename__ = "auth_sync_state" - __table_args__ = (UniqueConstraint("entity_type", "entity_id", name="uq_auth_sync_state_entity"),) - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - entity_type: Mapped[str] = mapped_column(String(32), nullable=False) - entity_id: Mapped[str] = mapped_column(UUID(as_uuid=False), nullable=False) - last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - source_version: Mapped[str | None] = mapped_column(String(255)) - last_error: Mapped[str | None] = mapped_column(String) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/app/models/company.py b/backend/app/models/company.py deleted file mode 100644 index 8ff225d..0000000 --- a/backend/app/models/company.py +++ /dev/null @@ -1,30 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, String, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class Company(Base): - __tablename__ = "companies" - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - company_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) - name: Mapped[str] = mapped_column(String(255), nullable=False) - provider_group_id: Mapped[str | None] = mapped_column(String(128)) - status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False - ) - - @property - def display_name(self) -> str: - return self.name - - @display_name.setter - def display_name(self, value: str) -> None: - self.name = value diff --git a/backend/app/models/role.py b/backend/app/models/role.py deleted file mode 100644 index 83425eb..0000000 --- a/backend/app/models/role.py +++ /dev/null @@ -1,32 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class Role(Base): - __tablename__ = "roles" - __table_args__ = (UniqueConstraint("system_id", "name", name="uq_roles_system_name"),) - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - role_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) - system_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("systems.id", ondelete="CASCADE"), nullable=False) - name: Mapped[str] = mapped_column(String(255), nullable=False) - description: Mapped[str | None] = mapped_column(String(1024)) - status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False - ) - - @property - def provider_role_name(self) -> str: - return self.name - - @provider_role_name.setter - def provider_role_name(self, value: str) -> None: - self.name = value diff --git a/backend/app/models/site.py b/backend/app/models/site.py deleted file mode 100644 index 499a2d9..0000000 --- a/backend/app/models/site.py +++ /dev/null @@ -1,24 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, ForeignKey, String, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class Site(Base): - __tablename__ = "sites" - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - site_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) - company_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False) - display_name: Mapped[str] = mapped_column(String(255), nullable=False) - domain: Mapped[str | None] = mapped_column(String(255)) - provider_group_id: Mapped[str | None] = mapped_column(String(128)) - status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False - ) diff --git a/backend/app/models/site_role.py b/backend/app/models/site_role.py deleted file mode 100644 index de37137..0000000 --- a/backend/app/models/site_role.py +++ /dev/null @@ -1,18 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class SiteRole(Base): - __tablename__ = "site_roles" - __table_args__ = (UniqueConstraint("site_id", "role_id", name="uq_site_roles_site_role"),) - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - site_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("sites.id", ondelete="CASCADE"), nullable=False) - role_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("roles.id", ondelete="CASCADE"), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/app/models/system.py b/backend/app/models/system.py deleted file mode 100644 index 74f594f..0000000 --- a/backend/app/models/system.py +++ /dev/null @@ -1,29 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, String, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class System(Base): - __tablename__ = "systems" - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - system_key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) - name: Mapped[str] = mapped_column(String(255), nullable=False) - status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False - ) - - @property - def provider_client_id(self) -> str: - return self.name - - @provider_client_id.setter - def provider_client_id(self, value: str) -> None: - self.name = value diff --git a/backend/app/models/user.py b/backend/app/models/user.py deleted file mode 100644 index 6c3a7ec..0000000 --- a/backend/app/models/user.py +++ /dev/null @@ -1,25 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import Boolean, DateTime, String, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class User(Base): - __tablename__ = "users" - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - user_sub: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) - provider_user_id: Mapped[str | None] = mapped_column(String(128), unique=True) - username: Mapped[str | None] = mapped_column(String(255), unique=True) - email: Mapped[str | None] = mapped_column(String(320), unique=True) - display_name: Mapped[str | None] = mapped_column(String(255)) - status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False - ) diff --git a/backend/app/models/user_site.py b/backend/app/models/user_site.py deleted file mode 100644 index 5d29797..0000000 --- a/backend/app/models/user_site.py +++ /dev/null @@ -1,21 +0,0 @@ -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, func -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.db.base import Base - - -class UserSite(Base): - __tablename__ = "user_sites" - __table_args__ = (UniqueConstraint("user_id", "site_id", name="uq_user_sites_user_site"),) - - id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) - user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - site_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("sites.id", ondelete="CASCADE"), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False - ) diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py deleted file mode 100644 index 4e2fa5e..0000000 --- a/backend/app/repositories/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Repository layer.""" diff --git a/backend/app/repositories/api_clients_repo.py b/backend/app/repositories/api_clients_repo.py deleted file mode 100644 index d8e4496..0000000 --- a/backend/app/repositories/api_clients_repo.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import annotations - -from sqlalchemy import func, or_, select -from sqlalchemy.orm import Session - -from app.models.api_client import ApiClient - - -class ApiClientsRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def get_by_key(self, client_key: str) -> ApiClient | None: - return self.db.scalar(select(ApiClient).where(ApiClient.client_key == client_key)) - - def list(self, *, keyword: str | None = None, status: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[ApiClient], int]: - stmt = select(ApiClient) - count_stmt = select(func.count()).select_from(ApiClient) - if keyword: - pattern = f"%{keyword}%" - cond = or_(ApiClient.client_key.ilike(pattern), ApiClient.name.ilike(pattern)) - stmt = stmt.where(cond) - count_stmt = count_stmt.where(cond) - if status: - stmt = stmt.where(ApiClient.status == status) - count_stmt = count_stmt.where(ApiClient.status == status) - - stmt = stmt.order_by(ApiClient.created_at.desc()).limit(limit).offset(offset) - return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) - - def create( - self, - *, - client_key: str, - name: str, - status: str, - api_key_hash: str, - allowed_origins: list[str], - allowed_ips: list[str], - allowed_paths: list[str], - rate_limit_per_min: int | None, - expires_at, - ) -> ApiClient: - item = ApiClient( - client_key=client_key, - name=name, - status=status, - api_key_hash=api_key_hash, - allowed_origins=allowed_origins, - allowed_ips=allowed_ips, - allowed_paths=allowed_paths, - rate_limit_per_min=rate_limit_per_min, - expires_at=expires_at, - ) - self.db.add(item) - self.db.commit() - self.db.refresh(item) - return item - - def update( - self, - item: ApiClient, - *, - name: str | None = None, - status: str | None = None, - api_key_hash: str | None = None, - allowed_origins: list[str] | None = None, - allowed_ips: list[str] | None = None, - allowed_paths: list[str] | None = None, - rate_limit_per_min: int | None = None, - expires_at=None, - ) -> ApiClient: - if name is not None: - item.name = name - if status is not None: - item.status = status - if api_key_hash is not None: - item.api_key_hash = api_key_hash - if allowed_origins is not None: - item.allowed_origins = allowed_origins - if allowed_ips is not None: - item.allowed_ips = allowed_ips - if allowed_paths is not None: - item.allowed_paths = allowed_paths - if rate_limit_per_min is not None: - item.rate_limit_per_min = rate_limit_per_min - if expires_at is not None: - item.expires_at = expires_at - - self.db.commit() - self.db.refresh(item) - return item - - def delete(self, item: ApiClient) -> None: - self.db.delete(item) - self.db.commit() diff --git a/backend/app/repositories/companies_repo.py b/backend/app/repositories/companies_repo.py deleted file mode 100644 index 3a0f6e8..0000000 --- a/backend/app/repositories/companies_repo.py +++ /dev/null @@ -1,71 +0,0 @@ -from sqlalchemy import func, or_, select -from sqlalchemy.orm import Session - -from app.models.company import Company - - -class CompaniesRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def get_by_key(self, company_key: str) -> Company | None: - return self.db.scalar(select(Company).where(Company.company_key == company_key)) - - def get_by_id(self, company_id: str) -> Company | None: - return self.db.scalar(select(Company).where(Company.id == company_id)) - - def list(self, keyword: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[Company], int]: - stmt = select(Company) - count_stmt = select(func.count()).select_from(Company) - if keyword: - pattern = f"%{keyword}%" - cond = or_( - Company.company_key.ilike(pattern), - Company.name.ilike(pattern), - ) - stmt = stmt.where(cond) - count_stmt = count_stmt.where(cond) - - stmt = stmt.order_by(Company.created_at.desc()).limit(limit).offset(offset) - return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) - - def create( - self, - *, - company_key: str, - name: str, - provider_group_id: str | None = None, - status: str = "active", - ) -> Company: - item = Company( - company_key=company_key, - name=name, - provider_group_id=provider_group_id, - status=status, - ) - self.db.add(item) - self.db.commit() - self.db.refresh(item) - return item - - def update( - self, - item: Company, - *, - name: str | None = None, - provider_group_id: str | None = None, - status: str | None = None, - ) -> Company: - if name is not None: - item.name = name - if provider_group_id is not None: - item.provider_group_id = provider_group_id - if status is not None: - item.status = status - self.db.commit() - self.db.refresh(item) - return item - - def delete(self, item: Company) -> None: - self.db.delete(item) - self.db.commit() diff --git a/backend/app/repositories/roles_repo.py b/backend/app/repositories/roles_repo.py deleted file mode 100644 index ff31343..0000000 --- a/backend/app/repositories/roles_repo.py +++ /dev/null @@ -1,91 +0,0 @@ -from sqlalchemy import func, or_, select -from sqlalchemy.orm import Session - -from app.models.role import Role - - -class RolesRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def get_by_key(self, role_key: str) -> Role | None: - return self.db.scalar(select(Role).where(Role.role_key == role_key)) - - def get_by_id(self, role_id: str) -> Role | None: - return self.db.scalar(select(Role).where(Role.id == role_id)) - - def list( - self, - *, - keyword: str | None = None, - system_id: str | None = None, - status: str | None = None, - limit: int = 100, - offset: int = 0, - ) -> tuple[list[Role], int]: - stmt = select(Role) - count_stmt = select(func.count()).select_from(Role) - if keyword: - pattern = f"%{keyword}%" - cond = or_( - Role.role_key.ilike(pattern), - Role.name.ilike(pattern), - Role.description.ilike(pattern), - ) - stmt = stmt.where(cond) - count_stmt = count_stmt.where(cond) - if system_id: - stmt = stmt.where(Role.system_id == system_id) - count_stmt = count_stmt.where(Role.system_id == system_id) - if status: - stmt = stmt.where(Role.status == status) - count_stmt = count_stmt.where(Role.status == status) - - stmt = stmt.order_by(Role.created_at.desc()).limit(limit).offset(offset) - return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) - - def create( - self, - *, - role_key: str, - system_id: str, - name: str, - description: str | None, - status: str = "active", - ) -> Role: - item = Role( - role_key=role_key, - system_id=system_id, - name=name, - description=description, - status=status, - ) - self.db.add(item) - self.db.commit() - self.db.refresh(item) - return item - - def update( - self, - item: Role, - *, - system_id: str | None = None, - name: str | None = None, - description: str | None = None, - status: str | None = None, - ) -> Role: - if system_id is not None: - item.system_id = system_id - if name is not None: - item.name = name - if description is not None: - item.description = description - if status is not None: - item.status = status - self.db.commit() - self.db.refresh(item) - return item - - def delete(self, item: Role) -> None: - self.db.delete(item) - self.db.commit() diff --git a/backend/app/repositories/site_roles_repo.py b/backend/app/repositories/site_roles_repo.py deleted file mode 100644 index aa015af..0000000 --- a/backend/app/repositories/site_roles_repo.py +++ /dev/null @@ -1,45 +0,0 @@ -from sqlalchemy import delete, select -from sqlalchemy.orm import Session - -from app.models.role import Role -from app.models.site import Site -from app.models.site_role import SiteRole -from app.models.system import System - - -class SiteRolesRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def list_site_role_rows(self, site_id: str) -> list[tuple[SiteRole, Role, System]]: - stmt = ( - select(SiteRole, Role, System) - .join(Role, Role.id == SiteRole.role_id) - .join(System, System.id == Role.system_id) - .where(SiteRole.site_id == site_id) - .order_by(System.name.asc(), Role.name.asc()) - ) - return list(self.db.execute(stmt).all()) - - def list_role_site_rows(self, role_id: str) -> list[tuple[SiteRole, Site]]: - stmt = ( - select(SiteRole, Site) - .join(Site, Site.id == SiteRole.site_id) - .where(SiteRole.role_id == role_id) - .order_by(Site.display_name.asc()) - ) - return list(self.db.execute(stmt).all()) - - def set_site_roles(self, *, site_id: str, role_ids: list[str], commit: bool = True) -> None: - self.db.execute(delete(SiteRole).where(SiteRole.site_id == site_id)) - for role_id in role_ids: - self.db.add(SiteRole(site_id=site_id, role_id=role_id)) - if commit: - self.db.commit() - - def set_role_sites(self, *, role_id: str, site_ids: list[str], commit: bool = True) -> None: - self.db.execute(delete(SiteRole).where(SiteRole.role_id == role_id)) - for site_id in site_ids: - self.db.add(SiteRole(site_id=site_id, role_id=role_id)) - if commit: - self.db.commit() diff --git a/backend/app/repositories/sites_repo.py b/backend/app/repositories/sites_repo.py deleted file mode 100644 index 3f5277e..0000000 --- a/backend/app/repositories/sites_repo.py +++ /dev/null @@ -1,90 +0,0 @@ -from sqlalchemy import func, or_, select -from sqlalchemy.orm import Session - -from app.models.site import Site - - -class SitesRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def get_by_key(self, site_key: str) -> Site | None: - return self.db.scalar(select(Site).where(Site.site_key == site_key)) - - def get_by_id(self, site_id: str) -> Site | None: - return self.db.scalar(select(Site).where(Site.id == site_id)) - - def list( - self, - *, - keyword: str | None = None, - company_id: str | None = None, - limit: int = 100, - offset: int = 0, - ) -> tuple[list[Site], int]: - stmt = select(Site) - count_stmt = select(func.count()).select_from(Site) - - if keyword: - pattern = f"%{keyword}%" - cond = or_(Site.site_key.ilike(pattern), Site.display_name.ilike(pattern), Site.domain.ilike(pattern)) - stmt = stmt.where(cond) - count_stmt = count_stmt.where(cond) - - if company_id: - stmt = stmt.where(Site.company_id == company_id) - count_stmt = count_stmt.where(Site.company_id == company_id) - - stmt = stmt.order_by(Site.created_at.desc()).limit(limit).offset(offset) - return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) - - def create( - self, - *, - site_key: str, - company_id: str, - display_name: str, - domain: str | None, - provider_group_id: str | None = None, - status: str = "active", - ) -> Site: - item = Site( - site_key=site_key, - company_id=company_id, - display_name=display_name, - domain=domain, - provider_group_id=provider_group_id, - status=status, - ) - self.db.add(item) - self.db.commit() - self.db.refresh(item) - return item - - def update( - self, - item: Site, - *, - company_id: str | None = None, - display_name: str | None = None, - domain: str | None = None, - provider_group_id: str | None = None, - status: str | None = None, - ) -> Site: - if company_id is not None: - item.company_id = company_id - if display_name is not None: - item.display_name = display_name - if domain is not None: - item.domain = domain - if provider_group_id is not None: - item.provider_group_id = provider_group_id - if status is not None: - item.status = status - self.db.commit() - self.db.refresh(item) - return item - - def delete(self, item: Site) -> None: - self.db.delete(item) - self.db.commit() diff --git a/backend/app/repositories/systems_repo.py b/backend/app/repositories/systems_repo.py deleted file mode 100644 index 5bdf873..0000000 --- a/backend/app/repositories/systems_repo.py +++ /dev/null @@ -1,56 +0,0 @@ -from sqlalchemy import func, or_, select -from sqlalchemy.orm import Session - -from app.models.system import System - - -class SystemsRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def get_by_key(self, system_key: str) -> System | None: - return self.db.scalar(select(System).where(System.system_key == system_key)) - - def get_by_id(self, system_id: str) -> System | None: - return self.db.scalar(select(System).where(System.id == system_id)) - - def list(self, *, keyword: str | None = None, status: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[System], int]: - stmt = select(System) - count_stmt = select(func.count()).select_from(System) - if keyword: - pattern = f"%{keyword}%" - cond = or_(System.system_key.ilike(pattern), System.name.ilike(pattern)) - stmt = stmt.where(cond) - count_stmt = count_stmt.where(cond) - if status: - stmt = stmt.where(System.status == status) - count_stmt = count_stmt.where(System.status == status) - - stmt = stmt.order_by(System.created_at.desc()).limit(limit).offset(offset) - return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0) - - def create(self, *, system_key: str, name: str, status: str = "active") -> System: - item = System(system_key=system_key, name=name, status=status) - self.db.add(item) - self.db.commit() - self.db.refresh(item) - return item - - def update( - self, - item: System, - *, - name: str | None = None, - status: str | None = None, - ) -> System: - if name is not None: - item.name = name - if status is not None: - item.status = status - self.db.commit() - self.db.refresh(item) - return item - - def delete(self, item: System) -> None: - self.db.delete(item) - self.db.commit() diff --git a/backend/app/repositories/user_sites_repo.py b/backend/app/repositories/user_sites_repo.py deleted file mode 100644 index 99e4b9b..0000000 --- a/backend/app/repositories/user_sites_repo.py +++ /dev/null @@ -1,54 +0,0 @@ -from sqlalchemy import delete, select -from sqlalchemy.orm import Session - -from app.models.company import Company -from app.models.role import Role -from app.models.site import Site -from app.models.site_role import SiteRole -from app.models.system import System -from app.models.user import User -from app.models.user_site import UserSite - - -class UserSitesRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def list_user_site_rows(self, user_id: str) -> list[tuple[UserSite, Site, Company]]: - stmt = ( - select(UserSite, Site, Company) - .join(Site, Site.id == UserSite.site_id) - .join(Company, Company.id == Site.company_id) - .where(UserSite.user_id == user_id) - .order_by(Company.name.asc(), Site.display_name.asc()) - ) - return list(self.db.execute(stmt).all()) - - def list_site_member_rows(self, site_id: str) -> list[tuple[UserSite, User]]: - stmt = ( - select(UserSite, User) - .join(User, User.id == UserSite.user_id) - .where(UserSite.site_id == site_id) - .order_by(User.display_name.asc().nulls_last(), User.username.asc().nulls_last(), User.user_sub.asc()) - ) - return list(self.db.execute(stmt).all()) - - def set_user_sites(self, *, user_id: str, site_ids: list[str]) -> None: - self.db.execute(delete(UserSite).where(UserSite.user_id == user_id)) - for site_id in site_ids: - self.db.add(UserSite(user_id=user_id, site_id=site_id)) - self.db.commit() - - def get_user_role_rows(self, user_id: str) -> list[tuple[Site, Company, Role, System]]: - stmt = ( - select(Site, Company, Role, System) - .select_from(UserSite) - .join(Site, Site.id == UserSite.site_id) - .join(Company, Company.id == Site.company_id) - .join(SiteRole, SiteRole.site_id == Site.id) - .join(Role, Role.id == SiteRole.role_id) - .join(System, System.id == Role.system_id) - .where(UserSite.user_id == user_id) - .order_by(Company.name.asc(), Site.display_name.asc(), System.name.asc(), Role.name.asc()) - ) - return list(self.db.execute(stmt).all()) diff --git a/backend/app/repositories/users_repo.py b/backend/app/repositories/users_repo.py deleted file mode 100644 index 8030add..0000000 --- a/backend/app/repositories/users_repo.py +++ /dev/null @@ -1,125 +0,0 @@ -from sqlalchemy import func, or_, select -from sqlalchemy.orm import Session - -from app.models.user import User - - -class UsersRepository: - def __init__(self, db: Session) -> None: - self.db = db - - def get_by_sub(self, user_sub: str) -> User | None: - return self.db.scalar(select(User).where(User.user_sub == user_sub)) - - def get_by_id(self, user_id: str) -> User | None: - return self.db.scalar(select(User).where(User.id == user_id)) - - def list( - self, - *, - keyword: str | None = None, - is_active: bool | None = None, - limit: int = 50, - offset: int = 0, - ) -> tuple[list[User], int]: - stmt = select(User) - count_stmt = select(func.count()).select_from(User) - - if keyword: - pattern = f"%{keyword}%" - cond = or_( - User.user_sub.ilike(pattern), - User.username.ilike(pattern), - User.email.ilike(pattern), - User.display_name.ilike(pattern), - ) - stmt = stmt.where(cond) - count_stmt = count_stmt.where(cond) - - if is_active is not None: - stmt = stmt.where(User.is_active == is_active) - count_stmt = count_stmt.where(User.is_active == is_active) - - stmt = stmt.order_by(User.created_at.desc()).limit(limit).offset(offset) - items = list(self.db.scalars(stmt).all()) - total = int(self.db.scalar(count_stmt) or 0) - return items, total - - def upsert_by_sub( - self, - *, - user_sub: str, - username: str | None, - email: str | None, - display_name: str | None, - is_active: bool, - status: str = "active", - provider_user_id: str | None = None, - ) -> User: - user = self.get_by_sub(user_sub) - changed = False - if user is None: - user = User( - user_sub=user_sub, - provider_user_id=provider_user_id, - username=username, - email=email, - display_name=display_name, - is_active=is_active, - status=status, - ) - self.db.add(user) - changed = True - else: - if provider_user_id is not None and user.provider_user_id != provider_user_id: - user.provider_user_id = provider_user_id - changed = True - if user.username != username: - user.username = username - changed = True - if user.email != email: - user.email = email - changed = True - if user.display_name != display_name: - user.display_name = display_name - changed = True - if user.is_active != is_active: - user.is_active = is_active - changed = True - if user.status != status: - user.status = status - changed = True - - if changed: - self.db.commit() - self.db.refresh(user) - return user - - def update_member( - self, - user: User, - *, - username: str | None = None, - email: str | None = None, - display_name: str | None = None, - is_active: bool | None = None, - status: str | None = None, - ) -> User: - if username is not None: - user.username = username - if email is not None: - user.email = email - if display_name is not None: - user.display_name = display_name - if is_active is not None: - user.is_active = is_active - if status is not None: - user.status = status - - self.db.commit() - self.db.refresh(user) - return user - - def delete(self, user: User) -> None: - self.db.delete(user) - self.db.commit() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py deleted file mode 100644 index f391682..0000000 --- a/backend/app/schemas/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Pydantic schemas.""" diff --git a/backend/app/schemas/api_clients.py b/backend/app/schemas/api_clients.py deleted file mode 100644 index 66a7961..0000000 --- a/backend/app/schemas/api_clients.py +++ /dev/null @@ -1,49 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field - - -class ApiClientItem(BaseModel): - id: str - client_key: str - name: str - status: str - allowed_origins: list[str] = Field(default_factory=list) - allowed_ips: list[str] = Field(default_factory=list) - allowed_paths: list[str] = Field(default_factory=list) - rate_limit_per_min: int | None = None - expires_at: datetime | None = None - last_used_at: datetime | None = None - created_at: datetime - updated_at: datetime - - -class ApiClientCreateRequest(BaseModel): - name: str - client_key: str | None = None - status: str = "active" - allowed_origins: list[str] = Field(default_factory=list) - allowed_ips: list[str] = Field(default_factory=list) - allowed_paths: list[str] = Field(default_factory=list) - rate_limit_per_min: int | None = None - expires_at: datetime | None = None - - -class ApiClientUpdateRequest(BaseModel): - name: str | None = None - status: str | None = None - allowed_origins: list[str] | None = None - allowed_ips: list[str] | None = None - allowed_paths: list[str] | None = None - rate_limit_per_min: int | None = None - expires_at: datetime | None = None - - -class ApiClientCreateResponse(BaseModel): - item: ApiClientItem - api_key: str - - -class ApiClientRotateKeyResponse(BaseModel): - client_key: str - api_key: str diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py deleted file mode 100644 index 31a4897..0000000 --- a/backend/app/schemas/auth.py +++ /dev/null @@ -1,16 +0,0 @@ -from pydantic import BaseModel, Field - - -class ProviderPrincipal(BaseModel): - sub: str - email: str | None = None - name: str | None = None - preferred_username: str | None = None - groups: list[str] = Field(default_factory=list) - realm_roles: list[str] = Field(default_factory=list) - - -class MeSummaryResponse(BaseModel): - sub: str - email: str | None = None - display_name: str | None = None diff --git a/backend/app/schemas/catalog.py b/backend/app/schemas/catalog.py deleted file mode 100644 index 02eeb58..0000000 --- a/backend/app/schemas/catalog.py +++ /dev/null @@ -1,216 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field - - -class ListResponse(BaseModel): - items: list - total: int - limit: int - offset: int - - -class CompanyCreateRequest(BaseModel): - name: str - status: str = "active" - - -class CompanyUpdateRequest(BaseModel): - name: str | None = None - provider_group_id: str | None = None - status: str | None = None - - -class CompanyItem(BaseModel): - id: str - company_key: str - name: str - provider_group_id: str | None = None - status: str - - -class SiteCreateRequest(BaseModel): - company_key: str - display_name: str - domain: str | None = None - status: str = "active" - - -class SiteUpdateRequest(BaseModel): - company_key: str | None = None - display_name: str | None = None - domain: str | None = None - provider_group_id: str | None = None - status: str | None = None - - -class SiteItem(BaseModel): - id: str - site_key: str - company_key: str - company_display_name: str - display_name: str - domain: str | None = None - provider_group_id: str | None = None - status: str - - -class SystemCreateRequest(BaseModel): - name: str - status: str = "active" - - -class SystemUpdateRequest(BaseModel): - name: str | None = None - status: str | None = None - - -class SystemItem(BaseModel): - id: str - system_key: str - name: str - status: str - - -class RoleCreateRequest(BaseModel): - system_key: str - name: str - description: str | None = None - status: str = "active" - - -class RoleUpdateRequest(BaseModel): - system_key: str | None = None - name: str | None = None - description: str | None = None - status: str | None = None - - -class RoleItem(BaseModel): - id: str - role_key: str - system_key: str - system_name: str - name: str - description: str | None = None - status: str - - -class MemberItem(BaseModel): - id: str - user_sub: str - provider_user_id: str | None = None - username: str | None = None - email: str | None = None - display_name: str | None = None - is_active: bool - status: str - - -class MemberUpsertRequest(BaseModel): - user_sub: str | None = None - username: str | None = None - email: str | None = None - display_name: str | None = None - is_active: bool = True - status: str = "active" - sync_to_idp: bool = True - - -class MemberUpdateRequest(BaseModel): - username: str | None = None - email: str | None = None - display_name: str | None = None - is_active: bool | None = None - status: str | None = None - sync_to_idp: bool = True - - -class MemberPasswordResetResponse(BaseModel): - user_sub: str - temporary_password: str - - -class SiteRoleAssignRequest(BaseModel): - role_keys: list[str] = Field(default_factory=list) - - -class SiteRoleItem(BaseModel): - id: str - role_key: str - role_name: str - system_key: str - system_name: str - - -class UserSiteAssignRequest(BaseModel): - site_keys: list[str] = Field(default_factory=list) - - -class UserSiteItem(BaseModel): - id: str - site_key: str - site_display_name: str - company_key: str - company_display_name: str - - -class UserEffectiveRoleItem(BaseModel): - site_key: str - site_display_name: str - company_key: str - company_display_name: str - system_key: str - system_name: str - role_key: str - role_name: str - - -class UserEffectiveRolesResponse(BaseModel): - user_sub: str - roles: list[UserEffectiveRoleItem] - - -class SiteMembersResponse(BaseModel): - site_key: str - members: list[MemberItem] - - -class SiteRolesResponse(BaseModel): - site_key: str - roles: list[SiteRoleItem] - - -class UserSitesResponse(BaseModel): - user_sub: str - sites: list[UserSiteItem] - - -class CompanySitesResponse(BaseModel): - company_key: str - sites: list[SiteItem] - - -class SystemRolesResponse(BaseModel): - system_key: str - roles: list[RoleItem] - - -class RoleSitesResponse(BaseModel): - role_key: str - sites: list[UserSiteItem] - - -class ApiClientItem(BaseModel): - id: str - client_key: str - name: str - status: str - allowed_origins: list[str] = Field(default_factory=list) - allowed_ips: list[str] = Field(default_factory=list) - allowed_paths: list[str] = Field(default_factory=list) - rate_limit_per_min: int | None = None - expires_at: datetime | None = None - last_used_at: datetime | None = None - created_at: datetime - updated_at: datetime diff --git a/backend/app/schemas/idp_admin.py b/backend/app/schemas/idp_admin.py deleted file mode 100644 index 131c894..0000000 --- a/backend/app/schemas/idp_admin.py +++ /dev/null @@ -1,14 +0,0 @@ -from pydantic import AliasChoices, BaseModel, Field - - -class ProviderEnsureUserRequest(BaseModel): - user_sub: str | None = Field(default=None, validation_alias=AliasChoices("user_sub", "sub")) - username: str | None = None - email: str - display_name: str | None = None - is_active: bool = True - - -class ProviderEnsureUserResponse(BaseModel): - provider_user_id: str - action: str diff --git a/backend/app/schemas/internal.py b/backend/app/schemas/internal.py deleted file mode 100644 index f88a63a..0000000 --- a/backend/app/schemas/internal.py +++ /dev/null @@ -1,107 +0,0 @@ -from pydantic import BaseModel - - -class InternalSystemItem(BaseModel): - id: str - system_key: str - name: str - status: str - - -class InternalSystemListResponse(BaseModel): - items: list[InternalSystemItem] - total: int - limit: int - offset: int - - -class InternalRoleItem(BaseModel): - id: str - role_key: str - system_key: str - system_name: str - name: str - description: str | None = None - status: str - - -class InternalRoleListResponse(BaseModel): - items: list[InternalRoleItem] - total: int - limit: int - offset: int - - -class InternalCompanyItem(BaseModel): - id: str - company_key: str - name: str - status: str - - -class InternalCompanyListResponse(BaseModel): - items: list[InternalCompanyItem] - total: int - limit: int - offset: int - - -class InternalSiteItem(BaseModel): - id: str - site_key: str - company_key: str - company_display_name: str - display_name: str - domain: str | None = None - status: str - - -class InternalSiteListResponse(BaseModel): - items: list[InternalSiteItem] - total: int - limit: int - offset: int - - -class InternalMemberItem(BaseModel): - id: str - user_sub: str - username: str | None = None - email: str | None = None - display_name: str | None = None - is_active: bool - status: str - - -class InternalMemberListResponse(BaseModel): - items: list[InternalMemberItem] - total: int - limit: int - offset: int - - -class InternalUpsertUserBySubResponse(BaseModel): - id: str - user_sub: str - provider_user_id: str | None = None - username: str | None = None - email: str | None = None - display_name: str | None = None - is_active: bool - status: str - - -class InternalUserRoleItem(BaseModel): - site_key: str - site_display_name: str - company_key: str - company_display_name: str - system_key: str - system_name: str - role_key: str - role_name: str - - -class InternalUserRoleResponse(BaseModel): - user_sub: str - roles: list[InternalUserRoleItem] diff --git a/backend/app/schemas/login.py b/backend/app/schemas/login.py deleted file mode 100644 index 4d1e99d..0000000 --- a/backend/app/schemas/login.py +++ /dev/null @@ -1,29 +0,0 @@ -from pydantic import BaseModel - - -class LoginRequest(BaseModel): - username: str - password: str - - -class LoginResponse(BaseModel): - access_token: str - refresh_token: str | None = None - token_type: str = "Bearer" - expires_in: int | None = None - refresh_expires_in: int | None = None - scope: str | None = None - - -class OIDCAuthUrlResponse(BaseModel): - authorize_url: str - - -class OIDCCodeExchangeRequest(BaseModel): - code: str - redirect_uri: str - code_verifier: str | None = None - - -class RefreshTokenRequest(BaseModel): - refresh_token: str diff --git a/backend/app/schemas/permissions.py b/backend/app/schemas/permissions.py deleted file mode 100644 index 5af6bac..0000000 --- a/backend/app/schemas/permissions.py +++ /dev/null @@ -1,17 +0,0 @@ -from pydantic import BaseModel - - -class RoleSnapshotItem(BaseModel): - site_key: str - site_display_name: str - company_key: str - company_display_name: str - system_key: str - system_name: str - role_key: str - role_name: str - - -class RoleSnapshotResponse(BaseModel): - user_sub: str - roles: list[RoleSnapshotItem] diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py deleted file mode 100644 index f1dc272..0000000 --- a/backend/app/schemas/users.py +++ /dev/null @@ -1,10 +0,0 @@ -from pydantic import AliasChoices, BaseModel, Field - - -class UserUpsertBySubRequest(BaseModel): - user_sub: str = Field(validation_alias=AliasChoices("user_sub", "sub")) - username: str | None = None - email: str | None = None - display_name: str | None = None - is_active: bool = True - status: str = "active" diff --git a/backend/app/security/__init__.py b/backend/app/security/__init__.py deleted file mode 100644 index 218069a..0000000 --- a/backend/app/security/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Security dependencies and guards.""" diff --git a/backend/app/security/admin_guard.py b/backend/app/security/admin_guard.py deleted file mode 100644 index 6f31b16..0000000 --- a/backend/app/security/admin_guard.py +++ /dev/null @@ -1,31 +0,0 @@ -from fastapi import Depends, HTTPException, status - -from app.core.config import get_settings -from app.schemas.auth import ProviderPrincipal -from app.security.idp_jwt import require_authenticated_principal - - -def _normalize_roles(values: set[str]) -> set[str]: - normalized: set[str] = set() - for value in values: - role = value.strip().lower() - if role: - normalized.add(role) - return normalized - - -def require_admin_principal( - principal: ProviderPrincipal = Depends(require_authenticated_principal), -) -> ProviderPrincipal: - settings = get_settings() - required_roles = _normalize_roles(set(settings.admin_required_realm_roles)) - - if not required_roles: - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="admin_policy_not_configured") - - principal_roles = _normalize_roles(set(principal.realm_roles)) - role_ok = bool(required_roles.intersection(principal_roles)) - - if not role_ok: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_forbidden") - return principal diff --git a/backend/app/security/api_client_auth.py b/backend/app/security/api_client_auth.py deleted file mode 100644 index 1f6681c..0000000 --- a/backend/app/security/api_client_auth.py +++ /dev/null @@ -1,83 +0,0 @@ -import hashlib -import hmac -from datetime import datetime, timezone - -from fastapi import Depends, Header, HTTPException, Request, status -from passlib.context import CryptContext -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.db.session import get_db -from app.models.api_client import ApiClient - -pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto") - - -def hash_api_key(plain_key: str) -> str: - try: - return pwd_context.hash(plain_key) - except Exception: - # Fallback for environments missing argon2 backend. - return f"sha256:{hashlib.sha256(plain_key.encode('utf-8')).hexdigest()}" - - -def _verify_api_key(plain_key: str, stored_hash: str) -> bool: - # Support sha256: 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 diff --git a/backend/app/security/idp_jwt.py b/backend/app/security/idp_jwt.py deleted file mode 100644 index 8821539..0000000 --- a/backend/app/security/idp_jwt.py +++ /dev/null @@ -1,357 +0,0 @@ -from __future__ import annotations - -from functools import lru_cache -import logging -import time - -import httpx -import jwt -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - -from app.core.config import get_settings -from app.schemas.auth import ProviderPrincipal - -bearer_scheme = HTTPBearer(auto_error=False) -logger = logging.getLogger(__name__) - - -class ProviderTokenVerifier: - def __init__( - self, - issuer: str | None, - jwks_url: str | None, - audience: str | None, - client_id: str | None, - client_secret: str | None, - base_url: str | None, - userinfo_endpoint: str | None, - verify_tls: bool, - realm: str | None, - admin_realm: str | None, - admin_client_id: str | None, - admin_client_secret: str | None, - member_required_realm_roles: list[str], - ) -> None: - self.issuer = issuer.strip() if issuer else None - self.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer) - self.audience = audience.strip() if audience else None - self.client_id = client_id.strip() if client_id else None - self.client_secret = client_secret.strip() if client_secret else None - self.base_url = base_url.strip() if base_url else None - self.realm = realm.strip() if realm else None - self.admin_realm = admin_realm.strip() if admin_realm else self.realm - self.admin_client_id = admin_client_id.strip() if admin_client_id else None - self.admin_client_secret = admin_client_secret.strip() if admin_client_secret else None - self.userinfo_endpoint = ( - userinfo_endpoint.strip() if userinfo_endpoint else self._infer_userinfo_endpoint(self.issuer, self.base_url) - ) - self.verify_tls = verify_tls - - if not self.jwks_url: - raise ValueError("KEYCLOAK_JWKS_URL or KEYCLOAK_ISSUER is required") - - self._jwk_client = jwt.PyJWKClient( - self.jwks_url, - cache_jwk_set=True, - lifespan=600, - headers={ - "Accept": "application/json", - "User-Agent": "member-ose-backend/1.0", - }, - timeout=5, - ) - self._admin_token_cached: str | None = None - self._admin_token_expires_at: float = 0 - self._principal_cache: dict[str, tuple[float, ProviderPrincipal]] = {} - self.member_required_realm_roles = {r.strip().lower() for r in member_required_realm_roles if r and r.strip()} - - @staticmethod - def _infer_introspection_endpoint(issuer: str | None) -> str | None: - if not issuer: - return None - normalized = issuer.rstrip("/") - if "/realms/" in normalized: - return normalized + "/protocol/openid-connect/token/introspect" - return None - - def _introspect_token(self, token: str) -> dict | None: - endpoint = self._infer_introspection_endpoint(self.issuer) - if not endpoint or not self.client_id or not self.client_secret: - return None - try: - resp = httpx.post( - endpoint, - timeout=8, - verify=self.verify_tls, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data={ - "token": token, - "client_id": self.client_id, - "client_secret": self.client_secret, - }, - ) - except Exception: - return None - if resp.status_code >= 400: - return None - data = resp.json() if resp.content else {} - if not isinstance(data, dict) or not data.get("active"): - return None - return data - - @staticmethod - def _infer_jwks_url(issuer: str | None) -> str | None: - if not issuer: - return None - return issuer.rstrip("/") + "/protocol/openid-connect/certs" - - @staticmethod - def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None: - if issuer: - return issuer.rstrip("/") + "/protocol/openid-connect/userinfo" - if base_url: - return base_url.rstrip("/") + "/realms/master/protocol/openid-connect/userinfo" - return None - - def _enrich_from_userinfo(self, principal: ProviderPrincipal, token: str) -> ProviderPrincipal: - if principal.email and (principal.name or principal.preferred_username) and principal.groups: - return principal - if not self.userinfo_endpoint: - return self._enrich_groups_from_admin(principal) - - try: - resp = httpx.get( - self.userinfo_endpoint, - timeout=5, - verify=self.verify_tls, - headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, - ) - except Exception: - return self._enrich_groups_from_admin(principal) - - if resp.status_code >= 400: - return self._enrich_groups_from_admin(principal) - - data = resp.json() if resp.content else {} - sub = data.get("sub") - if isinstance(sub, str) and sub and sub != principal.sub: - return self._enrich_groups_from_admin(principal) - - email = principal.email or (data.get("email") if isinstance(data.get("email"), str) else None) - name = principal.name or (data.get("name") if isinstance(data.get("name"), str) else None) - preferred_username = principal.preferred_username or ( - data.get("preferred_username") if isinstance(data.get("preferred_username"), str) else None - ) - groups = principal.groups - payload_groups = data.get("groups") - if isinstance(payload_groups, list): - groups = [str(g) for g in payload_groups if str(g)] - enriched = ProviderPrincipal( - sub=principal.sub, - email=email, - name=name, - preferred_username=preferred_username, - groups=groups, - realm_roles=principal.realm_roles, - ) - return self._enrich_groups_from_admin(enriched) - - def _get_admin_token(self) -> str | None: - now = time.time() - if self._admin_token_cached and now < self._admin_token_expires_at: - return self._admin_token_cached - if ( - not self.base_url - or not self.admin_realm - or not self.admin_client_id - or not self.admin_client_secret - ): - return None - token_endpoint = f"{self.base_url}/realms/{self.admin_realm}/protocol/openid-connect/token" - try: - resp = httpx.post( - token_endpoint, - data={ - "grant_type": "client_credentials", - "client_id": self.admin_client_id, - "client_secret": self.admin_client_secret, - }, - timeout=6, - verify=self.verify_tls, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - except Exception: - return None - if resp.status_code >= 400: - return None - token = resp.json().get("access_token") - expires_in = resp.json().get("expires_in") - if token: - ttl = int(expires_in) if isinstance(expires_in, int) else 30 - # Keep a small buffer to avoid using near-expiry admin token. - self._admin_token_cached = str(token) - self._admin_token_expires_at = max(now + ttl - 15, now + 5) - return self._admin_token_cached - return None - - def _enrich_groups_from_admin(self, principal: ProviderPrincipal) -> ProviderPrincipal: - if principal.groups: - return principal - if not self.base_url or not self.realm: - return principal - admin_token = self._get_admin_token() - if not admin_token: - return principal - - try: - resp = httpx.get( - f"{self.base_url}/admin/realms/{self.realm}/users/{principal.sub}/groups", - timeout=6, - verify=self.verify_tls, - headers={"Authorization": f"Bearer {admin_token}", "Accept": "application/json"}, - ) - except Exception: - return principal - if resp.status_code >= 400: - return principal - - payload = resp.json() if resp.content else [] - groups: list[str] = [] - if isinstance(payload, list): - for item in payload: - if not isinstance(item, dict): - continue - path = item.get("path") - name = item.get("name") - if isinstance(path, str) and path: - groups.append(path) - elif isinstance(name, str) and name: - groups.append(name) - if not groups: - return principal - return ProviderPrincipal( - sub=principal.sub, - email=principal.email, - name=principal.name, - preferred_username=principal.preferred_username, - groups=groups, - realm_roles=principal.realm_roles, - ) - - def _require_member_role(self, principal: ProviderPrincipal) -> None: - if not self.member_required_realm_roles: - return - user_roles = {r.strip().lower() for r in principal.realm_roles if isinstance(r, str) and r.strip()} - if not user_roles.intersection(self.member_required_realm_roles): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="member_forbidden") - - def verify_access_token(self, token: str) -> ProviderPrincipal: - now = time.time() - cached = self._principal_cache.get(token) - if cached and now < cached[0]: - return cached[1] - - try: - header = jwt.get_unverified_header(token) - algorithm = str(header.get("alg", "")).upper() - options = { - "verify_signature": True, - "verify_exp": True, - "verify_aud": bool(self.audience), - "verify_iss": bool(self.issuer), - } - - if algorithm.startswith("HS"): - if not self.client_secret: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="missing_idp_client_secret", - ) - key = self.client_secret - allowed_algorithms = ["HS256", "HS384", "HS512"] - else: - signing_key = self._jwk_client.get_signing_key_from_jwt(token) - key = signing_key.key - allowed_algorithms = ["RS256", "RS384", "RS512"] - - claims = jwt.decode( - token, - key, - algorithms=allowed_algorithms, - audience=self.audience, - issuer=self.issuer, - options=options, - ) - except Exception as exc: - claims = self._introspect_token(token) - if claims: - logger.debug("jwt verify failed, used introspection fallback: %s", exc) - else: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc - - sub = claims.get("sub") - if not sub: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub") - - principal = ProviderPrincipal( - sub=sub, - email=claims.get("email"), - name=claims.get("name"), - preferred_username=claims.get("preferred_username"), - groups=[str(g) for g in claims.get("groups", []) if str(g)] if isinstance(claims.get("groups"), list) else [], - realm_roles=[ - str(r) - for r in ( - claims.get("realm_access", {}).get("roles", []) - if isinstance(claims.get("realm_access"), dict) - else [] - ) - if str(r) - ], - ) - enriched = self._enrich_from_userinfo(principal, token) - self._require_member_role(enriched) - - exp = claims.get("exp") - if isinstance(exp, int): - cache_until = min(float(exp), now + 60) - else: - cache_until = now + 30 - if cache_until > now: - self._principal_cache[token] = (cache_until, enriched) - if len(self._principal_cache) > 512: - # Simple bound to avoid unbounded memory growth. - self._principal_cache.clear() - - return enriched - - -@lru_cache -def _get_verifier() -> ProviderTokenVerifier: - settings = get_settings() - return ProviderTokenVerifier( - issuer=settings.idp_issuer, - jwks_url=settings.idp_jwks_url, - audience=settings.idp_audience, - client_id=settings.idp_client_id, - client_secret=settings.idp_client_secret, - base_url=settings.idp_base_url, - userinfo_endpoint=settings.idp_userinfo_endpoint, - verify_tls=settings.idp_verify_tls, - realm=settings.keycloak_realm, - admin_realm=settings.keycloak_admin_realm, - admin_client_id=settings.keycloak_admin_client_id, - admin_client_secret=settings.keycloak_admin_client_secret, - member_required_realm_roles=settings.member_required_realm_roles, - ) - - -def require_authenticated_principal( - credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), -) -> ProviderPrincipal: - if credentials is None or credentials.scheme.lower() != "bearer": - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_bearer_token") - - verifier = _get_verifier() - return verifier.verify_access_token(credentials.credentials) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py deleted file mode 100644 index 02dea84..0000000 --- a/backend/app/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Service layer.""" diff --git a/backend/app/services/idp_admin_service.py b/backend/app/services/idp_admin_service.py deleted file mode 100644 index 0f37e77..0000000 --- a/backend/app/services/idp_admin_service.py +++ /dev/null @@ -1,567 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -import secrets -import string - -import httpx -from fastapi import HTTPException, status - -from app.core.config import Settings - - -@dataclass -class ProviderSyncResult: - user_id: str - action: str - user_sub: str | None = None - - -@dataclass -class ProviderPasswordResetResult: - user_id: str - temporary_password: str - - -@dataclass -class ProviderDeleteResult: - action: str - user_id: str | None = None - - -@dataclass -class ProviderGroupSyncResult: - group_id: str - action: str - - -@dataclass -class ProviderRoleSyncResult: - role_name: str - action: str - - -class ProviderAdminService: - def __init__(self, settings: Settings) -> None: - self.base_url = settings.keycloak_base_url.rstrip("/") - self.realm = settings.keycloak_realm - self.admin_realm = settings.keycloak_admin_realm or settings.keycloak_realm - self.admin_client_id = settings.keycloak_admin_client_id - self.admin_client_secret = settings.keycloak_admin_client_secret - self.verify_tls = settings.keycloak_verify_tls - - if not self.base_url or not self.realm or not self.admin_client_id or not self.admin_client_secret: - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="idp_admin_not_configured") - - @staticmethod - def _safe_username(sub: str | None, email: str) -> str: - if email and "@" in email: - return email.split("@", 1)[0] - if sub: - return sub.replace("|", "_")[:150] - return "member-user" - - @staticmethod - def _generate_temporary_password(length: int = 14) -> str: - alphabet = string.ascii_letters + string.digits + "!@#$%^&*" - return "".join(secrets.choice(alphabet) for _ in range(length)) - - def _get_admin_token(self) -> str: - token_endpoint = f"{self.base_url}/realms/{self.admin_realm}/protocol/openid-connect/token" - try: - resp = httpx.post( - token_endpoint, - data={ - "grant_type": "client_credentials", - "client_id": self.admin_client_id, - "client_secret": self.admin_client_secret, - }, - timeout=10, - verify=self.verify_tls, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - except Exception as exc: - raise HTTPException(status_code=502, detail="idp_lookup_failed") from exc - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - token = resp.json().get("access_token") - if not token: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - return str(token) - - def _client(self) -> httpx.Client: - return httpx.Client( - base_url=self.base_url, - headers={ - "Authorization": f"Bearer {self._get_admin_token()}", - "Accept": "application/json", - "Content-Type": "application/json", - }, - timeout=10, - verify=self.verify_tls, - ) - - def _lookup_user_by_id(self, client: httpx.Client, user_id: str) -> dict | None: - resp = client.get(f"/admin/realms/{self.realm}/users/{user_id}") - if resp.status_code == 404: - return None - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - return resp.json() - - def _lookup_group_by_id(self, client: httpx.Client, group_id: str) -> dict | None: - resp = client.get(f"/admin/realms/{self.realm}/groups/{group_id}") - if resp.status_code == 404: - return None - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_group_lookup_failed") - payload = resp.json() if resp.content else {} - return payload if isinstance(payload, dict) else None - - def _lookup_group_by_name(self, client: httpx.Client, *, name: str, parent_group_id: str | None) -> dict | None: - if parent_group_id: - resp = client.get( - f"/admin/realms/{self.realm}/groups/{parent_group_id}/children", - params={"search": name, "briefRepresentation": "false"}, - ) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_group_lookup_failed") - matches = resp.json() if isinstance(resp.json(), list) else [] - for row in matches: - if isinstance(row, dict) and str(row.get("name", "")).strip() == name: - return row - return None - - resp = client.get( - f"/admin/realms/{self.realm}/groups", - params={"search": name, "exact": "true", "briefRepresentation": "false"}, - ) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_group_lookup_failed") - matches = resp.json() if isinstance(resp.json(), list) else [] - for row in matches: - if not isinstance(row, dict): - continue - if str(row.get("name", "")).strip() != name: - continue - parent_id = row.get("parentId") - if parent_group_id: - if str(parent_id or "") == parent_group_id: - return row - elif not parent_id: - return row - return None - - @staticmethod - def _normalize_group_attributes(attributes: dict[str, str | list[str]] | None) -> dict[str, list[str]]: - if not attributes: - return {} - output: dict[str, list[str]] = {} - for key, value in attributes.items(): - normalized_key = str(key).strip() - if not normalized_key: - continue - if isinstance(value, list): - output[normalized_key] = [str(v) for v in value if str(v)] - elif value is not None and str(value): - output[normalized_key] = [str(value)] - return output - - def _lookup_user_by_email_or_username( - self, client: httpx.Client, *, email: str | None, username: str | None - ) -> dict | None: - if email: - resp = client.get(f"/admin/realms/{self.realm}/users", params={"email": email, "exact": "true"}) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - matches = resp.json() if isinstance(resp.json(), list) else [] - if matches: - return matches[0] - if username: - resp = client.get(f"/admin/realms/{self.realm}/users", params={"username": username, "exact": "true"}) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - matches = resp.json() if isinstance(resp.json(), list) else [] - if matches: - return matches[0] - return None - - def ensure_user( - self, - *, - sub: str | None, - email: str, - username: str | None, - display_name: str | None, - is_active: bool = True, - provider_user_id: str | None = None, - ) -> ProviderSyncResult: - resolved_username = username or self._safe_username(sub=sub, email=email) - first_name = display_name or resolved_username - payload = { - "username": resolved_username, - "email": email, - "enabled": is_active, - "emailVerified": True, - "firstName": first_name, - "attributes": {"user_sub": [sub]} if sub else {}, - } - - with self._client() as client: - existing = self._lookup_user_by_id(client, provider_user_id) if provider_user_id else None - if existing is None: - existing = self._lookup_user_by_email_or_username(client, email=email, username=resolved_username) - - if existing and existing.get("id"): - user_id = str(existing["id"]) - put_resp = client.put(f"/admin/realms/{self.realm}/users/{user_id}", json=payload) - if put_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_update_failed") - return ProviderSyncResult(user_id=user_id, action="updated", user_sub=user_id) - - create_resp = client.post(f"/admin/realms/{self.realm}/users", json=payload) - if create_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_create_failed") - - location = create_resp.headers.get("Location", "") - user_id = location.rstrip("/").split("/")[-1] if location and "/" in location else "" - if not user_id: - found = self._lookup_user_by_email_or_username(client, email=email, username=resolved_username) - user_id = str(found["id"]) if found and found.get("id") else "" - if not user_id: - raise HTTPException(status_code=502, detail="idp_create_failed") - return ProviderSyncResult(user_id=user_id, action="created", user_sub=user_id) - - def ensure_group( - self, - *, - name: str, - group_id: str | None = None, - parent_group_id: str | None = None, - attributes: dict[str, str | list[str]] | None = None, - ) -> ProviderGroupSyncResult: - if not name: - raise HTTPException(status_code=400, detail="idp_group_name_required") - normalized_attrs = self._normalize_group_attributes(attributes) - - with self._client() as client: - existing = self._lookup_group_by_id(client, group_id) if group_id else None - if existing is None: - existing = self._lookup_group_by_name(client, name=name, parent_group_id=parent_group_id) - - if existing and existing.get("id"): - resolved_id = str(existing["id"]) - payload = {"name": name, "attributes": normalized_attrs} - put_resp = client.put(f"/admin/realms/{self.realm}/groups/{resolved_id}", json=payload) - if put_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_group_update_failed") - return ProviderGroupSyncResult(group_id=resolved_id, action="updated") - - payload = {"name": name, "attributes": normalized_attrs} - if parent_group_id: - create_resp = client.post(f"/admin/realms/{self.realm}/groups/{parent_group_id}/children", json=payload) - else: - create_resp = client.post(f"/admin/realms/{self.realm}/groups", json=payload) - if create_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_group_create_failed") - - location = create_resp.headers.get("Location", "") - resolved_id = location.rstrip("/").split("/")[-1] if location and "/" in location else "" - if not resolved_id: - found = self._lookup_group_by_name(client, name=name, parent_group_id=parent_group_id) - resolved_id = str(found.get("id")) if found and found.get("id") else "" - if not resolved_id: - raise HTTPException(status_code=502, detail="idp_group_create_failed") - return ProviderGroupSyncResult(group_id=resolved_id, action="created") - - def delete_group(self, *, group_id: str | None) -> ProviderDeleteResult: - if not group_id: - return ProviderDeleteResult(action="not_found") - with self._client() as client: - resp = client.delete(f"/admin/realms/{self.realm}/groups/{group_id}") - if resp.status_code in {204, 404}: - return ProviderDeleteResult(action="deleted" if resp.status_code == 204 else "not_found") - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_group_delete_failed") - return ProviderDeleteResult(action="deleted") - - def reset_password( - self, - *, - provider_user_id: str | None, - email: str | None, - username: str | None, - ) -> ProviderPasswordResetResult: - with self._client() as client: - existing = self._lookup_user_by_id(client, provider_user_id) if provider_user_id else None - if existing is None: - existing = self._lookup_user_by_email_or_username(client, email=email, username=username) - if not existing or not existing.get("id"): - raise HTTPException(status_code=404, detail="idp_user_not_found") - - user_id = str(existing["id"]) - temp_password = self._generate_temporary_password() - resp = client.put( - f"/admin/realms/{self.realm}/users/{user_id}/reset-password", - json={"type": "password", "value": temp_password, "temporary": True}, - ) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_set_password_failed") - return ProviderPasswordResetResult(user_id=user_id, temporary_password=temp_password) - - def delete_user( - self, - *, - provider_user_id: str | None, - email: str | None, - username: str | None, - ) -> ProviderDeleteResult: - with self._client() as client: - existing = self._lookup_user_by_id(client, provider_user_id) if provider_user_id else None - if existing is None: - existing = self._lookup_user_by_email_or_username(client, email=email, username=username) - if not existing or not existing.get("id"): - return ProviderDeleteResult(action="not_found") - - user_id = str(existing["id"]) - resp = client.delete(f"/admin/realms/{self.realm}/users/{user_id}") - if resp.status_code in {204, 404}: - return ProviderDeleteResult(action="deleted" if resp.status_code == 204 else "not_found", user_id=user_id) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_delete_failed") - return ProviderDeleteResult(action="deleted", user_id=user_id) - - def list_groups_tree(self) -> list[dict]: - with self._client() as client: - resp = client.get( - f"/admin/realms/{self.realm}/groups", - params={"first": 0, "max": 5000, "briefRepresentation": "false"}, - ) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_group_lookup_failed") - payload = resp.json() if resp.content else [] - return payload if isinstance(payload, list) else [] - - def list_users(self) -> list[dict]: - users: list[dict] = [] - first = 0 - page_size = 200 - with self._client() as client: - while True: - resp = client.get( - f"/admin/realms/{self.realm}/users", - params={"first": first, "max": page_size}, - ) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - batch = resp.json() if isinstance(resp.json(), list) else [] - users.extend([row for row in batch if isinstance(row, dict)]) - if len(batch) < page_size: - break - first += page_size - return users - - def list_clients(self) -> list[dict]: - clients: list[dict] = [] - first = 0 - page_size = 200 - with self._client() as client: - while True: - resp = client.get( - f"/admin/realms/{self.realm}/clients", - params={"first": first, "max": page_size}, - ) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - batch = resp.json() if isinstance(resp.json(), list) else [] - clients.extend([row for row in batch if isinstance(row, dict)]) - if len(batch) < page_size: - break - first += page_size - return clients - - def list_client_roles(self, client_uuid: str) -> list[dict]: - with self._client() as client: - resp = client.get( - f"/admin/realms/{self.realm}/clients/{client_uuid}/roles", - params={"first": 0, "max": 5000}, - ) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - payload = resp.json() if resp.content else [] - return payload if isinstance(payload, list) else [] - - def _resolve_client_uuid(self, client: httpx.Client, provider_client_id: str) -> str: - if not provider_client_id: - raise HTTPException(status_code=400, detail="provider_client_id_required") - # provider_client_id stores Keycloak clientId (not internal UUID). - resp = client.get( - f"/admin/realms/{self.realm}/clients", - params={"clientId": provider_client_id}, - ) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - payload = resp.json() if resp.content else [] - rows = payload if isinstance(payload, list) else [] - for row in rows: - if not isinstance(row, dict): - continue - if str(row.get("clientId", "")).strip() != provider_client_id: - continue - client_uuid = str(row.get("id", "")).strip() - if client_uuid: - return client_uuid - raise HTTPException(status_code=404, detail="provider_client_not_found") - - def _get_client_role_representation(self, client: httpx.Client, *, client_uuid: str, role_name: str) -> dict: - resp = client.get(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}") - if resp.status_code == 404: - raise HTTPException(status_code=404, detail=f"provider_role_not_found:{role_name}") - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - payload = resp.json() if resp.content else {} - if not isinstance(payload, dict): - raise HTTPException(status_code=502, detail="idp_lookup_failed") - return payload - - def set_group_client_roles(self, *, group_id: str, provider_client_id: str, role_names: list[str]) -> None: - if not group_id: - raise HTTPException(status_code=400, detail="provider_group_id_required") - - desired_names = [name.strip() for name in role_names if isinstance(name, str) and name.strip()] - desired_name_set = set(desired_names) - - with self._client() as client: - client_uuid = self._resolve_client_uuid(client, provider_client_id) - - current_resp = client.get(f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}") - if current_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_group_role_mapping_lookup_failed") - current_payload = current_resp.json() if current_resp.content else [] - current_rows = current_payload if isinstance(current_payload, list) else [] - current_map: dict[str, dict] = {} - for row in current_rows: - if not isinstance(row, dict): - continue - name = str(row.get("name", "")).strip() - if name: - current_map[name] = row - - to_add_names = sorted(desired_name_set - set(current_map.keys())) - to_remove_names = sorted(set(current_map.keys()) - desired_name_set) - - if to_add_names: - add_payload = [ - self._get_client_role_representation(client, client_uuid=client_uuid, role_name=role_name) - for role_name in to_add_names - ] - add_resp = client.post( - f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}", - json=add_payload, - ) - if add_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_group_role_mapping_add_failed") - - if to_remove_names: - remove_payload = [current_map[name] for name in to_remove_names] - remove_resp = client.request( - "DELETE", - f"/admin/realms/{self.realm}/groups/{group_id}/role-mappings/clients/{client_uuid}", - json=remove_payload, - ) - if remove_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_group_role_mapping_remove_failed") - - def ensure_client_role( - self, - *, - provider_client_id: str, - provider_role_name: str, - description: str | None = None, - ) -> ProviderRoleSyncResult: - role_name = provider_role_name.strip() - if not role_name: - raise HTTPException(status_code=400, detail="provider_role_name_required") - - with self._client() as client: - client_uuid = self._resolve_client_uuid(client, provider_client_id) - get_resp = client.get(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}") - if get_resp.status_code == 404: - create_resp = client.post( - f"/admin/realms/{self.realm}/clients/{client_uuid}/roles", - json={"name": role_name, "description": description or ""}, - ) - if create_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_role_create_failed") - return ProviderRoleSyncResult(role_name=role_name, action="created") - if get_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - - payload = get_resp.json() if get_resp.content else {} - update_payload = { - "name": role_name, - "description": description if description is not None else payload.get("description", ""), - "attributes": payload.get("attributes") if isinstance(payload, dict) else {}, - } - put_resp = client.put( - f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}", - json=update_payload, - ) - if put_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_role_update_failed") - return ProviderRoleSyncResult(role_name=role_name, action="updated") - - def update_client_role( - self, - *, - provider_client_id: str, - old_provider_role_name: str, - new_provider_role_name: str, - description: str | None = None, - ) -> ProviderRoleSyncResult: - old_name = old_provider_role_name.strip() - new_name = new_provider_role_name.strip() - if not old_name or not new_name: - raise HTTPException(status_code=400, detail="provider_role_name_required") - - with self._client() as client: - client_uuid = self._resolve_client_uuid(client, provider_client_id) - get_resp = client.get(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{old_name}") - if get_resp.status_code == 404: - # If old role missing, create the new one to self-heal drift. - create_resp = client.post( - f"/admin/realms/{self.realm}/clients/{client_uuid}/roles", - json={"name": new_name, "description": description or ""}, - ) - if create_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_role_create_failed") - return ProviderRoleSyncResult(role_name=new_name, action="created") - if get_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_lookup_failed") - - payload = get_resp.json() if get_resp.content else {} - update_payload = { - "name": new_name, - "description": description if description is not None else payload.get("description", ""), - "attributes": payload.get("attributes") if isinstance(payload, dict) else {}, - } - put_resp = client.put( - f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{old_name}", - json=update_payload, - ) - if put_resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_role_update_failed") - return ProviderRoleSyncResult(role_name=new_name, action="updated") - - def delete_client_role(self, *, provider_client_id: str, provider_role_name: str) -> ProviderDeleteResult: - role_name = provider_role_name.strip() - if not role_name: - return ProviderDeleteResult(action="not_found") - with self._client() as client: - client_uuid = self._resolve_client_uuid(client, provider_client_id) - resp = client.delete(f"/admin/realms/{self.realm}/clients/{client_uuid}/roles/{role_name}") - if resp.status_code in {204, 404}: - return ProviderDeleteResult(action="deleted" if resp.status_code == 204 else "not_found") - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="idp_role_delete_failed") - return ProviderDeleteResult(action="deleted") diff --git a/backend/app/services/idp_catalog_sync.py b/backend/app/services/idp_catalog_sync.py deleted file mode 100644 index f2c4aa8..0000000 --- a/backend/app/services/idp_catalog_sync.py +++ /dev/null @@ -1,400 +0,0 @@ -from __future__ import annotations - -import threading -import time - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.core.config import get_settings -from app.core.keygen import generate_key -from app.models.company import Company -from app.models.role import Role -from app.models.site import Site -from app.models.system import System -from app.repositories.companies_repo import CompaniesRepository -from app.repositories.roles_repo import RolesRepository -from app.repositories.sites_repo import SitesRepository -from app.repositories.systems_repo import SystemsRepository -from app.repositories.users_repo import UsersRepository -from app.services.idp_admin_service import ProviderAdminService - -BUILTIN_CLIENT_IDS = { - "account", - "account-console", - "admin-cli", - "broker", - "realm-management", - "security-admin-console", - "master-realm", -} - -_sync_lock = threading.Lock() -_last_synced_at = 0.0 -_last_systems_synced_at = 0.0 -_min_sync_interval_sec = 30.0 - - -def _generate_unique_key(prefix: str, exists_check) -> str: - for salt in range(5000): - key = generate_key(prefix, salt) - if not exists_check(key): - return key - raise RuntimeError(f"failed_generate_{prefix.lower()}_key") - - -def _first_attr(attrs: dict | None, key: str) -> str | None: - if not isinstance(attrs, dict): - return None - raw = attrs.get(key) - if isinstance(raw, list) and raw: - value = str(raw[0]).strip() - return value or None - if isinstance(raw, str): - value = raw.strip() - return value or None - return None - - -def _flatten_groups(nodes: list[dict], inherited_company_key: str | None = None) -> tuple[dict[str, dict], dict[str, dict]]: - companies: dict[str, dict] = {} - sites: dict[str, dict] = {} - - for node in nodes: - if not isinstance(node, dict): - continue - attrs = node.get("attributes") - group_id = str(node.get("id", "")).strip() or None - name = str(node.get("name", "")).strip() - children = node.get("subGroups") if isinstance(node.get("subGroups"), list) else [] - - company_key = _first_attr(attrs, "company_key") - if not company_key and name.startswith("CP"): - company_key = name - if _first_attr(attrs, "member_entity_type") == "company" and not company_key: - company_key = name or None - - current_company_key = company_key or inherited_company_key - - if company_key: - companies[company_key] = { - "company_key": company_key, - "name": _first_attr(attrs, "name") or _first_attr(attrs, "display_name") or name or company_key, - "status": _first_attr(attrs, "status") or "active", - "provider_group_id": group_id, - } - - site_key = _first_attr(attrs, "site_key") - if not site_key and name.startswith("ST"): - site_key = name - if _first_attr(attrs, "member_entity_type") == "site" and not site_key: - site_key = name or None - if site_key: - sites[site_key] = { - "site_key": site_key, - "company_key": _first_attr(attrs, "company_key") or current_company_key, - "display_name": _first_attr(attrs, "display_name") or name or site_key, - "domain": _first_attr(attrs, "domain"), - "status": _first_attr(attrs, "status") or "active", - "provider_group_id": group_id, - } - - child_companies, child_sites = _flatten_groups(children, current_company_key) - companies.update(child_companies) - sites.update(child_sites) - - return companies, sites - - -def sync_from_provider(db: Session, *, force: bool = False) -> dict[str, int]: - global _last_synced_at - now = time.time() - if not force and now - _last_synced_at < _min_sync_interval_sec: - return {"synced": 0} - - if not _sync_lock.acquire(blocking=False): - return {"synced": 0} - - try: - now = time.time() - if not force and now - _last_synced_at < _min_sync_interval_sec: - return {"synced": 0} - - idp = ProviderAdminService(get_settings()) - companies_repo = CompaniesRepository(db) - sites_repo = SitesRepository(db) - systems_repo = SystemsRepository(db) - roles_repo = RolesRepository(db) - users_repo = UsersRepository(db) - - companies_created = 0 - companies_updated = 0 - sites_created = 0 - sites_updated = 0 - systems_created = 0 - systems_updated = 0 - roles_created = 0 - roles_updated = 0 - users_created_or_updated = 0 - - group_tree = idp.list_groups_tree() - company_records, site_records = _flatten_groups(group_tree) - - company_id_map: dict[str, str] = {} - for company_key, row in company_records.items(): - company = companies_repo.get_by_key(company_key) - if company is None: - company = companies_repo.create( - company_key=company_key, - name=row["name"], - provider_group_id=row["provider_group_id"], - status=row["status"], - ) - companies_created += 1 - else: - company = companies_repo.update( - company, - name=row["name"], - provider_group_id=row["provider_group_id"], - status=row["status"], - ) - companies_updated += 1 - company_id_map[company_key] = company.id - - for site_key, row in site_records.items(): - company_key = row.get("company_key") - if not company_key: - continue - company_id = company_id_map.get(company_key) - if not company_id: - placeholder = companies_repo.get_by_key(company_key) - if placeholder is None: - placeholder = companies_repo.create( - company_key=company_key, - name=company_key, - provider_group_id=None, - status="active", - ) - companies_created += 1 - company_id = placeholder.id - company_id_map[company_key] = company_id - - site = sites_repo.get_by_key(site_key) - if site is None: - sites_repo.create( - site_key=site_key, - company_id=company_id, - display_name=row["display_name"], - domain=row["domain"], - provider_group_id=row["provider_group_id"], - status=row["status"], - ) - sites_created += 1 - else: - sites_repo.update( - site, - company_id=company_id, - display_name=row["display_name"], - domain=row["domain"], - provider_group_id=row["provider_group_id"], - status=row["status"], - ) - sites_updated += 1 - - client_rows = idp.list_clients() - system_map_by_client_id: dict[str, System] = {} - for client in client_rows: - client_uuid = str(client.get("id", "")).strip() - client_id = str(client.get("clientId", "")).strip() - if not client_uuid or not client_id: - continue - if client_id in BUILTIN_CLIENT_IDS: - continue - - system = db.scalar(select(System).where(System.name == client_id)) - system_name = str(client.get("name", "")).strip() or client_id - system_status = "active" if client.get("enabled", True) else "inactive" - if system is None: - system_key = _generate_unique_key("SY", lambda key: systems_repo.get_by_key(key) is not None) - system = systems_repo.create( - system_key=system_key, - name=system_name, - status=system_status, - ) - systems_created += 1 - else: - system = systems_repo.update( - system, - name=system_name, - status=system_status, - ) - systems_updated += 1 - system_map_by_client_id[client_id] = system - - client_roles = idp.list_client_roles(client_uuid) - for role_row in client_roles: - if not isinstance(role_row, dict): - continue - role_name = str(role_row.get("name", "")).strip() - if not role_name: - continue - role_desc = str(role_row.get("description", "")).strip() or None - role_status = "active" if not role_row.get("composite", False) else "active" - role = db.scalar( - select(Role).where( - Role.system_id == system.id, - Role.name == role_name, - ) - ) - if role is None: - role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None) - roles_repo.create( - role_key=role_key, - system_id=system.id, - name=role_name, - description=role_desc, - status=role_status, - ) - roles_created += 1 - else: - roles_repo.update( - role, - name=role_name, - description=role_desc, - status=role_status, - ) - roles_updated += 1 - - for user in idp.list_users(): - user_id = str(user.get("id", "")).strip() - if not user_id: - continue - display_name = ( - str(user.get("firstName", "")).strip() - or str(user.get("username", "")).strip() - or str(user.get("email", "")).strip() - or user_id - ) - users_repo.upsert_by_sub( - user_sub=user_id, - provider_user_id=user_id, - username=str(user.get("username", "")).strip() or None, - email=str(user.get("email", "")).strip() or None, - display_name=display_name, - is_active=bool(user.get("enabled", True)), - status="active" if user.get("enabled", True) else "inactive", - ) - users_created_or_updated += 1 - - _last_synced_at = time.time() - return { - "synced": 1, - "companies_created": companies_created, - "companies_updated": companies_updated, - "sites_created": sites_created, - "sites_updated": sites_updated, - "systems_created": systems_created, - "systems_updated": systems_updated, - "roles_created": roles_created, - "roles_updated": roles_updated, - "users_upserted": users_created_or_updated, - } - finally: - _sync_lock.release() - - -def sync_systems_from_provider(db: Session, *, force: bool = False) -> dict[str, int]: - global _last_systems_synced_at - now = time.time() - if not force and now - _last_systems_synced_at < _min_sync_interval_sec: - return {"synced": 0} - - if not _sync_lock.acquire(blocking=False): - return {"synced": 0} - - try: - now = time.time() - if not force and now - _last_systems_synced_at < _min_sync_interval_sec: - return {"synced": 0} - - idp = ProviderAdminService(get_settings()) - systems_repo = SystemsRepository(db) - roles_repo = RolesRepository(db) - - systems_created = 0 - systems_updated = 0 - roles_created = 0 - roles_updated = 0 - - client_rows = idp.list_clients() - for client in client_rows: - client_uuid = str(client.get("id", "")).strip() - client_id = str(client.get("clientId", "")).strip() - if not client_uuid or not client_id: - continue - if client_id in BUILTIN_CLIENT_IDS: - continue - - system = db.scalar(select(System).where(System.name == client_id)) - system_name = str(client.get("name", "")).strip() or client_id - system_status = "active" if client.get("enabled", True) else "inactive" - if system is None: - system_key = _generate_unique_key("SY", lambda key: systems_repo.get_by_key(key) is not None) - system = systems_repo.create( - system_key=system_key, - name=system_name, - status=system_status, - ) - systems_created += 1 - else: - system = systems_repo.update( - system, - name=system_name, - status=system_status, - ) - systems_updated += 1 - - client_roles = idp.list_client_roles(client_uuid) - for role_row in client_roles: - if not isinstance(role_row, dict): - continue - role_name = str(role_row.get("name", "")).strip() - if not role_name: - continue - role_desc = str(role_row.get("description", "")).strip() or None - role_status = "active" if not role_row.get("composite", False) else "active" - role = db.scalar( - select(Role).where( - Role.system_id == system.id, - Role.name == role_name, - ) - ) - if role is None: - role_key = _generate_unique_key("RL", lambda key: roles_repo.get_by_key(key) is not None) - roles_repo.create( - role_key=role_key, - system_id=system.id, - name=role_name, - description=role_desc, - status=role_status, - ) - roles_created += 1 - else: - roles_repo.update( - role, - name=role_name, - description=role_desc, - status=role_status, - ) - roles_updated += 1 - - _last_systems_synced_at = time.time() - return { - "synced": 1, - "systems_created": systems_created, - "systems_updated": systems_updated, - "roles_created": roles_created, - "roles_updated": roles_updated, - } - finally: - _sync_lock.release() diff --git a/backend/app/services/permission_service.py b/backend/app/services/permission_service.py deleted file mode 100644 index f00d377..0000000 --- a/backend/app/services/permission_service.py +++ /dev/null @@ -1,31 +0,0 @@ -from app.schemas.permissions import RoleSnapshotItem, RoleSnapshotResponse - - -class PermissionService: - @staticmethod - def build_role_snapshot(user_sub: str, rows: list[tuple[str, str, str, str, str, str, str, str]]) -> RoleSnapshotResponse: - return RoleSnapshotResponse( - user_sub=user_sub, - roles=[ - RoleSnapshotItem( - site_key=site_key, - site_display_name=site_display_name, - company_key=company_key, - company_display_name=company_display_name, - system_key=system_key, - system_name=system_name, - role_key=role_key, - role_name=role_name, - ) - for ( - site_key, - site_display_name, - company_key, - company_display_name, - system_key, - system_name, - role_key, - role_name, - ) in rows - ], - ) diff --git a/backend/app/services/runtime_cache.py b/backend/app/services/runtime_cache.py deleted file mode 100644 index 0907a9b..0000000 --- a/backend/app/services/runtime_cache.py +++ /dev/null @@ -1,175 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from hashlib import sha256 -import logging -import pickle -from threading import RLock -import time -from typing import Callable, Protocol, TypeVar - -from app.core.config import get_settings - -try: - import redis -except Exception: # pragma: no cover - optional dependency in local dev - redis = None - -T = TypeVar("T") -logger = logging.getLogger(__name__) - - -class CacheBackend(Protocol): - def get(self, key: str) -> object | None: ... - def set(self, key: str, value: object, ttl_seconds: int = 30) -> object: ... - def get_or_set(self, key: str, factory: Callable[[], T], ttl_seconds: int = 30) -> T: ... - def bump_revision(self) -> int: ... - def revision(self) -> int: ... - - -@dataclass -class _CacheEntry: - value: object - expires_at: float - revision: int - - -class MemoryRuntimeCache: - """Simple in-memory cache for local/single-instance environments.""" - - def __init__(self) -> None: - self._lock = RLock() - self._revision = 0 - self._entries: dict[str, _CacheEntry] = {} - - def get(self, key: str) -> object | None: - now = time.time() - with self._lock: - entry = self._entries.get(key) - if not entry: - return None - if entry.expires_at <= now or entry.revision != self._revision: - self._entries.pop(key, None) - return None - return entry.value - - def set(self, key: str, value: object, ttl_seconds: int = 30) -> object: - now = time.time() - with self._lock: - self._entries[key] = _CacheEntry( - value=value, - expires_at=now + max(ttl_seconds, 1), - revision=self._revision, - ) - if len(self._entries) > 2000: - self._entries.clear() - return value - - def get_or_set(self, key: str, factory: Callable[[], T], ttl_seconds: int = 30) -> T: - cached = self.get(key) - if cached is not None: - return cached # type: ignore[return-value] - return self.set(key, factory(), ttl_seconds=ttl_seconds) # type: ignore[return-value] - - def bump_revision(self) -> int: - with self._lock: - self._revision += 1 - if self._revision > 1_000_000_000: - self._revision = 1 - self._entries.clear() - return self._revision - - def revision(self) -> int: - with self._lock: - return self._revision - - -class RedisRuntimeCache: - """Redis-backed cache for multi-instance deployments.""" - - def __init__(self, *, redis_url: str, prefix: str, default_ttl_seconds: int = 30) -> None: - if redis is None: - raise RuntimeError("redis_package_not_installed") - self._redis = redis.Redis.from_url(redis_url, decode_responses=False) - self._prefix = prefix.strip() or "memberapi" - self._default_ttl_seconds = max(int(default_ttl_seconds), 1) - self._revision_key = f"{self._prefix}:cache:revision" - self._rev_cache_value = 0 - self._rev_cache_expires_at = 0.0 - - def _cache_key(self, key: str, revision: int) -> str: - key_hash = sha256(key.encode("utf-8")).hexdigest() - return f"{self._prefix}:cache:{revision}:{key_hash}" - - def _get_revision_cached(self) -> int: - now = time.time() - if now < self._rev_cache_expires_at: - return self._rev_cache_value - try: - raw = self._redis.get(self._revision_key) - value = int(raw) if raw else 0 - except Exception: - return 0 - self._rev_cache_value = value - self._rev_cache_expires_at = now + 1.0 - return value - - def get(self, key: str) -> object | None: - try: - revision = self._get_revision_cached() - raw = self._redis.get(self._cache_key(key, revision)) - if raw is None: - return None - return pickle.loads(raw) - except Exception: - return None - - def set(self, key: str, value: object, ttl_seconds: int = 30) -> object: - ttl = max(int(ttl_seconds), 1) if ttl_seconds else self._default_ttl_seconds - try: - revision = self._get_revision_cached() - self._redis.setex(self._cache_key(key, revision), ttl, pickle.dumps(value)) - except Exception: - # Keep request path healthy even when Redis has issues. - pass - return value - - def get_or_set(self, key: str, factory: Callable[[], T], ttl_seconds: int = 30) -> T: - cached = self.get(key) - if cached is not None: - return cached # type: ignore[return-value] - return self.set(key, factory(), ttl_seconds=ttl_seconds) # type: ignore[return-value] - - def bump_revision(self) -> int: - try: - value = int(self._redis.incr(self._revision_key)) - self._rev_cache_value = value - self._rev_cache_expires_at = time.time() + 1.0 - return value - except Exception: - # Fail-open: keep app usable; caller still succeeds. - return self._get_revision_cached() - - def revision(self) -> int: - return self._get_revision_cached() - - -def _build_runtime_cache() -> CacheBackend: - settings = get_settings() - backend = (settings.cache_backend or "memory").strip().lower() - if backend == "redis": - try: - cache = RedisRuntimeCache( - redis_url=settings.cache_redis_url, - prefix=settings.cache_prefix, - default_ttl_seconds=settings.cache_default_ttl_seconds, - ) - logger.info("runtime cache backend: redis") - return cache - except Exception as exc: - logger.warning("redis cache unavailable, fallback to memory: %s", exc) - logger.info("runtime cache backend: memory") - return MemoryRuntimeCache() - - -runtime_cache: CacheBackend = _build_runtime_cache() diff --git a/backend/pyproject.toml b/backend/pyproject.toml deleted file mode 100644 index 7633731..0000000 --- a/backend/pyproject.toml +++ /dev/null @@ -1,31 +0,0 @@ -[project] -name = "memberapi-backend" -version = "0.1.0" -description = "memberapi.ose.tw backend" -requires-python = ">=3.12" -dependencies = [ - "fastapi>=0.116.0", - "uvicorn[standard]>=0.35.0", - "sqlalchemy>=2.0.44", - "psycopg[binary]>=3.2.9", - "pydantic-settings>=2.11.0", - "python-dotenv>=1.1.1", - "passlib[bcrypt]>=1.7.4", - "pyjwt[crypto]>=2.10.1", - "httpx>=0.28.1", - "redis>=5.2.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.4.2", - "ruff>=0.13.0", -] - -[tool.pytest.ini_options] -pythonpath = ["."] -addopts = "-q" - -[build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.build_meta" diff --git a/backend/scripts/generate_api_key_hash.py b/backend/scripts/generate_api_key_hash.py deleted file mode 100755 index 8f3f11a..0000000 --- a/backend/scripts/generate_api_key_hash.py +++ /dev/null @@ -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() diff --git a/backend/scripts/init_schema.sql b/backend/scripts/init_schema.sql deleted file mode 100644 index eb335ed..0000000 --- a/backend/scripts/init_schema.sql +++ /dev/null @@ -1,138 +0,0 @@ -BEGIN; - -CREATE EXTENSION IF NOT EXISTS pgcrypto; - --- Drop legacy/managed tables for clean rebuild -DROP TABLE IF EXISTS auth_sync_state CASCADE; -DROP TABLE IF EXISTS user_sites CASCADE; -DROP TABLE IF EXISTS site_roles CASCADE; -DROP TABLE IF EXISTS roles CASCADE; -DROP TABLE IF EXISTS api_clients CASCADE; -DROP TABLE IF EXISTS sites CASCADE; -DROP TABLE IF EXISTS companies CASCADE; -DROP TABLE IF EXISTS systems CASCADE; -DROP TABLE IF EXISTS users CASCADE; - --- legacy tables -DROP TABLE IF EXISTS permissions CASCADE; -DROP TABLE IF EXISTS modules CASCADE; -DROP TABLE IF EXISTS user_scope_permissions CASCADE; -DROP TABLE IF EXISTS permission_group_permissions CASCADE; -DROP TABLE IF EXISTS permission_group_members CASCADE; -DROP TABLE IF EXISTS permission_groups CASCADE; - -CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_sub TEXT NOT NULL UNIQUE, - provider_user_id VARCHAR(128) UNIQUE, - username TEXT UNIQUE, - email TEXT UNIQUE, - display_name TEXT, - status VARCHAR(16) NOT NULL DEFAULT 'active', - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE companies ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - company_key TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - provider_group_id TEXT, - status VARCHAR(16) NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE sites ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - site_key TEXT NOT NULL UNIQUE, - company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE, - display_name TEXT NOT NULL, - domain TEXT, - provider_group_id TEXT, - status VARCHAR(16) NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE systems ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - system_key TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - status VARCHAR(16) NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE roles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - role_key TEXT NOT NULL UNIQUE, - system_id UUID NOT NULL REFERENCES systems(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - status VARCHAR(16) NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uq_roles_system_name UNIQUE (system_id, name) -); - -CREATE TABLE site_roles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE, - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uq_site_roles_site_role UNIQUE (site_id, role_id) -); - -CREATE TABLE user_sites ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - site_id UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uq_user_sites_user_site UNIQUE (user_id, site_id) -); - -CREATE TABLE auth_sync_state ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - entity_type VARCHAR(32) NOT NULL, - entity_id UUID NOT NULL, - last_synced_at TIMESTAMPTZ, - source_version TEXT, - last_error TEXT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uq_auth_sync_state_entity UNIQUE (entity_type, entity_id) -); - -CREATE TABLE api_clients ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - client_key TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - status VARCHAR(16) NOT NULL DEFAULT 'active', - api_key_hash TEXT NOT NULL, - allowed_origins JSONB NOT NULL DEFAULT '[]'::jsonb, - allowed_ips JSONB NOT NULL DEFAULT '[]'::jsonb, - allowed_paths JSONB NOT NULL DEFAULT '[]'::jsonb, - rate_limit_per_min INTEGER, - expires_at TIMESTAMPTZ, - last_used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_users_user_sub ON users(user_sub); -CREATE INDEX idx_users_username ON users(username); -CREATE INDEX idx_users_email ON users(email); -CREATE INDEX idx_sites_company_id ON sites(company_id); -CREATE INDEX idx_roles_system_id ON roles(system_id); -CREATE INDEX idx_site_roles_site_id ON site_roles(site_id); -CREATE INDEX idx_site_roles_role_id ON site_roles(role_id); -CREATE INDEX idx_user_sites_user_id ON user_sites(user_id); -CREATE INDEX idx_user_sites_site_id ON user_sites(site_id); -CREATE INDEX idx_auth_sync_entity ON auth_sync_state(entity_type, entity_id); -CREATE INDEX idx_api_clients_status ON api_clients(status); -CREATE INDEX idx_api_clients_expires_at ON api_clients(expires_at); -CREATE INDEX idx_systems_system_key ON systems(system_key); - -COMMIT; diff --git a/backend/scripts/migrate_provider_columns.sql b/backend/scripts/migrate_provider_columns.sql deleted file mode 100644 index 149f3e2..0000000 --- a/backend/scripts/migrate_provider_columns.sql +++ /dev/null @@ -1,131 +0,0 @@ --- Rename legacy IdP column names to provider_* naming. --- Safe to run multiple times. - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'idp_group_id' - ) AND NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'provider_group_id' - ) THEN - ALTER TABLE public.companies RENAME COLUMN idp_group_id TO provider_group_id; - END IF; -END $$; - --- companies.display_name -> companies.name -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'display_name' - ) AND NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'name' - ) THEN - ALTER TABLE public.companies RENAME COLUMN display_name TO name; - END IF; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'companies' AND column_name = 'legal_name' - ) THEN - ALTER TABLE public.companies DROP COLUMN legal_name; - END IF; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'provider_client_id' - ) THEN - ALTER TABLE public.systems DROP COLUMN provider_client_id; - END IF; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'provider_role_name' - ) THEN - ALTER TABLE public.roles DROP COLUMN provider_role_name; - END IF; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.table_constraints - WHERE table_schema='public' AND table_name='roles' AND constraint_name='uq_roles_system_provider_role_name' - ) THEN - ALTER TABLE public.roles DROP CONSTRAINT uq_roles_system_provider_role_name; - END IF; -END $$; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM information_schema.table_constraints - WHERE table_schema='public' AND table_name='roles' AND constraint_name='uq_roles_system_name' - ) THEN - ALTER TABLE public.roles ADD CONSTRAINT uq_roles_system_name UNIQUE (system_id, name); - END IF; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'sites' AND column_name = 'idp_group_id' - ) AND NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'sites' AND column_name = 'provider_group_id' - ) THEN - ALTER TABLE public.sites RENAME COLUMN idp_group_id TO provider_group_id; - END IF; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'idp_client_id' - ) AND NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'systems' AND column_name = 'provider_client_id' - ) THEN - ALTER TABLE public.systems RENAME COLUMN idp_client_id TO provider_client_id; - END IF; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'idp_role_name' - ) AND NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'roles' AND column_name = 'provider_role_name' - ) THEN - ALTER TABLE public.roles RENAME COLUMN idp_role_name TO provider_role_name; - END IF; -END $$; - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'idp_user_id' - ) AND NOT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'provider_user_id' - ) THEN - ALTER TABLE public.users RENAME COLUMN idp_user_id TO provider_user_id; - END IF; -END $$; diff --git a/backend/scripts/start_dev.sh b/backend/scripts/start_dev.sh deleted file mode 100755 index 7fa39aa..0000000 --- a/backend/scripts/start_dev.sh +++ /dev/null @@ -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 diff --git a/backend/tests/test_healthz.py b/backend/tests/test_healthz.py deleted file mode 100644 index b8d26dd..0000000 --- a/backend/tests/test_healthz.py +++ /dev/null @@ -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" diff --git a/backend/tests/test_idp_jwt.py b/backend/tests/test_idp_jwt.py deleted file mode 100644 index 0092a3d..0000000 --- a/backend/tests/test_idp_jwt.py +++ /dev/null @@ -1,17 +0,0 @@ -from fastapi.testclient import TestClient - -from app.main import app -from app.security.idp_jwt import ProviderTokenVerifier - - -def test_infer_jwks_url() -> None: - assert ProviderTokenVerifier._infer_jwks_url("https://auth.ose.tw/application/o/member/") == ( - "https://auth.ose.tw/application/o/member/protocol/openid-connect/certs" - ) - - -def test_me_requires_bearer_token() -> None: - client = TestClient(app) - resp = client.get("/me") - assert resp.status_code == 401 - assert resp.json()["detail"] == "missing_bearer_token" diff --git a/backend/tests/test_internal_idp_sync.py b/backend/tests/test_internal_idp_sync.py deleted file mode 100644 index da1feee..0000000 --- a/backend/tests/test_internal_idp_sync.py +++ /dev/null @@ -1,23 +0,0 @@ -from fastapi.testclient import TestClient - -from app.main import app -from app.security.api_client_auth import require_api_client - - -def test_internal_idp_ensure_requires_config() -> None: - app.dependency_overrides[require_api_client] = lambda: None - client = TestClient(app) - try: - resp = client.post( - "/internal/idp/users/ensure", - json={ - "sub": "idp-sub-1", - "email": "user@example.com", - "display_name": "User Example", - "is_active": True, - }, - ) - assert resp.status_code == 503 - assert resp.json()["detail"] == "idp_admin_not_configured" - finally: - app.dependency_overrides.pop(require_api_client, None) diff --git a/docs/index.md b/docs/index.md index 9be237b..7cc875e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,6 +29,11 @@ ## 單一真實來源 - DB SQL:[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql) +## Repo 結構(已拆分) +- 整合層(本 repo):`member.ose.tw`(docs / 部署 / 整合) +- 後端子模組:[backend](../backend)(submodule: `../member-backend`) +- 前端子模組:[frontend](../frontend)(submodule: `../member-frontend`) + ## 文件邊界 - 本輪只保留可開發、可交辦、可驗收文件。 - 最終規格白皮書延後到專案完成後再產出。 diff --git a/frontend b/frontend new file mode 160000 index 0000000..cf54146 --- /dev/null +++ b/frontend @@ -0,0 +1 @@ +Subproject commit cf541466061d498f15df716bd3527ddaa2b93419 diff --git a/frontend/.env b/frontend/.env deleted file mode 100644 index c2f68f4..0000000 --- a/frontend/.env +++ /dev/null @@ -1,2 +0,0 @@ -VITE_APP_TITLE=member.ose.tw -VITE_API_BASE_URL=https://memberapi.ose.tw diff --git a/frontend/.env.development b/frontend/.env.development deleted file mode 100644 index dd13e1d..0000000 --- a/frontend/.env.development +++ /dev/null @@ -1,2 +0,0 @@ -VITE_APP_TITLE=member.ose.tw (dev) -VITE_API_BASE_URL=http://127.0.0.1:8000 diff --git a/frontend/.env.example b/frontend/.env.example deleted file mode 100644 index 2cb3449..0000000 --- a/frontend/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -# member.ose.tw frontend env -VITE_APP_TITLE=member.ose.tw -VITE_API_BASE_URL=https://memberapi.ose.tw diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index e3cf358..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - member.ose.tw - - -
- - - diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index 83c2cc8..0000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,2922 +0,0 @@ -{ - "name": "member-ose-tw", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "member-ose-tw", - "version": "0.1.0", - "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" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@ctrl/tinycolor": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", - "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@element-plus/icons-vue": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", - "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", - "license": "MIT", - "peerDependencies": { - "vue": "^3.2.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@popperjs/core": { - "name": "@sxzz/popperjs-es", - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", - "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", - "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", - "license": "MIT" - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.31.tgz", - "integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.2", - "@vue/shared": "3.5.31", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz", - "integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.31", - "@vue/shared": "3.5.31" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz", - "integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.2", - "@vue/compiler-core": "3.5.31", - "@vue/compiler-dom": "3.5.31", - "@vue/compiler-ssr": "3.5.31", - "@vue/shared": "3.5.31", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.8", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz", - "integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.31", - "@vue/shared": "3.5.31" - } - }, - "node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", - "license": "MIT" - }, - "node_modules/@vue/reactivity": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz", - "integrity": "sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.31" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.31.tgz", - "integrity": "sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.31", - "@vue/shared": "3.5.31" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.31.tgz", - "integrity": "sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.31", - "@vue/runtime-core": "3.5.31", - "@vue/shared": "3.5.31", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.31.tgz", - "integrity": "sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.31", - "@vue/shared": "3.5.31" - }, - "peerDependencies": { - "vue": "3.5.31" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz", - "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==", - "license": "MIT" - }, - "node_modules/@vueuse/core": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", - "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.20", - "@vueuse/metadata": "12.0.0", - "@vueuse/shared": "12.0.0", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/metadata": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", - "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/shared": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", - "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", - "license": "MIT", - "dependencies": { - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/async-validator": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", - "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.12", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", - "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", - "license": "MIT" - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.328", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", - "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", - "dev": true, - "license": "ISC" - }, - "node_modules/element-plus": { - "version": "2.13.6", - "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz", - "integrity": "sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==", - "license": "MIT", - "dependencies": { - "@ctrl/tinycolor": "^4.2.0", - "@element-plus/icons-vue": "^2.3.2", - "@floating-ui/dom": "^1.0.1", - "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", - "@types/lodash": "^4.17.20", - "@types/lodash-es": "^4.17.12", - "@vueuse/core": "12.0.0", - "async-validator": "^4.2.5", - "dayjs": "^1.11.19", - "lodash": "^4.17.23", - "lodash-es": "^4.17.23", - "lodash-unified": "^1.0.3", - "memoize-one": "^6.0.0", - "normalize-wheel-es": "^1.2.0", - "vue-component-type-helpers": "^3.2.4" - }, - "peerDependencies": { - "vue": "^3.3.0" - } - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, - "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", - "license": "MIT" - }, - "node_modules/lodash-unified": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", - "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", - "license": "MIT", - "peerDependencies": { - "@types/lodash-es": "*", - "lodash": "*", - "lodash-es": "*" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-wheel-es": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", - "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", - "license": "BSD-3-Clause" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinia": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", - "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^6.6.3", - "vue-demi": "^0.14.10" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "typescript": ">=4.4.4", - "vue": "^2.7.0 || ^3.5.11" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vue": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz", - "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.31", - "@vue/compiler-sfc": "3.5.31", - "@vue/runtime-dom": "3.5.31", - "@vue/server-renderer": "3.5.31", - "@vue/shared": "3.5.31" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-component-type-helpers": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", - "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", - "license": "MIT" - }, - "node_modules/vue-demi": { - "version": "0.14.10", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, - "node_modules/vue-router": { - "version": "4.6.4", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", - "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^6.6.4" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - } - } -} diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index d9a1a9b..0000000 --- a/frontend/package.json +++ /dev/null @@ -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" - } -} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js deleted file mode 100644 index 2b75bd8..0000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {} - } -} diff --git a/frontend/src/App.vue b/frontend/src/App.vue deleted file mode 100644 index 092977c..0000000 --- a/frontend/src/App.vue +++ /dev/null @@ -1,111 +0,0 @@ - - - diff --git a/frontend/src/api/api-clients.js b/frontend/src/api/api-clients.js deleted file mode 100644 index 6ec6760..0000000 --- a/frontend/src/api/api-clients.js +++ /dev/null @@ -1,7 +0,0 @@ -import { adminHttp } from './http' - -export const getApiClients = (params) => adminHttp.get('/admin/api-clients', { params }) -export const createApiClient = (data) => adminHttp.post('/admin/api-clients', data) -export const updateApiClient = (clientKey, data) => adminHttp.patch(`/admin/api-clients/${clientKey}`, data) -export const rotateApiClientKey = (clientKey) => adminHttp.post(`/admin/api-clients/${clientKey}/rotate-key`) -export const deleteApiClient = (clientKey) => adminHttp.delete(`/admin/api-clients/${clientKey}`) diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js deleted file mode 100644 index ab5cb29..0000000 --- a/frontend/src/api/auth.js +++ /dev/null @@ -1,25 +0,0 @@ -import { userHttp } from './http' - -export const getOidcAuthorizeUrl = (redirectUri, options = {}) => - userHttp.get('/auth/oidc/url', { - params: { - redirect_uri: redirectUri, - login_hint: options.loginHint || undefined, - prompt: options.prompt || undefined, - idp_hint: options.idpHint || undefined, - code_challenge: options.codeChallenge || undefined, - code_challenge_method: options.codeChallengeMethod || undefined - } - }) - -export const exchangeOidcCode = (code, redirectUri, codeVerifier) => - userHttp.post('/auth/oidc/exchange', { - code, - redirect_uri: redirectUri, - code_verifier: codeVerifier || undefined - }) - -export const refreshOidcToken = (refreshToken) => - userHttp.post('/auth/refresh', { - refresh_token: refreshToken - }) diff --git a/frontend/src/api/companies.js b/frontend/src/api/companies.js deleted file mode 100644 index 5db9bd3..0000000 --- a/frontend/src/api/companies.js +++ /dev/null @@ -1,7 +0,0 @@ -import { adminHttp } from './http' - -export const getCompanies = (params) => adminHttp.get('/admin/companies', { params }) -export const createCompany = (data) => adminHttp.post('/admin/companies', data) -export const updateCompany = (companyKey, data) => adminHttp.patch(`/admin/companies/${companyKey}`, data) -export const deleteCompany = (companyKey) => adminHttp.delete(`/admin/companies/${companyKey}`) -export const getCompanySites = (companyKey) => adminHttp.get(`/admin/companies/${companyKey}/sites`) diff --git a/frontend/src/api/http.js b/frontend/src/api/http.js deleted file mode 100644 index d5df82d..0000000 --- a/frontend/src/api/http.js +++ /dev/null @@ -1,95 +0,0 @@ -import axios from 'axios' -import router from '@/router' - -const BASE_URL = import.meta.env.VITE_API_BASE_URL -let refreshPromise = null - -async function refreshAccessToken() { - if (refreshPromise) return refreshPromise - const refreshToken = localStorage.getItem('refresh_token') - if (!refreshToken) throw new Error('missing_refresh_token') - - refreshPromise = axios - .post(`${BASE_URL}/auth/refresh`, { refresh_token: refreshToken }) - .then((res) => { - const nextAccessToken = res.data?.access_token - const nextRefreshToken = res.data?.refresh_token || refreshToken - if (!nextAccessToken) { - throw new Error('missing_access_token') - } - localStorage.setItem('access_token', nextAccessToken) - localStorage.setItem('refresh_token', nextRefreshToken) - return nextAccessToken - }) - .finally(() => { - refreshPromise = null - }) - - return refreshPromise -} - -function hardLogoutToLogin() { - localStorage.removeItem('access_token') - localStorage.removeItem('refresh_token') - router.push('/login') -} - -// 使用者 API:帶 Bearer token -export const userHttp = axios.create({ baseURL: BASE_URL }) - -userHttp.interceptors.request.use(config => { - const token = localStorage.getItem('access_token') - if (token) { - config.headers['Authorization'] = `Bearer ${token}` - } - return config -}) - -userHttp.interceptors.response.use( - res => res, - async err => { - const original = err.config || {} - if (err.response?.status === 401 && !original._retriedByRefresh) { - original._retriedByRefresh = true - try { - const nextToken = await refreshAccessToken() - original.headers = original.headers || {} - original.headers['Authorization'] = `Bearer ${nextToken}` - return userHttp.request(original) - } catch (_refreshErr) { - hardLogoutToLogin() - } - } - return Promise.reject(err) - } -) - -// 管理員 API:只帶 Bearer token(後端再檢查 admin 群組) -export const adminHttp = axios.create({ baseURL: BASE_URL }) - -adminHttp.interceptors.request.use(config => { - const token = localStorage.getItem('access_token') - if (token) { - config.headers['Authorization'] = `Bearer ${token}` - } - return config -}) - -adminHttp.interceptors.response.use( - res => res, - async err => { - const original = err.config || {} - if (err.response?.status === 401 && !original._retriedByRefresh) { - original._retriedByRefresh = true - try { - const nextToken = await refreshAccessToken() - original.headers = original.headers || {} - original.headers['Authorization'] = `Bearer ${nextToken}` - return adminHttp.request(original) - } catch (_refreshErr) { - hardLogoutToLogin() - } - } - return Promise.reject(err) - } -) diff --git a/frontend/src/api/me.js b/frontend/src/api/me.js deleted file mode 100644 index 02ac4a8..0000000 --- a/frontend/src/api/me.js +++ /dev/null @@ -1,4 +0,0 @@ -import { userHttp } from './http' - -export const getMe = () => userHttp.get('/me') -export const getMyPermissionSnapshot = () => userHttp.get('/me/permissions/snapshot') diff --git a/frontend/src/api/members.js b/frontend/src/api/members.js deleted file mode 100644 index 615853d..0000000 --- a/frontend/src/api/members.js +++ /dev/null @@ -1,10 +0,0 @@ -import { adminHttp } from './http' - -export const getMembers = (params) => adminHttp.get('/admin/members', { params }) -export const createMember = (data) => adminHttp.post('/admin/members', data) -export const updateMember = (userSub, data) => adminHttp.patch(`/admin/members/${userSub}`, data) -export const deleteMember = (userSub, syncToIdp = true) => adminHttp.delete(`/admin/members/${userSub}`, { params: { sync_to_idp: syncToIdp } }) -export const resetMemberPassword = (userSub) => adminHttp.post(`/admin/members/${userSub}/password/reset`) -export const getMemberSites = (userSub) => adminHttp.get(`/admin/members/${userSub}/sites`) -export const setMemberSites = (userSub, siteKeys) => adminHttp.put(`/admin/members/${userSub}/sites`, { site_keys: siteKeys }) -export const getMemberRoles = (userSub) => adminHttp.get(`/admin/members/${userSub}/roles`) diff --git a/frontend/src/api/roles.js b/frontend/src/api/roles.js deleted file mode 100644 index 063d891..0000000 --- a/frontend/src/api/roles.js +++ /dev/null @@ -1,8 +0,0 @@ -import { adminHttp } from './http' - -export const getRoles = (params) => adminHttp.get('/admin/roles', { params }) -export const createRole = (data) => adminHttp.post('/admin/roles', data) -export const updateRole = (roleKey, data) => adminHttp.patch(`/admin/roles/${roleKey}`, data) -export const deleteRole = (roleKey) => adminHttp.delete(`/admin/roles/${roleKey}`) -export const getRoleSites = (roleKey) => adminHttp.get(`/admin/roles/${roleKey}/sites`) -export const setRoleSites = (roleKey, siteKeys) => adminHttp.put(`/admin/roles/${roleKey}/sites`, { site_keys: siteKeys }) diff --git a/frontend/src/api/sites.js b/frontend/src/api/sites.js deleted file mode 100644 index cda7309..0000000 --- a/frontend/src/api/sites.js +++ /dev/null @@ -1,9 +0,0 @@ -import { adminHttp } from './http' - -export const getSites = (params) => adminHttp.get('/admin/sites', { params }) -export const createSite = (data) => adminHttp.post('/admin/sites', data) -export const updateSite = (siteKey, data) => adminHttp.patch(`/admin/sites/${siteKey}`, data) -export const deleteSite = (siteKey) => adminHttp.delete(`/admin/sites/${siteKey}`) -export const getSiteRoles = (siteKey) => adminHttp.get(`/admin/sites/${siteKey}/roles`) -export const setSiteRoles = (siteKey, roleKeys) => adminHttp.put(`/admin/sites/${siteKey}/roles`, { role_keys: roleKeys }) -export const getSiteMembers = (siteKey) => adminHttp.get(`/admin/sites/${siteKey}/members`) diff --git a/frontend/src/api/systems.js b/frontend/src/api/systems.js deleted file mode 100644 index 653a561..0000000 --- a/frontend/src/api/systems.js +++ /dev/null @@ -1,7 +0,0 @@ -import { adminHttp } from './http' - -export const getSystems = (params) => adminHttp.get('/admin/systems', { params }) -export const createSystem = (data) => adminHttp.post('/admin/systems', data) -export const updateSystem = (systemKey, data) => adminHttp.patch(`/admin/systems/${systemKey}`, data) -export const deleteSystem = (systemKey) => adminHttp.delete(`/admin/systems/${systemKey}`) -export const getSystemRoles = (systemKey) => adminHttp.get(`/admin/systems/${systemKey}/roles`) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css deleted file mode 100644 index b5c61c9..0000000 --- a/frontend/src/assets/main.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/frontend/src/main.js b/frontend/src/main.js deleted file mode 100644 index dbc7c6a..0000000 --- a/frontend/src/main.js +++ /dev/null @@ -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') diff --git a/frontend/src/pages/AuthCallbackPage.vue b/frontend/src/pages/AuthCallbackPage.vue deleted file mode 100644 index 4e17dbe..0000000 --- a/frontend/src/pages/AuthCallbackPage.vue +++ /dev/null @@ -1,98 +0,0 @@ - - - diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue deleted file mode 100644 index 2971b1e..0000000 --- a/frontend/src/pages/LoginPage.vue +++ /dev/null @@ -1,114 +0,0 @@ - - - diff --git a/frontend/src/pages/admin/ApiClientsPage.vue b/frontend/src/pages/admin/ApiClientsPage.vue deleted file mode 100644 index cd3a638..0000000 --- a/frontend/src/pages/admin/ApiClientsPage.vue +++ /dev/null @@ -1,295 +0,0 @@ - - - diff --git a/frontend/src/pages/admin/CompaniesPage.vue b/frontend/src/pages/admin/CompaniesPage.vue deleted file mode 100644 index 681a28b..0000000 --- a/frontend/src/pages/admin/CompaniesPage.vue +++ /dev/null @@ -1,198 +0,0 @@ - - - diff --git a/frontend/src/pages/admin/MembersPage.vue b/frontend/src/pages/admin/MembersPage.vue deleted file mode 100644 index 305f1d4..0000000 --- a/frontend/src/pages/admin/MembersPage.vue +++ /dev/null @@ -1,309 +0,0 @@ - - - diff --git a/frontend/src/pages/admin/RolesPage.vue b/frontend/src/pages/admin/RolesPage.vue deleted file mode 100644 index a9c1e9d..0000000 --- a/frontend/src/pages/admin/RolesPage.vue +++ /dev/null @@ -1,296 +0,0 @@ - - - diff --git a/frontend/src/pages/admin/SitesPage.vue b/frontend/src/pages/admin/SitesPage.vue deleted file mode 100644 index 375719d..0000000 --- a/frontend/src/pages/admin/SitesPage.vue +++ /dev/null @@ -1,319 +0,0 @@ - - - diff --git a/frontend/src/pages/admin/SystemsPage.vue b/frontend/src/pages/admin/SystemsPage.vue deleted file mode 100644 index a515180..0000000 --- a/frontend/src/pages/admin/SystemsPage.vue +++ /dev/null @@ -1,110 +0,0 @@ - - - diff --git a/frontend/src/pages/permissions/PermissionSnapshotPage.vue b/frontend/src/pages/permissions/PermissionSnapshotPage.vue deleted file mode 100644 index 37dda8c..0000000 --- a/frontend/src/pages/permissions/PermissionSnapshotPage.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - diff --git a/frontend/src/pages/profile/MePage.vue b/frontend/src/pages/profile/MePage.vue deleted file mode 100644 index 08621cd..0000000 --- a/frontend/src/pages/profile/MePage.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js deleted file mode 100644 index 621d25e..0000000 --- a/frontend/src/router/index.js +++ /dev/null @@ -1,78 +0,0 @@ -import { createRouter, createWebHistory } from 'vue-router' -import { useAuthStore } from '@/stores/auth' - -const routes = [ - { path: '/', redirect: '/me' }, - { - path: '/login', - name: 'login', - component: () => import('@/pages/LoginPage.vue') - }, - { - path: '/auth/callback', - name: 'auth-callback', - component: () => import('@/pages/AuthCallbackPage.vue') - }, - { - path: '/me', - name: 'me', - component: () => import('@/pages/profile/MePage.vue'), - meta: { requiresAuth: true } - }, - { - path: '/me/permissions', - name: 'my-permissions', - component: () => import('@/pages/permissions/PermissionSnapshotPage.vue'), - meta: { requiresAuth: true } - }, - { - path: '/admin/companies', - name: 'admin-companies', - component: () => import('@/pages/admin/CompaniesPage.vue'), - meta: { requiresAuth: true } - }, - { - path: '/admin/sites', - name: 'admin-sites', - component: () => import('@/pages/admin/SitesPage.vue'), - meta: { requiresAuth: true } - }, - { - path: '/admin/systems', - name: 'admin-systems', - component: () => import('@/pages/admin/SystemsPage.vue'), - meta: { requiresAuth: true } - }, - { - path: '/admin/roles', - name: 'admin-roles', - component: () => import('@/pages/admin/RolesPage.vue'), - meta: { requiresAuth: true } - }, - { - path: '/admin/members', - name: 'admin-members', - component: () => import('@/pages/admin/MembersPage.vue'), - meta: { requiresAuth: true } - }, - { - path: '/admin/api-clients', - name: 'admin-api-clients', - component: () => import('@/pages/admin/ApiClientsPage.vue'), - meta: { requiresAuth: true } - } -] - -const router = createRouter({ - history: createWebHistory(), - routes -}) - -router.beforeEach((to) => { - const authStore = useAuthStore() - if (to.meta.requiresAuth && !authStore.isLoggedIn) { - return { name: 'login', query: { redirect: to.fullPath } } - } -}) - -export default router diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js deleted file mode 100644 index 6000127..0000000 --- a/frontend/src/stores/auth.js +++ /dev/null @@ -1,42 +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 refreshToken = ref(localStorage.getItem('refresh_token') || null) - const me = ref(null) - - const isLoggedIn = computed(() => !!accessToken.value) - - function setTokens(token, nextRefreshToken = null) { - accessToken.value = token || null - refreshToken.value = nextRefreshToken || null - if (token) { - localStorage.setItem('access_token', token) - } else { - localStorage.removeItem('access_token') - } - if (nextRefreshToken) { - localStorage.setItem('refresh_token', nextRefreshToken) - } else { - localStorage.removeItem('refresh_token') - } - } - - async function fetchMe() { - const res = await getMe() - me.value = res.data - return res.data - } - - function logout() { - accessToken.value = null - refreshToken.value = null - me.value = null - localStorage.removeItem('access_token') - localStorage.removeItem('refresh_token') - } - - return { accessToken, refreshToken, me, isLoggedIn, setTokens, fetchMe, logout } -}) diff --git a/frontend/src/stores/permission.js b/frontend/src/stores/permission.js deleted file mode 100644 index b07c6f5..0000000 --- a/frontend/src/stores/permission.js +++ /dev/null @@ -1,18 +0,0 @@ -import { defineStore } from 'pinia' -import { ref } from 'vue' -import { getMyPermissionSnapshot } from '@/api/me' - -export const usePermissionStore = defineStore('permission', () => { - const snapshot = ref(null) - - async function fetchMySnapshot() { - const res = await getMyPermissionSnapshot() - snapshot.value = res.data - return res.data - } - - return { - snapshot, - fetchMySnapshot - } -}) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js deleted file mode 100644 index df83ba8..0000000 --- a/frontend/tailwind.config.js +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: ['./index.html', './src/**/*.{vue,js}'], - theme: { - extend: {} - }, - plugins: [] -} diff --git a/frontend/vite.config.js b/frontend/vite.config.js deleted file mode 100644 index 4343664..0000000 --- a/frontend/vite.config.js +++ /dev/null @@ -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)) - } - } -})