diff --git a/Dockerfile b/Dockerfile index 8730f95ef..426e15847 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,9 +36,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY cli_wrapper.sh /usr/bin/pasarguard-cli RUN chmod +x /usr/bin/pasarguard-cli -COPY tui_wrapper.sh /usr/bin/pasarguard-tui -RUN chmod +x /usr/bin/pasarguard-tui - # Copy healthcheck script COPY healthcheck.sh /code/healthcheck.sh RUN chmod +x /code/healthcheck.sh diff --git a/Makefile b/Makefile index bb5e36d37..ea37b6a80 100644 --- a/Makefile +++ b/Makefile @@ -102,12 +102,6 @@ run: run-cli: @uv run pasarguard-cli.py -# run pasarguard-tui -.PHONY: run-tui -run-tui: - @uv run pasarguard-tui.py - - # Run tests .PHONY: test test: diff --git a/app/app_factory.py b/app/app_factory.py index ea95ef3e6..173c9b9e3 100644 --- a/app/app_factory.py +++ b/app/app_factory.py @@ -131,4 +131,20 @@ def validation_exception_handler(request: Request, exc: RequestValidationError): content=jsonable_encoder({"detail": details}), ) + from app.operation.permissions import LimitExceeded, PermissionDenied # noqa: F401 + + @app.exception_handler(PermissionDenied) + async def permission_denied_handler(request: Request, exc: PermissionDenied): + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"detail": exc.detail}, + ) + + @app.exception_handler(LimitExceeded) + async def limit_exceeded_handler(request: Request, exc: LimitExceeded): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": exc.detail}, + ) + return app diff --git a/app/db/crud/admin.py b/app/db/crud/admin.py index 1c4463859..f84f5bbe5 100644 --- a/app/db/crud/admin.py +++ b/app/db/crud/admin.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import and_, case, delete, func, select, update +from sqlalchemy import and_, case, delete, func, insert, not_, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.db.crud.general import ( @@ -9,19 +9,22 @@ get_complete_period_start_for_filter, to_utc_for_filter, ) -from app.db.models import Admin, AdminUsageLogs, NodeUserUsage, User +from app.db.models import Admin, AdminNotificationReminder, AdminRole, AdminUsageLogs, NodeUserUsage, ReminderType, User from app.models.admin import ( AdminCreate, AdminDetails, AdminListQuery, AdminModify, + AdminRoleData, AdminSimpleListQuery, AdminSimpleSortField, AdminSimpleSortOption, AdminSortField, AdminSortOption, + AdminStatus, hash_password, ) +from app.models.admin_role import RoleLimits from app.models.stats import Period, UserUsageStat, UserUsageStatsList from app.utils.logger import get_logger @@ -64,16 +67,6 @@ async def get_admin( load_users: bool = True, load_usage_logs: bool = True, ) -> Admin: - """ - Retrieves an admin by username. - - Args: - db (AsyncSession): Database session. - username (str): The username of the admin. - - Returns: - Admin: The admin object. - """ admin = (await db.execute(select(Admin).where(Admin.username == username))).unique().scalar_one_or_none() if admin: await load_admin_attrs(admin, load_users=load_users, load_usage_logs=load_usage_logs) @@ -111,13 +104,30 @@ async def update_admin(db: AsyncSession, db_admin: Admin, modified_admin: AdminM Returns: Admin: The updated admin object. """ - if modified_admin.is_sudo is not None: - db_admin.is_sudo = modified_admin.is_sudo - if modified_admin.is_disabled is not None: - db_admin.is_disabled = modified_admin.is_disabled + if modified_admin.status is not None: + if modified_admin.status != db_admin.status: + db_admin.status = modified_admin.status + db_admin.last_status_change = datetime.now(timezone.utc) + if modified_admin.data_limit is not None: + db_admin.data_limit = modified_admin.data_limit if modified_admin.data_limit > 0 else None + # Recompute limited/active based on new data_limit — never touch disabled + if db_admin.status != AdminStatus.disabled: + should_be_limited = ( + db_admin.data_limit is not None + and db_admin.data_limit > 0 + and db_admin.used_traffic >= db_admin.data_limit + ) + new_status = AdminStatus.limited if should_be_limited else AdminStatus.active + if db_admin.status != new_status: + db_admin.status = new_status + db_admin.last_status_change = datetime.now(timezone.utc) if modified_admin.password is not None: db_admin.hashed_password = await hash_password(modified_admin.password) db_admin.password_reset_at = datetime.now(timezone.utc) + if modified_admin.role_id is not None: + db_admin.role_id = modified_admin.role_id + if modified_admin.permission_overrides is not None: + db_admin.permission_overrides = modified_admin.permission_overrides.model_dump() if modified_admin.telegram_id is not None: db_admin.telegram_id = modified_admin.telegram_id if modified_admin.discord_webhook is not None: @@ -138,6 +148,7 @@ async def update_admin(db: AsyncSession, db_admin: Admin, modified_admin: AdminM db_admin.notification_enable = modified_admin.notification_enable.model_dump() await db.commit() + await db.refresh(db_admin) await load_admin_attrs(db_admin) return db_admin @@ -161,16 +172,6 @@ async def get_admin_by_id( load_users: bool = True, load_usage_logs: bool = True, ) -> Admin: - """ - Retrieves an admin by their ID. - - Args: - db (AsyncSession): Database session. - id (int): The ID of the admin. - - Returns: - Admin: The admin object. - """ admin = (await db.execute(select(Admin).where(Admin.id == id))).unique().scalar_one_or_none() if admin: await load_admin_attrs(admin, load_users=load_users, load_usage_logs=load_usage_logs) @@ -184,16 +185,6 @@ async def get_admin_by_telegram_id( load_users: bool = True, load_usage_logs: bool = True, ) -> Admin: - """ - Retrieves an admin by their Telegram ID. - - Args: - db (AsyncSession): Database session. - telegram_id (int): The Telegram ID of the admin. - - Returns: - Admin: The admin object. - """ admins = ( (await db.execute(select(Admin).where(Admin.telegram_id == telegram_id).order_by(Admin.id.asc()).limit(2))) .scalars() @@ -232,17 +223,7 @@ async def get_admin_by_discord_id( load_users: bool = True, load_usage_logs: bool = True, ) -> Admin: - """ - Retrieves an admin by their Discord ID. - - Args: - db (AsyncSession): Database session. - discord_id (int): The Discord ID of the admin. - - Returns: - Admin: The admin object. - """ - admin = (await db.execute(select(Admin).where(Admin.discord_id == discord_id))).first() + admin = (await db.execute(select(Admin).where(Admin.discord_id == discord_id))).scalar_one_or_none() if admin: await load_admin_attrs(admin, load_users=load_users, load_usage_logs=load_usage_logs) return admin @@ -253,20 +234,21 @@ async def get_admins( query: AdminListQuery, return_with_count: bool = False, compact: bool = False, -) -> list[Admin] | tuple[list[Admin], int, int, int]: + include_owner: bool = True, +) -> list[Admin] | tuple[list[Admin], int, int, int, int]: """ Retrieves a list of admins with optional filters and pagination. Args: db (AsyncSession): Database session. query: Structured admin list query. - return_with_count (bool): If True, returns tuple with (admins, total, active, disabled). + return_with_count (bool): If True, returns tuple with (admins, total, active, disabled, limited). Returns: - List[Admin] | tuple[list[Admin], int, int, int]: A list of admin objects or tuple with counts. + List[Admin] | tuple[list[Admin], int, int, int, int]: + A list of admin objects or tuple with counts (total, active, disabled, limited). """ params = query - total = None active = None disabled = None @@ -274,8 +256,9 @@ async def get_admins( if return_with_count: counts_stmt = select( func.count(Admin.id).label("total"), - func.sum(case((Admin.is_disabled.is_(False), 1), else_=0)).label("active"), - func.sum(case((Admin.is_disabled.is_(True), 1), else_=0)).label("disabled"), + func.sum(case((Admin.status == AdminStatus.active, 1), else_=0)).label("active"), + func.sum(case((Admin.status == AdminStatus.disabled, 1), else_=0)).label("disabled"), + func.sum(case((Admin.status == AdminStatus.limited, 1), else_=0)).label("limited"), ) if params.ids: counts_stmt = counts_stmt.where(Admin.id.in_(params.ids)) @@ -283,12 +266,15 @@ async def get_admins( counts_stmt = counts_stmt.where(Admin.username.in_(params.usernames)) if params.username: counts_stmt = counts_stmt.where(Admin.username.ilike(f"%{params.username}%")) + if not include_owner: + counts_stmt = counts_stmt.where(Admin.role.has(AdminRole.is_owner.is_(False))) result = await db.execute(counts_stmt) row = result.one() total = row.total or 0 active = row.active or 0 disabled = row.disabled or 0 + limited = row.limited or 0 if compact: users_count_subq = ( @@ -322,6 +308,8 @@ async def get_admins( stmt = stmt.where(Admin.username.in_(params.usernames)) if params.username: stmt = stmt.where(Admin.username.ilike(f"%{params.username}%")) + if not include_owner: + stmt = stmt.where(Admin.role.has(AdminRole.is_owner.is_(False))) # Apply sorting if params.sort: @@ -342,10 +330,10 @@ async def get_admins( AdminDetails( id=admin.id, username=admin.username, - is_sudo=admin.is_sudo, total_users=int(total_users or 0), used_traffic=int(admin.used_traffic or 0), - is_disabled=admin.is_disabled, + data_limit=admin.data_limit, + status=admin.status, telegram_id=admin.telegram_id, discord_webhook=admin.discord_webhook, sub_domain=admin.sub_domain, @@ -356,6 +344,10 @@ async def get_admins( discord_id=admin.discord_id, sub_template=admin.sub_template, lifetime_used_traffic=lifetime_used_traffic, + role=AdminRoleData.model_validate(admin.role) if admin.role is not None else None, + permission_overrides=RoleLimits.model_validate(admin.permission_overrides) + if admin.permission_overrides + else None, ) ) else: @@ -364,13 +356,14 @@ async def get_admins( await load_admin_attrs(admin) if return_with_count: - return admins, total, active, disabled + return admins, total, active, disabled, limited return admins async def get_admins_simple( db: AsyncSession, query: AdminSimpleListQuery, + include_owner: bool = True, ) -> tuple[list[tuple[int, str]], int]: """ Retrieves lightweight admin data with only id and username. @@ -390,6 +383,8 @@ async def get_admins_simple( stmt = stmt.where(Admin.username.in_(query.usernames)) if query.search: stmt = stmt.where(Admin.username.ilike(f"%{query.search}%")) + if not include_owner: + stmt = stmt.where(Admin.role.has(AdminRole.is_owner.is_(False))) if query.sort: stmt = stmt.order_by(*[_build_admin_simple_sort_clause(sort_option) for sort_option in query.sort]) @@ -405,7 +400,7 @@ async def get_admins_simple( if query.limit is not None: stmt = stmt.limit(query.limit) else: - stmt = stmt.limit(10000) # Safety limit when all=true + stmt = stmt.limit(10000) # Execute and return result = await db.execute(stmt) @@ -414,6 +409,132 @@ async def get_admins_simple( return rows, total +async def get_active_admins_with_data_limit( + db: AsyncSession, + *, + threshold: int | None = None, + admin_ids: list[int] | None = None, +) -> list[Admin]: + """Return active admins with a finite data_limit, used by warning-threshold checks.""" + stmt = select(Admin).where( + Admin.status == AdminStatus.active, + Admin.data_limit.isnot(None), + Admin.data_limit > 0, + ) + + if threshold is not None: + stmt = stmt.where(Admin.used_traffic >= (Admin.data_limit * (threshold / 100))) + + if admin_ids is not None: + if not admin_ids: + return [] + stmt = stmt.where(Admin.id.in_(admin_ids)) + + return list((await db.execute(stmt)).scalars().all()) + + +async def get_usage_percentage_reached_admins( + db: AsyncSession, + percentage: int, + admin_ids: list[int] | None = None, +) -> list[Admin]: + """Get active admins who reached a usage threshold and have no reminder for that threshold.""" + if admin_ids is not None and not admin_ids: + return [] + + existing_reminder_subq = ( + select(AdminNotificationReminder.admin_id) + .where( + AdminNotificationReminder.admin_id == Admin.id, + AdminNotificationReminder.type == ReminderType.data_usage, + AdminNotificationReminder.threshold == percentage, + ) + .exists() + ) + + stmt = select(Admin).where( + Admin.status == AdminStatus.active, + Admin.data_limit.isnot(None), + Admin.data_limit > 0, + (Admin.used_traffic * 100) >= (Admin.data_limit * percentage), + not_(existing_reminder_subq), + ) + + if admin_ids is not None: + stmt = stmt.where(Admin.id.in_(admin_ids)) + + return list((await db.execute(stmt)).scalars().all()) + + +async def bulk_create_admin_notification_reminders(db: AsyncSession, reminder_data: list[dict]) -> None: + """Bulk-insert admin reminder rows after successful sends.""" + if not reminder_data: + return + + await db.execute(insert(AdminNotificationReminder), reminder_data) + await db.commit() + + +async def delete_admin_notification_reminders( + db: AsyncSession, + admin_id: int, + reminder_type: ReminderType, +) -> None: + """Delete persisted admin reminders for a specific type (used when re-arming thresholds).""" + await db.execute( + delete(AdminNotificationReminder).where( + AdminNotificationReminder.admin_id == admin_id, + AdminNotificationReminder.type == reminder_type, + ) + ) + + +async def get_active_to_limited_admins(db: AsyncSession) -> list[Admin]: + """Return ALL active admins that have exceeded their data_limit (for status flip).""" + stmt = select(Admin).where( + Admin.status == AdminStatus.active, + Admin.data_limit.isnot(None), + Admin.data_limit > 0, + Admin.used_traffic >= Admin.data_limit, + ) + return list((await db.execute(stmt)).scalars().all()) + + +async def get_limited_admin_ids_with_user_sync(db: AsyncSession) -> set[int]: + """Return IDs of currently limited admins that have disable_users_when_limited=True. + Used to exclude their users from node sync — avoids loading relationships.""" + stmt = ( + select(Admin.id) + .join(AdminRole, Admin.role_id == AdminRole.id) + .where( + Admin.status == AdminStatus.limited, + AdminRole.disable_users_when_limited.is_(True), + ) + ) + result = await db.execute(stmt) + return set(result.scalars().all()) + + +async def update_admin_status(db: AsyncSession, db_admin: Admin, new_status: AdminStatus) -> Admin: + """ + Update an admin's status and record the transition time. + + Args: + db: Database session. + db_admin: The admin to update. + new_status: The new status to set. + + Returns: + Admin: The updated admin object. + """ + db_admin.status = new_status + db_admin.last_status_change = datetime.now(timezone.utc) + await db.commit() + await db.refresh(db_admin) + await load_admin_attrs(db_admin) + return db_admin + + async def reset_admin_usage(db: AsyncSession, db_admin: Admin) -> Admin: """ Retrieves an admin's usage by their username. @@ -423,13 +544,21 @@ async def reset_admin_usage(db: AsyncSession, db_admin: Admin) -> Admin: Returns: Admin: The updated admin. """ + await delete_admin_notification_reminders(db, db_admin.id, ReminderType.data_usage) + if db_admin.used_traffic == 0: + await db.commit() return db_admin usage_log = AdminUsageLogs(admin_id=db_admin.id, used_traffic_at_reset=db_admin.used_traffic) db.add(usage_log) db_admin.used_traffic = 0 + # After reset, used_traffic = 0 so the admin is no longer limited + if db_admin.status == AdminStatus.limited: + db_admin.status = AdminStatus.active + db_admin.last_status_change = datetime.now(timezone.utc) + await db.commit() await db.refresh(db_admin) await db.refresh(db_admin, attribute_names=["usage_logs"]) @@ -517,7 +646,6 @@ async def get_admin_usages( # Attach timezone info to period_start attach_timezone_to_period_start(row_dict, start.tzinfo, dialect) - if node_id_val not in stats: stats[node_id_val] = [] stats[node_id_val].append(UserUsageStat(**row_dict)) @@ -525,6 +653,21 @@ async def get_admin_usages( return UserUsageStatsList(period=period, start=start, end=end, stats=stats) +async def update_owner_password(db: AsyncSession, owner: Admin, new_password: str) -> Admin: + """Reset the owner's password. All DB work stays in the CRUD layer.""" + owner.hashed_password = await hash_password(new_password) + owner.password_reset_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(owner) + await load_admin_attrs(owner) + return owner + + +async def get_owner(db: AsyncSession) -> Admin | None: + """Return the owner admin (role_id=1), or None if not found.""" + return (await db.execute(select(Admin).where(Admin.role_id == 1))).scalar_one_or_none() + + async def get_admins_count(db: AsyncSession) -> int: """ Retrieves the total count of admins. diff --git a/app/db/crud/admin_role.py b/app/db/crud/admin_role.py new file mode 100644 index 000000000..a5e0813a3 --- /dev/null +++ b/app/db/crud/admin_role.py @@ -0,0 +1,95 @@ +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Admin, AdminRole +from app.models.admin_role import AdminRoleCreate, AdminRoleListQuery, AdminRoleModify, AdminRoleSortField + + +def _sort_clause(sort_option): + field_map = { + AdminRoleSortField.id: AdminRole.id, + AdminRoleSortField.name: AdminRole.name, + AdminRoleSortField.created_at: AdminRole.created_at, + } + col = field_map[sort_option.field] + return col.desc() if sort_option.is_desc else col.asc() + + +async def get_role(db: AsyncSession, role_id: int) -> AdminRole | None: + return (await db.execute(select(AdminRole).where(AdminRole.id == role_id))).scalar_one_or_none() + + +async def get_role_by_name(db: AsyncSession, name: str) -> AdminRole | None: + return (await db.execute(select(AdminRole).where(AdminRole.name == name))).scalar_one_or_none() + + +async def get_roles(db: AsyncSession, query: AdminRoleListQuery) -> tuple[list[AdminRole], int]: + stmt = select(AdminRole) + if query.search: + stmt = stmt.where(AdminRole.name.ilike(f"%{query.search}%")) + if query.sort: + stmt = stmt.order_by(*[_sort_clause(s) for s in query.sort]) + + total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0 + + if query.offset: + stmt = stmt.offset(query.offset) + if query.limit: + stmt = stmt.limit(query.limit) + + roles = list((await db.execute(stmt)).scalars().all()) + return roles, total + + +async def get_roles_simple(db: AsyncSession) -> list[AdminRole]: + return list((await db.execute(select(AdminRole.id, AdminRole.name, AdminRole.is_owner))).all()) + + +async def create_role(db: AsyncSession, data: AdminRoleCreate) -> AdminRole: + role = AdminRole( + name=data.name, + permissions=data.permissions.model_dump(exclude_none=True), + limits=data.limits.model_dump(), + features=data.features.model_dump(), + access=data.access.model_dump(), + disabled_when_limited=data.disabled_when_limited, + disable_users_when_limited=data.disable_users_when_limited, + ) + db.add(role) + await db.flush() + await db.refresh(role) + return role + + +async def modify_role(db: AsyncSession, role: AdminRole, data: AdminRoleModify) -> AdminRole: + if role.is_owner: + raise ValueError(f"Cannot modify owner role '{role.name}'") + if data.name is not None: + role.name = data.name + if data.permissions is not None: + role.permissions = data.permissions.model_dump(exclude_none=True) + if data.limits is not None: + role.limits = data.limits.model_dump() + if data.features is not None: + role.features = data.features.model_dump() + if data.access is not None: + role.access = data.access.model_dump() + if data.disabled_when_limited is not None: + role.disabled_when_limited = data.disabled_when_limited + if data.disable_users_when_limited is not None: + role.disable_users_when_limited = data.disable_users_when_limited + await db.flush() + await db.refresh(role) + return role + + +async def count_admins_by_role(db: AsyncSession, role_id: int) -> int: + """Return the number of admins assigned to the given role.""" + return (await db.execute(select(func.count()).where(Admin.role_id == role_id))).scalar() or 0 + + +async def delete_role(db: AsyncSession, role: AdminRole) -> None: + if role.id in (1, 2, 3): + raise ValueError(f"Cannot delete built-in role '{role.name}'") + await db.delete(role) + await db.flush() diff --git a/app/db/crud/temp_key.py b/app/db/crud/temp_key.py new file mode 100644 index 000000000..5928788b1 --- /dev/null +++ b/app/db/crud/temp_key.py @@ -0,0 +1,76 @@ +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import TempKey + +KEY_TTL_MINUTES = 5 + + +class TempKeyConsumeError(Exception): + def __init__(self, detail: str): + super().__init__(detail) + self.detail = detail + + +async def create_temp_key(db: AsyncSession) -> TempKey: + """Create a new single-use temp key valid for 5 minutes.""" + key = TempKey( + key=str(uuid.uuid4()), + action="pending", # updated to the actual action when consumed + expires_at=datetime.now(timezone.utc) + timedelta(minutes=KEY_TTL_MINUTES), + ) + db.add(key) + await db.commit() + await db.refresh(key) + return key + + +async def get_temp_key(db: AsyncSession, key: str) -> TempKey | None: + return (await db.execute(select(TempKey).where(TempKey.key == key))).scalar_one_or_none() + + +def _normalize_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +async def consume_temp_key(db: AsyncSession, key: str, action: str, ip: str) -> None: + """Atomically validate and mark a temp key as used.""" + now = datetime.now(timezone.utc) + result = await db.execute( + update(TempKey) + .where( + TempKey.key == key, + TempKey.used_at.is_(None), + or_(TempKey.expires_at.is_(None), TempKey.expires_at > now), + ) + .values(action=action, used_at=now, used_by_ip=ip) + ) + if result.rowcount == 1: + await db.commit() + return + + await db.rollback() + temp_key = await get_temp_key(db, key) + if temp_key is None: + raise TempKeyConsumeError("invalid key") + if temp_key.used_at is not None: + raise TempKeyConsumeError("key already used") + expires_at = _normalize_utc(temp_key.expires_at) + if expires_at is not None and expires_at <= now: + raise TempKeyConsumeError("key expired") + raise TempKeyConsumeError("invalid key") + + +async def mark_temp_key_used(db: AsyncSession, key: TempKey, action: str, ip: str) -> None: + """Backward-compatible helper for code paths that already own a locked TempKey instance.""" + key.action = action + key.used_at = datetime.now(timezone.utc) + key.used_by_ip = ip + await db.commit() diff --git a/app/db/crud/user.py b/app/db/crud/user.py index bc8f75a63..3622de666 100644 --- a/app/db/crud/user.py +++ b/app/db/crud/user.py @@ -129,6 +129,7 @@ async def get_user( load_next_plan: bool = True, load_usage_logs: bool = True, load_groups: bool = True, + admin_id: int | None = None, ) -> Optional[User]: """ Retrieves a user by username. @@ -136,6 +137,7 @@ async def get_user( Args: db (AsyncSession): Database session. username (str): The username of the user. + admin_id: If provided, only return the user if they belong to this admin. Returns: Optional[User]: The user object if found, else None. @@ -147,6 +149,9 @@ async def get_user( load_groups=load_groups, ).where(User.username == username) + if admin_id is not None: + stmt = stmt.where(User.admin_id == admin_id) + return (await db.execute(stmt)).unique().scalar_one_or_none() @@ -158,6 +163,7 @@ async def get_user_by_id( load_next_plan: bool = True, load_usage_logs: bool = True, load_groups: bool = True, + admin_id: int | None = None, ) -> User | None: """ Retrieves a user by user ID. @@ -165,6 +171,7 @@ async def get_user_by_id( Args: db (AsyncSession): Database session. user_id (int): The ID of the user. + admin_id: If provided, only return the user if they belong to this admin. Returns: Optional[User]: The user object if found, else None. @@ -176,6 +183,9 @@ async def get_user_by_id( load_groups=load_groups, ).where(User.id == user_id) + if admin_id is not None: + stmt = stmt.where(User.admin_id == admin_id) + return (await db.execute(stmt)).unique().scalar_one_or_none() @@ -410,7 +420,7 @@ async def get_users_simple( Args: db: Database session. query: Structured lightweight user list filters. - admin: Admin filter (for non-sudo authorization). + admin: Admin filter (for scope-based authorization). Returns: Tuple of (list of (id, username) tuples, total_count). @@ -711,6 +721,23 @@ async def get_user_usages( return UserUsageStatsList(period=period, start=start, end=end, stats=stats) +async def get_users_count_by_admin(db: AsyncSession, admin_id: int | None) -> int: + """ + Gets the total count of users belonging to a specific admin. + + Args: + db (AsyncSession): Database session. + admin_id (int | None): Admin ID to filter by. If None, counts all users. + + Returns: + int: Total count of users for the given admin. + """ + stmt = select(func.count(User.id)) + if admin_id is not None: + stmt = stmt.where(User.admin_id == admin_id) + return (await db.execute(stmt)).scalar_one() or 0 + + async def get_users_count(db: AsyncSession, status: UserStatus = None, admin_id: int = None) -> int: """ Gets the total count of users with optional filters. diff --git a/app/db/migrations/env.py b/app/db/migrations/env.py index 6c79bad49..b967dae7b 100644 --- a/app/db/migrations/env.py +++ b/app/db/migrations/env.py @@ -1,11 +1,13 @@ import asyncio from logging.config import fileConfig +from sqlalchemy import BigInteger from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config from alembic import context from app.db.base import Base +from app.db.compiles_types import SqliteCompatibleBigInteger from config import database_settings # this is the Alembic Config object, which provides @@ -31,6 +33,26 @@ # ... etc. +def _compare_type(context, inspected_column, metadata_column, inspected_type, metadata_type) -> bool | None: + """Treat BIGINT and SqliteCompatibleBigInteger as equivalent on SQLite. + + The custom type compiles to INTEGER for SQLite but may be reflected back as + BIGINT depending on how the table was originally created, which can produce + false-positive autogenerate diffs. + """ + if context.dialect.name != "sqlite": + return None + + sqlite_bigint_equivalent = ( + (isinstance(inspected_type, BigInteger) and isinstance(metadata_type, SqliteCompatibleBigInteger)) + or (isinstance(inspected_type, SqliteCompatibleBigInteger) and isinstance(metadata_type, BigInteger)) + ) + if sqlite_bigint_equivalent: + return False + + return None + + def run_migrations_offline() -> None: """Run migrations in 'offline' mode. @@ -49,13 +71,19 @@ def run_migrations_offline() -> None: target_metadata=target_metadata, literal_binds=True, render_as_batch=True, + compare_type=_compare_type, dialect_opts={"paramstyle": "named"}, ) with context.begin_transaction(): context.run_migrations() def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata, render_as_batch=True) + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + compare_type=_compare_type, + ) with context.begin_transaction(): context.run_migrations() diff --git a/app/db/migrations/versions/66c38b8a687a_admin_rbac_roles.py b/app/db/migrations/versions/66c38b8a687a_admin_rbac_roles.py new file mode 100644 index 000000000..669466110 --- /dev/null +++ b/app/db/migrations/versions/66c38b8a687a_admin_rbac_roles.py @@ -0,0 +1,132 @@ +"""admin_rbac_roles + +Revision ID: 66c38b8a687a +Revises: f02194c811d6 +Create Date: 2026-05-17 14:32:55.004013 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import Text +from sqlalchemy.dialects import postgresql +import app.db.compiles_types + +# revision identifiers, used by Alembic. +revision = '66c38b8a687a' +down_revision = 'f02194c811d6' +branch_labels = None +depends_on = None + +OWNER_PERMISSIONS = { + "users": {"create": True, "read": {"scope": 2}, "read_simple": {"scope": 2}, "update": {"scope": 2}, "delete": {"scope": 2}, "reset_usage": {"scope": 2}, "revoke_sub": {"scope": 2}, "set_owner": True, "activate_next_plan": {"scope": 2}}, + "admins": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True, "reset_usage": True}, + "nodes": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True, "reconnect": True, "update_core": True, "logs": True, "stats": True}, + "groups": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True}, + "hosts": {"create": True, "read": True, "update": True}, + "templates": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True}, + "client_templates": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True}, + "cores": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True}, + "settings": {"read": True, "read_general": True, "update": True}, + "system": {"read": True}, + "hwids": {"read": True, "delete": True}, + "admin_roles": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True}, +} +ADMINISTRATOR_PERMISSIONS = { + "users": {"create": True, "read": {"scope": 2}, "read_simple": {"scope": 2}, "update": {"scope": 2}, "delete": {"scope": 2}, "reset_usage": {"scope": 2}, "revoke_sub": {"scope": 2}, "set_owner": True, "activate_next_plan": {"scope": 2}}, + "admins": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True, "reset_usage": True}, + "nodes": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True, "reconnect": True, "update_core": True, "logs": True, "stats": True}, + "groups": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True}, + "hosts": {"create": True, "read": True, "update": True}, + "templates": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True}, + "client_templates": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True}, + "cores": {"create": True, "read": True, "read_simple": True, "update": True, "delete": True}, + "settings": {"read": True, "read_general": True, "update": True}, + "system": {"read": True}, + "hwids": {"read": True, "delete": True}, + "admin_roles": {"read": True, "read_simple": True}, +} +OPERATOR_PERMISSIONS = { + "users": {"create": True, "read": {"scope": 1}, "read_simple": {"scope": 1}, "update": {"scope": 1}, "delete": {"scope": 1}, "reset_usage": {"scope": 1}, "revoke_sub": {"scope": 1}, "activate_next_plan": {"scope": 1}}, + "groups": {"read": True, "read_simple": True}, + "templates": {"read": True, "read_simple": True}, + "system": {"read": True}, + "settings": {"read_general": True}, + "hwids": {"read": True, "delete": True}, +} +DEFAULT_LIMITS = {"max_users": None, "data_limit_min": None, "data_limit_max": None, "expire_min": None, "expire_max": None, "min_hwid_per_user": None, "max_hwid_per_user": None} +DEFAULT_FEATURES = {"can_use_reset_strategy": True, "can_use_next_plan": True} +DEFAULT_ACCESS = {"require_template": False, "allowed_template_ids": None, "allowed_group_ids": None} + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('admin_roles', + sa.Column('id', app.db.compiles_types.SqliteCompatibleBigInteger(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('is_owner', sa.Boolean(), server_default='0', nullable=False), + sa.Column('permissions', sa.JSON().with_variant(postgresql.JSONB(none_as_null=True, astext_type=Text()), 'postgresql'), nullable=False), + sa.Column('limits', sa.JSON().with_variant(postgresql.JSONB(none_as_null=True, astext_type=Text()), 'postgresql'), nullable=False), + sa.Column('features', sa.JSON().with_variant(postgresql.JSONB(none_as_null=True, astext_type=Text()), 'postgresql'), nullable=False), + sa.Column('access', sa.JSON().with_variant(postgresql.JSONB(none_as_null=True, astext_type=Text()), 'postgresql'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_admin_roles')), + sa.UniqueConstraint('name', name=op.f('uq_admin_roles_name')) + ) + + # Seed default roles + from datetime import datetime + now = datetime.utcnow() # naive UTC — compatible with all DBs + + admin_roles_table = sa.table( + 'admin_roles', + sa.column('name', sa.String), + sa.column('is_owner', sa.Boolean), + sa.column('permissions', sa.JSON), + sa.column('limits', sa.JSON), + sa.column('features', sa.JSON), + sa.column('access', sa.JSON), + sa.column('created_at', sa.DateTime), + ) + op.bulk_insert(admin_roles_table, [ + {"name": "owner", "is_owner": True, "permissions": OWNER_PERMISSIONS, "limits": DEFAULT_LIMITS, "features": DEFAULT_FEATURES, "access": DEFAULT_ACCESS, "created_at": now}, + {"name": "administrator", "is_owner": False, "permissions": ADMINISTRATOR_PERMISSIONS, "limits": DEFAULT_LIMITS, "features": DEFAULT_FEATURES, "access": DEFAULT_ACCESS, "created_at": now}, + {"name": "operator", "is_owner": False, "permissions": OPERATOR_PERMISSIONS, "limits": DEFAULT_LIMITS, "features": DEFAULT_FEATURES, "access": DEFAULT_ACCESS, "created_at": now}, + ]) + + op.create_table('temp_keys', + sa.Column('key', sa.String(length=36), nullable=False), + sa.Column('action', sa.String(length=32), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('used_by_ip', sa.String(length=45), nullable=True), + sa.PrimaryKeyConstraint('key', name=op.f('pk_temp_keys')) + ) + with op.batch_alter_table('admins', schema=None) as batch_op: + batch_op.add_column(sa.Column('role_id', app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=True)) + batch_op.add_column(sa.Column('permission_overrides', sa.JSON().with_variant(postgresql.JSONB(none_as_null=True, astext_type=Text()), 'postgresql'), nullable=True)) + # Backfill: is_sudo=true -> administrator (id=2), is_sudo=false -> operator (id=3) + conn = op.get_bind() + dialect = conn.dialect.name + if dialect == "postgresql": + conn.execute(sa.text("UPDATE admins SET role_id = 2 WHERE is_sudo = true")) + conn.execute(sa.text("UPDATE admins SET role_id = 3 WHERE is_sudo = false OR role_id IS NULL")) + else: + conn.execute(sa.text("UPDATE admins SET role_id = 2 WHERE is_sudo = 1")) + conn.execute(sa.text("UPDATE admins SET role_id = 3 WHERE is_sudo = 0 OR role_id IS NULL")) + + with op.batch_alter_table('admins', schema=None) as batch_op: + batch_op.alter_column('role_id', existing_type=app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False) + batch_op.create_foreign_key(batch_op.f('fk_admins_role_id_admin_roles'), 'admin_roles', ['role_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('admins', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_admins_role_id_admin_roles'), type_='foreignkey') + batch_op.drop_column('permission_overrides') + batch_op.drop_column('role_id') + + op.drop_table('temp_keys') + op.drop_table('admin_roles') + # ### end Alembic commands ### diff --git a/app/db/migrations/versions/a1d3f5b7c9e2_admin_status_and_data_limit.py b/app/db/migrations/versions/a1d3f5b7c9e2_admin_status_and_data_limit.py new file mode 100644 index 000000000..1adeaa25d --- /dev/null +++ b/app/db/migrations/versions/a1d3f5b7c9e2_admin_status_and_data_limit.py @@ -0,0 +1,99 @@ +"""admin status enum, data_limit, last_status_change, role limit columns + +Revision ID: a1d3f5b7c9e2 +Revises: b1e4f9a2c3d5 +Create Date: 2026-05-19 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'a1d3f5b7c9e2' +down_revision = 'b1e4f9a2c3d5' +branch_labels = None +depends_on = None + +_ADMIN_STATUS_ENUM = sa.Enum('active', 'disabled', 'limited', name='adminstatus') + + +def upgrade() -> None: + conn = op.get_bind() + dialect = conn.dialect.name + + # Create the enum type for PostgreSQL + if dialect == "postgresql": + _ADMIN_STATUS_ENUM.create(conn, checkfirst=True) + + # --- admins table --- + with op.batch_alter_table('admins', schema=None) as batch_op: + batch_op.add_column( + sa.Column('status', sa.Enum('active', 'disabled', 'limited', name='adminstatus'), + nullable=True, server_default='active') + ) + batch_op.add_column(sa.Column('data_limit', sa.BigInteger(), nullable=True)) + batch_op.add_column(sa.Column('last_status_change', sa.DateTime(timezone=True), nullable=True)) + + # Backfill status from is_disabled + if dialect == "postgresql": + conn.execute(sa.text( + "UPDATE admins SET status = CASE " + "WHEN is_disabled = true THEN 'disabled'::adminstatus " + "ELSE 'active'::adminstatus END" + )) + else: + conn.execute(sa.text( + "UPDATE admins SET status = CASE WHEN is_disabled = 1 THEN 'disabled' ELSE 'active' END" + )) + + # Make status NOT NULL + with op.batch_alter_table('admins', schema=None) as batch_op: + batch_op.alter_column('status', nullable=False, + existing_type=sa.Enum('active', 'disabled', 'limited', name='adminstatus')) + + # Drop is_disabled column + with op.batch_alter_table('admins', schema=None) as batch_op: + batch_op.drop_column('is_disabled') + + # --- admin_roles table: add dedicated boolean columns --- + with op.batch_alter_table('admin_roles', schema=None) as batch_op: + batch_op.add_column( + sa.Column('disabled_when_limited', sa.Boolean(), nullable=False, server_default='0') + ) + batch_op.add_column( + sa.Column('disable_users_when_limited', sa.Boolean(), nullable=False, server_default='1') + ) + + +def downgrade() -> None: + conn = op.get_bind() + dialect = conn.dialect.name + + # Drop role limit columns + with op.batch_alter_table('admin_roles', schema=None) as batch_op: + batch_op.drop_column('disable_users_when_limited') + batch_op.drop_column('disabled_when_limited') + + # Restore is_disabled from status + with op.batch_alter_table('admins', schema=None) as batch_op: + batch_op.add_column( + sa.Column('is_disabled', sa.Boolean(), nullable=True, server_default='0') + ) + + if dialect == "postgresql": + conn.execute(sa.text( + "UPDATE admins SET is_disabled = (status = 'disabled'::adminstatus)" + )) + else: + conn.execute(sa.text( + "UPDATE admins SET is_disabled = CASE WHEN status = 'disabled' THEN 1 ELSE 0 END" + )) + + with op.batch_alter_table('admins', schema=None) as batch_op: + batch_op.alter_column('is_disabled', nullable=False, existing_type=sa.Boolean()) + batch_op.drop_column('status') + batch_op.drop_column('data_limit') + batch_op.drop_column('last_status_change') + + if dialect == "postgresql": + _ADMIN_STATUS_ENUM.drop(conn, checkfirst=True) diff --git a/app/db/migrations/versions/b1e4f9a2c3d5_drop_is_sudo.py b/app/db/migrations/versions/b1e4f9a2c3d5_drop_is_sudo.py new file mode 100644 index 000000000..6e0b61689 --- /dev/null +++ b/app/db/migrations/versions/b1e4f9a2c3d5_drop_is_sudo.py @@ -0,0 +1,34 @@ +"""drop is_sudo from admins + +Revision ID: b1e4f9a2c3d5 +Revises: 66c38b8a687a +Create Date: 2026-05-18 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'b1e4f9a2c3d5' +down_revision = '66c38b8a687a' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table('admins', schema=None) as batch_op: + batch_op.drop_column('is_sudo') + + +def downgrade() -> None: + with op.batch_alter_table('admins', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_sudo', sa.Boolean(), server_default='0', nullable=False)) + # Backfill from role: administrator (id=2) -> is_sudo=true, others -> is_sudo=false + conn = op.get_bind() + dialect = conn.dialect.name + if dialect == "postgresql": + conn.execute(sa.text("UPDATE admins SET is_sudo = true WHERE role_id = 2")) + conn.execute(sa.text("UPDATE admins SET is_sudo = false WHERE role_id != 2")) + else: + conn.execute(sa.text("UPDATE admins SET is_sudo = 1 WHERE role_id = 2")) + conn.execute(sa.text("UPDATE admins SET is_sudo = 0 WHERE role_id != 2")) diff --git a/app/db/migrations/versions/bb4a32b7f5ce_add_admin_notification_reminders.py b/app/db/migrations/versions/bb4a32b7f5ce_add_admin_notification_reminders.py new file mode 100644 index 000000000..60370c7e9 --- /dev/null +++ b/app/db/migrations/versions/bb4a32b7f5ce_add_admin_notification_reminders.py @@ -0,0 +1,51 @@ +"""add admin notification reminders + +Revision ID: bb4a32b7f5ce +Revises: a1d3f5b7c9e2 +Create Date: 2026-05-19 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = "bb4a32b7f5ce" +down_revision = "a1d3f5b7c9e2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + dialect = bind.dialect.name + + reminder_type = ( + postgresql.ENUM("expiration_date", "data_usage", name="remindertype", create_type=False) + if dialect == "postgresql" + else sa.Enum("expiration_date", "data_usage", name="remindertype") + ) + + op.create_table( + "admin_notification_reminders", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("admin_id", sa.BigInteger(), nullable=False), + sa.Column("type", reminder_type, nullable=False), + sa.Column("threshold", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["admin_id"], ["admins.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_admin_notification_reminders_admin_id_type", + "admin_notification_reminders", + ["admin_id", "type"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_admin_notification_reminders_admin_id_type", table_name="admin_notification_reminders") + op.drop_table("admin_notification_reminders") diff --git a/app/db/models.py b/app/db/models.py index f74bc01f7..35f5936c8 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -63,6 +63,12 @@ def fk_id_table_column(name: str, target: str, **column_kwargs: Any): ) +class AdminStatus(str, Enum): + active = "active" + disabled = "disabled" + limited = "limited" + + class Admin(Base): __tablename__ = "admins" @@ -74,19 +80,49 @@ class Admin(Base): usage_logs: Mapped[List["AdminUsageLogs"]] = relationship( back_populates="admin", init=False, default_factory=list, cascade="all, delete-orphan" ) - is_sudo: Mapped[bool] = mapped_column(default=False) + notification_reminders: Mapped[List["AdminNotificationReminder"]] = 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) discord_webhook: Mapped[Optional[str]] = mapped_column(String(1024), default=None) discord_id: Mapped[Optional[int]] = mapped_column(BigInteger, default=None) used_traffic: Mapped[int] = mapped_column(BigInteger, default=0) - is_disabled: Mapped[bool] = mapped_column(server_default="0", default=False) + data_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None) + status: Mapped[AdminStatus] = mapped_column( + SQLEnum(AdminStatus, name="adminstatus", create_constraint=True), + default=AdminStatus.active, + server_default="active", + ) + last_status_change: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) sub_template: Mapped[Optional[str]] = mapped_column(String(1024), default=None) sub_domain: Mapped[Optional[str]] = mapped_column(String(256), default=None) profile_title: Mapped[Optional[str]] = mapped_column(String(512), default=None) support_url: Mapped[Optional[str]] = mapped_column(String(1024), default=None) notification_enable: Mapped[Optional[Dict]] = mapped_column(PostgresJSONB, default=None) note: Mapped[Optional[str]] = mapped_column(String(500), default=None) + role_id: Mapped[int] = fk_id_column("admin_roles.id", default=0) + role: Mapped[Optional[AdminRole]] = relationship(back_populates="admins", init=False, lazy="selectin") + permission_overrides: Mapped[Optional[Dict]] = mapped_column(PostgresJSONB, default=None) + + @hybrid_property + def is_disabled(self) -> bool: + """Backward-compat property — True when status is disabled.""" + return self.status == AdminStatus.disabled + + @is_disabled.expression + def is_disabled(cls): + return cls.status == AdminStatus.disabled + + @hybrid_property + def is_limited(self) -> bool: + """True when status is limited.""" + return self.status == AdminStatus.limited + + @is_limited.expression + def is_limited(cls): + return cls.status == AdminStatus.limited @hybrid_property def reseted_usage(self) -> int: @@ -104,6 +140,11 @@ def reseted_usage(cls): def lifetime_used_traffic(self) -> int: return self.reseted_usage + self.used_traffic + @property + def users_sync_blocked(self) -> bool: + """True when this admin's users should NOT be synced to nodes.""" + return self.status == AdminStatus.limited and self.role.disable_users_when_limited + @property def total_users(self) -> int: return len(self.users) @@ -733,6 +774,18 @@ class NotificationReminder(Base): expires_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) +class AdminNotificationReminder(Base): + __tablename__ = "admin_notification_reminders" + __table_args__ = (Index("ix_admin_notification_reminders_admin_id_type", "admin_id", "type"),) + + id: Mapped[int] = id_column() + created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False) + admin_id: Mapped[int] = fk_id_column("admins.id", ondelete="CASCADE") + admin: Mapped["Admin"] = relationship(back_populates="notification_reminders", init=False) + type: Mapped[ReminderType] = mapped_column(SQLEnum(ReminderType)) + threshold: Mapped[Optional[int]] = mapped_column(default=None) + + class Group(Base): __tablename__ = "groups" @@ -821,3 +874,38 @@ class Settings(Base): subscription: Mapped[dict] = mapped_column(JSON()) hwid: Mapped[dict] = mapped_column(JSON()) general: Mapped[dict] = mapped_column(JSON()) + + +class AdminRole(Base): + __tablename__ = "admin_roles" + + id: Mapped[int] = id_column() + name: Mapped[str] = mapped_column(String(64), unique=True) + is_owner: Mapped[bool] = mapped_column(default=False, server_default="0") + permissions: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict) + limits: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict) + features: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict) + access: Mapped[Dict] = mapped_column(PostgresJSONB, default_factory=dict) + disabled_when_limited: Mapped[bool] = mapped_column(default=False, server_default="0") + disable_users_when_limited: Mapped[bool] = mapped_column(default=True, server_default="1") + created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False) + admins: Mapped[List["Admin"]] = relationship(back_populates="role", init=False, viewonly=True, lazy="noload") + + @hybrid_property + def is_builtin(self) -> bool: + """True for the 3 default roles (owner, administrator, operator) that cannot be deleted.""" + return self.id <= 3 + + @is_builtin.expression + def is_builtin(cls): + return cls.id <= 3 + + +class TempKey(Base): + __tablename__ = "temp_keys" + + key: Mapped[str] = mapped_column(String(36), primary_key=True, init=True) + action: Mapped[str] = mapped_column(String(32)) + expires_at: Mapped[dt] = mapped_column(DateTime(timezone=True)) + used_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + used_by_ip: Mapped[Optional[str]] = mapped_column(String(45), default=None) diff --git a/app/jobs/dependencies.py b/app/jobs/dependencies.py index 992d74668..a8f67c633 100644 --- a/app/jobs/dependencies.py +++ b/app/jobs/dependencies.py @@ -1,3 +1,3 @@ -from app.models.admin import AdminDetails +from app.models.admin import AdminDetails, AdminRoleData -SYSTEM_ADMIN = AdminDetails(username="system", is_sudo=True) +SYSTEM_ADMIN = AdminDetails(username="system", role=AdminRoleData(is_owner=True)) diff --git a/app/jobs/record_usages.py b/app/jobs/record_usages.py index 341f2b16b..9c3c05b2f 100644 --- a/app/jobs/record_usages.py +++ b/app/jobs/record_usages.py @@ -672,7 +672,6 @@ async def _record_user_usages_impl(): async with JOB_SEM: await safe_execute(admin_stmt, admin_data) logger.debug(f"Updated {len(admin_data)} admins") - if usage_settings.disable_recording_node_usage: return diff --git a/app/jobs/review_admins.py b/app/jobs/review_admins.py new file mode 100644 index 000000000..1506c93c1 --- /dev/null +++ b/app/jobs/review_admins.py @@ -0,0 +1,103 @@ +""" +Review admin data limits and flip active → limited for admins that exceeded their data_limit. + +The reverse (limited → active) happens synchronously in the operation layer: +- _modify_admin: when data_limit is raised or cleared +- _reset_admin_usage: when used_traffic is zeroed + +This job only handles the active → limited transition that occurs via traffic accumulation +(record_usages increments used_traffic but doesn't load admin objects). +""" + +from datetime import datetime as dt, timezone as tz + +from app import notification, scheduler +from app.db import GetDB +from app.db.crud.admin import ( + bulk_create_admin_notification_reminders, + get_active_to_limited_admins, + get_usage_percentage_reached_admins, + update_admin_status, +) +from app.db.crud.user import get_users +from app.db.models import AdminStatus, ReminderType, UserStatus +from app.models.admin import AdminDetails +from app.models.user import UserListQuery +from app.models.validators import ListValidator +from app.node.sync import remove_users as sync_remove_users +from app.settings import notification_enable +from app.utils.logger import get_logger +from config import job_settings, runtime_settings + +logger = get_logger("review-admins") + + +async def _send_usage_limit_warning_notifications(db): + notify_settings = await notification_enable() + admin_notify = notify_settings.admin + + if not admin_notify.usage_limit_warning: + return + + default_thresholds = ListValidator.normalize_percentage_list_input( + admin_notify.usage_limit_warning_percentages, + strict=False, + ) + default_thresholds = default_thresholds or [] + if not default_thresholds: + return + + reminder_rows: list[dict] = [] + + for threshold in default_thresholds: + threshold_admins = await get_usage_percentage_reached_admins(db, threshold) + for admin in threshold_admins: + if not admin.data_limit or admin.data_limit <= 0: + continue + usage_percentage = int((admin.used_traffic * 100) / admin.data_limit) + admin_model = AdminDetails.model_validate(admin) + await notification.admin_usage_limit_reached(admin_model, usage_percentage, threshold) + reminder_rows.append({ + "admin_id": admin.id, + "type": ReminderType.data_usage, + "threshold": threshold, + }) + + if reminder_rows: + await bulk_create_admin_notification_reminders(db, reminder_rows) + + +async def limit_admins_job(): + """Send warning notifications and flip active → limited admins that exceeded data_limit.""" + async with GetDB() as db: + await _send_usage_limit_warning_notifications(db) + + admins = await get_active_to_limited_admins(db) + if not admins: + return + + for admin in admins: + await update_admin_status(db, admin, AdminStatus.limited) + logger.info(f'Admin "{admin.username}" status changed to limited') + + if admin.role and admin.role.disable_users_when_limited: + users = await get_users( + db, + query=UserListQuery(status=[UserStatus.active, UserStatus.on_hold]), + admin=admin, + ) + await sync_remove_users(users) + logger.info(f'Admin "{admin.username}" — removed {len(users)} users from nodes') + + +if runtime_settings.role.runs_scheduler: + scheduler.add_job( + limit_admins_job, + "interval", + seconds=job_settings.review_admin_limits_interval, + coalesce=True, + max_instances=1, + start_date=dt.now(tz.utc), + id="limit_admins", + replace_existing=True, + ) diff --git a/app/models/admin.py b/app/models/admin.py index 532c59284..4c90f46f9 100644 --- a/app/models/admin.py +++ b/app/models/admin.py @@ -3,16 +3,21 @@ from concurrent.futures import ThreadPoolExecutor from datetime import datetime as dt from enum import Enum +from typing import Literal import bcrypt -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator +from app.db.models import AdminStatus +from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions from app.models.stats import Period from app.utils.helpers import fix_datetime_timezone from .notification_enable import UserNotificationEnable from .validators import DiscordValidator, ListValidator, NumericValidatorMixin, PasswordValidator +AdminStatusModify = Literal[AdminStatus.active, AdminStatus.disabled] + BCRYPT_ROUNDS = 12 _PASSWORD_WORKERS = max(2, min(os.cpu_count() or 1, 8)) _password_executor = ThreadPoolExecutor(max_workers=_PASSWORD_WORKERS, thread_name_prefix="bcrypt") @@ -43,6 +48,22 @@ async def verify_password(raw: str, hashed: str) -> bool: return await loop.run_in_executor(_password_executor, _verify_password_sync, raw, hashed) +class AdminRoleData(BaseModel): + """Runtime role data carried on AdminDetails — only the fields needed for permission checks.""" + + id: int | None = None + name: str = "" + is_owner: bool = False + permissions: RolePermissions = Field(default_factory=RolePermissions) + limits: RoleLimits = Field(default_factory=RoleLimits) + features: RoleFeatures = Field(default_factory=RoleFeatures) + access: RoleAccess = Field(default_factory=RoleAccess) + disabled_when_limited: bool = False + disable_users_when_limited: bool = True + + model_config = ConfigDict(from_attributes=True) + + class Token(BaseModel): access_token: str token_type: str = "bearer" @@ -51,6 +72,7 @@ class Token(BaseModel): class AdminBase(BaseModel): """Minimal admin model containing only the username.""" + id: int | None = None username: str model_config = ConfigDict(from_attributes=True) @@ -84,15 +106,30 @@ def convert_notification_enable(cls, value): class AdminDetails(AdminContactInfo): """Complete admin model with all fields for database representation and API responses.""" - id: int | None = None - is_sudo: bool total_users: int = 0 used_traffic: int = 0 - is_disabled: bool = False + data_limit: int | None = None + status: AdminStatus = AdminStatus.active discord_id: int | None = None sub_template: str | None = None lifetime_used_traffic: int | None = None note: str | None = None + role: AdminRoleData | None = None + permission_overrides: RoleLimits | None = None + + @property + def is_owner(self) -> bool: + return self.role.is_owner if self.role is not None else False + + @computed_field + @property + def is_disabled(self) -> bool: + return self.status == AdminStatus.disabled + + @computed_field + @property + def is_limited(self) -> bool: + return self.status == AdminStatus.limited model_config = ConfigDict(from_attributes=True) @@ -103,17 +140,19 @@ def cast_to_int(cls, v): class AdminModify(BaseModel): password: str | None = None - is_sudo: bool telegram_id: int | None = None discord_webhook: str | None = None discord_id: int | None = None - is_disabled: bool | None = None + status: AdminStatusModify | None = None + data_limit: int | None = None sub_template: str | None = None sub_domain: str | None = None profile_title: str | None = None support_url: str | None = None note: str | None = None notification_enable: UserNotificationEnable | None = None + role_id: int | None = None + permission_overrides: RoleLimits | None = None @field_validator("discord_webhook") @classmethod @@ -131,6 +170,7 @@ class AdminCreate(AdminModify): username: str password: str + role_id: int class AdminInDB(AdminDetails): @@ -146,8 +186,7 @@ async def verify_password_async(self, plain_password): class AdminValidationResult(BaseModel): id: int | None = None username: str - is_sudo: bool - is_disabled: bool + status: AdminStatus = Field(default=AdminStatus.active) class AdminsResponse(BaseModel): @@ -157,6 +196,7 @@ class AdminsResponse(BaseModel): total: int active: int disabled: int + limited: int class AdminSimple(BaseModel): @@ -267,13 +307,13 @@ def validate_datetimes(cls, value): class BulkAdminSelection(BaseModel): - """Model for bulk admin selection by usernames""" + """Model for bulk admin selection by IDs""" - usernames: set[str] = Field(default_factory=set) + ids: set[int] = Field(default_factory=set) - @field_validator("usernames", mode="after") + @field_validator("ids", mode="after") @classmethod - def usernames_validator(cls, v): + def ids_validator(cls, v): return ListValidator.not_null_list(list(v), "admin") diff --git a/app/models/admin_role.py b/app/models/admin_role.py new file mode 100644 index 000000000..4bef7f7a5 --- /dev/null +++ b/app/models/admin_role.py @@ -0,0 +1,223 @@ +from datetime import datetime as dt +from enum import Enum, IntEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.models.validators import ListValidator + + +class PermissionScope(IntEnum): + """Scope for user-resource permissions. Stored as int in JSON for efficiency.""" + + NONE = 0 # explicitly denied + OWN = 1 # only own users (user.admin_id == admin.id) + ALL = 2 # all users regardless of owner + + +# Action value: True = allowed (no scope), {"scope": N} = scoped, None/missing = denied +RoleActionValue = bool | dict[str, PermissionScope | int] + + +class _ResourcePermissions(BaseModel): + """Base for all per-resource permission models. Provides dict-like .get() for the enforcement layer.""" + + model_config = ConfigDict(from_attributes=True, extra="forbid") + + def get(self, action: str, default: Any = None) -> RoleActionValue | None: + """Return the permission value for an action, or default if not set.""" + return getattr(self, action, default) + + +class CRUDPermissions(_ResourcePermissions): + """Standard create/read/read_simple/update/delete permissions. + Used directly by: groups, templates, client_templates, cores, admin_roles. + Also serves as base for resources with additional actions.""" + + create: RoleActionValue | None = None + read: RoleActionValue | None = None + read_simple: RoleActionValue | None = None + update: RoleActionValue | None = None + delete: RoleActionValue | None = None + + +class UsersPermissions(CRUDPermissions): + reset_usage: RoleActionValue | None = None + revoke_sub: RoleActionValue | None = None + set_owner: RoleActionValue | None = None + activate_next_plan: RoleActionValue | None = None + + +class AdminsPermissions(CRUDPermissions): + reset_usage: RoleActionValue | None = None + + +class NodesPermissions(CRUDPermissions): + reconnect: RoleActionValue | None = None + update_core: RoleActionValue | None = None + logs: RoleActionValue | None = None + stats: RoleActionValue | None = None + + +class HostsPermissions(_ResourcePermissions): + create: RoleActionValue | None = None + read: RoleActionValue | None = None + update: RoleActionValue | None = None + + +class SettingsPermissions(_ResourcePermissions): + read: RoleActionValue | None = None + read_general: RoleActionValue | None = None + update: RoleActionValue | None = None + + +class SystemPermissions(_ResourcePermissions): + read: RoleActionValue | None = None + + +class HwidsPermissions(_ResourcePermissions): + read: RoleActionValue | None = None + delete: RoleActionValue | None = None + + +class RoleLimits(BaseModel): + max_users: int | None = None + data_limit_min: int | None = None + data_limit_max: int | None = None + expire_min: int | None = None + expire_max: int | None = None + min_hwid_per_user: int | None = None + max_hwid_per_user: int | None = None + + model_config = ConfigDict(from_attributes=True) + + +class RoleFeatures(BaseModel): + can_use_reset_strategy: bool = True + can_use_next_plan: bool = True + + model_config = ConfigDict(from_attributes=True) + + +class RoleAccess(BaseModel): + require_template: bool = False + allowed_template_ids: list[int] | None = None + allowed_group_ids: list[int] | None = None + + model_config = ConfigDict(from_attributes=True) + + +class RolePermissions(BaseModel): + """ + Typed permission map. Missing resource or action = denied. + Each action value is True (allowed), {"scope": N} (scoped), or None (denied). + """ + + users: UsersPermissions | None = None + admins: AdminsPermissions | None = None + nodes: NodesPermissions | None = None + groups: CRUDPermissions | None = None + hosts: HostsPermissions | None = None + templates: CRUDPermissions | None = None + client_templates: CRUDPermissions | None = None + cores: CRUDPermissions | None = None + settings: SettingsPermissions | None = None + system: SystemPermissions | None = None + hwids: HwidsPermissions | None = None + admin_roles: CRUDPermissions | None = None + + model_config = ConfigDict(from_attributes=True) + + def get(self, resource: str, default: Any = None) -> _ResourcePermissions | None: + """Dict-like access so permissions.py can call permissions.get('users').""" + return getattr(self, resource, default) + + +class AdminRoleBase(BaseModel): + name: str = Field(max_length=64) + permissions: RolePermissions = Field(default_factory=RolePermissions) + limits: RoleLimits = Field(default_factory=RoleLimits) + features: RoleFeatures = Field(default_factory=RoleFeatures) + access: RoleAccess = Field(default_factory=RoleAccess) + disabled_when_limited: bool = False + disable_users_when_limited: bool = True + + model_config = ConfigDict(from_attributes=True) + + +class AdminRoleCreate(AdminRoleBase): + pass + + +class AdminRoleModify(BaseModel): + name: str | None = Field(default=None, max_length=64) + permissions: RolePermissions | None = None + limits: RoleLimits | None = None + features: RoleFeatures | None = None + access: RoleAccess | None = None + disabled_when_limited: bool | None = None + disable_users_when_limited: bool | None = None + + +class AdminRoleResponse(AdminRoleBase): + id: int + is_owner: bool + created_at: dt + + model_config = ConfigDict(from_attributes=True) + + +class AdminRoleSimple(BaseModel): + id: int + name: str + is_owner: bool + + model_config = ConfigDict(from_attributes=True) + + +# --- List query --- + + +class AdminRoleSortField(str, Enum): + id = "id" + name = "name" + created_at = "created_at" + + +class AdminRoleSortOption(str, Enum): + id = "id" + name = "name" + created_at = "created_at" + desc_id = "-id" + desc_name = "-name" + desc_created_at = "-created_at" + + @property + def field(self) -> AdminRoleSortField: + return AdminRoleSortField(self.value.lstrip("-")) + + @property + def is_desc(self) -> bool: + return self.value.startswith("-") + + +class AdminRoleListQuery(BaseModel): + search: str | None = None + offset: int | None = None + limit: int | None = None + sort: list[AdminRoleSortOption] = Field(default_factory=list) + + @field_validator("sort", mode="before") + @classmethod + def validate_sort(cls, value): + return ListValidator.normalize_enum_list_input(value, AdminRoleSortOption) + + +class AdminRolesResponse(BaseModel): + roles: list[AdminRoleResponse] + total: int + + +class AdminRolesSimpleResponse(BaseModel): + roles: list[AdminRoleSimple] + total: int diff --git a/app/models/notification_enable.py b/app/models/notification_enable.py index 18e934027..4902d11a4 100644 --- a/app/models/notification_enable.py +++ b/app/models/notification_enable.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator + +from app.models.validators import ListValidator class BaseNotificationEnable(BaseModel): @@ -10,6 +12,13 @@ class BaseNotificationEnable(BaseModel): class AdminNotificationEnable(BaseNotificationEnable): reset_usage: bool = Field(default=True) login: bool = Field(default=True) + usage_limit_warning: bool = Field(default=True) + usage_limit_warning_percentages: list[int] = Field(default_factory=list) + + @field_validator("usage_limit_warning_percentages", mode="before") + @classmethod + def validate_usage_limit_warning_percentages(cls, value): + return ListValidator.normalize_percentage_list_input(value) class NodeNotificationEnable(BaseNotificationEnable): @@ -32,6 +41,7 @@ class UserNotificationEnable(BaseNotificationEnable): class NotificationEnable(BaseModel): admin: AdminNotificationEnable = Field(default_factory=AdminNotificationEnable) + admin_role: BaseNotificationEnable = Field(default_factory=BaseNotificationEnable) core: BaseNotificationEnable = Field(default_factory=BaseNotificationEnable) group: BaseNotificationEnable = Field(default_factory=BaseNotificationEnable) host: HostNotificationEnable = Field(default_factory=HostNotificationEnable) diff --git a/app/models/settings.py b/app/models/settings.py index eeb1880de..5302bf867 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -128,6 +128,7 @@ class NotificationChannels(BaseModel): """Per-object notification channels""" admin: NotificationChannel = Field(default_factory=NotificationChannel) + admin_role: NotificationChannel = Field(default_factory=NotificationChannel) core: NotificationChannel = Field(default_factory=NotificationChannel) group: NotificationChannel = Field(default_factory=NotificationChannel) host: NotificationChannel = Field(default_factory=NotificationChannel) diff --git a/app/models/setup.py b/app/models/setup.py new file mode 100644 index 000000000..d2a2ef690 --- /dev/null +++ b/app/models/setup.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class OwnerCreateRequest(BaseModel): + key: str + username: str + password: str + + +class OwnerResetRequest(BaseModel): + key: str + password: str + + +class OwnerDeleteRequest(BaseModel): + key: str diff --git a/app/models/validators.py b/app/models/validators.py index 3d2a31984..3d655abc4 100644 --- a/app/models/validators.py +++ b/app/models/validators.py @@ -91,6 +91,34 @@ def normalize_enum_list_input(value, enum_cls) -> list: normalized.append(item if isinstance(item, enum_cls) else enum_cls(item)) return normalized + @staticmethod + def normalize_percentage_list_input( + value, + *, + none_as_none: bool = False, + strict: bool = True, + ) -> list[int] | None: + """Normalize percentage thresholds to sorted unique ints in [1, 100].""" + if value in (None, "", []): + return None if value is None and none_as_none else [] + + if isinstance(value, str): + raw_values = [item.strip() for item in value.split(",") if item.strip()] + elif isinstance(value, list): + raw_values = value + else: + raw_values = [value] + + normalized: list[int] = [] + for item in raw_values: + percentage = int(item) + if 1 <= percentage <= 100: + normalized.append(percentage) + elif strict: + raise ValueError("percentage values must be between 1 and 100") + + return sorted(set(normalized)) + class PasswordValidator: @staticmethod diff --git a/app/node/sync.py b/app/node/sync.py index b6e13dcb1..123817215 100644 --- a/app/node/sync.py +++ b/app/node/sync.py @@ -32,6 +32,9 @@ async def _dispatch_users_update(proto_users): async def sync_user(db_user: User) -> None: + if db_user.admin_id and db_user.admin.users_sync_blocked: + return + proto_user = await serialize_user(db_user) asyncio.create_task(_dispatch_user_update(proto_user)) @@ -41,6 +44,16 @@ async def remove_user(user: UserNotificationResponse) -> None: asyncio.create_task(_dispatch_user_update(proto_user)) +async def remove_users(users: list[User]) -> None: + """Batch-remove users from nodes (serialized without inbounds so nodes drop them).""" + if not users: + return + proto_users = [_serialize_user_for_node(u.id, u.username, u.proxy_settings) for u in users] + asyncio.create_task(_dispatch_users_update(proto_users)) + + async def sync_users(users: list[User]) -> None: - proto_users = await serialize_users_for_node(users) + """Sync users to nodes, excluding users whose admin has users_sync_blocked.""" + filtered = [u for u in users if not (u.admin_id and u.admin.users_sync_blocked)] + proto_users = await serialize_users_for_node(filtered) asyncio.create_task(_dispatch_users_update(proto_users)) diff --git a/app/node/user.py b/app/node/user.py index 1afffc596..5b6d17d95 100644 --- a/app/node/user.py +++ b/app/node/user.py @@ -3,7 +3,17 @@ from sqlalchemy import and_, func, select from app.db import AsyncSession -from app.db.models import Group, ProxyInbound, User, UserStatus, inbounds_groups_association, users_groups_association +from app.db.models import ( + Admin, + AdminRole, + AdminStatus, + Group, + ProxyInbound, + User, + UserStatus, + inbounds_groups_association, + users_groups_association, +) from app.models.protocol import ProxyProtocol _ALL_PROXY_PROTOCOLS = frozenset(ProxyProtocol) @@ -116,7 +126,11 @@ async def core_users( ProxyInbound.tag.in_(inbound_tags) if inbound_tags else True, ), ) + # Exclude users whose admin is limited AND disable_users_when_limited=True + .outerjoin(Admin, Admin.id == User.admin_id) + .outerjoin(AdminRole, AdminRole.id == Admin.role_id) .where(User.status.in_([UserStatus.active, UserStatus.on_hold])) + .where(~((Admin.status == AdminStatus.limited) & (AdminRole.disable_users_when_limited.is_(True)))) .group_by(User.id) ) @@ -139,8 +153,10 @@ async def core_users( async def serialize_users_for_node( - users: list[User], allowed_protocols: frozenset[ProxyProtocol] | None = None + users: list[User], + allowed_protocols: frozenset[ProxyProtocol] | None = None, ) -> list[ProtoUser]: + """Serialize users for node dispatch.""" bridge_users: list = [] for user in users: diff --git a/app/notification/__init__.py b/app/notification/__init__.py index 14cab3e51..43e165498 100644 --- a/app/notification/__init__.py +++ b/app/notification/__init__.py @@ -1,17 +1,32 @@ import asyncio -from . import discord as ds -from . import telegram as tg -from . import webhook as wh +from app.models.admin import AdminDetails +from app.models.admin_role import AdminRoleResponse +from app.models.core import CoreResponse +from app.models.group import GroupResponse from app.models.host import BaseHost -from app.models.user_template import UserTemplateResponse from app.models.node import NodeNotification, NodeResponse -from app.models.group import GroupResponse -from app.models.core import CoreResponse -from app.models.admin import AdminDetails from app.models.user import UserNotificationResponse +from app.models.user_template import UserTemplateResponse from app.settings import notification_enable +from . import discord as ds, telegram as tg, webhook as wh + + +async def create_admin_role(role: AdminRoleResponse, by: str): + if (await notification_enable()).admin_role.create: + await asyncio.gather(ds.create_admin_role(role, by), tg.create_admin_role(role, by)) + + +async def modify_admin_role(role: AdminRoleResponse, by: str): + if (await notification_enable()).admin_role.modify: + await asyncio.gather(ds.modify_admin_role(role, by), tg.modify_admin_role(role, by)) + + +async def remove_admin_role(role: AdminRoleResponse, by: str): + if (await notification_enable()).admin_role.delete: + await asyncio.gather(ds.remove_admin_role(role, by), tg.remove_admin_role(role, by)) + async def create_host(host: BaseHost, by: str): if (await notification_enable()).host.create: @@ -122,6 +137,14 @@ async def admin_usage_reset(admin: AdminDetails, by: str): await asyncio.gather(ds.admin_reset_usage(admin, by), tg.admin_reset_usage(admin, by)) +async def admin_usage_limit_reached(admin: AdminDetails, usage_percentage: int, threshold: int): + if (await notification_enable()).admin.usage_limit_warning: + await asyncio.gather( + ds.admin_usage_limit_reached(admin, usage_percentage, threshold), + tg.admin_usage_limit_reached(admin, usage_percentage, threshold), + ) + + async def admin_login(username: str, password: str, client_ip: str, success: bool): if (await notification_enable()).admin.login: await asyncio.gather( diff --git a/app/notification/discord/__init__.py b/app/notification/discord/__init__.py index 24b43f44f..06e69cee4 100644 --- a/app/notification/discord/__init__.py +++ b/app/notification/discord/__init__.py @@ -1,4 +1,5 @@ -from .admin import admin_login, admin_reset_usage, create_admin, modify_admin, remove_admin +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 .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 @@ -15,6 +16,9 @@ from .user_template import create_user_template, modify_user_template, remove_user_template __all__ = [ + "create_admin_role", + "modify_admin_role", + "remove_admin_role", "create_host", "modify_host", "remove_host", @@ -38,6 +42,7 @@ "modify_admin", "remove_admin", "admin_reset_usage", + "admin_usage_limit_reached", "admin_login", "user_status_change", "create_user", diff --git a/app/notification/discord/admin.py b/app/notification/discord/admin.py index 89f768eb3..32199d7a2 100644 --- a/app/notification/discord/admin.py +++ b/app/notification/discord/admin.py @@ -1,11 +1,12 @@ import copy -from app.notification.client import send_discord_webhook -from app.notification.helpers import get_discord_webhook from app.models.admin import AdminDetails 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 app.utils.helpers import escape_ds_markdown_list +from app.utils.system import readable_size from . import colors, messages @@ -15,10 +16,11 @@ async def create_admin(admin: AdminDetails, by: str): username, by = escape_ds_markdown_list((admin.username, by)) message = copy.deepcopy(messages.CREATE_ADMIN) + role = admin.role.name if admin.role else "unknown" message["description"] = message["description"].format( username=username, - is_sudo=admin.is_sudo, - is_disabled=admin.is_disabled, + role=role, + status=admin.status.value, used_traffic=admin.used_traffic, ) message["footer"]["text"] = message["footer"]["text"].format(by=by) @@ -36,10 +38,11 @@ async def create_admin(admin: AdminDetails, by: str): async def modify_admin(admin: AdminDetails, by: str): username, by = escape_ds_markdown_list((admin.username, by)) message = copy.deepcopy(messages.MODIFY_ADMIN) + role = admin.role.name if admin.role else "unknown" message["description"] = message["description"].format( username=username, - is_sudo=admin.is_sudo, - is_disabled=admin.is_disabled, + role=role, + status=admin.status.value, used_traffic=admin.used_traffic, ) message["footer"]["text"] = message["footer"]["text"].format(by=by) @@ -86,6 +89,26 @@ async def admin_reset_usage(admin: AdminDetails, by: str): await send_discord_webhook(data, webhook) +async def admin_usage_limit_reached(admin: AdminDetails, usage_percentage: int, threshold: int): + username = escape_ds_markdown_list((admin.username,))[0] + message = copy.deepcopy(messages.ADMIN_USAGE_LIMIT_REACHED) + message["description"] = message["description"].format( + username=username, + used_traffic=readable_size(admin.used_traffic), + data_limit=readable_size(admin.data_limit) if admin.data_limit else "Unlimited", + usage_percentage=usage_percentage, + threshold=threshold, + ) + data = { + "content": "", + "embeds": [message], + } + data["embeds"][0]["color"] = colors.YELLOW + settings: NotificationSettings = await notification_settings() + if settings.notify_discord and admin.discord_webhook: + await send_discord_webhook(data, admin.discord_webhook) + + async def admin_login(username: str, password: str, client_ip: str, success: bool): username, password = escape_ds_markdown_list((username, password)) message = copy.deepcopy(messages.ADMIN_LOGIN) diff --git a/app/notification/discord/admin_role.py b/app/notification/discord/admin_role.py new file mode 100644 index 000000000..749aae639 --- /dev/null +++ b/app/notification/discord/admin_role.py @@ -0,0 +1,51 @@ +import copy + +from app.notification.client import send_discord_webhook +from app.notification.helpers import get_discord_webhook +from app.models.admin_role import AdminRoleResponse +from app.models.settings import NotificationSettings +from app.settings import notification_settings +from app.utils.helpers import escape_ds_markdown_list + +from . import colors, messages + +ENTITY = "admin_role" + + +async def create_admin_role(role: AdminRoleResponse, by: str): + name, by = escape_ds_markdown_list((role.name, by)) + message = copy.deepcopy(messages.CREATE_ADMIN_ROLE) + message["description"] = message["description"].format(name=name, is_owner=role.is_owner) + message["footer"]["text"] = message["footer"]["text"].format(id=role.id, by=by) + data = {"content": "", "embeds": [message]} + data["embeds"][0]["color"] = colors.GREEN + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook = get_discord_webhook(settings, ENTITY) + await send_discord_webhook(data, webhook) + + +async def modify_admin_role(role: AdminRoleResponse, by: str): + name, by = escape_ds_markdown_list((role.name, by)) + message = copy.deepcopy(messages.MODIFY_ADMIN_ROLE) + message["description"] = message["description"].format(name=name, is_owner=role.is_owner) + message["footer"]["text"] = message["footer"]["text"].format(id=role.id, by=by) + data = {"content": "", "embeds": [message]} + data["embeds"][0]["color"] = colors.YELLOW + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook = get_discord_webhook(settings, ENTITY) + await send_discord_webhook(data, webhook) + + +async def remove_admin_role(role: AdminRoleResponse, by: str): + name, by = escape_ds_markdown_list((role.name, by)) + message = copy.deepcopy(messages.REMOVE_ADMIN_ROLE) + message["description"] = message["description"].format(name=name) + message["footer"]["text"] = message["footer"]["text"].format(id=role.id, by=by) + data = {"content": "", "embeds": [message]} + data["embeds"][0]["color"] = colors.RED + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook = get_discord_webhook(settings, ENTITY) + await send_discord_webhook(data, webhook) diff --git a/app/notification/discord/messages.py b/app/notification/discord/messages.py index a63b27c79..7a47fc9d2 100644 --- a/app/notification/discord/messages.py +++ b/app/notification/discord/messages.py @@ -95,8 +95,8 @@ CREATE_ADMIN = { "title": "Create Admin", "description": "**Username:** {username}\n" - + "**Is Sudo:** {is_sudo}\n" - + "**Is Disabled:** {is_disabled}\n" + + "**Role:** {role}\n" + + "**Status:** {status}\n" + "**Used Traffic:** {used_traffic}\n", "footer": {"text": "By: {by}"}, } @@ -104,8 +104,8 @@ MODIFY_ADMIN = { "title": "Modify Admin", "description": "**Username:** {username}\n" - + "**Is Sudo:** {is_sudo}\n" - + "**Is Disabled:** {is_disabled}\n" + + "**Role:** {role}\n" + + "**Status:** {status}\n" + "**Used Traffic:** {used_traffic}\n", "footer": {"text": "By: {by}"}, } @@ -122,6 +122,15 @@ "footer": {"text": "By: {by}"}, } +ADMIN_USAGE_LIMIT_REACHED = { + "title": "⚠️ Admin Usage Limit Warning", + "description": "**Username:** {username}\n" + + "**Used Traffic:** {used_traffic}\n" + + "**Data Limit:** {data_limit}\n" + + "**Usage:** {usage_percentage}%\n" + + "**Reached Threshold:** {threshold}%", +} + ADMIN_LOGIN = { "title": "Login Attempt", "description": "**Username:** {username}\n**Password:** {password}\n**IP:** {client_ip}", @@ -249,13 +258,13 @@ CREATE_GROUP = { "title": "Create Group", - "description": "**Name:** {name}\n" + "**Inbound Tags:** {inbound_tags}\n" + "**Is Disabled:** {is_disabled}\n", + "description": "**Name:** {name}\n" + "**Inbound Tags:** {inbound_tags}\n" + "**Status:** {status}\n", "footer": {"text": "ID: {id}\nBy: {by}"}, } MODIFY_GROUP = { "title": "Modify Group", - "description": "**Name:** {name}\n" + "**Inbound Tags:** {inbound_tags}\n" + "**Is Disabled:** {is_disabled}\n", + "description": "**Name:** {name}\n" + "**Inbound Tags:** {inbound_tags}\n" + "**Status:** {status}\n", "footer": {"text": "ID: {id}\nBy: {by}"}, } @@ -264,3 +273,21 @@ "description": "**ID:** {id}", "footer": {"text": "By: {by}"}, } + +CREATE_ADMIN_ROLE = { + "title": "Create Admin Role", + "description": "**Name:** {name}\n**Is Owner:** {is_owner}\n", + "footer": {"text": "ID: {id}\nBy: {by}"}, +} + +MODIFY_ADMIN_ROLE = { + "title": "Modify Admin Role", + "description": "**Name:** {name}\n**Is Owner:** {is_owner}\n", + "footer": {"text": "ID: {id}\nBy: {by}"}, +} + +REMOVE_ADMIN_ROLE = { + "title": "Remove Admin Role", + "description": "**Name:** {name}\n", + "footer": {"text": "ID: {id}\nBy: {by}"}, +} diff --git a/app/notification/telegram/__init__.py b/app/notification/telegram/__init__.py index d5f161518..b910e947e 100644 --- a/app/notification/telegram/__init__.py +++ b/app/notification/telegram/__init__.py @@ -1,20 +1,24 @@ -from .host import create_host, modify_host, remove_host, modify_hosts -from .user_template import create_user_template, modify_user_template, remove_user_template -from .node import create_node, modify_node, remove_node, connect_node, error_node, limited_node, reset_node_usage -from .group import create_group, modify_group, remove_group +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 .core import create_core, modify_core, remove_core -from .admin import create_admin, modify_admin, remove_admin, admin_reset_usage, admin_login +from .group import create_group, modify_group, remove_group +from .host import create_host, modify_host, modify_hosts, remove_host +from .node import connect_node, create_node, error_node, limited_node, modify_node, remove_node, reset_node_usage from .user import ( - user_status_change, create_user, modify_user, remove_user, reset_user_data_usage, user_data_reset_by_next, + user_status_change, user_subscription_revoked, ) +from .user_template import create_user_template, modify_user_template, remove_user_template __all__ = [ + "create_admin_role", + "modify_admin_role", + "remove_admin_role", "create_host", "modify_host", "remove_host", @@ -39,6 +43,7 @@ "modify_admin", "remove_admin", "admin_reset_usage", + "admin_usage_limit_reached", "admin_login", "user_status_change", "create_user", diff --git a/app/notification/telegram/admin.py b/app/notification/telegram/admin.py index b6b27fb6d..ac850e328 100644 --- a/app/notification/telegram/admin.py +++ b/app/notification/telegram/admin.py @@ -1,9 +1,11 @@ -from app.notification.client import send_telegram_message -from app.notification.helpers import get_telegram_channel from app.models.admin import AdminDetails 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 app.utils.system import readable_size + from . import messages ENTITY = "admin" @@ -11,10 +13,11 @@ async def create_admin(admin: AdminDetails, by: str): username, by = escape_tg_html((admin.username, by)) + role = admin.role.name if admin.role else "unknown" data = messages.CREATE_ADMIN.format( username=username, - is_sudo=admin.is_sudo, - is_disabled=admin.is_disabled, + role=role, + status=admin.status.value, used_traffic=admin.used_traffic, by=by, ) @@ -26,10 +29,11 @@ async def create_admin(admin: AdminDetails, by: str): async def modify_admin(admin: AdminDetails, by: str): username, by = escape_tg_html((admin.username, by)) + role = admin.role.name if admin.role else "unknown" data = messages.MODIFY_ADMIN.format( username=username, - is_sudo=admin.is_sudo, - is_disabled=admin.is_disabled, + role=role, + status=admin.status.value, used_traffic=admin.used_traffic, by=by, ) @@ -57,6 +61,20 @@ async def admin_reset_usage(admin: AdminDetails, by: str): await send_telegram_message(data, chat_id, topic_id) +async def admin_usage_limit_reached(admin: AdminDetails, usage_percentage: int, threshold: int): + username = escape_tg_html((admin.username,))[0] + data = messages.ADMIN_USAGE_LIMIT_REACHED.format( + username=username, + used_traffic=readable_size(admin.used_traffic), + data_limit=readable_size(admin.data_limit) if admin.data_limit else "Unlimited", + usage_percentage=usage_percentage, + threshold=threshold, + ) + settings: NotificationSettings = await notification_settings() + if settings.notify_telegram and admin.telegram_id: + await send_telegram_message(data, chat_id=admin.telegram_id) + + async def admin_login(username: str, password: str, client_ip: str, success: bool): username, password = escape_tg_html((username, password)) data = messages.ADMIN_LOGIN.format( diff --git a/app/notification/telegram/admin_role.py b/app/notification/telegram/admin_role.py new file mode 100644 index 000000000..9f1664c33 --- /dev/null +++ b/app/notification/telegram/admin_role.py @@ -0,0 +1,36 @@ +from app.notification.client import send_telegram_message +from app.notification.helpers import get_telegram_channel +from app.models.admin_role import AdminRoleResponse +from app.models.settings import NotificationSettings +from app.settings import notification_settings +from app.utils.helpers import escape_tg_html +from . import messages + +ENTITY = "admin_role" + + +async def create_admin_role(role: AdminRoleResponse, by: str): + name, by = escape_tg_html((role.name, by)) + data = messages.CREATE_ADMIN_ROLE.format(name=name, is_owner=role.is_owner, id=role.id, 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_admin_role(role: AdminRoleResponse, by: str): + name, by = escape_tg_html((role.name, by)) + data = messages.MODIFY_ADMIN_ROLE.format(name=name, is_owner=role.is_owner, id=role.id, 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_admin_role(role: AdminRoleResponse, by: str): + name, by = escape_tg_html((role.name, by)) + data = messages.REMOVE_ADMIN_ROLE.format(name=name, id=role.id, 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 8979e0196..d6b64c90f 100644 --- a/app/notification/telegram/messages.py +++ b/app/notification/telegram/messages.py @@ -81,8 +81,8 @@ #Create_Admin ➖➖➖➖➖➖➖➖➖ Username: {username} -Is Sudo: {is_sudo} -Is Disabled: {is_disabled} +Role: {role} +Status: {status} Used Traffic: {used_traffic} ➖➖➖➖➖➖➖➖➖ By: #{by} @@ -92,8 +92,8 @@ #Modify_Admin ➖➖➖➖➖➖➖➖➖ Username: {username} -Is Sudo: {is_sudo} -Is Disabled: {is_disabled} +Role: {role} +Status: {status} Used Traffic: {used_traffic} ➖➖➖➖➖➖➖➖➖ By: #{by} @@ -113,6 +113,16 @@ By: #{by} """ +ADMIN_USAGE_LIMIT_REACHED = """ +⚠️ #Admin_Usage_Limit_Warning +➖➖➖➖➖➖➖➖➖ +Username: {username} +Used Traffic: {used_traffic} +Data Limit: {data_limit} +Usage: {usage_percentage}% +Reached Threshold: {threshold}% +""" + ADMIN_LOGIN = """ #Login_Attempt Status: {status} @@ -298,7 +308,7 @@ ➖➖➖➖➖➖➖➖➖ Name: {name} Inbound Tags: {inbound_tags} -Is Disabled: {is_disabled} +Status: {status} ➖➖➖➖➖➖➖➖➖ ID: {id} By: #{by} @@ -309,7 +319,7 @@ ➖➖➖➖➖➖➖➖➖ Name: {name} Inbound Tags: {inbound_tags} -Is Disabled: {is_disabled} +Status: {status} ➖➖➖➖➖➖➖➖➖ ID: {id} By: #{by} @@ -322,3 +332,32 @@ ➖➖➖➖➖➖➖➖➖ By: #{by} """ + +CREATE_ADMIN_ROLE = """ +#Create_Admin_Role +➖➖➖➖➖➖➖➖➖ +Name: {name} +Is Owner: {is_owner} +➖➖➖➖➖➖➖➖➖ +ID: {id} +By: #{by} +""" + +MODIFY_ADMIN_ROLE = """ +#Modify_Admin_Role +➖➖➖➖➖➖➖➖➖ +Name: {name} +Is Owner: {is_owner} +➖➖➖➖➖➖➖➖➖ +ID: {id} +By: #{by} +""" + +REMOVE_ADMIN_ROLE = """ +#Remove_Admin_Role +➖➖➖➖➖➖➖➖➖ +Name: {name} +➖➖➖➖➖➖➖➖➖ +ID: {id} +By: #{by} +""" diff --git a/app/operation/__init__.py b/app/operation/__init__.py index 346232920..461475482 100644 --- a/app/operation/__init__.py +++ b/app/operation/__init__.py @@ -25,6 +25,7 @@ from app.models.group import BulkGroup from app.models.user import UserCreate, UserModify from app.utils.helpers import ensure_datetime_timezone +from app.operation.permissions import get_scope_admin_id from app.utils.jwt import get_subscription_payload @@ -175,13 +176,10 @@ async def get_validated_user( load_next_plan=load_next_plan, load_usage_logs=load_usage_logs, load_groups=load_groups, + admin_id=get_scope_admin_id(admin, "users", "read"), ) if not db_user: await self.raise_error(message="User not found", code=404) - - if not (admin.is_sudo or db_user.admin_id == admin.id): - await self.raise_error(message="You're not allowed", code=403) - return db_user async def get_validated_user_by_id( @@ -194,6 +192,8 @@ async def get_validated_user_by_id( load_next_plan: bool = True, load_usage_logs: bool = True, load_groups: bool = True, + scope_resource: str = "users", + scope_action: str = "read", ) -> User: db_user = await get_user_by_id( db, @@ -202,13 +202,10 @@ async def get_validated_user_by_id( load_next_plan=load_next_plan, load_usage_logs=load_usage_logs, load_groups=load_groups, + admin_id=get_scope_admin_id(admin, scope_resource, scope_action), ) if not db_user: await self.raise_error(message="User not found", code=404) - - if not (admin.is_sudo or db_user.admin_id == admin.id): - await self.raise_error(message="You're not allowed", code=403) - return db_user async def get_validated_admin(self, db: AsyncSession, username: str) -> DBAdmin: diff --git a/app/operation/admin.py b/app/operation/admin.py index 30b31d2c9..7bf98b5ef 100644 --- a/app/operation/admin.py +++ b/app/operation/admin.py @@ -20,28 +20,26 @@ ) from app.db.crud.bulk import activate_all_disabled_users, disable_all_active_users from app.db.crud.user import get_users, remove_users -from app.db.models import Admin +from app.models.user import UserListQuery +from app.db.models import Admin, AdminStatus, UserStatus from app.models.admin import ( AdminCreate, - AdminListQuery, - BulkAdminsActionResponse, AdminDetails, + AdminListQuery, AdminModify, - AdminSimpleListQuery, AdminSimple, + AdminSimpleListQuery, AdminsResponse, AdminsSimpleResponse, AdminUsageQuery, BulkAdminSelection, + BulkAdminsActionResponse, RemoveAdminsResponse, ) -from app.models.user import UserListQuery -from app.node.sync import ( - sync_users, - remove_user as sync_remove_user, -) from app.models.stats import Period, UserUsageStatsList -from app.operation import BaseOperation, OperatorType +from app.node.sync import remove_user as sync_remove_user, remove_users as sync_remove_users, sync_users +from app.operation import BaseOperation +from app.operation.permissions import enforce_permission, PermissionDenied from app.operation.user import UserOperation from app.utils.logger import get_logger @@ -50,14 +48,18 @@ class AdminOperation(BaseOperation): @staticmethod - def _is_non_blocking_sync_operator(operator_type: OperatorType) -> bool: - return operator_type in (OperatorType.API, OperatorType.WEB) + def _is_owner_admin(db_admin: Admin) -> bool: + return db_admin.role_id == 1 or bool(db_admin.role and db_admin.role.is_owner) + + async def _ensure_owner_target_access(self, db_admin: Admin, current_admin: AdminDetails) -> None: + if not current_admin.is_owner and self._is_owner_admin(db_admin): + await self.raise_error(message="Owner account is not accessible.", code=403) async def create_admin(self, db: AsyncSession, new_admin: AdminCreate, admin: AdminDetails) -> AdminDetails: - """Create a new admin if the current admin has sudo privileges.""" - if self.operator_type != OperatorType.CLI and new_admin.is_sudo: + """Create a new admin.""" + if new_admin.role_id == 1: await self.raise_error( - message="Creating sudo admin via API is not allowed. Use pasarguard cli / tui.", code=403 + message="Owner role cannot be assigned via this endpoint. Use the setup flow.", code=403 ) if new_admin.telegram_id is not None: @@ -70,40 +72,42 @@ async def create_admin(self, db: AsyncSession, new_admin: AdminCreate, admin: Ad except IntegrityError: await self.raise_error(message="Admin already exists", code=409, db=db) - if self.operator_type != OperatorType.CLI: - logger.info(f'New admin "{db_admin.username}" with id "{db_admin.id}" added by admin "{admin.username}"') - new_admin = AdminDetails.model_validate(db_admin) - asyncio.create_task(notification.create_admin(new_admin, admin.username)) - + logger.info(f'New admin "{db_admin.username}" with id "{db_admin.id}" added by admin "{admin.username}"') + new_admin_details = AdminDetails.model_validate(db_admin) + asyncio.create_task(notification.create_admin(new_admin_details, admin.username)) return db_admin async def modify_admin( self, db: AsyncSession, username: str, modified_admin: AdminModify, current_admin: AdminDetails ) -> AdminDetails: warnings.warn( - "modify_admin(username, ...) is deprecated and will be removed in v6.0.0. " - "Use modify_admin_by_id(admin_id, ...).", + "modify_admin(username, ...) is deprecated. Use modify_admin_by_id(admin_id, ...).", DeprecationWarning, stacklevel=2, ) db_admin = await self.get_validated_admin(db, username=username) + await self._ensure_owner_target_access(db_admin, current_admin) return await self._modify_admin(db, db_admin, modified_admin, current_admin) async def _modify_admin( self, db: AsyncSession, db_admin: Admin, modified_admin: AdminModify, current_admin: AdminDetails ) -> AdminDetails: """Modify an existing admin's details.""" - if self.operator_type != OperatorType.CLI and not db_admin.is_sudo and modified_admin.is_sudo: - await self.raise_error( - message="Promoting admin to sudo via API is not allowed. Use pasarguard cli / tui instead.", code=403 - ) + # Owner can only be modified by themselves — not by other admins via normal routes + if db_admin.role_id == 1 and (current_admin.id is None or db_admin.id != current_admin.id): + await self.raise_error(message="Owner cannot be modified via this endpoint. Use the setup flow.", code=403) - if self.operator_type != OperatorType.CLI and db_admin.is_sudo and db_admin.username != current_admin.username: + if modified_admin.role_id == 1: await self.raise_error( - message="You're not allowed to modify sudoer's account. Use pasarguard cli / tui instead.", code=403 + message="Owner role cannot be assigned via this endpoint. Use the setup flow.", code=403 ) - if db_admin.username == current_admin.username and modified_admin.is_disabled is True: + # Non-owner admins cannot modify other admins with equal or higher role level (role_id <= 2) + # Only the owner can modify administrators (role_id=2) + if not current_admin.is_owner and db_admin.id != current_admin.id and db_admin.role_id <= 2: + await self.raise_error(message="You're not allowed to modify an administrator account.", code=403) + + if db_admin.username == current_admin.username and modified_admin.status == AdminStatus.disabled: await self.raise_error(message="You're not allowed to disable your own account.", code=403) if modified_admin.telegram_id is not None: @@ -113,42 +117,54 @@ async def _modify_admin( if existing_admins: await self.raise_error(message="Telegram ID is already assigned to another admin.", code=409, db=db) + old_status = db_admin.status db_admin = await update_admin(db, db_admin, modified_admin) - if self.operator_type != OperatorType.CLI: - logger.info( - f'Admin "{db_admin.username}" with id "{db_admin.id}" modified by admin "{current_admin.username}"' - ) + # Sync users to nodes if admin status changed due to data_limit change + if modified_admin.data_limit is not None: + if old_status != AdminStatus.limited and db_admin.status == AdminStatus.limited: + # active → limited: remove active/on_hold users from nodes + users = await get_users( + db, query=UserListQuery(status=[UserStatus.active, UserStatus.on_hold]), admin=db_admin + ) + await sync_remove_users(users) + elif old_status == AdminStatus.limited and db_admin.status == AdminStatus.active: + # limited → active: re-sync all users to nodes + # Pass empty set — this admin is now active, no exclusion needed + users = await get_users(db, query=UserListQuery(), admin=db_admin) + await sync_users(users) - modified_admin = AdminDetails.model_validate(db_admin) - asyncio.create_task(notification.modify_admin(modified_admin, current_admin.username)) - return modified_admin + logger.info(f'Admin "{db_admin.username}" with id "{db_admin.id}" modified by admin "{current_admin.username}"') + + modified_admin_details = AdminDetails.model_validate(db_admin) + asyncio.create_task(notification.modify_admin(modified_admin_details, current_admin.username)) + return modified_admin_details async def modify_admin_by_id( self, db: AsyncSession, admin_id: int, modified_admin: AdminModify, current_admin: AdminDetails ) -> AdminDetails: db_admin = await self.get_validated_admin_by_id(db, admin_id) + await self._ensure_owner_target_access(db_admin, current_admin) return await self._modify_admin(db, db_admin, modified_admin, current_admin) async def remove_admin(self, db: AsyncSession, username: str, current_admin: AdminDetails | None = None): warnings.warn( - "remove_admin(username, ...) is deprecated and will be removed in v6.0.0. " - "Use remove_admin_by_id(admin_id, ...).", + "remove_admin(username, ...) is deprecated. Use remove_admin_by_id(admin_id, ...).", DeprecationWarning, stacklevel=2, ) db_admin = await self.get_validated_admin(db, username=username) + if current_admin is not None: + await self._ensure_owner_target_access(db_admin, current_admin) await self._remove_admin(db, db_admin, current_admin) async def _remove_admin(self, db: AsyncSession, db_admin: Admin, current_admin: AdminDetails | None = None): """Remove an admin from the database.""" - if self.operator_type != OperatorType.CLI and db_admin.is_sudo: - await self.raise_error( - message="You're not allowed to remove sudoer's account. Use pasarguard cli / tui instead.", code=403 - ) + if db_admin.role_id == 1: + await self.raise_error(message="Owner cannot be deleted via this endpoint. Use the setup flow.", code=403) await remove_admin(db, db_admin) - if self.operator_type != OperatorType.CLI: + if current_admin: logger.info( f'Admin "{db_admin.username}" with id "{db_admin.id}" deleted by admin "{current_admin.username}"' ) @@ -156,42 +172,27 @@ async def _remove_admin(self, db: AsyncSession, db_admin: Admin, current_admin: async def remove_admin_by_id(self, db: AsyncSession, admin_id: int, current_admin: AdminDetails | None = None): db_admin = await self.get_validated_admin_by_id(db, admin_id) + if current_admin is not None: + await self._ensure_owner_target_access(db_admin, current_admin) await self._remove_admin(db, db_admin, current_admin) - async def get_admins( - self, - db: AsyncSession, - query: AdminListQuery, - ) -> AdminsResponse: - use_compact = self.operator_type in (OperatorType.API, OperatorType.WEB) - admins, total, active, disabled = await get_admins( + async def get_admins(self, db: AsyncSession, query: AdminListQuery, admin: AdminDetails) -> AdminsResponse: + """Retrieve a list of admins with optional filters and pagination.""" + admins, total, active, disabled, limited = await get_admins( db, query, return_with_count=True, - compact=use_compact, + compact=True, + include_owner=admin.is_owner, ) - - if self.operator_type in (OperatorType.API, OperatorType.WEB): - return AdminsResponse( - admins=admins, - total=total, - active=active, - disabled=disabled, - ) - return admins # type: ignore[return-value] + return AdminsResponse(admins=admins, total=total, active=active, disabled=disabled, limited=limited) async def get_admins_simple( - self, - db: AsyncSession, - query: AdminSimpleListQuery, + self, db: AsyncSession, query: AdminSimpleListQuery, admin: AdminDetails ) -> AdminsSimpleResponse: - """Get lightweight admin list with only id and username""" - # Call CRUD function - rows, total = await get_admins_simple(db=db, query=query) - - # Convert tuples to Pydantic models + """Get lightweight admin list with only id and username.""" + rows, total = await get_admins_simple(db=db, query=query, include_owner=admin.is_owner) admins = [AdminSimple(id=row[0], username=row[1]) for row in rows] - return AdminsSimpleResponse(admins=admins, total=total) async def get_admins_count(self, db: AsyncSession) -> int: @@ -199,73 +200,60 @@ async def get_admins_count(self, db: AsyncSession) -> int: async def disable_all_active_users(self, db: AsyncSession, username: str, admin: AdminDetails): warnings.warn( - "disable_all_active_users(username, ...) is deprecated and will be removed in v6.0.0. " - "Use disable_all_active_users_by_id(admin_id, ...).", + "disable_all_active_users(username, ...) is deprecated. Use disable_all_active_users_by_id(admin_id, ...).", DeprecationWarning, stacklevel=2, ) db_admin = await self.get_validated_admin(db, username=username) + await self._ensure_owner_target_access(db_admin, admin) await self._disable_all_active_users_for_admin(db, db_admin, admin) async def _disable_all_active_users_for_admin(self, db: AsyncSession, db_admin: Admin, admin: AdminDetails): - """Disable all active users under a specific admin""" - if db_admin.is_sudo: - await self.raise_error(message="You're not allowed to disable sudo admin users.", code=403) - + """Disable all active users under a specific admin.""" await disable_all_active_users(db=db, admin=db_admin) - users = await get_users(db, query=UserListQuery(), admin=db_admin) await sync_users(users) - logger.info(f'Admin "{db_admin.username}" users has been disabled by admin "{admin.username}"') async def disable_all_active_users_by_id(self, db: AsyncSession, admin_id: int, admin: AdminDetails): db_admin = await self.get_validated_admin_by_id(db, admin_id) + await self._ensure_owner_target_access(db_admin, admin) await self._disable_all_active_users_for_admin(db, db_admin, admin) async def activate_all_disabled_users(self, db: AsyncSession, username: str, admin: AdminDetails): warnings.warn( - "activate_all_disabled_users(username, ...) is deprecated and will be removed in v6.0.0. " - "Use activate_all_disabled_users_by_id(admin_id, ...).", + "activate_all_disabled_users(username, ...) is deprecated. Use activate_all_disabled_users_by_id(admin_id, ...).", DeprecationWarning, stacklevel=2, ) db_admin = await self.get_validated_admin(db, username=username) + await self._ensure_owner_target_access(db_admin, admin) await self._activate_all_disabled_users_for_admin(db, db_admin, admin) async def _activate_all_disabled_users_for_admin(self, db: AsyncSession, db_admin: Admin, admin: AdminDetails): - """Enable all active users under a specific admin""" - if db_admin.is_sudo: - await self.raise_error(message="You're not allowed to enable sudo admin users.", code=403) - + """Activate all disabled users under a specific admin.""" await activate_all_disabled_users(db=db, admin=db_admin) - users = await get_users(db, query=UserListQuery(), admin=db_admin) await sync_users(users) - logger.info(f'Admin "{db_admin.username}" users has been activated by admin "{admin.username}"') async def activate_all_disabled_users_by_id(self, db: AsyncSession, admin_id: int, admin: AdminDetails): db_admin = await self.get_validated_admin_by_id(db, admin_id) + await self._ensure_owner_target_access(db_admin, admin) await self._activate_all_disabled_users_for_admin(db, db_admin, admin) async def remove_all_users(self, db: AsyncSession, username: str, admin: AdminDetails) -> int: warnings.warn( - "remove_all_users(username, ...) is deprecated and will be removed in v6.0.0. " - "Use remove_all_users_by_id(admin_id, ...).", + "remove_all_users(username, ...) is deprecated. Use remove_all_users_by_id(admin_id, ...).", DeprecationWarning, stacklevel=2, ) db_admin = await self.get_validated_admin(db, username=username) + await self._ensure_owner_target_access(db_admin, admin) return await self._remove_all_users_for_admin(db, db_admin, admin) async def _remove_all_users_for_admin(self, db: AsyncSession, db_admin: Admin, admin: AdminDetails) -> int: """Delete all users that belong to the specified admin.""" - target_username = db_admin.username - - if self.operator_type != OperatorType.CLI and db_admin.is_sudo: - await self.raise_error(message="You're not allowed to delete sudo admin users.", code=403) - users = await get_users(db, query=UserListQuery(), admin=db_admin) if not users: return 0 @@ -276,57 +264,59 @@ async def _remove_all_users_for_admin(self, db: AsyncSession, db_admin: Admin, a await remove_users(db, users) for user in serialized_users: await sync_remove_user(user) - for user in serialized_users: asyncio.create_task(notification.remove_user(user, admin)) logger.info( - f'Admin "{admin.username}" deleted {len(serialized_users)} users belonging to admin "{target_username}"' + f'Admin "{admin.username}" deleted {len(serialized_users)} users belonging to admin "{db_admin.username}"' ) return len(serialized_users) async def remove_all_users_by_id(self, db: AsyncSession, admin_id: int, admin: AdminDetails) -> int: db_admin = await self.get_validated_admin_by_id(db, admin_id) + await self._ensure_owner_target_access(db_admin, admin) return await self._remove_all_users_for_admin(db, db_admin, admin) async def reset_admin_usage(self, db: AsyncSession, username: str, admin: AdminDetails) -> AdminDetails: warnings.warn( - "reset_admin_usage(username, ...) is deprecated and will be removed in v6.0.0. " - "Use reset_admin_usage_by_id(admin_id, ...).", + "reset_admin_usage(username, ...) is deprecated. Use reset_admin_usage_by_id(admin_id, ...).", DeprecationWarning, stacklevel=2, ) db_admin = await self.get_validated_admin(db, username=username) + await self._ensure_owner_target_access(db_admin, admin) return await self._reset_admin_usage(db, db_admin, admin) async def _reset_admin_usage(self, db: AsyncSession, db_admin: Admin, admin: AdminDetails) -> AdminDetails: + """Reset an admin's traffic usage and log the action.""" + old_status = db_admin.status db_admin = await reset_admin_usage(db, db_admin=db_admin) - if self.operator_type != OperatorType.CLI: - logger.info(f'Admin "{db_admin.username}" usage has been reset by admin "{admin.username}"') + # If admin was limited and is now active, re-sync all users to nodes + if old_status == AdminStatus.limited and db_admin.status == AdminStatus.active: + users = await get_users(db, query=UserListQuery(), admin=db_admin) + await sync_users(users) + + logger.info(f'Admin "{db_admin.username}" usage has been reset by admin "{admin.username}"') reseted_admin_details = AdminDetails.model_validate(db_admin) asyncio.create_task(notification.admin_usage_reset(reseted_admin_details, admin.username)) - return reseted_admin_details async def reset_admin_usage_by_id(self, db: AsyncSession, admin_id: int, admin: AdminDetails) -> AdminDetails: db_admin = await self.get_validated_admin_by_id(db, admin_id) + await self._ensure_owner_target_access(db_admin, admin) return await self._reset_admin_usage(db, db_admin, admin) async def get_admin_usage( - self, - db: AsyncSession, - username: str, - admin: AdminDetails, - query: AdminUsageQuery, + self, db: AsyncSession, username: str, admin: AdminDetails, query: AdminUsageQuery ) -> UserUsageStatsList: warnings.warn( - "get_admin_usage(username, ...) is deprecated and will be removed in v6.0.0. " - "Use get_admin_usage_by_id(admin_id, ...).", + "get_admin_usage(username, ...) is deprecated. Use get_admin_usage_by_id(admin_id, ...).", DeprecationWarning, stacklevel=2, ) db_admin = await self.get_validated_admin(db, username=username) + await self._ensure_owner_target_access(db_admin, admin) return await self._get_admin_usage( db, db_admin, @@ -352,9 +342,15 @@ async def _get_admin_usage( """Get aggregated usage for an admin's users.""" start, end = await self.validate_dates(start, end, True) - if not admin.is_sudo: - if db_admin.username != admin.username: + is_self = db_admin.username == admin.username + if not is_self: + # Non-self access requires admins.read permission + try: + enforce_permission(admin, "admins", "read") + except PermissionDenied: await self.raise_error(message="You're not allowed", code=403) + else: + # Self-access: restrict to own data only (no cross-node filtering) node_id = None group_by_node = False @@ -369,13 +365,10 @@ async def _get_admin_usage( ) async def get_admin_usage_by_id( - self, - db: AsyncSession, - admin_id: int, - admin: AdminDetails, - query: AdminUsageQuery, + self, db: AsyncSession, admin_id: int, admin: AdminDetails, query: AdminUsageQuery ) -> UserUsageStatsList: db_admin = await self.get_validated_admin_by_id(db, admin_id) + await self._ensure_owner_target_access(db_admin, admin) return await self._get_admin_usage( db, db_admin, @@ -390,90 +383,61 @@ async def get_admin_usage_by_id( async def bulk_remove_admins( self, db: AsyncSession, bulk_admins: BulkAdminSelection, admin: AdminDetails ) -> RemoveAdminsResponse: - """Remove multiple admins by username""" - db_admins = [] - for username in bulk_admins.usernames: - db_admin = await self.get_validated_admin(db, username) - if self.operator_type != OperatorType.CLI and db_admin.is_sudo: - await self.raise_error( - message=f"You're not allowed to remove sudo admin {username}. Use pasarguard cli / tui instead.", - code=403, - ) - db_admins.append(db_admin) - - usernames = [admin_obj.username for admin_obj in db_admins] - admin_ids = [admin_obj.id for admin_obj in db_admins] + """Remove multiple admins by ID.""" + db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, admin) + if any(self._is_owner_admin(db_admin) for db_admin in db_admins): + await self.raise_error(message="Owner cannot be deleted via this endpoint. Use the setup flow.", code=403) - # Batch delete using CRUD function + usernames = [a.username for a in db_admins] + admin_ids = [a.id for a in db_admins] await remove_admins(db, admin_ids) - if self.operator_type != OperatorType.CLI: - for username in usernames: - logger.info(f'Admin "{username}" deleted by admin "{admin.username}"') - asyncio.create_task(notification.remove_admin(username, admin.username)) + for username in usernames: + logger.info(f'Admin "{username}" deleted by admin "{admin.username}"') + asyncio.create_task(notification.remove_admin(username, admin.username)) return RemoveAdminsResponse(admins=usernames, count=len(db_admins)) @staticmethod - def _build_bulk_action_response(admins: list[AdminDetails | AdminSimple | Admin]) -> BulkAdminsActionResponse: - usernames = [admin.username for admin in admins] + def _build_bulk_action_response(admins: list) -> BulkAdminsActionResponse: + usernames = [a.username for a in admins] return BulkAdminsActionResponse(admins=usernames, count=len(usernames)) async def _get_validated_bulk_admins( - self, - db: AsyncSession, - usernames: list[str] | set[str], + self, db: AsyncSession, ids: list[int] | set[int], current_admin: AdminDetails ) -> list[Admin]: - db_admins: list[Admin] = [] - for username in usernames: - db_admins.append(await self.get_validated_admin(db, username=username)) - return db_admins + if not ids: + return [] - async def _ensure_can_change_admin_status( - self, - db_admin: Admin, - current_admin: AdminDetails, - *, - is_disabled: bool, - ) -> None: - if self.operator_type != OperatorType.CLI and db_admin.is_sudo and db_admin.username != current_admin.username: - await self.raise_error( - message="You're not allowed to modify sudoer's account. Use pasarguard cli / tui instead.", - code=403, - ) + ids_list = list(ids) - if is_disabled and db_admin.username == current_admin.username: - await self.raise_error(message="You're not allowed to disable your own account.", code=403) + admins = await get_admins(db, AdminListQuery(ids=ids_list, limit=len(ids_list))) - async def _ensure_can_manage_admin_users(self, db_admin: Admin, *, action: str) -> None: - if not db_admin.is_sudo: - return + # Verify every requested ID was found (mirrors the 404 in get_validated_admin_by_id) + found_ids = {a.id for a in admins} + missing = set(ids_list) - found_ids + if missing: + await self.raise_error(message="Admin not found", code=404) - messages = { - "disable": "You're not allowed to disable sudo admin users.", - "activate": "You're not allowed to enable sudo admin users.", - "remove": "You're not allowed to delete sudo admin users.", - } - await self.raise_error(message=messages[action], code=403) + for db_admin in admins: + await self._ensure_owner_target_access(db_admin, current_admin) + + return admins async def bulk_set_admins_disabled( - self, - db: AsyncSession, - bulk_admins: BulkAdminSelection, - current_admin: AdminDetails, - *, - is_disabled: bool, + self, db: AsyncSession, bulk_admins: BulkAdminSelection, current_admin: AdminDetails, *, is_disabled: bool ) -> BulkAdminsActionResponse: - db_admins = await self._get_validated_bulk_admins(db, bulk_admins.usernames) + """Enable or disable selected admins in bulk.""" + db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, current_admin) + target_status = AdminStatus.disabled if is_disabled else AdminStatus.active for db_admin in db_admins: - await self._ensure_can_change_admin_status(db_admin, current_admin, is_disabled=is_disabled) - - admins_to_update = [db_admin for db_admin in db_admins if db_admin.is_disabled != is_disabled] + if is_disabled and db_admin.username == current_admin.username: + await self.raise_error(message="You're not allowed to disable your own account.", code=403) + admins_to_update = [a for a in db_admins if a.status != target_status] for db_admin in admins_to_update: - db_admin.is_disabled = is_disabled - + db_admin.status = target_status await db.commit() for db_admin in admins_to_update: @@ -488,51 +452,38 @@ async def bulk_set_admins_disabled( async def bulk_reset_admins_usage( self, db: AsyncSession, bulk_admins: BulkAdminSelection, admin: AdminDetails ) -> BulkAdminsActionResponse: - db_admins = await self._get_validated_bulk_admins(db, bulk_admins.usernames) - + """Reset usage for selected admins by ID.""" + db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, admin) for db_admin in db_admins: db_admin = await reset_admin_usage(db, db_admin=db_admin) reseted_admin = AdminDetails.model_validate(db_admin) asyncio.create_task(notification.admin_usage_reset(reseted_admin, admin.username)) logger.info(f'Admin "{db_admin.username}" usage has been reset by admin "{admin.username}"') - return self._build_bulk_action_response(db_admins) async def bulk_disable_all_active_users_for_admins( self, db: AsyncSession, bulk_admins: BulkAdminSelection, admin: AdminDetails ) -> BulkAdminsActionResponse: - db_admins = await self._get_validated_bulk_admins(db, bulk_admins.usernames) - - for db_admin in db_admins: - await self._ensure_can_manage_admin_users(db_admin, action="disable") - + """Disable all active users under selected admins.""" + db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, admin) for db_admin in db_admins: await self._disable_all_active_users_for_admin(db, db_admin, admin) - return self._build_bulk_action_response(db_admins) async def bulk_activate_all_disabled_users_for_admins( self, db: AsyncSession, bulk_admins: BulkAdminSelection, admin: AdminDetails ) -> BulkAdminsActionResponse: - db_admins = await self._get_validated_bulk_admins(db, bulk_admins.usernames) - - for db_admin in db_admins: - await self._ensure_can_manage_admin_users(db_admin, action="activate") - + """Activate all disabled users under selected admins.""" + db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, admin) for db_admin in db_admins: await self._activate_all_disabled_users_for_admin(db, db_admin, admin) - return self._build_bulk_action_response(db_admins) async def bulk_remove_all_users_for_admins( self, db: AsyncSession, bulk_admins: BulkAdminSelection, admin: AdminDetails ) -> BulkAdminsActionResponse: - db_admins = await self._get_validated_bulk_admins(db, bulk_admins.usernames) - - for db_admin in db_admins: - await self._ensure_can_manage_admin_users(db_admin, action="remove") - + """Remove all users under selected admins.""" + db_admins = await self._get_validated_bulk_admins(db, bulk_admins.ids, admin) for db_admin in db_admins: await self._remove_all_users_for_admin(db, db_admin, admin) - return self._build_bulk_action_response(db_admins) diff --git a/app/operation/admin_role.py b/app/operation/admin_role.py new file mode 100644 index 000000000..853167349 --- /dev/null +++ b/app/operation/admin_role.py @@ -0,0 +1,113 @@ +import asyncio + +from sqlalchemy.exc import IntegrityError + +from app import notification +from app.db import AsyncSession +from app.db.crud.admin_role import ( + count_admins_by_role, + create_role, + delete_role, + get_role, + get_roles, + get_roles_simple, + modify_role, +) +from app.models.admin import AdminDetails +from app.models.admin_role import ( + AdminRoleCreate, + AdminRoleListQuery, + AdminRoleModify, + AdminRoleResponse, + AdminRolesResponse, + AdminRoleSimple, + AdminRolesSimpleResponse, +) +from app.operation import BaseOperation +from app.utils.logger import get_logger + +logger = get_logger("admin-role-operation") + + +class AdminRoleOperation(BaseOperation): + async def get_roles(self, db: AsyncSession, query: AdminRoleListQuery) -> AdminRolesResponse: + """List all roles with optional search and pagination.""" + roles, total = await get_roles(db, query) + return AdminRolesResponse( + roles=[AdminRoleResponse.model_validate(r) for r in roles], + total=total, + ) + + async def get_roles_simple(self, db: AsyncSession) -> AdminRolesSimpleResponse: + """List all roles as lightweight id/name/is_owner tuples.""" + rows = await get_roles_simple(db) + return AdminRolesSimpleResponse( + roles=[AdminRoleSimple(id=row[0], name=row[1], is_owner=row[2]) for row in rows], + total=len(rows), + ) + + async def get_role(self, db: AsyncSession, role_id: int) -> AdminRoleResponse: + """Fetch a single role by ID.""" + role = await get_role(db, role_id) + if role is None: + await self.raise_error(message="Role not found", code=404) + return AdminRoleResponse.model_validate(role) + + async def create_role(self, db: AsyncSession, data: AdminRoleCreate, admin: AdminDetails) -> AdminRoleResponse: + """Create a new role.""" + try: + role = await create_role(db, data) + await db.commit() + await db.refresh(role) + except IntegrityError: + await self.raise_error(message="Role with this name already exists", code=409, db=db) + + logger.info(f'Role "{role.name}" created by admin "{admin.username}"') + asyncio.create_task(notification.create_admin_role(AdminRoleResponse.model_validate(role), admin.username)) + return AdminRoleResponse.model_validate(role) + + async def modify_role( + self, db: AsyncSession, role_id: int, data: AdminRoleModify, admin: AdminDetails + ) -> AdminRoleResponse: + """Modify an existing role. Owner role cannot be modified.""" + role = await get_role(db, role_id) + if role is None: + await self.raise_error(message="Role not found", code=404) + + try: + role = await modify_role(db, role, data) + await db.commit() + except ValueError as e: + await self.raise_error(message=str(e), code=403) + except IntegrityError: + await self.raise_error(message="Role with this name already exists", code=409, db=db) + + logger.info(f'Role "{role.name}" modified by admin "{admin.username}"') + response = AdminRoleResponse.model_validate(role) + asyncio.create_task(notification.modify_admin_role(response, admin.username)) + return response + + async def delete_role(self, db: AsyncSession, role_id: int, admin: AdminDetails) -> None: + """Delete a role. Built-in roles (id 1, 2, 3) cannot be deleted.""" + role = await get_role(db, role_id) + if role is None: + await self.raise_error(message="Role not found", code=404) + + if role.is_builtin: + await self.raise_error(message=f"Cannot delete built-in role '{role.name}'", code=403) + + count = await count_admins_by_role(db, role_id) + if count > 0: + await self.raise_error( + message=f"Cannot delete role '{role.name}': {count} admin(s) are assigned to it", + code=409, + ) + + try: + await delete_role(db, role) + await db.commit() + except ValueError as e: + await self.raise_error(message=str(e), code=403) + + logger.info(f'Role "{role.name}" deleted by admin "{admin.username}"') + asyncio.create_task(notification.remove_admin_role(AdminRoleResponse.model_validate(role), admin.username)) diff --git a/app/operation/client_template.py b/app/operation/client_template.py index 4dc41cb21..062bef28c 100644 --- a/app/operation/client_template.py +++ b/app/operation/client_template.py @@ -186,12 +186,21 @@ async def bulk_remove_client_templates( self, db: AsyncSession, bulk_templates: BulkClientTemplateSelection, admin: AdminDetails ) -> RemoveClientTemplatesResponse: """Remove multiple client templates by ID - fast batch delete""" - db_templates = [] + ids_list = list(bulk_templates.ids) + db_templates_list, _ = await get_client_templates( + db, ClientTemplateListQuery(ids=ids_list, limit=len(ids_list)) + ) + + found_ids = {t.id for t in db_templates_list} + missing = set(ids_list) - found_ids + if missing: + await self.raise_error(message="Client template not found", code=404) + + db_templates = list(db_templates_list) templates_by_type = {} - # Validate all templates exist and can be deleted - for template_id in bulk_templates.ids: - db_template = await self.get_validated_client_template(db, template_id) + # Validate all templates can be deleted + for db_template in db_templates: template_type = ClientTemplateType(db_template.template_type) if db_template.is_system: @@ -201,7 +210,6 @@ async def bulk_remove_client_templates( if template_type not in templates_by_type: templates_by_type[template_type] = [] templates_by_type[template_type].append(db_template) - db_templates.append(db_template) # Validate we won't leave any type without templates for template_type, templates_of_type in templates_by_type.items(): diff --git a/app/operation/core.py b/app/operation/core.py index aaabf1323..203b48964 100644 --- a/app/operation/core.py +++ b/app/operation/core.py @@ -110,15 +110,20 @@ async def bulk_remove_cores( self, db: AsyncSession, bulk_cores: BulkCoreSelection, admin: AdminDetails ) -> RemoveCoresResponse: """Remove multiple cores by ID""" - db_cores = [] - for core_id in bulk_cores.ids: - if core_id == 1: + ids_list = list(bulk_cores.ids) + db_cores_list, _ = await get_core_configs(db, CoreListQuery(ids=ids_list, limit=len(ids_list))) + + found_ids = {c.id for c in db_cores_list} + missing = set(ids_list) - found_ids + if missing: + await self.raise_error(message="Core not found", code=404) + + for db_core in db_cores_list: + if db_core.id == 1: await self.raise_error(message="Cannot delete default core config", code=403) - db_core = await self.get_validated_core_config(db, core_id) - db_cores.append(db_core) - core_ids = [c.id for c in db_cores] - core_names = [c.name for c in db_cores] + core_ids = [c.id for c in db_cores_list] + core_names = [c.name for c in db_cores_list] # Batch delete using CRUD function await remove_cores(db, core_ids) @@ -131,4 +136,4 @@ async def bulk_remove_cores( await host_manager.setup_local(db) - return RemoveCoresResponse(cores=core_names, count=len(db_cores)) + return RemoveCoresResponse(cores=core_names, count=len(db_cores_list)) diff --git a/app/operation/group.py b/app/operation/group.py index 078bed51d..c6dedc93c 100644 --- a/app/operation/group.py +++ b/app/operation/group.py @@ -6,6 +6,7 @@ from app.db.crud.group import ( create_group, get_group, + get_groups_by_ids, get_groups_simple, load_group_attrs, modify_group, @@ -32,15 +33,24 @@ from app.models.user import BulkOperationDryRunResponse, UserListQuery from app.node.sync import sync_users from app.operation import BaseOperation, OperatorType +from app.operation.permissions import apply_group_access from app.utils.logger import get_logger logger = get_logger("group-operation") class GroupOperation(BaseOperation): + async def _get_group_with_access(self, db: AsyncSession, group_id: int, admin: Admin) -> Group: + """Fetch a group, returning 404 if it doesn't exist or is outside the admin's allowed set.""" + allowed = apply_group_access(admin, [group_id]) + # If allowed is an empty list, the id was filtered out → not accessible + if allowed is not None and group_id not in allowed: + await self.raise_error("Group not found", 404) + db_group = await self.get_validated_group(db, group_id) + return db_group + async def create_group(self, db: AsyncSession, new_group: GroupCreate, admin: Admin) -> Group: await self.check_inbound_tags(new_group.inbound_tags) - db_group = await create_group(db, new_group) group = GroupResponse.model_validate(db_group) @@ -50,7 +60,8 @@ async def create_group(self, db: AsyncSession, new_group: GroupCreate, admin: Ad logger.info(f'Group "{group.name}" created by admin "{admin.username}"') return group - async def get_all_groups(self, db: AsyncSession, query: GroupListQuery) -> GroupsResponse: + async def get_all_groups(self, db: AsyncSession, query: GroupListQuery, admin: Admin) -> GroupsResponse: + query.ids = apply_group_access(admin, query.ids) db_groups, count = await get_group(db, query) return GroupsResponse(groups=db_groups, total=count) @@ -58,18 +69,16 @@ async def get_groups_simple( self, db: AsyncSession, query: GroupSimpleListQuery, + admin: Admin, ) -> GroupsSimpleResponse: """Get lightweight group list with only id and name""" - # Call CRUD function + query.ids = apply_group_access(admin, query.ids) rows, total = await get_groups_simple(db=db, query=query) - - # Convert tuples to Pydantic models groups = [GroupSimple(id=row[0], name=row[1]) for row in rows] - return GroupsSimpleResponse(groups=groups, total=total) async def modify_group(self, db: AsyncSession, group_id: int, modified_group: GroupModify, admin: Admin) -> Group: - db_group = await self.get_validated_group(db, group_id) + db_group = await self._get_group_with_access(db, group_id, admin) if modified_group.inbound_tags is not None: await self.check_inbound_tags(modified_group.inbound_tags) db_group = await modify_group(db, db_group, modified_group) @@ -88,7 +97,7 @@ async def modify_group(self, db: AsyncSession, group_id: int, modified_group: Gr return group async def remove_group(self, db: AsyncSession, group_id: int, admin: Admin) -> None: - db_group = await self.get_validated_group(db, group_id) + db_group = await self._get_group_with_access(db, group_id, admin) users = await get_users(db, query=UserListQuery(group_ids=[db_group.id])) username_list = [user.username for user in users] @@ -132,15 +141,17 @@ async def bulk_remove_groups_by_id( self, db: AsyncSession, bulk_groups: BulkGroupSelection, admin: Admin ) -> RemoveGroupsResponse: """Remove multiple groups by ID""" - db_groups = [] - all_affected_usernames = set() + requested_ids = list(bulk_groups.ids) + allowed_ids = apply_group_access(admin, requested_ids) + # Fetch all allowed groups in one query + db_groups = await get_groups_by_ids(db, allowed_ids or [], load_users=False, load_inbounds=False) + # Verify all requested ids were found and accessible + found_ids = {g.id for g in db_groups} + for gid in requested_ids: + if gid not in found_ids: + await self.raise_error("Group not found", 404) - # Validate all groups exist - for group_id in bulk_groups.ids: - db_group = await self.get_validated_group(db, group_id) - db_groups.append(db_group) - - # Get all affected users before deletion + all_affected_usernames = set() for db_group in db_groups: users = await get_users(db, query=UserListQuery(group_ids=[db_group.id])) all_affected_usernames.update(user.username for user in users) @@ -148,15 +159,12 @@ async def bulk_remove_groups_by_id( group_ids = [g.id for g in db_groups] group_names = [g.name for g in db_groups] - # Batch delete using CRUD function await remove_groups(db, group_ids) - # Sync affected users if all_affected_usernames: users = await get_users(db, query=UserListQuery(username=list(all_affected_usernames))) await sync_users(users) - # Log and notify for name, group_id in zip(group_names, group_ids): logger.info(f'Group "{name}" deleted by admin "{admin.username}"') asyncio.create_task(notification.remove_group(group_id, admin.username)) @@ -176,9 +184,13 @@ async def bulk_set_groups_disabled( *, is_disabled: bool, ) -> BulkGroupsActionResponse: - db_groups = [] - for group_id in bulk_groups.ids: - db_groups.append(await self.get_validated_group(db, group_id)) + requested_ids = list(bulk_groups.ids) + allowed_ids = apply_group_access(admin, requested_ids) + db_groups = await get_groups_by_ids(db, allowed_ids or [], load_users=False, load_inbounds=False) + found_ids = {g.id for g in db_groups} + for gid in requested_ids: + if gid not in found_ids: + await self.raise_error("Group not found", 404) groups_to_update = [db_group for db_group in db_groups if db_group.is_disabled != is_disabled] diff --git a/app/operation/host.py b/app/operation/host.py index e600f9535..ca7e59b7c 100644 --- a/app/operation/host.py +++ b/app/operation/host.py @@ -139,10 +139,13 @@ async def bulk_remove_hosts( self, db: AsyncSession, bulk_hosts: BulkHostSelection, admin: AdminDetails ) -> RemoveHostsResponse: """Remove multiple hosts by ID""" - db_hosts = [] - for host_id in bulk_hosts.ids: - db_host = await self.get_validated_host(db, host_id) - db_hosts.append(db_host) + ids_list = list(bulk_hosts.ids) + db_hosts = await get_hosts(db, HostListQuery(ids=ids_list, limit=len(ids_list))) + + found_ids = {h.id for h in db_hosts} + missing = set(ids_list) - found_ids + if missing: + await self.raise_error(message="Host not found", code=404) host_ids = [h.id for h in db_hosts] @@ -171,9 +174,13 @@ async def bulk_set_hosts_disabled( *, is_disabled: bool, ) -> BulkHostsActionResponse: - db_hosts = [] - for host_id in bulk_hosts.ids: - db_hosts.append(await self.get_validated_host(db, host_id)) + ids_list = list(bulk_hosts.ids) + db_hosts = await get_hosts(db, HostListQuery(ids=ids_list, limit=len(ids_list))) + + found_ids = {h.id for h in db_hosts} + missing = set(ids_list) - found_ids + if missing: + await self.raise_error(message="Host not found", code=404) hosts_to_update = [db_host for db_host in db_hosts if db_host.is_disabled != is_disabled] diff --git a/app/operation/node.py b/app/operation/node.py index b679399f1..fca4dda06 100644 --- a/app/operation/node.py +++ b/app/operation/node.py @@ -1006,10 +1006,18 @@ async def bulk_remove_nodes( return RemoveNodesResponse(nodes=node_names, count=len(db_nodes)) async def _get_validated_nodes(self, db: AsyncSession, node_ids: list[int] | set[int]) -> list[Node]: - nodes: list[Node] = [] - for node_id in node_ids: - nodes.append(await self.get_validated_node(db, node_id)) - return nodes + if not node_ids: + return [] + + ids_list = list(node_ids) + db_nodes, _ = await get_nodes(db, NodeListQuery(ids=ids_list, limit=len(ids_list))) + + found_ids = {n.id for n in db_nodes} + missing = set(ids_list) - found_ids + if missing: + await self.raise_error(message="Node not found", code=404) + + return list(db_nodes) @staticmethod def _build_bulk_action_response(nodes: list[Node | NodeResponse]) -> BulkNodesActionResponse: diff --git a/app/operation/permissions.py b/app/operation/permissions.py new file mode 100644 index 000000000..df6368a28 --- /dev/null +++ b/app/operation/permissions.py @@ -0,0 +1,180 @@ +from functools import wraps + +from app.models.admin import AdminDetails +from app.models.admin_role import PermissionScope, RoleLimits + + +class PermissionDenied(Exception): + def __init__(self, detail: str = "Permission denied"): + self.detail = detail + super().__init__(detail) + + +class LimitExceeded(Exception): + def __init__(self, detail: str): + self.detail = detail + super().__init__(detail) + + +def _resolve_scope(action_perm) -> PermissionScope | None: + """Return PermissionScope if the action value is a scoped permission, else None.""" + if isinstance(action_perm, dict): + raw = action_perm.get("scope") + if raw is not None: + return PermissionScope(raw) + return None + + +def _get_resource_action(admin: AdminDetails, resource: str, action: str): + """Return the action permission value for resource+action, or None if missing.""" + permissions = admin.role.permissions if admin.role else None + resource_perms = permissions.get(resource) if permissions else None + return (resource_perms or {}).get(action) if resource_perms is not None else None + + +_READ_ACTIONS = frozenset({"read", "read_simple", "read_general", "logs", "stats"}) + + +def enforce_permission(admin: AdminDetails, resource: str, action: str) -> None: + """ + Check if admin has permission for resource+action. + Raises PermissionDenied if not allowed. + + Resolution order: + 1. role.is_owner → ALLOW unconditionally + 2. admin.is_limited: + - role.disabled_when_limited=True → DENY all actions + - role.disabled_when_limited=False → DENY write actions, allow read actions + 3. permissions[resource][action]: + - missing → DENY + - True → ALLOW + - {scope: NONE (0)} → DENY (explicitly disabled) + - {scope: OWN (1)} → ALLOW (scope enforced separately) + - {scope: ALL (2)} → ALLOW + """ + if admin.is_owner: + return + + if admin.is_limited: + if admin.role and admin.role.disabled_when_limited: + raise PermissionDenied("Admin is limited — all access blocked") + if action not in _READ_ACTIONS: + raise PermissionDenied("Admin is limited — write actions blocked") + + action_perm = _get_resource_action(admin, resource, action) + if not action_perm: + raise PermissionDenied(f"Permission denied: {resource}.{action}") + + scope = _resolve_scope(action_perm) + if scope is PermissionScope.NONE: + raise PermissionDenied(f"Permission denied: {resource}.{action}") + + +def enforce_scope(admin: AdminDetails, resource: str, action: str, target_admin_id: int | None) -> None: + """ + Enforce scope restriction (users resource only). Call AFTER enforce_permission. + Raises PermissionDenied if scope is OWN and target doesn't belong to this admin. + """ + if admin.is_owner: + return + + action_perm = _get_resource_action(admin, resource, action) + if _resolve_scope(action_perm) is PermissionScope.OWN and target_admin_id != admin.id: + raise PermissionDenied(f"Permission denied: {resource}.{action} (scope: own)") + + +def is_scope_all(admin: AdminDetails, resource: str, action: str) -> bool: + """ + Return True if the action has scope=ALL or True (no scope restriction). + Used to gate operations that require all-user access. + """ + if admin.is_owner: + return True + action_perm = _get_resource_action(admin, resource, action) + if action_perm is None: + return False + scope = _resolve_scope(action_perm) + if scope is None: + # True = allowed with no scope restriction = effectively all + return action_perm is True + return scope is PermissionScope.ALL + + +def get_scope_admin_id(admin: AdminDetails, resource: str, action: str) -> int | None: + """ + Return admin.id if scope=OWN, else None. + Pass as admin_id to CRUD queries so the DB enforces scope. + """ + if admin.is_owner: + return None + if admin.role is None: + return None + action_perm = _get_resource_action(admin, resource, action) + if _resolve_scope(action_perm) is PermissionScope.OWN: + return admin.id + return None + + +def get_effective_limits(admin: AdminDetails) -> RoleLimits: + """ + Merge role limits with per-admin permission_overrides. + Non-null override values win over role limits. + """ + base = admin.role.limits if admin.role else RoleLimits() + overrides = admin.permission_overrides + + if overrides is None: + return base + + return base.model_copy( + update={k: getattr(overrides, k) for k in overrides.model_fields_set if getattr(overrides, k) is not None} + ) + + +def get_allowed_group_ids(admin: AdminDetails) -> list[int] | None: + """None means all groups allowed (owner or no restriction).""" + if admin.is_owner or admin.role is None: + return None + return admin.role.access.allowed_group_ids + + +def get_allowed_template_ids(admin: AdminDetails) -> list[int] | None: + """None means all templates allowed (owner or no restriction).""" + if admin.is_owner or admin.role is None: + return None + return admin.role.access.allowed_template_ids + + +def _intersect_ids(requested: list[int] | None, allowed: list[int] | None) -> list[int] | None: + if allowed is None: + return requested + if requested is None: + return allowed + return [i for i in requested if i in set(allowed)] + + +def apply_group_access(admin: AdminDetails, ids: list[int] | None) -> list[int] | None: + """Intersect requested ids with admin's allowed_group_ids.""" + return _intersect_ids(ids, get_allowed_group_ids(admin)) + + +def apply_template_access(admin: AdminDetails, ids: list[int] | None) -> list[int] | None: + """Intersect requested ids with admin's allowed_template_ids.""" + return _intersect_ids(ids, get_allowed_template_ids(admin)) + + +def check_permission(resource: str, action: str): + """ + Decorator for operation-layer methods. + Signature: async def method(self, db, *args, admin: AdminDetails, **kwargs) + """ + + def decorator(func): + @wraps(func) + async def wrapper(self, db, *args, admin: AdminDetails, **kwargs): + enforce_permission(admin, resource, action) + return await func(self, db, *args, admin=admin, **kwargs) + + return wrapper + + return decorator diff --git a/app/operation/system.py b/app/operation/system.py index 8f3223a85..2e969670c 100644 --- a/app/operation/system.py +++ b/app/operation/system.py @@ -10,6 +10,7 @@ from app.db.models import UserStatus from app.models.admin import AdminDetails from app.models.system import InboundSummary, SystemStats +from app.operation.permissions import enforce_permission, PermissionDenied from app.utils.system import cpu_usage, disk_usage, get_uptime, memory_usage from . import BaseOperation @@ -25,10 +26,26 @@ async def get_system_stats(db: AsyncSession, admin: AdminDetails, admin_username disk_task = asyncio.create_task(asyncio.to_thread(disk_usage)) uptime_task = asyncio.create_task(asyncio.to_thread(get_uptime)) - admin_param = None - if admin.is_sudo and admin_username: - admin_param = await get_admin(db, admin_username, load_users=False, load_usage_logs=False) - elif not admin.is_sudo: + # Determine which admin's stats to show: + # - Owner with no admin_username: global system stats (all users) + # - Owner with admin_username: that admin's stats + # - Non-owner with admins.read + admin_username: that admin's stats + # - Non-owner (any other case): scoped to their own users only + admin_param: AdminDetails | None = None + if admin_username: + can_read_admins = False + if not admin.is_owner: + try: + enforce_permission(admin, "admins", "read") + can_read_admins = True + except PermissionDenied: + can_read_admins = False + if admin.is_owner or can_read_admins: + admin_param = await get_admin(db, admin_username, load_users=False, load_usage_logs=False) + else: + admin_param = admin + elif not admin.is_owner: + # Non-owner without an explicit target only sees their own stats admin_param = admin system_task = None diff --git a/app/operation/user.py b/app/operation/user.py index 63a406de5..e57151bcc 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -3,7 +3,7 @@ import secrets import warnings from collections import Counter -from datetime import datetime as dt, timedelta as td, timezone as tz +from datetime import datetime, datetime as dt, timedelta as td, timezone, timezone as tz from fastapi import HTTPException from pydantic import ValidationError @@ -22,6 +22,7 @@ update_users_expire, update_users_proxy_settings, ) +from app.db.crud.hwid import get_user_hwid_count from app.db.crud.user import ( bulk_reset_user_data_usage, bulk_revoke_user_sub, @@ -34,6 +35,7 @@ get_user_count_metric_stats, get_user_usages, get_users, + get_users_count_by_admin, get_users_simple, get_users_sub_update_list, get_users_subscription_agent_counts, @@ -88,11 +90,21 @@ UserUsageQuery, WireGuardPeerIPsReallocateResponse, ) -from app.node.sync import remove_user as sync_remove_user, sync_user, sync_users +from app.node.sync import remove_user as sync_remove_user, sync_users, sync_user from app.operation import BaseOperation, OperatorType +from app.operation.permissions import ( + PermissionDenied, + apply_template_access, + enforce_permission, + get_effective_limits, + get_scope_admin_id, + is_scope_all, +) from app.settings import hwid_settings, subscription_settings from app.utils.jwt import create_subscription_token from app.utils.logger import get_logger +from app.utils.helpers import fix_datetime_timezone +from app.utils.system import readable_duration, readable_size from app.utils.wireguard import ( build_wireguard_peer_ip_allocator, bulk_reallocate_wireguard_peer_ips as run_bulk_reallocate_wireguard_peer_ips, @@ -103,6 +115,16 @@ ) from config import subscription_env_settings, usage_settings, wireguard_settings + +def _has_permission(admin: AdminDetails, resource: str, action: str) -> bool: + """Return True if admin has the given resource+action permission (no scope check).""" + try: + enforce_permission(admin, resource, action) + return True + except PermissionDenied: + return False + + logger = get_logger("user-operation") _USER_AGENT_SPLIT_RE = re.compile(r"[;/\s\(\)]+") @@ -236,10 +258,35 @@ async def _persist_bulk_users( db_admin, users_to_create: list[UserCreate], groups: list, + *, + skip_per_user_limits: bool = False, ) -> list[str]: if not users_to_create: return [] + # Enforce limits first — before any expensive wireguard/proxy work + if not admin.is_owner: + limits = get_effective_limits(admin) + if limits.max_users is not None: + current_count = await get_users_count_by_admin(db, admin.id) + if current_count + len(users_to_create) > limits.max_users: + await self.raise_error( + message=f"Bulk create would exceed user limit ({limits.max_users})", code=400, db=db + ) + + if not skip_per_user_limits: + for user_to_create in users_to_create: + await self._enforce_user_limits( + db, + admin, + data_limit=user_to_create.data_limit, + expire=user_to_create.expire, + on_hold_expire_duration=user_to_create.on_hold_expire_duration, + hwid_limit=user_to_create.hwid_limit, + data_limit_reset_strategy=user_to_create.data_limit_reset_strategy, + next_plan=user_to_create.next_plan, + ) + wireguard_tags = await get_wireguard_tags_from_groups(groups) use_shared_allocator = bool(wireguard_tags) and wireguard_settings.enabled @@ -311,18 +358,123 @@ async def _prepare_user_proxy_settings( except ValueError as exc: await self.raise_error(message=str(exc), code=400, db=db) - async def create_user(self, db: AsyncSession, new_user: UserCreate, admin: AdminDetails) -> UserResponse: + async def _get_validated_template_with_access( + self, db: AsyncSession, template_id: int, admin: AdminDetails + ) -> UserTemplate: + """Fetch a user template and verify the admin is allowed to access it via allowed_template_ids.""" + allowed = apply_template_access(admin, [template_id]) + if allowed is not None and template_id not in allowed: + await self.raise_error("User Template not found", 404) + return await self.get_validated_user_template(db, template_id) + + async def _enforce_user_limits( + self, + db: AsyncSession, + admin: AdminDetails, + *, + data_limit: int | None = None, + expire: dt | int | None = None, + on_hold_expire_duration: int | None = None, + hwid_limit: int | None = None, + data_limit_reset_strategy=None, + next_plan=None, + check_max_users: bool = False, + ) -> None: + """Enforce role-level limits and feature flags. No-op for owner.""" + if admin.is_owner: + return + + limits = get_effective_limits(admin) + + if check_max_users and limits.max_users is not None: + current_count = await get_users_count_by_admin(db, admin.id) + if current_count >= limits.max_users: + await self.raise_error(message=f"User limit reached ({limits.max_users})", code=400, db=db) + + if data_limit is not None and data_limit > 0: + if limits.data_limit_min is not None and data_limit < limits.data_limit_min: + await self.raise_error( + message=f"Data limit must be at least {readable_size(limits.data_limit_min)}", code=400, db=db + ) + if limits.data_limit_max is not None and data_limit > limits.data_limit_max: + await self.raise_error( + message=f"Data limit cannot exceed {readable_size(limits.data_limit_max)}", code=400, db=db + ) + + if expire is not None and expire != 0: + expire_dt = fix_datetime_timezone(expire) + seconds = (expire_dt - datetime.now(timezone.utc)).total_seconds() + if limits.expire_min is not None and seconds < limits.expire_min: + await self.raise_error( + message=f"Expire must be at least {readable_duration(limits.expire_min)} from now", + code=400, + db=db, + ) + if limits.expire_max is not None and seconds > limits.expire_max: + await self.raise_error( + message=f"Expire cannot exceed {readable_duration(limits.expire_max)} from now", + code=400, + db=db, + ) + + if on_hold_expire_duration is not None and on_hold_expire_duration != 0: + if limits.expire_min is not None and on_hold_expire_duration < limits.expire_min: + await self.raise_error( + message=f"On-hold duration must be at least {readable_duration(limits.expire_min)}", + code=400, + db=db, + ) + if limits.expire_max is not None and on_hold_expire_duration > limits.expire_max: + await self.raise_error( + message=f"On-hold duration cannot exceed {readable_duration(limits.expire_max)}", + code=400, + db=db, + ) + + if hwid_limit is not None: + if limits.min_hwid_per_user is not None and hwid_limit < limits.min_hwid_per_user: + await self.raise_error( + message=f"HWID limit must be at least {limits.min_hwid_per_user}", code=400, db=db + ) + if limits.max_hwid_per_user is not None and hwid_limit > limits.max_hwid_per_user: + await self.raise_error(message=f"HWID limit cannot exceed {limits.max_hwid_per_user}", code=400, db=db) + + features = admin.role.features if admin.role else None + if features is not None: + if data_limit_reset_strategy is not None and not features.can_use_reset_strategy: + strategy_val = getattr(data_limit_reset_strategy, "value", str(data_limit_reset_strategy)) + if strategy_val != "no_reset": + await self.raise_error(message="Reset strategy is not allowed for your role", code=403, db=db) + if next_plan is not None and not features.can_use_next_plan: + await self.raise_error(message="Next plan is not allowed for your role", code=403, db=db) + + async def create_user( + self, db: AsyncSession, new_user: UserCreate, admin: AdminDetails, *, skip_role_limits: bool = False + ) -> UserResponse: hwid_conf = await hwid_settings() if new_user.hwid_limit is None: new_user.hwid_limit = hwid_conf.fallback_limit - if new_user.hwid_limit is not None and not admin.is_sudo: + if new_user.hwid_limit is not None and not admin.is_owner: if new_user.hwid_limit < hwid_conf.min_limit: await self.raise_error(message=f"HWID limit cannot be less than {hwid_conf.min_limit}", code=400, db=db) if hwid_conf.max_limit > 0 and (new_user.hwid_limit > hwid_conf.max_limit or new_user.hwid_limit == 0): await self.raise_error(message=f"HWID limit cannot exceed {hwid_conf.max_limit}", code=400, db=db) + if not skip_role_limits: + await self._enforce_user_limits( + db, + admin, + data_limit=new_user.data_limit, + expire=new_user.expire, + on_hold_expire_duration=new_user.on_hold_expire_duration, + hwid_limit=new_user.hwid_limit, + data_limit_reset_strategy=new_user.data_limit_reset_strategy, + next_plan=new_user.next_plan, + check_max_users=True, + ) + if new_user.next_plan is not None and new_user.next_plan.user_template_id is not None: await self.get_validated_user_template(db, new_user.next_plan.user_template_id) @@ -344,11 +496,15 @@ async def create_user(self, db: AsyncSession, new_user: UserCreate, admin: Admin return user async def _prepare_modified_user( - self, db: AsyncSession, db_user: User, modified_user: UserModify, admin: AdminDetails + self, + db: AsyncSession, + db_user: User, + modified_user: UserModify, + admin: AdminDetails, + *, + skip_role_limits: bool = False, ): if modified_user.hwid_limit is not None and modified_user.hwid_limit > 0: - from app.db.crud.hwid import get_user_hwid_count - current_count = await get_user_hwid_count(db, db_user.id) if current_count > modified_user.hwid_limit: await self.raise_error( @@ -357,7 +513,7 @@ async def _prepare_modified_user( db=db, ) - if modified_user.hwid_limit is not None and not admin.is_sudo: + if modified_user.hwid_limit is not None and not admin.is_owner: hwid_conf = await hwid_settings() if modified_user.hwid_limit < hwid_conf.min_limit: await self.raise_error(message=f"HWID limit cannot be less than {hwid_conf.min_limit}", code=400, db=db) @@ -366,6 +522,18 @@ async def _prepare_modified_user( ): await self.raise_error(message=f"HWID limit cannot exceed {hwid_conf.max_limit}", code=400, db=db) + if not skip_role_limits: + await self._enforce_user_limits( + db, + admin, + data_limit=modified_user.data_limit, + expire=modified_user.expire, + on_hold_expire_duration=modified_user.on_hold_expire_duration, + hwid_limit=modified_user.hwid_limit, + data_limit_reset_strategy=modified_user.data_limit_reset_strategy, + next_plan=modified_user.next_plan, + ) + validated_groups = None if modified_user.group_ids: validated_groups = await self.validate_all_groups(db, modified_user) @@ -426,9 +594,17 @@ async def _apply_modified_user( return user async def _modify_user( - self, db: AsyncSession, db_user: User, modified_user: UserModify, admin: AdminDetails + self, + db: AsyncSession, + db_user: User, + modified_user: UserModify, + admin: AdminDetails, + *, + skip_role_limits: bool = False, ) -> UserNotificationResponse: - validated_groups = await self._prepare_modified_user(db, db_user, modified_user, admin) + validated_groups = await self._prepare_modified_user( + db, db_user, modified_user, admin, skip_role_limits=skip_role_limits + ) return await self._apply_modified_user(db, db_user, modified_user, admin, validated_groups=validated_groups) async def modify_user( @@ -484,19 +660,27 @@ async def _get_validated_users_by_ids( load_usage_logs: bool = True, load_groups: bool = True, ) -> list[User]: - users: list[User] = [] - for user_id in user_ids: - users.append( - await self.get_validated_user_by_id( - db, - user_id, - admin, - load_admin=load_admin, - load_next_plan=load_next_plan, - load_usage_logs=load_usage_logs, - load_groups=load_groups, - ) - ) + if not user_ids: + return [] + + ids_list = list(user_ids) + + # Replicate the scope filter that get_validated_user_by_id applies per-user: + # non-owners with scope=OWN can only see their own users. + scope_admin_id = get_scope_admin_id(admin, "users", "read") + query = UserListQuery( + ids=ids_list, + admin_ids=[scope_admin_id] if scope_admin_id is not None else None, + limit=len(ids_list), + ) + users = await get_users(db, query=query) + + # Verify every requested ID was found (mirrors the 404 in get_validated_user_by_id) + found_ids = {user.id for user in users} + missing = set(ids_list) - found_ids + if missing: + await self.raise_error(message="User not found", code=404) + return users @staticmethod @@ -647,11 +831,10 @@ async def bulk_enable_users( return self._build_bulk_action_response(users) async def reset_users_data_usage(self, db: AsyncSession, admin: AdminDetails): - """Reset all users data usage""" - db_admin = await self.get_validated_admin(db, admin.username) + """Reset all users data usage — requires scope=all, resets every user.""" await reset_all_users_data_usage( db=db, - admin=db_admin, + admin=None, # None = all admins, not filtered by owner clean_chart_data=usage_settings.reset_user_usage_clean_chart_data, ) @@ -745,7 +928,7 @@ async def _get_user_usage( ) -> UserUsageStatsList: start, end = await self.validate_dates(start, end, True) - if not admin.is_sudo: + if not _has_permission(admin, "nodes", "stats"): node_id = None group_by_node = False @@ -815,7 +998,8 @@ async def get_users( query: UserListQuery, ) -> UsersResponse: """Get all users""" - if not admin.is_sudo: + scope_admin_id = get_scope_admin_id(admin, "users", "read_simple") + if scope_admin_id is not None: query = query.model_copy(update={"owner": [admin.username], "admin_ids": None}) users, count = await get_users( @@ -842,9 +1026,11 @@ async def get_users_simple( query: UserSimpleListQuery, ) -> UsersSimpleResponse: """Get lightweight user list with only id and username""" - # Authorization: non-sudo admins see only their users + scope_admin_id = get_scope_admin_id(admin, "users", "read") admin_filter = ( - None if admin.is_sudo else await get_admin(db, admin.username, load_users=False, load_usage_logs=False) + None + if scope_admin_id is None + else await get_admin(db, admin.username, load_users=False, load_usage_logs=False) ) # Call CRUD function @@ -870,17 +1056,20 @@ async def get_users_usage( node_id = query.node_id group_by_node = query.group_by_node - if not admin.is_sudo: + can_use_node_scope = _has_permission(admin, "nodes", "stats") + if not can_use_node_scope: node_id = None group_by_node = False + admins_filter = query.owner if is_scope_all(admin, "users", "read") else [admin.username] + return await get_all_users_usages( db=db, start=start, end=end, period=query.period, node_id=node_id, - admins=query.owner if admin.is_sudo else [admin.username], + admins=admins_filter, group_by_node=group_by_node, ) @@ -896,7 +1085,8 @@ async def get_users_count_metric( node_id = query.node_id group_by_node = query.group_by_node - if not admin.is_sudo: + can_use_node_scope = _has_permission(admin, "nodes", "stats") + if not can_use_node_scope: node_id = None group_by_node = False @@ -905,9 +1095,11 @@ async def get_users_count_metric( except ValueError as exc: await self.raise_error(message=str(exc), code=400) + admins_filter = query.owner if is_scope_all(admin, "users", "read") else [admin.username] + return await get_user_count_metric_stats( db=db, - admins=query.owner if admin.is_sudo else [admin.username], + admins=admins_filter, start=start, end=end, period=query.period, @@ -1047,7 +1239,7 @@ def _build_user_create_from_template( async def create_user_from_template( self, db: AsyncSession, new_template_user: CreateUserFromTemplate, admin: AdminDetails ) -> UserResponse: - user_template = await self.get_validated_user_template(db, new_template_user.user_template_id) + user_template = await self._get_validated_template_with_access(db, new_template_user.user_template_id, admin) if user_template.is_disabled: await self.raise_error("this template is disabled", 403) @@ -1057,13 +1249,15 @@ async def create_user_from_template( except HTTPException as exc: raise exc - return await self.create_user(db, new_user, admin) + # Template defines data_limit/expire/etc — only check max_users + await self._enforce_user_limits(db, admin, check_max_users=True) + return await self.create_user(db, new_user, admin, skip_role_limits=True) async def _modify_user_with_template( self, db: AsyncSession, db_user: User, modified_template: ModifyUserByTemplate, admin: AdminDetails ) -> UserResponse: original_status = db_user.status - user_template = await self.get_validated_user_template(db, modified_template.user_template_id) + user_template = await self._get_validated_template_with_access(db, modified_template.user_template_id, admin) if user_template.is_disabled: await self.raise_error("this template is disabled", 403) @@ -1078,7 +1272,7 @@ async def _modify_user_with_template( await self.raise_error(message=error_messages, code=400) modify_user = self.apply_settings(modify_user, user_template) - validated_groups = await self._prepare_modified_user(db, db_user, modify_user, admin) + validated_groups = await self._prepare_modified_user(db, db_user, modify_user, admin, skip_role_limits=True) if user_template.reset_usages: suppress_reset_status_change = ( @@ -1115,7 +1309,7 @@ async def bulk_create_users_from_template( self, db: AsyncSession, bulk_users: BulkUsersFromTemplate, admin: AdminDetails ) -> BulkUsersCreateResponse: template_payload = bulk_users - user_template = await self.get_validated_user_template(db, template_payload.user_template_id) + user_template = await self._get_validated_template_with_access(db, template_payload.user_template_id, admin) if user_template.is_disabled: await self.raise_error("this template is disabled", 403) @@ -1159,7 +1353,9 @@ def builder(username: str): groups = await self.validate_all_groups(db, users_to_create[0]) db_admin = await get_admin(db, admin.username, load_users=False, load_usage_logs=False) - subscription_urls = await self._persist_bulk_users(db, admin, db_admin, users_to_create, groups) + subscription_urls = await self._persist_bulk_users( + db, admin, db_admin, users_to_create, groups, skip_per_user_limits=True + ) return BulkUsersCreateResponse(subscription_urls=subscription_urls, created=len(subscription_urls)) @@ -1170,7 +1366,7 @@ async def bulk_apply_template_to_users( admin: AdminDetails, ) -> BulkUsersActionResponse: db_users = await self._get_validated_users_by_ids(db, body.ids, admin, load_usage_logs=False) - user_template = await self.get_validated_user_template(db, body.user_template_id) + user_template = await self._get_validated_template_with_access(db, body.user_template_id, admin) if user_template.is_disabled: await self.raise_error("this template is disabled", 403) @@ -1200,7 +1396,7 @@ async def bulk_apply_template_to_users( emit_status_change_notification=not suppress_reset_status_change, ) - modified_users.append(await self._modify_user(db, db_user, modify_user, admin)) + modified_users.append(await self._modify_user(db, db_user, modify_user, admin, skip_role_limits=True)) return self._build_bulk_action_response(modified_users) @@ -1245,7 +1441,7 @@ async def bulk_reallocate_wireguard_peer_ips( users = await get_bulk_wireguard_peer_ip_users( db, body, - admin_id=None if admin.is_sudo else admin.id, + admin_id=get_scope_admin_id(admin, "users", "update"), ) out = await run_bulk_reallocate_wireguard_peer_ips( @@ -1305,12 +1501,18 @@ async def get_users_sub_update_chart( return self._build_user_agent_chart(agent_counts) if admin_id: - if not admin.is_sudo and admin_id != admin.id: + can_read_admins = False + try: + enforce_permission(admin, "admins", "read") + can_read_admins = True + except PermissionDenied: + pass + if not can_read_admins and admin_id != admin.id: await self.raise_error(message="You're not allowed", code=403) - elif admin.is_sudo and admin_id != admin.id: + elif can_read_admins and admin_id != admin.id: await self.get_validated_admin_by_id(db, admin_id) else: - admin_id = None if admin.is_sudo else admin.id + admin_id = get_scope_admin_id(admin, "users", "read") agent_counts = await get_users_subscription_agent_counts(db, admin_id=admin_id) return self._build_user_agent_chart(agent_counts) diff --git a/app/operation/user_template.py b/app/operation/user_template.py index 0c8ddc39b..f06b92476 100644 --- a/app/operation/user_template.py +++ b/app/operation/user_template.py @@ -3,7 +3,7 @@ from app.db import AsyncSession import asyncio -from app.db.models import Admin +from app.db.models import Admin, UserTemplate from app.db.crud.user_template import ( create_user_template, get_user_templates, @@ -14,6 +14,7 @@ remove_user_templates, ) from app.operation import BaseOperation +from app.operation.permissions import apply_template_access from app.models.user_template import ( BulkUserTemplatesActionResponse, BulkUserTemplateSelection, @@ -33,6 +34,13 @@ class UserTemplateOperation(BaseOperation): + async def _get_template_with_access(self, db: AsyncSession, template_id: int, admin: Admin) -> UserTemplate: + """Fetch a user template, returning 404 if outside the admin's allowed set.""" + allowed = apply_template_access(admin, [template_id]) + if allowed is not None and template_id not in allowed: + await self.raise_error("User Template not found", 404) + return await self.get_validated_user_template(db, template_id) + async def create_user_template( self, db: AsyncSession, new_user_template: UserTemplateCreate, admin: Admin ) -> UserTemplateResponse: @@ -53,7 +61,7 @@ async def create_user_template( async def modify_user_template( self, db: AsyncSession, template_id: int, modified_user_template: UserTemplateModify, admin: Admin ) -> UserTemplateResponse: - db_user_template = await self.get_validated_user_template(db, template_id) + db_user_template = await self._get_template_with_access(db, template_id, admin) if modified_user_template.group_ids: for group_id in modified_user_template.group_ids: await self.get_validated_group(db, group_id) @@ -70,33 +78,39 @@ async def modify_user_template( return db_user_template async def remove_user_template(self, db: AsyncSession, template_id: int, admin: Admin) -> None: - db_user_template = await self.get_validated_user_template(db, template_id) + db_user_template = await self._get_template_with_access(db, template_id, admin) await remove_user_template(db, db_user_template) logger.info(f'User template "{db_user_template.name}" deleted by admin "{admin.username}"') asyncio.create_task(notification.remove_user_template(db_user_template.name, admin.username)) - async def get_user_templates(self, db: AsyncSession, query: UserTemplateListQuery) -> list[UserTemplateResponse]: + async def get_user_templates( + self, db: AsyncSession, query: UserTemplateListQuery, admin: Admin + ) -> list[UserTemplateResponse]: + query.ids = apply_template_access(admin, query.ids) return await get_user_templates(db, query) async def get_user_templates_simple( - self, db: AsyncSession, query: UserTemplateSimpleListQuery + self, db: AsyncSession, query: UserTemplateSimpleListQuery, admin: Admin ) -> UserTemplatesSimpleResponse: """Get lightweight user template list with only id and name""" + query.ids = apply_template_access(admin, query.ids) rows, total = await get_user_templates_simple(db=db, query=query) - templates = [UserTemplateSimple(id=row[0], name=row[1]) for row in rows] - return UserTemplatesSimpleResponse(templates=templates, total=total) async def bulk_remove_user_templates( self, db: AsyncSession, bulk_templates: BulkUserTemplateSelection, admin: Admin ) -> RemoveUserTemplatesResponse: """Remove multiple user templates by ID""" - db_templates = [] - for template_id in bulk_templates.ids: - db_template = await self.get_validated_user_template(db, template_id) - db_templates.append(db_template) + requested_ids = list(bulk_templates.ids) + allowed_ids = apply_template_access(admin, requested_ids) + # Fetch all in one query + db_templates = await get_user_templates(db, UserTemplateListQuery(ids=allowed_ids or [])) + found_ids = {t.id for t in db_templates} + for tid in requested_ids: + if tid not in found_ids: + await self.raise_error("User Template not found", 404) template_ids = [t.id for t in db_templates] template_names = [t.name for t in db_templates] @@ -124,9 +138,13 @@ async def bulk_set_user_templates_disabled( *, is_disabled: bool, ) -> BulkUserTemplatesActionResponse: - db_templates = [] - for template_id in bulk_templates.ids: - db_templates.append(await self.get_validated_user_template(db, template_id)) + requested_ids = list(bulk_templates.ids) + allowed_ids = apply_template_access(admin, requested_ids) + db_templates = await get_user_templates(db, UserTemplateListQuery(ids=allowed_ids or [])) + found_ids = {t.id for t in db_templates} + for tid in requested_ids: + if tid not in found_ids: + await self.raise_error("User Template not found", 404) templates_to_update = [db_template for db_template in db_templates if db_template.is_disabled != is_disabled] diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 3f547ceb5..18dcc6d3a 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -2,6 +2,7 @@ from . import ( admin, + admin_role, core, client_template, group, @@ -9,6 +10,7 @@ host, node, settings, + setup, subscription, system, user, @@ -21,6 +23,8 @@ routers = [ home.router, admin.router, + admin_role.router, + setup.router, system.router, settings.router, group.router, diff --git a/app/routers/admin.py b/app/routers/admin.py index 36d0ef608..f5d49fadb 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -12,6 +12,7 @@ AdminListQuery, AdminModify, AdminSimpleListQuery, + AdminStatus, AdminsResponse, AdminsSimpleResponse, AdminUsageQuery, @@ -25,11 +26,12 @@ from app.operation.admin import AdminOperation from app.utils import responses from app.utils.jwt import create_admin_token +from app.utils.request import get_client_ip from .authentication import ( - check_sudo_admin, get_current, get_current_with_metrics, + require_permission, validate_admin, validate_mini_app_admin, ) @@ -39,74 +41,56 @@ admin_operator = AdminOperation(operator_type=OperatorType.API) -def get_client_ip(request: Request) -> str: - """Extract the client's IP address from the request.""" - if request.client: - return request.client.host - return "Unknown" - - @router.post("/token", response_model=Token) async def admin_token( request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db) ): """Authenticate an admin and issue a token.""" client_ip = get_client_ip(request) - db_admin = await validate_admin(db, form_data.username, form_data.password) if not db_admin: asyncio.create_task(notification.admin_login(form_data.username, form_data.password, client_ip, False)) raise HTTPException( - status_code=401, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Bearer"}, + status_code=401, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"} ) - if db_admin.is_disabled: + if db_admin.status == AdminStatus.disabled: asyncio.create_task(notification.admin_login(form_data.username, form_data.password, client_ip, False)) raise HTTPException( - status_code=403, - detail="your account has been disabled", - headers={"WWW-Authenticate": "Bearer"}, + status_code=403, detail="your account has been disabled", headers={"WWW-Authenticate": "Bearer"} ) asyncio.create_task(notification.admin_login(db_admin.username, "", client_ip, True)) - return Token(access_token=await create_admin_token(db_admin.id, form_data.username, db_admin.is_sudo)) + return Token(access_token=await create_admin_token(db_admin.id, form_data.username)) @router.post("/miniapp/token", responses={409: responses._409}) async def admin_mini_app_token( request: Request, x_telegram_authorization: str = Header(), db: AsyncSession = Depends(get_db) ): - """Authenticate an admin and issue a token.""" - + """Authenticate an admin via Telegram MiniApp and issue a token.""" client_ip = get_client_ip(request) - db_admin = await validate_mini_app_admin(db, x_telegram_authorization) if not db_admin: + raise HTTPException(status_code=401, detail="admin not found.", headers={"WWW-Authenticate": "Bearer"}) + if db_admin.status == AdminStatus.disabled: raise HTTPException( - status_code=401, - detail="admin not found.", - headers={"WWW-Authenticate": "Bearer"}, - ) - if db_admin.is_disabled: - raise HTTPException( - status_code=403, - detail="your account has been disabled", - headers={"WWW-Authenticate": "Bearer"}, + status_code=403, detail="your account has been disabled", headers={"WWW-Authenticate": "Bearer"} ) asyncio.create_task(notification.admin_login(db_admin.username, "", client_ip, True)) - return Token(access_token=await create_admin_token(db_admin.id, db_admin.username, db_admin.is_sudo)) + return Token(access_token=await create_admin_token(db_admin.id, db_admin.username)) @router.post( "", response_model=AdminDetails, - responses={201: {"description": "Admin created successfully"}, 409: responses._409}, status_code=status.HTTP_201_CREATED, + responses={201: {"description": "Admin created successfully"}, 409: responses._409}, ) async def create_admin( - new_admin: AdminCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + new_admin: AdminCreate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "create")), ): - """Create a new admin if the current admin has sudo privileges.""" + """Create a new admin.""" return await admin_operator.create_admin(db, new_admin=new_admin, admin=admin) @@ -119,7 +103,7 @@ async def modify_admin( username: str, modified_admin: AdminModify, db: AsyncSession = Depends(get_db), - current_admin: AdminDetails = Depends(check_sudo_admin), + current_admin: AdminDetails = Depends(require_permission("admins", "update")), ): """Modify an existing admin's details.""" return await admin_operator.modify_admin( @@ -136,13 +120,10 @@ async def modify_admin_by_username( username: str, modified_admin: AdminModify, db: AsyncSession = Depends(get_db), - current_admin: AdminDetails = Depends(check_sudo_admin), + current_admin: AdminDetails = Depends(require_permission("admins", "update")), ): return await admin_operator.modify_admin( - db, - username=username, - modified_admin=modified_admin, - current_admin=current_admin, + db, username=username, modified_admin=modified_admin, current_admin=current_admin ) @@ -155,19 +136,18 @@ async def modify_admin_by_id( admin_id: int, modified_admin: AdminModify, db: AsyncSession = Depends(get_db), - current_admin: AdminDetails = Depends(check_sudo_admin), + current_admin: AdminDetails = Depends(require_permission("admins", "update")), ): return await admin_operator.modify_admin_by_id( - db, - admin_id=admin_id, - modified_admin=modified_admin, - current_admin=current_admin, + db, admin_id=admin_id, modified_admin=modified_admin, current_admin=current_admin ) @router.delete("/{username}", status_code=status.HTTP_204_NO_CONTENT) async def remove_admin( - username: str, db: AsyncSession = Depends(get_db), current_admin: AdminDetails = Depends(check_sudo_admin) + username: str, + db: AsyncSession = Depends(get_db), + current_admin: AdminDetails = Depends(require_permission("admins", "delete")), ): """Remove an admin from the database.""" await admin_operator.remove_admin(db, username=username, current_admin=current_admin) @@ -176,7 +156,9 @@ async def remove_admin( @router.delete("/by-username/{username}", status_code=status.HTTP_204_NO_CONTENT) async def remove_admin_by_username( - username: str, db: AsyncSession = Depends(get_db), current_admin: AdminDetails = Depends(check_sudo_admin) + username: str, + db: AsyncSession = Depends(get_db), + current_admin: AdminDetails = Depends(require_permission("admins", "delete")), ): await admin_operator.remove_admin(db, username=username, current_admin=current_admin) return {} @@ -184,7 +166,9 @@ async def remove_admin_by_username( @router.delete("/by-id/{admin_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_admin_by_id( - admin_id: int, db: AsyncSession = Depends(get_db), current_admin: AdminDetails = Depends(check_sudo_admin) + admin_id: int, + db: AsyncSession = Depends(get_db), + current_admin: AdminDetails = Depends(require_permission("admins", "delete")), ): await admin_operator.remove_admin_by_id(db, admin_id=admin_id, current_admin=current_admin) return {} @@ -200,10 +184,10 @@ def get_current_admin(admin: AdminDetails = Depends(get_current_with_metrics)): async def get_admins( query: Annotated[AdminListQuery, Depends(get_admin_list_query)], db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("admins", "read")), ): """Fetch a list of admins with optional filters for pagination and username.""" - return await admin_operator.get_admins(db, query=query) + return await admin_operator.get_admins(db, query=query, admin=admin) @router.get( @@ -211,14 +195,14 @@ async def get_admins( response_model=AdminsSimpleResponse, summary="Get lightweight admin list", description="Returns only id and username for admins. Optimized for dropdowns and autocomplete.", - dependencies=[Depends(check_sudo_admin)], ) async def get_admins_simple( query: Annotated[AdminSimpleListQuery, Depends(get_admin_simple_list_query)], db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "read_simple")), ): - """Get lightweight admin list with only id and username""" - return await admin_operator.get_admins_simple(db=db, query=query) + """Get lightweight admin list with only id and username.""" + return await admin_operator.get_admins_simple(db=db, query=query, admin=admin) @router.get( @@ -245,7 +229,7 @@ async def get_admin_usage_by_username( username: str, query: Annotated[AdminUsageQuery, Depends(get_admin_usage_query)], db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("admins", "read")), ): return await admin_operator.get_admin_usage(db, username=username, admin=admin, query=query) @@ -259,23 +243,27 @@ async def get_admin_usage_by_id( admin_id: int, query: Annotated[AdminUsageQuery, Depends(get_admin_usage_query)], db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("admins", "read")), ): return await admin_operator.get_admin_usage_by_id(db, admin_id=admin_id, admin=admin, query=query) @router.post("/{username}/users/disable", responses={404: responses._404}) async def disable_all_active_users( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "update")), ): - """Disable all active users under a specific admin""" + """Disable all active users under a specific admin.""" await admin_operator.disable_all_active_users(db, username=username, admin=admin) return {} @router.post("/by-username/{username}/users/disable", responses={404: responses._404}) async def disable_all_active_users_by_username( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "update")), ): await admin_operator.disable_all_active_users(db, username=username, admin=admin) return {} @@ -283,7 +271,9 @@ async def disable_all_active_users_by_username( @router.post("/by-id/{admin_id}/users/disable", responses={404: responses._404}) async def disable_all_active_users_by_id( - admin_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + admin_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "update")), ): await admin_operator.disable_all_active_users_by_id(db, admin_id=admin_id, admin=admin) return {} @@ -291,16 +281,20 @@ async def disable_all_active_users_by_id( @router.post("/{username}/users/activate", responses={404: responses._404}) async def activate_all_disabled_users( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "update")), ): - """Activate all disabled users under a specific admin""" + """Activate all disabled users under a specific admin.""" await admin_operator.activate_all_disabled_users(db, username=username, admin=admin) return {} @router.post("/by-username/{username}/users/activate", responses={404: responses._404}) async def activate_all_disabled_users_by_username( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "update")), ): await admin_operator.activate_all_disabled_users(db, username=username, admin=admin) return {} @@ -308,7 +302,9 @@ async def activate_all_disabled_users_by_username( @router.post("/by-id/{admin_id}/users/activate", responses={404: responses._404}) async def activate_all_disabled_users_by_id( - admin_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + admin_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "update")), ): await admin_operator.activate_all_disabled_users_by_id(db, admin_id=admin_id, admin=admin) return {} @@ -316,7 +312,9 @@ async def activate_all_disabled_users_by_id( @router.delete("/{username}/users", responses={403: responses._403, 404: responses._404}) async def remove_all_users( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "delete")), ): """Remove all users under a specific admin.""" deleted = await admin_operator.remove_all_users(db, username=username, admin=admin) @@ -325,7 +323,9 @@ async def remove_all_users( @router.delete("/by-username/{username}/users", responses={403: responses._403, 404: responses._404}) async def remove_all_users_by_username( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "delete")), ): deleted = await admin_operator.remove_all_users(db, username=username, admin=admin) return {"detail": f"operation has been successfuly done {deleted} users deleted"} @@ -333,7 +333,9 @@ async def remove_all_users_by_username( @router.delete("/by-id/{admin_id}/users", responses={403: responses._403, 404: responses._404}) async def remove_all_users_by_id( - admin_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + admin_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "delete")), ): deleted = await admin_operator.remove_all_users_by_id(db, admin_id=admin_id, admin=admin) return {"detail": f"operation has been successfuly done {deleted} users deleted"} @@ -341,7 +343,9 @@ async def remove_all_users_by_id( @router.post("/{username}/reset", response_model=AdminDetails, responses={404: responses._404}) async def reset_admin_usage( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "reset_usage")), ): """Resets usage of admin.""" return await admin_operator.reset_admin_usage(db, username=username, admin=admin) @@ -349,14 +353,18 @@ async def reset_admin_usage( @router.post("/by-username/{username}/reset", response_model=AdminDetails, responses={404: responses._404}) async def reset_admin_usage_by_username( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "reset_usage")), ): return await admin_operator.reset_admin_usage(db, username=username, admin=admin) @router.post("/by-id/{admin_id}/reset", response_model=AdminDetails, responses={404: responses._404}) async def reset_admin_usage_by_id( - admin_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + admin_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("admins", "reset_usage")), ): return await admin_operator.reset_admin_usage_by_id(db, admin_id=admin_id, admin=admin) @@ -369,9 +377,9 @@ async def reset_admin_usage_by_id( async def bulk_delete_admins( bulk_admins: BulkAdminSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("admins", "delete")), ): - """Delete selected admins by username.""" + """Delete selected admins by ID.""" return await admin_operator.bulk_remove_admins(db, bulk_admins, admin) @@ -383,9 +391,9 @@ async def bulk_delete_admins( async def bulk_reset_admins_usage( bulk_admins: BulkAdminSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("admins", "reset_usage")), ): - """Reset usage for selected admins by username.""" + """Reset usage for selected admins by ID.""" return await admin_operator.bulk_reset_admins_usage(db, bulk_admins, admin) @@ -397,9 +405,9 @@ async def bulk_reset_admins_usage( async def bulk_disable_admins( bulk_admins: BulkAdminSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("admins", "update")), ): - """Disable selected admins by username.""" + """Disable selected admins by ID.""" return await admin_operator.bulk_set_admins_disabled(db, bulk_admins, admin, is_disabled=True) @@ -411,9 +419,9 @@ async def bulk_disable_admins( async def bulk_enable_admins( bulk_admins: BulkAdminSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("admins", "update")), ): - """Enable selected admins by username.""" + """Enable selected admins by ID.""" return await admin_operator.bulk_set_admins_disabled(db, bulk_admins, admin, is_disabled=False) @@ -425,7 +433,7 @@ async def bulk_enable_admins( async def bulk_disable_all_active_users( bulk_admins: BulkAdminSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("admins", "update")), ): """Disable all active users under selected admins.""" return await admin_operator.bulk_disable_all_active_users_for_admins(db, bulk_admins, admin) @@ -439,7 +447,7 @@ async def bulk_disable_all_active_users( async def bulk_activate_all_disabled_users( bulk_admins: BulkAdminSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("admins", "update")), ): """Activate all disabled users under selected admins.""" return await admin_operator.bulk_activate_all_disabled_users_for_admins(db, bulk_admins, admin) @@ -453,7 +461,7 @@ async def bulk_activate_all_disabled_users( async def bulk_remove_all_users( bulk_admins: BulkAdminSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("admins", "delete")), ): """Remove all users under selected admins.""" return await admin_operator.bulk_remove_all_users_for_admins(db, bulk_admins, admin) diff --git a/app/routers/admin_role.py b/app/routers/admin_role.py new file mode 100644 index 000000000..a0428f58b --- /dev/null +++ b/app/routers/admin_role.py @@ -0,0 +1,101 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, status + +from app.db import AsyncSession, get_db +from app.models.admin import AdminDetails +from app.models.admin_role import ( + AdminRoleCreate, + AdminRoleListQuery, + AdminRoleModify, + AdminRoleResponse, + AdminRolesResponse, + AdminRolesSimpleResponse, +) +from app.operation import OperatorType +from app.operation.admin_role import AdminRoleOperation +from app.utils import responses + +from .authentication import require_owner, require_permission +from .dependencies import get_admin_role_list_query + +router = APIRouter( + tags=["Admin Roles"], + prefix="/api/admin-role", + responses={401: responses._401, 403: responses._403}, +) +role_operator = AdminRoleOperation(operator_type=OperatorType.API) + + +@router.get("s", response_model=AdminRolesResponse) +async def get_roles( + query: Annotated[AdminRoleListQuery, Depends(get_admin_role_list_query)], + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(require_permission("admin_roles", "read")), +): + """List all roles.""" + return await role_operator.get_roles(db, query) + + +@router.get("s/simple", response_model=AdminRolesSimpleResponse) +async def get_roles_simple( + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(require_permission("admin_roles", "read_simple")), +): + """List all roles as lightweight id/name/is_owner tuples.""" + return await role_operator.get_roles_simple(db) + + +@router.get("/{role_id}", response_model=AdminRoleResponse, responses={404: responses._404}) +async def get_role( + role_id: int, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(require_permission("admin_roles", "read")), +): + """Get a role by ID.""" + return await role_operator.get_role(db, role_id) + + +@router.post( + "", + response_model=AdminRoleResponse, + status_code=status.HTTP_201_CREATED, + responses={409: responses._409}, +) +async def create_role( + data: AdminRoleCreate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_owner), +): + """Create a new role. Owner only.""" + return await role_operator.create_role(db, data, admin) + + +@router.put( + "/{role_id}", + response_model=AdminRoleResponse, + responses={403: responses._403, 404: responses._404, 409: responses._409}, +) +async def modify_role( + role_id: int, + data: AdminRoleModify, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_owner), +): + """Modify a role. Owner only. Owner role cannot be modified.""" + return await role_operator.modify_role(db, role_id, data, admin) + + +@router.delete( + "/{role_id}", + status_code=status.HTTP_204_NO_CONTENT, + responses={403: responses._403, 404: responses._404, 409: responses._409}, +) +async def delete_role( + role_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_owner), +): + """Delete a role. Owner only. Built-in roles and in-use roles cannot be deleted.""" + await role_operator.delete_role(db, role_id, admin) + return {} diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 4fc5b9e6f..b7bdb2150 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -3,7 +3,6 @@ from aiogram.utils.web_app import WebAppInitData, safe_parse_webapp_init_data from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer - from sqlalchemy import func, select from app.db import AsyncSession, get_db @@ -14,14 +13,25 @@ get_admin_by_telegram_id, ) from app.db.models import Admin, AdminUsageLogs, User -from app.models.admin import AdminDetails, AdminValidationResult, verify_password +from app.models.admin import AdminDetails, AdminRoleData, AdminStatus, AdminValidationResult, verify_password +from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions from app.models.settings import Telegram +from app.operation.permissions import PermissionDenied, enforce_permission, is_scope_all from app.settings import telegram_settings from app.utils.jwt import get_admin_payload from config import auth_settings, runtime_settings oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/token") +# Owner-level role data given to env admins — full permissions, bypasses all checks +_ENV_ADMIN_ROLE = AdminRoleData( + is_owner=True, + permissions=RolePermissions(), # is_owner=True bypasses permission checks entirely + limits=RoleLimits(), + features=RoleFeatures(), + access=RoleAccess(), +) + def _build_admin_details( db_admin: Admin, @@ -30,13 +40,14 @@ def _build_admin_details( reseted_usage: int | None = None, ) -> AdminDetails: used_traffic = int(db_admin.used_traffic or 0) + role = AdminRoleData.model_validate(db_admin.role) if db_admin.role is not None else None return AdminDetails( id=db_admin.id, username=db_admin.username, - is_sudo=db_admin.is_sudo, total_users=int(total_users or 0), used_traffic=used_traffic, - is_disabled=db_admin.is_disabled, + data_limit=db_admin.data_limit, + status=db_admin.status, telegram_id=db_admin.telegram_id, discord_webhook=db_admin.discord_webhook, sub_domain=db_admin.sub_domain, @@ -47,6 +58,10 @@ def _build_admin_details( discord_id=db_admin.discord_id, sub_template=db_admin.sub_template, lifetime_used_traffic=None if reseted_usage is None else int(reseted_usage or 0) + used_traffic, + role=role, + permission_overrides=RoleLimits.model_validate(db_admin.permission_overrides) + if db_admin.permission_overrides + else None, ) @@ -61,7 +76,7 @@ def _is_token_valid_for_admin(db_admin: Admin, payload: dict) -> bool: async def get_admin(db: AsyncSession, token: str) -> AdminDetails | None: payload = await get_admin_payload(token) if not payload: - return + return None db_admin = None if payload.get("admin_id") is not None: @@ -72,18 +87,20 @@ async def get_admin(db: AsyncSession, token: str) -> AdminDetails | None: if db_admin: if not _is_token_valid_for_admin(db_admin, payload): - return - + return None return _build_admin_details(db_admin) - elif payload["username"] in auth_settings.sudoers and payload["is_sudo"] is True: - return AdminDetails(username=payload["username"], is_sudo=True) + # Env admin fallback — no DB record, but username is a known env admin + if payload["username"] in auth_settings.sudoers: + return AdminDetails(username=payload["username"], role=_ENV_ADMIN_ROLE) + + return None async def get_admin_with_metrics(db: AsyncSession, token: str) -> AdminDetails | None: payload = await get_admin_payload(token) if not payload: - return + return None total_users_subquery = ( select(func.count(User.id)).where(User.admin_id == Admin.id).correlate(Admin).scalar_subquery() @@ -94,36 +111,27 @@ async def get_admin_with_metrics(db: AsyncSession, token: str) -> AdminDetails | .correlate(Admin) .scalar_subquery() ) + + base_stmt = select(Admin, total_users_subquery, reseted_usage_subquery) + if payload.get("admin_id") is not None: - admin_row = ( - await db.execute( - select(Admin, total_users_subquery, reseted_usage_subquery).where(Admin.id == payload["admin_id"]) - ) - ).one_or_none() + admin_row = (await db.execute(base_stmt.where(Admin.id == payload["admin_id"]))).one_or_none() if admin_row is None: - admin_row = ( - await db.execute( - select(Admin, total_users_subquery, reseted_usage_subquery).where( - Admin.username == payload["username"] - ) - ) - ).one_or_none() + admin_row = (await db.execute(base_stmt.where(Admin.username == payload["username"]))).one_or_none() else: - admin_row = ( - await db.execute( - select(Admin, total_users_subquery, reseted_usage_subquery).where(Admin.username == payload["username"]) - ) - ).one_or_none() + admin_row = (await db.execute(base_stmt.where(Admin.username == payload["username"]))).one_or_none() if admin_row: db_admin, total_users, reseted_usage = admin_row if not _is_token_valid_for_admin(db_admin, payload): - return - + return None return _build_admin_details(db_admin, total_users=total_users, reseted_usage=reseted_usage) - elif payload["username"] in auth_settings.sudoers and payload["is_sudo"] is True: - return AdminDetails(username=payload["username"], is_sudo=True) + # Env admin fallback — no DB record, but username is a known env admin + if payload["username"] in auth_settings.sudoers: + return AdminDetails(username=payload["username"], role=_ENV_ADMIN_ROLE) + + return None async def get_current(db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme)): @@ -134,13 +142,12 @@ async def get_current(db: AsyncSession = Depends(get_db), token: str = Depends(o detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) - if admin.is_disabled: + if admin.status == AdminStatus.disabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="your account has been disabled", headers={"WWW-Authenticate": "Bearer"}, ) - return admin @@ -152,39 +159,75 @@ async def get_current_with_metrics(db: AsyncSession = Depends(get_db), token: st detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) - if admin.is_disabled: + if admin.status == AdminStatus.disabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="your account has been disabled", headers={"WWW-Authenticate": "Bearer"}, ) - return admin -async def check_sudo_admin(admin: AdminDetails = Depends(get_current)): - if not admin.is_sudo: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You're not allowed") +def require_permission(resource: str, action: str): + """FastAPI dependency factory — checks RBAC permission for resource+action.""" + + async def _check(admin: AdminDetails = Depends(get_current)): + try: + enforce_permission(admin, resource, action) + except PermissionDenied as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + return admin + + return _check + + +def require_scope_all(resource: str, action: str): + """ + FastAPI dependency factory — checks RBAC permission AND requires scope=all (or owner). + Used for operations that affect all users regardless of ownership. + """ + + async def _check(admin: AdminDetails = Depends(get_current)): + try: + enforce_permission(admin, resource, action) + except PermissionDenied as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + + # Scope check: must be owner or have scope=ALL (or True = no scope restriction) + if not is_scope_all(admin, resource, action): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Permission denied: {resource}.{action} requires scope=all", + ) + return admin + + return _check + + +async def require_owner(admin: AdminDetails = Depends(get_current)): + """FastAPI dependency — allows only the owner (is_owner=True).""" + if not admin.is_owner: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can perform this action") return admin async def validate_admin(db: AsyncSession, username: str, password: str) -> AdminValidationResult | None: - """Validate admin credentials with environment variables or database.""" - + """Validate admin credentials against the database, with env admin fallback.""" db_admin = await get_admin_by_username(db, username, load_users=False, load_usage_logs=False) if db_admin and await verify_password(password, db_admin.hashed_password): return AdminValidationResult( id=db_admin.id, username=db_admin.username, - is_sudo=db_admin.is_sudo, - is_disabled=db_admin.is_disabled, + status=db_admin.status, ) + # Env admin fallback — only allowed in debug/testing if not db_admin and auth_settings.sudoers.get(username) == password: if not runtime_settings.debug: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="env admin not allowed in production") + return AdminValidationResult(username=username, status=AdminStatus.active) - return AdminValidationResult(username=username, is_sudo=True, is_disabled=False) + return None async def validate_mini_app_admin(db: AsyncSession, token: str) -> AdminValidationResult | None: @@ -218,6 +261,6 @@ async def validate_mini_app_admin(db: AsyncSession, token: str) -> AdminValidati return AdminValidationResult( id=db_admin.id, username=db_admin.username, - is_sudo=db_admin.is_sudo, - is_disabled=db_admin.is_disabled, + status=db_admin.status, ) + return None diff --git a/app/routers/client_template.py b/app/routers/client_template.py index 4e2e35561..e9a9183f6 100644 --- a/app/routers/client_template.py +++ b/app/routers/client_template.py @@ -15,7 +15,7 @@ from app.operation.client_template import ClientTemplateOperation from app.utils import responses -from .authentication import check_sudo_admin, get_current +from .authentication import require_permission from .dependencies import get_client_template_list_query, get_client_template_simple_list_query router = APIRouter( @@ -31,7 +31,7 @@ async def create_client_template( new_template: ClientTemplateCreate, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("client_templates", "create")), ): return await client_template_operator.create_client_template(db, new_template, admin) @@ -40,7 +40,7 @@ async def create_client_template( async def get_client_template( template_id: int, db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(get_current), + _: AdminDetails = Depends(require_permission("client_templates", "read")), ): return await client_template_operator.get_validated_client_template(db, template_id) @@ -50,7 +50,7 @@ async def modify_client_template( template_id: int, modified_template: ClientTemplateModify, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("client_templates", "update")), ): return await client_template_operator.modify_client_template(db, template_id, modified_template, admin) @@ -59,7 +59,7 @@ async def modify_client_template( async def remove_client_template( template_id: int, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("client_templates", "delete")), ): await client_template_operator.remove_client_template(db, template_id, admin) return {} @@ -69,7 +69,7 @@ async def remove_client_template( async def get_client_templates( query=Depends(get_client_template_list_query), db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(get_current), + _: AdminDetails = Depends(require_permission("client_templates", "read")), ): return await client_template_operator.get_client_templates(db, query=query) @@ -78,7 +78,7 @@ async def get_client_templates( async def get_client_templates_simple( query=Depends(get_client_template_simple_list_query), db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(get_current), + _: AdminDetails = Depends(require_permission("client_templates", "read_simple")), ): return await client_template_operator.get_client_templates_simple(db=db, query=query) @@ -91,7 +91,7 @@ async def get_client_templates_simple( async def bulk_delete_client_templates( bulk_templates: BulkClientTemplateSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("client_templates", "delete")), ): """Delete selected client templates by ID.""" return await client_template_operator.bulk_remove_client_templates(db, bulk_templates, admin) diff --git a/app/routers/core.py b/app/routers/core.py index 5623bd0db..26f3100da 100644 --- a/app/routers/core.py +++ b/app/routers/core.py @@ -15,7 +15,7 @@ from app.operation.node import NodeOperation from app.utils import responses -from .authentication import check_sudo_admin +from .authentication import require_permission from .dependencies import get_core_list_query, get_core_simple_list_query core_operator = CoreOperation(operator_type=OperatorType.API) @@ -25,7 +25,9 @@ @router.post("", response_model=CoreResponse, status_code=status.HTTP_201_CREATED) async def create_core_config( - new_core: CoreCreate, admin: AdminDetails = Depends(check_sudo_admin), db: AsyncSession = Depends(get_db) + new_core: CoreCreate, + admin: AdminDetails = Depends(require_permission("cores", "create")), + db: AsyncSession = Depends(get_db), ): """Create a new core configuration.""" return await core_operator.create_core(db, new_core, admin) @@ -33,7 +35,7 @@ async def create_core_config( @router.get("/{core_id}", response_model=CoreResponse) async def get_core_config( - core_id: int, _: AdminDetails = Depends(check_sudo_admin), db: AsyncSession = Depends(get_db) + core_id: int, _: AdminDetails = Depends(require_permission("cores", "read")), db: AsyncSession = Depends(get_db) ) -> dict: """Get a core configuration by its ID.""" return await core_operator.get_validated_core_config(db, core_id) @@ -44,7 +46,7 @@ async def modify_core_config( core_id: int, restart_nodes: bool, modified_core: CoreCreate, - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("cores", "update")), db: AsyncSession = Depends(get_db), ): """Update an existing core configuration.""" @@ -60,7 +62,7 @@ async def modify_core_config( async def delete_core_config( core_id: int, restart_nodes: bool = False, - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("cores", "delete")), db: AsyncSession = Depends(get_db), ): """Delete a core configuration.""" @@ -75,7 +77,7 @@ async def delete_core_config( @router.get("s", response_model=CoreResponseList) async def get_all_cores( query=Depends(get_core_list_query), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("cores", "read")), db: AsyncSession = Depends(get_db), ): """Get a list of all core configurations.""" @@ -90,7 +92,7 @@ async def get_all_cores( ) async def get_cores_simple( query=Depends(get_core_simple_list_query), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("cores", "read_simple")), db: AsyncSession = Depends(get_db), ): """Get lightweight core list with only id and name""" @@ -99,7 +101,9 @@ async def get_cores_simple( @router.post("/{core_id}/restart", status_code=status.HTTP_204_NO_CONTENT) async def restart_core( - core_id: int, admin: AdminDetails = Depends(check_sudo_admin), db: AsyncSession = Depends(get_db) + core_id: int, + admin: AdminDetails = Depends(require_permission("cores", "update")), + db: AsyncSession = Depends(get_db), ): """restart nodes related to the core config""" @@ -115,7 +119,7 @@ async def restart_core( async def bulk_delete_cores( bulk_cores: BulkCoreSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("cores", "delete")), ): """Delete selected cores by ID.""" return await core_operator.bulk_remove_cores(db, bulk_cores, admin) diff --git a/app/routers/dependencies/__init__.py b/app/routers/dependencies/__init__.py index 4324c2fc7..f091ff4e6 100644 --- a/app/routers/dependencies/__init__.py +++ b/app/routers/dependencies/__init__.py @@ -1,4 +1,5 @@ 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 .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 @@ -25,6 +26,8 @@ "get_admin_list_query", "get_admin_simple_list_query", "get_admin_usage_query", + # admin_role + "get_admin_role_list_query", # client_template "get_client_template_list_query", "get_client_template_simple_list_query", diff --git a/app/routers/dependencies/admin_role.py b/app/routers/dependencies/admin_role.py new file mode 100644 index 000000000..21a41f94d --- /dev/null +++ b/app/routers/dependencies/admin_role.py @@ -0,0 +1,15 @@ +from fastapi import Query + +from app.models.admin_role import AdminRoleListQuery + +from ._common import make_query_dependency, query_param + +get_admin_role_list_query = make_query_dependency( + AdminRoleListQuery, + field_overrides={ + "search": Query(None), + "offset": Query(None), + "limit": Query(None), + "sort": query_param(str | None, None), + }, +) diff --git a/app/routers/group.py b/app/routers/group.py index 9d6981c69..a6ced4efb 100644 --- a/app/routers/group.py +++ b/app/routers/group.py @@ -22,7 +22,7 @@ from app.utils import responses from .dependencies import get_group_list_query, get_group_simple_list_query -from .authentication import check_sudo_admin, get_current +from .authentication import require_permission router = APIRouter(prefix="/api/group", tags=["Groups"], responses={401: responses._401, 403: responses._403}) group_operator = GroupOperation(OperatorType.API) @@ -33,10 +33,12 @@ response_model=GroupResponse, status_code=status.HTTP_201_CREATED, summary="Create a new group", - description="Creates a new group in the system. Only sudo administrators can create groups.", + description="Creates a new group in the system. Only authorized administrators can create groups.", ) async def create_group( - new_group: GroupCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + new_group: GroupCreate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("groups", "create")), ): """ Create a new group in the system. @@ -53,7 +55,7 @@ async def create_group( Raises: 401: Unauthorized - If not authenticated - 403: Forbidden - If not sudo admin + 403: Forbidden - If not authorized admin """ return await group_operator.create_group(db, new_group, admin) @@ -67,7 +69,7 @@ async def create_group( async def get_all_groups( query: Annotated[GroupListQuery, Depends(get_group_list_query)], db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("groups", "read")), ): """ Retrieve a list of all groups with optional pagination. @@ -87,7 +89,7 @@ async def get_all_groups( Raises: 401: Unauthorized - If not authenticated """ - return await group_operator.get_all_groups(db, query) + return await group_operator.get_all_groups(db, query, admin) @router.get( @@ -99,10 +101,10 @@ async def get_all_groups( async def get_groups_simple( query: Annotated[GroupSimpleListQuery, Depends(get_group_simple_list_query)], db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("groups", "read_simple")), ): """Get lightweight group list with only id and name""" - return await group_operator.get_groups_simple(db=db, query=query) + return await group_operator.get_groups_simple(db=db, query=query, admin=admin) @router.get( @@ -112,7 +114,11 @@ async def get_groups_simple( description="Retrieves detailed information about a specific group by its ID.", responses={404: responses._404}, ) -async def get_group(group_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(get_current)): +async def get_group( + group_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("groups", "read")), +): """ Get a specific group by its **ID**. @@ -129,21 +135,21 @@ async def get_group(group_id: int, db: AsyncSession = Depends(get_db), _: AdminD Raises: 404: Not Found - If group doesn't exist """ - return await group_operator.get_validated_group(db, group_id) + return await group_operator._get_group_with_access(db, group_id, admin) @router.put( "/{group_id}", response_model=GroupResponse, summary="Modify group", - description="Updates an existing group's information. Only sudo administrators can modify groups.", + description="Updates an existing group's information. Only authorized administrators can modify groups.", responses={404: responses._404}, ) async def modify_group( group_id: int, modified_group: GroupModify, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("groups", "update")), ): """ Modify an existing group's information. @@ -160,7 +166,7 @@ async def modify_group( Raises: 401: Unauthorized - If not authenticated - 403: Forbidden - If not sudo admin + 403: Forbidden - If not authorized admin 404: Not Found - If group doesn't exist """ return await group_operator.modify_group(db, group_id, modified_group, admin) @@ -170,11 +176,13 @@ async def modify_group( "/{group_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove group", - description="Deletes a group from the system. Only sudo administrators can delete groups.", + description="Deletes a group from the system. Only authorized administrators can delete groups.", responses={404: responses._404}, ) async def remove_group( - group_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + group_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("groups", "delete")), ): """ Remove a group by its **ID**. @@ -184,7 +192,7 @@ async def remove_group( Raises: 401: Unauthorized - If not authenticated - 403: Forbidden - If not sudo admin + 403: Forbidden - If not authorized admin 404: Not Found - If group doesn't exist """ await group_operator.remove_group(db, group_id, admin) @@ -197,7 +205,9 @@ async def remove_group( response_description="Success confirmation", ) async def bulk_add_groups_to_users( - bulk_group: BulkGroup, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin) + bulk_group: BulkGroup, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(require_permission("groups", "update")), ): """ Bulk assign groups to multiple users, users under specific admins, or all users. @@ -220,7 +230,9 @@ async def bulk_add_groups_to_users( response_description="Success confirmation", ) async def bulk_remove_users_from_groups( - bulk_group: BulkGroup, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin) + bulk_group: BulkGroup, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(require_permission("groups", "update")), ): """ Bulk remove groups from multiple users, users under specific admins, or all users. @@ -245,7 +257,7 @@ async def bulk_remove_users_from_groups( async def bulk_delete_groups( bulk_groups: BulkGroupSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("groups", "delete")), ): """Delete selected groups by ID.""" return await group_operator.bulk_remove_groups_by_id(db, bulk_groups, admin) @@ -259,7 +271,7 @@ async def bulk_delete_groups( async def bulk_disable_groups( bulk_groups: BulkGroupSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("groups", "update")), ): """Disable selected groups by ID.""" return await group_operator.bulk_set_groups_disabled(db, bulk_groups, admin, is_disabled=True) @@ -273,7 +285,7 @@ async def bulk_disable_groups( async def bulk_enable_groups( bulk_groups: BulkGroupSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("groups", "update")), ): """Enable selected groups by ID.""" return await group_operator.bulk_set_groups_disabled(db, bulk_groups, admin, is_disabled=False) diff --git a/app/routers/host.py b/app/routers/host.py index 4c7192adb..98e7d994d 100644 --- a/app/routers/host.py +++ b/app/routers/host.py @@ -7,7 +7,7 @@ from app.operation.host import HostOperation from app.utils import responses -from .authentication import check_sudo_admin +from .authentication import require_permission from .dependencies import get_host_list_query host_operator = HostOperation(operator_type=OperatorType.API) @@ -15,7 +15,9 @@ @router.get("/{host_id}", response_model=BaseHost) -async def get_host(host_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)): +async def get_host( + host_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(require_permission("hosts", "read")) +): """ get host by **id** """ @@ -26,7 +28,7 @@ async def get_host(host_id: int, db: AsyncSession = Depends(get_db), _: AdminDet async def get_hosts( query=Depends(get_host_list_query), db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("hosts", "read")), ): """ Get proxy hosts. @@ -36,7 +38,9 @@ async def get_hosts( @router.post("/", response_model=BaseHost, status_code=status.HTTP_201_CREATED) async def create_host( - new_host: CreateHost, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + new_host: CreateHost, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("hosts", "create")), ): """ create a new host @@ -51,7 +55,7 @@ async def modify_host( host_id: int, modified_host: CreateHost, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("hosts", "update")), ): """ modify host by **id** @@ -67,7 +71,9 @@ async def modify_host( status_code=status.HTTP_204_NO_CONTENT, ) async def remove_host( - host_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + host_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("hosts", "update")), ): """ remove host by **id** @@ -80,7 +86,7 @@ async def remove_host( async def modify_hosts( modified_hosts: list[CreateHost], db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("hosts", "update")), ): """ Modify proxy hosts and update the configuration. @@ -96,7 +102,7 @@ async def modify_hosts( async def bulk_delete_hosts( bulk_hosts: BulkHostSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("hosts", "update")), ): """Delete selected hosts by ID.""" return await host_operator.bulk_remove_hosts(db, bulk_hosts, admin) @@ -110,7 +116,7 @@ async def bulk_delete_hosts( async def bulk_disable_hosts( bulk_hosts: BulkHostSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("hosts", "update")), ): """Disable selected hosts by ID.""" return await host_operator.bulk_set_hosts_disabled(db, bulk_hosts, admin, is_disabled=True) @@ -124,7 +130,7 @@ async def bulk_disable_hosts( async def bulk_enable_hosts( bulk_hosts: BulkHostSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("hosts", "update")), ): """Enable selected hosts by ID.""" return await host_operator.bulk_set_hosts_disabled(db, bulk_hosts, admin, is_disabled=False) diff --git a/app/routers/hwid.py b/app/routers/hwid.py index b55f18f07..31a5c8847 100644 --- a/app/routers/hwid.py +++ b/app/routers/hwid.py @@ -6,7 +6,7 @@ from app.operation import OperatorType from app.operation.hwid import HWIDOperation from app.utils import responses -from .authentication import get_current +from .authentication import require_permission hwid_operator = HWIDOperation(operator_type=OperatorType.API) router = APIRouter(tags=["User HWID"], prefix="/api/user", responses={401: responses._401}) @@ -17,7 +17,9 @@ response_model=UserHWIDListResponse, responses={403: responses._403, 404: responses._404}, ) -async def get_user_hwids(user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)): +async def get_user_hwids( + user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(require_permission("hwids", "read")) +): """Get user's registered hardware IDs""" return await hwid_operator.get_user_hwids(db, user_id=user_id, admin=admin) @@ -27,7 +29,10 @@ async def get_user_hwids(user_id: int, db: AsyncSession = Depends(get_db), admin responses={403: responses._403, 404: responses._404}, ) async def delete_user_hwid( - user_id: int, hwid: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + user_id: int, + hwid: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("hwids", "delete")), ): """Delete a specific hardware ID from user""" return await hwid_operator.delete_user_hwid(db, user_id=user_id, hwid=hwid, admin=admin) @@ -38,7 +43,9 @@ async def delete_user_hwid( responses={403: responses._403, 404: responses._404}, ) async def reset_user_hwids( - user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + user_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("hwids", "delete")), ): """Delete all hardware IDs for user""" return await hwid_operator.reset_user_hwids(db, user_id=user_id, admin=admin) diff --git a/app/routers/node.py b/app/routers/node.py index da7512745..68b39212f 100644 --- a/app/routers/node.py +++ b/app/routers/node.py @@ -45,7 +45,7 @@ from app.utils.logger import get_logger from config import runtime_settings -from .authentication import check_sudo_admin +from .authentication import require_permission from .dependencies import ( get_node_clear_usage_query, get_node_list_query, @@ -133,7 +133,7 @@ async def event_generator() -> AsyncGenerator[str, None]: @router.get("/settings", response_model=NodeSettings) -async def get_node_settings(_: AdminDetails = Depends(check_sudo_admin)): +async def get_node_settings(_: AdminDetails = Depends(require_permission("nodes", "read"))): """Retrieve the current node settings.""" return NodeSettings() @@ -142,7 +142,7 @@ async def get_node_settings(_: AdminDetails = Depends(check_sudo_admin)): async def get_usage( query: Annotated[NodeUsageQuery, Depends(get_node_usage_query)], db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("nodes", "stats")), ): """Retrieve usage statistics for nodes within a specified date range.""" return await node_operator.get_usage(db=db, query=query) @@ -153,7 +153,7 @@ async def get_user_count_metric( metric: UserCountMetric, query: Annotated[NodeUsageQuery, Depends(get_node_usage_query)], db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("nodes", "stats")), ): """Retrieve one user activity/status count metric from node user usage rows.""" try: @@ -168,9 +168,9 @@ async def get_user_count_metric( async def get_nodes( query: Annotated[NodeListQuery, Depends(get_node_list_query)], db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("nodes", "read")), ): - """Retrieve a list of all nodes. Accessible only to sudo admins.""" + """Retrieve a list of all nodes. Accessible only to authorized admins.""" return await node_operator.get_db_nodes(db=db, query=query) @@ -184,7 +184,7 @@ async def get_nodes( async def get_nodes_simple( query: Annotated[NodeSimpleListQuery, Depends(get_node_simple_list_query)], db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("nodes", "read_simple")), ): """Get lightweight node list with only id and name""" return await node_operator.get_nodes_simple(db=db, query=query) @@ -194,7 +194,7 @@ async def get_nodes_simple( async def reconnect_all_node( core_id: int | None = None, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("nodes", "reconnect")), ): """ Trigger reconnection for all nodes or a specific core. @@ -210,20 +210,28 @@ async def reconnect_all_node( status_code=status.HTTP_201_CREATED, ) async def create_node( - new_node: NodeCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + new_node: NodeCreate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("nodes", "create")), ): """Create a new node to the database.""" return await node_operator.create_node(db, new_node, admin) @router.get("/{node_id}", response_model=NodeResponse) -async def get_node(node_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)): +async def get_node( + node_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(require_permission("nodes", "read")) +): """Retrieve details of a specific node by its ID.""" return await node_operator.get_validated_node(db=db, node_id=node_id) @router.post("/{node_id}/update") -async def update_node(node_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)): +async def update_node( + node_id: int, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(require_permission("nodes", "update_core")), +): return await node_operator.update_node(db=db, node_id=node_id) @@ -232,7 +240,7 @@ async def update_core( node_id: int, node_core_update: NodeCoreUpdate, db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("nodes", "update_core")), ): return await node_operator.update_core(db=db, node_id=node_id, node_core_update=node_core_update) @@ -242,7 +250,7 @@ async def update_geofiles( node_id: int, node_geofiles_update: NodeGeoFilesUpdate, db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("nodes", "update_core")), ): return await node_operator.update_geofiles(db=db, node_id=node_id, node_geofiles_update=node_geofiles_update) @@ -252,29 +260,33 @@ async def modify_node( modified_node: NodeModify, node_id: int, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("nodes", "update")), ): - """Modify a node's details. Only accessible to sudo admins.""" + """Modify a node's details. Only accessible to authorized admins.""" return await node_operator.modify_node(db, node_id=node_id, modified_node=modified_node, admin=admin) @router.post("/{node_id}/reset", response_model=NodeResponse) async def reset_node_usage( - node_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + node_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("nodes", "update")), ): """ Reset node traffic usage (uplink and downlink). Creates a log entry in node_usage_reset_logs table. - Only accessible to sudo admins. + Only accessible to authorized admins. """ return await node_operator.reset_node_usage(db, node_id=node_id, admin=admin) @router.post("/{node_id}/reconnect") async def reconnect_node( - node_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + node_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("nodes", "reconnect")), ): - """Trigger a reconnection for the specified node. Only accessible to sudo admins.""" + """Trigger a reconnection for the specified node. Only accessible to authorized admins.""" await node_operator.restart_node(db, node_id, admin) return {} @@ -284,14 +296,16 @@ async def sync_node( node_id: int, flush_users: bool = False, db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("nodes", "update")), ): return await node_operator.sync_node_users(db, node_id=node_id, flush_users=flush_users) @router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_node( - node_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + node_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("nodes", "delete")), ): """Remove a node and remove it from xray in the background.""" await node_operator.remove_node(db=db, node_id=node_id, admin=admin) @@ -299,7 +313,7 @@ async def remove_node( @router.get("/{node_id}/logs") -async def node_logs(node_id: int, request: Request, _: AdminDetails = Depends(check_sudo_admin)): +async def node_logs(node_id: int, request: Request, _: AdminDetails = Depends(require_permission("nodes", "logs"))): """ Stream logs for a specific node as Server-Sent Events. """ @@ -311,13 +325,13 @@ async def get_node_stats_periodic( node_id: int, query: Annotated[NodeStatsPeriodQuery, Depends(get_node_stats_period_query)], db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("nodes", "stats")), ): return await node_operator.get_node_stats_periodic(db, node_id=node_id, query=query) @router.get("/{node_id}/realtime_stats", response_model=NodeRealtimeStats) -async def realtime_node_stats(node_id: int, _: AdminDetails = Depends(check_sudo_admin)): +async def realtime_node_stats(node_id: int, _: AdminDetails = Depends(require_permission("nodes", "stats"))): """Retrieve node real-time statistics.""" return await node_operator.get_node_system_stats(node_id=node_id) @@ -327,21 +341,21 @@ async def node_outbounds_latency( node_id: int, name: str = "", timeout: int | None = None, - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("nodes", "stats")), ): """Retrieve outbound latency for one outbound or all outbounds of a node.""" return await node_operator.get_outbounds_latency(node_id=node_id, name=name, timeout=timeout) @router.get("s/realtime_stats", response_model=dict[int, NodeRealtimeStats | None]) -async def realtime_nodes_stats(_: AdminDetails = Depends(check_sudo_admin)): +async def realtime_nodes_stats(_: AdminDetails = Depends(require_permission("nodes", "stats"))): """Retrieve nodes real-time statistics.""" return await node_operator.get_nodes_system_stats() @router.get("/online_stats/{username}/ip", response_model=UserIPListAll) async def user_online_ip_list_all_nodes( - username: str, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin) + username: str, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(require_permission("nodes", "stats")) ): """Retrieve user ips from all nodes.""" return await node_operator.get_user_ip_list_all_nodes(db=db, username=username) @@ -349,7 +363,10 @@ async def user_online_ip_list_all_nodes( @router.get("/{node_id}/online_stats/{username}", response_model=dict[int, int]) async def user_online_stats( - node_id: int, username: str, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin) + node_id: int, + username: str, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(require_permission("nodes", "stats")), ): """Retrieve user online stats by node.""" return await node_operator.get_user_online_stats_by_node(db=db, node_id=node_id, username=username) @@ -357,7 +374,10 @@ async def user_online_stats( @router.get("/{node_id}/online_stats/{username}/ip", response_model=UserIPList) async def user_online_ip_list( - node_id: int, username: str, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin) + node_id: int, + username: str, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(require_permission("nodes", "stats")), ): """Retrieve user ips by node.""" return await node_operator.get_user_ip_list_by_node(db=db, node_id=node_id, username=username) @@ -371,7 +391,7 @@ async def clear_usage_data( table: UsageTable, query: Annotated[NodeClearUsageQuery, Depends(get_node_clear_usage_query)], db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_permission("nodes", "delete")), ): """ Deletes **all rows** from the selected usage data table. Use with caution. @@ -397,7 +417,7 @@ async def clear_usage_data( async def bulk_delete_nodes( bulk_nodes: BulkNodeSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("nodes", "delete")), ): """Delete selected nodes by ID.""" return await node_operator.bulk_remove_nodes(db, bulk_nodes, admin) @@ -411,7 +431,7 @@ async def bulk_delete_nodes( async def bulk_disable_nodes( bulk_nodes: BulkNodeSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("nodes", "update")), ): """Disable selected nodes by ID.""" return await node_operator.bulk_set_nodes_status(db, bulk_nodes, admin, status=NodeStatus.disabled) @@ -425,7 +445,7 @@ async def bulk_disable_nodes( async def bulk_enable_nodes( bulk_nodes: BulkNodeSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("nodes", "update")), ): """Enable selected nodes by ID.""" return await node_operator.bulk_set_nodes_status(db, bulk_nodes, admin, status=NodeStatus.connected) @@ -439,7 +459,7 @@ async def bulk_enable_nodes( async def bulk_reset_nodes_usage( bulk_nodes: BulkNodeSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("nodes", "update")), ): """Reset usage for selected nodes by ID.""" return await node_operator.bulk_reset_nodes_usage(db, bulk_nodes, admin) @@ -453,7 +473,7 @@ async def bulk_reset_nodes_usage( async def bulk_reconnect_nodes( bulk_nodes: BulkNodeSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("nodes", "reconnect")), ): """Reconnect selected nodes by ID.""" return await node_operator.bulk_restart_nodes(db, bulk_nodes, admin) @@ -467,7 +487,7 @@ async def bulk_reconnect_nodes( async def bulk_update_nodes( bulk_nodes: BulkNodeSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("nodes", "update_core")), ): """Update selected nodes by ID.""" return await node_operator.bulk_update_nodes(db, bulk_nodes, admin) diff --git a/app/routers/settings.py b/app/routers/settings.py index 57991bcf3..bf3077899 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -6,22 +6,26 @@ from app.operation.settings import SettingsOperation from app.utils import responses -from .authentication import check_sudo_admin, get_current +from .authentication import require_permission settings_operator = SettingsOperation(operator_type=OperatorType.API) router = APIRouter(tags=["Settings"], prefix="/api/settings", responses={401: responses._401, 403: responses._403}) @router.get("", response_model=SettingsSchema) -async def get_settings(db: AsyncSession = Depends(get_db), _=Depends(check_sudo_admin)): +async def get_settings(db: AsyncSession = Depends(get_db), _=Depends(require_permission("settings", "read"))): return await settings_operator.get_settings(db) @router.get("/general", response_model=General) -async def get_general_settings(db: AsyncSession = Depends(get_db), _=Depends(get_current)): +async def get_general_settings( + db: AsyncSession = Depends(get_db), _=Depends(require_permission("settings", "read_general")) +): return await settings_operator.get_general_settings(db) @router.put("", response_model=SettingsSchema) -async def modify_settings(modify: SettingsSchema, db: AsyncSession = Depends(get_db), _=Depends(check_sudo_admin)): +async def modify_settings( + modify: SettingsSchema, db: AsyncSession = Depends(get_db), _=Depends(require_permission("settings", "update")) +): return await settings_operator.modify_settings(db, modify) diff --git a/app/routers/setup.py b/app/routers/setup.py new file mode 100644 index 000000000..0a68f2472 --- /dev/null +++ b/app/routers/setup.py @@ -0,0 +1,98 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import Response + +from app.db import AsyncSession, get_db +from app.db.crud.admin import create_admin, get_owner, remove_admin, update_owner_password +from app.db.crud.temp_key import TempKeyConsumeError, consume_temp_key +from app.models.admin import AdminCreate, AdminDetails +from app.models.setup import OwnerCreateRequest, OwnerDeleteRequest, OwnerResetRequest +from app.utils import responses +from app.utils.request import get_client_ip + +router = APIRouter(tags=["Setup"], prefix="/api/setup") + + +async def _consume_key_or_raise(db: AsyncSession, key_str: str, action: str, request: Request) -> None: + try: + await consume_temp_key(db, key_str, action=action, ip=get_client_ip(request)) + except TempKeyConsumeError as exc: + status_code = status.HTTP_400_BAD_REQUEST if exc.detail == "invalid key" else status.HTTP_410_GONE + raise HTTPException(status_code=status_code, detail=exc.detail) from exc + + +@router.post( + "/owner", + response_model=AdminDetails, + status_code=status.HTTP_201_CREATED, + responses={ + 400: responses._400, + 409: responses._409, + 410: {"description": "Key already used or expired"}, + }, +) +async def create_owner( + body: OwnerCreateRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Create the owner admin using a one-time temp key.""" + if await get_owner(db) is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="owner already exists") + + await _consume_key_or_raise(db, body.key, action="create_owner", request=request) + + db_admin = await create_admin( + db, + AdminCreate(username=body.username, password=body.password, role_id=1), + ) + return AdminDetails.model_validate(db_admin) + + +@router.patch( + "/owner", + response_model=AdminDetails, + responses={ + 400: responses._400, + 404: responses._404, + 410: {"description": "Key already used or expired"}, + }, +) +async def reset_owner_password( + body: OwnerResetRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Reset the owner admin's password using a one-time temp key.""" + await _consume_key_or_raise(db, body.key, action="reset_owner", request=request) + + owner = await get_owner(db) + if owner is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="owner not found") + + owner = await update_owner_password(db, owner, body.password) + return AdminDetails.model_validate(owner) + + +@router.delete( + "/owner", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + 400: responses._400, + 404: responses._404, + 410: {"description": "Key already used or expired"}, + }, +) +async def delete_owner( + body: OwnerDeleteRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Delete the owner admin using a one-time temp key.""" + await _consume_key_or_raise(db, body.key, action="delete_owner", request=request) + + owner = await get_owner(db) + if owner is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="owner not found") + + await remove_admin(db, owner) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/routers/system.py b/app/routers/system.py index 4ec7ba36e..e849048f9 100644 --- a/app/routers/system.py +++ b/app/routers/system.py @@ -20,7 +20,7 @@ from app.utils.logger import EndpointFilter, get_logger from config import telegram_env_settings -from .authentication import get_current +from .authentication import require_permission system_operator = SystemOperation(operator_type=OperatorType.API) router = APIRouter(tags=["System"], prefix="/api", responses={401: responses._401}) @@ -33,20 +33,22 @@ @router.get("/system", response_model=SystemStats) async def get_system_stats( - admin_username: str | None = None, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + admin_username: str | None = None, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("system", "read")), ): """Fetch system stats including memory, CPU, disk, and user metrics.""" return await system_operator.get_system_stats(db, admin=admin, admin_username=admin_username) @router.get("/inbounds", response_model=list[str]) -async def get_inbounds(_: AdminDetails = Depends(get_current)): +async def get_inbounds(_: AdminDetails = Depends(require_permission("system", "read"))): """Retrieve inbound configurations grouped by protocol.""" return await system_operator.get_inbounds() @router.get("/inbounds/details", response_model=list[InboundSummary]) -async def get_inbound_details(_: AdminDetails = Depends(get_current)): +async def get_inbound_details(_: AdminDetails = Depends(require_permission("system", "read"))): """Retrieve lightweight inbound metadata for dashboard forms.""" return await system_operator.get_inbound_details() @@ -64,7 +66,7 @@ async def _measure_worker_health(request_coro) -> WorkerHealth: @router.get("/workers/health", response_model=WorkersHealth) -async def get_workers_health(_: AdminDetails = Depends(get_current)): +async def get_workers_health(_: AdminDetails = Depends(require_permission("system", "read"))): if not is_nats_enabled(): disabled = WorkerHealth(status="disabled") return WorkersHealth(scheduler=disabled, node=disabled) diff --git a/app/routers/user.py b/app/routers/user.py index b99935191..91b9a96e8 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -9,7 +9,6 @@ UserCountMetric, UserCountMetricStatsList, UserUsageStatsList, - validate_user_count_metric_scope, ) from app.models.user import ( BulkUser, @@ -51,7 +50,7 @@ get_users_usage_query, ) -from .authentication import check_sudo_admin, get_current +from .authentication import require_permission, require_scope_all user_operator = UserOperation(operator_type=OperatorType.API) node_operator = NodeOperation(operator_type=OperatorType.API) @@ -66,7 +65,9 @@ status_code=status.HTTP_201_CREATED, ) async def create_user( - new_user: UserCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + new_user: UserCreate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "create")), ): """ Create a new user @@ -96,7 +97,7 @@ async def modify_user( username: str, modified_user: UserModify, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "update")), ): """ Modify an existing user @@ -127,7 +128,7 @@ async def modify_user_by_username( username: str, modified_user: UserModify, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "update")), ): return await user_operator.modify_user(db, username=username, modified_user=modified_user, admin=admin) @@ -141,7 +142,7 @@ async def modify_user_by_id( user_id: int, modified_user: UserModify, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "update")), ): return await user_operator.modify_user_by_id(db, user_id=user_id, modified_user=modified_user, admin=admin) @@ -149,7 +150,11 @@ async def modify_user_by_id( @router.delete( "/{username}", responses={403: responses._403, 404: responses._404}, status_code=status.HTTP_204_NO_CONTENT ) -async def remove_user(username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)): +async def remove_user( + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "delete")), +): """Remove a user""" return await user_operator.remove_user(db, username=username, admin=admin) @@ -160,7 +165,9 @@ async def remove_user(username: str, db: AsyncSession = Depends(get_db), admin: status_code=status.HTTP_204_NO_CONTENT, ) async def remove_user_by_username( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "delete")), ): return await user_operator.remove_user(db, username=username, admin=admin) @@ -171,14 +178,18 @@ async def remove_user_by_username( status_code=status.HTTP_204_NO_CONTENT, ) async def remove_user_by_id( - user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + user_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "delete")), ): return await user_operator.remove_user_by_id(db, user_id=user_id, admin=admin) @router.post("/{username}/reset", response_model=UserResponse, responses={403: responses._403, 404: responses._404}) async def reset_user_data_usage( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "reset_usage")), ): """Reset user data usage""" return await user_operator.reset_user_data_usage(db, username=username, admin=admin) @@ -190,7 +201,9 @@ async def reset_user_data_usage( responses={403: responses._403, 404: responses._404}, ) async def reset_user_data_usage_by_username( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "reset_usage")), ): return await user_operator.reset_user_data_usage(db, username=username, admin=admin) @@ -199,7 +212,9 @@ async def reset_user_data_usage_by_username( "/by-id/{user_id}/reset", response_model=UserResponse, responses={403: responses._403, 404: responses._404} ) async def reset_user_data_usage_by_id( - user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + user_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "reset_usage")), ): return await user_operator.reset_user_data_usage_by_id(db, user_id=user_id, admin=admin) @@ -208,7 +223,9 @@ async def reset_user_data_usage_by_id( "/{username}/revoke_sub", response_model=UserResponse, responses={403: responses._403, 404: responses._404} ) async def revoke_user_subscription( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "revoke_sub")), ): """Revoke users subscription (Subscription link and proxies)""" return await user_operator.revoke_user_sub(db, username=username, admin=admin) @@ -220,7 +237,9 @@ async def revoke_user_subscription( responses={403: responses._403, 404: responses._404}, ) async def revoke_user_subscription_by_username( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "revoke_sub")), ): return await user_operator.revoke_user_sub(db, username=username, admin=admin) @@ -231,13 +250,17 @@ async def revoke_user_subscription_by_username( responses={403: responses._403, 404: responses._404}, ) async def revoke_user_subscription_by_id( - user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + user_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "revoke_sub")), ): return await user_operator.revoke_user_sub_by_id(db, user_id=user_id, admin=admin) @router.post("s/reset", responses={403: responses._403, 404: responses._404}) -async def reset_users_data_usage(db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)): +async def reset_users_data_usage( + db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(require_scope_all("users", "reset_usage")) +): """Reset all users data usage""" await user_operator.reset_users_data_usage(db, admin) await node_operator.restart_all_node(db, admin) @@ -254,7 +277,7 @@ async def get_users_sub_update_chart( username: str | None = None, admin_id: int | None = None, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read")), ): """Get subscription agent distribution percentages (optionally filtered by user_id/username).""" return await user_operator.get_users_sub_update_chart( @@ -271,7 +294,7 @@ async def set_owner( username: str, admin_username: str, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("users", "set_owner")), ): """Set a new owner (admin) for a user.""" return await user_operator.set_owner(db, username=username, admin_username=admin_username, admin=admin) @@ -282,7 +305,7 @@ async def set_owner_by_username( username: str, admin_username: str, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("users", "set_owner")), ): return await user_operator.set_owner(db, username=username, admin_username=admin_username, admin=admin) @@ -292,7 +315,7 @@ async def set_owner_by_id( user_id: int, admin_username: str, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("users", "set_owner")), ): return await user_operator.set_owner_by_id(db, user_id=user_id, admin_username=admin_username, admin=admin) @@ -301,7 +324,9 @@ async def set_owner_by_id( "/{username}/active_next", response_model=UserResponse, responses={403: responses._403, 404: responses._404} ) async def active_next_plan( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "activate_next_plan")), ): """Reset user by next plan""" return await user_operator.active_next_plan(db, username=username, admin=admin) @@ -313,7 +338,9 @@ async def active_next_plan( responses={403: responses._403, 404: responses._404}, ) async def active_next_plan_by_username( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "activate_next_plan")), ): return await user_operator.active_next_plan(db, username=username, admin=admin) @@ -322,13 +349,19 @@ async def active_next_plan_by_username( "/by-id/{user_id}/active_next", response_model=UserResponse, responses={403: responses._403, 404: responses._404} ) async def active_next_plan_by_id( - user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + user_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "activate_next_plan")), ): return await user_operator.active_next_plan_by_id(db, user_id=user_id, admin=admin) @router.get("/{username}", response_model=UserResponse, responses={403: responses._403, 404: responses._404}) -async def get_user(username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)): +async def get_user( + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "read")), +): """Get user information""" return await user_operator.get_user(db=db, username=username, admin=admin) @@ -337,13 +370,17 @@ async def get_user(username: str, db: AsyncSession = Depends(get_db), admin: Adm "/by-username/{username}", response_model=UserResponse, responses={403: responses._403, 404: responses._404} ) async def get_user_by_username( - username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current) + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("users", "read")), ): return await user_operator.get_user(db=db, username=username, admin=admin) @router.get("/by-id/{user_id}", response_model=UserResponse, responses={403: responses._403, 404: responses._404}) -async def get_user_by_id(user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)): +async def get_user_by_id( + user_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(require_permission("users", "read")) +): return await user_operator.get_user_by_id(db=db, user_id=user_id, admin=admin) @@ -356,7 +393,7 @@ async def get_user_subscription_by_id( user_id: int, client_type: ConfigFormat, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read")), ): """Get a user's subscription content in the requested format.""" return await subscription_operator.user_subscription_by_id( @@ -378,7 +415,7 @@ async def get_user_sub_update_list( offset: int = 0, limit: int = 10, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read")), ): """Get user subscription agent list""" return await user_operator.get_users_sub_update_list(db, username=username, admin=admin, offset=offset, limit=limit) @@ -394,7 +431,7 @@ async def get_user_sub_update_list_by_username( offset: int = 0, limit: int = 10, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read")), ): return await user_operator.get_users_sub_update_list(db, username=username, admin=admin, offset=offset, limit=limit) @@ -409,7 +446,7 @@ async def get_user_sub_update_list_by_id( offset: int = 0, limit: int = 10, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read")), ): return await user_operator.get_users_sub_update_list_by_id( db, @@ -426,7 +463,7 @@ async def get_user_sub_update_list_by_id( async def get_users( query: Annotated[UserListQuery, Depends(get_user_list_query)], db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read")), ): """Get all users""" return await user_operator.get_users(db=db, admin=admin, query=query) @@ -442,7 +479,7 @@ async def get_users( async def get_users_simple( query: Annotated[UserSimpleListQuery, Depends(get_user_simple_list_query)], db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read_simple")), ): """Get lightweight user list with only id and username""" return await user_operator.get_users_simple(db=db, admin=admin, query=query) @@ -455,7 +492,7 @@ async def get_user_usage( username: str, query: Annotated[UserUsageQuery, Depends(get_user_usage_query)], db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read")), ): """Get users usage""" return await user_operator.get_user_usage(db, username=username, admin=admin, query=query) @@ -470,7 +507,7 @@ async def get_user_usage_by_username( username: str, query: Annotated[UserUsageQuery, Depends(get_user_usage_query)], db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read")), ): return await user_operator.get_user_usage(db, username=username, admin=admin, query=query) @@ -484,7 +521,7 @@ async def get_user_usage_by_id( user_id: int, query: Annotated[UserUsageQuery, Depends(get_user_usage_query)], db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read")), ): return await user_operator.get_user_usage_by_id(db, user_id=user_id, admin=admin, query=query) @@ -493,7 +530,7 @@ async def get_user_usage_by_id( async def get_users_usage( query: Annotated[UsersUsageQuery, Depends(get_users_usage_query)], db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read")), ): """Get all users usage""" return await user_operator.get_users_usage(db, admin=admin, query=query) @@ -504,18 +541,9 @@ async def get_users_count_metric( metric: UserCountMetric, query: Annotated[UsersUsageQuery, Depends(get_users_usage_query)], db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "read")), ): """Get one users activity/status count metric from usage rows.""" - try: - validate_user_count_metric_scope( - metric, - node_id=query.node_id if admin.is_sudo else None, - group_by_node=query.group_by_node if admin.is_sudo else False, - ) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - return await user_operator.get_users_count_metric(db, admin=admin, metric=metric, query=query) @@ -523,7 +551,7 @@ async def get_users_count_metric( async def get_expired_users( query: Annotated[ExpiredUsersQuery, Depends(get_expired_users_query)], db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_scope_all("users", "read")), ): """ Get cleanup-target users in the specified scope. @@ -541,7 +569,7 @@ async def get_expired_users( async def delete_expired_users( query: Annotated[ExpiredUsersQuery, Depends(get_expired_users_query)], db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_scope_all("users", "delete")), ): """ Delete cleanup-target users in the specified scope. @@ -562,7 +590,7 @@ async def delete_expired_users( async def bulk_delete_users( bulk_users: BulkUsersSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "delete")), ): """Delete selected users by ID.""" return await user_operator.bulk_remove_users(db, bulk_users, admin) @@ -576,7 +604,7 @@ async def bulk_delete_users( async def bulk_reset_users_data_usage( bulk_users: BulkUsersSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "reset_usage")), ): """Reset usage for selected users by ID.""" return await user_operator.bulk_reset_user_data_usage(db, bulk_users, admin) @@ -590,7 +618,7 @@ async def bulk_reset_users_data_usage( async def bulk_disable_users( bulk_users: BulkUsersSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "update")), ): """Disable selected users by ID.""" return await user_operator.bulk_disable_users(db, bulk_users, admin) @@ -604,7 +632,7 @@ async def bulk_disable_users( async def bulk_enable_users( bulk_users: BulkUsersSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "update")), ): """Enable selected users by ID.""" return await user_operator.bulk_enable_users(db, bulk_users, admin) @@ -618,7 +646,7 @@ async def bulk_enable_users( async def bulk_revoke_users_subscription( bulk_users: BulkUsersSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "revoke_sub")), ): """Revoke subscriptions for selected users by ID.""" return await user_operator.bulk_revoke_user_sub(db, bulk_users, admin) @@ -632,7 +660,7 @@ async def bulk_revoke_users_subscription( async def bulk_set_owner( bulk_users: BulkUsersSetOwner, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("users", "set_owner")), ): """Set a new owner for selected users by ID.""" return await user_operator.bulk_set_owner(db, bulk_users, admin) @@ -642,7 +670,7 @@ async def bulk_set_owner( async def create_user_from_template( new_template_user: CreateUserFromTemplate, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "create")), ): return await user_operator.create_user_from_template(db, new_template_user, admin) @@ -656,7 +684,7 @@ async def create_user_from_template( async def bulk_create_users_from_template( bulk_template_users: BulkUsersFromTemplate, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "create")), ): """ Bulk create users from a template using configurable username strategies. @@ -679,7 +707,7 @@ async def bulk_create_users_from_template( async def bulk_apply_template_to_users( body: BulkUsersApplyTemplate, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "update")), ): """Apply a user template to selected existing users by ID.""" return await user_operator.bulk_apply_template_to_users(db, body, admin) @@ -690,7 +718,7 @@ async def modify_user_with_template( username: str, modify_template_user: ModifyUserByTemplate, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "update")), ): return await user_operator.modify_user_with_template(db, username, modify_template_user, admin) @@ -700,7 +728,7 @@ async def modify_user_with_template_by_username( username: str, modify_template_user: ModifyUserByTemplate, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "update")), ): return await user_operator.modify_user_with_template(db, username, modify_template_user, admin) @@ -710,7 +738,7 @@ async def modify_user_with_template_by_id( user_id: int, modify_template_user: ModifyUserByTemplate, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("users", "update")), ): return await user_operator.modify_user_with_template_by_id(db, user_id, modify_template_user, admin) @@ -719,7 +747,7 @@ async def modify_user_with_template_by_id( async def bulk_modify_users_expire( bulk_model: BulkUser, db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_scope_all("users", "update")), ): """ Bulk expire users based on the provided criteria. @@ -741,7 +769,7 @@ async def bulk_modify_users_expire( async def bulk_modify_users_datalimit( bulk_model: BulkUser, db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_scope_all("users", "update")), ): """ Bulk modify users' data limit based on the provided criteria. @@ -763,7 +791,7 @@ async def bulk_modify_users_datalimit( async def bulk_modify_users_proxy_settings( bulk_model: BulkUsersProxy, db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(check_sudo_admin), + _: AdminDetails = Depends(require_scope_all("users", "update")), ): return await user_operator.bulk_modify_proxy_settings(db, bulk_model) @@ -772,12 +800,12 @@ async def bulk_modify_users_proxy_settings( "s/bulk/wireguard/reallocate-peer-ips", response_model=WireGuardPeerIPsReallocateResponse, summary="Bulk reallocate WireGuard peer IPs", - description="Same scoping as other bulk user actions (users, admins, group_ids, optional status filter). Non-sudo admins only affect their own users.", + description="Same scoping as other bulk user actions (users, admins, group_ids, optional status filter). non-owner admins only affect their own users.", ) async def bulk_reallocate_wireguard_peer_ips( body: BulkWireGuardPeerIPs, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_scope_all("users", "update")), ): if not body.dry_run and not body.confirm: raise HTTPException( diff --git a/app/routers/user_template.py b/app/routers/user_template.py index 0ece310bd..1e3db54f0 100644 --- a/app/routers/user_template.py +++ b/app/routers/user_template.py @@ -2,7 +2,7 @@ from app.db import AsyncSession, get_db from app.models.admin import AdminDetails -from .authentication import check_sudo_admin, get_current +from .authentication import require_permission from app.models.user_template import ( BulkUserTemplatesActionResponse, BulkUserTemplateSelection, @@ -28,7 +28,7 @@ async def create_user_template( new_user_template: UserTemplateCreate, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("templates", "create")), ): """ Create a new user template @@ -43,10 +43,12 @@ async def create_user_template( @router.get("/{template_id}", response_model=UserTemplateResponse) async def get_user_template( - template_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(get_current) + template_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("templates", "read")), ): """Get User Template information with id""" - return await template_operator.get_validated_user_template(db, template_id) + return await template_operator._get_template_with_access(db, template_id, admin) @router.put("/{template_id}", response_model=UserTemplateResponse, responses={403: responses._403}) @@ -54,7 +56,7 @@ async def modify_user_template( template_id: int, modify_user_template: UserTemplateModify, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("templates", "update")), ): """ Modify User Template @@ -69,7 +71,9 @@ async def modify_user_template( @router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, responses={403: responses._403}) async def remove_user_template( - template_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin) + template_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("templates", "delete")), ): """Remove a User Template by its ID""" await template_operator.remove_user_template(db, template_id, admin) @@ -80,10 +84,10 @@ async def remove_user_template( async def get_user_templates( query=Depends(get_user_template_list_query), db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("templates", "read")), ): """Get a list of User Templates with optional pagination""" - return await template_operator.get_user_templates(db, query) + return await template_operator.get_user_templates(db, query, admin) @router.get( @@ -95,10 +99,10 @@ async def get_user_templates( async def get_user_templates_simple( query=Depends(get_user_template_simple_list_query), db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(get_current), + admin: AdminDetails = Depends(require_permission("templates", "read_simple")), ): """Get lightweight user template list with only id and name""" - return await template_operator.get_user_templates_simple(db=db, query=query) + return await template_operator.get_user_templates_simple(db=db, query=query, admin=admin) @router.post( @@ -109,7 +113,7 @@ async def get_user_templates_simple( async def bulk_delete_user_templates( bulk_templates: BulkUserTemplateSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("templates", "delete")), ): """Delete selected user templates by ID.""" return await template_operator.bulk_remove_user_templates(db, bulk_templates, admin) @@ -123,7 +127,7 @@ async def bulk_delete_user_templates( async def bulk_disable_user_templates( bulk_templates: BulkUserTemplateSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("templates", "update")), ): """Disable selected user templates by ID.""" return await template_operator.bulk_set_user_templates_disabled(db, bulk_templates, admin, is_disabled=True) @@ -137,7 +141,7 @@ async def bulk_disable_user_templates( async def bulk_enable_user_templates( bulk_templates: BulkUserTemplateSelection, db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), + admin: AdminDetails = Depends(require_permission("templates", "update")), ): """Enable selected user templates by ID.""" return await template_operator.bulk_set_user_templates_disabled(db, bulk_templates, admin, is_disabled=False) diff --git a/app/subscription/singbox.py b/app/subscription/singbox.py index 69469538e..a2b87abb0 100644 --- a/app/subscription/singbox.py +++ b/app/subscription/singbox.py @@ -166,13 +166,15 @@ def _transport_ws(self, config: WebSocketTransportConfig, path: str) -> dict: def _transport_grpc(self, config: GRPCTransportConfig, path: str) -> dict: """Handle GRPC transport - only gets GRPC config""" - return self._normalize_and_remove_none_values({ - "type": "grpc", - "service_name": path, - "idle_timeout": f"{config.idle_timeout}s" if config.idle_timeout else "15s", - "ping_timeout": f"{config.health_check_timeout}s" if config.health_check_timeout else "15s", - "permit_without_stream": config.permit_without_stream, - }) + return self._normalize_and_remove_none_values( + { + "type": "grpc", + "service_name": path, + "idle_timeout": f"{config.idle_timeout}s" if config.idle_timeout else "15s", + "ping_timeout": f"{config.health_check_timeout}s" if config.health_check_timeout else "15s", + "permit_without_stream": config.permit_without_stream, + } + ) def _transport_httpupgrade(self, config: WebSocketTransportConfig, path: str) -> dict: """Handle HTTPUpgrade transport - only gets WS config (similar to WS)""" diff --git a/app/subscription/xray.py b/app/subscription/xray.py index 789710f42..bccd3c6c4 100644 --- a/app/subscription/xray.py +++ b/app/subscription/xray.py @@ -274,23 +274,27 @@ def _transport_quic(self, config: QUICTransportConfig, path: str) -> dict: """Handle QUIC transport - only gets QUIC config""" host = config.host if isinstance(config.host, str) else (config.host[0] if config.host else "") - return self._normalize_and_remove_none_values({ - "security": host, - "header": {"type": config.header_type}, - "key": path, - }) + return self._normalize_and_remove_none_values( + { + "security": host, + "header": {"type": config.header_type}, + "key": path, + } + ) def _transport_kcp(self, config: KCPTransportConfig, path: str) -> dict: """Handle KCP transport - only gets KCP config""" - return self._normalize_and_remove_none_values({ - "mtu": config.mtu if config.mtu is not None else 1350, - "tti": config.tti if config.tti is not None else 50, - "uplinkCapacity": config.uplink_capacity if config.uplink_capacity is not None else 5, - "downlinkCapacity": config.downlink_capacity if config.downlink_capacity is not None else 20, - "congestion": config.congestion, - "readBufferSize": config.read_buffer_size if config.read_buffer_size is not None else 2, - "writeBufferSize": config.write_buffer_size if config.write_buffer_size is not None else 2, - }) + return self._normalize_and_remove_none_values( + { + "mtu": config.mtu if config.mtu is not None else 1350, + "tti": config.tti if config.tti is not None else 50, + "uplinkCapacity": config.uplink_capacity if config.uplink_capacity is not None else 5, + "downlinkCapacity": config.downlink_capacity if config.downlink_capacity is not None else 20, + "congestion": config.congestion, + "readBufferSize": config.read_buffer_size if config.read_buffer_size is not None else 2, + "writeBufferSize": config.write_buffer_size if config.write_buffer_size is not None else 2, + } + ) def _apply_transport(self, network: str, inbound: SubscriptionInboundData, path: str) -> dict | None: """Apply transport settings using registry pattern""" @@ -306,15 +310,17 @@ def _apply_tls(self, tls_config: TLSConfig, security: str) -> dict: sni = tls_config.sni if isinstance(tls_config.sni, str) else (tls_config.sni[0] if tls_config.sni else None) if security == "reality": - return self._normalize_and_remove_none_values({ - "serverName": sni, - "fingerprint": tls_config.fingerprint, - "show": False, - "publicKey": tls_config.reality_public_key, - "shortId": tls_config.reality_short_id, - "spiderX": tls_config.reality_spx, - "mldsa65Verify": tls_config.mldsa65_verify, - }) + return self._normalize_and_remove_none_values( + { + "serverName": sni, + "fingerprint": tls_config.fingerprint, + "show": False, + "publicKey": tls_config.reality_public_key, + "shortId": tls_config.reality_short_id, + "spiderX": tls_config.reality_spx, + "mldsa65Verify": tls_config.mldsa65_verify, + } + ) else: # tls config = { "serverName": sni, @@ -383,11 +389,13 @@ def _download_config(self, download_settings: SubscriptionInboundData, link_form sockopt=sockopt, ) - return self._normalize_and_remove_none_values({ - "address": download_settings.address, - "port": self._select_port(download_settings.port), - **stream_settings, - }) + return self._normalize_and_remove_none_values( + { + "address": download_settings.address, + "port": self._select_port(download_settings.port), + **stream_settings, + } + ) # ========== Protocol Builders (Registry Methods) ========== diff --git a/app/telegram/handlers/admin/bulk_actions.py b/app/telegram/handlers/admin/bulk_actions.py index 114434fe9..5f7a62114 100644 --- a/app/telegram/handlers/admin/bulk_actions.py +++ b/app/telegram/handlers/admin/bulk_actions.py @@ -23,6 +23,7 @@ ) from app.telegram.keyboards.confim_action import ConfirmAction from app.telegram.utils import forms +from app.telegram.utils.filters import IsScopeAll, HasPermission from app.telegram.utils.shared import add_to_messages_to_delete, delete_messages from app.telegram.utils.texts import Message as Texts @@ -58,12 +59,16 @@ def _chunk_subscription_urls(urls: list[str], limit: int = 3800) -> list[str]: return chunks -@router.callback_query(AdminPanel.Callback.filter(AdminPanelAction.bulk_actions == F.action)) -async def bulk_actions(event: CallbackQuery): +@router.callback_query( + IsScopeAll("users", "update"), AdminPanel.Callback.filter(AdminPanelAction.bulk_actions == F.action) +) +async def bulk_actions(event: CallbackQuery, admin: AdminDetails): await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup()) -@router.callback_query(BulkActionPanel.Callback.filter(BulkAction.create_from_template == F.action)) +@router.callback_query( + HasPermission("users", "create"), BulkActionPanel.Callback.filter(BulkAction.create_from_template == F.action) +) async def bulk_create_from_template(event: CallbackQuery, db: AsyncSession, state: FSMContext): templates = await user_templates.get_user_templates(db, UserTemplateListQuery()) if not templates: @@ -242,7 +247,9 @@ async def _perform_bulk_creation( await target.answer(chunk) -@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.delete_expired == F.action) & ~F.amount)) +@router.callback_query( + IsScopeAll("users", "delete"), BulkActionPanel.Callback.filter((BulkAction.delete_expired == F.action) & ~F.amount) +) async def delete_expired(event: CallbackQuery, state: FSMContext): try: await event.message.delete() @@ -276,7 +283,9 @@ async def process_expire_before(event: Message, state: FSMContext): ) -@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.delete_expired == F.action) & F.amount)) +@router.callback_query( + IsScopeAll("users", "delete"), BulkActionPanel.Callback.filter((BulkAction.delete_expired == F.action) & F.amount) +) async def delete_expired_done( event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: BulkActionPanel.Callback ): @@ -293,7 +302,9 @@ async def delete_expired_done( await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup()) -@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_expiry == F.action) & ~F.amount)) +@router.callback_query( + IsScopeAll("users", "update"), BulkActionPanel.Callback.filter((BulkAction.modify_expiry == F.action) & ~F.amount) +) async def modify_expiry(event: CallbackQuery, state: FSMContext): try: await event.message.delete() @@ -329,14 +340,21 @@ async def process_expiry(event: Message, state: FSMContext): ) -@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_expiry == F.action) & F.amount)) -async def modify_expiry_done(event: CallbackQuery, db: AsyncSession, callback_data: BulkActionPanel.Callback): +@router.callback_query( + IsScopeAll("users", "update"), BulkActionPanel.Callback.filter((BulkAction.modify_expiry == F.action) & F.amount) +) +async def modify_expiry_done( + event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: BulkActionPanel.Callback +): result = await user_operations.bulk_modify_expire(db, BulkUser(amount=int(callback_data.amount) * 86400)) await event.answer(Texts.users_expiry_changed(result, int(callback_data.amount))) await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup()) -@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_data_limit == F.action) & ~F.amount)) +@router.callback_query( + IsScopeAll("users", "update"), + BulkActionPanel.Callback.filter((BulkAction.modify_data_limit == F.action) & ~F.amount), +) async def modify_data_limit(event: CallbackQuery, state: FSMContext): try: await event.message.delete() @@ -372,8 +390,13 @@ async def process_data_limit(event: Message, state: FSMContext): ) -@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_data_limit == F.action) & F.amount)) -async def modify_data_limit_done(event: CallbackQuery, db: AsyncSession, callback_data: BulkActionPanel.Callback): +@router.callback_query( + IsScopeAll("users", "update"), + BulkActionPanel.Callback.filter((BulkAction.modify_data_limit == F.action) & F.amount), +) +async def modify_data_limit_done( + event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: BulkActionPanel.Callback +): result = await user_operations.bulk_modify_datalimit(db, BulkUser(amount=int(callback_data.amount) * (1024**3))) await event.answer(Texts.users_data_limit_changed(result, int(callback_data.amount))) await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup()) diff --git a/app/telegram/handlers/admin/main_menu.py b/app/telegram/handlers/admin/main_menu.py index c1b2718c2..efb6baa0f 100644 --- a/app/telegram/handlers/admin/main_menu.py +++ b/app/telegram/handlers/admin/main_menu.py @@ -10,7 +10,7 @@ from app.operation.system import SystemOperation from app.settings import telegram_settings from app.telegram.keyboards.admin import AdminPanel, AdminPanelAction -from app.telegram.utils.filters import IsAdminFilter, IsAdminSUDO +from app.telegram.utils.filters import HasPermission, IsAdminFilter from app.telegram.utils.texts import Message as Texts system_operator = SystemOperation(OperatorType.TELEGRAM) @@ -19,58 +19,53 @@ router = Router(name="main_menu") +async def _render_main_menu(event: CallbackQuery, db: AsyncSession, admin: AdminDetails): + """Render the main admin panel with permission-aware keyboard.""" + stats = await system_operator.get_system_stats(db, admin) + settings = await telegram_settings() + return AdminPanel( + admin=admin, + panel_url=settings.mini_app_web_url if settings.mini_app_login else None, + ).as_markup(), Texts.start(stats) + + @router.callback_query(IsAdminFilter(), AdminPanel.Callback.filter(AdminPanelAction.refresh == F.action)) async def reload_data(event: CallbackQuery, db: AsyncSession, admin: AdminDetails): - stats = await system_operator.get_system_stats(db, admin) + markup, text = await _render_main_menu(event, db, admin) try: - settings = await telegram_settings() - await event.message.edit_text( - text=Texts.start(stats), - reply_markup=AdminPanel( - is_sudo=admin.is_sudo, panel_url=settings.mini_app_web_url if settings.mini_app_login else None - ).as_markup(), - ) + await event.message.edit_text(text=text, reply_markup=markup) except TelegramBadRequest: pass - await event.answer(Texts.refreshed) -@router.callback_query(IsAdminSUDO(), AdminPanel.Callback.filter(AdminPanelAction.sync_users == F.action)) +@router.callback_query( + HasPermission("nodes", "reconnect"), + AdminPanel.Callback.filter(AdminPanelAction.sync_users == F.action), +) async def sync_users(event: CallbackQuery, db: AsyncSession, admin: AdminDetails): await event.answer(Texts.syncing) nodes_response = await node_operator.get_db_nodes(db, NodeListQuery()) for node in nodes_response.nodes: await node_operator.sync_node_users(db, node.id, flush_users=True) + markup, text = await _render_main_menu(event, db, admin) try: - stats = await system_operator.get_system_stats(db, admin) - settings = await telegram_settings() - await event.message.edit_text( - text=Texts.start(stats), - reply_markup=AdminPanel( - is_sudo=admin.is_sudo, panel_url=settings.mini_app_web_url if settings.mini_app_login else None - ).as_markup(), - ) + await event.message.edit_text(text=text, reply_markup=markup) except TelegramBadRequest: pass - await event.answer(Texts.synced) -@router.callback_query(IsAdminSUDO(), AdminPanel.Callback.filter(AdminPanelAction.reconnect_all_nodes == F.action)) +@router.callback_query( + HasPermission("nodes", "reconnect"), + AdminPanel.Callback.filter(AdminPanelAction.reconnect_all_nodes == F.action), +) async def reconnect_all_nodes(event: CallbackQuery, db: AsyncSession, admin: AdminDetails): await event.answer(Texts.reconnecting_nodes) await node_operator.restart_all_node(db=db, admin=admin) + markup, text = await _render_main_menu(event, db, admin) try: - stats = await system_operator.get_system_stats(db, admin) - settings = await telegram_settings() - await event.message.edit_text( - text=Texts.start(stats), - reply_markup=AdminPanel( - is_sudo=admin.is_sudo, panel_url=settings.mini_app_web_url if settings.mini_app_login else None - ).as_markup(), - ) + await event.message.edit_text(text=text, reply_markup=markup) except TelegramBadRequest: pass - await event.answer(Texts.nodes_reconnected) diff --git a/app/telegram/handlers/admin/user.py b/app/telegram/handlers/admin/user.py index e89d8c1c8..61a4ddc96 100644 --- a/app/telegram/handlers/admin/user.py +++ b/app/telegram/handlers/admin/user.py @@ -2,7 +2,9 @@ from datetime import datetime as dt, timedelta as td from io import BytesIO -from aiogram import Router, F +from aiogram import F, Router +from aiogram.exceptions import TelegramBadRequest +from aiogram.fsm.context import FSMContext from aiogram.types import ( BufferedInputFile, CallbackQuery, @@ -11,13 +13,12 @@ InputTextMessageContent, Message, ) -from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from aiogram.exceptions import TelegramBadRequest from app.db.models import UserStatus -from app.models.settings import ConfigFormat +from app.models.admin import AdminDetails from app.models.group import GroupListQuery +from app.models.settings import ConfigFormat from app.models.user import ( CreateUserFromTemplate, ModifyUserByTemplate, @@ -29,18 +30,19 @@ from app.models.user_template import UserTemplateListQuery from app.models.validators import UserValidator from app.operation import OperatorType +from app.operation.group import GroupOperation +from app.operation.permissions import PermissionDenied, enforce_permission, get_scope_admin_id from app.operation.subscription import SubscriptionOperation from app.operation.user import UserOperation -from app.operation.group import GroupOperation from app.operation.user_template import UserTemplateOperation -from app.telegram.keyboards.group import SelectGroupAction, GroupsSelector -from app.telegram.utils import forms -from app.models.admin import AdminDetails from app.telegram.keyboards.admin import AdminPanel, AdminPanelAction, InlineQuerySearch from app.telegram.keyboards.base import CancelKeyboard -from app.telegram.utils.texts import Message as Texts -from app.telegram.keyboards.user import UserPanel, UserPanelAction, ChooseStatus, ChooseTemplate, RandomUsername +from app.telegram.keyboards.group import GroupsSelector, SelectGroupAction +from app.telegram.keyboards.user import ChooseStatus, ChooseTemplate, RandomUsername, UserPanel, UserPanelAction +from app.telegram.utils import forms +from app.telegram.utils.filters import HasPermission from app.telegram.utils.shared import add_to_messages_to_delete, delete_messages +from app.telegram.utils.texts import Message as Texts user_operations = UserOperation(OperatorType.TELEGRAM) subscription_operations = SubscriptionOperation(OperatorType.TELEGRAM) @@ -52,6 +54,7 @@ @router.callback_query( + HasPermission("users", "create"), AdminPanel.Callback.filter(AdminPanelAction.create_user == F.action), ) async def create_user(event: CallbackQuery, state: FSMContext): @@ -219,7 +222,9 @@ async def select_groups( ) -@router.callback_query(GroupsSelector.Callback.filter(SelectGroupAction.create == F.action)) +@router.callback_query( + HasPermission("users", "create"), GroupsSelector.Callback.filter(SelectGroupAction.create == F.action) +) async def process_done(event: CallbackQuery, db: AsyncSession, admin: AdminDetails, state: FSMContext): data = await state.get_data() if not data.get("group_ids", []): @@ -247,10 +252,14 @@ async def process_done(event: CallbackQuery, db: AsyncSession, admin: AdminDetai user = await user_operations.create_user(db, new_user, admin) groups = await user_operations.validate_all_groups(db, user) await event.answer(Texts.user_created) - return await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + return await event.message.edit_text( + Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup() + ) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_groups == F.action)) +@router.callback_query( + HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.modify_groups == F.action) +) async def modify_groups( event: CallbackQuery, db: AsyncSession, state: FSMContext, callback_data: UserPanel.Callback, admin: AdminDetails ): @@ -271,7 +280,9 @@ async def modify_groups( ) -@router.callback_query(GroupsSelector.Callback.filter(SelectGroupAction.modify == F.action)) +@router.callback_query( + HasPermission("users", "update"), GroupsSelector.Callback.filter(SelectGroupAction.modify == F.action) +) async def modify_groups_done(event: CallbackQuery, db: AsyncSession, admin: AdminDetails, state: FSMContext): data = await state.get_data() if not data.get("group_ids", []): @@ -288,10 +299,14 @@ async def modify_groups_done(event: CallbackQuery, db: AsyncSession, admin: Admi groups = await user_operations.validate_all_groups(db, user) await delete_messages(event, state) await state.clear() - await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.message.edit_text( + Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup() + ) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_expiry == F.action)) +@router.callback_query( + HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.modify_expiry == F.action) +) async def modify_expiry(event: CallbackQuery, callback_data: UserPanel.Callback, state: FSMContext): await state.set_state(forms.ModifyUser.new_expiry) await state.update_data(user_id=callback_data.user_id) @@ -334,10 +349,12 @@ async def modify_expiry_done(event: Message, state: FSMContext, db: AsyncSession modified_user = UserModify(expire=(dt.now() + td(days=duration)) if duration else 0) user = await user_operations.modify_user(db, user.username, modified_user, admin) groups = await user_operations.validate_all_groups(db, user) - await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_data_limit == F.action)) +@router.callback_query( + HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.modify_data_limit == F.action) +) async def modify_data_limit(event: CallbackQuery, callback_data: UserPanel.Callback, state: FSMContext): await state.set_state(forms.ModifyUser.new_data_limit) await state.update_data(user_id=callback_data.user_id) @@ -374,10 +391,12 @@ async def modify_data_limit_done(event: Message, state: FSMContext, db: AsyncSes modified_user = UserModify(data_limit=data_limit * 1024**3) user = await user_operations.modify_user(db, user.username, modified_user, admin) groups = await user_operations.validate_all_groups(db, user) - await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_note == F.action)) +@router.callback_query( + HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.modify_note == F.action) +) async def modify_note(event: CallbackQuery, callback_data: UserPanel.Callback, state: FSMContext): await state.set_state(forms.ModifyUser.new_note) await state.update_data(user_id=callback_data.user_id) @@ -407,10 +426,10 @@ async def modify_note_done(event: Message, state: FSMContext, db: AsyncSession, modified_user = UserModify(note=note) user = await user_operations.modify_user(db, user.username, modified_user, admin) groups = await user_operations.validate_all_groups(db, user) - await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.disable == F.action)) +@router.callback_query(HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.disable == F.action)) async def disable_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback): user = await user_operations.get_user_by_id(db, callback_data.user_id, admin) modified_user = UserModify(**user.model_dump()) @@ -418,18 +437,23 @@ async def disable_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSessi user = await user_operations.modify_user(db, user.username, modified_user, admin) await event.answer(f"User {user.username} has been disabled.") groups = await user_operations.validate_all_groups(db, user) - await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.message.edit_text( + Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup() + ) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.delete == F.action)) +@router.callback_query(HasPermission("users", "delete"), UserPanel.Callback.filter(UserPanelAction.delete == F.action)) async def delete_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback): - user = await user_operations.get_user_by_id(db, callback_data.user_id, admin) - await user_operations.remove_user(db, user.username, admin) + try: + user = await user_operations.get_user_by_id(db, callback_data.user_id, admin) + await user_operations.remove_user(db, user.username, admin) + except (ValueError, PermissionDenied) as e: + return await event.answer(str(e), show_alert=True) await event.answer(Texts.user_deleted(user.username)) - await event.message.edit_text(Texts.user_deleted(user.username), reply_markup=AdminPanel(admin.is_sudo).as_markup()) + await event.message.edit_text(Texts.user_deleted(user.username), reply_markup=AdminPanel(admin=admin).as_markup()) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.enable == F.action)) +@router.callback_query(HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.enable == F.action)) async def enable_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback): user = await user_operations.get_user_by_id(db, callback_data.user_id, admin) modified_user = UserModify(**user.model_dump()) @@ -437,28 +461,41 @@ async def enable_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSessio user = await user_operations.modify_user(db, user.username, modified_user, admin) await event.answer(Texts.user_enabled(user.username)) groups = await user_operations.validate_all_groups(db, user) - await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.message.edit_text( + Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup() + ) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.revoke_sub == F.action)) +@router.callback_query( + HasPermission("users", "revoke_sub"), UserPanel.Callback.filter(UserPanelAction.revoke_sub == F.action) +) async def revoke_sub(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback): user = await user_operations.get_user_by_id(db, callback_data.user_id, admin) user = await user_operations.revoke_user_sub(db, user.username, admin) await event.answer(Texts.user_sub_revoked(user.username)) groups = await user_operations.validate_all_groups(db, user) - await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.message.edit_text( + Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup() + ) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.reset_usage == F.action)) +@router.callback_query( + HasPermission("users", "reset_usage"), UserPanel.Callback.filter(UserPanelAction.reset_usage == F.action) +) async def reset_usage(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback): user = await user_operations.get_user_by_id(db, callback_data.user_id, admin) user = await user_operations.reset_user_data_usage(db, user.username, admin) await event.answer(Texts.user_reset_usage(user.username)) groups = await user_operations.validate_all_groups(db, user) - await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.message.edit_text( + Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup() + ) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.activate_next_plan == F.action)) +@router.callback_query( + HasPermission("users", "activate_next_plan"), + UserPanel.Callback.filter(UserPanelAction.activate_next_plan == F.action), +) async def activate_next_plan( event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback ): @@ -466,10 +503,14 @@ async def activate_next_plan( user = await user_operations.active_next_plan(db, user.username, admin) await event.answer(Texts.user_next_plan_activated(user.username)) groups = await user_operations.validate_all_groups(db, user) - await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.message.edit_text( + Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup() + ) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_with_template == F.action)) +@router.callback_query( + HasPermission("users", "update"), UserPanel.Callback.filter(UserPanelAction.modify_with_template == F.action) +) async def modify_with_template(event: CallbackQuery, db: AsyncSession, callback_data: UserPanel.Callback): templates = await user_templates.get_user_templates(db, UserTemplateListQuery()) if not templates: @@ -480,7 +521,7 @@ async def modify_with_template(event: CallbackQuery, db: AsyncSession, callback_ ) -@router.callback_query(ChooseTemplate.Callback.filter(F.user_id)) +@router.callback_query(HasPermission("users", "update"), ChooseTemplate.Callback.filter(F.user_id)) async def modify_with_template_done( event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: ChooseTemplate.Callback ): @@ -492,10 +533,15 @@ async def modify_with_template_done( admin, ) groups = await user_operations.validate_all_groups(db, user) - return await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + return await event.message.edit_text( + Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup() + ) -@router.callback_query(AdminPanel.Callback.filter(AdminPanelAction.create_user_from_template == F.action)) +@router.callback_query( + HasPermission("users", "create"), + AdminPanel.Callback.filter(AdminPanelAction.create_user_from_template == F.action), +) async def create_user_from_template(event: CallbackQuery, db: AsyncSession): templates = await user_templates.get_user_templates(db, UserTemplateListQuery()) if not templates: @@ -503,7 +549,7 @@ async def create_user_from_template(event: CallbackQuery, db: AsyncSession): await event.message.edit_text(Texts.choose_a_template, reply_markup=ChooseTemplate(templates).as_markup()) -@router.callback_query(ChooseTemplate.Callback.filter(~F.username)) +@router.callback_query(HasPermission("users", "create"), ChooseTemplate.Callback.filter(~F.username)) async def create_user_from_template_username( event: CallbackQuery, state: FSMContext, callback_data: ChooseTemplate.Callback ): @@ -568,9 +614,13 @@ async def create_user_from_template_choose( ) groups = await user_operations.validate_all_groups(db, user) if isinstance(event, Message): - return await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + return await event.answer( + Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup() + ) else: - return await event.message.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + return await event.message.answer( + Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup() + ) @router.message(F.text.contains("/sub/")) @@ -580,16 +630,24 @@ async def get_user_by_sub(event: Message, db: AsyncSession, admin: AdminDetails) try: db_user = await user_operations.get_validated_sub(db, token) user = await user_operations.validate_user(db_user) - if user.admin and user.admin.username != admin.username and not admin.is_sudo: + try: + enforce_permission(admin, "users", "read") + except PermissionDenied: + return await event.reply(Texts.user_not_found) + + scope_id = get_scope_admin_id(admin, "users", "read") + if scope_id is not None and user.admin.id != scope_id: return await event.reply(Texts.user_not_found) except ValueError: return await event.reply(Texts.user_not_found) groups = await user_operations.validate_all_groups(db, user) - await event.reply(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.reply(Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.v2ray_links == F.action)) +@router.callback_query( + HasPermission("users", "read"), UserPanel.Callback.filter(UserPanelAction.v2ray_links == F.action) +) async def get_v2ray_links( event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: UserPanel.Callback ): @@ -621,8 +679,8 @@ async def get_v2ray_links( await event.answer() -@router.message(F.text) -@router.callback_query(UserPanel.Callback.filter(UserPanelAction.show == F.action)) +@router.message(HasPermission("users", "read"), F.text) +@router.callback_query(HasPermission("users", "read"), UserPanel.Callback.filter(UserPanelAction.show == F.action)) async def get_user(event: Message | CallbackQuery, admin: AdminDetails, db: AsyncSession, **kwargs): """get exact user, otherwise not found""" try: @@ -638,9 +696,11 @@ async def get_user(event: Message | CallbackQuery, admin: AdminDetails, db: Asyn groups = await user_operations.validate_all_groups(db, user) if isinstance(event, Message): - await event.reply(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.reply(Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup()) else: - await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup()) + await event.message.edit_text( + Texts.user_details(user, groups), reply_markup=UserPanel(user, admin=admin).as_markup() + ) @router.inline_query() diff --git a/app/telegram/handlers/base.py b/app/telegram/handlers/base.py index b4036e4cf..e856265ea 100644 --- a/app/telegram/handlers/base.py +++ b/app/telegram/handlers/base.py @@ -44,7 +44,7 @@ async def command_start_handler( return await message.edit_text( text=Texts.start(stats), reply_markup=AdminPanel( - is_sudo=admin.is_sudo, + admin=admin, panel_url=settings.mini_app_web_url if settings.mini_app_login else None, ).as_markup(), ) @@ -53,7 +53,7 @@ async def command_start_handler( await message.answer( text=Texts.start(stats), reply_markup=AdminPanel( - is_sudo=admin.is_sudo, panel_url=settings.mini_app_web_url if settings.mini_app_login else None + admin=admin, panel_url=settings.mini_app_web_url if settings.mini_app_login else None ).as_markup(), ) else: diff --git a/app/telegram/keyboards/admin.py b/app/telegram/keyboards/admin.py index b59cc067b..ed3417a2e 100644 --- a/app/telegram/keyboards/admin.py +++ b/app/telegram/keyboards/admin.py @@ -3,6 +3,8 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder, WebAppInfo from aiogram.filters.callback_data import CallbackData +from app.models.admin import AdminDetails +from app.operation.permissions import enforce_permission, is_scope_all, PermissionDenied from app.telegram.utils.texts import Button as Texts @@ -15,36 +17,61 @@ class AdminPanelAction(str, Enum): bulk_actions = "bulk_actions" +def _has_permission(admin: AdminDetails | None, resource: str, action: str) -> bool: + """Return True if admin has the given permission.""" + if not admin: + return False + try: + enforce_permission(admin, resource, action) + return True + except PermissionDenied: + return False + + class AdminPanel(InlineKeyboardBuilder): class Callback(CallbackData, prefix="panel"): action: AdminPanelAction - def __init__(self, is_sudo: bool = False, panel_url: str = None, *args, **kwargs): + def __init__(self, admin: AdminDetails | None = None, panel_url: str = None, *args, **kwargs): super().__init__(*args, **kwargs) adjust = [] + if panel_url and panel_url.startswith("https://"): self.button(text=Texts.open_panel, web_app=WebAppInfo(url=panel_url)) adjust.append(1) self.button(text=Texts.refresh_data, callback_data=self.Callback(action=AdminPanelAction.refresh)) - if is_sudo: + + can_read_nodes = _has_permission(admin, "nodes", "reconnect") + can_read_users = _has_permission(admin, "users", "read") + can_create_users = _has_permission(admin, "users", "create") + # bulk_actions requires scope=all on users.update + can_bulk = is_scope_all(admin, "users", "update") if admin else False + + if can_read_nodes: self.button(text=Texts.sync_users, callback_data=self.Callback(action=AdminPanelAction.sync_users)) self.button( text=Texts.reconnect_all_nodes, callback_data=self.Callback(action=AdminPanelAction.reconnect_all_nodes), ) + adjust = adjust + [1, 2] + + if can_read_users: self.button(text=Texts.users, switch_inline_query_current_chat="") + adjust.append(1) + + if can_bulk: self.button(text=Texts.bulk_actions, callback_data=self.Callback(action=AdminPanelAction.bulk_actions)) - adjust = adjust + [2] * 2 + [1] - else: - self.button(text=Texts.users, switch_inline_query_current_chat="") - adjust = adjust + [1] * 2 - self.button(text=Texts.create_user, callback_data=self.Callback(action=AdminPanelAction.create_user)) - self.button( - text=Texts.create_user_from_template, - callback_data=self.Callback(action=AdminPanelAction.create_user_from_template), - ) - adjust = adjust + [1] * 2 + adjust.append(1) + + if can_create_users: + self.button(text=Texts.create_user, callback_data=self.Callback(action=AdminPanelAction.create_user)) + self.button( + text=Texts.create_user_from_template, + callback_data=self.Callback(action=AdminPanelAction.create_user_from_template), + ) + adjust = adjust + [1, 1] + self.adjust(*adjust) diff --git a/app/telegram/keyboards/user.py b/app/telegram/keyboards/user.py index 912f2f6ca..99870e6df 100644 --- a/app/telegram/keyboards/user.py +++ b/app/telegram/keyboards/user.py @@ -5,14 +5,27 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.filters.callback_data import CallbackData +from app.models.admin import AdminDetails from app.models.user import UserResponse, UserStatus from app.models.user_template import UserTemplate +from app.operation.permissions import enforce_permission, PermissionDenied from app.telegram.utils.texts import Button as Texts from .base import CancelAction, CancelKeyboard from .confim_action import ConfirmAction +def _has_permission(admin: AdminDetails | None, resource: str, action: str) -> bool: + """Return True if admin has the given permission.""" + if not admin: + return False + try: + enforce_permission(admin, resource, action) + return True + except PermissionDenied: + return False + + class UserPanelAction(str, Enum): show = "show" disable = "disable" @@ -34,60 +47,73 @@ class Callback(CallbackData, prefix="user"): user_id: int action: UserPanelAction = UserPanelAction.show - def __init__(self, user: UserResponse, *args, **kwargs): + def __init__(self, user: UserResponse, admin: AdminDetails | None = None, *args, **kwargs): super().__init__(*args, **kwargs) - if user.status == UserStatus.active: + can_modify = _has_permission(admin, "users", "update") + can_delete = _has_permission(admin, "users", "delete") + can_revoke_sub = _has_permission(admin, "users", "revoke_sub") + can_reset_usage = _has_permission(admin, "users", "reset_usage") + can_activate_next_plan = _has_permission(admin, "users", "activate_next_plan") + + if can_modify: + if user.status == UserStatus.active: + self.button( + text=Texts.disable, + callback_data=ConfirmAction.Callback( + action=self.Callback(action=UserPanelAction.disable, user_id=user.id).pack(), + cancel=self.Callback(user_id=user.id).pack(), + ), + ) + else: + self.button( + text=Texts.enable, + callback_data=ConfirmAction.Callback( + action=self.Callback(action=UserPanelAction.enable, user_id=user.id).pack(), + cancel=self.Callback(user_id=user.id).pack(), + ), + ) + if can_delete: self.button( - text=Texts.disable, + text=Texts.delete, callback_data=ConfirmAction.Callback( - action=self.Callback(action=UserPanelAction.disable, user_id=user.id).pack(), + action=self.Callback(action=UserPanelAction.delete, user_id=user.id).pack(), cancel=self.Callback(user_id=user.id).pack(), ), ) - else: + if can_revoke_sub: self.button( - text=Texts.enable, + text=Texts.revoke_sub, callback_data=ConfirmAction.Callback( - action=self.Callback(action=UserPanelAction.enable, user_id=user.id).pack(), + action=self.Callback(action=UserPanelAction.revoke_sub, user_id=user.id).pack(), cancel=self.Callback(user_id=user.id).pack(), ), ) - self.button( - text=Texts.delete, - callback_data=ConfirmAction.Callback( - action=self.Callback(action=UserPanelAction.delete, user_id=user.id).pack(), - cancel=self.Callback(user_id=user.id).pack(), - ), - ) - self.button( - text=Texts.revoke_sub, - callback_data=ConfirmAction.Callback( - action=self.Callback(action=UserPanelAction.revoke_sub, user_id=user.id).pack(), - cancel=self.Callback(user_id=user.id).pack(), - ), - ) - self.button( - text=Texts.reset_usage, - callback_data=ConfirmAction.Callback( - action=self.Callback(action=UserPanelAction.reset_usage, user_id=user.id).pack(), - cancel=self.Callback(user_id=user.id).pack(), - ), - ) - self.button( - text=Texts.modify_data_limit, - callback_data=self.Callback(action=UserPanelAction.modify_data_limit, user_id=user.id), - ) - self.button( - text=Texts.modify_expiry, - callback_data=self.Callback(action=UserPanelAction.modify_expiry, user_id=user.id), - ) - self.button( - text=Texts.modify_note, callback_data=self.Callback(action=UserPanelAction.modify_note, user_id=user.id) - ) - self.button( - text=Texts.modify_groups, callback_data=self.Callback(action=UserPanelAction.modify_groups, user_id=user.id) - ) - if not user.next_plan: + if can_reset_usage: + self.button( + text=Texts.reset_usage, + callback_data=ConfirmAction.Callback( + action=self.Callback(action=UserPanelAction.reset_usage, user_id=user.id).pack(), + cancel=self.Callback(user_id=user.id).pack(), + ), + ) + if can_modify: + self.button( + text=Texts.modify_data_limit, + callback_data=self.Callback(action=UserPanelAction.modify_data_limit, user_id=user.id), + ) + self.button( + text=Texts.modify_expiry, + callback_data=self.Callback(action=UserPanelAction.modify_expiry, user_id=user.id), + ) + self.button( + text=Texts.modify_note, + callback_data=self.Callback(action=UserPanelAction.modify_note, user_id=user.id), + ) + self.button( + text=Texts.modify_groups, + callback_data=self.Callback(action=UserPanelAction.modify_groups, user_id=user.id), + ) + if can_activate_next_plan and not user.next_plan: self.button( text=Texts.activate_next_plan, callback_data=ConfirmAction.Callback( @@ -95,11 +121,11 @@ def __init__(self, user: UserResponse, *args, **kwargs): cancel=self.Callback(user_id=user.id).pack(), ), ) - - self.button( - text=Texts.modify_with_template, - callback_data=self.Callback(action=UserPanelAction.modify_with_template, user_id=user.id), - ) + if can_modify: + self.button( + text=Texts.modify_with_template, + callback_data=self.Callback(action=UserPanelAction.modify_with_template, user_id=user.id), + ) self.button(text=Texts.subscription_url, copy_text=CopyTextButton(text=user.subscription_url)) self.button( diff --git a/app/telegram/middlewares/acl.py b/app/telegram/middlewares/acl.py index 631166b61..722b61e43 100644 --- a/app/telegram/middlewares/acl.py +++ b/app/telegram/middlewares/acl.py @@ -5,6 +5,7 @@ from app.db import GetDB from app.db.crud.admin import get_admin_by_telegram_id +from app.db.models import AdminStatus from app.models.admin import AdminDetails from app.settings import telegram_settings from app.models.settings import Telegram @@ -20,7 +21,7 @@ async def __call__( settings: Telegram = await telegram_settings() admin = await get_admin_by_telegram_id(db, user_id) if admin: - if admin.is_disabled: + if admin.status == AdminStatus.disabled: if settings.for_admins_only: return data["admin"] = None diff --git a/app/telegram/utils/filters.py b/app/telegram/utils/filters.py index b3fda4272..b1a4ada11 100644 --- a/app/telegram/utils/filters.py +++ b/app/telegram/utils/filters.py @@ -1,13 +1,47 @@ from aiogram.filters import Filter from app.models.admin import AdminDetails +from app.operation.permissions import enforce_permission, is_scope_all, PermissionDenied class IsAdminFilter(Filter): + """Passes if the user is a known, non-disabled admin.""" + async def __call__(self, _, admin: AdminDetails | None = None) -> bool: return bool(admin) -class IsAdminSUDO(Filter): +class HasPermission(Filter): + """ + RBAC filter — passes if the admin has the given resource+action permission. + Usage: HasPermission("users", "create") + """ + + def __init__(self, resource: str, action: str): + self.resource = resource + self.action = action + + async def __call__(self, _, admin: AdminDetails | None = None) -> bool: + if not admin: + return False + try: + enforce_permission(admin, self.resource, self.action) + return True + except PermissionDenied: + return False + + +class IsScopeAll(Filter): + """ + RBAC filter — passes only if the admin has scope=ALL (or is owner) for resource+action. + Usage: IsScopeAll("users", "update") + """ + + def __init__(self, resource: str, action: str): + self.resource = resource + self.action = action + async def __call__(self, _, admin: AdminDetails | None = None) -> bool: - return admin.is_sudo if admin else False + if not admin: + return False + return is_scope_all(admin, self.resource, self.action) diff --git a/app/utils/jwt.py b/app/utils/jwt.py index f00f30b43..2efa14451 100644 --- a/app/utils/jwt.py +++ b/app/utils/jwt.py @@ -18,8 +18,8 @@ async def get_secret_key(): return key -async def create_admin_token(admin_id: int | None, username: str, is_sudo=False) -> str: - data = {"sub": username, "access": "sudo" if is_sudo else "admin", "iat": datetime.now(timezone.utc)} +async def create_admin_token(admin_id: int | None, username: str) -> str: + data = {"sub": username, "access": "admin", "iat": datetime.now(timezone.utc)} if admin_id is not None: data["aid"] = int(admin_id) if jwt_settings.access_token_expire_minutes > 0: @@ -50,7 +50,6 @@ async def get_admin_payload(token: str) -> dict | None: return { "admin_id": admin_id, "username": username, - "is_sudo": access == "sudo", "created_at": created_at, } except jwt.exceptions.PyJWTError: diff --git a/app/utils/request.py b/app/utils/request.py new file mode 100644 index 000000000..ca5397488 --- /dev/null +++ b/app/utils/request.py @@ -0,0 +1,8 @@ +from fastapi import Request + + +def get_client_ip(request: Request) -> str: + """Extract the client's IP address from the request.""" + if request.client: + return request.client.host + return "Unknown" diff --git a/app/utils/system.py b/app/utils/system.py index e14d8afa5..bf3227742 100644 --- a/app/utils/system.py +++ b/app/utils/system.py @@ -75,3 +75,33 @@ def readable_size(size_bytes): p = math.pow(1024, i) s = round(size_bytes / p, 2) return f"{s} {size_name[i]}" + + +def readable_duration(seconds: int | float) -> str: + """Format a duration (in seconds) as a human-readable string. + + Mirrors :func:`readable_size`: caller always passes seconds, this picks the + largest natural unit (years, months, days, hours, minutes, seconds) and + pluralizes correctly. + """ + if not seconds or seconds <= 0: + return "0 seconds" + + units = ( + ("year", 31_536_000), # 365 days + ("month", 2_592_000), # 30 days + ("day", 86_400), + ("hour", 3_600), + ("minute", 60), + ("second", 1), + ) + + for label, factor in units: + if seconds >= factor: + amount = seconds / factor + if amount % 1 == 0: + amount_int = int(amount) + return f"{amount_int} {label}" if amount_int == 1 else f"{amount_int} {label}s" + return f"{amount:.2f} {label}s" + + return f"{seconds} seconds" diff --git a/cli/__init__.py b/cli/__init__.py index bba064b08..89ff812bd 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -8,30 +8,18 @@ from rich.console import Console from rich.table import Table -from app.models.admin import AdminDetails from app.operation import OperatorType from app.operation.admin import AdminOperation -from app.operation.system import SystemOperation # Initialize console for rich output console = Console() -# system admin for CLI operations -SYSTEM_ADMIN = AdminDetails( - username="cli", is_sudo=True, telegram_id=None, discord_webhook=None, notification_enable=None -) - def get_admin_operation() -> AdminOperation: """Get admin operation instance.""" return AdminOperation(OperatorType.CLI) -def get_system_operation() -> SystemOperation: - """Get node operation instance.""" - return SystemOperation(OperatorType.CLI) - - class BaseCLI: """Base class for CLI operations.""" diff --git a/cli/admin.py b/cli/admin.py index da7d820ee..8680e74e5 100644 --- a/cli/admin.py +++ b/cli/admin.py @@ -1,392 +1,21 @@ """ -Admin CLI Module - -Handles admin account management through the command line interface. +Admin CLI Module — generate-temp-key only """ -import typer -from pydantic import ValidationError -from typing_extensions import Annotated - +import asyncio from app.db.base import GetDB -from app.models.admin import AdminCreate, AdminListQuery, AdminModify -from app.models.notification_enable import UserNotificationEnable -from app.utils.system import readable_size -from cli import SYSTEM_ADMIN, BaseCLI, console, get_admin_operation - - -class AdminCLI(BaseCLI): - """Admin CLI operations.""" - - async def list_admins(self, db): - """List all admin accounts.""" - admin_op = get_admin_operation() - admins = await admin_op.get_admins(db, AdminListQuery()) - - if not admins: - self.console.print("[yellow]No admins found[/yellow]") - return - - table = self.create_table( - "Admin Accounts", - [ - {"name": "Username", "style": "cyan"}, - {"name": "Is Sudo", "style": "green"}, - {"name": "Used Traffic", "style": "blue"}, - {"name": "Is Disabled", "style": "red"}, - ], - ) - - for admin in admins: - table.add_row( - admin.username, - "✓" if admin.is_sudo else "✗", - readable_size(admin.used_traffic), - "✓" if admin.is_disabled else "✗", - ) - - self.console.print(table) - - async def create_admin(self, db, username: str, is_sudo: bool): - """Create a new admin account.""" - admin_op = get_admin_operation() - - # Check if admin already exists - admins = await admin_op.get_admins(db, AdminListQuery()) - if any(admin.username == username for admin in admins): - self.console.print(f"[red]Admin '{username}' already exists[/red]") - return - - while True: - # Get password - password = typer.prompt("Password", hide_input=True) - if not password: - self.console.print("[red]Password is required[/red]") - continue - - confirm_password = typer.prompt("Confirm Password", hide_input=True) - if password != confirm_password: - self.console.print("[red]Passwords do not match[/red]") - continue - - try: - # Notification preferences setup - self.console.print("\n[cyan]Notification Preferences:[/cyan]") - enable_notifications = typer.confirm("Enable user notifications for this admin?", default=False) - - if enable_notifications: - self.console.print("[yellow]Select which notification types to enable:[/yellow]") - notif_create = typer.confirm(" User Create?", default=False) - notif_modify = typer.confirm(" User Modify?", default=False) - notif_delete = typer.confirm(" User Delete?", default=False) - notif_status_change = typer.confirm(" Status Change?", default=False) - notif_reset_data = typer.confirm(" Reset Data Usage?", default=False) - notif_data_reset_by_next = typer.confirm(" Data Reset By Next?", default=False) - notif_sub_revoked = typer.confirm(" Subscription Revoked?", default=False) - else: - notif_create = notif_modify = notif_delete = notif_status_change = False - notif_reset_data = notif_data_reset_by_next = notif_sub_revoked = False - - notification_enable = UserNotificationEnable( - create=notif_create, - modify=notif_modify, - delete=notif_delete, - status_change=notif_status_change, - reset_data_usage=notif_reset_data, - data_reset_by_next=notif_data_reset_by_next, - subscription_revoked=notif_sub_revoked, - ) - - # Create admin - new_admin = AdminCreate( - username=username, password=password, is_sudo=is_sudo, notification_enable=notification_enable - ) - await admin_op.create_admin(db, new_admin, SYSTEM_ADMIN) - self.console.print(f"[green]Admin '{username}' created successfully[/green]") - break - except ValidationError as e: - self.format_cli_validation_error(e) - continue - except Exception as e: - self.console.print(f"[red]Error creating admin: {e}[/red]") - break - - async def delete_admin(self, db, username: str): - """Delete an admin account.""" - admin_op = get_admin_operation() - - # Check if admin exists - admins = await admin_op.get_admins(db, AdminListQuery()) - target_admin = next((admin for admin in admins if admin.username == username), None) - if not target_admin: - self.console.print(f"[red]Admin '{username}' not found[/red]") - return - - user_count = len(target_admin.users or []) - - if typer.confirm(f"Are you sure you want to delete admin '{username}'?"): - if user_count > 0: - message = ( - f"Admin '{username}' owns {user_count} users. Delete all of their users before removing the admin?" - ) - delete_users = typer.confirm(message, default=False) - if delete_users: - try: - await admin_op.remove_all_users_by_id(db, target_admin.id, SYSTEM_ADMIN) - self.console.print(f"[green]Deleted {user_count} users belonging to admin '{username}'[/green]") - except Exception as e: - self.console.print(f"[red]Error deleting users: {e}[/red]") - return - - try: - await admin_op.remove_admin_by_id(db, target_admin.id, SYSTEM_ADMIN) - self.console.print(f"[green]Admin '{username}' deleted successfully[/green]") - except Exception as e: - self.console.print(f"[red]Error deleting admin: {e}[/red]") - - async def delete_admin_users(self, db, username: str): - """Delete all users belonging to an admin.""" - admin_op = get_admin_operation() - - admins = await admin_op.get_admins(db, AdminListQuery()) - target_admin = next((admin for admin in admins if admin.username == username), None) - if not target_admin: - self.console.print(f"[red]Admin '{username}' not found[/red]") - return - - if not typer.confirm( - f"Delete all users belonging to admin '{username}'? This action cannot be undone.", default=False - ): - self.console.print("[yellow]Operation cancelled[/yellow]") - return - - try: - deleted = await admin_op.remove_all_users_by_id(db, target_admin.id, SYSTEM_ADMIN) - if deleted == 0: - self.console.print(f"[yellow]Admin '{username}' has no users to delete[/yellow]") - else: - self.console.print(f"[green]Deleted {deleted} users belonging to admin '{username}'[/green]") - except Exception as e: - self.console.print(f"[red]Error deleting users: {e}[/red]") - - async def modify_admin(self, db, username: str, disable: bool): - """Modify an admin account.""" - admin_op = get_admin_operation() - - # Check if admin exists - admins = await admin_op.get_admins(db, AdminListQuery()) - if not any(admin.username == username for admin in admins): - self.console.print(f"[red]Admin '{username}' not found[/red]") - return - - # Get the current admin details - current_admin = next(admin for admin in admins if admin.username == username) +from app.db.crud.temp_key import create_temp_key +from cli import console - self.console.print(f"[yellow]Modifying admin '{username}'[/yellow]") - self.console.print("[cyan]Current settings:[/cyan]") - self.console.print(f" Username: {current_admin.username}") - self.console.print(f" Is Sudo: {'✓' if current_admin.is_sudo else '✗'}") - self.console.print(f" Is Disabled: {'✓' if current_admin.is_disabled else '✗'}") - # Display current notification settings - if current_admin.notification_enable is None: - self.console.print(" Notifications: [yellow]Legacy (Receiving ALL)[/yellow]") - else: - notif = current_admin.notification_enable - self.console.print(" Notifications:") - self.console.print(f" User Create: {'✓' if notif['create'] else '✗'}") - self.console.print(f" User Modify: {'✓' if notif['modify'] else '✗'}") - self.console.print(f" User Delete: {'✓' if notif['delete'] else '✗'}") - self.console.print(f" Status Change: {'✓' if notif['status_change'] else '✗'}") - self.console.print(f" Reset Data Usage: {'✓' if notif['reset_data_usage'] else '✗'}") - self.console.print(f" Data Reset By Next: {'✓' if notif['data_reset_by_next'] else '✗'}") - self.console.print(f" Subscription Revoked: {'✓' if notif['subscription_revoked'] else '✗'}") - - new_password = None - is_sudo = current_admin.is_sudo - is_disabled = current_admin.is_disabled - notification_enable = current_admin.notification_enable - - # Password modification - if typer.confirm("Do you want to change the password?"): - new_password = typer.prompt("New password", hide_input=True) - confirm_password = typer.prompt("Confirm Password", hide_input=True) - if new_password != confirm_password: - self.console.print("[red]Passwords do not match[/red]") - return - - # Sudo status modification - if typer.confirm(f"Do you want to change sudo status? (Current: {'✓' if current_admin.is_sudo else '✗'})"): - is_sudo = typer.confirm("Make this admin a sudo admin?") - - # Disabled status modification - if disable is not None: - is_disabled = disable - elif typer.confirm( - f"Do you want to change disabled status? (Current: {'✓' if current_admin.is_disabled else '✗'})" - ): - is_disabled = typer.confirm("Disable this admin account?") - - # Notification preferences modification (skip for legacy admins with None) - if current_admin.notification_enable is not None and typer.confirm( - "Do you want to modify notification preferences?" - ): - self.console.print("\n[cyan]Notification Preferences:[/cyan]") - enable_notifications = typer.confirm( - "Enable user notifications for this admin?", - default=any( - [ - current_admin.notification_enable["create"], - current_admin.notification_enable["modify"], - current_admin.notification_enable["delete"], - current_admin.notification_enable["status_change"], - current_admin.notification_enable["reset_data_usage"], - current_admin.notification_enable["data_reset_by_next"], - current_admin.notification_enable["subscription_revoked"], - ] - ), - ) - - if enable_notifications: - self.console.print("[yellow]Select which notification types to enable:[/yellow]") - notif_create = typer.confirm(" User Create?", default=current_admin.notification_enable["create"]) - notif_modify = typer.confirm(" User Modify?", default=current_admin.notification_enable["modify"]) - notif_delete = typer.confirm(" User Delete?", default=current_admin.notification_enable["delete"]) - notif_status_change = typer.confirm( - " Status Change?", default=current_admin.notification_enable["status_change"] - ) - notif_reset_data = typer.confirm( - " Reset Data Usage?", default=current_admin.notification_enable["reset_data_usage"] - ) - notif_data_reset_by_next = typer.confirm( - " Data Reset By Next?", default=current_admin.notification_enable["data_reset_by_next"] - ) - notif_sub_revoked = typer.confirm( - " Subscription Revoked?", default=current_admin.notification_enable["subscription_revoked"] - ) - else: - notif_create = notif_modify = notif_delete = notif_status_change = False - notif_reset_data = notif_data_reset_by_next = notif_sub_revoked = False - - notification_enable = UserNotificationEnable( - create=notif_create, - modify=notif_modify, - delete=notif_delete, - status_change=notif_status_change, - reset_data_usage=notif_reset_data, - data_reset_by_next=notif_data_reset_by_next, - subscription_revoked=notif_sub_revoked, - ) - - # Confirm changes - self.console.print("\n[cyan]Summary of changes:[/cyan]") - if new_password: - self.console.print(" Password: [yellow]Will be updated[/yellow]") - if is_sudo != current_admin.is_sudo: - self.console.print(f" Is Sudo: {'✓' if is_sudo else '✗'} [yellow](changed)[/yellow]") - if is_disabled != current_admin.is_disabled: - self.console.print(f" Is Disabled: {'✓' if is_disabled else '✗'} [yellow](changed)[/yellow]") - if notification_enable != current_admin.notification_enable: - self.console.print(" Notifications: [yellow](changed)[/yellow]") - - if typer.confirm("Do you want to apply these changes?"): - try: - # Interactive modification - modified_admin = AdminModify( - is_sudo=is_sudo, - password=new_password, - is_disabled=is_disabled, - notification_enable=notification_enable, - ) - await admin_op.modify_admin_by_id(db, current_admin.id, modified_admin, SYSTEM_ADMIN) - self.console.print(f"[green]Admin '{username}' modified successfully[/green]") - except Exception as e: - self.console.print(f"[red]Error modifying admin: {e}[/red]") - else: - self.console.print("[yellow]Modification cancelled[/yellow]") - - async def reset_admin_usage(self, db, username: str): - """Reset admin usage statistics.""" - admin_op = get_admin_operation() - - # Check if admin exists - admins = await admin_op.get_admins(db, AdminListQuery()) - if not any(admin.username == username for admin in admins): - self.console.print(f"[red]Admin '{username}' not found[/red]") - return - - if typer.confirm(f"Are you sure you want to reset usage for admin '{username}'?"): - try: - target_admin = next(admin for admin in admins if admin.username == username) - await admin_op.reset_admin_usage_by_id(db, target_admin.id, SYSTEM_ADMIN) - self.console.print(f"[green]Usage reset for admin '{username}'[/green]") - except Exception as e: - self.console.print(f"[red]Error resetting usage: {e}[/red]") - - -admin_cli = AdminCLI() - - -# CLI commands -async def list_admins(): - """List all admin accounts.""" - async with GetDB() as db: - try: - await admin_cli.list_admins(db) - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - - -async def create_admin( - username: str, - sudo: Annotated[bool, typer.Option(False, "--sudo", "-s", help="Create a sudo admin.")] = False, -): - """Create a new admin account.""" - async with GetDB() as db: - try: - if not sudo: - sudo = typer.confirm("Make this admin a sudo admin?") - await admin_cli.create_admin(db, username, sudo) - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - - -async def delete_admin(username: str): - """Delete an admin account.""" +async def _generate_temp_key(): async with GetDB() as db: - try: - await admin_cli.delete_admin(db, username) - except Exception as e: - console.print(f"[red]Error: {e}[/red]") + key = await create_temp_key(db) + console.print(f"[bold green]Temp key:[/bold green] {key.key}") + console.print(f"[yellow]Expires at:[/yellow] {key.expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}") + console.print("[dim]This key is valid for 5 minutes and can only be used once.[/dim]") -async def delete_admin_users(username: str): - """Delete all users belonging to an admin.""" - async with GetDB() as db: - try: - await admin_cli.delete_admin_users(db, username) - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - - -async def modify_admin( - username: str, - disable: Annotated[bool, typer.Option(..., "--disable", help="Disable or enable the admin account.")] = None, -): - """Modify an admin account.""" - async with GetDB() as db: - try: - if disable is None: - disable = typer.confirm("Do you want to disable this admin?") - await admin_cli.modify_admin(db, username, disable) - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - - -async def reset_admin_usage(username: str): - """Reset admin usage statistics.""" - async with GetDB() as db: - try: - await admin_cli.reset_admin_usage(db, username) - except Exception as e: - console.print(f"[red]Error: {e}[/red]") +def generate_temp_key(): + """Generate a one-time temp key for owner setup operations.""" + asyncio.run(_generate_temp_key()) diff --git a/cli/main.py b/cli/main.py index 052fb1096..22e51c98e 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1,28 +1,24 @@ #!/usr/bin/env python3 -""" -PasarGuard CLI - Command Line Interface for PasarGuard Management - -A modern, type-safe CLI built with Typer for managing PasarGuard instances. -""" - -import asyncio -from typing import Optional +"""PasarGuard CLI""" import typer - from cli import console -from cli.admin import create_admin, delete_admin, delete_admin_users, list_admins, modify_admin, reset_admin_usage -from cli.system import show_status +from cli.admin import generate_temp_key -# Initialize Typer app app = typer.Typer( name="PasarGuard", - help="PasarGuard CLI - Command Line Interface for PasarGuard Management", + help="PasarGuard CLI", add_completion=False, rich_markup_mode="rich", ) +@app.command("generate-temp-key") +def cmd_generate_temp_key(): + """Generate a one-time temp key for owner setup (create/reset/delete).""" + generate_temp_key() + + @app.command() def version(): """Show PasarGuard version.""" @@ -31,40 +27,5 @@ def version(): console.print(f"[bold blue]PasarGuard[/bold blue] version [bold green]{__version__}[/bold green]") -@app.command() -def admins( - list: bool = typer.Option(False, "--list", "-l", help="List all admins"), - create: Optional[str] = typer.Option(None, "--create", "-c", help="Create new admin"), - sudo: bool = typer.Option(False, "--sudo", "-s", help="Create a sudo admin."), - delete: Optional[str] = typer.Option(None, "--delete", "-d", help="Delete admin"), - delete_users: Optional[str] = typer.Option( - None, "--delete-users", "-u", help="Delete all users belonging to an admin" - ), - modify: Optional[str] = typer.Option(None, "--modify", "-m", help="Modify admin"), - disable: Optional[bool] = typer.Option(None, "--disable", help="Disable or enable the admin account."), - reset_usage: Optional[str] = typer.Option(None, "--reset-usage", "-r", help="Reset admin usage"), -): - """List & manage admin accounts.""" - - if list or not any([create, delete, delete_users, modify, reset_usage]): - asyncio.run(list_admins()) - elif create: - asyncio.run(create_admin(create, sudo)) - elif delete: - asyncio.run(delete_admin(delete)) - elif delete_users: - asyncio.run(delete_admin_users(delete_users)) - elif modify: - asyncio.run(modify_admin(modify, disable)) - elif reset_usage: - asyncio.run(reset_admin_usage(reset_usage)) - - -@app.command() -def system(): - """Show system status.""" - asyncio.run(show_status()) - - if __name__ == "__main__": app() diff --git a/cli/system.py b/cli/system.py deleted file mode 100644 index d1d8c5c91..000000000 --- a/cli/system.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -System CLI Module - -Handles system status and information through the command line interface. -""" - -from rich.panel import Panel - -from app.db.base import GetDB -from app.utils.system import readable_size -from cli import SYSTEM_ADMIN, BaseCLI, console, get_system_operation - - -class SystemCLI(BaseCLI): - """System CLI operations.""" - - async def show_status(self, db): - """Show system status.""" - system_op = get_system_operation() - stats = await system_op.get_system_stats(db, SYSTEM_ADMIN) - - status_text = ( - f"[bold]System Statistics[/bold]\n\n" - f"CPU Usage: [green]{stats.cpu_usage:.1f}%[/green]\n" - f"Memory Usage: [green]{stats.mem_used / stats.mem_total * 100:.1f}%[/green] " - f"Disk Usage: [green]{stats.disk_used / stats.disk_total * 100:.1f}%[/green] " - f"([cyan]{readable_size(stats.mem_used)}[/cyan] / [cyan]{readable_size(stats.mem_total)}[/cyan])\n" - f"Disk Usage: [green]{stats.disk_used / stats.disk_total * 100:.1f}%[/green] " - f"([cyan]{readable_size(stats.disk_used)}[/cyan] / [cyan]{readable_size(stats.disk_total)}[/cyan])\n" - f"CPU Cores: [magenta]{stats.cpu_cores}[/magenta]\n" - f"Total Users: [blue]{stats.total_user}[/blue]\n" - f"Active Users: [green]{stats.active_users}[/green]\n" - f"Online Users: [yellow]{stats.online_users}[/yellow]\n" - f"On Hold Users: [yellow]{stats.on_hold_users}[/yellow]\n" - f"Disabled Users: [red]{stats.disabled_users}[/red]\n" - f"Expired Users: [red]{stats.expired_users}[/red]\n" - f"Limited Users: [yellow]{stats.limited_users}[/yellow]\n" - f"Data Usage (In): [blue]{readable_size(stats.incoming_bandwidth)}[/blue]\n" - f"Data Usage (Out): [blue]{readable_size(stats.outgoing_bandwidth)}[/blue]\n" - ) - - panel = Panel( - status_text, - title="System Information", - border_style="blue", - ) - - self.console.print(panel) - - -# CLI commands -async def show_status(): - """Show system status.""" - system_cli = SystemCLI() - async with GetDB() as db: - try: - await system_cli.show_status(db) - except Exception as e: - console.print(f"[red]Error: {e}[/red]") diff --git a/config.py b/config.py index 157d6e968..6f7521a30 100644 --- a/config.py +++ b/config.py @@ -177,6 +177,7 @@ class JobSettings(EnvSettings): record_node_usages_interval: int = Field(default=30, validation_alias="JOB_RECORD_NODE_USAGES_INTERVAL") record_user_usages_interval: int = Field(default=10, validation_alias="JOB_RECORD_USER_USAGES_INTERVAL") review_users_interval: int = Field(default=30, validation_alias="JOB_REVIEW_USERS_INTERVAL") + review_admin_limits_interval: int = Field(default=10, validation_alias="JOB_REVIEW_ADMIN_LIMITS_INTERVAL") send_notifications_interval: int = Field(default=30, validation_alias="JOB_SEND_NOTIFICATIONS_INTERVAL") gather_nodes_stats_interval: int = Field(default=25, validation_alias="JOB_GATHER_NODES_STATS_INTERVAL") remove_old_inbounds_interval: int = Field(default=600, validation_alias="JOB_REMOVE_OLD_INBOUNDS_INTERVAL") diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 1d511d83f..8e4dbde0d 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -155,8 +155,10 @@ "cancelSuccess": "Changes cancelled and original settings restored", "filterTitle": "Filter Notifications", "filterDescription": "Select events that should trigger notifications for important system activities", + "toggleAll": "Toggle all notifications", "types": { "admin": "Admins", + "adminRole": "Admin roles", "core": "Cores", "group": "Groups", "host": "Hosts", @@ -173,9 +175,11 @@ "delete": "Delete", "resetUsage": "Reset Usage", "login": "Login", + "usageLimitWarning": "Usage Limit Warning", "modifyHosts": "Modify Hosts", "connect": "Connect", "error": "Error", + "limited": "Limited", "statusChange": "Status Change", "resetDataUsage": "Reset Data Usage", "dataResetByNext": "Data Reset By Next", @@ -204,6 +208,12 @@ "title": "Channel Overrides", "description": "Override the default destinations for specific notification events. Leave fields empty to inherit the global configuration.", "hint": "Leave these fields empty to fall back to the main notification settings for this event." + }, + "usageLimitWarnings": { + "thresholds": "Warning Thresholds", + "description": "Send admin data-limit warnings when usage reaches these percentages.", + "addThreshold": "Add Threshold", + "empty": "No thresholds configured" } }, "subscriptions": { @@ -639,9 +649,11 @@ "description": "Manage system administrators", "createAdmin": "Create Admin", "editAdmin": "Modify Admin", + "profileSection": "Profile & contact", "deleteAdmin": "Remove Admin", "username": "Username", "password": "Password", + "dataLimit": "Admin data limit", "isSudo": "Super Admin", "createdAt": "Created At", "actions": "Actions", @@ -686,11 +698,14 @@ "create": "Create Admin", "status": "Status", "role": "Role", + "permissionOverrides": "Permission overrides", + "permissionOverridesHint": "Leave empty to inherit limits from the selected role. Set to 0 to disable.", "total.users": "Total Users", "used.traffic": "Used Traffic", "total": "Total Admins", "active": "Active Admins", "disable": "Disabled Admins", + "limited": "Limited Admins", "telegramId": "Telegram ID", "discord": "Discord Webhook", "supportUrl": "Support URL", @@ -725,6 +740,185 @@ "no_traffic": "No traffic data available" } }, + "adminRoles": { + "title": "Admin Roles", + "description": "Manage dashboard roles and RBAC permissions", + "createRole": "Create role", + "editRole": "Edit role", + "viewRole": "View role", + "readOnlyHint": "This is a built-in role. You can review its configuration but cannot modify it.", + "modalDescription": "Configure permissions, limits, features and access for this role.", + "currentRoleUnavailable": "Current role unavailable", + "loadFallback": "Using built-in roles", + "id": "ID", + "ownerRole": "owner", + "builtInRole": "built-in", + "protectedRole": "Protected role", + "protectedRoleHint": "Built-in and owner roles cannot be modified.", + "permissions": "Permissions", + "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.", + "scopedActionInfo": "Choose ownership scope: none denies access, own limits to the admin's own users, all grants full access.", + "allowAll": "Allow all", + "features": "Features", + "access": "Access", + "permissionCount": "{{count}} permissions", + "limitFeatureCount": "{{limits}} limits, {{features}} feature flags", + "unlimited": "Unlimited", + "empty": "No roles", + "emptyDescription": "Create a role to assign granular permissions, limits, features, and access restrictions to admins.", + "noSearchResults": "No roles match your search.", + "createSuccess": "Role «{{name}}» has been created successfully", + "editSuccess": "Role «{{name}}» has been updated successfully", + "deleteSuccess": "Role «{{name}}» has been deleted successfully", + "deleteFailed": "Failed to delete role «{{name}}»", + "deleteConfirmation": "Delete role", + "deleteConfirm": "Are you sure you want to delete role {{name}}?", + "bulkDeleteTitle": "Delete selected roles", + "bulkDeletePrompt": "Are you sure you want to delete {{count}} selected roles? This action cannot be undone.", + "bulkDeleteSuccess": "{{count}} roles deleted successfully.", + "bulkDeletePartial": "{{count}} roles could not be deleted.", + "bulkDeleteFailed": "Failed to delete selected roles.", + "requireTemplateTitle": "Require template", + "requireTemplateDescription": "Force admins with this role to create users only from a template.", + "allowedTemplates": "Allowed templates", + "allowedTemplatesDescription": "Restrict templates this role can use. Leave empty to allow all.", + "allowedGroups": "Allowed groups", + "allowedGroupsDescription": "Restrict user groups this role can manage. Leave empty to allow all.", + "noTemplates": "No templates available", + "noGroups": "No groups available", + "names": { + "administrator": "Administrator", + "operator": "Operator" + }, + "groups": { + "users": "Users", + "admins": "Admins", + "roles": "Roles", + "nodes": "Nodes", + "coreHosts": "Core and hosts", + "groupsTemplates": "Groups and templates", + "settings": "Settings" + }, + "actions": { + "common": { + "read": "View", + "read_simple": "View simple list", + "create": "Create", + "update": "Update", + "delete": "Delete", + "reset_usage": "Reset usage", + "revoke_sub": "Revoke subscription", + "set_owner": "Set owner", + "activate_next_plan": "Activate next plan", + "reconnect": "Reconnect", + "update_core": "Update core", + "stats": "View statistics", + "logs": "View logs", + "read_general": "View general" + }, + "users": { + "reset_usage": "Reset user usage", + "revoke_sub": "Revoke subscription", + "set_owner": "Set user owner", + "activate_next_plan": "Activate next plan" + }, + "admins": { + "reset_usage": "Reset admin usage" + }, + "settings": { + "read": "View settings", + "read_general": "View general settings", + "update": "Update settings" + }, + "system": { + "read": "View system", + "update": "Update system" + }, + "hwids": { + "read": "View HWIDs", + "update": "Update HWIDs" + }, + "nodes": { + "reconnect": "Reconnect nodes", + "update_core": "Update node core", + "stats": "View node statistics", + "logs": "View node logs" + } + }, + "resources": { + "users": "Users", + "admins": "Admins", + "admin_roles": "Roles", + "nodes": "Nodes", + "cores": "Cores", + "hosts": "Hosts", + "groups": "Groups", + "templates": "User templates", + "client_templates": "Client templates", + "settings": "Settings", + "system": "System", + "hwids": "HWIDs" + }, + "scopedBadge": "Scoped", + "scopes": { + "none": "None", + "own": "Own", + "all": "All" + }, + "limitFields": { + "max_users": "Max users", + "data_limit_min": "Min data limit (bytes)", + "data_limit_max": "Max data limit (bytes)", + "expire_days_min": "Min expire days", + "expire_days_max": "Max expire days", + "min_hwid_per_user": "Min HWIDs per user", + "max_hwid_per_user": "Max HWIDs per user" + }, + "limitedBehavior": { + "disabledWhenLimited": { + "title": "Block limited admins", + "description": "Deny all dashboard and API access after an admin reaches their data limit." + }, + "disableUsersWhenLimited": { + "title": "Disable users when limited", + "description": "Remove this admin's users from nodes while the admin is usage-limited." + } + }, + "featureFields": { + "can_use_reset_strategy": { + "title": "Use reset strategy", + "description": "Allow this role to set the data-limit reset strategy on users." + }, + "can_use_next_plan": { + "title": "Use next plan", + "description": "Allow this role to configure auto-applied next plans." + } + } + }, + "setup": { + "createOwner": "Create owner", + "resetOwner": "Reset owner password", + "deleteOwner": "Delete owner", + "createOwnerShort": "Create", + "resetOwnerShort": "Reset", + "deleteOwnerShort": "Delete", + "ownerAccess": "Owner access", + "ownerAccessDescription": "Use a temporary setup key to create, reset, or remove the owner account.", + "tempKey": "Temp key", + "deleteConfirm": "Type DELETE to confirm", + "keyRequired": "Temp key is required", + "usernameRequired": "Username is required", + "passwordRequired": "Password is required", + "passwordMismatch": "Passwords do not match", + "deleteConfirmRequired": "Type DELETE to confirm", + "ownerCreated": "Owner created successfully", + "ownerReset": "Owner password reset successfully", + "ownerDeleted": "Owner deleted successfully", + "deleteWarning": "This action cannot be undone. The owner account will be permanently removed." + }, "shortcuts": { "title": "Keyboard Shortcuts", "description": "Manage your dashboard shortcuts for quick access", @@ -1303,6 +1497,7 @@ "login.fieldRequired": "This field is required", "login.loginYourAccount": "Login to your account", "login.welcomeBack": "Welcome back, please enter your details", + "login.backToLogin": "Back to login", "memoryUsage": "memory usage", "next": "Next", "monitorServers": "Monitor your servers and users", @@ -2760,6 +2955,7 @@ "createdAt": "Created at", "toggle": "Toggle status", "close": "Close", + "view": "View", "copy": "Copy", "copyAll": "Copy All", "copied": "Copied!", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index ce413765c..42726e7cb 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -38,8 +38,10 @@ "cancelSuccess": "تغییرات لغو شد و تنظیمات اصلی بازیابی شد", "filterTitle": "فیلتر اعلان‌ها", "filterDescription": "رویدادهایی را انتخاب کنید که باید اعلان‌هایی را برای اطلاع‌رسانی از فعالیت‌های مهم سیستم ایجاد کنند", + "toggleAll": "تغییر وضعیت همه اعلان‌ها", "types": { "admin": "مدیران", + "adminRole": "نقش‌های مدیر", "core": "هسته‌ها", "group": "گروه‌ها", "host": "هاست‌ها", @@ -56,9 +58,11 @@ "delete": "حذف", "resetUsage": "بازنشانی مصرف", "login": "ورود", + "usageLimitWarning": "هشدار محدودیت مصرف", "modifyHosts": "ویرایش هاست‌ها", "connect": "اتصال", "error": "خطا", + "limited": "محدود شده", "statusChange": "تغییر وضعیت", "resetDataUsage": "بازنشانی مصرف داده", "dataResetByNext": "بازنشانی داده در بعدی", @@ -90,6 +94,12 @@ "title": "مقصد اعلان‌ها", "description": "تنظیم مقصد پیش‌فرض برای رویدادهای اعلان‌های خاص. هر فیلد را خالی بگذارید تا تنظیمات عمومی را دریافت کنید.", "hint": "هر فیلد را خالی بگذارید تا تنظیمات عمومی را دریافت کنید." + }, + "usageLimitWarnings": { + "thresholds": "آستانه‌های هشدار", + "description": "وقتی مصرف مدیر به این درصدها رسید، هشدار محدودیت داده ارسال می‌شود.", + "addThreshold": "افزودن آستانه", + "empty": "هیچ آستانه‌ای تنظیم نشده است" } }, "subscriptions": { @@ -504,9 +514,11 @@ "description": "مدیریت مدیران سیستم", "createAdmin": "ایجاد مدیر", "editAdmin": "ویرایش مدیر", + "profileSection": "پروفایل و ارتباط", "deleteAdmin": "حذف مدیر", "username": "نام کاربری", "password": "رمز عبور", + "dataLimit": "محدودیت داده مدیر", "isSudo": "sudo", "createdAt": "تاریخ ایجاد", "actions": "عملیات", @@ -551,6 +563,8 @@ "create": "ایجاد مدیر", "status": "وضعیت", "role": "نقش", + "permissionOverrides": "بازنویسی‌های دسترسی", + "permissionOverridesHint": "برای ارث‌بری محدودیت‌ها از نقش انتخاب‌شده، خالی بگذارید. برای غیرفعال‌سازی ۰ بگذارید.", "total.users": "کل کاربران", "used.traffic": "ترافیک مصرف‌ شده", "monitor.traffic": "نظارت بر ترافیک مصرف شده", @@ -558,6 +572,7 @@ "total": "کل مدیران", "active": "مدیران فعال", "disable": "مدیران غیرفعال", + "limited": "مدیران محدودشده", "telegramId": "شناسه تلگرام", "discord": "وبهوک دیسکورد", "supportUrl": "آدرس پشتیبانی", @@ -592,6 +607,185 @@ "no_traffic": "داده‌ای برای ترافیک موجود نیست" } }, + "adminRoles": { + "title": "نقش‌های مدیر", + "description": "مدیریت نقش‌های داشبورد و مجوزهای RBAC", + "createRole": "ایجاد نقش", + "editRole": "ویرایش نقش", + "viewRole": "مشاهده نقش", + "readOnlyHint": "این یک نقش داخلی است. می‌توانید پیکربندی آن را مشاهده کنید اما قابل ویرایش نیست.", + "modalDescription": "مجوزها، محدودیت‌ها، قابلیت‌ها و دسترسی‌های این نقش را پیکربندی کنید.", + "currentRoleUnavailable": "نقش فعلی در دسترس نیست", + "loadFallback": "استفاده از نقش‌های داخلی", + "id": "شناسه", + "ownerRole": "مالک", + "builtInRole": "داخلی", + "protectedRole": "نقش محافظت‌شده", + "protectedRoleHint": "نقش‌های داخلی و مالک قابل ویرایش نیستند.", + "permissions": "مجوزها", + "limits": "محدودیت‌ها", + "limitsAndFeatures": "محدودیت‌ها و قابلیت‌ها", + "limitsHint": "برای استفاده از پیش‌فرض، خالی بگذارید. برای غیرفعال‌سازی ۰ بگذارید.", + "roleFormHint": "اعمال کاربر دارای محدوده، مقدار «هیچ»، «خودی» یا «همه» می‌گیرند. سایر اعمال سوئیچ بولی هستند.", + "scopedActionInfo": "محدوده مالکیت را انتخاب کنید: «هیچ» دسترسی را رد می‌کند، «خودی» فقط کاربران خود مدیر را شامل می‌شود و «همه» دسترسی کامل می‌دهد.", + "allowAll": "اجازه همه", + "features": "قابلیت‌ها", + "access": "دسترسی", + "permissionCount": "{{count}} مجوز", + "limitFeatureCount": "{{limits}} محدودیت، {{features}} پرچم قابلیت", + "unlimited": "نامحدود", + "empty": "نقشی وجود ندارد", + "emptyDescription": "نقش جدیدی بسازید تا مجوزها، محدودیت‌ها، قابلیت‌ها و دسترسی‌های دقیق به مدیران اختصاص دهید.", + "noSearchResults": "نقشی با جستجوی شما مطابقت ندارد.", + "createSuccess": "نقش «{{name}}» با موفقیت ایجاد شد", + "editSuccess": "نقش «{{name}}» با موفقیت به‌روزرسانی شد", + "deleteSuccess": "نقش «{{name}}» با موفقیت حذف شد", + "deleteFailed": "حذف نقش «{{name}}» ناموفق بود", + "deleteConfirmation": "حذف نقش", + "deleteConfirm": "آیا از حذف نقش {{name}} اطمینان دارید؟", + "bulkDeleteTitle": "حذف نقش‌های انتخاب‌شده", + "bulkDeletePrompt": "آیا مطمئنید که می‌خواهید {{count}} نقش انتخاب‌شده را حذف کنید؟ این عمل قابل بازگشت نیست.", + "bulkDeleteSuccess": "{{count}} نقش با موفقیت حذف شد.", + "bulkDeletePartial": "{{count}} نقش حذف نشد.", + "bulkDeleteFailed": "حذف نقش‌های انتخاب‌شده ناموفق بود.", + "requireTemplateTitle": "نیاز به قالب", + "requireTemplateDescription": "مدیران این نقش را مجبور کنید کاربران را فقط از روی یک قالب بسازند.", + "allowedTemplates": "قالب‌های مجاز", + "allowedTemplatesDescription": "قالب‌های قابل استفاده توسط این نقش را محدود کنید. خالی بگذارید تا همه مجاز باشند.", + "allowedGroups": "گروه‌های مجاز", + "allowedGroupsDescription": "گروه‌های کاربری قابل مدیریت توسط این نقش را محدود کنید. خالی بگذارید تا همه مجاز باشند.", + "noTemplates": "هیچ قالبی موجود نیست", + "noGroups": "هیچ گروهی موجود نیست", + "names": { + "administrator": "مدیر کل", + "operator": "اپراتور" + }, + "groups": { + "users": "کاربران", + "admins": "مدیران", + "roles": "نقش‌ها", + "nodes": "گره‌ها", + "coreHosts": "هسته و هاست‌ها", + "groupsTemplates": "گروه‌ها و قالب‌ها", + "settings": "تنظیمات" + }, + "actions": { + "common": { + "read": "مشاهده", + "read_simple": "مشاهده فهرست ساده", + "create": "ایجاد", + "update": "ویرایش", + "delete": "حذف", + "reset_usage": "بازنشانی مصرف", + "revoke_sub": "لغو اشتراک", + "set_owner": "تغییر مالک", + "activate_next_plan": "فعال‌سازی پلن بعدی", + "reconnect": "اتصال مجدد", + "update_core": "به‌روزرسانی هسته", + "stats": "مشاهده آمار", + "logs": "مشاهده لاگ‌ها", + "read_general": "مشاهده عمومی" + }, + "users": { + "reset_usage": "بازنشانی مصرف کاربر", + "revoke_sub": "لغو اشتراک", + "set_owner": "تغییر مالک کاربر", + "activate_next_plan": "فعال‌سازی پلن بعدی" + }, + "admins": { + "reset_usage": "بازنشانی مصرف مدیر" + }, + "settings": { + "read": "مشاهده تنظیمات", + "read_general": "مشاهده تنظیمات عمومی", + "update": "ویرایش تنظیمات" + }, + "system": { + "read": "مشاهده سیستم", + "update": "ویرایش سیستم" + }, + "hwids": { + "read": "مشاهده HWIDها", + "update": "ویرایش HWIDها" + }, + "nodes": { + "reconnect": "اتصال مجدد گره‌ها", + "update_core": "به‌روزرسانی هسته گره", + "stats": "مشاهده آمار گره‌ها", + "logs": "مشاهده لاگ گره‌ها" + } + }, + "resources": { + "users": "کاربران", + "admins": "مدیران", + "admin_roles": "نقش‌ها", + "nodes": "گره‌ها", + "cores": "هسته‌ها", + "hosts": "هاست‌ها", + "groups": "گروه‌ها", + "templates": "قالب‌های کاربری", + "client_templates": "قالب‌های کلاینت", + "settings": "تنظیمات", + "system": "سیستم", + "hwids": "HWID" + }, + "scopedBadge": "محدوده‌دار", + "scopes": { + "none": "هیچ", + "own": "خودی", + "all": "همه" + }, + "limitFields": { + "max_users": "حداکثر کاربران", + "data_limit_min": "حداقل محدودیت داده (بایت)", + "data_limit_max": "حداکثر محدودیت داده (بایت)", + "expire_days_min": "حداقل روز انقضا", + "expire_days_max": "حداکثر روز انقضا", + "min_hwid_per_user": "حداقل HWID به ازای کاربر", + "max_hwid_per_user": "حداکثر HWID به ازای کاربر" + }, + "limitedBehavior": { + "disabledWhenLimited": { + "title": "مسدود کردن مدیران محدودشده", + "description": "پس از رسیدن مدیر به محدودیت داده، همه دسترسی‌های داشبورد و API را رد می‌کند." + }, + "disableUsersWhenLimited": { + "title": "غیرفعال کردن کاربران هنگام محدودیت", + "description": "تا زمانی که مدیر از نظر مصرف محدود است، کاربران این مدیر را از گره‌ها حذف می‌کند." + } + }, + "featureFields": { + "can_use_reset_strategy": { + "title": "استفاده از استراتژی بازنشانی", + "description": "اجازه تنظیم استراتژی بازنشانی محدودیت داده برای کاربران." + }, + "can_use_next_plan": { + "title": "استفاده از پلن بعدی", + "description": "اجازه پیکربندی پلن بعدی برای اعمال خودکار." + } + } + }, + "setup": { + "createOwner": "ایجاد مالک", + "resetOwner": "بازنشانی رمز مالک", + "deleteOwner": "حذف مالک", + "createOwnerShort": "ایجاد", + "resetOwnerShort": "بازنشانی", + "deleteOwnerShort": "حذف", + "ownerAccess": "دسترسی مالک", + "ownerAccessDescription": "با کلید موقت راه‌اندازی، حساب مالک را ایجاد، بازنشانی یا حذف کنید.", + "tempKey": "کلید موقت", + "deleteConfirm": "برای تایید DELETE را وارد کنید", + "keyRequired": "کلید موقت الزامی است", + "usernameRequired": "نام کاربری الزامی است", + "passwordRequired": "رمز عبور الزامی است", + "passwordMismatch": "رمزهای عبور یکسان نیستند", + "deleteConfirmRequired": "برای تایید DELETE را وارد کنید", + "ownerCreated": "مالک با موفقیت ایجاد شد", + "ownerReset": "رمز مالک با موفقیت بازنشانی شد", + "ownerDeleted": "مالک با موفقیت حذف شد", + "deleteWarning": "این عمل قابل بازگشت نیست. حساب مالک برای همیشه حذف خواهد شد." + }, "shortcuts": { "title": "میانبرهای صفحه‌کلید", "description": "میانبرهای داشبورد خود را برای دسترسی سریع مدیریت کنید", @@ -1142,6 +1336,7 @@ "login.fieldRequired": "این فیلد باید پر شود!", "login.loginYourAccount": "وارد حساب خود شوید", "login.welcomeBack": "خوش آمدید, لطفا اطلاعات خود را وارد کنید", + "login.backToLogin": "بازگشت به ورود", "memoryUsage": "مصرف حافظه", "next": "بعدی", "monitorServers": "نظارت بر سرورها و کاربران شما", @@ -2681,6 +2876,7 @@ "core.bulkDeleteTitle": "حذف هسته‌های انتخاب‌شده", "core.bulkDeletePrompt": "آیا مطمئن هستید که می‌خواهید {{count}} هسته انتخاب‌شده را حذف کنید؟ این عمل قابل بازگشت نیست.", "close": "بستن", + "view": "مشاهده", "copy": "کپی", "copyAll": "کپی همه", "copied": "کپی شد!", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 4a6ffbaef..34c5c8afd 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -169,9 +169,11 @@ "cancelSuccess": "Изменения отменены и восстановлены исходные настройки", "activeTypes": "Активные типы", "filterTitle": "Фильтр уведомлений", + "toggleAll": "Включить все уведомления", "filterDescription": "Выберите события, которые должны вызывать уведомления для информирования о важных системных действиях", "types": { "admin": "Админы", + "adminRole": "Роли админов", "core": "Ядра", "group": "Группы", "host": "Хосты", @@ -188,9 +190,11 @@ "delete": "Удалить", "resetUsage": "Сбросить использование", "login": "Вход", + "usageLimitWarning": "Предупреждение о лимите", "modifyHosts": "Изменить хосты", "connect": "Подключить", "error": "Ошибка", + "limited": "Ограничен", "statusChange": "Изменение статуса", "resetDataUsage": "Сбросить использование данных", "dataResetByNext": "Сброс данных при следующем", @@ -222,6 +226,12 @@ "title": "Переопределение каналов", "description": "Переопределите стандартные каналы для конкретных событий уведомлений. Оставьте поля пустыми, чтобы использовать глобальные настройки.", "hint": "Оставьте поля пустыми, чтобы вернуться к основным настройкам уведомлений для этого события." + }, + "usageLimitWarnings": { + "thresholds": "Пороги предупреждений", + "description": "Отправлять предупреждения о лимите данных админа при достижении этих процентов.", + "addThreshold": "Добавить порог", + "empty": "Пороги не настроены" } }, "subscriptions": { @@ -625,9 +635,11 @@ "description": "Управление системными администраторами", "createAdmin": "Создать администратора", "editAdmin": "Редактировать администратора", + "profileSection": "Профиль и контакты", "deleteAdmin": "Удалить администратора", "username": "Имя пользователя", "password": "Пароль", + "dataLimit": "Лимит данных администратора", "isSudo": "Супер администратор", "createdAt": "Дата создания", "actions": "Действия", @@ -672,11 +684,14 @@ "create": "Создать администратора", "status": "Статус", "role": "Роль", + "permissionOverrides": "Переопределения прав", + "permissionOverridesHint": "Оставьте пустым, чтобы унаследовать ограничения выбранной роли. Установите 0, чтобы отключить.", "total.users": "Всего пользователей", "used.traffic": "Использованный трафик", "total": "Всего администраторов", "active": "Активные администраторы", "disable": "Отключённые администраторы", + "limited": "Ограниченные администраторы", "telegramId": "Telegram ID", "discord": "Вебхук Discord", "supportUrl": "URL поддержки", @@ -711,6 +726,185 @@ "no_traffic": "Данные о трафике отсутствуют" } }, + "adminRoles": { + "title": "Роли администраторов", + "description": "Управление ролями панели и правами RBAC", + "createRole": "Создать роль", + "editRole": "Изменить роль", + "viewRole": "Просмотр роли", + "readOnlyHint": "Это встроенная роль. Вы можете просмотреть её настройки, но не можете изменить.", + "modalDescription": "Настройте права, лимиты, функции и доступ для этой роли.", + "currentRoleUnavailable": "Текущая роль недоступна", + "loadFallback": "Используются встроенные роли", + "id": "ID", + "ownerRole": "владелец", + "builtInRole": "встроенная", + "protectedRole": "Защищенная роль", + "protectedRoleHint": "Встроенные роли и роль владельца нельзя изменять.", + "permissions": "Права", + "limits": "Лимиты", + "limitsAndFeatures": "Лимиты и функции", + "limitsHint": "Оставьте пустым, чтобы использовать значения по умолчанию. Установите 0, чтобы отключить.", + "roleFormHint": "Действия с пользователями имеют область: нет, свои или все. Остальные действия — булевы переключатели.", + "scopedActionInfo": "Выберите область: нет — запрет доступа, свои — только пользователи этого админа, все — полный доступ.", + "allowAll": "Разрешить все", + "features": "Функции", + "access": "Доступ", + "permissionCount": "{{count}} прав", + "limitFeatureCount": "{{limits}} лимитов, {{features}} флагов функций", + "unlimited": "Без ограничений", + "empty": "Ролей нет", + "emptyDescription": "Создайте роль, чтобы назначать админам детальные права, лимиты, функции и ограничения доступа.", + "noSearchResults": "Нет ролей по вашему запросу.", + "createSuccess": "Роль «{{name}}» успешно создана", + "editSuccess": "Роль «{{name}}» успешно обновлена", + "deleteSuccess": "Роль «{{name}}» успешно удалена", + "deleteFailed": "Не удалось удалить роль «{{name}}»", + "deleteConfirmation": "Удалить роль", + "deleteConfirm": "Вы уверены, что хотите удалить роль {{name}}?", + "bulkDeleteTitle": "Удалить выбранные роли", + "bulkDeletePrompt": "Удалить {{count}} выбранных ролей? Это действие нельзя отменить.", + "bulkDeleteSuccess": "{{count}} ролей успешно удалено.", + "bulkDeletePartial": "{{count}} ролей не удалось удалить.", + "bulkDeleteFailed": "Не удалось удалить выбранные роли.", + "requireTemplateTitle": "Требовать шаблон", + "requireTemplateDescription": "Заставить админов с этой ролью создавать пользователей только из шаблона.", + "allowedTemplates": "Разрешённые шаблоны", + "allowedTemplatesDescription": "Ограничить шаблоны, доступные для роли. Оставьте пустым, чтобы разрешить все.", + "allowedGroups": "Разрешённые группы", + "allowedGroupsDescription": "Ограничить группы пользователей, доступные роли. Оставьте пустым, чтобы разрешить все.", + "noTemplates": "Шаблонов нет", + "noGroups": "Групп нет", + "names": { + "administrator": "Администратор", + "operator": "Оператор" + }, + "groups": { + "users": "Пользователи", + "admins": "Админы", + "roles": "Роли", + "nodes": "Узлы", + "coreHosts": "Ядра и хосты", + "groupsTemplates": "Группы и шаблоны", + "settings": "Настройки" + }, + "actions": { + "common": { + "read": "Просмотр", + "read_simple": "Просмотр простого списка", + "create": "Создание", + "update": "Изменение", + "delete": "Удаление", + "reset_usage": "Сброс трафика", + "revoke_sub": "Отозвать подписку", + "set_owner": "Назначить владельца", + "activate_next_plan": "Активировать следующий план", + "reconnect": "Переподключить", + "update_core": "Обновить ядро", + "stats": "Просмотр статистики", + "logs": "Просмотр логов", + "read_general": "Просмотр общих" + }, + "users": { + "reset_usage": "Сброс трафика пользователя", + "revoke_sub": "Отозвать подписку", + "set_owner": "Назначить владельца пользователя", + "activate_next_plan": "Активировать следующий план" + }, + "admins": { + "reset_usage": "Сброс трафика администратора" + }, + "settings": { + "read": "Просмотр настроек", + "read_general": "Просмотр общих настроек", + "update": "Изменение настроек" + }, + "system": { + "read": "Просмотр системы", + "update": "Изменение системы" + }, + "hwids": { + "read": "Просмотр HWID", + "update": "Изменение HWID" + }, + "nodes": { + "reconnect": "Переподключить узлы", + "update_core": "Обновить ядро узла", + "stats": "Просмотр статистики узлов", + "logs": "Просмотр логов узлов" + } + }, + "resources": { + "users": "Пользователи", + "admins": "Админы", + "admin_roles": "Роли", + "nodes": "Узлы", + "cores": "Ядра", + "hosts": "Хосты", + "groups": "Группы", + "templates": "Шаблоны пользователей", + "client_templates": "Шаблоны клиентов", + "settings": "Настройки", + "system": "Система", + "hwids": "HWID" + }, + "scopedBadge": "С областью", + "scopes": { + "none": "Нет", + "own": "Свои", + "all": "Все" + }, + "limitFields": { + "max_users": "Макс. пользователей", + "data_limit_min": "Мин. лимит трафика (байт)", + "data_limit_max": "Макс. лимит трафика (байт)", + "expire_days_min": "Мин. дней до истечения", + "expire_days_max": "Макс. дней до истечения", + "min_hwid_per_user": "Мин. HWID на пользователя", + "max_hwid_per_user": "Макс. HWID на пользователя" + }, + "limitedBehavior": { + "disabledWhenLimited": { + "title": "Блокировать ограниченных администраторов", + "description": "Запрещать весь доступ к панели и API после достижения администратором лимита трафика." + }, + "disableUsersWhenLimited": { + "title": "Отключать пользователей при ограничении", + "description": "Удалять пользователей этого администратора с узлов, пока администратор ограничен по использованию." + } + }, + "featureFields": { + "can_use_reset_strategy": { + "title": "Стратегия сброса", + "description": "Разрешить роли задавать стратегию сброса лимита трафика для пользователей." + }, + "can_use_next_plan": { + "title": "Следующий план", + "description": "Разрешить роли настраивать автоматически применяемые следующие планы." + } + } + }, + "setup": { + "createOwner": "Создать владельца", + "resetOwner": "Сбросить пароль владельца", + "deleteOwner": "Удалить владельца", + "createOwnerShort": "Создать", + "resetOwnerShort": "Сбросить", + "deleteOwnerShort": "Удалить", + "ownerAccess": "Доступ владельца", + "ownerAccessDescription": "Используйте временный ключ настройки, чтобы создать, сбросить или удалить учетную запись владельца.", + "tempKey": "Временный ключ", + "deleteConfirm": "Введите DELETE для подтверждения", + "keyRequired": "Требуется временный ключ", + "usernameRequired": "Требуется имя пользователя", + "passwordRequired": "Требуется пароль", + "passwordMismatch": "Пароли не совпадают", + "deleteConfirmRequired": "Введите DELETE для подтверждения", + "ownerCreated": "Владелец успешно создан", + "ownerReset": "Пароль владельца успешно сброшен", + "ownerDeleted": "Владелец успешно удален", + "deleteWarning": "Это действие нельзя отменить. Учетная запись владельца будет удалена безвозвратно." + }, "shortcuts": { "title": "Горячие клавиши", "description": "Управляйте горячими клавишами панели для быстрого доступа", @@ -923,6 +1117,7 @@ "login.fieldRequired": "Это поле обязательно для заполнения", "login.loginYourAccount": "Войдите в свой аккаунт", "login.welcomeBack": "Пожалуйста, введите свои данные", + "login.backToLogin": "Вернуться ко входу", "memoryUsage": "Память", "next": "Вперед", "monitorServers": "Следите за своими серверами и пользователями", @@ -2647,6 +2842,7 @@ "createdAt": "Дата создания", "toggle": "Переключить статус", "close": "Закрыть", + "view": "Просмотр", "copy": "Копировать", "copyAll": "Копировать все", "copied": "Скопировано!", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index abf43ef8f..8b251ddcc 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -138,9 +138,11 @@ "cancelSuccess": "已取消更改并恢复原始设置", "activeTypes": "活跃类型", "filterTitle": "通知过滤器", + "toggleAll": "切换所有通知", "filterDescription": "选择应触发通知的事件,以便了解重要的系统活动", "types": { "admin": "管理员们", + "adminRole": "管理员角色", "core": "核心们", "group": "群组", "host": "主机", @@ -157,9 +159,11 @@ "delete": "删除", "resetUsage": "重置使用量", "login": "登录", + "usageLimitWarning": "用量限制预警", "modifyHosts": "修改主机", "connect": "连接", "error": "错误", + "limited": "受限", "statusChange": "状态变更", "resetDataUsage": "重置数据使用量", "dataResetByNext": "下次重置数据", @@ -188,6 +192,12 @@ "title": "频道覆盖", "description": "覆盖特定通知事件的默认目标。留空以继承全局配置。", "hint": "留空以恢复此事件的主要通知设置。" + }, + "usageLimitWarnings": { + "thresholds": "预警阈值", + "description": "当管理员数据用量达到这些百分比时发送限制预警。", + "addThreshold": "添加阈值", + "empty": "未配置阈值" } }, "subscriptions": { @@ -639,9 +649,11 @@ "description": "管理系统管理员", "createAdmin": "创建管理员", "editAdmin": "编辑管理员", + "profileSection": "资料与联系方式", "deleteAdmin": "删除管理员", "username": "用户名", "password": "密码", + "dataLimit": "管理员数据限制", "isSudo": "超级管理员", "createdAt": "创建时间", "actions": "操作", @@ -686,11 +698,14 @@ "create": "创建管理员", "status": "状态", "role": "角色", + "permissionOverrides": "权限覆盖", + "permissionOverridesHint": "留空以继承所选角色的限制。设置为 0 表示禁用。", "total.users": "用户总数", "used.traffic": "已用流量", "total": "管理员总数", "active": "活跃管理员", "disable": "禁用的管理员", + "limited": "受限管理员", "telegramId": "Telegram ID", "discord": "Discord Webhook", "supportUrl": "支持链接", @@ -725,6 +740,185 @@ "no_traffic": "没有使用情况数据" } }, + "adminRoles": { + "title": "管理员角色", + "description": "管理仪表板角色和 RBAC 权限", + "createRole": "创建角色", + "editRole": "编辑角色", + "viewRole": "查看角色", + "readOnlyHint": "这是内置角色。您可以查看其配置,但无法修改。", + "modalDescription": "为此角色配置权限、限制、功能和访问。", + "currentRoleUnavailable": "当前角色不可用", + "loadFallback": "使用内置角色", + "id": "ID", + "ownerRole": "所有者", + "builtInRole": "内置", + "protectedRole": "受保护角色", + "protectedRoleHint": "内置角色和所有者角色无法修改。", + "permissions": "权限", + "limits": "限制", + "limitsAndFeatures": "限制与功能", + "limitsHint": "留空以使用默认值。设置为 0 表示禁用。", + "roleFormHint": "带范围的用户操作使用「无」、「自己的」或「全部」。其他操作为布尔开关。", + "scopedActionInfo": "选择权限范围:无表示拒绝访问,自己的仅限管理员自己的用户,全部表示完全访问。", + "allowAll": "允许全部", + "features": "功能", + "access": "访问", + "permissionCount": "{{count}} 项权限", + "limitFeatureCount": "{{limits}} 个限制,{{features}} 个功能标记", + "unlimited": "无限制", + "empty": "暂无角色", + "emptyDescription": "创建角色以为管理员分配精细的权限、限制、功能和访问限制。", + "noSearchResults": "没有匹配的角色。", + "createSuccess": "角色 «{{name}}» 创建成功", + "editSuccess": "角色 «{{name}}» 更新成功", + "deleteSuccess": "角色 «{{name}}» 已成功删除", + "deleteFailed": "无法删除角色 «{{name}}»", + "deleteConfirmation": "删除角色", + "deleteConfirm": "确定要删除角色 {{name}} 吗?", + "bulkDeleteTitle": "删除所选角色", + "bulkDeletePrompt": "确定要删除选中的 {{count}} 个角色吗?此操作无法撤消。", + "bulkDeleteSuccess": "已成功删除 {{count}} 个角色。", + "bulkDeletePartial": "{{count}} 个角色无法删除。", + "bulkDeleteFailed": "删除所选角色失败。", + "requireTemplateTitle": "需要模板", + "requireTemplateDescription": "强制使用此角色的管理员只能从模板创建用户。", + "allowedTemplates": "允许的模板", + "allowedTemplatesDescription": "限制此角色可使用的模板。留空以允许全部。", + "allowedGroups": "允许的分组", + "allowedGroupsDescription": "限制此角色可管理的用户组。留空以允许全部。", + "noTemplates": "没有可用模板", + "noGroups": "没有可用分组", + "names": { + "administrator": "管理员", + "operator": "操作员" + }, + "groups": { + "users": "用户", + "admins": "管理员", + "roles": "角色", + "nodes": "节点", + "coreHosts": "核心和主机", + "groupsTemplates": "组和模板", + "settings": "设置" + }, + "actions": { + "common": { + "read": "查看", + "read_simple": "查看简单列表", + "create": "创建", + "update": "更新", + "delete": "删除", + "reset_usage": "重置用量", + "revoke_sub": "撤销订阅", + "set_owner": "设置所有者", + "activate_next_plan": "激活下一计划", + "reconnect": "重新连接", + "update_core": "更新核心", + "stats": "查看统计", + "logs": "查看日志", + "read_general": "查看常规" + }, + "users": { + "reset_usage": "重置用户用量", + "revoke_sub": "撤销订阅", + "set_owner": "设置用户所有者", + "activate_next_plan": "激活下一计划" + }, + "admins": { + "reset_usage": "重置管理员用量" + }, + "settings": { + "read": "查看设置", + "read_general": "查看常规设置", + "update": "更新设置" + }, + "system": { + "read": "查看系统", + "update": "更新系统" + }, + "hwids": { + "read": "查看 HWID", + "update": "更新 HWID" + }, + "nodes": { + "reconnect": "重新连接节点", + "update_core": "更新节点核心", + "stats": "查看节点统计", + "logs": "查看节点日志" + } + }, + "resources": { + "users": "用户", + "admins": "管理员", + "admin_roles": "角色", + "nodes": "节点", + "cores": "核心", + "hosts": "主机", + "groups": "分组", + "templates": "用户模板", + "client_templates": "客户端模板", + "settings": "设置", + "system": "系统", + "hwids": "HWID" + }, + "scopedBadge": "带范围", + "scopes": { + "none": "无", + "own": "自己的", + "all": "全部" + }, + "limitFields": { + "max_users": "最大用户数", + "data_limit_min": "最小流量限制(字节)", + "data_limit_max": "最大流量限制(字节)", + "expire_days_min": "最小到期天数", + "expire_days_max": "最大到期天数", + "min_hwid_per_user": "每用户最少 HWID", + "max_hwid_per_user": "每用户最多 HWID" + }, + "limitedBehavior": { + "disabledWhenLimited": { + "title": "阻止受限管理员", + "description": "当管理员达到流量限制后,拒绝其所有仪表板和 API 访问。" + }, + "disableUsersWhenLimited": { + "title": "受限时禁用用户", + "description": "当该管理员处于用量受限状态时,从节点中移除其用户。" + } + }, + "featureFields": { + "can_use_reset_strategy": { + "title": "使用重置策略", + "description": "允许此角色为用户设置流量重置策略。" + }, + "can_use_next_plan": { + "title": "使用下一计划", + "description": "允许此角色配置自动应用的下一计划。" + } + } + }, + "setup": { + "createOwner": "创建所有者", + "resetOwner": "重置所有者密码", + "deleteOwner": "删除所有者", + "createOwnerShort": "创建", + "resetOwnerShort": "重置", + "deleteOwnerShort": "删除", + "ownerAccess": "所有者访问", + "ownerAccessDescription": "使用临时设置密钥创建、重置或删除所有者账户。", + "tempKey": "临时密钥", + "deleteConfirm": "输入 DELETE 以确认", + "keyRequired": "临时密钥是必填项", + "usernameRequired": "用户名是必填项", + "passwordRequired": "密码是必填项", + "passwordMismatch": "密码不匹配", + "deleteConfirmRequired": "输入 DELETE 以确认", + "ownerCreated": "所有者创建成功", + "ownerReset": "所有者密码重置成功", + "ownerDeleted": "所有者删除成功", + "deleteWarning": "此操作无法撤销。所有者账户将被永久删除。" + }, "shortcuts": { "title": "键盘快捷键", "description": "管理您的仪表板快捷键以便快速访问", @@ -1276,6 +1470,7 @@ "login.fieldRequired": "此项必填", "login.loginYourAccount": "登录您的帐号", "login.welcomeBack": "欢迎回来,请输入您的详细信息", + "login.backToLogin": "返回登录", "memoryUsage": "内存状态", "next": "下一页", "monitorServers": "监控您的服务器和用户", @@ -2717,6 +2912,7 @@ "settings.cores.coreNotFound": "未找到核心", "toggle": "切换状态", "close": "关闭", + "view": "查看", "copy": "复制", "copyAll": "复制全部", "copied": "已复制!", diff --git a/dashboard/src/app/router.tsx b/dashboard/src/app/router.tsx index 0bef294ee..463479c82 100644 --- a/dashboard/src/app/router.tsx +++ b/dashboard/src/app/router.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react' import { useAdmin } from '@/hooks/use-admin' import { getCurrentAdmin } from '@/service/api' +import { hasPermission } from '@/utils/rbac' import { createHashRouter, Navigate, RouteObject } from 'react-router' import { LoadingSpinner } from '@/components/common/loading-spinner' import { TabbedRouteSuspenseFallback } from '@/components/layout/tabbed-route-suspense-fallback' @@ -13,6 +14,7 @@ const ThemePage = lazyWithChunkRecovery(() => import('@/pages/_dashboard.setting 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 BulkPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk')) const BulkCreatePage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk.create')) const BulkDataPage = lazyWithChunkRecovery(() => import('../pages/_dashboard.bulk.data')) @@ -44,10 +46,9 @@ const Login = lazyWithChunkRecovery(() => import('../pages/login')) // Component to handle default settings routing based on user permissions function SettingsIndex() { const { admin } = useAdmin() - const is_sudo = admin?.is_sudo || false - - // For sudo admins, default to notifications; for non-sudo admins, default to theme - const defaultPath = is_sudo ? '/settings/general' : '/settings/theme' + const canUpdateSettings = hasPermission(admin, 'settings', 'update') + const canSeeGeneral = hasPermission(admin, 'settings', 'read_general') && canUpdateSettings + const defaultPath = canSeeGeneral ? '/settings/general' : '/settings/theme' return } @@ -208,6 +209,14 @@ export const router = createHashRouter([ ), }, + { + path: '/admin-roles', + 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 43ca45717..397a02664 100644 --- a/dashboard/src/components/common/admin-filter-combobox.tsx +++ b/dashboard/src/components/common/admin-filter-combobox.tsx @@ -143,7 +143,6 @@ export default function AdminFilterCombobox({ value, onValueChange, onAdminSelec onValueChange(admin.username) onAdminSelect?.({ ...admin, - is_sudo: false, }) setOpen(false) }} diff --git a/dashboard/src/components/common/groups-selector.tsx b/dashboard/src/components/common/groups-selector.tsx index 0474c54b8..9279c3cbe 100644 --- a/dashboard/src/components/common/groups-selector.tsx +++ b/dashboard/src/components/common/groups-selector.tsx @@ -9,6 +9,7 @@ import { Control, FieldPath, FieldValues, useController } from 'react-hook-form' import { Trans, useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' import { useAdmin } from '@/hooks/use-admin.ts' +import { hasPermission } from '@/utils/rbac' interface GroupsSelectorProps { control: Control @@ -22,7 +23,7 @@ export default function GroupsSelector({ control, name, o const navigate = useNavigate() const [searchQuery, setSearchQuery] = useState('') const { admin } = useAdmin() - const isSudo = admin?.is_sudo || false + const canManageGroups = hasPermission(admin, 'groups', 'create') const { field } = useController({ control, @@ -99,7 +100,7 @@ export default function GroupsSelector({ control, name, o
{t('warning')} - {isSudo ? ( + {canManageGroups ? ( { + if (!admin) return { dataLimit: null, maxUsers: null } + const overrides = admin.permission_overrides ?? null + const roleLimits = admin.role?.limits ?? null + const maxUsersOverride = overrides?.max_users + const maxUsersRole = roleLimits?.max_users + const maxUsers = maxUsersOverride != null ? maxUsersOverride : maxUsersRole != null ? maxUsersRole : null + return { + dataLimit: admin.data_limit ?? null, + maxUsers, + } +} + +const isLimitActive = (limit: number | null | undefined): limit is number => typeof limit === 'number' && limit > 0 + +const getProgressPct = (used: number, total: number) => { + if (total <= 0) return 0 + return Math.min(100, Math.max(0, (used / total) * 100)) +} export function NavUser({ username, @@ -27,6 +56,13 @@ export function NavUser({ const { t } = useTranslation() const { state, isMobile } = useSidebar() const navigate = useNavigate() + const RoleIcon = isOwner(admin) ? UserRoundKey : UserRound + const { dataLimit, maxUsers } = getEffectiveLimits(admin) + const hasDataLimit = isLimitActive(dataLimit) + const hasUserLimit = isLimitActive(maxUsers) + const usedTraffic = admin?.used_traffic ?? 0 + const totalUsers = admin?.total_users ?? 0 + const sliderColor = statusColors[admin?.status ?? 'active']?.sliderColor const handleLogout = (e: React.MouseEvent) => { e.preventDefault() @@ -59,18 +95,9 @@ export function NavUser({
{username.name} {admin && ( - - {admin.is_sudo ? ( - <> - - {t('sudo')} - - ) : ( - <> - - {t('admin')} - - )} + + + {roleLabel(admin)} )}
@@ -78,13 +105,17 @@ export function NavUser({ {admin && (
-
- {t('admins.used.traffic')} - - - {formatBytes(admin?.used_traffic || 0)} +
+
+ {t('admins.used.traffic')} + + + {formatBytes(usedTraffic)} + {hasDataLimit ? ` / ${formatBytes(dataLimit)}` : ''} + - +
+ {hasDataLimit && }
{t('statistics.totalUsage')} @@ -94,9 +125,17 @@ export function NavUser({
-
- {t('admins.total.users')} - {admin?.total_users || 0} +
+
+ {t('admins.total.users')} + + + {totalUsers} + {hasUserLimit ? ` / ${maxUsers}` : ''} + + +
+ {hasUserLimit && }
)} @@ -130,18 +169,9 @@ export function NavUser({
{username.name} {admin && ( - - {admin.is_sudo ? ( - <> - - {t('sudo')} - - ) : ( - <> - - {t('admin')} - - )} + + + {roleLabel(admin)} )}
@@ -149,7 +179,8 @@ export function NavUser({
- {formatBytes(admin?.used_traffic || 0)} + {formatBytes(usedTraffic)} + {hasDataLimit ? ` / ${formatBytes(dataLimit)}` : ''}
)} @@ -164,32 +195,27 @@ export function NavUser({
{username.name} {admin && ( - - {admin.is_sudo ? ( - <> - - {t('sudo')} - - ) : ( - <> - - {t('admin')} - - )} + + + {roleLabel(admin)} )}
{admin && (
-
- - - {t('admins.used.traffic')}:{' '} - - {formatBytes(admin?.used_traffic || 0)} +
+
+ + + {t('admins.used.traffic')}:{' '} + + {formatBytes(usedTraffic)} + {hasDataLimit ? ` / ${formatBytes(dataLimit)}` : ''} + - +
+ {hasDataLimit && }
@@ -200,11 +226,18 @@ export function NavUser({
-
- - - {t('admins.total.users')}: {admin?.total_users || 0} - +
+
+ + + {t('admins.total.users')}:{' '} + + {totalUsers} + {hasUserLimit ? ` / ${maxUsers}` : ''} + + +
+ {hasUserLimit && }
)} diff --git a/dashboard/src/components/layout/route-guard.tsx b/dashboard/src/components/layout/route-guard.tsx index ced58a5f3..4bbb6bf93 100644 --- a/dashboard/src/components/layout/route-guard.tsx +++ b/dashboard/src/components/layout/route-guard.tsx @@ -1,4 +1,5 @@ import { useAdmin } from '@/hooks/use-admin' +import { canAccessRoute, firstAllowedRoute } from '@/utils/rbac' import { useEffect, useRef } from 'react' import { useLocation, useNavigate } from 'react-router' @@ -6,7 +7,6 @@ export default function RouteGuard({ children }: { children: React.ReactNode }) const { admin } = useAdmin() const location = useLocation() const navigate = useNavigate() - const is_sudo = admin?.is_sudo || false const hasNavigatedRef = useRef(false) useEffect(() => { @@ -15,66 +15,18 @@ export default function RouteGuard({ children }: { children: React.ReactNode }) return // Wait for admin data to load } - if (!is_sudo) { - const currentPath = location.pathname - - // Define allowed routes for non-sudo admins - const allowedRoutes = ['/', '/users', '/settings', '/settings/theme', '/bulk', '/bulk/create'] - const isAllowedRoute = allowedRoutes.some( - route => - currentPath === route || - (route === '/settings' && currentPath.startsWith('/settings/theme')), - ) - - // If current route is allowed, don't redirect - if (isAllowedRoute) { - hasNavigatedRef.current = false - return - } - - // Prevent multiple navigations for the same route change - if (hasNavigatedRef.current) { - return - } - - // Define restricted routes for non-sudo admins - const restrictedRoutes = ['/statistics', '/hosts', '/groups', '/templates', '/admins', '/nodes'] - const isRestrictedRoute = restrictedRoutes.some(route => currentPath.startsWith(route)) - - if (isRestrictedRoute) { - hasNavigatedRef.current = true - navigate('/users', { replace: true }) - return - } - - // Handle settings routes - if (currentPath === '/settings') { - hasNavigatedRef.current = true - navigate('/settings/theme', { replace: true }) - return - } - - // Redirect from restricted settings pages - const restrictedSettingsRoutes = ['/settings/general', '/settings/notifications', '/settings/subscriptions', '/settings/telegram', '/settings/discord', '/settings/webhook', '/settings/cleanup'] - - if (restrictedSettingsRoutes.includes(currentPath)) { - // Redirecting non-sudo admin from restricted settings - hasNavigatedRef.current = true - navigate('/settings/theme', { replace: true }) - return - } - - const restrictedBulkRoutes = ['/bulk/wireguard', '/bulk/groups', '/bulk/expire', '/bulk/data', '/bulk/proxy'] - - if (restrictedBulkRoutes.some(route => currentPath.startsWith(route))) { - hasNavigatedRef.current = true - navigate('/bulk', { replace: true }) - return - } - } else { + if (canAccessRoute(admin, location.pathname)) { hasNavigatedRef.current = false + return } - }, [admin, is_sudo, location.pathname, navigate]) + + if (hasNavigatedRef.current) { + return + } + + hasNavigatedRef.current = true + navigate(firstAllowedRoute(admin), { replace: true }) + }, [admin, location.pathname, navigate]) // Reset navigation flag when pathname changes (after navigation completes) useEffect(() => { diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx index de0b2b3bf..18116202a 100644 --- a/dashboard/src/components/layout/sidebar.tsx +++ b/dashboard/src/components/layout/sidebar.tsx @@ -18,6 +18,7 @@ import useDirDetection from '@/hooks/use-dir-detection' import { useSystemVersion } from '@/hooks/use-system-version' import { useVersionCheck } from '@/hooks/use-version-check' import { cn } from '@/lib/utils' +import { hasPermission, hasScopeAll, isOwner, canManageResource } from '@/utils/rbac' import { ArrowUpDown, Bell, @@ -49,6 +50,7 @@ import { Settings2, Share2Icon, UserCog, + UserKey, UserPlus, UsersIcon, Webhook, @@ -62,14 +64,14 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const isRTL = useDirDetection() === 'rtl' const { t } = useTranslation() const { admin } = useAdmin() - const isSudo = admin?.is_sudo ?? false - const { currentVersion: systemVersion } = useSystemVersion({ enabled: isSudo }) + const canReadSystem = hasPermission(admin, 'system', 'read') + const { currentVersion: systemVersion } = useSystemVersion({ enabled: canReadSystem }) const { setOpenMobile, openMobile, state, isMobile, toggleSidebar } = useSidebar() const { resolvedTheme } = useTheme() const [showCollapseButton, setShowCollapseButton] = useState(false) - const normalizedVersion = isSudo && systemVersion ? systemVersion.replace(/[^0-9.]/g, '') : null - const displayVersion = isSudo && systemVersion ? `(v${systemVersion})` : '' - const { hasUpdate } = useVersionCheck(normalizedVersion, { enabled: isSudo }) + const normalizedVersion = canReadSystem && systemVersion ? systemVersion.replace(/[^0-9.]/g, '') : null + const displayVersion = canReadSystem && systemVersion ? `(v${systemVersion})` : '' + const { hasUpdate } = useVersionCheck(normalizedVersion, { enabled: canReadSystem }) const touchStartX = useRef(null) const touchEndX = useRef(null) const minSwipeDistance = 50 @@ -125,89 +127,107 @@ export function AppSidebar({ ...props }: React.ComponentProps) { name: admin?.username || 'Admin', }, navMain: [ - { + ...(canReadSystem ? [{ title: 'dashboard', url: '/', icon: LayoutDashboardIcon, - }, - { + }] : []), + ...(hasPermission(admin, 'users', 'read') ? [{ title: 'users', url: '/users', icon: UsersIcon, - }, - ...(admin?.is_sudo - ? [ - { + }] : []), + ...(hasPermission(admin, 'nodes', 'stats') + ? [{ title: 'statistics', url: '/statistics', icon: PieChart, - }, - { + }] + : []), + ...(canManageResource(admin, 'hosts', ['create', 'update']) + ? [{ title: 'hosts', url: '/hosts', icon: ListTodo, - }, - { + }] + : []), + ...(canManageResource(admin, 'groups') + ? [{ title: 'groups', url: '/groups', icon: Group, - }, - { + }] + : []), + ...(canManageResource(admin, 'admins') + ? [{ title: 'admins.title', url: '/admins', icon: UserCog, - }, - { + }] + : []), + ...(isOwner(admin) + ? [{ + title: 'adminRoles.title', + url: '/admin-roles', + icon: UserKey, + }] + : []), + ...(canManageResource(admin, 'nodes', ['create', 'update', 'delete', 'reconnect', 'update_core']) || canManageResource(admin, 'cores') || hasPermission(admin, 'nodes', 'logs') + ? [{ title: 'nodes.title', url: '/nodes', icon: Share2Icon, items: [ - { + ...(canManageResource(admin, 'nodes', ['create', 'update', 'delete', 'reconnect', 'update_core']) ? [{ title: 'nodes.title', url: '/nodes', icon: Share2Icon, - }, - { + }] : []), + ...(canManageResource(admin, 'cores') ? [{ title: 'settings.cores.title', url: '/nodes/cores', icon: Cpu, matchPrefix: true, - }, - { + }] : []), + ...(hasPermission(admin, 'nodes', 'logs') ? [{ title: 'nodes.logs.title', url: '/nodes/logs', icon: Logs, - }, + }] : []), ], - }, - { + }] + : []), + ...(canManageResource(admin, 'templates') || canManageResource(admin, 'client_templates') + ? [{ title: 'templates.title', url: '/templates/user', icon: LayoutTemplate, items: [ - { + ...(canManageResource(admin, 'templates') ? [{ title: 'templates.userTemplates', url: '/templates/user', icon: FileUser, - }, - { + }] : []), + ...(canManageResource(admin, 'client_templates') ? [{ title: 'templates.clientTemplates', url: '/templates/client', icon: FileCode2, - }, + }] : []), ], - }, - { + }] + : []), + ...(hasPermission(admin, 'users', 'create') || hasScopeAll(admin, 'users', 'update') + ? [{ title: 'bulk.title', url: '/bulk', icon: Layers, items: [ - { + ...(hasPermission(admin, 'users', 'create') ? [{ title: 'bulk.createUsers', url: '/bulk', icon: UserPlus, - }, - { + }] : []), + ...(hasScopeAll(admin, 'users', 'update') ? [{ title: 'bulk.groups', url: '/bulk/groups', icon: Group, @@ -231,20 +251,21 @@ export function AppSidebar({ ...props }: React.ComponentProps) { title: 'bulk.wireguardPeerIps', url: '/bulk/wireguard', icon: Network, - }, + }] : []), ], - }, - { + }] + : []), + { title: 'settings.title', url: '/settings', icon: Settings2, items: [ - { + ...(hasPermission(admin, 'settings', 'read_general') && hasPermission(admin, 'settings', 'update') ? [{ title: 'settings.general.title', url: '/settings/general', icon: Settings, - }, - { + }] : []), + ...(hasPermission(admin, 'settings', 'read') && hasPermission(admin, 'settings', 'update') ? [{ title: 'settings.notifications.title', url: '/settings/notifications', icon: Bell, @@ -278,34 +299,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { title: 'settings.cleanup.title', url: '/settings/cleanup', icon: Database, - }, - { - title: 'theme.title', - url: '/settings/theme', - icon: Palette, - }, - ], - }, - ] - : [ - { - title: 'bulk.title', - url: '/bulk', - icon: Layers, - items: [ - { - title: 'bulk.createUsers', - url: '/bulk', - icon: UserPlus, - }, - ], - }, - // For non-sudo admins, show only theme settings and keep settings at the end - { - title: 'settings.title', - url: '/settings', - icon: Settings2, - items: [ + }] : []), { title: 'theme.title', url: '/settings/theme', @@ -313,7 +307,6 @@ export function AppSidebar({ ...props }: React.ComponentProps) { }, ], }, - ]), ], navSecondary: [ { @@ -360,7 +353,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {t('pasarguard')}
- +
@@ -372,7 +365,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {state === 'collapsed' && !isMobile ? (
setShowCollapseButton(true)} onMouseLeave={() => setShowCollapseButton(false)}> {/* Badge - always visible, positioned on top layer */} - {isSudo && ( + {canReadSystem && (
@@ -391,7 +384,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { alt="PasarGuard Logo" className="h-6 w-6 flex-shrink-0 object-contain" /> - {isSudo && hasUpdate && ( + {canReadSystem && hasUpdate && ( @@ -412,7 +405,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { > Expand Sidebar - {isSudo && hasUpdate && } + {canReadSystem && hasUpdate && } @@ -432,7 +425,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { />
{t('pasarguard')} - {isSudo && ( + {canReadSystem && (
{displayVersion}
@@ -481,7 +474,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { />
{t('pasarguard')} - {isSudo && ( + {canReadSystem && (
{displayVersion}
@@ -500,7 +493,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - {admin?.is_sudo && } + {isOwner(admin) && }
diff --git a/dashboard/src/components/layout/tabbed-route-suspense-fallback.tsx b/dashboard/src/components/layout/tabbed-route-suspense-fallback.tsx index 7e7da5102..c1117e4f8 100644 --- a/dashboard/src/components/layout/tabbed-route-suspense-fallback.tsx +++ b/dashboard/src/components/layout/tabbed-route-suspense-fallback.tsx @@ -3,6 +3,7 @@ import { LoadingSpinner } from '@/components/common/loading-spinner' import PageHeader from '@/components/layout/page-header' import { getDocsUrl } from '@/utils/docs-url' import { useAdmin } from '@/hooks/use-admin' +import { hasPermission, hasScopeAll } from '@/utils/rbac' import { cn } from '@/lib/utils' import { ArrowUpDown, @@ -224,17 +225,17 @@ function TemplatesTabbedFallback({ pathname }: { pathname: string }) { export function TabbedRouteSuspenseFallback() { const { pathname } = useLocation() const { admin } = useAdmin() - /** While admin is still resolving, prefer full tab strip (typical sudo layout). */ - const isSudo = admin?.is_sudo !== false + const canUseSettings = (hasPermission(admin, 'settings', 'read') || hasPermission(admin, 'settings', 'read_general')) && hasPermission(admin, 'settings', 'update') + const canUseBulkAll = hasScopeAll(admin, 'users', 'update') if (pathname.startsWith('/nodes')) { return } if (pathname.startsWith('/settings')) { - return + return } if (pathname.startsWith('/bulk')) { - return + return } if (pathname.startsWith('/templates')) { return diff --git a/dashboard/src/components/layout/version-update-banner.tsx b/dashboard/src/components/layout/version-update-banner.tsx index baeac7c46..75a28f4c7 100644 --- a/dashboard/src/components/layout/version-update-banner.tsx +++ b/dashboard/src/components/layout/version-update-banner.tsx @@ -11,6 +11,7 @@ import { useClipboard } from '@/hooks/use-clipboard' import { toast } from 'sonner' import { useSystemVersion } from '@/hooks/use-system-version' import { useAdmin } from '@/hooks/use-admin' +import { hasPermission } from '@/utils/rbac' const VERSION_BANNER_STORAGE_KEY = 'version_update_banner_closed' const HOURS_TO_HIDE = 24 @@ -27,19 +28,19 @@ export function VersionUpdateBanner() { const isDark = resolvedTheme === 'dark' const { copy } = useClipboard() const { admin } = useAdmin() - const isSudo = admin?.is_sudo ?? false - const { currentVersion } = useSystemVersion({ enabled: isSudo }) + const canReadSystem = hasPermission(admin, 'system', 'read') + const { currentVersion } = useSystemVersion({ enabled: canReadSystem }) const [isVisible, setIsVisible] = useState(false) const [isClosing, setIsClosing] = useState(false) const [isAnimating, setIsAnimating] = useState(false) const normalizedVersion = currentVersion ? currentVersion.replace(/[^0-9.]/g, '') : null - const { hasUpdate, latestVersion, releaseUrl, isLoading } = useVersionCheck(normalizedVersion, { enabled: isSudo }) + const { hasUpdate, latestVersion, releaseUrl, isLoading } = useVersionCheck(normalizedVersion, { enabled: canReadSystem }) const gradientBg = getGradientByColorTheme(colorTheme, isDark, 'banner') const indicatorColor = getIndicatorColorByTheme(colorTheme, isDark) useEffect(() => { - if (!isSudo || isLoading || !hasUpdate || !normalizedVersion) { + if (!canReadSystem || isLoading || !hasUpdate || !normalizedVersion) { setIsVisible(false) setIsAnimating(false) return @@ -90,7 +91,7 @@ export function VersionUpdateBanner() { } checkShouldShow() - }, [hasUpdate, isSudo, latestVersion, normalizedVersion, isLoading]) + }, [hasUpdate, canReadSystem, latestVersion, normalizedVersion, isLoading]) const handleClose = (e: React.MouseEvent) => { e.preventDefault() @@ -117,7 +118,7 @@ export function VersionUpdateBanner() { toast.success(t('usersTable.copied')) } - if (!isSudo || isLoading || !hasUpdate || !isVisible || !latestVersion || !normalizedVersion) return null + if (!canReadSystem || isLoading || !hasUpdate || !isVisible || !latestVersion || !normalizedVersion) return null const releaseLink = releaseUrl || 'https://github.com/PasarGuard/panel/releases/latest' diff --git a/dashboard/src/features/admin-roles/components/admin-role-actions-menu.tsx b/dashboard/src/features/admin-roles/components/admin-role-actions-menu.tsx new file mode 100644 index 000000000..4401f959b --- /dev/null +++ b/dashboard/src/features/admin-roles/components/admin-role-actions-menu.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { Eye, MoreVertical, Pencil, Trash2 } from 'lucide-react' + +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' + +import useDirDetection from '@/hooks/use-dir-detection' +import { cn } from '@/lib/utils' +import { queryClient } from '@/utils/query-client' +import { AdminRoleResponse, getGetRolesQueryKey, getGetRolesSimpleQueryKey, useDeleteRole } from '@/service/api' + +import { isProtectedRole } from '@/features/admin-roles/forms/admin-role-form' + +interface AdminRoleActionsMenuProps { + role: AdminRoleResponse + onEdit: (role: AdminRoleResponse) => void + className?: string +} + +export default function AdminRoleActionsMenu({ role, onEdit, className }: AdminRoleActionsMenuProps) { + const { t } = useTranslation() + const dir = useDirDetection() + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false) + const deleteRole = useDeleteRole() + const protectedRole = isProtectedRole(role) + + const handleDeleteClick = (event: Event) => { + event.stopPropagation() + setDeleteDialogOpen(true) + } + + const handleConfirmDelete = async () => { + try { + await deleteRole.mutateAsync({ roleId: role.id }) + toast.success(t('success', { defaultValue: 'Success' }), { + description: t('adminRoles.deleteSuccess', { name: role.name, defaultValue: 'Role «{{name}}» has been deleted successfully' }), + }) + setDeleteDialogOpen(false) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: getGetRolesQueryKey() }), + queryClient.invalidateQueries({ queryKey: getGetRolesSimpleQueryKey() }), + ]) + } catch (error: any) { + toast.error(t('error', { defaultValue: 'Error' }), { + description: error?.data?.detail || error?.message || t('adminRoles.deleteFailed', { name: role.name, defaultValue: 'Failed to delete role «{{name}}»' }), + }) + } + } + + return ( + <> +
e.stopPropagation()}> + + + + + + { + e.stopPropagation() + onEdit(role) + }} + > + {protectedRole ? ( + + ) : ( + + )} + + {protectedRole ? t('view', { defaultValue: 'View' }) : t('edit', { defaultValue: 'Edit' })} + + + + + + {t('delete')} + + + +
+ + + + + {t('adminRoles.deleteConfirmation', { defaultValue: 'Delete role' })} + + {{name}}?' }) }} /> + + + + {t('cancel')} + + {t('delete')} + + + + + + ) +} diff --git a/dashboard/src/features/admin-roles/components/admin-role-card.tsx b/dashboard/src/features/admin-roles/components/admin-role-card.tsx new file mode 100644 index 000000000..47d21a329 --- /dev/null +++ b/dashboard/src/features/admin-roles/components/admin-role-card.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { Crown, Shield, ShieldCheck } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Card } from '@/components/ui/card' +import { cn } from '@/lib/utils' +import { AdminRoleResponse } from '@/service/api' + +import { BUILT_IN_ROLE_IDS } from '@/features/admin-roles/forms/admin-role-form' +import AdminRoleActionsMenu from './admin-role-actions-menu' + +interface AdminRoleCardProps { + role: AdminRoleResponse + onEdit: (role: AdminRoleResponse) => void + selectionControl?: ReactNode + selected?: boolean +} + +const countResourcePermissions = (role: AdminRoleResponse) => { + const permissions = role.permissions || {} + let total = 0 + for (const value of Object.values(permissions)) { + if (!value || typeof value !== 'object') continue + total += Object.keys(value as Record).length + } + return total +} + +export default function AdminRoleCard({ role, onEdit, selectionControl, selected = false }: AdminRoleCardProps) { + const { t } = useTranslation() + const builtIn = BUILT_IN_ROLE_IDS.has(role.id) && !role.is_owner + const RoleIcon = role.is_owner ? Crown : builtIn ? ShieldCheck : Shield + const permissionCount = countResourcePermissions(role) + const limitsCount = Object.keys(role.limits || {}).length + const featureCount = Object.keys(role.features || {}).length + 2 + + const localizedName = t(`adminRoles.names.${role.name}`, { defaultValue: role.name }) + + return ( + onEdit(role)} + > +
+ {selectionControl ?
{selectionControl}
: null} +
+ +
+
+
+ {localizedName} + {role.is_owner && ( + + {t('adminRoles.ownerRole', { defaultValue: 'owner' })} + + )} + {builtIn && ( + + {t('adminRoles.builtInRole', { defaultValue: 'built-in' })} + + )} +
+
+ {t('adminRoles.id', { defaultValue: 'ID' })} {role.id} +
+
+ +
+ +
+ {t('adminRoles.permissionCount', { count: permissionCount, defaultValue: '{{count}} permissions' })} + · + {t('adminRoles.limitFeatureCount', { limits: limitsCount, features: featureCount, defaultValue: '{{limits}} limits, {{features}} feature flags' })} +
+
+ ) +} diff --git a/dashboard/src/features/admin-roles/components/admin-roles-list.tsx b/dashboard/src/features/admin-roles/components/admin-roles-list.tsx new file mode 100644 index 000000000..7d612959a --- /dev/null +++ b/dashboard/src/features/admin-roles/components/admin-roles-list.tsx @@ -0,0 +1,287 @@ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { toast } from 'sonner' +import { RefreshCw, Search, Trash2, X } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' + +import ViewToggle from '@/components/common/view-toggle' +import { ListGenerator } from '@/components/common/list-generator' +import { ListGeneratorGrid } from '@/components/common/list-generator-grid' + +import useDirDetection from '@/hooks/use-dir-detection' +import { usePersistedViewMode } from '@/hooks/use-persisted-view-mode' +import { cn } from '@/lib/utils' +import { queryClient } from '@/utils/query-client' +import { + AdminRoleResponse, + getGetRolesQueryKey, + getGetRolesSimpleQueryKey, + useDeleteRole, + useGetRoles, +} from '@/service/api' + +import { BulkActionAlertDialog } from '@/features/users/components/bulk-action-alert-dialog' +import { BulkActionItem, BulkActionsBar } from '@/features/users/components/bulk-actions-bar' + +import { + AdminRoleFormValues, + AdminRoleFormValuesInput, + adminRoleFormDefaultValues, + adminRoleFormFromResponse, + adminRoleFormSchema, + isProtectedRole, +} from '@/features/admin-roles/forms/admin-role-form' +import AdminRoleCard from '@/features/admin-roles/components/admin-role-card' +import { useAdminRolesListColumns } from '@/features/admin-roles/components/use-admin-roles-list-columns' +import AdminRoleModal from '@/features/admin-roles/dialogs/admin-role-modal' + +interface AdminRolesListProps { + isDialogOpen: boolean + onOpenChange: (open: boolean) => void +} + +export default function AdminRolesList({ isDialogOpen, onOpenChange }: AdminRolesListProps) { + const { t } = useTranslation() + const dir = useDirDetection() + const [editingRole, setEditingRole] = useState(null) + const [isReadOnly, setIsReadOnly] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [viewMode, setViewMode] = usePersistedViewMode('view-mode:admin-roles') + const [selectedRoleIds, setSelectedRoleIds] = useState([]) + const [confirmBulkDelete, setConfirmBulkDelete] = useState(false) + const deleteRole = useDeleteRole() + + const { data: rolesData, isLoading, isFetching, refetch } = useGetRoles({ limit: 100, offset: 0 }) + + const form = useForm({ + resolver: zodResolver(adminRoleFormSchema), + defaultValues: adminRoleFormDefaultValues, + }) + + const handleEdit = (role: AdminRoleResponse) => { + setEditingRole(role) + setIsReadOnly(isProtectedRole(role)) + form.reset(adminRoleFormFromResponse(role)) + onOpenChange(true) + } + + const handleDialogChange = (open: boolean) => { + if (!open) { + setEditingRole(null) + setIsReadOnly(false) + form.reset(adminRoleFormDefaultValues) + } + onOpenChange(open) + } + + const handleRefresh = async () => { + await refetch() + } + + const filteredRoles = useMemo(() => { + const list = rolesData?.roles || [] + const query = searchQuery.toLowerCase().trim() + if (!query) return list + return list.filter(role => { + const localized = t(`adminRoles.names.${role.name}`, { defaultValue: role.name }).toLowerCase() + return role.name.toLowerCase().includes(query) || localized.includes(query) + }) + }, [rolesData?.roles, searchQuery, t]) + + const isCurrentlyLoading = isLoading || (isFetching && !rolesData) + const hasSearch = searchQuery.trim() !== '' + const isEmpty = !isCurrentlyLoading && filteredRoles.length === 0 && !hasSearch + const isSearchEmpty = !isCurrentlyLoading && filteredRoles.length === 0 && hasSearch + + const listColumns = useAdminRolesListColumns({ onEdit: handleEdit }) + + const clearSelection = () => setSelectedRoleIds([]) + + const deletableSelectedIds = useMemo(() => { + const map = new Map((rolesData?.roles || []).map(role => [role.id, role])) + return selectedRoleIds.filter(id => { + const role = map.get(id) + return role && !isProtectedRole(role) + }) + }, [rolesData?.roles, selectedRoleIds]) + + const handleBulkDelete = async () => { + if (!deletableSelectedIds.length) return + try { + const results = await Promise.allSettled(deletableSelectedIds.map(roleId => deleteRole.mutateAsync({ roleId }))) + const failed = results.filter(r => r.status === 'rejected').length + const succeeded = results.length - failed + if (succeeded) { + toast.success(t('success', { defaultValue: 'Success' }), { + description: t('adminRoles.bulkDeleteSuccess', { count: succeeded, defaultValue: '{{count}} roles deleted successfully.' }), + }) + } + if (failed) { + toast.error(t('error', { defaultValue: 'Error' }), { + description: t('adminRoles.bulkDeletePartial', { count: failed, defaultValue: '{{count}} roles could not be deleted.' }), + }) + } + clearSelection() + setConfirmBulkDelete(false) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: getGetRolesQueryKey() }), + queryClient.invalidateQueries({ queryKey: getGetRolesSimpleQueryKey() }), + ]) + } catch (error: any) { + toast.error(t('error', { defaultValue: 'Error' }), { + description: error?.data?.detail || error?.message || t('adminRoles.bulkDeleteFailed', { defaultValue: 'Failed to delete selected roles.' }), + }) + } + } + + const bulkActions: BulkActionItem[] = + deletableSelectedIds.length > 0 + ? [ + { + key: 'delete', + label: t('delete', { defaultValue: 'Delete' }), + icon: Trash2, + onClick: () => setConfirmBulkDelete(true), + direct: true, + destructive: true, + }, + ] + : [] + + return ( +
+
+
+ + setSearchQuery(e.target.value)} + className={cn('pl-8 pr-10', dir === 'rtl' && 'pl-10 pr-8')} + /> + {searchQuery && ( + + )} +
+
+ + +
+
+ + + + {isEmpty && ( + + +
+

{t('adminRoles.empty', { defaultValue: 'No roles' })}

+

{t('adminRoles.emptyDescription', { defaultValue: 'Create a role to assign granular permissions, limits, features, and access restrictions to admins.' })}

+
+
+
+ )} + + {isSearchEmpty && ( + + +
+

{t('noResults', { defaultValue: 'No results' })}

+

{t('adminRoles.noSearchResults', { defaultValue: 'No roles match your search.' })}

+
+
+
+ )} + + {(isCurrentlyLoading || (!isEmpty && !isSearchEmpty)) && + (viewMode === 'grid' ? ( + role.id} + isLoading={isCurrentlyLoading} + loadingRows={6} + className="gap-4" + enableSelection + injectSelectionProps + isRowSelectable={role => !isProtectedRole(role)} + selectedRowIds={selectedRoleIds} + onSelectionChange={ids => setSelectedRoleIds(ids.map(id => Number(id)))} + showEmptyState={false} + renderItem={role => } + renderSkeleton={i => ( + +
+ +
+ + +
+ +
+
+ )} + /> + ) : ( + role.id} + isLoading={isCurrentlyLoading} + loadingRows={6} + className="gap-3" + onRowClick={handleEdit} + enableSelection + isRowSelectable={role => !isProtectedRole(role)} + selectedRowIds={selectedRoleIds} + onSelectionChange={ids => setSelectedRoleIds(ids.map(id => Number(id)))} + showEmptyState={false} + /> + ))} + + + + +
+ ) +} diff --git a/dashboard/src/features/admin-roles/components/use-admin-roles-list-columns.tsx b/dashboard/src/features/admin-roles/components/use-admin-roles-list-columns.tsx new file mode 100644 index 000000000..40c4684e1 --- /dev/null +++ b/dashboard/src/features/admin-roles/components/use-admin-roles-list-columns.tsx @@ -0,0 +1,84 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Crown, Shield, ShieldCheck } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { ListColumn } from '@/components/common/list-generator' +import { cn } from '@/lib/utils' +import { AdminRoleResponse } from '@/service/api' + +import { BUILT_IN_ROLE_IDS } from '@/features/admin-roles/forms/admin-role-form' +import AdminRoleActionsMenu from '@/features/admin-roles/components/admin-role-actions-menu' + +interface UseAdminRolesListColumnsProps { + onEdit: (role: AdminRoleResponse) => void +} + +const countResourcePermissions = (role: AdminRoleResponse) => { + const permissions = role.permissions || {} + let total = 0 + for (const value of Object.values(permissions)) { + if (!value || typeof value !== 'object') continue + total += Object.keys(value as Record).length + } + return total +} + +export const useAdminRolesListColumns = ({ onEdit }: UseAdminRolesListColumnsProps) => { + const { t } = useTranslation() + + return useMemo[]>( + () => [ + { + id: 'name', + header: t('name', { defaultValue: 'Name' }), + width: '3fr', + cell: role => { + const builtIn = BUILT_IN_ROLE_IDS.has(role.id) && !role.is_owner + const RoleIcon = role.is_owner ? Crown : builtIn ? ShieldCheck : Shield + return ( +
+
+ +
+ {t(`adminRoles.names.${role.name}`, { defaultValue: role.name })} + {role.is_owner && ( + + {t('adminRoles.ownerRole', { defaultValue: 'owner' })} + + )} + {builtIn && ( + + {t('adminRoles.builtInRole', { defaultValue: 'built-in' })} + + )} +
+ ) + }, + }, + { + id: 'permissions', + header: t('adminRoles.permissions', { defaultValue: 'Permissions' }), + width: '1fr', + cell: role => {countResourcePermissions(role)}, + hideOnMobile: true, + }, + { + id: 'limits', + header: t('adminRoles.limits', { defaultValue: 'Limits' }), + width: '1fr', + cell: role => {Object.keys(role.limits || {}).length}, + hideOnMobile: true, + }, + { + id: 'actions', + header: '', + width: '64px', + align: 'end', + hideOnMobile: true, + cell: role => , + }, + ], + [t, onEdit], + ) +} diff --git a/dashboard/src/features/admin-roles/dialogs/admin-role-modal.tsx b/dashboard/src/features/admin-roles/dialogs/admin-role-modal.tsx new file mode 100644 index 000000000..62e9dfe17 --- /dev/null +++ b/dashboard/src/features/admin-roles/dialogs/admin-role-modal.tsx @@ -0,0 +1,805 @@ +import { useEffect, useMemo, useState } from 'react' +import { FieldErrors, UseFormReturn, useWatch } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { useQueryClient } from '@tanstack/react-query' +import { Check, ChevronsUpDown, Eye, FolderTree, KeyRound, Minus, Pencil, Search, Shield, Sliders, Sparkles, X } from 'lucide-react' + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { LoaderButton } from '@/components/ui/loader-button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' + +import { DecimalInput } from '@/components/common/decimal-input' + +import useDirDetection from '@/hooks/use-dir-detection' +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 { + AdminRoleFormValues, + AdminRoleFormValuesInput, + FEATURE_KEYS, + PERMISSION_GROUPS, + PermissionAction, + RoleScope, + adminRoleFormDefaultValues, + adminRoleFormToPayload, +} from '@/features/admin-roles/forms/admin-role-form' + +type RolePermissionFormMap = Record> + +const ONE_GB_IN_BYTES = 1024 * 1024 * 1024 + +interface AdminRoleModalProps { + isDialogOpen: boolean + onOpenChange: (open: boolean) => void + form: UseFormReturn + editingRole: boolean + editingRoleId?: number | null + readOnly?: boolean +} + +const SECTION_PERMISSIONS = 'permissions' +const SECTION_LIMITS = 'limits' +const SECTION_FEATURES = 'features' +const SECTION_ACCESS = 'access' + +export default function AdminRoleModal({ isDialogOpen, onOpenChange, form, editingRole, editingRoleId, readOnly = false }: AdminRoleModalProps) { + const { t } = useTranslation() + const handleError = useDynamicErrorHandler() + const queryClient = useQueryClient() + const createRole = useCreateRole() + const modifyRole = useModifyRole() + const [openSection, setOpenSection] = useState(SECTION_PERMISSIONS) + + const groupsQuery = useGetAllGroups({}, { query: { enabled: isDialogOpen } }) + const templatesQuery = useGetUserTemplatesSimple(undefined, { query: { enabled: isDialogOpen } }) + + 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], + ) + + useEffect(() => { + if (!isDialogOpen) { + form.clearErrors() + setOpenSection(SECTION_PERMISSIONS) + } + }, [isDialogOpen, form]) + + const isSaving = createRole.isPending || modifyRole.isPending + + const onSubmit = async (values: AdminRoleFormValues) => { + try { + const payload = adminRoleFormToPayload(values) + if (editingRole && editingRoleId != null) { + await modifyRole.mutateAsync({ roleId: editingRoleId, data: payload }) + toast.success(t('adminRoles.editSuccess', { name: payload.name, defaultValue: 'Role «{{name}}» has been updated successfully' })) + } else { + await createRole.mutateAsync({ data: payload }) + toast.success(t('adminRoles.createSuccess', { name: payload.name, defaultValue: 'Role «{{name}}» has been created successfully' })) + } + await Promise.all([ + queryClient.invalidateQueries({ queryKey: getGetRolesQueryKey() }), + queryClient.invalidateQueries({ queryKey: getGetRolesSimpleQueryKey() }), + ]) + onOpenChange(false) + form.reset(adminRoleFormDefaultValues) + } catch (error: any) { + handleError({ error, fields: ['name'], form, contextKey: 'adminRoles' }) + } + } + + const onInvalidSubmit = (errors: FieldErrors) => { + const firstPath = firstErrorPath(errors) + if (firstPath?.startsWith('limits.')) setOpenSection(SECTION_LIMITS) + else if (firstPath?.startsWith('features.')) setOpenSection(SECTION_FEATURES) + else if (firstPath?.startsWith('access.')) setOpenSection(SECTION_ACCESS) + else if (firstPath?.startsWith('permissions.')) setOpenSection(SECTION_PERMISSIONS) + + toast.error( + firstPath + ? t('validation.invalidField', { field: firstPath, defaultValue: `Invalid value for ${firstPath}` }) + : t('validation.formInvalid', { defaultValue: 'Form is invalid. Please check all fields.' }), + ) + } + + const handleAccordionChange = (value: string) => { + setOpenSection(prev => (prev === value ? undefined : value)) + } + + return ( + + e.preventDefault()}> + + + {readOnly ? : editingRole ? : } + + {readOnly + ? t('adminRoles.viewRole', { defaultValue: 'View role' }) + : editingRole + ? t('adminRoles.editRole', { defaultValue: 'Edit role' }) + : t('adminRoles.createRole', { defaultValue: 'Create role' })} + + + {t('adminRoles.modalDescription', { defaultValue: 'Configure permissions, limits, features and access for this role.' })} + + +
+ + {readOnly && ( +
+ {t('adminRoles.readOnlyHint', { defaultValue: 'This is a built-in role. You can review its configuration but cannot modify it.' })} +
+ )} +
+
+ ( + + {t('name', { defaultValue: 'Name' })} + + + + + + )} + /> + + + + +
+ + {t('adminRoles.permissions', { defaultValue: 'Permissions' })} + +
+
+ + + +
+ + + +
+ + {t('adminRoles.limits', { defaultValue: 'Limits' })} +
+
+ + + +
+ + + +
+ + {t('adminRoles.features', { defaultValue: 'Features' })} +
+
+ + + +
+ + + +
+ + {t('adminRoles.access', { defaultValue: 'Access' })} +
+
+ + + +
+
+
+
+ +
+ + {!readOnly && ( + + {editingRole ? t('modify', { defaultValue: 'Modify' }) : t('create', { defaultValue: 'Create' })} + + )} +
+
+ +
+
+ ) +} + +type AdminRoleForm = UseFormReturn + +function firstErrorPath(errors: FieldErrors, prefix = ''): string | null { + for (const [key, value] of Object.entries(errors)) { + if (!value) continue + const path = prefix ? `${prefix}.${key}` : key + if ('message' in value || 'type' in value) return path + if (typeof value === 'object') { + const nestedPath = firstErrorPath(value as FieldErrors, path) + if (nestedPath) return nestedPath + } + } + 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() + + return ( +
+

{t('adminRoles.limitsHint', { defaultValue: 'Leave empty to inherit defaults. Set to 0 to disable.' })}

+ + ( + + {t('adminRoles.limitFields.max_users', { defaultValue: 'Max users' })} + + field.onChange(value ?? null)} + /> + + + + )} + /> + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ ) +} + +function NumberLimitField({ form, name, labelKey }: { form: AdminRoleForm; name: any; labelKey: string }) { + const { t } = useTranslation() + return ( + ( + + {t(labelKey)} + + field.onChange(value ?? null)} + /> + + + + )} + /> + ) +} + +function BytesLimitField({ form, name, labelKey }: { form: AdminRoleForm; name: any; labelKey: string }) { + const { t } = useTranslation() + return ( + { + const numericValue = typeof field.value === 'number' ? field.value : null + return ( + + {t(labelKey)} + +
+ { + if (value == null) { + field.onChange(null) + return + } + field.onChange(gbToBytes(value)) + }} + emptyValue={undefined} + className="pr-10" + /> + + {t('userDialog.gb', { defaultValue: 'GB' })} + +
+
+ {numericValue != null && numericValue > 0 && numericValue < ONE_GB_IN_BYTES && ( +

+ {formatBytes(numericValue)} +

+ )} + +
+ ) + }} + /> + ) +} + +function FeaturesSection({ form }: { form: AdminRoleForm }) { + const { t } = useTranslation() + return ( +
+ ( + field.onChange(!field.value)} + > +
+ {t('adminRoles.limitedBehavior.disabledWhenLimited.title', { defaultValue: 'Block limited admins' })} +

+ {t('adminRoles.limitedBehavior.disabledWhenLimited.description', { defaultValue: 'Deny all dashboard and API access after an admin reaches their data limit.' })} +

+
+ +
e.stopPropagation()}> + +
+
+
+ )} + /> + + ( + field.onChange(!field.value)} + > +
+ {t('adminRoles.limitedBehavior.disableUsersWhenLimited.title', { defaultValue: 'Disable users when limited' })} +

+ {t('adminRoles.limitedBehavior.disableUsersWhenLimited.description', { defaultValue: "Remove this admin's users from nodes while the admin is usage-limited." })} +

+
+ +
e.stopPropagation()}> + +
+
+
+ )} + /> + + {FEATURE_KEYS.map(key => ( + ( + field.onChange(!field.value)} + > +
+ {t(`adminRoles.featureFields.${key}.title`, { defaultValue: key })} +

{t(`adminRoles.featureFields.${key}.description`, { defaultValue: '' })}

+
+ +
e.stopPropagation()}> + +
+
+
+ )} + /> + ))} +
+ ) +} + +function AccessSection({ + form, + groupsOptions, + templatesOptions, + isLoading, +}: { + form: AdminRoleForm + groupsOptions: Array<{ id: number; name: string }> + templatesOptions: Array<{ id: number; name: string }> + isLoading: boolean +}) { + const { t } = useTranslation() + + return ( +
+ ( + field.onChange(!field.value)} + > +
+ {t('adminRoles.requireTemplateTitle', { defaultValue: 'Require template' })} +

{t('adminRoles.requireTemplateDescription', { defaultValue: 'Force admins with this role to create users only from a template.' })}

+
+ +
e.stopPropagation()}> + +
+
+
+ )} + /> + + ( + field.onChange(ids.length ? ids : null)} + isLoading={isLoading} + /> + )} + /> + + ( + field.onChange(ids.length ? ids : null)} + isLoading={isLoading} + /> + )} + /> +
+ ) +} + +interface IdMultiSelectProps { + label: string + description?: string + emptyText: string + options: Array<{ id: number; name: string }> + value: number[] + onChange: (ids: number[]) => void + isLoading?: boolean +} + +function IdMultiSelect({ label, description, emptyText, options, value, onChange, isLoading }: IdMultiSelectProps) { + const { t } = useTranslation() + const dir = useDirDetection() + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const selected = useMemo(() => new Set(value), [value]) + const optionMap = useMemo(() => new Map(options.map(option => [option.id, option] as const)), [options]) + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase() + if (!query) return options + return options.filter(option => option.name.toLowerCase().includes(query)) + }, [options, search]) + + const toggle = (id: number) => { + if (selected.has(id)) onChange(value.filter(item => item !== id)) + else onChange([...value, id]) + } + + const allFilteredSelected = filtered.length > 0 && filtered.every(option => selected.has(option.id)) + const anyFilteredSelected = filtered.some(option => selected.has(option.id)) + + const handleToggleAll = () => { + if (allFilteredSelected) { + const filteredIds = new Set(filtered.map(option => option.id)) + onChange(value.filter(id => !filteredIds.has(id))) + return + } + const next = [...value] + for (const option of filtered) { + if (!selected.has(option.id)) next.push(option.id) + } + onChange(next) + } + + return ( + + {label} + {description &&

{description}

} + + + + + +
+
+ + setSearch(event.target.value)} + placeholder={t('search', { defaultValue: 'Search' })} + className="pl-8" + /> +
+ {options.length > 0 && ( + + )} +
+ {isLoading ? ( +
{t('loading', { defaultValue: 'Loading...' })}
+ ) : filtered.length === 0 ? ( +
{options.length === 0 ? emptyText : t('noResults', { defaultValue: 'No results' })}
+ ) : ( + filtered.map(option => { + const isSelected = selected.has(option.id) + return ( + + ) + }) + )} +
+
+
+
+ +
+ ) +} + +function SelectionCheckbox({ checked, className }: { checked: boolean | 'indeterminate'; className?: string }) { + return ( + + ) +} diff --git a/dashboard/src/features/admin-roles/forms/admin-role-form.ts b/dashboard/src/features/admin-roles/forms/admin-role-form.ts new file mode 100644 index 000000000..a3bd4a5e3 --- /dev/null +++ b/dashboard/src/features/admin-roles/forms/admin-role-form.ts @@ -0,0 +1,303 @@ +import { z } from 'zod' +import type { AdminRoleResponse, RoleAccess, RoleFeatures, RoleLimits, RolePermissions } from '@/service/api' + +export type RoleScope = 0 | 1 | 2 +type RolePermissionFormValue = boolean | { scope: RoleScope } +type RolePermissionFormMap = Record> +type RolePermissionInput = object | null | undefined + +export type PermissionAction = { + resource: string + action: string + scoped?: boolean +} + +export type PermissionGroup = { + labelKey: string + actions: PermissionAction[] +} + +export const PERMISSION_GROUPS: PermissionGroup[] = [ + { + labelKey: 'users', + actions: [ + { resource: 'users', action: 'read', scoped: true }, + { resource: 'users', action: 'read_simple', scoped: true }, + { resource: 'users', action: 'create' }, + { resource: 'users', action: 'update', scoped: true }, + { resource: 'users', action: 'delete', scoped: true }, + { resource: 'users', action: 'reset_usage', scoped: true }, + { resource: 'users', action: 'revoke_sub', scoped: true }, + { resource: 'users', action: 'set_owner', scoped: true }, + { resource: 'users', action: 'activate_next_plan', scoped: true }, + ], + }, + { + labelKey: 'admins', + actions: [ + { resource: 'admins', action: 'read' }, + { resource: 'admins', action: 'read_simple' }, + { resource: 'admins', action: 'create' }, + { resource: 'admins', action: 'update' }, + { resource: 'admins', action: 'delete' }, + { resource: 'admins', action: 'reset_usage' }, + ], + }, + { + labelKey: 'roles', + actions: [ + { resource: 'admin_roles', action: 'read' }, + { resource: 'admin_roles', action: 'read_simple' }, + { resource: 'admin_roles', action: 'create' }, + { resource: 'admin_roles', action: 'update' }, + { resource: 'admin_roles', action: 'delete' }, + ], + }, + { + labelKey: 'nodes', + actions: [ + { resource: 'nodes', action: 'read' }, + { resource: 'nodes', action: 'read_simple' }, + { resource: 'nodes', action: 'create' }, + { resource: 'nodes', action: 'update' }, + { resource: 'nodes', action: 'delete' }, + { resource: 'nodes', action: 'reconnect' }, + { resource: 'nodes', action: 'update_core' }, + { resource: 'nodes', action: 'stats' }, + { resource: 'nodes', action: 'logs' }, + ], + }, + { + labelKey: 'coreHosts', + actions: [ + { resource: 'cores', action: 'read' }, + { resource: 'cores', action: 'read_simple' }, + { resource: 'cores', action: 'create' }, + { resource: 'cores', action: 'update' }, + { resource: 'cores', action: 'delete' }, + { resource: 'hosts', action: 'read' }, + { resource: 'hosts', action: 'create' }, + { resource: 'hosts', action: 'update' }, + ], + }, + { + labelKey: 'groupsTemplates', + actions: [ + { resource: 'groups', action: 'read' }, + { resource: 'groups', action: 'read_simple' }, + { resource: 'groups', action: 'create' }, + { resource: 'groups', action: 'update' }, + { resource: 'groups', action: 'delete' }, + { resource: 'templates', action: 'read' }, + { resource: 'templates', action: 'read_simple' }, + { resource: 'templates', action: 'create' }, + { resource: 'templates', action: 'update' }, + { resource: 'templates', action: 'delete' }, + { resource: 'client_templates', action: 'read' }, + { resource: 'client_templates', action: 'read_simple' }, + { resource: 'client_templates', action: 'create' }, + { resource: 'client_templates', action: 'update' }, + { resource: 'client_templates', action: 'delete' }, + ], + }, + { + labelKey: 'settings', + actions: [ + { resource: 'settings', action: 'read' }, + { resource: 'settings', action: 'read_general' }, + { resource: 'settings', action: 'update' }, + { resource: 'system', action: 'read' }, + { resource: 'hwids', action: 'read' }, + { resource: 'hwids', action: 'delete' }, + ], + }, +] + +export const LIMIT_KEYS = [ + 'max_users', + 'data_limit_min', + 'data_limit_max', + 'expire_days_min', + 'expire_days_max', + 'min_hwid_per_user', + 'max_hwid_per_user', +] as const + +export const FEATURE_KEYS: Array = ['can_use_reset_strategy', 'can_use_next_plan'] + +const VALID_PERMISSION_ACTIONS = PERMISSION_GROUPS.reduce>>((acc, group) => { + for (const item of group.actions) { + 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 + + const rawScope = (value as { scope?: unknown }).scope + const scope = typeof rawScope === 'string' ? Number(rawScope) : rawScope + if (scope === 0 || scope === 1 || scope === 2) return { scope } + + return undefined +} + +const sanitizeRolePermissions = (permissions: RolePermissionInput): RolePermissionFormMap => { + const next: RolePermissionFormMap = {} + + for (const [resource, actions] of Object.entries(permissions || {})) { + const allowedActions = VALID_PERMISSION_ACTIONS[resource] + if (!allowedActions || !actions || typeof actions !== 'object') continue + + for (const [action, value] of Object.entries(actions as Record)) { + if (!allowedActions.has(action)) continue + const normalizedValue = normalizePermissionValue(value) + if (normalizedValue === undefined) continue + next[resource] = { ...(next[resource] || {}), [action]: normalizedValue } + } + } + + return next +} + +const scopeSchema = z.object({ scope: z.union([z.literal(0), z.literal(1), z.literal(2)]) }) +const permissionValueSchema = z.union([z.boolean(), scopeSchema]) +const resourcePermissionsSchema = z.record(z.string(), permissionValueSchema) +const permissionsSchema = z.preprocess(value => sanitizeRolePermissions(value as RolePermissionInput), z.record(z.string(), resourcePermissionsSchema)) + +const optionalNullableNumber = z.union([z.literal('').transform(() => null), z.null(), z.coerce.number()]).optional() + +const limitsSchema = z.object({ + max_users: optionalNullableNumber, + data_limit_min: optionalNullableNumber, + data_limit_max: optionalNullableNumber, + expire_days_min: optionalNullableNumber, + expire_days_max: optionalNullableNumber, + min_hwid_per_user: optionalNullableNumber, + max_hwid_per_user: optionalNullableNumber, +}) + +const SECONDS_PER_DAY = 86_400 + +const secondsToDays = (value: number | null | undefined): number | null => { + if (value === null || value === undefined) return null + return Math.round(value / SECONDS_PER_DAY) +} + +const daysToSeconds = (value: number | null | undefined): number | null => { + if (value === null || value === undefined || value === ('' as unknown)) return null + const n = typeof value === 'string' ? Number(value) : value + if (!Number.isFinite(n)) return null + return Math.round(n * SECONDS_PER_DAY) +} + +const featuresSchema = z.object({ + can_use_reset_strategy: z.boolean(), + can_use_next_plan: z.boolean(), +}) + +const accessSchema = z.object({ + require_template: z.boolean(), + allowed_template_ids: z.array(z.number().int().positive()).nullable(), + allowed_group_ids: z.array(z.number().int().positive()).nullable(), +}) + +export const adminRoleFormSchema = z.object({ + name: z.string().trim().min(1, 'Name is required').max(64), + permissions: permissionsSchema, + limits: limitsSchema, + features: featuresSchema, + access: accessSchema, + disabled_when_limited: z.boolean(), + disable_users_when_limited: z.boolean(), +}) + +export type AdminRoleFormValuesInput = z.input +export type AdminRoleFormValues = z.infer + +export const defaultAdminRoleFeatures = (): AdminRoleFormValues['features'] => ({ + can_use_reset_strategy: true, + can_use_next_plan: true, +}) + +export const defaultAdminRoleAccess = (): AdminRoleFormValues['access'] => ({ + require_template: false, + allowed_template_ids: null, + allowed_group_ids: null, +}) + +export const adminRoleFormDefaultValues: AdminRoleFormValuesInput = { + name: '', + permissions: {}, + limits: { + max_users: null, + data_limit_min: null, + data_limit_max: null, + expire_days_min: null, + expire_days_max: null, + min_hwid_per_user: null, + max_hwid_per_user: null, + }, + features: defaultAdminRoleFeatures(), + access: defaultAdminRoleAccess(), + disabled_when_limited: false, + disable_users_when_limited: true, +} + +export const adminRoleFormFromResponse = (role: AdminRoleResponse): AdminRoleFormValuesInput => ({ + name: role.name, + permissions: sanitizeRolePermissions(role.permissions), + limits: { + max_users: role.limits?.max_users ?? null, + data_limit_min: role.limits?.data_limit_min ?? null, + data_limit_max: role.limits?.data_limit_max ?? null, + expire_days_min: secondsToDays(role.limits?.expire_min), + expire_days_max: secondsToDays(role.limits?.expire_max), + min_hwid_per_user: role.limits?.min_hwid_per_user ?? null, + max_hwid_per_user: role.limits?.max_hwid_per_user ?? null, + }, + features: { + can_use_reset_strategy: role.features?.can_use_reset_strategy ?? true, + can_use_next_plan: role.features?.can_use_next_plan ?? true, + }, + access: { + require_template: role.access?.require_template ?? false, + allowed_template_ids: role.access?.allowed_template_ids ?? null, + allowed_group_ids: role.access?.allowed_group_ids ?? null, + }, + disabled_when_limited: role.disabled_when_limited ?? false, + disable_users_when_limited: role.disable_users_when_limited ?? true, +}) + +export const adminRoleFormToPayload = (values: AdminRoleFormValuesInput) => { + // Convert form's day-based fields back to seconds, then drop empty/null entries + const limitsRaw = { + max_users: values.limits.max_users, + data_limit_min: values.limits.data_limit_min, + data_limit_max: values.limits.data_limit_max, + expire_min: daysToSeconds(values.limits.expire_days_min as number | null | undefined), + expire_max: daysToSeconds(values.limits.expire_days_max as number | null | undefined), + min_hwid_per_user: values.limits.min_hwid_per_user, + max_hwid_per_user: values.limits.max_hwid_per_user, + } + + return { + name: values.name.trim(), + permissions: sanitizeRolePermissions(values.permissions as RolePermissionInput) as RolePermissions, + limits: Object.fromEntries(Object.entries(limitsRaw).filter(([, v]) => v !== null && v !== undefined && v !== '')) as RoleLimits, + features: values.features as RoleFeatures, + access: { + require_template: values.access.require_template, + allowed_template_ids: values.access.allowed_template_ids?.length ? values.access.allowed_template_ids : null, + allowed_group_ids: values.access.allowed_group_ids?.length ? values.access.allowed_group_ids : null, + } as RoleAccess, + disabled_when_limited: values.disabled_when_limited, + disable_users_when_limited: values.disable_users_when_limited, + } +} + +export const BUILT_IN_ROLE_IDS = new Set([1, 2, 3]) + +export const isProtectedRole = (role: AdminRoleResponse) => role.is_owner || BUILT_IN_ROLE_IDS.has(role.id) diff --git a/dashboard/src/features/admins/components/admin-statistics.tsx b/dashboard/src/features/admins/components/admin-statistics.tsx index 295f33e5a..0d6bfa40f 100644 --- a/dashboard/src/features/admins/components/admin-statistics.tsx +++ b/dashboard/src/features/admins/components/admin-statistics.tsx @@ -3,24 +3,25 @@ import { cn } from '@/lib/utils.ts' import { useTranslation } from 'react-i18next' import { Card, CardTitle } from '@/components/ui/card' import { CountUp } from '@/components/ui/count-up' -import { User, UserCheck, UserX } from 'lucide-react' +import { User, UserCheck, UserLock, UserX } from 'lucide-react' import React, { useEffect, useState } from 'react' interface AdminsStatisticsProps { - counts: { total: number; active: number; disabled: number } | null + counts: { total: number; active: number; disabled: number; limited: number } | null } export default function AdminStatisticsSection({ counts }: AdminsStatisticsProps) { const { t } = useTranslation() const dir = useDirDetection() - const [prevStats, setPrevStats] = useState<{ total: number; active: number; disabled: number } | null>(null) + const [prevStats, setPrevStats] = useState<{ total: number; active: number; disabled: number; limited: number } | null>(null) const [isIncreased, setIsIncreased] = useState>({}) const total = counts?.total || 0 const active = counts?.active || 0 const disabled = counts?.disabled || 0 + const limited = counts?.limited || 0 - const currentStats = { total, active, disabled } + const currentStats = { total, active, disabled, limited } useEffect(() => { if (prevStats) { @@ -28,6 +29,7 @@ export default function AdminStatisticsSection({ counts }: AdminsStatisticsProps total: currentStats.total > prevStats.total, active: currentStats.active > prevStats.active, disabled: currentStats.disabled > prevStats.disabled, + limited: currentStats.limited > prevStats.limited, }) } setPrevStats(currentStats) @@ -56,6 +58,13 @@ export default function AdminStatisticsSection({ counts }: AdminsStatisticsProps color: '', key: 'disabled', }, + { + icon: UserLock, + label: t('admins.limited'), + value: limited, + color: '', + key: 'limited', + }, ] return ( diff --git a/dashboard/src/features/admins/components/admin-status-badge.tsx b/dashboard/src/features/admins/components/admin-status-badge.tsx index 645c96b69..2d3624cb8 100644 --- a/dashboard/src/features/admins/components/admin-status-badge.tsx +++ b/dashboard/src/features/admins/components/admin-status-badge.tsx @@ -3,37 +3,35 @@ import { statusColors } from '@/constants/UserSettings' import { cn } from '@/lib/utils' import { FC } from 'react' import { useTranslation } from 'react-i18next' -import { UserRound, UserRoundKey } from 'lucide-react' type AdminStatusProps = { isSudo: boolean - isDisabled: boolean + status?: string | null + isDisabled?: boolean + label?: string + compact?: boolean } -export const AdminStatusBadge: FC = ({ isSudo, isDisabled }) => { +export const AdminStatusBadge: FC = ({ isSudo: _isSudo, status, isDisabled, label, compact }) => { const { t } = useTranslation() + const resolvedStatus = status || (isDisabled ? 'disabled' : 'active') const getStatusInfo = () => { - if (isDisabled) { - return { - color: statusColors['disabled']?.statusColor || 'bg-gray-400 text-white', - icon: null, - text: t('disabled'), - } - } + const baseColor = statusColors[resolvedStatus]?.statusColor || 'bg-gray-400 text-white' + const baseIcon = statusColors[resolvedStatus]?.icon || null - if (isSudo) { + if (compact) { return { - color: 'bg-violet-500 text-white', - icon: UserRoundKey, - text: t('sudo'), + color: baseColor, + icon: baseIcon, + text: t(`status.${resolvedStatus}`, { defaultValue: resolvedStatus }), } } return { - color: statusColors['active']?.statusColor || 'bg-green-500 text-white', - icon: UserRound, - text: t('admin'), + color: baseColor, + icon: baseIcon, + text: label || t(`status.${resolvedStatus}`, { defaultValue: resolvedStatus }), } } @@ -41,10 +39,16 @@ export const AdminStatusBadge: FC = ({ isSudo, isDisabled }) = const StatusIcon = statusInfo.icon return ( - -
+ +
{StatusIcon && } - {statusInfo.text} + {statusInfo.text}
) diff --git a/dashboard/src/features/admins/components/admins-table.tsx b/dashboard/src/features/admins/components/admins-table.tsx index d711147b4..f44051f93 100644 --- a/dashboard/src/features/admins/components/admins-table.tsx +++ b/dashboard/src/features/admins/components/admins-table.tsx @@ -29,6 +29,7 @@ import { useQueryClient } from '@tanstack/react-query' import { BulkActionItem, BulkActionsBar } from '@/features/users/components/bulk-actions-bar' import { BulkActionAlertDialog } from '@/features/users/components/bulk-action-alert-dialog' import { Power, PowerOff, RefreshCw, Trash2, UserCheck, UserMinus, UserX } from 'lucide-react' +import { hasPermission, hasScopeAll } from '@/utils/rbac' interface AdminFilters { sort?: string @@ -42,7 +43,7 @@ interface AdminsTableProps { onDelete: (admin: AdminDetails) => void onToggleStatus: (admin: AdminDetails, checked: boolean) => void onResetUsage: (admin: AdminDetails) => void - onTotalAdminsChange?: (counts: { total: number; active: number; disabled: number } | null) => void + onTotalAdminsChange?: (counts: { total: number; active: number; disabled: number; limited: number } | null) => void } type BulkUsersActionType = 'disable' | 'activate' @@ -57,6 +58,9 @@ interface BulkActionDialogConfig { destructive?: boolean } +const compactAdminIds = (admins: AdminDetails[]): number[] => admins.map(admin => admin.id).filter((id): id is number => typeof id === 'number') +const getAdminStatus = (admin: AdminDetails) => admin.status || (admin.is_disabled ? 'disabled' : 'active') + const DeleteAlertDialog = ({ admin, isOpen, onClose, onConfirm }: { admin: AdminDetails; isOpen: boolean; onClose: () => void; onConfirm: () => void }) => { const { t } = useTranslation() const dir = useDirDetection() @@ -96,10 +100,10 @@ const ToggleAdminStatusModal = ({ admin, isOpen, onClose, onConfirm }: { admin: - {t(admin.is_disabled ? 'admin.enable' : 'admin.disable')} + {t(getAdminStatus(admin) === 'disabled' ? 'admin.enable' : 'admin.disable')} setAdminUsersToggle(!adminUsersToggle)} /> - + @@ -198,6 +202,12 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU const { t } = useTranslation() const queryClient = useQueryClient() const { admin: currentAdmin } = useAdmin() + const canUpdateAdmins = hasPermission(currentAdmin, 'admins', 'update') + const canDeleteAdmins = hasPermission(currentAdmin, 'admins', 'delete') + const canResetAdmins = hasPermission(currentAdmin, 'admins', 'reset_usage') + const canUpdateAllUsers = hasScopeAll(currentAdmin, 'users', 'update') + const canDeleteAllUsers = hasScopeAll(currentAdmin, 'users', 'delete') + const canUseBulkSelection = canUpdateAdmins || canDeleteAdmins || canResetAdmins || canUpdateAllUsers || canDeleteAllUsers const [currentPage, setCurrentPage] = useState(0) const [itemsPerPage, setItemsPerPage] = useState(getAdminsPerPageLimitSize()) const [isChangingPage, setIsChangingPage] = useState(false) @@ -243,8 +253,11 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU const adminsData = adminsResponse?.admins || [] const selectedAdmins = adminsData.filter(admin => selectedAdminUsernames.includes(admin.username)) - const selectedEnableEligibleUsernames = selectedAdmins.filter(admin => admin.is_disabled).map(admin => admin.username) - const selectedDisableEligibleUsernames = selectedAdmins.filter(admin => !admin.is_disabled).map(admin => admin.username) + const selectedEnableEligibleAdmins = selectedAdmins.filter(admin => getAdminStatus(admin) === 'disabled') + const selectedDisableEligibleAdmins = selectedAdmins.filter(admin => getAdminStatus(admin) !== 'disabled') + const selectedAdminIds = compactAdminIds(selectedAdmins) + const selectedEnableEligibleIds = compactAdminIds(selectedEnableEligibleAdmins) + const selectedDisableEligibleIds = compactAdminIds(selectedDisableEligibleAdmins) // Expose counts to parent component for statistics useEffect(() => { @@ -254,6 +267,7 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU total: adminsResponse.total, active: adminsResponse.active, disabled: adminsResponse.disabled, + limited: adminsResponse.limited, }) } else { onTotalAdminsChange(null) @@ -508,12 +522,12 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU } const handleBulkDelete = async () => { - if (!selectedAdminUsernames.length) return + if (!selectedAdminIds.length) return try { const response = await bulkDeleteAdminsMutation.mutateAsync({ data: { - usernames: selectedAdminUsernames, + ids: selectedAdminIds, }, }) toast.success(t('success', { defaultValue: 'Success' }), { @@ -538,12 +552,12 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU } const handleBulkResetUsage = async () => { - if (!selectedAdminUsernames.length) return + if (!selectedAdminIds.length) return try { const response = await bulkResetAdminsUsageMutation.mutateAsync({ data: { - usernames: selectedAdminUsernames, + ids: selectedAdminIds, }, }) toast.success(t('success', { defaultValue: 'Success' }), { @@ -563,12 +577,12 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU } const handleBulkDisable = async () => { - if (!selectedDisableEligibleUsernames.length) return + if (!selectedDisableEligibleIds.length) return try { const response = await bulkDisableAdminsMutation.mutateAsync({ data: { - usernames: selectedDisableEligibleUsernames, + ids: selectedDisableEligibleIds, }, }) toast.success(t('success', { defaultValue: 'Success' }), { @@ -588,12 +602,12 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU } const handleBulkEnable = async () => { - if (!selectedEnableEligibleUsernames.length) return + if (!selectedEnableEligibleIds.length) return try { const response = await bulkEnableAdminsMutation.mutateAsync({ data: { - usernames: selectedEnableEligibleUsernames, + ids: selectedEnableEligibleIds, }, }) toast.success(t('success', { defaultValue: 'Success' }), { @@ -613,12 +627,12 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU } const handleBulkDisableUsers = async () => { - if (!selectedAdminUsernames.length) return + if (!selectedAdminIds.length) return try { const response = await bulkDisableAllActiveUsersMutation.mutateAsync({ data: { - usernames: selectedAdminUsernames, + ids: selectedAdminIds, }, }) toast.success(t('success', { defaultValue: 'Success' }), { @@ -638,12 +652,12 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU } const handleBulkActivateUsers = async () => { - if (!selectedAdminUsernames.length) return + if (!selectedAdminIds.length) return try { const response = await bulkActivateAllDisabledUsersMutation.mutateAsync({ data: { - usernames: selectedAdminUsernames, + ids: selectedAdminIds, }, }) toast.success(t('success', { defaultValue: 'Success' }), { @@ -663,12 +677,12 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU } const handleBulkRemoveUsers = async () => { - if (!selectedAdminUsernames.length) return + if (!selectedAdminIds.length) return try { const response = await bulkRemoveAllUsersMutation.mutateAsync({ data: { - usernames: selectedAdminUsernames, + ids: selectedAdminIds, }, }) toast.success(t('success', { defaultValue: 'Success' }), { @@ -688,10 +702,12 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU } const selectedCount = selectedAdminUsernames.length - const enableEligibleCount = selectedEnableEligibleUsernames.length - const disableEligibleCount = selectedDisableEligibleUsernames.length + const enableEligibleCount = selectedEnableEligibleAdmins.length + const disableEligibleCount = selectedDisableEligibleAdmins.length const bulkActions: BulkActionItem[] = selectedCount ? [ + ...(canDeleteAdmins + ? [ { key: 'delete', label: t('delete'), @@ -699,14 +715,20 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU onClick: () => setBulkAction('delete'), direct: true, destructive: true, - }, + } as BulkActionItem, + ] + : []), + ...(canResetAdmins + ? [ { key: 'reset', label: t('admins.reset'), icon: RefreshCw, onClick: () => setBulkAction('reset'), - }, - ...(disableEligibleCount > 0 + } as BulkActionItem, + ] + : []), + ...(canUpdateAdmins && disableEligibleCount > 0 ? [ { key: 'disable', @@ -716,7 +738,7 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU } as BulkActionItem, ] : []), - ...(enableEligibleCount > 0 + ...(canUpdateAdmins && enableEligibleCount > 0 ? [ { key: 'enable', @@ -726,25 +748,33 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU } as BulkActionItem, ] : []), + ...(canUpdateAllUsers + ? [ { key: 'disableUsers', label: t('admins.disableAllActiveUsers'), icon: UserMinus, onClick: () => setBulkAction('disableUsers'), - }, + } as BulkActionItem, { key: 'activateUsers', label: t('admins.activateAllDisabledUsers'), icon: UserCheck, onClick: () => setBulkAction('activateUsers'), - }, + } as BulkActionItem, + ] + : []), + ...(canDeleteAllUsers + ? [ { key: 'removeUsers', label: t('admins.removeAllUsers'), icon: UserX, onClick: () => setBulkAction('removeUsers'), destructive: true, - }, + } as BulkActionItem, + ] + : []), ] : [] const bulkActionConfigs: Record = { @@ -828,13 +858,13 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU handleSort, filters, currentAdminUsername: currentAdmin?.username, - onEdit, - onDelete: handleDeleteClick, - toggleStatus: handleStatusToggleClick, - onResetUsage: handleResetUsersUsageClick, - onDisableAllActiveUsers: handleDisableAllActiveUsersClick, - onActivateAllDisabledUsers: handleActivateAllDisabledUsersClick, - onRemoveAllUsers: handleRemoveAllUsersClick, + onEdit: canUpdateAdmins ? onEdit : undefined, + onDelete: canDeleteAdmins ? handleDeleteClick : undefined, + toggleStatus: canUpdateAdmins ? handleStatusToggleClick : undefined, + onResetUsage: canResetAdmins ? handleResetUsersUsageClick : undefined, + onDisableAllActiveUsers: canUpdateAllUsers ? handleDisableAllActiveUsersClick : undefined, + onActivateAllDisabledUsers: canUpdateAllUsers ? handleActivateAllDisabledUsersClick : undefined, + onRemoveAllUsers: canDeleteAllUsers ? handleRemoveAllUsersClick : undefined, }) const isCurrentlyLoading = isLoading || (isFetching && !adminsResponse) @@ -843,20 +873,21 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU return (
- + {canUseBulkSelection && } string handleSort: (column: string) => void filters: { sort?: string } currentAdminUsername?: string - onEdit: (admin: AdminDetails) => void - onDelete: (admin: AdminDetails) => void - toggleStatus: (admin: AdminDetails) => void - onResetUsage: (admin: AdminDetails) => void - onDisableAllActiveUsers: (admin: AdminDetails) => void - onActivateAllDisabledUsers: (admin: AdminDetails) => void - onRemoveAllUsers: (admin: AdminDetails) => void + onEdit?: (admin: AdminDetails) => void + onDelete?: (admin: AdminDetails) => void + toggleStatus?: (admin: AdminDetails) => void + onResetUsage?: (admin: AdminDetails) => void + onDisableAllActiveUsers?: (admin: AdminDetails) => void + onActivateAllDisabledUsers?: (admin: AdminDetails) => void + onRemoveAllUsers?: (admin: AdminDetails) => void } const createSortButton = ( @@ -30,6 +31,8 @@ const createSortButton = ( filters: { sort?: string }, + className?: string, + desktopLabel?: string, ) => { const handleClick = (e: React.MouseEvent) => { e.preventDefault() @@ -38,8 +41,17 @@ const createSortButton = ( } return ( - - { e.preventDefault() e.stopPropagation() @@ -201,8 +217,8 @@ export const setupColumns = ({ > {t('edit')} - - } + {onResetUsage && { e.preventDefault() e.stopPropagation() @@ -211,8 +227,8 @@ export const setupColumns = ({ > {t('admins.reset')} - - {!isSudoTarget && ( + } + {!isOwnerTarget && toggleStatus && ( { e.preventDefault() @@ -220,11 +236,11 @@ export const setupColumns = ({ toggleStatus(row.original) }} > - {row.original.is_disabled ? : } - {row.original.is_disabled ? t('enable') : t('disable')} + {isAdminDisabled(row.original) ? : } + {isAdminDisabled(row.original) ? t('enable') : t('disable')} )} - {!isSudoTarget && ( + {!isOwnerTarget && onDisableAllActiveUsers && ( { e.preventDefault() @@ -236,7 +252,7 @@ export const setupColumns = ({ {t('admins.disableAllActiveUsers')} )} - {!isSudoTarget && ( + {!isOwnerTarget && onActivateAllDisabledUsers && ( { e.preventDefault() @@ -248,7 +264,7 @@ export const setupColumns = ({ {t('admins.activateAllDisabledUsers')} )} - {!isSudoTarget && ( + {!isOwnerTarget && onRemoveAllUsers && ( { @@ -261,7 +277,7 @@ export const setupColumns = ({ {t('admins.removeAllUsers')} )} - {!isSudoTarget && row.original.username !== currentAdminUsername && ( + {!isOwnerTarget && row.original.username !== currentAdminUsername && onDelete && ( <> { columns: ColumnDef[] data: TData[] currentAdminUsername?: string - onEdit: (admin: AdminDetails) => void - onDelete: (admin: AdminDetails) => void - onToggleStatus: (admin: AdminDetails) => void + onEdit?: (admin: AdminDetails) => void + onDelete?: (admin: AdminDetails) => void + onToggleStatus?: (admin: AdminDetails) => void setStatusToggleDialogOpen: (isOpen: boolean) => void - onResetUsage: (admin: AdminDetails) => void + onResetUsage?: (admin: AdminDetails) => void onDisableAllActiveUsers?: (admin: AdminDetails) => void onActivateAllDisabledUsers?: (admin: AdminDetails) => void onRemoveAllUsers?: (admin: AdminDetails) => void onSelectionChange?: (selectedUsernames: string[]) => void resetSelectionKey?: number + enableSelection?: boolean isLoading?: boolean isFetching?: boolean } +const getAdminStatus = (admin: AdminDetails) => admin.status || (admin.is_disabled ? 'disabled' : 'active') + const ExpandedRowContent = memo( ({ row, @@ -42,46 +46,61 @@ const ExpandedRowContent = memo( currentAdminUsername, }: { row: AdminDetails - onEdit: (admin: AdminDetails) => void - onDelete: (admin: AdminDetails) => void - onToggleStatus: (admin: AdminDetails) => void - onResetUsage: (admin: AdminDetails) => void + onEdit?: (admin: AdminDetails) => void + onDelete?: (admin: AdminDetails) => void + onToggleStatus?: (admin: AdminDetails) => void + onResetUsage?: (admin: AdminDetails) => void onDisableAllActiveUsers?: (admin: AdminDetails) => void onActivateAllDisabledUsers?: (admin: AdminDetails) => void onRemoveAllUsers?: (admin: AdminDetails) => void currentAdminUsername?: string }) => { const { t } = useTranslation() - const isSudoTarget = row.is_sudo + const isOwnerTarget = isOwner(row) + const isDisabled = getAdminStatus(row) === 'disabled' + const hasMoreActions = + (!isOwnerTarget && row.username !== currentAdminUsername && !!onToggleStatus) || + !!onResetUsage || + (!isOwnerTarget && !!onDisableAllActiveUsers) || + (!isOwnerTarget && !!onActivateAllDisabledUsers) || + (!isOwnerTarget && !!onRemoveAllUsers) || + (!isOwnerTarget && row.username !== currentAdminUsername && !!onDelete) return ( -
-
-
- - {t('admins.total.users')}: - {row.total_users || 0} -
-
- - {t('statistics.totalUsage')}: - - {formatBytes(row.lifetime_used_traffic || 0)} - +
+
+
+
+ + {t('admins.total.users')}: + + {(() => { + const total = row.total_users || 0 + const overrideMax = row.permission_overrides?.max_users + const roleMax = row.role?.limits?.max_users + const effectiveMax = typeof overrideMax === 'number' && overrideMax > 0 ? overrideMax : typeof roleMax === 'number' && roleMax > 0 ? roleMax : null + return effectiveMax != null ? `${total} / ${effectiveMax}` : total + })()} + +
+
+ {isOwnerTarget ? : } + {t('admins.role')}: + {roleLabel(row)} +
-
-
- - + } + {hasMoreActions && - {!isSudoTarget && row.username !== currentAdminUsername && ( + {!isOwnerTarget && row.username !== currentAdminUsername && onToggleStatus && ( { e.preventDefault() @@ -89,11 +108,11 @@ const ExpandedRowContent = memo( onToggleStatus(row) }} > - {row.is_disabled ? : } - {row.is_disabled ? t('enable') : t('disable')} + {isDisabled ? : } + {isDisabled ? t('enable') : t('disable')} )} - { e.preventDefault() e.stopPropagation() @@ -102,8 +121,8 @@ const ExpandedRowContent = memo( > {t('admins.reset')} - - {!isSudoTarget && onDisableAllActiveUsers && ( + } + {!isOwnerTarget && onDisableAllActiveUsers && ( { e.preventDefault() @@ -115,7 +134,7 @@ const ExpandedRowContent = memo( {t('admins.disableAllActiveUsers')} )} - {!isSudoTarget && onActivateAllDisabledUsers && ( + {!isOwnerTarget && onActivateAllDisabledUsers && ( { e.preventDefault() @@ -127,7 +146,7 @@ const ExpandedRowContent = memo( {t('admins.activateAllDisabledUsers')} )} - {!isSudoTarget && onRemoveAllUsers && ( + {!isOwnerTarget && onRemoveAllUsers && ( { @@ -140,7 +159,7 @@ const ExpandedRowContent = memo( {t('admins.removeAllUsers')} )} - {!isSudoTarget && row.username !== currentAdminUsername && ( + {!isOwnerTarget && row.username !== currentAdminUsername && onDelete && ( <> )} - + } +
+
) }, @@ -177,6 +204,7 @@ export function DataTable({ onRemoveAllUsers, onSelectionChange, resetSelectionKey = 0, + enableSelection = true, isLoading = false, isFetching = false, }: DataTableProps) { @@ -204,7 +232,7 @@ export function DataTable({ columns, getRowId: row => row.username, getCoreRowModel: getCoreRowModel(), - enableRowSelection: row => !row.original.is_sudo && row.original.username !== currentAdminUsername, + enableRowSelection: row => enableSelection && !isOwner(row.original) && row.original.username !== currentAdminUsername, onRowSelectionChange: handleRowSelectionChange, state: { rowSelection, @@ -219,15 +247,16 @@ export function DataTable({ (columnId: string) => cn( 'text-sm', - columnId !== 'username' && 'whitespace-nowrap', - columnId === 'select' && 'w-8 !px-1 !py-4', - columnId === 'username' && 'max-w-[calc(100vw-32px-72px-44px-16px-56px)] !px-0', - columnId === 'used_traffic' && '!px-0 text-center', - columnId === 'lifetime_used_traffic' && '!px-0 text-center', - columnId === 'chevron' && 'w-10', - !['select', 'username', 'used_traffic', 'lifetime_used_traffic', 'chevron'].includes(columnId) && 'hidden !p-0 md:table-cell', + columnId !== 'used_traffic' && 'whitespace-nowrap', + columnId === 'used_traffic' && 'w-[104px] px-1 md:w-[290px] md:px-2 md:whitespace-nowrap', + columnId !== 'used_traffic' && 'py-1.5', + columnId === 'username' && 'max-w-[calc(100vw-32px-70px-104px-40px-56px)] !px-0', + columnId === 'status' && '!px-2', + columnId === 'select' && 'w-8 !px-1 !py-5', + columnId === 'chevron' && 'w-10 px-2', + !['select', 'username', 'status', 'used_traffic', 'chevron'].includes(columnId) && 'hidden !p-0 md:table-cell', columnId === 'chevron' && 'table-cell md:hidden', - !['select', 'username', 'used_traffic', 'lifetime_used_traffic', 'chevron'].includes(columnId) && (isRTL ? 'pl-1.5 sm:pl-3' : 'pr-1.5 sm:pr-3'), + !['select', 'username', 'status', 'used_traffic', 'chevron'].includes(columnId) && (isRTL ? 'pl-1.5 sm:pl-3' : 'pr-1.5 sm:pr-3'), ), [isRTL], ) @@ -242,33 +271,50 @@ export function DataTable({ ) case 'username': return ( -
- - +
+ + +
+ ) + case 'status': + return ( +
+ +
) case 'used_traffic': - return - case 'lifetime_used_traffic': return ( - <> - - - +
+ +
+ +
+ + +
+
+
) - case 'is_sudo': - return case 'total_users': return (
- +
) case 'actions': - return + return ( +
+ +
+ ) case 'chevron': - return + return ( +
+ +
+ ) default: return } @@ -326,7 +372,7 @@ export function DataTable({ return } - onEdit(rowData) + onEdit?.(rowData) }, [handleRowToggle, onEdit], ) @@ -346,10 +392,11 @@ export function DataTable({ header.id === 'select' && 'w-8 !px-1 py-1.5', header.id === 'username' && 'w-auto md:w-auto', header.id === 'total_users' && '!px-0', - header.id === 'used_traffic' && 'w-[72px] !px-0 text-center md:w-auto md:px-2 md:text-left', - header.id === 'lifetime_used_traffic' && 'w-[44px] !px-0 text-center md:w-auto md:px-2 md:text-left', - !['select', 'username', 'used_traffic', 'lifetime_used_traffic', 'chevron'].includes(header.id) && 'hidden md:table-cell', - header.id === 'chevron' && 'w-4 !p-0 table-cell md:hidden', + header.id === 'status' && '!px-2', + header.id === 'used_traffic' && 'w-[104px] px-1 md:w-[290px] md:px-2 md:text-left', + header.id === 'lifetime_used_traffic' && 'hidden md:table-cell md:w-auto md:px-2 md:text-left', + !['select', 'username', 'status', 'used_traffic', 'chevron'].includes(header.id) && 'hidden md:table-cell', + header.id === 'chevron' && 'w-10 px-2 table-cell md:hidden', )} > {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} @@ -374,15 +421,17 @@ export function DataTable({ data-role={cell.column.id === 'select' ? 'row-selector' : undefined} className={cn( 'text-sm', - cell.column.id !== 'username' && 'whitespace-nowrap', - cell.column.id === 'select' && 'w-8 !px-1 !py-4', - cell.column.id === 'username' && 'max-w-[calc(100vw-32px-72px-44px-16px-56px)] !px-0', - cell.column.id === 'used_traffic' && '!px-0 text-center', - cell.column.id === 'lifetime_used_traffic' && '!px-0 text-center', - cell.column.id === 'chevron' && 'w-10', - !['select', 'username', 'used_traffic', 'lifetime_used_traffic', 'chevron'].includes(cell.column.id) && 'hidden !p-0 md:table-cell', + cell.column.id !== 'used_traffic' && 'whitespace-nowrap', + cell.column.id === 'used_traffic' && 'w-[104px] px-1 md:w-[290px] md:px-2 md:whitespace-nowrap', + cell.column.id !== 'used_traffic' && 'py-1.5', + cell.column.id === 'username' && 'max-w-[calc(100vw-32px-70px-104px-40px-56px)] !px-0', + cell.column.id === 'status' && '!px-2', + cell.column.id === 'select' && 'w-8 !px-1 !py-5', + cell.column.id === 'lifetime_used_traffic' && 'hidden md:table-cell md:w-auto md:px-2 md:text-left', + cell.column.id === 'chevron' && 'w-10 px-2', + !['select', 'username', 'status', 'used_traffic', 'chevron'].includes(cell.column.id) && 'hidden !p-0 md:table-cell', cell.column.id === 'chevron' && 'table-cell md:hidden', - !['select', 'username', 'used_traffic', 'lifetime_used_traffic', 'chevron'].includes(cell.column.id) && (isRTL ? 'pl-1.5 sm:pl-3' : 'pr-1.5 sm:pr-3'), + !['select', 'username', 'status', 'used_traffic', 'chevron'].includes(cell.column.id) && (isRTL ? 'pl-1.5 sm:pl-3' : 'pr-1.5 sm:pr-3'), )} > {cell.column.id === 'chevron' ? ( diff --git a/dashboard/src/features/admins/dialogs/admin-modal.tsx b/dashboard/src/features/admins/dialogs/admin-modal.tsx index 2ae95dc8a..cd66b7002 100644 --- a/dashboard/src/features/admins/dialogs/admin-modal.tsx +++ b/dashboard/src/features/admins/dialogs/admin-modal.tsx @@ -1,26 +1,64 @@ -import type { AdminFormValuesInput } from '@/features/admins/forms/admin-form' +import type { AdminFormValuesInput } from '@/features/admins/forms/admin-form' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { DecimalInput } from '@/components/common/decimal-input' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { LoaderButton } from '@/components/ui/loader-button' import { PasswordInput } from '@/components/ui/password-input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { VariablesPopover } from '@/components/ui/variables-popover' import useDynamicErrorHandler from '@/hooks/use-dynamic-errors.ts' -import { cn } from '@/lib/utils' -import { useCreateAdmin, useModifyAdminById } from '@/service/api' +import { useCreateAdmin, useGetRolesSimple, useModifyAdminById } from '@/service/api' +import type { RoleLimits } from '@/service/api' import { upsertAdminInAdminsCache } from '@/utils/adminsCache' +import { bytesToFormGigabytes, formatBytes, gbToBytes } from '@/utils/formatByte' import { useQueryClient } from '@tanstack/react-query' -import { ChevronDown, Pencil, UserCog } from 'lucide-react' -import { useEffect, useState } from 'react' -import { UseFormReturn } from 'react-hook-form' +import { Bell, IdCard, Pencil, Sliders, UserCog } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { UseFormReturn, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +const BUILTIN_ADMIN_ROLES = [ + { id: 2, name: 'administrator', is_owner: false }, + { id: 3, name: 'operator', is_owner: false }, +] +const normalizeOverrideValue = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return null +} + +const SECONDS_PER_DAY = 86_400 + +const normalizePermissionOverrides = (overrides: AdminFormValuesInput['permission_overrides']): RoleLimits => { + const minDays = normalizeOverrideValue(overrides?.expire_days_min) + const maxDays = normalizeOverrideValue(overrides?.expire_days_max) + return { + max_users: normalizeOverrideValue(overrides?.max_users), + data_limit_min: normalizeOverrideValue(overrides?.data_limit_min), + data_limit_max: normalizeOverrideValue(overrides?.data_limit_max), + expire_min: minDays === null ? null : Math.round(minDays * SECONDS_PER_DAY), + expire_max: maxDays === null ? null : Math.round(maxDays * SECONDS_PER_DAY), + min_hwid_per_user: normalizeOverrideValue(overrides?.min_hwid_per_user), + max_hwid_per_user: normalizeOverrideValue(overrides?.max_hwid_per_user), + } +} + +const normalizeDataLimit = (value: AdminFormValuesInput['data_limit']): number => { + const normalized = normalizeOverrideValue(value) + return normalized && normalized > 0 ? normalized : 0 +} +const ONE_GB_IN_BYTES = 1024 * 1024 * 1024 + interface AdminModalProps { isDialogOpen: boolean onOpenChange: (open: boolean) => void @@ -35,16 +73,55 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId, const queryClient = useQueryClient() const addAdminMutation = useCreateAdmin() const modifyAdminMutation = useModifyAdminById() + const rolesQuery = useGetRolesSimple() + const selectedRoleId = form.watch('role_id') + const roleOptions = useMemo(() => { + const rolesById = new Map() + BUILTIN_ADMIN_ROLES.forEach(role => rolesById.set(role.id, role)) + ; (rolesQuery.data?.roles || []).forEach(role => { + if (!role.is_owner && role.id !== 1) { + rolesById.set(role.id, role) + } + }) + + return Array.from(rolesById.values()).sort((a, b) => a.id - b.id) + }, [rolesQuery.data?.roles]) + const selectedRoleExists = selectedRoleId == null || roleOptions.some(role => role.id === selectedRoleId) useEffect(() => { - if (!isDialogOpen) setNotificationExpanded(false) + if (!isDialogOpen) { + setOpenSection(undefined) + } }, [isDialogOpen]) - // State for collapsible notification section - const [notificationExpanded, setNotificationExpanded] = useState(false) + // Accordion: only one section open at a time + const [openSection, setOpenSection] = useState(undefined) // Watch notification enable fields - const watchedNotificationEnable = form.watch('notification_enable') + const watchedNotificationEnable = useWatch({ control: form.control, name: 'notification_enable' }) + const watchedPermissionOverrides = useWatch({ control: form.control, name: 'permission_overrides' }) + const NOTIFICATION_KEYS = [ + 'create', + 'modify', + 'delete', + 'status_change', + 'reset_data_usage', + 'data_reset_by_next', + 'subscription_revoked', + ] as const + const notificationEnabledCount = useMemo( + () => NOTIFICATION_KEYS.reduce((sum, key) => sum + ((watchedNotificationEnable as any)?.[key] ? 1 : 0), 0), + [watchedNotificationEnable], + ) + const allNotificationsEnabled = notificationEnabledCount === NOTIFICATION_KEYS.length + const permissionOverridesCount = useMemo( + () => Object.values(watchedPermissionOverrides || {}).filter(value => value !== null && value !== undefined && value !== '').length, + [watchedPermissionOverrides], + ) + + const handleAccordionChange = (value: string) => { + setOpenSection(prev => (prev === value ? undefined : value)) + } // Ensure form is cleared when modal is closed const handleClose = (open: boolean) => { @@ -56,10 +133,19 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId, const onSubmit = async (values: AdminFormValuesInput) => { try { + const dataLimitChanged = !!form.formState.dirtyFields.data_limit + const dataLimitHasValue = values.data_limit !== null && values.data_limit !== undefined && values.data_limit !== '' + const dataLimitPayload = editingAdmin + ? dataLimitChanged + ? { data_limit: normalizeDataLimit(values.data_limit) } + : {} + : dataLimitHasValue + ? { data_limit: normalizeDataLimit(values.data_limit) } + : {} const editData = { - is_sudo: values.is_sudo ?? false, password: values.password || undefined, - is_disabled: values.is_disabled, + ...(form.formState.dirtyFields.status ? { status: values.status || 'active' } : {}), + ...dataLimitPayload, discord_webhook: values.discord_webhook, sub_domain: values.sub_domain, sub_template: values.sub_template, @@ -69,6 +155,8 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId, note: values.note, discord_id: values.discord_id, notification_enable: values.notification_enable || null, + role_id: values.role_id, + permission_overrides: normalizePermissionOverrides(values.permission_overrides), } if (editingAdmin && editingAdminId != null) { const updatedAdmin = await modifyAdminMutation.mutateAsync({ @@ -85,9 +173,21 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId, } else { if (!values.password) return const createData = { - ...values, - is_sudo: values.is_sudo ?? false, + username: values.username, password: values.password, // Ensure password is present + status: values.status || 'active', + ...dataLimitPayload, + discord_webhook: values.discord_webhook, + sub_domain: values.sub_domain, + sub_template: values.sub_template, + support_url: values.support_url, + telegram_id: values.telegram_id, + profile_title: values.profile_title, + note: values.note, + discord_id: values.discord_id, + notification_enable: values.notification_enable || null, + role_id: values.role_id, + permission_overrides: normalizePermissionOverrides(values.permission_overrides), } const createdAdmin = await addAdminMutation.mutateAsync({ data: createData, @@ -107,8 +207,9 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId, 'username', 'password', 'passwordConfirm', - 'is_sudo', - 'is_disabled', + 'role_id', + 'status', + 'data_limit', 'discord_webhook', 'sub_domain', 'sub_template', @@ -117,6 +218,7 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId, 'profile_title', 'note', 'discord_id', + 'permission_overrides', ] handleError({ error, fields, form, contextKey: 'admins' }) } @@ -124,7 +226,7 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId, return ( - e.preventDefault()}> + e.preventDefault()}> {editingAdmin ? : } @@ -134,8 +236,9 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId,
-
-
+
+ {/* Essentials: always visible */} +
- {t('admins.username')} + {t('admins.username')} @@ -154,23 +257,38 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId, /> { + const isOwnerAdmin = editingAdmin && selectedRoleId === 1 return ( - {t('admins.telegramId')} - - { - const value = e.target.value - field.onChange(value ? parseInt(value) : 0) - }} - value={field.value ? field.value : ''} - /> - + {t('admins.role')} + ) @@ -210,164 +328,195 @@ export default function AdminModal({ isDialogOpen, onOpenChange, editingAdminId, /> ( - - {t('admins.discordId')} - - { - const value = e.target.value - field.onChange(value ? parseInt(value) : 0) - }} - value={field.value ? field.value : ''} - /> - - - - )} - /> - ( - - {t('admins.discord')} - - - - - - )} - /> - ( - {t('admins.supportUrl')} - - - - - - )} - /> - ( - -
- {t('admins.profile')} - -
- - - - -
- )} - /> - ( - - {t('admins.subDomain')} - - - - - - )} - /> - ( - - {t('admins.subTemplate')} - - - - - - )} - /> - ( - - {t('fields.note')} - -