{id}
By: #{by}
"""
+
+CREATE_API_KEY = """
+#Create_API_Key
+➖➖➖➖➖➖➖➖➖
+Name: {name}
+Expire Date: {expire_date}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+Belongs To: {admin_username}
+By: #{by}
+"""
+
+MODIFY_API_KEY = """
+✏️ #Modify_API_Key
+➖➖➖➖➖➖➖➖➖
+Name: {name}
+Expire Date: {expire_date}
+Status: {status}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+Belongs To: {admin_username}
+By: #{by}
+"""
+
+REMOVE_API_KEY = """
+#Remove_API_Key
+➖➖➖➖➖➖➖➖➖
+Name: {name}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+Belongs To: {admin_username}
+By: #{by}
+"""
diff --git a/app/operation/api_key.py b/app/operation/api_key.py
new file mode 100644
index 000000000..eae9fb0ca
--- /dev/null
+++ b/app/operation/api_key.py
@@ -0,0 +1,301 @@
+from datetime import datetime as dt, timezone as tz
+
+from sqlalchemy.exc import IntegrityError
+
+from app.db import AsyncSession
+from app.db.crud.admin import build_admin_details, get_admin_by_id
+from app.db.crud.api_key import (
+ create_api_key,
+ delete_api_key,
+ get_api_key_by_id,
+ get_api_keys,
+ get_api_keys_by_ids,
+ revoke_api_key as revoke_api_key_crud,
+ update_api_key,
+)
+from app.notification import (
+ create_api_key as notify_create,
+ modify_api_key as notify_modify,
+ remove_api_key as notify_delete,
+)
+from app.models.admin import AdminDetails
+from app.models.admin_role import RolePermissions
+from app.models.api_key import (
+ APIKeyCreate,
+ APIKeyCreateResponse,
+ APIKeyResponse,
+ APIKeyUpdate,
+ APIKeysQuery,
+ APIKeysResponse,
+ BulkAPIKeySelection,
+ RemoveAPIKeysResponse,
+)
+from app.operation import BaseOperation
+
+
+def _check_permissions_not_exceed_admin(admin: AdminDetails, requested: RolePermissions) -> None:
+ """Raise ValueError if any permission in `requested` exceeds what `admin` has.
+
+ Owners are exempt — they can assign any permissions.
+ """
+ if admin.is_owner:
+ return
+
+ admin_perms = admin.role.permissions if admin.role else RolePermissions()
+
+ for resource_name, resource_perms in requested.model_dump(exclude_none=True).items():
+ if resource_perms is None:
+ continue
+ admin_resource = admin_perms.get(resource_name)
+ if admin_resource is None:
+ raise ValueError(f"You don't have access to resource '{resource_name}'")
+
+ for action, value in resource_perms.items():
+ if value is None:
+ continue
+ admin_action = admin_resource.get(action) if admin_resource else None
+ if admin_action is None:
+ raise ValueError(
+ f"You don't have the '{action}' permission on '{resource_name}'"
+ )
+ # True means unrestricted — cannot grant if admin only has scoped access
+ if value is True and admin_action is not True:
+ raise ValueError(
+ f"Cannot grant '{resource_name}.{action}=True': "
+ f"your own access is scoped (scope={admin_action.get('scope', 0) if isinstance(admin_action, dict) else admin_action})"
+ )
+ # If both sides have a scope dict, key scope must not exceed admin scope
+ if isinstance(value, dict) and isinstance(admin_action, dict):
+ key_scope = value.get("scope", 0)
+ admin_scope = admin_action.get("scope", 0)
+ if key_scope > admin_scope:
+ raise ValueError(
+ f"Cannot grant '{resource_name}.{action}' with scope={key_scope}: "
+ f"your scope is {admin_scope}"
+ )
+
+
+class APIKeyOperation(BaseOperation):
+ async def create_api_key(
+ self, db: AsyncSession, *, admin: AdminDetails, model: APIKeyCreate
+ ) -> APIKeyCreateResponse:
+ if admin.id is None:
+ await self.raise_error(message="API key creation is not available for env admins", code=403)
+
+ target_admin_id = model.admin_id or admin.id
+ if target_admin_id != admin.id and not admin.is_owner:
+ await self.raise_error(message="Only the owner can assign API keys to another admin", code=403)
+
+ target_db_admin = await get_admin_by_id(
+ db, target_admin_id, load_users=False, load_usage_logs=False, load_role=True
+ )
+ if target_db_admin is None:
+ await self.raise_error(message="Target admin not found", code=404)
+
+ target_admin = build_admin_details(target_db_admin)
+
+ if not model.inherit_permissions:
+ try:
+ _check_permissions_not_exceed_admin(admin, model.permissions)
+ _check_permissions_not_exceed_admin(target_admin, model.permissions)
+ except ValueError as exc:
+ await self.raise_error(message=str(exc), code=403)
+
+ duplicates, _ = await get_api_keys(db, admin_id=target_admin_id, offset=0, limit=1, name=model.name)
+ if duplicates:
+ await self.raise_error(message="API key name already exists", code=409)
+
+ if model.expire_date is not None and model.expire_date <= dt.now(tz.utc):
+ await self.raise_error(message="expire_date must be in the future", code=422)
+
+ try:
+ raw_key, db_key = await create_api_key(
+ db,
+ admin_id=target_admin_id,
+ model=model,
+ )
+ await db.commit()
+ await notify_create(APIKeyResponse.model_validate(db_key), target_db_admin.username, admin.username)
+ except IntegrityError:
+ await self.raise_error(message="API key already exists", code=409, db=db)
+
+ return APIKeyCreateResponse(
+ id=db_key.id,
+ admin_id=db_key.admin_id,
+ name=db_key.name,
+ note=db_key.note,
+ permissions=RolePermissions.model_validate(db_key.permissions),
+ inherit_permissions=db_key.inherit_permissions,
+ created_at=db_key.created_at,
+ expire_date=db_key.expire_date,
+ revoked_at=db_key.revoked_at,
+ status=db_key.status,
+ is_expired=db_key.is_expired,
+ api_key=raw_key,
+ api_key_trimmed=db_key.api_key_trimmed,
+ )
+
+ async def list_api_keys(self, db: AsyncSession, *, admin: AdminDetails, query: APIKeysQuery) -> APIKeysResponse:
+ scope_admin_id = None if admin.is_owner else admin.id
+ rows, total = await get_api_keys(
+ db,
+ admin_id=scope_admin_id,
+ offset=query.offset,
+ limit=query.limit,
+ key_id=query.key_id,
+ name=query.name,
+ status=query.status,
+ )
+ return APIKeysResponse(api_keys=[APIKeyResponse.model_validate(row) for row in rows], total=total)
+
+ async def modify_api_key(
+ self, db: AsyncSession, *, admin: AdminDetails, key_id: int, model: APIKeyUpdate
+ ) -> APIKeyResponse:
+ db_key = await get_api_key_by_id(db, key_id)
+ if db_key is None:
+ await self.raise_error(message="API key not found", code=404)
+
+ if not admin.is_owner and db_key.admin_id != admin.id:
+ await self.raise_error(message="Permission denied", code=403)
+
+ target_admin_id = model.admin_id or db_key.admin_id
+ if target_admin_id != db_key.admin_id and not admin.is_owner:
+ await self.raise_error(message="Only the owner can assign API keys to another admin", code=403)
+
+ target_admin = None
+ if target_admin_id != db_key.admin_id or model.permissions is not None:
+ target_db_admin = await get_admin_by_id(
+ db, target_admin_id, load_users=False, load_usage_logs=False, load_role=True
+ )
+ if target_db_admin is None:
+ await self.raise_error(message="Target admin not found", code=404)
+ target_admin = build_admin_details(target_db_admin)
+
+ next_name = model.name if model.name is not None else db_key.name
+ if next_name != db_key.name or target_admin_id != db_key.admin_id:
+ duplicates, _ = await get_api_keys(db, admin_id=target_admin_id, offset=0, limit=1, name=next_name)
+ if any(duplicate.id != db_key.id for duplicate in duplicates):
+ await self.raise_error(message="API key name already exists", code=409)
+
+ uses_custom_permissions = model.inherit_permissions is False or (
+ model.inherit_permissions is None and model.permissions is not None and not db_key.inherit_permissions
+ )
+
+ if model.permissions is not None and uses_custom_permissions:
+ try:
+ _check_permissions_not_exceed_admin(admin, model.permissions)
+ if target_admin is not None:
+ _check_permissions_not_exceed_admin(target_admin, model.permissions)
+ except ValueError as exc:
+ await self.raise_error(message=str(exc), code=403)
+ elif target_admin is not None and not db_key.inherit_permissions:
+ try:
+ _check_permissions_not_exceed_admin(target_admin, RolePermissions.model_validate(db_key.permissions))
+ except ValueError as exc:
+ await self.raise_error(message=str(exc), code=403)
+
+ update_data = model.model_dump(exclude_unset=True)
+ if update_data.get("admin_id") is None:
+ update_data.pop("admin_id", None)
+ if update_data.get("inherit_permissions") is True:
+ update_data["permissions"] = {}
+ # Serialize permissions to plain dict for DB storage
+ if "permissions" in update_data and isinstance(update_data["permissions"], RolePermissions):
+ update_data["permissions"] = update_data["permissions"].model_dump(exclude_none=True)
+ elif "permissions" in update_data and model.permissions is not None:
+ update_data["permissions"] = model.permissions.model_dump(exclude_none=True)
+
+ db_key = await update_api_key(db, db_key, update_data)
+ await db.commit()
+
+ api_key_resp = APIKeyResponse.model_validate(db_key)
+ admin_username = db_key.admin.username if db_key.admin else "Unknown"
+ await notify_modify(api_key_resp, admin_username, admin.username)
+
+ return api_key_resp
+
+ async def revoke_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyCreateResponse:
+ db_key = await get_api_key_by_id(db, key_id)
+ if db_key is None:
+ await self.raise_error(message="API key not found", code=404)
+
+ if not admin.is_owner and db_key.admin_id != admin.id:
+ await self.raise_error(message="Permission denied", code=403)
+
+ raw_key, db_key = await revoke_api_key_crud(db, db_key)
+ await db.commit()
+
+ api_key_resp = APIKeyResponse.model_validate(db_key)
+ admin_username = db_key.admin.username if db_key.admin else "Unknown"
+ await notify_modify(api_key_resp, admin_username, admin.username)
+
+ return APIKeyCreateResponse(
+ id=db_key.id,
+ admin_id=db_key.admin_id,
+ name=db_key.name,
+ note=db_key.note,
+ permissions=RolePermissions.model_validate(db_key.permissions),
+ inherit_permissions=db_key.inherit_permissions,
+ created_at=db_key.created_at,
+ expire_date=db_key.expire_date,
+ revoked_at=db_key.revoked_at,
+ status=db_key.status,
+ is_expired=db_key.is_expired,
+ api_key=raw_key,
+ api_key_trimmed=db_key.api_key_trimmed,
+ )
+
+ async def get_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyResponse:
+ scope_admin_id = None if admin.is_owner else admin.id
+ rows, _ = await get_api_keys(db, admin_id=scope_admin_id, offset=0, limit=1, key_id=key_id)
+ if not rows:
+ await self.raise_error(message="API key not found", code=404)
+ return APIKeyResponse.model_validate(rows[0])
+
+ async def delete_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> None:
+ db_key = await get_api_key_by_id(db, key_id)
+ if db_key is None:
+ await self.raise_error(message="API key not found", code=404)
+
+ if not admin.is_owner and db_key.admin_id != admin.id:
+ await self.raise_error(message="Permission denied", code=403)
+
+ api_key_resp = APIKeyResponse.model_validate(db_key)
+ admin_username = db_key.admin.username if db_key.admin else "Unknown"
+
+ await delete_api_key(db, db_key)
+ await db.commit()
+ await notify_delete(api_key_resp, admin_username, admin.username)
+
+ async def bulk_delete_api_keys(
+ self, db: AsyncSession, *, admin: AdminDetails, bulk_api_keys: BulkAPIKeySelection
+ ) -> RemoveAPIKeysResponse:
+ requested_ids = list(bulk_api_keys.ids)
+ db_keys = await get_api_keys_by_ids(db, requested_ids)
+ found_ids = {db_key.id for db_key in db_keys}
+
+ for key_id in requested_ids:
+ if key_id not in found_ids:
+ await self.raise_error(message="API key not found", code=404)
+
+ if not admin.is_owner:
+ for db_key in db_keys:
+ if db_key.admin_id != admin.id:
+ await self.raise_error(message="Permission denied", code=403)
+
+ api_key_responses: list[APIKeyResponse] = []
+ admin_usernames: list[str] = []
+ api_key_names: list[str] = []
+
+ for db_key in db_keys:
+ api_key_responses.append(APIKeyResponse.model_validate(db_key))
+ admin_usernames.append(db_key.admin.username if db_key.admin else "Unknown")
+ api_key_names.append(db_key.name)
+ await delete_api_key(db, db_key)
+
+ await db.commit()
+
+ for api_key_resp, admin_username in zip(api_key_responses, admin_usernames):
+ await notify_delete(api_key_resp, admin_username, admin.username)
+
+ return RemoveAPIKeysResponse(api_keys=api_key_names, count=len(db_keys))
diff --git a/app/routers/__init__.py b/app/routers/__init__.py
index 18dcc6d3a..c7def101f 100644
--- a/app/routers/__init__.py
+++ b/app/routers/__init__.py
@@ -3,11 +3,13 @@
from . import (
admin,
admin_role,
- core,
+ api_key,
client_template,
+ core,
group,
home,
host,
+ hwid,
node,
settings,
setup,
@@ -15,7 +17,6 @@
system,
user,
user_template,
- hwid,
)
api_router = APIRouter()
@@ -23,6 +24,7 @@
routers = [
home.router,
admin.router,
+ api_key.router,
admin_role.router,
setup.router,
system.router,
diff --git a/app/routers/api_key.py b/app/routers/api_key.py
new file mode 100644
index 000000000..6e81a6ce5
--- /dev/null
+++ b/app/routers/api_key.py
@@ -0,0 +1,103 @@
+from fastapi import APIRouter, Depends, status
+from starlette.responses import Response
+
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminDetails
+from app.models.api_key import (
+ APIKeyCreate,
+ APIKeyCreateResponse,
+ APIKeyResponse,
+ APIKeyUpdate,
+ APIKeysQuery,
+ APIKeysResponse,
+ BulkAPIKeySelection,
+ RemoveAPIKeysResponse,
+)
+from app.operation import OperatorType
+from app.operation.api_key import APIKeyOperation
+from app.routers.dependencies import get_api_key_list_query
+from app.utils import responses
+
+from .authentication import require_permission
+
+router = APIRouter(
+ tags=["API Keys"],
+ prefix="/api/api_key",
+ responses={401: responses._401, 403: responses._403},
+)
+
+api_key_operator = APIKeyOperation(operator_type=OperatorType.API)
+
+
+@router.post(
+ "",
+ response_model=APIKeyCreateResponse,
+ status_code=status.HTTP_201_CREATED,
+ responses={409: responses._409},
+)
+async def create_api_key(
+ model: APIKeyCreate,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("api_keys", "create")),
+):
+ return await api_key_operator.create_api_key(db, admin=admin, model=model)
+
+
+@router.get("s", response_model=APIKeysResponse)
+async def list_api_keys(
+ query: APIKeysQuery = Depends(get_api_key_list_query),
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("api_keys", "read")),
+):
+ return await api_key_operator.list_api_keys(db, admin=admin, query=query)
+
+
+@router.post(
+ "s/bulk/delete",
+ response_model=RemoveAPIKeysResponse,
+ responses={400: responses._400, 403: responses._403, 404: responses._404},
+)
+async def bulk_delete_api_keys(
+ bulk_api_keys: BulkAPIKeySelection,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("api_keys", "delete")),
+):
+ return await api_key_operator.bulk_delete_api_keys(db, admin=admin, bulk_api_keys=bulk_api_keys)
+
+
+@router.patch("/{key_id}", response_model=APIKeyResponse, responses={404: responses._404, 409: responses._409})
+async def modify_api_key(
+ key_id: int,
+ model: APIKeyUpdate,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("api_keys", "update")),
+):
+ return await api_key_operator.modify_api_key(db, admin=admin, key_id=key_id, model=model)
+
+
+@router.get("/{key_id}", response_model=APIKeyResponse, responses={404: responses._404})
+async def get_api_key(
+ key_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("api_keys", "read")),
+):
+ return await api_key_operator.get_api_key(db, admin=admin, key_id=key_id)
+
+
+@router.post("/{key_id}/revoke", response_model=APIKeyCreateResponse, responses={404: responses._404})
+async def revoke_api_key(
+ key_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("api_keys", "delete")),
+):
+ return await api_key_operator.revoke_api_key(db, admin=admin, key_id=key_id)
+
+
+@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT, responses={404: responses._404})
+async def remove_api_key(
+ key_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(require_permission("api_keys", "delete")),
+):
+ await api_key_operator.delete_api_key(db, admin=admin, key_id=key_id)
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
diff --git a/app/routers/authentication.py b/app/routers/authentication.py
index 90964484c..aaafd4a6d 100644
--- a/app/routers/authentication.py
+++ b/app/routers/authentication.py
@@ -1,7 +1,8 @@
from datetime import timezone as tz
+from uuid import UUID
from aiogram.utils.web_app import WebAppInitData, safe_parse_webapp_init_data
-from fastapi import Depends, HTTPException, status
+from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
@@ -14,6 +15,7 @@
get_admin_by_id as get_admin_by_id_crud,
get_admin_by_telegram_id,
)
+from app.db.crud.api_key import get_api_key_by_raw_key
from app.db.models import Admin, AdminUsageLogs, User
from app.models.admin import AdminDetails, AdminRoleData, AdminStatus, AdminValidationResult, verify_password
from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions
@@ -23,7 +25,7 @@
from app.utils.jwt import get_admin_payload
from config import auth_settings, runtime_settings
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/token")
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/token", auto_error=False)
# Owner-level role data given to env admins — full permissions, bypasses all checks
_ENV_ADMIN_ROLE = AdminRoleData(
@@ -43,6 +45,86 @@ def _is_token_valid_for_admin(db_admin: Admin, payload: dict) -> bool:
return db_admin.password_reset_at.astimezone(tz.utc) <= payload.get("created_at")
+def _extract_api_key(request: Request) -> str | None:
+ auth = request.headers.get("Authorization")
+ x_api_key = request.headers.get("X-Api-Key")
+
+ if x_api_key:
+ return x_api_key.strip()
+
+ if not auth:
+ return None
+
+ scheme, _, credentials = auth.partition(" ")
+ if not scheme or not credentials:
+ return None
+
+ scheme = scheme.lower().strip()
+ credentials = credentials.strip()
+ if scheme == "apikey":
+ return credentials
+ return None
+
+
+async def _build_admin_metrics(db: AsyncSession, admin_id: int) -> tuple[int, int]:
+ total_users = (await db.execute(select(func.count(User.id)).where(User.admin_id == admin_id))).scalar() or 0
+ reseted_usage = (
+ await db.execute(
+ select(func.coalesce(func.sum(AdminUsageLogs.used_traffic_at_reset), 0)).where(
+ AdminUsageLogs.admin_id == admin_id
+ )
+ )
+ ).scalar() or 0
+ return int(total_users), int(reseted_usage)
+
+
+async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics: bool = False) -> AdminDetails | None:
+ return await _get_admin_from_api_key_internal(db, raw_key, with_metrics=with_metrics)
+
+
+async def _get_admin_from_api_key_internal(
+ db: AsyncSession, raw_key: str, *, with_metrics: bool = False
+) -> AdminDetails | None:
+ if not raw_key:
+ return
+
+ if not raw_key.startswith("pg_key_"):
+ return
+
+ uuid_str = raw_key[7:]
+ try:
+ parsed_key = UUID(uuid_str)
+ except ValueError:
+ return
+ if parsed_key.version != 4:
+ return
+
+ db_key = await get_api_key_by_raw_key(db, raw_key)
+ if db_key is None:
+ return
+
+ db_admin = db_key.admin
+ if db_admin is None:
+ return
+
+ if not db_key.is_usable:
+ return
+
+ if with_metrics:
+ total_users, reseted_usage = await _build_admin_metrics(db, db_admin.id)
+ admin = build_admin_details(db_admin, total_users=total_users, reseted_usage=reseted_usage)
+ else:
+ admin = build_admin_details(db_admin)
+
+ if not db_key.inherit_permissions and db_key.permissions:
+ # Build a minimal AdminRoleData from the stored permissions snapshot
+ role_data = dict(admin.role.model_dump() if admin.role else {})
+ role_data["permissions"] = RolePermissions.model_validate(db_key.permissions).model_dump()
+ role_data["is_owner"] = False # API keys are never owner-level
+ admin.role = AdminRoleData.model_validate(role_data)
+ return admin
+
+
async def get_admin(db: AsyncSession, token: str) -> AdminDetails | None:
payload = await get_admin_payload(token)
if not payload:
@@ -104,8 +186,37 @@ async def get_admin_with_metrics(db: AsyncSession, token: str) -> AdminDetails |
return None
-async def get_current(db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme)):
- admin: AdminDetails | None = await get_admin(db, token)
+async def _get_admin_from_request_credentials(
+ request: Request,
+ db: AsyncSession,
+ token: str | None,
+ *,
+ with_metrics: bool = False,
+) -> AdminDetails | None:
+ admin: AdminDetails | None = None
+
+ if token:
+ try:
+ if with_metrics:
+ admin = await get_admin_with_metrics(db, token)
+ else:
+ admin = await get_admin(db, token)
+ except HTTPException as exc:
+ if exc.status_code not in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN):
+ raise
+ admin = None
+
+ if not admin:
+ api_key = _extract_api_key(request)
+ if api_key:
+ admin = await get_admin_from_api_key(db, api_key, with_metrics=with_metrics)
+
+ return admin
+
+
+async def get_current(request: Request, db: AsyncSession = Depends(get_db), token: str | None = Depends(oauth2_scheme)):
+ admin = await _get_admin_from_request_credentials(request, db, token)
+
if not admin:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -121,8 +232,13 @@ async def get_current(db: AsyncSession = Depends(get_db), token: str = Depends(o
return admin
-async def get_current_with_metrics(db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme)):
- admin: AdminDetails | None = await get_admin_with_metrics(db, token)
+async def get_current_with_metrics(
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+ token: str | None = Depends(oauth2_scheme),
+):
+ admin = await _get_admin_from_request_credentials(request, db, token, with_metrics=True)
+
if not admin:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
diff --git a/app/routers/dependencies/__init__.py b/app/routers/dependencies/__init__.py
index f091ff4e6..809769f43 100644
--- a/app/routers/dependencies/__init__.py
+++ b/app/routers/dependencies/__init__.py
@@ -1,5 +1,6 @@
from .admin import get_admin_list_query, get_admin_simple_list_query, get_admin_usage_query
from .admin_role import get_admin_role_list_query
+from .api_key import get_api_key_list_query
from .client_template import get_client_template_list_query, get_client_template_simple_list_query
from .core import get_core_list_query, get_core_simple_list_query
from .group import get_group_list_query, get_group_simple_list_query
@@ -28,6 +29,8 @@
"get_admin_usage_query",
# admin_role
"get_admin_role_list_query",
+ # api_key
+ "get_api_key_list_query",
# client_template
"get_client_template_list_query",
"get_client_template_simple_list_query",
diff --git a/app/routers/dependencies/api_key.py b/app/routers/dependencies/api_key.py
new file mode 100644
index 000000000..a3f9e8b6f
--- /dev/null
+++ b/app/routers/dependencies/api_key.py
@@ -0,0 +1,16 @@
+from fastapi import Query
+
+from app.models.api_key import APIKeysQuery
+
+from ._common import make_query_dependency
+
+get_api_key_list_query = make_query_dependency(
+ APIKeysQuery,
+ field_overrides={
+ "offset": Query(None),
+ "limit": Query(None),
+ "key_id": Query(None),
+ "name": Query(None),
+ "status": Query(None),
+ },
+)
diff --git a/app/utils/crypto.py b/app/utils/crypto.py
index 3380cb94b..530d7582f 100644
--- a/app/utils/crypto.py
+++ b/app/utils/crypto.py
@@ -1,5 +1,7 @@
import base64
import binascii
+import hashlib
+import hmac
from cryptography import x509
from cryptography.hazmat.backends import default_backend
@@ -97,3 +99,39 @@ def generate_wireguard_keypair() -> tuple[str, str]:
base64.b64encode(private_key_bytes).decode("ascii"),
base64.b64encode(public_key_bytes).decode("ascii"),
)
+
+
+API_KEY_HASH_VERSION = "v1"
+API_KEY_SHA256_ALGORITHM = "sha256"
+API_KEY_LOOKUP_BYTES = 16
+
+
+def api_key_lookup_id(raw_api_key: str) -> str:
+ digest = hashlib.sha256(raw_api_key.encode("utf-8")).digest()[:API_KEY_LOOKUP_BYTES]
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
+
+
+def hash_api_key(raw_api_key: str) -> str:
+ lookup_id = api_key_lookup_id(raw_api_key)
+
+ hash_hex = _sha256_api_key_digest(raw_api_key)
+ return f"{API_KEY_HASH_VERSION}${lookup_id}${API_KEY_SHA256_ALGORITHM}${hash_hex}"
+
+
+def _sha256_api_key_digest(raw_api_key: str) -> str:
+ return hashlib.sha256(raw_api_key.encode("utf-8")).hexdigest()
+
+
+def verify_api_key(raw_api_key: str, stored_hash: str) -> bool:
+ parts = stored_hash.split("$")
+
+ if len(parts) != 4:
+ return False
+
+ version, lookup_id, algorithm, hash_hex = parts
+ if version != API_KEY_HASH_VERSION or algorithm != API_KEY_SHA256_ALGORITHM:
+ return False
+ if not hmac.compare_digest(lookup_id, api_key_lookup_id(raw_api_key)):
+ return False
+
+ return hmac.compare_digest(_sha256_api_key_digest(raw_api_key), hash_hex)
diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json
index 722b35950..a4f7a3f20 100644
--- a/dashboard/public/statics/locales/en.json
+++ b/dashboard/public/statics/locales/en.json
@@ -744,7 +744,54 @@
"monitor": {
"traffic": "Monitor admin traffic usage over time",
"no_traffic": "No traffic data available"
- }
+ },
+ "disabled": "Disabled"
+ },
+ "apiKeys": {
+ "title": "API Keys",
+ "description": "Manage API keys for programmatic access",
+ "createKey": "Create API Key",
+ "editKey": "Modify API Key",
+ "admin": "Admin",
+ "selectAdmin": "Select admin",
+ "adminDescription": "The key will authenticate as this admin.",
+ "name": "Name",
+ "key": "API Key",
+ "keyId": "Key ID",
+ "note": "Note",
+ "role": "Role",
+ "status": "Status",
+ "expireDate": "Expire Date",
+ "createdAt": "Created At",
+ "revokedAt": "Revoked At",
+ "revoke": "Revoke",
+ "delete": "Delete",
+ "apiKey": "API Key",
+ "apiKeyCopy": "Copy API Key",
+ "apiKeyCopySuccess": "API key copied to clipboard",
+ "apiKeyShowWarning": "This key will only be shown once. Please copy it now.",
+ "createSuccess": "API key created successfully",
+ "createFailed": "Failed to create API key",
+ "updateSuccess": "API key updated successfully",
+ "updateFailed": "Failed to update API key",
+ "revokeSuccess": "API key revoked and reissued successfully",
+ "revokeFailed": "Failed to revoke API key",
+ "deleteSuccess": "API key deleted successfully",
+ "deleteFailed": "Failed to delete API key",
+ "deletePrompt": "Are you sure you want to delete API key \"{{name}}\"?",
+ "revokePrompt": "Revoking will invalidate the current key and issue a new one. Are you sure?",
+ "revokeTitle": "Revoke & Reissue API Key",
+ "deleteTitle": "Delete API Key",
+ "inheritPermissions": "Inherit admin permissions",
+ "inheritPermissionsDescription": "Use the owning admin's current role permissions. Disable to store custom permissions on this key.",
+ "inherited": "Inherited",
+ "noKeys": "No API keys configured",
+ "noKeysDescription": "Create an API key to allow programmatic access.",
+ "noSearchResults": "No API keys match your search criteria. Try adjusting your search terms or filters.",
+ "bulkDeleteSuccess": "{{count}} API keys deleted successfully.",
+ "bulkDeleteFailed": "Failed to delete selected API keys.",
+ "bulkDeleteTitle": "Delete selected API keys",
+ "bulkDeletePrompt": "Are you sure you want to delete {{count}} selected API keys? This action cannot be undone."
},
"adminRoles": {
"title": "Admin Roles",
@@ -765,7 +812,7 @@
"limits": "Limits",
"limitsAndFeatures": "Limits & Features",
"limitsHint": "Leave empty to inherit defaults. Set to 0 to disable.",
- "roleFormHint": "Scoped user actions use none, own, or all. Other actions are boolean toggles.",
+ "roleFormHint": "Scoped actions use none, own, or all. Other actions are boolean toggles.",
"scopedActionInfo": "Choose ownership scope: none denies access, own limits to the admin's own users, all grants full access.",
"allowAll": "Allow all",
"features": "Features",
@@ -811,6 +858,7 @@
"users": "Users",
"admins": "Admins",
"roles": "Roles",
+ "apiKeys": "API Keys",
"nodes": "Nodes",
"coreHosts": "Core and hosts",
"groupsTemplates": "Groups and templates",
@@ -866,6 +914,7 @@
"users": "Users",
"admins": "Admins",
"admin_roles": "Roles",
+ "api_keys": "API Keys",
"nodes": "Nodes",
"cores": "Cores",
"hosts": "Hosts",
@@ -1143,7 +1192,9 @@
"header.nodeSettings": "Node Settings",
"header.nodesUsage": "Nodes Usage",
"loading": "Loading...",
+ "clear": "Clear",
"never": "Never",
+ "resources": "resources",
"noResults": "No results found",
"noAdminsFound": "No admins found",
"emptyState": {
@@ -3335,6 +3386,7 @@
"timeSelector.pickDate": "Pick a date",
"advanceSearch": {
"title": "Advanced Search",
+ "description": "Filter API keys by identifier and status.",
"searchMode": "Search mode",
"searchModeDescription": "Choose how the main search field should be interpreted.",
"byUsername": "Username and Notes",
diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json
index 392654359..1a8521189 100644
--- a/dashboard/public/statics/locales/fa.json
+++ b/dashboard/public/statics/locales/fa.json
@@ -481,7 +481,9 @@
"saving": "در حال ذخیره...",
"general": "عمومی",
"loading": "در حال بارگذاری...",
+ "clear": "پاک کردن",
"never": "هرگز",
+ "resources": "منابع",
"noResults": "نتیجهای یافت نشد",
"noAdminsFound": "مدیری یافت نشد",
"emptyState": {
@@ -611,7 +613,54 @@
"monitor": {
"traffic": "نظارت بر مصرف ترافیک مدیر در طول زمان",
"no_traffic": "دادهای برای ترافیک موجود نیست"
- }
+ },
+ "disabled": "???????"
+ },
+ "apiKeys": {
+ "title": "کلیدهای API",
+ "description": "مدیریت کلیدهای API برای دسترسی برنامهنویسی",
+ "createKey": "ایجاد کلید API",
+ "editKey": "ویرایش کلید API",
+ "admin": "مدیر",
+ "selectAdmin": "انتخاب مدیر",
+ "adminDescription": "این کلید با هویت این مدیر احراز هویت میشود.",
+ "name": "نام",
+ "key": "کلید API",
+ "keyId": "شناسه کلید",
+ "note": "یادداشت",
+ "role": "نقش",
+ "status": "وضعیت",
+ "expireDate": "تاریخ انقضا",
+ "createdAt": "تاریخ ایجاد",
+ "revokedAt": "تاریخ ابطال",
+ "revoke": "ابطال",
+ "delete": "حذف",
+ "apiKey": "کلید API",
+ "apiKeyCopy": "کپی کلید API",
+ "apiKeyCopySuccess": "کلید API در حافظه کپی شد",
+ "apiKeyShowWarning": "این کلید فقط یکبار نمایش داده میشود. لطفا همین الان آن را کپی کنید.",
+ "createSuccess": "کلید API با موفقیت ایجاد شد",
+ "createFailed": "ایجاد کلید API ناموفق بود",
+ "updateSuccess": "کلید API با موفقیت بروزرسانی شد",
+ "updateFailed": "بروزرسانی کلید API ناموفق بود",
+ "revokeSuccess": "کلید API با موفقیت ابطال و مجدداً صادر شد",
+ "revokeFailed": "ابطال کلید API ناموفق بود",
+ "deleteSuccess": "کلید API با موفقیت حذف شد",
+ "deleteFailed": "حذف کلید API ناموفق بود",
+ "deletePrompt": "آیا از حذف کلید API «{{name}}» اطمینان دارید؟",
+ "revokePrompt": "ابطال کلید باعث غیرفعال شدن کلید فعلی و صدور کلید جدید میشود. آیا مطمئن هستید؟",
+ "revokeTitle": "ابطال و صدور مجدد کلید API",
+ "deleteTitle": "حذف کلید API",
+ "inheritPermissions": "ارثبری مجوزهای مدیر",
+ "inheritPermissionsDescription": "از مجوزهای فعلی نقش مدیر مالک استفاده شود. برای ذخیره مجوزهای سفارشی روی این کلید غیرفعال کنید.",
+ "inherited": "ارثبریشده",
+ "noKeys": "هیچ کلید API تنظیم نشده است",
+ "noKeysDescription": "برای دسترسی برنامهنویسی یک کلید API ایجاد کنید.",
+ "noSearchResults": "هیچ کلید API با معیارهای جستوجوی شما مطابقت ندارد. عبارت جستوجو یا فیلترها را تغییر دهید.",
+ "bulkDeleteSuccess": "{{count}} کلید API با موفقیت حذف شد.",
+ "bulkDeleteFailed": "حذف کلیدهای API انتخابشده ناموفق بود.",
+ "bulkDeleteTitle": "حذف کلیدهای API انتخابشده",
+ "bulkDeletePrompt": "آیا از حذف {{count}} کلید API انتخابشده مطمئن هستید؟ این عملیات قابل بازگشت نیست."
},
"adminRoles": {
"title": "نقشهای مدیر",
@@ -632,7 +681,7 @@
"limits": "محدودیتها",
"limitsAndFeatures": "محدودیتها و قابلیتها",
"limitsHint": "برای استفاده از پیشفرض، خالی بگذارید. برای غیرفعالسازی ۰ بگذارید.",
- "roleFormHint": "اعمال کاربر دارای محدوده، مقدار «هیچ»، «خودی» یا «همه» میگیرند. سایر اعمال سوئیچ بولی هستند.",
+ "roleFormHint": "اعمال دارای محدوده، مقدار «هیچ»، «خودی» یا «همه» میگیرند. سایر اعمال سوئیچ بولی هستند.",
"scopedActionInfo": "محدوده مالکیت را انتخاب کنید: «هیچ» دسترسی را رد میکند، «خودی» فقط کاربران خود مدیر را شامل میشود و «همه» دسترسی کامل میدهد.",
"allowAll": "اجازه همه",
"features": "قابلیتها",
@@ -678,6 +727,7 @@
"users": "کاربران",
"admins": "مدیران",
"roles": "نقشها",
+ "apiKeys": "کلیدهای API",
"nodes": "گرهها",
"coreHosts": "هسته و هاستها",
"groupsTemplates": "گروهها و قالبها",
@@ -733,6 +783,7 @@
"users": "کاربران",
"admins": "مدیران",
"admin_roles": "نقشها",
+ "api_keys": "کلیدهای API",
"nodes": "گرهها",
"cores": "هستهها",
"hosts": "هاستها",
@@ -759,8 +810,7 @@
"max_hwid_per_user": "حداکثر HWID برای هر کاربر",
"on_hold_timeout_days_min": "حداقل زمان انتظار (روز)",
"on_hold_timeout_days_max": "حداکثر زمان انتظار (روز)"
- },
-
+ },
"limitedBehavior": {
"disabledWhenLimited": {
"title": "مسدود کردن مدیران محدودشده",
@@ -3309,6 +3359,7 @@
"timeSelector.pickDate": "انتخاب تاریخ",
"advanceSearch": {
"title": "جستجوی پیشرفته",
+ "description": "فیلتر کردن کلیدهای API بر اساس شناسه و وضعیت.",
"searchMode": "حالت جستوجو",
"searchModeDescription": "مشخص کنید فیلد جستوجوی اصلی چگونه تفسیر شود.",
"byUsername": "نام کاربری و یادداشتها",
diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json
index 0cec30c9e..d423b5126 100644
--- a/dashboard/public/statics/locales/ru.json
+++ b/dashboard/public/statics/locales/ru.json
@@ -730,7 +730,54 @@
"monitor": {
"traffic": "Мониторинг использования трафика администратором во времени",
"no_traffic": "Данные о трафике отсутствуют"
- }
+ },
+ "disabled": "????????"
+ },
+ "apiKeys": {
+ "title": "API ключи",
+ "description": "Управление API ключами для программного доступа",
+ "createKey": "Создать API ключ",
+ "editKey": "Изменить API ключ",
+ "admin": "Админ",
+ "selectAdmin": "Выберите админа",
+ "adminDescription": "Ключ будет выполнять аутентификацию от имени этого админа.",
+ "name": "Название",
+ "key": "API ключ",
+ "keyId": "ID ключа",
+ "note": "Заметка",
+ "role": "Роль",
+ "status": "Статус",
+ "expireDate": "Дата истечения",
+ "createdAt": "Дата создания",
+ "revokedAt": "Дата отзыва",
+ "revoke": "Отозвать",
+ "delete": "Удалить",
+ "apiKey": "API ключ",
+ "apiKeyCopy": "Копировать API ключ",
+ "apiKeyCopySuccess": "API ключ скопирован в буфер обмена",
+ "apiKeyShowWarning": "Этот ключ будет показан только один раз. Пожалуйста, скопируйте его сейчас.",
+ "createSuccess": "API ключ успешно создан",
+ "createFailed": "Не удалось создать API ключ",
+ "updateSuccess": "API ключ успешно обновлен",
+ "updateFailed": "Не удалось обновить API ключ",
+ "revokeSuccess": "API ключ успешно отозван и перевыпущен",
+ "revokeFailed": "Не удалось отозвать API ключ",
+ "deleteSuccess": "API ключ успешно удален",
+ "deleteFailed": "Не удалось удалить API ключ",
+ "deletePrompt": "Вы уверены, что хотите удалить API ключ «{{name}}»?",
+ "revokePrompt": "Отзыв сделает текущий ключ недействительным и выпустит новый. Вы уверены?",
+ "revokeTitle": "Отозвать и перевыпустить API ключ",
+ "deleteTitle": "Удалить API ключ",
+ "inheritPermissions": "Наследовать права админа",
+ "inheritPermissionsDescription": "Использовать текущие права роли владельца ключа. Отключите, чтобы сохранить на этом ключе собственные права.",
+ "inherited": "Наследуется",
+ "noKeys": "API ключи не настроены",
+ "noKeysDescription": "Создайте API ключ для программного доступа.",
+ "noSearchResults": "Нет API ключей, соответствующих условиям поиска. Измените поисковый запрос или фильтры.",
+ "bulkDeleteSuccess": "{{count}} API ключей успешно удалено.",
+ "bulkDeleteFailed": "Не удалось удалить выбранные API ключи.",
+ "bulkDeleteTitle": "Удалить выбранные API ключи",
+ "bulkDeletePrompt": "Вы уверены, что хотите удалить выбранные API ключи ({{count}})? Это действие нельзя отменить."
},
"adminRoles": {
"title": "Роли администраторов",
@@ -797,6 +844,7 @@
"users": "Пользователи",
"admins": "Админы",
"roles": "Роли",
+ "apiKeys": "API Ключи",
"nodes": "Узлы",
"coreHosts": "Ядра и хосты",
"groupsTemplates": "Группы и шаблоны",
@@ -852,6 +900,7 @@
"users": "Пользователи",
"admins": "Админы",
"admin_roles": "Роли",
+ "api_keys": "API ключи",
"nodes": "Узлы",
"cores": "Ядра",
"hosts": "Хосты",
@@ -3275,6 +3324,7 @@
"timeSelector.pickDate": "Выбрать дату",
"advanceSearch": {
"title": "Расширенный поиск",
+ "description": "Фильтрация API ключей по идентификатору и статусу.",
"searchMode": "Режим поиска",
"searchModeDescription": "Определяет, как интерпретируется основное поле поиска.",
"byUsername": "Имя пользователя и заметки",
@@ -3328,7 +3378,9 @@
"clearAllFilters": "Очистить все фильтры",
"activeFilters": "активных",
"loading": "Загрузка...",
+ "clear": "Очистить",
"never": "Никогда",
+ "resources": "ресурсы",
"all": "Все",
"hosts.filters.userStatus": "Правила статуса пользователя",
"hosts.filters.userStatusDescription": "Фильтрует хосты по заданным статусам пользователей (активен, на паузе, ограничен и т.д.).",
diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json
index 1b5dc65f3..c57c1faec 100644
--- a/dashboard/public/statics/locales/zh.json
+++ b/dashboard/public/statics/locales/zh.json
@@ -744,7 +744,54 @@
"monitor": {
"traffic": "监控管理员使用情况",
"no_traffic": "没有使用情况数据"
- }
+ },
+ "disabled": "??"
+ },
+ "apiKeys": {
+ "title": "API 密钥",
+ "description": "管理用于编程访问的 API 密钥",
+ "createKey": "创建 API 密钥",
+ "editKey": "修改 API 密钥",
+ "admin": "管理员",
+ "selectAdmin": "选择管理员",
+ "adminDescription": "此密钥将以该管理员身份进行认证。",
+ "name": "名称",
+ "key": "API 密钥",
+ "keyId": "密钥 ID",
+ "note": "备注",
+ "role": "角色",
+ "status": "状态",
+ "expireDate": "过期日期",
+ "createdAt": "创建于",
+ "revokedAt": "吊销于",
+ "revoke": "吊销",
+ "delete": "删除",
+ "apiKey": "API 密钥",
+ "apiKeyCopy": "复制 API 密钥",
+ "apiKeyCopySuccess": "API 密钥已复制到剪贴板",
+ "apiKeyShowWarning": "此密钥仅显示一次。请立即复制。",
+ "createSuccess": "API 密钥创建成功",
+ "createFailed": "创建 API 密钥失败",
+ "updateSuccess": "API 密钥更新成功",
+ "updateFailed": "更新 API 密钥失败",
+ "revokeSuccess": "API 密钥已成功吊销并重新签发",
+ "revokeFailed": "吊销 API 密钥失败",
+ "deleteSuccess": "API 密钥删除成功",
+ "deleteFailed": "删除 API 密钥失败",
+ "deletePrompt": "您确定要删除 API 密钥「{{name}}」吗?",
+ "revokePrompt": "吊销将使当前密钥失效并签发新密钥。您确定吗?",
+ "revokeTitle": "吊销并重新签发 API 密钥",
+ "deleteTitle": "删除 API 密钥",
+ "inheritPermissions": "继承管理员权限",
+ "inheritPermissionsDescription": "使用拥有者管理员当前角色的权限。关闭后将在此密钥上保存自定义权限。",
+ "inherited": "已继承",
+ "noKeys": "未配置 API 密钥",
+ "noKeysDescription": "创建 API 密钥以允许程序化访问。",
+ "noSearchResults": "没有 API 密钥符合搜索条件。请调整搜索词或筛选器。",
+ "bulkDeleteSuccess": "已成功删除 {{count}} 个 API 密钥。",
+ "bulkDeleteFailed": "删除所选 API 密钥失败。",
+ "bulkDeleteTitle": "删除所选 API 密钥",
+ "bulkDeletePrompt": "确定要删除选中的 {{count}} 个 API 密钥吗?此操作无法撤销。"
},
"adminRoles": {
"title": "管理员角色",
@@ -811,6 +858,7 @@
"users": "用户",
"admins": "管理员",
"roles": "角色",
+ "apiKeys": "API 密钥",
"nodes": "节点",
"coreHosts": "核心和主机",
"groupsTemplates": "组和模板",
@@ -866,6 +914,7 @@
"users": "用户",
"admins": "管理员",
"admin_roles": "角色",
+ "api_keys": "API 密钥",
"nodes": "节点",
"cores": "核心",
"hosts": "主机",
@@ -3119,7 +3168,9 @@
},
"online": "在线",
"loading": "加载中...",
+ "clear": "清除",
"never": "从未",
+ "resources": "资源",
"noResults": "未找到结果",
"noAdminsFound": "未找到管理员",
"emptyState": {
@@ -3332,6 +3383,7 @@
"timeSelector.pickDate": "选择日期",
"advanceSearch": {
"title": "高级搜索",
+ "description": "按标识符和状态筛选 API 密钥。",
"searchMode": "搜索模式",
"searchModeDescription": "选择如何解释主搜索框中的内容。",
"byUsername": "用户名和备注",
diff --git a/dashboard/src/app/router.tsx b/dashboard/src/app/router.tsx
index f2314b1b9..c958fd752 100644
--- a/dashboard/src/app/router.tsx
+++ b/dashboard/src/app/router.tsx
@@ -15,6 +15,7 @@ const DashboardLayout = lazyWithChunkRecovery(() => import('../pages/_dashboard'
const Dashboard = lazyWithChunkRecovery(() => import('../pages/_dashboard._index'))
const AdminsPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.admins'))
const AdminRolesPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.admin-roles'))
+const ApiKeysPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.api-keys'))
const BulkPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk'))
const BulkCreatePage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk.create'))
const BulkDataPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk.data'))
@@ -223,6 +224,14 @@ export const router = createHashRouter([
),
},
+ {
+ path: '/api-keys',
+ element: (
+ {t('adminRoles.roleFormHint', { defaultValue: 'Scoped actions use none, own, or all. Other actions are boolean toggles.' })}
+ {PERMISSION_GROUPS.map(group => { + const enabledInGroup = group.actions.reduce((acc, item) => { + const value = permissions?.[item.resource]?.[item.action] + if (value === true) return acc + 1 + if (value && typeof value === 'object' && Number((value as any).scope) > 0) return acc + 1 + return acc + }, 0) + + const groupLabel = t(`adminRoles.groups.${group.labelKey}`) + const showResourcePrefix = group.actions.some((a, _, all) => all.some(b => b !== a && b.action === a.action && b.resource !== a.resource)) + + return ( +{t('adminRoles.roleFormHint', { defaultValue: 'Scoped user actions use none, own, or all. Other actions are boolean toggles.' })}
- {PERMISSION_GROUPS.map(group => { - const enabledInGroup = group.actions.reduce((acc, item) => { - const value = permissions?.[item.resource]?.[item.action] - if (value === true) return acc + 1 - if (value && typeof value === 'object' && Number((value as any).scope) > 0) return acc + 1 - return acc - }, 0) - - const groupLabel = t(`adminRoles.groups.${group.labelKey}`) - const showResourcePrefix = group.actions.some((a, _, all) => all.some(b => b !== a && b.action === a.action && b.resource !== a.resource)) - - return ( -{apiKey.api_key_trimmed}
+ ) : (
+ -
+ )}
+ {apiKey.api_key_trimmed}
+ ) : null}
+ {apiKey.api_key_trimmed}
+ ) : (
+ -
+ ),
+ },
+ {
+ id: 'permissions',
+ header: t('adminRoles.permissions', { defaultValue: 'Permissions' }),
+ width: 'minmax(9rem, 1.2fr)',
+ hideOnMobile: true,
+ skeletonClassName: 'w-28',
+ cell: apiKey => {row.original.api_key_trimmed}
+ ) : null}
+ {trimmed} : -
+ },
+ },
+ {
+ accessorKey: 'permissions',
+ header: t('adminRoles.permissions', { defaultValue: 'Permissions' }),
+ cell: ({ row }) => {
+ const permissions = row.original.permissions as RolePermissions | undefined
+ const count = countEnabledPermissions(permissions as RolePermissionFormMap | undefined)
+ const resourceCount = countEnabledResources(permissions)
+ if (row.original.inherit_permissions) {
+ return (
+ + {t('apiKeys.noKeysDescription', { defaultValue: 'Create an API key to allow programmatic access.' })} +
++ {t('apiKeys.noSearchResults', { defaultValue: 'No API keys match your search criteria. Try adjusting your search terms or filters.' })} +
+