diff --git a/app/db/crud/admin.py b/app/db/crud/admin.py index 45c5e405f..4a69a2840 100644 --- a/app/db/crud/admin.py +++ b/app/db/crud/admin.py @@ -12,7 +12,7 @@ get_complete_period_start_for_filter, to_utc_for_filter, ) -from app.db.models import Admin, AdminNotificationReminder, AdminRole, AdminUsageLogs, NodeUserUsage, ReminderType, User +from app.db.models import APIKey, Admin, AdminNotificationReminder, AdminRole, AdminUsageLogs, NodeUserUsage, ReminderType, User from app.models.admin import ( AdminCreate, AdminDetails, @@ -235,6 +235,7 @@ async def remove_admin(db: AsyncSession, dbadmin: Admin) -> None: db (AsyncSession): Database session. dbadmin (Admin): The admin object to be removed. """ + await db.execute(delete(APIKey).where(APIKey.admin_id == dbadmin.id)) await db.delete(dbadmin) await db.commit() @@ -771,6 +772,7 @@ async def remove_admins(db: AsyncSession, admin_ids: list[int]) -> None: return await db.execute(update(User).where(User.admin_id.in_(admin_ids)).values(admin_id=None)) + await db.execute(delete(APIKey).where(APIKey.admin_id.in_(admin_ids))) await db.execute(delete(AdminUsageLogs).where(AdminUsageLogs.admin_id.in_(admin_ids))) await db.execute(delete(Admin).where(Admin.id.in_(admin_ids))) await db.commit() diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py new file mode 100644 index 000000000..a78134387 --- /dev/null +++ b/app/db/crud/api_key.py @@ -0,0 +1,125 @@ +import uuid +from datetime import datetime as dt, timezone as tz + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Admin, AdminStatus, APIKey, APIKeyStatus +from app.models.api_key import APIKeyCreate +from app.utils.crypto import api_key_lookup_id, hash_api_key, verify_api_key + + +async def create_api_key( + db: AsyncSession, + admin_id: int, + model: APIKeyCreate, +) -> tuple[str, APIKey]: + raw_uuid = str(uuid.uuid4()) + raw_key = f"pg_key_{raw_uuid}" + db_key = APIKey( + admin_id=admin_id, + permissions={} if model.inherit_permissions else model.permissions.model_dump(exclude_none=True), + inherit_permissions=model.inherit_permissions, + name=model.name, + note=model.note, + key_hash=hash_api_key(raw_key), + api_key_trimmed=f"pg_key_{raw_uuid[:3]}***{raw_uuid[-3:]}", + expire_date=model.expire_date, + ) + db.add(db_key) + await db.flush() + await db.refresh(db_key) + return raw_key, db_key + + +async def get_api_key_by_raw_key(db: AsyncSession, raw_api_key: str) -> APIKey | None: + lookup_id = api_key_lookup_id(raw_api_key) + + stmt = ( + select(APIKey) + .where( + APIKey.key_hash.startswith(f"v1${lookup_id}$"), + APIKey.status != APIKeyStatus.disabled, + ) + .options(selectinload(APIKey.admin).selectinload(Admin.role)) + .limit(1) + ) + db_key = (await db.execute(stmt)).scalar_one_or_none() + + if db_key is None or not verify_api_key(raw_api_key, db_key.key_hash): + return None + # Reject if the owning admin is disabled + if db_key.admin is not None and db_key.admin.status == AdminStatus.disabled: + return None + return db_key + + +async def get_api_key_by_id(db: AsyncSession, key_id: int) -> APIKey | None: + stmt = select(APIKey).where(APIKey.id == key_id).options(selectinload(APIKey.admin)) + return (await db.execute(stmt)).scalar_one_or_none() + + +async def get_api_keys_by_ids(db: AsyncSession, key_ids: list[int]) -> list[APIKey]: + if not key_ids: + return [] + + stmt = select(APIKey).where(APIKey.id.in_(key_ids)).options(selectinload(APIKey.admin)) + return list((await db.execute(stmt)).scalars().all()) + + +async def get_api_keys( + db: AsyncSession, + *, + admin_id: int | None, + offset: int, + limit: int, + key_id: int | None = None, + name: str | None = None, + status: APIKeyStatus | None = None, +) -> tuple[list[APIKey], int]: + stmt = select(APIKey) + if admin_id is not None: + stmt = stmt.where(APIKey.admin_id == admin_id) + if key_id is not None: + stmt = stmt.where(APIKey.id == key_id) + if name is not None: + stmt = stmt.where(APIKey.name == name) + if status is not None: + if status == APIKeyStatus.active: + # active = stored status is active AND not past expire_date + stmt = stmt.where(APIKey.status == APIKeyStatus.active, ~APIKey.is_expired) + else: + # disabled = stored status is disabled (expire_date irrelevant) + stmt = stmt.where(APIKey.status == APIKeyStatus.disabled) + + total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0 + + stmt = stmt.order_by(APIKey.created_at.desc()).offset(offset).limit(limit) + rows = list((await db.execute(stmt)).scalars().all()) + return rows, total + + +async def delete_api_key(db: AsyncSession, db_key: APIKey) -> None: + await db.delete(db_key) + await db.flush() + + +async def revoke_api_key(db: AsyncSession, db_key: APIKey) -> tuple[str, APIKey]: + raw_uuid = str(uuid.uuid4()) + raw_key = f"pg_key_{raw_uuid}" + db_key.key_hash = hash_api_key(raw_key) + db_key.api_key_trimmed = f"pg_key_{raw_uuid[:3]}***{raw_uuid[-3:]}" + db_key.revoked_at = dt.now(tz.utc) + db_key.status = APIKeyStatus.active + await db.flush() + await db.refresh(db_key) + return raw_key, db_key + + +async def update_api_key(db: AsyncSession, db_key: APIKey, update_data: dict) -> APIKey: + for key, value in update_data.items(): + setattr(db_key, key, value) + await db.flush() + await db.refresh(db_key) + return db_key diff --git a/app/db/migrations/versions/9aa99aaee80f_.py b/app/db/migrations/versions/9aa99aaee80f_.py new file mode 100644 index 000000000..73d7a5a17 --- /dev/null +++ b/app/db/migrations/versions/9aa99aaee80f_.py @@ -0,0 +1,24 @@ +"""empty message + +Revision ID: 9aa99aaee80f +Revises: c9b48df42f10, d4f8c1b2a9e3 +Create Date: 2026-06-19 10:09:22.055892 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9aa99aaee80f' +down_revision = ('c9b48df42f10', 'd4f8c1b2a9e3') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app/db/migrations/versions/b6c9d0e1f2a3_api_key_inherit_permissions.py b/app/db/migrations/versions/b6c9d0e1f2a3_api_key_inherit_permissions.py new file mode 100644 index 000000000..51055146a --- /dev/null +++ b/app/db/migrations/versions/b6c9d0e1f2a3_api_key_inherit_permissions.py @@ -0,0 +1,66 @@ +"""api key inherit permissions + +Revision ID: b6c9d0e1f2a3 +Revises: 9aa99aaee80f +Create Date: 2026-06-19 20:30:00.000000 + +""" + +import json + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "b6c9d0e1f2a3" +down_revision = "9aa99aaee80f" +branch_labels = None +depends_on = None + + +def _normalize_permissions(value): + if value is None: + return {} + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return {} + if isinstance(value, dict): + return value + return {} + + +def upgrade() -> None: + with op.batch_alter_table("api_keys", schema=None) as batch_op: + batch_op.add_column(sa.Column("inherit_permissions", sa.Boolean(), nullable=False, server_default="1")) + + conn = op.get_bind() + admin_roles = sa.table( + "admin_roles", + sa.column("id", sa.Integer), + sa.column("permissions", sa.JSON), + ) + + rows = conn.execute(sa.select(admin_roles.c.id, admin_roles.c.permissions)).fetchall() + for role_id, role_permissions in rows: + permissions = _normalize_permissions(role_permissions) + api_key_permissions = permissions.get("api_keys") + if not isinstance(api_key_permissions, dict): + continue + + changed = False + for action in ("read", "read_simple", "update", "delete"): + if api_key_permissions.get(action) is True: + api_key_permissions[action] = {"scope": 2} + changed = True + + if changed: + permissions["api_keys"] = api_key_permissions + conn.execute(admin_roles.update().where(admin_roles.c.id == role_id).values(permissions=permissions)) + + +def downgrade() -> None: + with op.batch_alter_table("api_keys", schema=None) as batch_op: + batch_op.drop_column("inherit_permissions") diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py new file mode 100644 index 000000000..280217e78 --- /dev/null +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -0,0 +1,111 @@ +"""add api keys table + +Revision ID: c9b48df42f10 +Revises: f9c69a49f544 +Create Date: 2026-05-25 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +import app.db.compiles_types +import json + + +# revision identifiers, used by Alembic. +revision = "c9b48df42f10" +down_revision = "f9c69a49f544" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "api_keys", + sa.Column("id", app.db.compiles_types.SqliteCompatibleBigInteger(), autoincrement=True, nullable=False), + sa.Column("admin_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("note", sa.String(length=512), nullable=True), + sa.Column("key_hash", sa.String(length=128), nullable=False), + sa.Column("api_key_trimmed", sa.String(length=16), nullable=False), + sa.Column("permissions", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expire_date", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "status", + sa.Enum("active", "disabled", name="apikeystatus"), + nullable=False, + server_default="active", + ), + sa.ForeignKeyConstraint( + ["admin_id"], ["admins.id"], name=op.f("fk_api_keys_admin_id_admins"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_api_keys")), + sa.UniqueConstraint("key_hash", name=op.f("uq_api_keys_key_hash")), + sa.UniqueConstraint("admin_id", "name", name=op.f("uq_api_keys_admin_id")), + ) + with op.batch_alter_table("api_keys", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_api_keys_admin_id"), ["admin_id"], unique=False) + batch_op.create_index(batch_op.f("ix_api_keys_created_at"), ["created_at"], unique=False) + batch_op.create_index(batch_op.f("ix_api_keys_expire_date"), ["expire_date"], unique=False) + + # Update admin_roles permissions to include api_keys entry + OWNER_ADMIN_API_KEY_PERMS = { + "create": True, + "read": {"scope": 2}, + "read_simple": {"scope": 2}, + "update": {"scope": 2}, + "delete": {"scope": 2}, + } + OPERATOR_API_KEY_PERMS = { + "read": {"scope": 1}, + "read_simple": {"scope": 1}, + "update": {"scope": 1}, + "delete": {"scope": 1}, + } + + + def _normalize_permissions(value): + if value is None: + return {} + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return {} + if isinstance(value, dict): + return value + return {} + + conn = op.get_bind() + admin_roles = sa.table( + "admin_roles", + sa.column("id", sa.Integer), + sa.column("name", sa.String), + sa.column("permissions", sa.JSON), + ) + + rows = conn.execute(sa.select(admin_roles.c.id, admin_roles.c.name, admin_roles.c.permissions)).fetchall() + for role_id, role_name, role_permissions in rows: + permissions = _normalize_permissions(role_permissions) + if "api_keys" in permissions: + continue + if role_name in {"owner", "administrator"}: + permissions["api_keys"] = OWNER_ADMIN_API_KEY_PERMS + else: + permissions["api_keys"] = OPERATOR_API_KEY_PERMS + conn.execute(admin_roles.update().where(admin_roles.c.id == role_id).values(permissions=permissions)) + + +def downgrade() -> None: + with op.batch_alter_table("api_keys", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_api_keys_expire_date")) + batch_op.drop_index(batch_op.f("ix_api_keys_created_at")) + batch_op.drop_index(batch_op.f("ix_api_keys_admin_id")) + + op.drop_table("api_keys") + + conn = op.get_bind() + if conn.dialect.name == "postgresql": + op.execute("DROP TYPE IF EXISTS apikeystatus") diff --git a/app/db/models.py b/app/db/models.py index 133408094..3be7f7a75 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -84,6 +84,9 @@ class Admin(Base, CreatedAtUTCMixin): notification_reminders: Mapped[List["AdminNotificationReminder"]] = relationship( back_populates="admin", init=False, default_factory=list, cascade="all, delete-orphan" ) + api_keys: Mapped[List["APIKey"]] = relationship( + back_populates="admin", init=False, default_factory=list, cascade="all, delete-orphan" + ) password_reset_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) telegram_id: Mapped[Optional[int]] = mapped_column(BigInteger, default=None) @@ -152,6 +155,11 @@ def users_sync_blocked(self) -> bool: def total_users(self) -> int: return len(self.users) + @property + def has_api_keys(self) -> bool: + """True when the admin owns at least one API key.""" + return len(self.api_keys) > 0 + class AdminUsageLogs(Base, IdMixin): __tablename__ = "admin_usage_logs" @@ -889,6 +897,59 @@ def is_builtin(cls): return cls.id <= 3 +class APIKeyStatus(str, Enum): + active = "active" + disabled = "disabled" + + +class APIKey(Base, CreatedAtUTCMixin): + __tablename__ = "api_keys" + __table_args__ = ( + UniqueConstraint("key_hash"), + UniqueConstraint("admin_id", "name"), + Index("ix_api_keys_admin_id", "admin_id"), + Index("ix_api_keys_created_at", "created_at"), + Index("ix_api_keys_expire_date", "expire_date"), + ) + + admin_id: Mapped[int] = fk_id_column("admins.id", ondelete="CASCADE") + admin: Mapped["Admin"] = relationship(back_populates="api_keys", init=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + key_hash: Mapped[str] = mapped_column(String(128), nullable=False) + api_key_trimmed: Mapped[str] = mapped_column(String(16)) + permissions: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict) + inherit_permissions: Mapped[bool] = mapped_column(default=True, server_default="1") + note: Mapped[Optional[str]] = mapped_column(String(512), default=None) + expire_date: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + revoked_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + status: Mapped[APIKeyStatus] = mapped_column( + SQLEnum(APIKeyStatus, name="apikeystatus", create_constraint=True), + default=APIKeyStatus.active, + server_default="active", + ) + + @hybrid_property + def is_expired(self) -> bool: + """True when expire_date is set and is in the past.""" + if self.expire_date is None: + return False + expire_at = self.expire_date if self.expire_date.tzinfo else self.expire_date.replace(tzinfo=tz.utc) + return expire_at <= dt.now(tz.utc) + + @is_expired.expression + def is_expired(cls): + return and_(cls.expire_date.isnot(None), cls.expire_date <= func.current_timestamp()) + + @property + def is_usable(self) -> bool: + """False if the key is disabled, its admin is missing/disabled, or it has expired.""" + if self.status == APIKeyStatus.disabled: + return False + if self.admin is None or self.admin.status == AdminStatus.disabled: + return False + return not self.is_expired + + class TempKey(Base): __tablename__ = "temp_keys" diff --git a/app/models/admin_role.py b/app/models/admin_role.py index 9a8800330..04216a623 100644 --- a/app/models/admin_role.py +++ b/app/models/admin_role.py @@ -70,6 +70,10 @@ class NodesPermissions(CRUDPermissions): stats: RoleActionValue | None = None +class APIKeysPermissions(CRUDPermissions): + create: bool | None = None + + class HostsPermissions(_ResourcePermissions): create: RoleActionValue | None = None read: RoleActionValue | None = None @@ -138,6 +142,7 @@ class RolePermissions(BaseModel): system: SystemPermissions | None = None hwids: HwidsPermissions | None = None admin_roles: CRUDPermissions | None = None + api_keys: APIKeysPermissions | None = None model_config = ConfigDict(from_attributes=True) diff --git a/app/models/api_key.py b/app/models/api_key.py new file mode 100644 index 000000000..5266704ca --- /dev/null +++ b/app/models/api_key.py @@ -0,0 +1,108 @@ +from datetime import datetime as dt, timezone as tz +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.db.models import APIKeyStatus +from app.models.admin_role import RolePermissions +from app.utils.helpers import fix_datetime_timezone + +from .validators import ListValidator + + +class APIKeyBase(BaseModel): + name: str = Field(min_length=1, max_length=128) + note: str | None = Field(default=None, max_length=512) + permissions: RolePermissions = Field(default_factory=RolePermissions) + inherit_permissions: bool = True + expire_date: dt | None = None + + model_config = ConfigDict(from_attributes=True) + + @field_validator("permissions", mode="before") + @classmethod + def validate_permissions(cls, value): + return value or RolePermissions() + + +class APIKeyCreate(APIKeyBase): + admin_id: int | None = Field(default=None, ge=1) + + @field_validator("expire_date", mode="before") + @classmethod + def validate_expire_date(cls, value): + if value is None: + return None + parsed = fix_datetime_timezone(value) + if parsed <= dt.now(tz.utc): + raise ValueError("expire_date must be in the future") + return parsed + + +class APIKeyUpdate(BaseModel): + admin_id: int | None = Field(default=None, ge=1) + name: str | None = Field(default=None, min_length=1, max_length=128) + note: str | None = Field(default=None, max_length=512) + permissions: RolePermissions | None = None + inherit_permissions: bool | None = None + expire_date: dt | None = None + status: APIKeyStatus | None = None + + @field_validator("expire_date", mode="before") + @classmethod + def validate_expire_date(cls, value): + if value is None: + return None + parsed = fix_datetime_timezone(value) + if parsed <= dt.now(tz.utc): + raise ValueError("expire_date must be in the future") + return parsed + + +class APIKeyResponse(APIKeyBase): + id: int + admin_id: int + created_at: dt + api_key_trimmed: str + revoked_at: dt | None = None + status: APIKeyStatus = APIKeyStatus.active + is_expired: bool = False + + +class APIKeyCreateResponse(APIKeyResponse): + api_key: str + + +class APIKeysResponse(BaseModel): + api_keys: list[APIKeyResponse] + total: int + + +class BulkAPIKeySelection(BaseModel): + """Model for bulk API key selection by IDs.""" + + ids: set[int] = Field(default_factory=set) + + @field_validator("ids", mode="after") + @classmethod + def ids_validator(cls, v): + return ListValidator.not_null_list(list(v), "API key") + + +class RemoveAPIKeysResponse(BaseModel): + """Response model for bulk API key deletion.""" + + api_keys: list[str] + count: int + + +Offset = Annotated[int, Field(default=0, ge=0)] +Limit = Annotated[int, Field(default=50, ge=1, le=200)] + + +class APIKeysQuery(BaseModel): + offset: Offset = 0 + limit: Limit = 50 + key_id: int | None = Field(default=None, ge=1) + name: str | None = Field(default=None, min_length=1, max_length=128) + status: APIKeyStatus | None = None diff --git a/app/models/notification_enable.py b/app/models/notification_enable.py index 27b82507e..03afc0dc5 100644 --- a/app/models/notification_enable.py +++ b/app/models/notification_enable.py @@ -49,5 +49,6 @@ class NotificationEnable(BaseModel): node: NodeNotificationEnable = Field(default_factory=NodeNotificationEnable) user: UserNotificationEnable = Field(default_factory=UserNotificationEnable) user_template: BaseNotificationEnable = Field(default_factory=BaseNotificationEnable) + api_key: BaseNotificationEnable = Field(default_factory=BaseNotificationEnable) days_left: bool = Field(default=True) percentage_reached: bool = Field(default=True) diff --git a/app/models/settings.py b/app/models/settings.py index a558f544d..cea47f7eb 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -139,6 +139,7 @@ class NotificationChannels(BaseModel): node: NotificationChannel = Field(default_factory=NotificationChannel) user: NotificationChannel = Field(default_factory=NotificationChannel) user_template: NotificationChannel = Field(default_factory=NotificationChannel) + api_key: NotificationChannel = Field(default_factory=NotificationChannel) class NotificationSettings(BaseModel): diff --git a/app/notification/__init__.py b/app/notification/__init__.py index 87bba532d..95c9eaa6b 100644 --- a/app/notification/__init__.py +++ b/app/notification/__init__.py @@ -7,6 +7,7 @@ from app.models.group import GroupResponse from app.models.host import BaseHost from app.models.node import NodeNotification, NodeResponse +from app.models.api_key import APIKeyResponse from app.models.user import UserNotificationResponse from app.models.user_template import UserTemplateResponse from app.settings import notification_enable @@ -47,6 +48,27 @@ async def wrapper(*args, **kwargs): return wrapper +async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + if (await notification_enable()).api_key.create: + await asyncio.gather( + ds.create_api_key(api_key, admin_username, by), tg.create_api_key_tg(api_key, admin_username, by) + ) + + +async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + if (await notification_enable()).api_key.modify: + await asyncio.gather( + ds.modify_api_key(api_key, admin_username, by), tg.modify_api_key_tg(api_key, admin_username, by) + ) + + +async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + if (await notification_enable()).api_key.delete: + await asyncio.gather( + ds.remove_api_key(api_key, admin_username, by), tg.remove_api_key_tg(api_key, admin_username, by) + ) + + async def create_admin_role(role: AdminRoleResponse, by: str): if (await notification_enable()).admin_role.create: await _gather_notifications("create_admin_role", ds.create_admin_role(role, by), tg.create_admin_role(role, by)) diff --git a/app/notification/discord/__init__.py b/app/notification/discord/__init__.py index dccef7238..ea98eab0c 100644 --- a/app/notification/discord/__init__.py +++ b/app/notification/discord/__init__.py @@ -1,5 +1,10 @@ from .admin import admin_login, admin_reset_usage, admin_usage_limit_reached, create_admin, modify_admin, remove_admin from .admin_role import create_admin_role, modify_admin_role, remove_admin_role +from .api_key import ( + create_api_key, + modify_api_key, + remove_api_key, +) from .core import create_core, modify_core, remove_core from .group import create_group, modify_group, remove_group from .host import create_host, modify_host, modify_hosts, remove_host @@ -28,6 +33,9 @@ "create_admin_role", "modify_admin_role", "remove_admin_role", + "create_api_key", + "modify_api_key", + "remove_api_key", "create_host", "modify_host", "remove_host", diff --git a/app/notification/discord/api_key.py b/app/notification/discord/api_key.py new file mode 100644 index 000000000..479b6eaa8 --- /dev/null +++ b/app/notification/discord/api_key.py @@ -0,0 +1,66 @@ +from app.models.api_key import APIKeyResponse +from app.models.settings import NotificationSettings +from app.notification.client import send_discord_webhook +from app.notification.helpers import get_discord_webhook +from app.settings import notification_settings + +from . import colors, messages + +ENTITY = "api_key" + + +async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + data = messages.CREATE_API_KEY.copy() + data["description"] = data["description"].format( + name=api_key.name, + expire_date=api_key.expire_date or "Never", + ) + data["footer"]["text"] = data["footer"]["text"].format( + id=api_key.id, + admin_username=admin_username, + by=by, + ) + data["color"] = colors.GREEN + + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook_url = get_discord_webhook(settings, ENTITY) + await send_discord_webhook(data, webhook_url) + + +async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + data = messages.MODIFY_API_KEY.copy() + data["description"] = data["description"].format( + name=api_key.name, + expire_date=api_key.expire_date or "Never", + status=api_key.status.value, + ) + data["footer"]["text"] = data["footer"]["text"].format( + id=api_key.id, + admin_username=admin_username, + by=by, + ) + data["color"] = colors.BLUE + + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook_url = get_discord_webhook(settings, ENTITY) + await send_discord_webhook(data, webhook_url) + + +async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + data = messages.REMOVE_API_KEY.copy() + data["description"] = data["description"].format( + name=api_key.name, + ) + data["footer"]["text"] = data["footer"]["text"].format( + id=api_key.id, + admin_username=admin_username, + by=by, + ) + data["color"] = colors.RED + + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook_url = get_discord_webhook(settings, ENTITY) + await send_discord_webhook(data, webhook_url) diff --git a/app/notification/discord/messages.py b/app/notification/discord/messages.py index e9fa4110b..0f956023a 100644 --- a/app/notification/discord/messages.py +++ b/app/notification/discord/messages.py @@ -297,3 +297,21 @@ "description": "**Name:** {name}\n", "footer": {"text": "ID: {id}\nBy: {by}"}, } + +CREATE_API_KEY = { + "title": "🆕 Create API Key", + "description": "**Name:** {name}\n**Expire Date:** {expire_date}", + "footer": {"text": "ID: {id}\nBelongs To: {admin_username}\nBy: {by}"}, +} + +MODIFY_API_KEY = { + "title": "✏️ Modify API Key", + "description": "**Name:** {name}\n**Expire Date:** {expire_date}\n**Status:** {status}", + "footer": {"text": "ID: {id}\nBelongs To: {admin_username}\nBy: {by}"}, +} + +REMOVE_API_KEY = { + "title": "🗑️ Remove API Key", + "description": "**Name:** {name}\n", + "footer": {"text": "ID: {id}\nBelongs To: {admin_username}\nBy: {by}"}, +} diff --git a/app/notification/telegram/__init__.py b/app/notification/telegram/__init__.py index bdca04cb7..77ec75583 100644 --- a/app/notification/telegram/__init__.py +++ b/app/notification/telegram/__init__.py @@ -1,5 +1,10 @@ from .admin import admin_login, admin_reset_usage, admin_usage_limit_reached, create_admin, modify_admin, remove_admin from .admin_role import create_admin_role, modify_admin_role, remove_admin_role +from .api_key import ( + create_api_key as create_api_key_tg, + modify_api_key as modify_api_key_tg, + remove_api_key as remove_api_key_tg, +) from .core import create_core, modify_core, remove_core from .group import create_group, modify_group, remove_group from .host import create_host, modify_host, modify_hosts, remove_host @@ -28,6 +33,9 @@ "create_admin_role", "modify_admin_role", "remove_admin_role", + "create_api_key_tg", + "modify_api_key_tg", + "remove_api_key_tg", "create_host", "modify_host", "remove_host", diff --git a/app/notification/telegram/api_key.py b/app/notification/telegram/api_key.py new file mode 100644 index 000000000..46a137b19 --- /dev/null +++ b/app/notification/telegram/api_key.py @@ -0,0 +1,55 @@ +from app.models.api_key import APIKeyResponse +from app.models.settings import NotificationSettings +from app.notification.client import send_telegram_message +from app.notification.helpers import get_telegram_channel +from app.settings import notification_settings +from app.utils.helpers import escape_tg_html + +from . import messages + +ENTITY = "api_key" + + +async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + name, admin_username, by = escape_tg_html((api_key.name, admin_username, by)) + data = messages.CREATE_API_KEY.format( + id=api_key.id, + name=name, + expire_date=api_key.expire_date or "Never", + admin_username=admin_username, + by=by, + ) + settings: NotificationSettings = await notification_settings() + if settings.notify_telegram: + chat_id, topic_id = get_telegram_channel(settings, ENTITY) + await send_telegram_message(data, chat_id, topic_id) + + +async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + name, admin_username, by = escape_tg_html((api_key.name, admin_username, by)) + data = messages.MODIFY_API_KEY.format( + id=api_key.id, + name=name, + expire_date=api_key.expire_date or "Never", + status=api_key.status.value, + admin_username=admin_username, + by=by, + ) + settings: NotificationSettings = await notification_settings() + if settings.notify_telegram: + chat_id, topic_id = get_telegram_channel(settings, ENTITY) + await send_telegram_message(data, chat_id, topic_id) + + +async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + name, admin_username, by = escape_tg_html((api_key.name, admin_username, by)) + data = messages.REMOVE_API_KEY.format( + id=api_key.id, + name=name, + admin_username=admin_username, + by=by, + ) + settings: NotificationSettings = await notification_settings() + if settings.notify_telegram: + chat_id, topic_id = get_telegram_channel(settings, ENTITY) + await send_telegram_message(data, chat_id, topic_id) diff --git a/app/notification/telegram/messages.py b/app/notification/telegram/messages.py index a60989092..1fd92cfc4 100644 --- a/app/notification/telegram/messages.py +++ b/app/notification/telegram/messages.py @@ -371,3 +371,36 @@ ID: {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: ( + }> + + + ), + }, { path: '/settings', element: ( diff --git a/dashboard/src/components/common/admin-filter-combobox.tsx b/dashboard/src/components/common/admin-filter-combobox.tsx index 21cab2fbe..83c270d90 100644 --- a/dashboard/src/components/common/admin-filter-combobox.tsx +++ b/dashboard/src/components/common/admin-filter-combobox.tsx @@ -5,7 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import useDirDetection from '@/hooks/use-dir-detection' import { useDebouncedSearch } from '@/hooks/use-debounced-search' import { cn } from '@/lib/utils' -import { type AdminDetails, type AdminSimple, useGetAdminsSimple } from '@/service/api' +import { type AdminSimple, useGetAdminsSimple } from '@/service/api' import { Check, ChevronDown, Loader2, Sigma, UserRound } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next' interface AdminFilterComboboxProps { value: string onValueChange: (username: string) => void - onAdminSelect?: (admin: AdminDetails | null) => void + onAdminSelect?: (admin: AdminSimple | null) => void className?: string } diff --git a/dashboard/src/components/common/list-generator.tsx b/dashboard/src/components/common/list-generator.tsx index 37540d7d6..6323a9783 100644 --- a/dashboard/src/components/common/list-generator.tsx +++ b/dashboard/src/components/common/list-generator.tsx @@ -15,11 +15,13 @@ export interface ListColumn { header: React.ReactNode cell: (item: T) => React.ReactNode width?: string + mobileWidth?: string className?: string headerClassName?: string skeletonClassName?: string align?: ListColumnAlign hideOnMobile?: boolean + hideInMobileDetails?: boolean } interface ListGeneratorProps { @@ -119,14 +121,14 @@ export function ListGenerator({ const shouldShowEmptyState = showEmptyState && !isLoading && !hasData const showRows = !isLoading && hasData const mobileDetailsColumns = useMemo(() => columns.filter(column => column.hideOnMobile), [columns]) - const mobileDetailDataColumns = useMemo(() => mobileDetailsColumns.filter(column => !!column.header), [mobileDetailsColumns]) + const mobileDetailDataColumns = useMemo(() => mobileDetailsColumns.filter(column => !!column.header && !column.hideInMobileDetails), [mobileDetailsColumns]) const mobileDetailActionColumns = useMemo(() => mobileDetailsColumns.filter(column => !column.header), [mobileDetailsColumns]) const hasMobileExpandableDetails = mobileDetailDataColumns.length > 0 const hasMobileTrailingWidth = mobileDetailsColumns.length > 0 const isAllVisibleSelected = visibleSelectableRowIds.length > 0 && visibleSelectableRowIds.every(id => selectedRowSet.has(id)) const isSomeVisibleSelected = !isAllVisibleSelected && visibleSelectableRowIds.some(id => selectedRowSet.has(id)) const mobileTemplateColumns = useMemo(() => { - const visibleColumns = columns.filter(column => !column.hideOnMobile).map(column => column.width ?? 'minmax(0, 1fr)') + const visibleColumns = columns.filter(column => !column.hideOnMobile).map(column => column.mobileWidth ?? column.width ?? 'minmax(0, 1fr)') if (hasMobileTrailingWidth) { visibleColumns.push(mobileDetailActionColumns.length > 0 ? 'max-content' : '32px') @@ -334,9 +336,9 @@ export function ListGenerator({ if (cellContent === null || cellContent === undefined) return null return ( -
-
{column.header}
-
{cellContent}
+
+
{column.header}
+
{cellContent}
) })} diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx index cba4a2b76..a44307abb 100644 --- a/dashboard/src/components/layout/sidebar.tsx +++ b/dashboard/src/components/layout/sidebar.tsx @@ -33,6 +33,7 @@ import { Fingerprint, GithubIcon, Group, + Key, Layers, LayoutDashboardIcon, LayoutTemplate, @@ -67,6 +68,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const canReadHosts = canReadResourcePage(admin, 'hosts') const canReadGroups = canReadResourcePage(admin, 'groups') const canReadAdmins = canReadResourcePage(admin, 'admins') + const canReadApiKeys = canReadResourcePage(admin, 'api_keys') const canReadNodes = canReadResourcePage(admin, 'nodes') const canReadCores = canReadResourcePage(admin, 'cores') const canReadTemplates = canReadResourcePage(admin, 'templates') @@ -249,6 +251,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) { }, ] : []), + ...(canReadApiKeys + ? [{ + title: 'apiKeys.title', + url: '/api-keys', + icon: Key, + }] + : []), ...(nodeNavItems.length > 0 ? [ { diff --git a/dashboard/src/features/admin-roles/components/permission-editor.tsx b/dashboard/src/features/admin-roles/components/permission-editor.tsx new file mode 100644 index 000000000..35afcf4a5 --- /dev/null +++ b/dashboard/src/features/admin-roles/components/permission-editor.tsx @@ -0,0 +1,144 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' + +import { cn } from '@/lib/utils' + +import { PERMISSION_GROUPS, PermissionAction, RolePermissionFormMap, RoleScope } from '@/features/admin-roles/forms/admin-role-form' + +interface PermissionEditorProps { + permissions?: RolePermissionFormMap + onPermissionsChange: (permissions: RolePermissionFormMap) => void + className?: string +} + +export function countEnabledPermissions(permissions?: RolePermissionFormMap | null): number { + let count = 0 + for (const value of Object.values(permissions || {})) { + if (!value || typeof value !== 'object') continue + for (const inner of Object.values(value as Record)) { + if (inner === true) count += 1 + else if (inner && typeof inner === 'object' && Number((inner as any).scope) > 0) count += 1 + } + } + return count +} + +export function PermissionCountBadge({ permissions }: { permissions?: RolePermissionFormMap | null }) { + const { t } = useTranslation() + const total = useMemo(() => countEnabledPermissions(permissions), [permissions]) + + if (!total) return null + return ( + + {t('adminRoles.permissionCount', { count: total, defaultValue: '{{count}} permissions' })} + + ) +} + +export function PermissionEditor({ permissions, onPermissionsChange, className }: PermissionEditorProps) { + const { t } = useTranslation() + + const setPermission = (resource: string, action: string, value: boolean | { scope: RoleScope }) => { + const next: RolePermissionFormMap = { ...(permissions || {}) } + next[resource] = { ...(next[resource] || {}), [action]: value } + onPermissionsChange(next) + } + + const setGroupAll = (group: { actions: PermissionAction[] }, mode: 'all' | 'none') => { + const next: RolePermissionFormMap = { ...(permissions || {}) } + for (const item of group.actions) { + const inner = { ...(next[item.resource] || {}) } + if (item.scoped) inner[item.action] = { scope: mode === 'all' ? 2 : 0 } + else inner[item.action] = mode === 'all' + next[item.resource] = inner + } + onPermissionsChange(next) + } + + const formatActionLabel = (item: PermissionAction) => { + const resourceLabel = t(`adminRoles.resources.${item.resource}`, { defaultValue: humanizeKey(item.resource) }) + const actionLabel = t(`adminRoles.actions.${item.resource}.${item.action}`, { + defaultValue: t(`adminRoles.actions.common.${item.action}`, { defaultValue: humanizeKey(item.action) }), + }) + return { resourceLabel, actionLabel } + } + + return ( +
+

{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 ( +
+
+
+ {groupLabel} + + {enabledInGroup}/{group.actions.length} + +
+
+ + +
+
+
+ {group.actions.map(item => { + const current = permissions?.[item.resource]?.[item.action] + const isScope = current && typeof current === 'object' + const scopeValue: RoleScope = isScope ? (Number((current as any).scope) as RoleScope) : current === true ? 2 : 0 + const boolValue = current === true + const { resourceLabel, actionLabel } = formatActionLabel(item) + + return ( +
+
+ {showResourcePrefix ? `${resourceLabel} - ${actionLabel}` : actionLabel} + {item.scoped && {t('adminRoles.scopedBadge', { defaultValue: 'Scoped' })}} +
+ {item.scoped ? ( + + ) : ( + setPermission(item.resource, item.action, checked)} /> + )} +
+ ) + })} +
+
+ ) + })} +
+ ) +} + +function humanizeKey(key: string) { + return key.replace(/_/g, ' ').replace(/\b\w/g, char => char.toUpperCase()) +} diff --git a/dashboard/src/features/admin-roles/dialogs/admin-role-modal.tsx b/dashboard/src/features/admin-roles/dialogs/admin-role-modal.tsx index c7758d5ac..81530767a 100644 --- a/dashboard/src/features/admin-roles/dialogs/admin-role-modal.tsx +++ b/dashboard/src/features/admin-roles/dialogs/admin-role-modal.tsx @@ -24,20 +24,17 @@ import useDynamicErrorHandler from '@/hooks/use-dynamic-errors.ts' import { cn } from '@/lib/utils' import { bytesToFormGigabytes, formatBytes, gbToBytes } from '@/utils/formatByte' import { getGetRolesQueryKey, getGetRolesSimpleQueryKey, useCreateRole, useGetAllGroups, useGetUserTemplatesSimple, useModifyRole } from '@/service/api' +import { PermissionCountBadge, PermissionEditor } from '@/features/admin-roles/components/permission-editor' import { AdminRoleFormValues, AdminRoleFormValuesInput, FEATURE_KEYS, - PERMISSION_GROUPS, - PermissionAction, - RoleScope, + RolePermissionFormMap, adminRoleFormDefaultValues, adminRoleFormToPayload, } from '@/features/admin-roles/forms/admin-role-form' -type RolePermissionFormMap = Record> - const ONE_GB_IN_BYTES = 1024 * 1024 * 1024 interface AdminRoleModalProps { @@ -68,6 +65,7 @@ export default function AdminRoleModal({ isDialogOpen, onOpenChange, form, editi const groupsOptions = useMemo(() => (groupsQuery.data?.groups || []).map(group => ({ id: group.id, name: group.name || `#${group.id}` })), [groupsQuery.data?.groups]) const templatesOptions = useMemo(() => (templatesQuery.data?.templates || []).map(tpl => ({ id: tpl.id, name: tpl.name || `#${tpl.id}` })), [templatesQuery.data?.templates]) + const permissions = useWatch({ control: form.control, name: 'permissions' }) as RolePermissionFormMap | undefined useEffect(() => { if (!isDialogOpen) { @@ -162,12 +160,12 @@ export default function AdminRoleModal({ isDialogOpen, onOpenChange, form, editi
{t('adminRoles.permissions', { defaultValue: 'Permissions' })} - +
- + form.setValue('permissions', next, { shouldDirty: true })} />
@@ -262,133 +260,6 @@ function firstErrorPath(errors: FieldErrors, prefix = return null } -function PermissionsBadge({ form }: { form: AdminRoleForm }) { - const { t } = useTranslation() - const permissions = useWatch({ control: form.control, name: 'permissions' }) - const total = useMemo(() => { - let count = 0 - for (const value of Object.values(permissions || {})) { - if (!value || typeof value !== 'object') continue - for (const inner of Object.values(value as Record)) { - if (inner === true) count += 1 - else if (inner && typeof inner === 'object' && Number((inner as any).scope) > 0) count += 1 - } - } - return count - }, [permissions]) - - if (!total) return null - return ( - - {t('adminRoles.permissionCount', { count: total, defaultValue: '{{count}} permissions' })} - - ) -} - -function PermissionsSection({ form }: { form: AdminRoleForm }) { - const { t } = useTranslation() - const permissions = useWatch({ control: form.control, name: 'permissions' }) as RolePermissionFormMap | undefined - - const setPermission = (resource: string, action: string, value: boolean | { scope: RoleScope }) => { - const next: RolePermissionFormMap = { ...(permissions || {}) } - next[resource] = { ...(next[resource] || {}), [action]: value } - form.setValue('permissions', next, { shouldDirty: true }) - } - - const setGroupAll = (group: { actions: PermissionAction[] }, mode: 'all' | 'none') => { - const next: RolePermissionFormMap = { ...(permissions || {}) } - for (const item of group.actions) { - const inner = { ...(next[item.resource] || {}) } - if (item.scoped) inner[item.action] = { scope: mode === 'all' ? 2 : 0 } - else inner[item.action] = mode === 'all' - next[item.resource] = inner - } - form.setValue('permissions', next, { shouldDirty: true }) - } - - const formatActionLabel = (item: PermissionAction) => { - const resourceLabel = t(`adminRoles.resources.${item.resource}`, { defaultValue: humanizeKey(item.resource) }) - const actionLabel = t(`adminRoles.actions.${item.resource}.${item.action}`, { - defaultValue: t(`adminRoles.actions.common.${item.action}`, { defaultValue: humanizeKey(item.action) }), - }) - return { resourceLabel, actionLabel } - } - - 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 ( -
-
-
- {groupLabel} - - {enabledInGroup}/{group.actions.length} - -
-
- - -
-
-
- {group.actions.map(item => { - const current = permissions?.[item.resource]?.[item.action] - const isScope = current && typeof current === 'object' - const scopeValue: RoleScope = isScope ? (Number((current as any).scope) as RoleScope) : current === true ? 2 : 0 - const boolValue = current === true - const { resourceLabel, actionLabel } = formatActionLabel(item) - - return ( -
-
- {showResourcePrefix ? `${resourceLabel} · ${actionLabel}` : actionLabel} - {item.scoped && {t('adminRoles.scopedBadge', { defaultValue: 'Scoped' })}} -
- {item.scoped ? ( - - ) : ( - setPermission(item.resource, item.action, checked)} /> - )} -
- ) - })} -
-
- ) - })} -
- ) -} - -function humanizeKey(key: string) { - return key.replace(/_/g, ' ').replace(/\b\w/g, char => char.toUpperCase()) -} - function LimitsSection({ form }: { form: AdminRoleForm }) { const { t } = useTranslation() diff --git a/dashboard/src/features/admin-roles/forms/admin-role-form.ts b/dashboard/src/features/admin-roles/forms/admin-role-form.ts index 600e3d74b..9726d19d0 100644 --- a/dashboard/src/features/admin-roles/forms/admin-role-form.ts +++ b/dashboard/src/features/admin-roles/forms/admin-role-form.ts @@ -2,8 +2,8 @@ import { z } from 'zod' import type { AdminRoleResponse, HWIDSettings, RoleAccess, RoleFeatures, RoleLimits, RolePermissions } from '@/service/api' export type RoleScope = 0 | 1 | 2 -type RolePermissionFormValue = boolean | { scope: RoleScope } -type RolePermissionFormMap = Record> +export type RolePermissionFormValue = boolean | { scope: RoleScope } +export type RolePermissionFormMap = Record> type RolePermissionInput = object | null | undefined export type RoleHwidPolicy = HWIDSettings @@ -57,6 +57,16 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [ { resource: 'admin_roles', action: 'delete' }, ], }, + { + labelKey: 'apiKeys', + actions: [ + { resource: 'api_keys', action: 'read', scoped: true }, + { resource: 'api_keys', action: 'read_simple', scoped: true }, + { resource: 'api_keys', action: 'create' }, + { resource: 'api_keys', action: 'update', scoped: true }, + { resource: 'api_keys', action: 'delete', scoped: true }, + ], + }, { labelKey: 'nodes', actions: [ @@ -129,6 +139,15 @@ const VALID_PERMISSION_ACTIONS = PERMISSION_GROUPS.reduce>>((acc, group) => { + for (const item of group.actions) { + if (!item.scoped) continue + acc[item.resource] = acc[item.resource] || new Set() + acc[item.resource].add(item.action) + } + return acc +}, {}) + const normalizePermissionValue = (value: unknown): RolePermissionFormValue | undefined => { if (typeof value === 'boolean') return value if (!value || typeof value !== 'object') return undefined @@ -140,7 +159,7 @@ const normalizePermissionValue = (value: unknown): RolePermissionFormValue | und return undefined } -const sanitizeRolePermissions = (permissions: RolePermissionInput): RolePermissionFormMap => { +export const sanitizeRolePermissions = (permissions: RolePermissionInput): RolePermissionFormMap => { const next: RolePermissionFormMap = {} for (const [resource, actions] of Object.entries(permissions || {})) { @@ -151,7 +170,10 @@ const sanitizeRolePermissions = (permissions: RolePermissionInput): RolePermissi if (!allowedActions.has(action)) continue const normalizedValue = normalizePermissionValue(value) if (normalizedValue === undefined) continue - next[resource] = { ...(next[resource] || {}), [action]: normalizedValue } + const isScoped = SCOPED_PERMISSION_ACTIONS[resource]?.has(action) === true + if (!isScoped && typeof normalizedValue !== 'boolean') continue + const normalizedActionValue = isScoped ? normalizedValue : normalizedValue + next[resource] = { ...(next[resource] || {}), [action]: normalizedActionValue } } } diff --git a/dashboard/src/features/api-keys/components/api-key-filters.tsx b/dashboard/src/features/api-keys/components/api-key-filters.tsx new file mode 100644 index 000000000..940ec9255 --- /dev/null +++ b/dashboard/src/features/api-keys/components/api-key-filters.tsx @@ -0,0 +1,115 @@ +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import useDirDetection from '@/hooks/use-dir-detection' +import { cn } from '@/lib/utils' +import { SearchIcon, X, RefreshCw, Filter } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' +import ViewToggle, { ViewMode } from '@/components/common/view-toggle' + +interface ApiKeyFiltersProps { + search: string + onSearchChange: (value: string) => void + isFetching?: boolean + onRefresh: () => void + viewMode?: ViewMode + onViewModeChange?: (mode: ViewMode) => void + filters: { + status?: string + key_id?: number + } + onFilterChange: (filters: { status?: string; key_id?: number }) => void + onAdvanceSearchOpen: () => void +} + +export const ApiKeyFilters = ({ + search, + onSearchChange, + isFetching, + onRefresh, + viewMode, + onViewModeChange, + filters, + onFilterChange, + onAdvanceSearchOpen, +}: ApiKeyFiltersProps) => { + const { t } = useTranslation() + const dir = useDirDetection() + + const clearSearch = () => { + onSearchChange('') + } + + const hasActiveFilters = !!(filters.status || filters.key_id) + const activeFiltersCount = (filters.status ? 1 : 0) + (filters.key_id ? 1 : 0) + + return ( +
+
+ + onSearchChange(e.target.value)} + className={cn('pr-10 pl-8', dir === 'rtl' && 'pr-8 pl-10')} + /> + {search && ( + + )} +
+ +
+ + + {hasActiveFilters && ( + + )} + + + {viewMode && onViewModeChange && } +
+
+ ) +} diff --git a/dashboard/src/features/api-keys/components/api-keys-table.tsx b/dashboard/src/features/api-keys/components/api-keys-table.tsx new file mode 100644 index 000000000..9726f5339 --- /dev/null +++ b/dashboard/src/features/api-keys/components/api-keys-table.tsx @@ -0,0 +1,435 @@ +import { useMemo, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { Calendar as CalendarIcon, KeyRound, MoreVertical, Pencil, RotateCcw, ShieldCheck, Trash2, UserRound } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { ListColumn, ListGenerator } from '@/components/common/list-generator' +import { ListGeneratorGrid } from '@/components/common/list-generator-grid' +import { Skeleton } from '@/components/ui/skeleton' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import useDirDetection from '@/hooks/use-dir-detection' +import { APIKeyResponse, RolePermissions, useGetAdminsSimple } from '@/service/api' +import { countEnabledPermissions } from '@/features/admin-roles/components/permission-editor' +import { RolePermissionFormMap } from '@/features/admin-roles/forms/admin-role-form' +import { AdminStatusBadge } from '@/features/admins/components/admin-status-badge' +import { dateUtils } from '@/utils/dateFormatter' +import { formatDateByLocale } from '@/utils/datePickerUtils' + +interface ApiKeysTableProps { + onEdit: (apiKey: APIKeyResponse) => void + onDelete: (apiKey: APIKeyResponse) => void + onRevoke: (apiKey: APIKeyResponse) => void + isCardView?: boolean + apiKeys: APIKeyResponse[] + isLoading: boolean + canUpdate?: boolean + canDelete?: boolean + enableSelection?: boolean + selectedRowIds?: number[] + onSelectionChange?: (ids: number[]) => void +} + +function countEnabledResources(permissions: RolePermissions | undefined): number { + if (!permissions) return 0 + + return Object.values(permissions).reduce((total, resource) => { + if (!resource || typeof resource !== 'object') return total + + const hasEnabledPermission = Object.values(resource as Record).some(value => { + if (value === true) return true + return !!value && typeof value === 'object' && Number((value as { scope?: unknown }).scope) > 0 + }) + + return hasEnabledPermission ? total + 1 : total + }, 0) +} + +function ApiKeyActionsMenu({ + apiKey, + onEdit, + onDelete, + onRevoke, + canUpdate = true, + canDelete = true, +}: { + apiKey: APIKeyResponse + onEdit: (apiKey: APIKeyResponse) => void + onDelete: (apiKey: APIKeyResponse) => void + onRevoke: (apiKey: APIKeyResponse) => void + canUpdate?: boolean + canDelete?: boolean +}) { + const { t } = useTranslation() + const dir = useDirDetection() + + if (!canUpdate && !canDelete) return null + + return ( +
event.stopPropagation()}> + + + + + + {canUpdate && ( + { + event.preventDefault() + event.stopPropagation() + onEdit(apiKey) + }} + > + + {t('edit')} + + )} + {canDelete && ( + <> + { + event.preventDefault() + event.stopPropagation() + onRevoke(apiKey) + }} + > + + {t('apiKeys.revoke')} + + + { + event.preventDefault() + event.stopPropagation() + onDelete(apiKey) + }} + > + + {t('delete')} + + + )} + + +
+ ) +} + +function formatApiKeyExpireDate(date: string | number | Date, language: string): string { + return formatDateByLocale(dateUtils.toDayjs(date).toDate(), language.toLowerCase().startsWith('fa'), true) +} + +function ApiKeyStatusBadge({ apiKey, compactOnMobile = true }: { apiKey: APIKeyResponse; compactOnMobile?: boolean }) { + const { t } = useTranslation() + const status = apiKey.is_expired ? 'expired' : apiKey.status || 'active' + + return ( +
+
+ +
+ {compactOnMobile ? ( +
+ +
+ ) : null} +
+ ) +} + +function ApiKeyPermissionsSummary({ apiKey, compact = false }: { apiKey: APIKeyResponse; compact?: boolean }) { + const { t } = useTranslation() + + if (apiKey.inherit_permissions) { + return ( + + {t('apiKeys.inherited', { defaultValue: 'Inherited' })} + + ) + } + + const permissions = apiKey.permissions as RolePermissions | undefined + const resourceCount = countEnabledResources(permissions) + const actionCount = countEnabledPermissions(permissions as RolePermissionFormMap | undefined) + + if (compact) { + return ( + + {resourceCount} {t('resources', { defaultValue: 'resources' })} / {actionCount} {t('actions', { defaultValue: 'actions' })} + + ) + } + + return ( +
+ + {resourceCount} {t('resources', { defaultValue: 'resources' })} + + + {actionCount} {t('actions', { defaultValue: 'actions' })} + +
+ ) +} + +function ApiKeyCard({ + apiKey, + adminName, + onEdit, + onDelete, + onRevoke, + canUpdate = true, + canDelete = true, + selectionControl, + selected = false, +}: { + apiKey: APIKeyResponse + adminName?: string + onEdit: (apiKey: APIKeyResponse) => void + onDelete: (apiKey: APIKeyResponse) => void + onRevoke: (apiKey: APIKeyResponse) => void + canUpdate?: boolean + canDelete?: boolean + selectionControl?: ReactNode + selected?: boolean +}) { + const { t, i18n } = useTranslation() + const isActive = apiKey.status === 'active' && !apiKey.is_expired + + return ( + { + if (canUpdate) onEdit(apiKey) + }} + > +
+ {selectionControl ?
{selectionControl}
: null} +
+ +
+ +
+
+
+
+ {apiKey.name} + {adminName ? ( + + + {adminName} + + ) : null} +
+ {apiKey.api_key_trimmed ? ( + {apiKey.api_key_trimmed} + ) : ( + - + )} +
+ +
+ + +
+
+ +
+
+ + +
+
+ + {apiKey.expire_date ? formatApiKeyExpireDate(apiKey.expire_date, i18n.language) : t('never')} +
+
+
+
+
+ ) +} + +export default function ApiKeysTable({ + onEdit, + onDelete, + onRevoke, + isCardView = false, + apiKeys, + isLoading, + canUpdate = true, + canDelete = true, + enableSelection = false, + selectedRowIds = [], + onSelectionChange, +}: ApiKeysTableProps) { + const { t, i18n } = useTranslation() + const adminsQuery = useGetAdminsSimple() + const admins = adminsQuery.data?.admins || [] + const adminNamesById = useMemo(() => new Map(admins.map(admin => [admin.id, admin.username])), [admins]) + + const columns = useMemo[]>( + () => [ + { + id: 'name', + header: t('apiKeys.name'), + width: 'minmax(14rem, 2fr)', + mobileWidth: 'minmax(0, 1fr)', + skeletonClassName: 'w-40', + cell: apiKey => { + const adminName = adminNamesById.get(apiKey.admin_id) + + return ( +
+
+ +
+
+
+ {apiKey.name} + {adminName ? ( + + + {adminName} + + ) : null} +
+ {apiKey.api_key_trimmed ? ( + {apiKey.api_key_trimmed} + ) : null} +
+
+ ) + }, + }, + { + id: 'key', + header: t('apiKeys.key', { defaultValue: 'API Key' }), + width: 'minmax(10rem, 1.2fr)', + hideOnMobile: true, + hideInMobileDetails: true, + skeletonClassName: 'w-32', + cell: apiKey => + apiKey.api_key_trimmed ? ( + {apiKey.api_key_trimmed} + ) : ( + - + ), + }, + { + id: 'permissions', + header: t('adminRoles.permissions', { defaultValue: 'Permissions' }), + width: 'minmax(9rem, 1.2fr)', + hideOnMobile: true, + skeletonClassName: 'w-28', + cell: apiKey => , + }, + { + id: 'status', + header: t('apiKeys.status'), + width: '7.5rem', + hideOnMobile: true, + skeletonClassName: 'h-7 w-[88px] rounded-full', + cell: apiKey => , + }, + { + id: 'expire_date', + header: t('apiKeys.expireDate'), + width: 'minmax(9rem, 1fr)', + hideOnMobile: true, + skeletonClassName: 'w-24', + cell: apiKey => ( + + {apiKey.expire_date ? formatApiKeyExpireDate(apiKey.expire_date, i18n.language) : t('never')} + + ), + }, + ...(canUpdate || canDelete + ? [ + { + id: 'actions', + header: '', + width: '56px', + align: 'center' as const, + hideOnMobile: true, + skeletonClassName: 'w-8', + cell: (apiKey: APIKeyResponse) => , + }, + ] + : []), + ], + [adminNamesById, canDelete, canUpdate, i18n.language, onDelete, onEdit, onRevoke, t], + ) + + if (isCardView) { + return ( + apiKey.id} + isLoading={isLoading} + loadingRows={6} + className="gap-4" + gridClassName="gap-3 md:grid-cols-2 xl:grid-cols-3" + enableSelection={enableSelection} + injectSelectionProps={enableSelection} + selectedRowIds={selectedRowIds} + onSelectionChange={ids => onSelectionChange?.(ids.map(id => Number(id)))} + showEmptyState={false} + renderItem={apiKey => ( + + )} + renderSkeleton={index => ( + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ )} + /> + ) + } + + return ( + apiKey.id} + isLoading={isLoading} + loadingRows={6} + className="gap-1.5" + rowClassName="py-2" + onRowClick={canUpdate ? onEdit : undefined} + enableSelection={enableSelection} + selectedRowIds={selectedRowIds} + onSelectionChange={ids => onSelectionChange?.(ids.map(id => Number(id)))} + showEmptyState={false} + /> + ) +} diff --git a/dashboard/src/features/api-keys/components/columns.tsx b/dashboard/src/features/api-keys/components/columns.tsx new file mode 100644 index 000000000..49da3893e --- /dev/null +++ b/dashboard/src/features/api-keys/components/columns.tsx @@ -0,0 +1,179 @@ +import { ColumnDef } from '@tanstack/react-table' +import { MoreVertical, Trash2, Edit2, RotateCcw, KeyRound, UserRound } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Badge } from '@/components/ui/badge' +import { APIKeyResponse, AdminBase, RolePermissions } from '@/service/api' +import { dateUtils } from '@/utils/dateFormatter' +import { countEnabledPermissions } from '@/features/admin-roles/components/permission-editor' +import { RolePermissionFormMap } from '@/features/admin-roles/forms/admin-role-form' +import { AdminStatusBadge } from '@/features/admins/components/admin-status-badge' + +interface ColumnsProps { + t: any + onEdit: (apiKey: APIKeyResponse) => void + onDelete: (apiKey: APIKeyResponse) => void + onRevoke: (apiKey: APIKeyResponse) => void + admins: AdminBase[] +} + +function countEnabledResources(permissions: RolePermissions | undefined): number { + if (!permissions) return 0 + return Object.values(permissions).reduce((total, resource) => { + if (!resource || typeof resource !== 'object') return total + return Object.values(resource as Record).some(value => { + if (value === true) return true + return !!value && typeof value === 'object' && Number((value as any).scope) > 0 + }) + ? total + 1 + : total + }, 0) +} + +export const setupColumns = ({ t, onEdit, onDelete, onRevoke, admins }: ColumnsProps): ColumnDef[] => { + return [ + { + accessorKey: 'name', + header: t('apiKeys.name'), + cell: ({ row }) => { + const adminId = row.original.admin_id + const admin = admins.find(a => a.id === adminId) + return ( +
+
+ +
+
+
+ {row.getValue('name')} + {admin && ( + + + {admin.username} + + )} +
+ {row.original.api_key_trimmed ? ( + {row.original.api_key_trimmed} + ) : null} +
+
+ ) + }, + }, + { + accessorKey: 'api_key_trimmed', + header: t('apiKeys.key', { defaultValue: 'API Key' }), + cell: ({ row }) => { + const trimmed = row.original.api_key_trimmed + return trimmed ? {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.inherited', { defaultValue: 'Inherited' })} + + ) + } + return ( +
+ + {resourceCount} {t('resources', { defaultValue: 'resources' })} + + {count > 0 && ( + + {count} {t('actions', { defaultValue: 'actions' })} + + )} +
+ ) + }, + }, + { + accessorKey: 'status', + header: t('apiKeys.status'), + cell: ({ row }) => { + const status = row.getValue('status') as string + const isExpired = row.original.is_expired + const resolvedStatus = isExpired ? 'expired' : status || 'active' + return ( +
+
+ +
+
+ +
+
+ ) + }, + }, + { + accessorKey: 'expire_date', + header: t('apiKeys.expireDate'), + cell: ({ row }) => { + const date = row.getValue('expire_date') as string | null + return {date ? dateUtils.formatDate(date) : t('never')} + }, + }, + { + id: 'actions', + cell: ({ row }) => { + const apiKey = row.original + + return ( + + e.stopPropagation()}> + + + + { + e.preventDefault() + e.stopPropagation() + onEdit(apiKey) + }} + > + + {t('edit')} + + { + e.preventDefault() + e.stopPropagation() + onRevoke(apiKey) + }} + > + + {t('apiKeys.revoke')} + + + { + e.preventDefault() + e.stopPropagation() + onDelete(apiKey) + }} + > + + {t('delete')} + + + + ) + }, + }, + ] +} diff --git a/dashboard/src/features/api-keys/dialogs/api-key-action-dialogs.tsx b/dashboard/src/features/api-keys/dialogs/api-key-action-dialogs.tsx new file mode 100644 index 000000000..c2617f1bc --- /dev/null +++ b/dashboard/src/features/api-keys/dialogs/api-key-action-dialogs.tsx @@ -0,0 +1,113 @@ +import { useTranslation } from 'react-i18next' +import { Check, Copy } from 'lucide-react' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import useDirDetection from '@/hooks/use-dir-detection' +import type { APIKeyResponse } from '@/service/api' + +interface ApiKeyConfirmDialogProps { + apiKey: APIKeyResponse | null + onOpenChange: (open: boolean) => void + onConfirm: () => void + isPending?: boolean +} + +export function ApiKeyDeleteDialog({ apiKey, onOpenChange, onConfirm, isPending }: ApiKeyConfirmDialogProps) { + const { t } = useTranslation() + const dir = useDirDetection() + + return ( + + + + {t('apiKeys.deleteTitle')} + + {t('apiKeys.deletePrompt', { name: apiKey?.name })} + + + + onOpenChange(false)}>{t('cancel')} + + {t('delete')} + + + + + ) +} + +export function ApiKeyRevokeDialog({ apiKey, onOpenChange, onConfirm, isPending }: ApiKeyConfirmDialogProps) { + const { t } = useTranslation() + const dir = useDirDetection() + + return ( + + + + {t('apiKeys.revokeTitle')} + {t('apiKeys.revokePrompt')} + + + onOpenChange(false)}>{t('cancel')} + + {t('confirm')} + + + + + ) +} + +interface ApiKeySecretDialogProps { + apiKey: string | null + copied: boolean + onCopy: () => void + onOpenChange: (open: boolean) => void +} + +export function ApiKeySecretDialog({ apiKey, copied, onCopy, onOpenChange }: ApiKeySecretDialogProps) { + const { t } = useTranslation() + const dir = useDirDetection() + + return ( + + + + {t('apiKeys.apiKey')} + {t('apiKeys.apiKeyShowWarning')} + +
+ (event.target as HTMLInputElement).select()} + /> + +
+ + onOpenChange(false)}>{t('close')} + +
+
+ ) +} diff --git a/dashboard/src/features/api-keys/dialogs/api-key-advance-search-modal.tsx b/dashboard/src/features/api-keys/dialogs/api-key-advance-search-modal.tsx new file mode 100644 index 000000000..4d6c9f3f4 --- /dev/null +++ b/dashboard/src/features/api-keys/dialogs/api-key-advance-search-modal.tsx @@ -0,0 +1,128 @@ +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Button } from '@/components/ui/button' +import { LoaderButton } from '@/components/ui/loader-button' +import useDirDetection from '@/hooks/use-dir-detection' +import { UseFormReturn } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Search, X, Hash } from 'lucide-react' +import { APIKeyStatus } from '@/service/api' +import { Input } from '@/components/ui/input' +import type { ApiKeyAdvanceSearchFormValue } from '@/features/api-keys/forms/api-key-advance-search-form' + +interface ApiKeyAdvanceSearchModalProps { + isDialogOpen: boolean + onOpenChange: (open: boolean) => void + form: UseFormReturn + onSubmit: (values: ApiKeyAdvanceSearchFormValue) => void +} + +const statusOptions = [ + { value: APIKeyStatus.active, label: 'status.active' }, + { value: APIKeyStatus.disabled, label: 'status.disabled' }, +] as const + +export default function ApiKeyAdvanceSearchModal({ isDialogOpen, onOpenChange, form, onSubmit }: ApiKeyAdvanceSearchModalProps) { + const dir = useDirDetection() + const { t } = useTranslation() + + return ( + + e.preventDefault()}> + + + + {t('advanceSearch.title')} + + + {t('advanceSearch.description', { defaultValue: 'Filter API keys by identifier and status.' })} + + +
+ +
+
+ ( + + + + {t('apiKeys.keyId')} + + + field.onChange(e.target.value ? parseInt(e.target.value) : undefined)} + /> + + + + )} + /> + + ( + + {t('advanceSearch.byStatus')} +
+ + {!!field.value?.length && ( + + )} +
+ +
+ )} + /> +
+
+ + + + {t('apply')} + + +
+ +
+
+ ) +} diff --git a/dashboard/src/features/api-keys/dialogs/api-key-modal.tsx b/dashboard/src/features/api-keys/dialogs/api-key-modal.tsx new file mode 100644 index 000000000..cbda384fe --- /dev/null +++ b/dashboard/src/features/api-keys/dialogs/api-key-modal.tsx @@ -0,0 +1,417 @@ +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Button } from '@/components/ui/button' +import { LoaderButton } from '@/components/ui/loader-button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { DatePicker } from '@/components/common/date-picker' +import { + serializeDatePickerValue, + toDatePickerDisplayDate, +} from '@/utils/datePickerUtils' +import { + apiKeyFormSchema, + ApiKeyFormValuesInput, + ApiKeyFormValues, + apiKeyFormDefaultValues, +} from '../forms/api-key-form' +import { + useCreateApiKey, + useModifyApiKey, + APIKeyResponse, + getListApiKeysQueryKey, + RolePermissions, + useGetAdminsSimple, +} from '@/service/api' +import { useAdmin } from '@/hooks/use-admin' +import { useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { Key, Copy, Check, KeyRound, Pencil } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Switch } from '@/components/ui/switch' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { PermissionCountBadge, PermissionEditor } from '@/features/admin-roles/components/permission-editor' +import { RolePermissionFormMap, sanitizeRolePermissions } from '@/features/admin-roles/forms/admin-role-form' + +interface ApiKeyModalProps { + isDialogOpen: boolean + onOpenChange: (open: boolean) => void + editingApiKey: APIKeyResponse | null +} + +export default function ApiKeyModal({ + isDialogOpen, + onOpenChange, + editingApiKey, +}: ApiKeyModalProps) { + const { t } = useTranslation() + const [createdKey, setCreatedKey] = useState(null) + const [copied, setCopied] = useState(false) + + const queryClient = useQueryClient() + const { admin } = useAdmin() + const isOwner = admin?.role?.is_owner === true + const adminsQuery = useGetAdminsSimple({ all: true }, { query: { enabled: isOwner && isDialogOpen } }) + const admins = adminsQuery.data?.admins || [] + const createMutation = useCreateApiKey({ + mutation: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getListApiKeysQueryKey() }) + }, + }, + }) + const updateMutation = useModifyApiKey({ + mutation: { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getListApiKeysQueryKey() }) + }, + }, + }) + + const form = useForm({ + resolver: zodResolver(apiKeyFormSchema), + defaultValues: apiKeyFormDefaultValues, + }) + + const permissionsValue = form.watch('permissions') as RolePermissionFormMap + const inheritPermissions = form.watch('inherit_permissions') + + useEffect(() => { + if (editingApiKey) { + form.reset({ + name: editingApiKey.name, + admin_id: editingApiKey.admin_id, + note: editingApiKey.note || '', + permissions: sanitizeRolePermissions(editingApiKey.inherit_permissions ? admin?.role?.permissions : editingApiKey.permissions), + inherit_permissions: editingApiKey.inherit_permissions ?? true, + status: editingApiKey.status || 'active', + expire_date: editingApiKey.expire_date, + }) + } else { + form.reset({ + ...apiKeyFormDefaultValues, + admin_id: admin?.id ?? null, + permissions: {}, + inherit_permissions: true, + }) + } + setCreatedKey(null) + }, [editingApiKey, form, isDialogOpen, admin]) + + const onSubmit = async (values: ApiKeyFormValues) => { + try { + if (editingApiKey) { + await updateMutation.mutateAsync({ + keyId: editingApiKey.id, + data: { + name: values.name, + note: values.note, + admin_id: isOwner ? values.admin_id || undefined : undefined, + permissions: values.inherit_permissions ? {} : (values.permissions as RolePermissions), + inherit_permissions: values.inherit_permissions, + expire_date: values.expire_date as string | null | undefined, + status: values.status, + }, + }) + toast.success(t('apiKeys.updateSuccess')) + onOpenChange(false) + } else { + const response = await createMutation.mutateAsync({ + data: { + name: values.name, + admin_id: values.admin_id || undefined, + note: values.note, + permissions: values.inherit_permissions ? {} : (values.permissions as RolePermissions), + inherit_permissions: values.inherit_permissions, + expire_date: values.expire_date as string | null | undefined, + }, + }) + setCreatedKey(response.api_key) + toast.success(t('apiKeys.createSuccess')) + } + } catch (error: any) { + toast.error( + editingApiKey ? t('apiKeys.updateFailed') : t('apiKeys.createFailed'), + { + description: error?.data?.detail || error?.message, + } + ) + } + } + + const copyToClipboard = () => { + if (createdKey) { + navigator.clipboard.writeText(createdKey) + setCopied(true) + toast.success(t('apiKeys.apiKeyCopySuccess')) + setTimeout(() => setCopied(false), 2000) + } + } + + const handleOpenChange = (open: boolean) => { + if (!open) { + setCreatedKey(null) + setCopied(false) + } + onOpenChange(open) + } + + return ( + + e.preventDefault()}> + + + {editingApiKey ? : } + {editingApiKey ? t('apiKeys.editKey') : t('apiKeys.createKey')} + + + {t('apiKeys.description', { defaultValue: 'Manage API keys for programmatic access' })} + + + + {createdKey ? ( +
+
+ + + {t('apiKeys.apiKey')} + {t('apiKeys.apiKeyShowWarning')} + +
+ (e.target as HTMLInputElement).select()} + /> + +
+
+ + + +
+ ) : ( +
+ +
+
+ {isOwner && ( + ( + + {t('apiKeys.admin')} + + + {t('apiKeys.adminDescription', { defaultValue: 'The key will authenticate as this admin.' })} + + + + )} + /> + )} + + ( + + {t('apiKeys.name')} + + + + + + )} + /> + + ( + + {t('apiKeys.expireDate')} + + { + const value = date ? serializeDatePickerValue(date, { useUtcTimestamp: true }) : null + field.onChange(value) + }} + placeholder={t('apiKeys.expireDate')} + /> + + + + )} + /> + + {editingApiKey && ( + ( + + {t('apiKeys.status')} + + + + )} + /> + )} +
+ + + + +
+ + {t('adminRoles.permissions', { defaultValue: 'Permissions' })} + {!inheritPermissions && } +
+
+ +
+ ( + field.onChange(!field.value)}> +
+ {t('apiKeys.inheritPermissions', { defaultValue: 'Inherit admin permissions' })} + + {t('apiKeys.inheritPermissionsDescription', { defaultValue: "Use the owning admin's current role permissions. Disable to store custom permissions on this key." })} + +
+ +
e.stopPropagation()}> + +
+
+
+ )} + /> + {!inheritPermissions && ( + form.setValue('permissions', next, { shouldDirty: true })} + /> + )} +
+
+
+
+ +
+ ( + + {t('apiKeys.note')} + +