diff --git a/.github/workflows/build-dev.yml b/.github/workflows/build-dev.yml index 57381c852..59ad0c864 100644 --- a/.github/workflows/build-dev.yml +++ b/.github/workflows/build-dev.yml @@ -2,11 +2,9 @@ name: Build-Docker-dev on: push: - branches: - - "dev" - - "next" - - "nats-io" - + branches-ignore: + - main + permissions: contents: read packages: write diff --git a/app/models/user.py b/app/models/user.py index fb3055ad8..0c4b4eba8 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -245,7 +245,7 @@ def validate_username(cls, v): # Skip validation if username is None (for random strategy) if v is None: return v - return UserValidator.validate_username(v) + return UserValidator.validate_username(username=v, len_check=False) @model_validator(mode="after") def validate_username_strategy(self): diff --git a/app/node/__init__.py b/app/node/__init__.py index 2d555a792..c4621dddc 100644 --- a/app/node/__init__.py +++ b/app/node/__init__.py @@ -65,8 +65,8 @@ async def update_node(self, node: Node, max_message_size: int | None = None) -> self._nodes[node.id] = new_node - # Stop the old node in the background so we don't block callers. - asyncio.create_task(self._shutdown_node(old_node)) + # Stop the old node after releasing the lock. + await self._shutdown_node(old_node) return new_node diff --git a/app/operation/admin.py b/app/operation/admin.py index ee2e0ae2a..abc3b59fd 100644 --- a/app/operation/admin.py +++ b/app/operation/admin.py @@ -79,6 +79,9 @@ async def modify_admin( message="You're not allowed to modify sudoer's account. Use pasarguard cli / tui instead.", code=403 ) + if db_admin.username == current_admin.username and modified_admin.is_disabled is True: + await self.raise_error(message="You're not allowed to disable your own account.", code=403) + if modified_admin.telegram_id is not None: existing_admins = await find_admins_by_telegram_id( db, modified_admin.telegram_id, exclude_admin_id=db_admin.id, limit=1 diff --git a/app/operation/user.py b/app/operation/user.py index 364b2ae89..9b0010662 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -51,19 +51,18 @@ ModifyUserByTemplate, RemoveUsersResponse, UserCreate, - UserSimple, - UsersSimpleResponse, UserModify, UsernameGenerationStrategy, UserNotificationResponse, UserResponse, + UserSimple, UsersResponse, + UsersSimpleResponse, UserSubscriptionUpdateChart, UserSubscriptionUpdateChartSegment, UserSubscriptionUpdateList, ) -from app.node.sync import remove_user as sync_remove_user -from app.node.sync import sync_user, sync_users +from app.node.sync import remove_user as sync_remove_user, sync_user, sync_users from app.operation import BaseOperation, OperatorType from app.settings import subscription_settings from app.utils.jwt import create_subscription_token @@ -83,9 +82,9 @@ def _is_non_blocking_sync_operator(operator_type: OperatorType) -> bool: @staticmethod def _format_validation_errors(error: ValidationError) -> str: - return "; ".join( - [f"{'.'.join(str(loc_part) for loc_part in err['loc'])}: {err['msg']}" for err in error.errors()] - ) + return "; ".join([ + f"{'.'.join(str(loc_part) for loc_part in err['loc'])}: {err['msg']}" for err in error.errors() + ]) @staticmethod async def generate_subscription_url(user: UserNotificationResponse): @@ -105,7 +104,14 @@ async def _generate_usernames( count: int, strategy: UsernameGenerationStrategy, start_number: int | None = None, + username_prefix: str | None = None, + username_suffix: str | None = None, ) -> list[str]: + def _apply_affixes(candidate: str) -> str: + return ( + f"{username_prefix if username_prefix else ''}{candidate}{username_suffix if username_suffix else ''}" + ) + if count <= 0: await self.raise_error(message="count must be greater than zero", code=400) if start_number is not None and start_number < 0: @@ -125,7 +131,7 @@ async def _generate_usernames( attempts += 1 if attempts > max_attempts: await self.raise_error(message="unable to generate unique usernames", code=500) - candidate = secrets.token_hex(6) + candidate = _apply_affixes(secrets.token_hex(6)) if candidate in seen: continue seen.add(candidate) @@ -136,7 +142,13 @@ async def _generate_usernames( if not base_username: await self.raise_error(message="base username is required for sequence strategy", code=400) - prefix = base_username + sequence_base_username = _apply_affixes(base_username) + + if not (3 <= len(sequence_base_username) <= 128): + await self.raise_error( + message="base username with affixes must be between 3 and 128 characters", code=400 + ) + width = 0 inferred_start_number = 1 @@ -146,7 +158,7 @@ async def _generate_usernames( suffix = str(current) if width: suffix = suffix.zfill(width) - generated.append(f"{prefix}{suffix}") + generated.append(f"{sequence_base_username}{suffix}") current += 1 return generated @@ -620,16 +632,26 @@ def apply_settings(user_args: UserCreate | UserModify, template: UserTemplate) - return user_args - def _build_user_create_from_template( - self, user_template: UserTemplate, payload: CreateUserFromTemplate - ) -> UserCreate: - new_user_args = self.load_base_user_args(user_template) - new_user_args["username"] = ( + @staticmethod + def _apply_template_username_affixes(username: str, user_template: UserTemplate) -> str: + return ( f"{user_template.username_prefix if user_template.username_prefix else ''}" - f"{payload.username}" + f"{username}" f"{user_template.username_suffix if user_template.username_suffix else ''}" ) + def _build_user_create_from_template( + self, + user_template: UserTemplate, + payload: CreateUserFromTemplate, + apply_template_username_affixes: bool = True, + ) -> UserCreate: + new_user_args = self.load_base_user_args(user_template) + username = payload.username + if apply_template_username_affixes: + username = self._apply_template_username_affixes(username, user_template) + new_user_args["username"] = username + try: new_user = UserCreate(**new_user_args, note=payload.note) except ValidationError as e: @@ -711,6 +733,8 @@ async def bulk_create_users_from_template( count=bulk_users.count, strategy=bulk_users.strategy, start_number=bulk_users.start_number, + username_prefix=user_template.username_prefix, + username_suffix=user_template.username_suffix, ) def builder(username: str): @@ -719,7 +743,11 @@ def builder(username: str): user_template_id=template_payload.user_template_id, note=template_payload.note, ) - return self._build_user_create_from_template(user_template, payload) + return self._build_user_create_from_template( + user_template, + payload, + apply_template_username_affixes=False, + ) users_to_create = self._build_bulk_user_models(candidate_usernames, builder) diff --git a/app/telegram/__init__.py b/app/telegram/__init__.py index 3985a84d1..ea5f43aff 100644 --- a/app/telegram/__init__.py +++ b/app/telegram/__init__.py @@ -7,17 +7,19 @@ from aiogram.client.session.aiohttp import AiohttpSession from aiogram.enums import ParseMode from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError, TelegramRetryAfter, TelegramUnauthorizedError +from aiogram.fsm.storage.memory import MemoryStorage from nats.js.kv import KeyValue from python_socks._errors import ProxyConnectionError from app import on_shutdown, on_startup from app.models.settings import RunMethod, Telegram -from app.settings import telegram_settings -from app.utils.logger import get_logger from app.nats import is_nats_enabled from app.nats.client import setup_nats_kv +from app.settings import telegram_settings +from app.utils.logger import get_logger from config import NATS_TELEGRAM_KV_BUCKET +from .fsm_storage import NatsFSMStorage from .handlers import include_routers from .middlewares import setup_middlewares @@ -29,7 +31,7 @@ def __init__(self): self._bot: Bot | None = None self._polling_task: asyncio.Task | None = None self._lock = Lock() - self._dp = Dispatcher() + self._dp = self._create_dispatcher() self._handlers_registered = False self._shutdown_in_progress = False self._stop_requested = False @@ -37,6 +39,13 @@ def __init__(self): self._kv: KeyValue | None = None self._nats_conn = None + @staticmethod + def _create_dispatcher() -> Dispatcher: + if is_nats_enabled(): + storage = NatsFSMStorage(NATS_TELEGRAM_KV_BUCKET) + return Dispatcher(storage=storage, events_isolation=storage.create_isolation()) + return Dispatcher(storage=MemoryStorage()) + def get_bot(self) -> Bot | None: return self._bot @@ -132,6 +141,10 @@ async def shutdown(self): async with self._lock: self._stop_requested = True await self._shutdown_locked() + try: + await self._dp.fsm.close() + except Exception: + pass # Close NATS KV connection if one was opened if self._nats_conn: try: diff --git a/app/telegram/fsm_storage.py b/app/telegram/fsm_storage.py new file mode 100644 index 000000000..9306333cf --- /dev/null +++ b/app/telegram/fsm_storage.py @@ -0,0 +1,371 @@ +import asyncio +import hashlib +import json +import time +import uuid +from collections import defaultdict +from collections.abc import AsyncGenerator, Callable, Mapping +from contextlib import asynccontextmanager +from typing import Any, cast + +import nats.js.errors as nats_js_errors +from aiogram.exceptions import DataNotDictLikeError +from aiogram.fsm.state import State +from aiogram.fsm.storage.base import ( + BaseEventIsolation, + BaseStorage, + DefaultKeyBuilder, + KeyBuilder, + StateType, + StorageKey, +) +from aiogram.fsm.storage.memory import MemoryStorage +from nats.js.kv import KeyValue + +from app.nats import is_nats_enabled +from app.nats.client import setup_nats_kv +from app.utils.logger import get_logger + +logger = get_logger("telegram-fsm") + +DEFAULT_LOCK_TTL_SECONDS = 60.0 +DEFAULT_LOCK_RETRY_DELAY_SECONDS = 0.05 +DEFAULT_CONNECT_RETRY_BACKOFF_SECONDS = 10.0 + +_JsonLoads = Callable[..., Any] +_JsonDumps = Callable[..., str] + + +class NatsFSMStorage(BaseStorage): + """ + Aiogram FSM storage backed by NATS KV with in-memory fallback. + + Data model follows aiogram's built-in storages: + - state and data are stored as separate records + - event isolation is delegated to NatsEventIsolation + """ + + def __init__( + self, + bucket_name: str, + key_builder: KeyBuilder | None = None, + json_loads: _JsonLoads = json.loads, + json_dumps: _JsonDumps = json.dumps, + key_prefix: str = "fsm", + ) -> None: + if key_builder is None: + key_builder = DefaultKeyBuilder( + prefix="fsm", + with_bot_id=True, + with_business_connection_id=True, + with_destiny=True, + ) + + self._memory = MemoryStorage() + self._bucket_name = bucket_name + self._key_prefix = key_prefix + self.key_builder = key_builder + self.json_loads = json_loads + self.json_dumps = json_dumps + + self._nc = None + self._kv: KeyValue | None = None + self._connect_lock = asyncio.Lock() + self._next_connect_try_at = 0.0 + + self._nats_enabled = is_nats_enabled() + + def create_isolation( + self, + lock_ttl: float = DEFAULT_LOCK_TTL_SECONDS, + retry_delay: float = DEFAULT_LOCK_RETRY_DELAY_SECONDS, + ) -> "NatsEventIsolation": + return NatsEventIsolation( + storage=self, + key_builder=self.key_builder, + lock_ttl=lock_ttl, + retry_delay=retry_delay, + ) + + @staticmethod + def _normalize_state(state: StateType = None) -> str | None: + return cast(str | None, state.state if isinstance(state, State) else state) + + def _to_nats_key(self, raw_key: str, part: str) -> str: + digest = hashlib.sha256(raw_key.encode()).hexdigest() + return f"{self._key_prefix}.{part}.{digest}" + + def build_kv_key(self, key: StorageKey, part: str, key_builder: KeyBuilder | None = None) -> str: + builder = key_builder or self.key_builder + raw_key = builder.build(key, part) + return self._to_nats_key(raw_key, part) + + async def ensure_kv(self) -> KeyValue | None: + if not self._nats_enabled: + return None + + if self._kv: + return self._kv + + now = time.monotonic() + if now < self._next_connect_try_at: + return None + + async with self._connect_lock: + if self._kv: + return self._kv + + now = time.monotonic() + if now < self._next_connect_try_at: + return None + + try: + self._nc, _, self._kv = await setup_nats_kv(self._bucket_name) + except Exception as exc: + logger.warning(f"Failed to initialize NATS KV for Telegram FSM: {exc}") + self._kv = None + + if self._kv: + self._next_connect_try_at = 0.0 + return self._kv + + self._next_connect_try_at = time.monotonic() + DEFAULT_CONNECT_RETRY_BACKOFF_SECONDS + logger.warning("NATS KV unavailable for Telegram FSM, using in-memory fallback") + return None + + async def _safe_get(self, kv_key: str) -> KeyValue.Entry | None: + kv = await self.ensure_kv() + if not kv: + return None + + try: + return await kv.get(kv_key) + except (nats_js_errors.KeyNotFoundError, nats_js_errors.KeyDeletedError): + return None + except Exception as exc: + logger.warning(f"Failed to read Telegram FSM record from NATS KV: {exc}") + return None + + async def set_state(self, key: StorageKey, state: StateType = None) -> None: + normalized_state = self._normalize_state(state) + await self._memory.set_state(key, normalized_state) + + kv = await self.ensure_kv() + if not kv: + return + + kv_key = self.build_kv_key(key, "state") + if normalized_state is None: + try: + await kv.delete(kv_key) + except Exception: + pass + return + + try: + await kv.put(kv_key, normalized_state.encode("utf-8")) + except Exception as exc: + logger.warning(f"Failed to write Telegram FSM state to NATS KV: {exc}") + + async def get_state(self, key: StorageKey) -> str | None: + entry = await self._safe_get(self.build_kv_key(key, "state")) + if entry and entry.value is not None: + try: + value = entry.value.decode("utf-8") + except Exception as exc: + logger.warning(f"Failed to decode Telegram FSM state from NATS KV: {exc}") + else: + await self._memory.set_state(key, value) + return value + + return await self._memory.get_state(key) + + async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: + if not isinstance(data, dict): + msg = f"Data must be a dict or dict-like object, got {type(data).__name__}" + raise DataNotDictLikeError(msg) + + normalized_data = data.copy() + await self._memory.set_data(key, normalized_data) + + kv = await self.ensure_kv() + if not kv: + return + + kv_key = self.build_kv_key(key, "data") + if not normalized_data: + try: + await kv.delete(kv_key) + except Exception: + pass + return + + try: + payload = self.json_dumps(normalized_data) + if isinstance(payload, bytes): + encoded = payload + else: + encoded = payload.encode("utf-8") + except TypeError: + logger.warning("Telegram FSM data is not JSON-serializable; skipped NATS KV sync") + return + + try: + await kv.put(kv_key, encoded) + except Exception as exc: + logger.warning(f"Failed to write Telegram FSM data to NATS KV: {exc}") + + async def get_data(self, key: StorageKey) -> dict[str, Any]: + entry = await self._safe_get(self.build_kv_key(key, "data")) + if entry and entry.value is not None: + try: + raw_value = entry.value.decode("utf-8") + payload = self.json_loads(raw_value) + except Exception as exc: + logger.warning(f"Failed to decode Telegram FSM data from NATS KV: {exc}") + else: + if isinstance(payload, dict): + await self._memory.set_data(key, payload) + return payload.copy() + + logger.warning("Invalid Telegram FSM data payload in NATS KV, expected dict") + + return await self._memory.get_data(key) + + async def close(self) -> None: + await self._memory.close() + + if self._nc: + try: + await self._nc.close() + except Exception: + pass + + self._nc = None + self._kv = None + self._next_connect_try_at = 0.0 + + +class NatsEventIsolation(BaseEventIsolation): + def __init__( + self, + storage: NatsFSMStorage, + key_builder: KeyBuilder | None = None, + lock_ttl: float = DEFAULT_LOCK_TTL_SECONDS, + retry_delay: float = DEFAULT_LOCK_RETRY_DELAY_SECONDS, + ) -> None: + if key_builder is None: + key_builder = storage.key_builder + + self.storage = storage + self.key_builder = key_builder + self.lock_ttl = lock_ttl + self.retry_delay = retry_delay + self._local_locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + + @staticmethod + def _build_lock_payload(token: str, expires_at: float) -> bytes: + return json.dumps({"token": token, "expires_at": expires_at}).encode("utf-8") + + @staticmethod + def _parse_lock_payload(value: bytes | None) -> tuple[str, float] | None: + if not value: + return None + + try: + payload = json.loads(value.decode("utf-8")) + except Exception: + return None + + token = payload.get("token") + expires_at = payload.get("expires_at") + + if not isinstance(token, str): + return None + + try: + expires_at_value = float(expires_at) + except (TypeError, ValueError): + return None + + return token, expires_at_value + + async def _acquire_distributed_lock(self, kv: KeyValue, lock_key: str) -> str: + token = uuid.uuid4().hex + + while True: + now = time.time() + payload = self._build_lock_payload(token, now + self.lock_ttl) + + try: + await kv.create(lock_key, payload) + return token + except nats_js_errors.KeyWrongLastSequenceError: + pass + except Exception as exc: + logger.warning(f"Failed to create Telegram FSM lock in NATS KV: {exc}") + await asyncio.sleep(self.retry_delay) + continue + + try: + entry = await kv.get(lock_key) + except (nats_js_errors.KeyNotFoundError, nats_js_errors.KeyDeletedError): + await asyncio.sleep(self.retry_delay) + continue + except Exception as exc: + logger.warning(f"Failed to read Telegram FSM lock from NATS KV: {exc}") + await asyncio.sleep(self.retry_delay) + continue + + lock_info = self._parse_lock_payload(entry.value) + is_expired = lock_info is None or lock_info[1] <= now + if is_expired: + try: + await kv.update(lock_key, payload, last=entry.revision) + return token + except nats_js_errors.KeyWrongLastSequenceError: + pass + except Exception as exc: + logger.warning(f"Failed to steal expired Telegram FSM lock in NATS KV: {exc}") + + await asyncio.sleep(self.retry_delay) + + async def _release_distributed_lock(self, kv: KeyValue, lock_key: str, token: str) -> None: + try: + entry = await kv.get(lock_key) + except (nats_js_errors.KeyNotFoundError, nats_js_errors.KeyDeletedError): + return + except Exception as exc: + logger.warning(f"Failed to read Telegram FSM lock for release from NATS KV: {exc}") + return + + lock_info = self._parse_lock_payload(entry.value) + if not lock_info or lock_info[0] != token: + return + + try: + await kv.delete(lock_key, last=entry.revision) + except nats_js_errors.KeyWrongLastSequenceError: + pass + except Exception as exc: + logger.warning(f"Failed to release Telegram FSM lock in NATS KV: {exc}") + + @asynccontextmanager + async def lock(self, key: StorageKey) -> AsyncGenerator[None, None]: + lock_key = self.storage.build_kv_key(key, "lock", key_builder=self.key_builder) + kv = await self.storage.ensure_kv() + + if not kv: + local_lock = self._local_locks[lock_key] + async with local_lock: + yield + return + + token = await self._acquire_distributed_lock(kv, lock_key) + try: + yield + finally: + await self._release_distributed_lock(kv, lock_key, token) + + async def close(self) -> None: + self._local_locks.clear() diff --git a/dashboard/bun.lock b/dashboard/bun.lock index 588290d33..524fb5ccf 100644 --- a/dashboard/bun.lock +++ b/dashboard/bun.lock @@ -39,6 +39,7 @@ "@tanstack/react-table": "^8.21.3", "@telegram-apps/sdk": "^3.11.8", "@vitejs/plugin-react": "^4.7.0", + "ace-builds": "^1.43.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -61,6 +62,7 @@ "ofetch": "^1.5.1", "qrcode.react": "^3.2.0", "react": "^18.3.1", + "react-ace": "^14.0.1", "react-day-picker": "9.7.0", "react-dom": "^18.3.1", "react-github-btn": "^1.4.0", @@ -850,6 +852,8 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "ace-builds": ["ace-builds@1.43.6", "", {}, "sha512-L1ddibQ7F3vyXR2k2fg+I8TQTPWVA6CKeDQr/h2+8CeyTp3W6EQL8xNFZRTztuP8xNOAqL3IYPqdzs31GCjDvg=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -1062,6 +1066,8 @@ "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], @@ -1434,6 +1440,8 @@ "lodash.isempty": ["lodash.isempty@4.4.0", "", {}, "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg=="], + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.omit": ["lodash.omit@4.5.0", "", {}, "sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg=="], @@ -1652,6 +1660,8 @@ "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react-ace": ["react-ace@14.0.1", "", { "dependencies": { "ace-builds": "^1.36.3", "diff-match-patch": "^1.0.5", "lodash.get": "^4.4.2", "lodash.isequal": "^4.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-z6YAZ20PNf/FqmYEic//G/UK6uw0rn21g58ASgHJHl9rfE4nITQLqthr9rHMVQK4ezwohJbp2dGrZpkq979PYQ=="], + "react-day-picker": ["react-day-picker@9.7.0", "", { "dependencies": { "@date-fns/tz": "1.2.0", "date-fns": "4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-urlK4C9XJZVpQ81tmVgd2O7lZ0VQldZeHzNejbwLWZSkzHH498KnArT0EHNfKBOWwKc935iMLGZdxXPRISzUxQ=="], "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], diff --git a/dashboard/package.json b/dashboard/package.json index 3dce54482..398a5ed45 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -16,6 +16,7 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", + "@noble/post-quantum": "^0.5.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", @@ -40,11 +41,11 @@ "@radix-ui/react-tooltip": "^1.2.8", "@stablelib/base64": "^2.0.1", "@stablelib/x25519": "^2.0.1", - "@noble/post-quantum": "^0.5.2", "@tanstack/react-query": "^5.90.7", "@tanstack/react-table": "^8.21.3", "@telegram-apps/sdk": "^3.11.8", "@vitejs/plugin-react": "^4.7.0", + "ace-builds": "^1.43.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -67,6 +68,7 @@ "ofetch": "^1.5.1", "qrcode.react": "^3.2.0", "react": "^18.3.1", + "react-ace": "^14.0.1", "react-day-picker": "9.7.0", "react-dom": "^18.3.1", "react-github-btn": "^1.4.0", diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index edf8d42d5..558bcd743 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -1589,7 +1589,14 @@ "datePickerPreferenceSaved": "Date picker preference saved", "datePickerModeLocale": "Locale default", "datePickerModeGregorian": "Gregorian", - "datePickerModePersian": "Jalali" + "datePickerModePersian": "Jalali", + "chartViewType": "Chart view", + "chartViewDescription": "Choose the default chart style for traffic charts.", + "chartViewBar": "Bars", + "chartViewArea": "Area", + "chartViewBarDescription": "Display traffic as bars.", + "chartViewAreaDescription": "Display traffic as area charts.", + "chartViewSaved": "Chart view preference saved" }, "coreConfigModal": { "addConfig": "Create Core Configuration", @@ -1987,6 +1994,7 @@ "title": "Advanced Search", "byUsername": "Username and Notes", "byProtocol": "Protocol Data", + "showCreatedBy": "Show created by", "byStatus": "Status", "byAdmin": "Admin", "byGroup": "Group", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 22941db55..5ed20abdf 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -1,4 +1,4 @@ -{ +{ "pasarguard": "پاسارگارد", "dashboard": "داشبورد", "master": "همه گره‌ها", @@ -1508,7 +1508,14 @@ "datePickerPreferenceSaved": "ترجیحات تقویم ذخیره شد", "datePickerModeLocale": "پیش‌فرض بر اساس زبان", "datePickerModeGregorian": "میلادی", - "datePickerModePersian": "جلالی" + "datePickerModePersian": "جلالی", + "chartViewType": "نوع نمایش نمودار", + "chartViewDescription": "سبک پیش‌فرض نمودار برای نمودارهای ترافیک را انتخاب کنید.", + "chartViewBar": "میله‌ای", + "chartViewArea": "ناحیه‌ای", + "chartViewBarDescription": "نمایش ترافیک به‌صورت نمودار میله‌ای.", + "chartViewAreaDescription": "نمایش ترافیک به‌صورت نمودار ناحیه‌ای.", + "chartViewSaved": "ترجیح نمایش نمودار ذخیره شد" }, "coreConfigModal": { "addConfig": "افزودن پیکربندی هسته", @@ -1961,6 +1968,7 @@ "title": "جستجوی پیشرفته", "byUsername": "نام کاربری و یادداشت‌ها", "byProtocol": "داده‌های پروتکل", + "showCreatedBy": "نمایش ایجاد شده توسط", "byStatus": "وضعیت", "byAdmin": "مدیر", "byGroup": "گروه", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 25b67ae2d..1e6370eeb 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1487,7 +1487,14 @@ "datePickerPreferenceSaved": "Предпочтение календаря сохранено", "datePickerModeLocale": "По языку", "datePickerModeGregorian": "Григорианский", - "datePickerModePersian": "Джалали" + "datePickerModePersian": "Джалали", + "chartViewType": "Вид диаграммы", + "chartViewDescription": "Выберите стиль диаграмм по умолчанию для графиков трафика.", + "chartViewBar": "Столбцы", + "chartViewArea": "Область", + "chartViewBarDescription": "Показывать трафик в виде столбцов.", + "chartViewAreaDescription": "Показывать трафик в виде диаграммы с областью.", + "chartViewSaved": "Предпочтение вида диаграммы сохранено" }, "coreConfigModal": { "addConfig": "Добавить конфигурацию ядра", @@ -1929,6 +1936,7 @@ "title": "Расширенный поиск", "byUsername": "Имя пользователя и заметки", "byProtocol": "Данные протокола", + "showCreatedBy": "Показать создателя", "byStatus": "Статус", "byAdmin": "Администратор", "byGroup": "Группа", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index d0888ce02..6dace73b1 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -1550,7 +1550,14 @@ "datePickerPreferenceSaved": "日期选择器偏好已保存", "datePickerModeLocale": "语言默认", "datePickerModeGregorian": "公历", - "datePickerModePersian": "贾拉里历" + "datePickerModePersian": "贾拉里历", + "chartViewType": "图表视图", + "chartViewDescription": "选择流量图表的默认显示样式。", + "chartViewBar": "柱状图", + "chartViewArea": "面积图", + "chartViewBarDescription": "以柱状图显示流量。", + "chartViewAreaDescription": "以面积图显示流量。", + "chartViewSaved": "图表视图偏好已保存" }, "coreConfigModal": { "addConfig": "添加核心配置", @@ -1984,6 +1991,7 @@ "title": "高级搜索", "byUsername": "用户名和备注", "byProtocol": "协议数据", + "showCreatedBy": "显示创建者", "byStatus": "状态", "byAdmin": "管理员", "byGroup": "分组", diff --git a/dashboard/src/components/admins/admins-table.tsx b/dashboard/src/components/admins/admins-table.tsx index 2dd5929ea..1af2678bd 100644 --- a/dashboard/src/components/admins/admins-table.tsx +++ b/dashboard/src/components/admins/admins-table.tsx @@ -13,6 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox.tsx' import { getAdminsPerPageLimitSize, setAdminsPerPageLimitSize } from '@/utils/userPreferenceStorage' import { toast } from 'sonner' import { queryClient } from '@/utils/query-client' +import { useAdmin } from '@/hooks/use-admin' interface AdminFilters { sort?: string @@ -76,7 +77,7 @@ const ToggleAdminStatusModal = ({ admin, isOpen, onClose, onConfirm }: { admin: - + {t('cancel')} onConfirm(adminUsersToggle)}>{t('confirm')} @@ -170,6 +171,7 @@ const BulkUsersStatusConfirmationDialog = ({ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetUsage, onTotalAdminsChange }: AdminsTableProps) { const { t } = useTranslation() + const { admin: currentAdmin } = useAdmin() const [currentPage, setCurrentPage] = useState(0) const [itemsPerPage, setItemsPerPage] = useState(getAdminsPerPageLimitSize()) const [isChangingPage, setIsChangingPage] = useState(false) @@ -418,6 +420,7 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU t, handleSort, filters, + currentAdminUsername: currentAdmin?.username, onEdit, onDelete: handleDeleteClick, toggleStatus: handleStatusToggleClick, @@ -443,6 +446,7 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU onDisableAllActiveUsers={handleDisableAllActiveUsersClick} onActivateAllDisabledUsers={handleActivateAllDisabledUsersClick} onRemoveAllUsers={handleRemoveAllUsersClick} + currentAdminUsername={currentAdmin?.username} setStatusToggleDialogOpen={setStatusToggleDialogOpen} isLoading={isCurrentlyLoading && isFirstLoadRef.current} isFetching={isFetching && !isFirstLoadRef.current && !isAutoRefreshingRef.current} diff --git a/dashboard/src/components/admins/columns.tsx b/dashboard/src/components/admins/columns.tsx index 77f24d323..677dd7c7b 100644 --- a/dashboard/src/components/admins/columns.tsx +++ b/dashboard/src/components/admins/columns.tsx @@ -10,6 +10,7 @@ interface ColumnSetupProps { t: (key: string) => string handleSort: (column: string) => void filters: { sort?: string } + currentAdminUsername?: string onEdit: (admin: AdminDetails) => void onDelete: (admin: AdminDetails) => void toggleStatus: (admin: AdminDetails) => void @@ -48,6 +49,7 @@ export const setupColumns = ({ t, handleSort, filters, + currentAdminUsername, onEdit, onDelete, toggleStatus, @@ -130,92 +132,106 @@ export const setupColumns = ({ }, { id: 'actions', - cell: ({ row }) => ( -
- - - - - - { - e.preventDefault() - e.stopPropagation() - onEdit(row.original) - }} - > - - {t('edit')} - - - { - e.preventDefault() - e.stopPropagation() - toggleStatus(row.original) - }} - > - {row.original.is_disabled ? : } - {row.original.is_disabled ? t('enable') : t('disable')} - - { - e.preventDefault() - e.stopPropagation() - onResetUsage(row.original.username) - }} - > - - {t('admins.reset')} - - { - e.preventDefault() - e.stopPropagation() - onDisableAllActiveUsers(row.original.username) - }} - > - - {t('admins.disableAllActiveUsers')} - - { - e.preventDefault() - e.stopPropagation() - onActivateAllDisabledUsers(row.original.username) - }} - > - - {t('admins.activateAllDisabledUsers')} - - { - e.preventDefault() - e.stopPropagation() - onRemoveAllUsers(row.original.username) - }} - > - - {t('admins.removeAllUsers')} - - { - e.preventDefault() - e.stopPropagation() - onDelete(row.original) - }} - > - - {t('delete')} - - - -
- ), + cell: ({ row }) => { + const isSudoTarget = row.original.is_sudo + + return ( +
+ + + + + + { + e.preventDefault() + e.stopPropagation() + onEdit(row.original) + }} + > + + {t('edit')} + + + { + e.preventDefault() + e.stopPropagation() + onResetUsage(row.original.username) + }} + > + + {t('admins.reset')} + + {!isSudoTarget && ( + { + e.preventDefault() + e.stopPropagation() + toggleStatus(row.original) + }} + > + {row.original.is_disabled ? : } + {row.original.is_disabled ? t('enable') : t('disable')} + + )} + {!isSudoTarget && ( + { + e.preventDefault() + e.stopPropagation() + onDisableAllActiveUsers(row.original.username) + }} + > + + {t('admins.disableAllActiveUsers')} + + )} + {!isSudoTarget && ( + { + e.preventDefault() + e.stopPropagation() + onActivateAllDisabledUsers(row.original.username) + }} + > + + {t('admins.activateAllDisabledUsers')} + + )} + {!isSudoTarget && ( + { + e.preventDefault() + e.stopPropagation() + onRemoveAllUsers(row.original.username) + }} + > + + {t('admins.removeAllUsers')} + + )} + {!isSudoTarget && row.original.username !== currentAdminUsername && ( + { + e.preventDefault() + e.stopPropagation() + onDelete(row.original) + }} + > + + {t('delete')} + + )} + + +
+ ) + }, }, { id: 'chevron', diff --git a/dashboard/src/components/admins/data-table.tsx b/dashboard/src/components/admins/data-table.tsx index a7ae27a27..d81903b26 100644 --- a/dashboard/src/components/admins/data-table.tsx +++ b/dashboard/src/components/admins/data-table.tsx @@ -34,6 +34,7 @@ import { interface DataTableProps { columns: ColumnDef[] data: TData[] + currentAdminUsername?: string onEdit: (admin: AdminDetails) => void onDelete: (admin: AdminDetails) => void onToggleStatus: (admin: AdminDetails) => void @@ -56,6 +57,7 @@ const ExpandedRowContent = memo( onDisableAllActiveUsers, onActivateAllDisabledUsers, onRemoveAllUsers, + currentAdminUsername, }: { row: AdminDetails onEdit: (admin: AdminDetails) => void @@ -65,10 +67,12 @@ const ExpandedRowContent = memo( onDisableAllActiveUsers?: (adminUsername: string) => void onActivateAllDisabledUsers?: (adminUsername: string) => void onRemoveAllUsers?: (adminUsername: string) => void + currentAdminUsername?: string }) => { const { t } = useTranslation() const isMobile = useIsMobile() const isSudo = row.is_sudo + const isSudoTarget = row.is_sudo return (
@@ -101,16 +105,18 @@ const ExpandedRowContent = memo( - { - e.preventDefault() - e.stopPropagation() - onToggleStatus(row) - }} - > - {row.is_disabled ? : } - {row.is_disabled ? t('enable') : t('disable')} - + {!isSudoTarget && row.username !== currentAdminUsername && ( + { + e.preventDefault() + e.stopPropagation() + onToggleStatus(row) + }} + > + {row.is_disabled ? : } + {row.is_disabled ? t('enable') : t('disable')} + + )} { e.preventDefault() @@ -121,7 +127,7 @@ const ExpandedRowContent = memo( {t('admins.reset')} - {onDisableAllActiveUsers && + {!isSudoTarget && onDisableAllActiveUsers && { e.preventDefault() @@ -133,7 +139,7 @@ const ExpandedRowContent = memo( {t('admins.disableAllActiveUsers')} } - {onActivateAllDisabledUsers && + {!isSudoTarget && onActivateAllDisabledUsers && { e.preventDefault() @@ -145,7 +151,7 @@ const ExpandedRowContent = memo( {t('admins.activateAllDisabledUsers')} } - {onRemoveAllUsers && + {!isSudoTarget && onRemoveAllUsers && { @@ -158,17 +164,19 @@ const ExpandedRowContent = memo( {t('admins.removeAllUsers')} } - { - e.preventDefault() - e.stopPropagation() - onDelete(row) - }} - > - - {t('delete')} - + {!isSudoTarget && row.username !== currentAdminUsername && ( + { + e.preventDefault() + e.stopPropagation() + onDelete(row) + }} + > + + {t('delete')} + + )}
@@ -180,6 +188,7 @@ const ExpandedRowContent = memo( export function DataTable({ columns, data, + currentAdminUsername, onEdit, onDelete, onToggleStatus, @@ -321,6 +330,7 @@ export function DataTable({ onDisableAllActiveUsers={onDisableAllActiveUsers} onActivateAllDisabledUsers={onActivateAllDisabledUsers} onRemoveAllUsers={onRemoveAllUsers} + currentAdminUsername={currentAdminUsername} /> diff --git a/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx b/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx index a7d3e6c2e..26272507c 100644 --- a/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx +++ b/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx @@ -1,10 +1,11 @@ import { useEffect, useState, useMemo, useCallback, useRef } from 'react' -import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis, TooltipProps } from 'recharts' +import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis, TooltipProps } from 'recharts' import { DateRange } from 'react-day-picker' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { type ChartConfig, ChartContainer, ChartTooltip } from '@/components/ui/chart' import { useTranslation } from 'react-i18next' import useDirDetection from '@/hooks/use-dir-detection' +import { useChartViewType } from '@/hooks/use-chart-view-type' import { Period, type NodeUsageStat, type UserUsageStat, useGetAdminUsage, useGetNodesSimple, type NodeSimple, useGetUsage } from '@/service/api' import { formatBytes, formatGigabytes } from '@/utils/formatByte' import { Skeleton } from '@/components/ui/skeleton' @@ -193,6 +194,7 @@ export function AllNodesStackedBarChart() { const { t, i18n } = useTranslation() const dir = useDirDetection() + const chartViewType = useChartViewType() const { data: nodesResponse } = useGetNodesSimple({ all: true }, { query: { enabled: true } }) const { resolvedTheme } = useTheme() const shouldUseNodeUsage = selectedAdmin === 'all' @@ -439,6 +441,30 @@ export function AllNodesStackedBarChart() { } }, []) + const handleChartPointClick = useCallback( + (data: any) => { + const clickedIndex = typeof data?.activeTooltipIndex === 'number' ? data.activeTooltipIndex : -1 + const clickedData = (data?.activePayload?.[0]?.payload ?? (clickedIndex >= 0 ? chartData[clickedIndex] : undefined)) as NodeChartDataPoint | undefined + if (!clickedData) return + + const activeNodesCount = Object.keys(clickedData).filter(key => { + if (key.startsWith('_') || key === 'time' || key === '_period_start') return false + const usageValue = Number(clickedData[key] || 0) + const uplinkValue = Number(clickedData[`_uplink_${key}`] || 0) + const downlinkValue = Number(clickedData[`_downlink_${key}`] || 0) + return usageValue > 0 || uplinkValue > 0 || downlinkValue > 0 + }).length + + if (activeNodesCount > 0) { + const resolvedIndex = clickedIndex >= 0 ? clickedIndex : chartData.findIndex(item => item._period_start === clickedData._period_start) + setCurrentDataIndex(resolvedIndex >= 0 ? resolvedIndex : 0) + setSelectedData(clickedData) + setModalOpen(true) + } + }, + [chartData], + ) + return ( <> @@ -471,7 +497,7 @@ export function AllNodesStackedBarChart() {
-
+
{formatBytes(memory.used, 1, false, false, 'GB')}/{formatBytes(memory.total, 1, true, false, 'GB')} - ({memoryPercent.toFixed(1)}%) + + {memoryPercent.toFixed(1)}% +
@@ -218,13 +220,15 @@ const DashboardStatistics = ({ systemData }: { systemData: SystemStats | undefin
-
+
{formatBytes(disk.used, 1, false, false, 'GB')}/{formatBytes(disk.total, 1, true, false, 'GB')} - ({diskPercent.toFixed(1)}%) + + {diskPercent.toFixed(1)}% +
@@ -254,7 +258,7 @@ const DashboardStatistics = ({ systemData }: { systemData: SystemStats | undefin
- + {formatBytes(getTotalTrafficValue() || 0, 1)}
diff --git a/dashboard/src/components/dashboard/data-usage-chart.tsx b/dashboard/src/components/dashboard/data-usage-chart.tsx index 03a23b38d..3d4d7a724 100644 --- a/dashboard/src/components/dashboard/data-usage-chart.tsx +++ b/dashboard/src/components/dashboard/data-usage-chart.tsx @@ -1,4 +1,4 @@ -import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell, TooltipProps } from 'recharts' +import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell, TooltipProps } from 'recharts' import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '../ui/card' import { ChartConfig, ChartContainer, ChartTooltip } from '../ui/chart' import { formatBytes } from '@/utils/formatByte' @@ -9,6 +9,7 @@ import { SearchXIcon, TrendingUp, TrendingDown } from 'lucide-react' import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '../ui/select' import { useAdmin } from '@/hooks/use-admin' import useDirDetection from '@/hooks/use-dir-detection' +import { useChartViewType } from '@/hooks/use-chart-view-type' import { formatPeriodLabelForPeriod, formatTooltipDate, getChartQueryRangeFromShortcut, getXAxisIntervalForShortcut } from '@/utils/chart-period-utils' type PeriodOption = { @@ -96,6 +97,7 @@ const DataUsageChart = ({ admin_username }: { admin_username?: string }) => { const { t, i18n } = useTranslation() const { admin } = useAdmin() const dir = useDirDetection() + const chartViewType = useChartViewType() const is_sudo = admin?.is_sudo || false const [activeIndex, setActiveIndex] = useState(null) const PERIOD_OPTIONS: PeriodOption[] = useMemo( @@ -252,41 +254,89 @@ const DataUsageChart = ({ admin_username }: { admin_username?: string }) => {
) : ( - { - if (state.activeTooltipIndex !== activeIndex) { - setActiveIndex(state.activeTooltipIndex !== undefined ? state.activeTooltipIndex : null) - } - }} - onMouseLeave={() => { - setActiveIndex(null) - }} - > - - value || ''} - /> - formatBytes(val, 0, true).toString()} tick={{ fontSize: 10 }} /> - } /> - - {chartData.map((_, index: number) => ( - - ))} - - + {chartViewType === 'area' ? ( + { + if (state.activeTooltipIndex !== activeIndex) { + setActiveIndex(state.activeTooltipIndex !== undefined ? state.activeTooltipIndex : null) + } + }} + onMouseLeave={() => { + setActiveIndex(null) + }} + > + + + + + + + + value || ''} + /> + formatBytes(val, 0, true).toString()} + tick={{ fontSize: 10 }} + /> + } /> + + + ) : ( + { + if (state.activeTooltipIndex !== activeIndex) { + setActiveIndex(state.activeTooltipIndex !== undefined ? state.activeTooltipIndex : null) + } + }} + onMouseLeave={() => { + setActiveIndex(null) + }} + > + + value || ''} + /> + formatBytes(val, 0, true).toString()} tick={{ fontSize: 10 }} /> + } /> + + {chartData.map((_, index: number) => ( + + ))} + + + )} )} diff --git a/dashboard/src/components/dialogs/advance-search-modal.tsx b/dashboard/src/components/dialogs/advance-search-modal.tsx index 3c1c379c4..f61fda8f5 100644 --- a/dashboard/src/components/dialogs/advance-search-modal.tsx +++ b/dashboard/src/components/dialogs/advance-search-modal.tsx @@ -92,6 +92,28 @@ export default function AdvanceSearchModal({ isDialogOpen, onOpenChange, form, o ) }} /> + {isSudo && ( + { + return ( + + {t('advanceSearch.showCreatedBy', { defaultValue: 'Show created by' })} + + { + field.onChange(checked) + }} + /> + + + + ) + }} + /> + )} = memo(({ subscribeUrl, on setIsLoading(true) setError(null) try { - const response = await fetch(`${sublink}/links`) + const response = await fetch(`${sublink}/links_base64`) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } - const text = await response.text() + const base64Text = await response.text() + const text = atob(base64Text) const configLines = text.split('\n').filter(line => line.trim() !== '') setConfigs(configLines.map((config, index) => ({ config, index }))) setCurrentIndex(0) diff --git a/dashboard/src/components/dialogs/core-config-modal.tsx b/dashboard/src/components/dialogs/core-config-modal.tsx index 9aab014b9..7eff4470f 100644 --- a/dashboard/src/components/dialogs/core-config-modal.tsx +++ b/dashboard/src/components/dialogs/core-config-modal.tsx @@ -10,18 +10,18 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import useDirDetection from '@/hooks/use-dir-detection' +import { useIsMobile } from '@/hooks/use-mobile' import { cn } from '@/lib/utils' import { useCreateCoreConfig, useModifyCoreConfig } from '@/service/api' import { isEmptyObject } from '@/utils/isEmptyObject.ts' import { generateMldsa65 } from '@/utils/mldsa65' import { queryClient } from '@/utils/query-client' -import Editor from '@monaco-editor/react' import { encodeURLSafe } from '@stablelib/base64' import { generateKeyPair } from '@stablelib/x25519' import { debounce } from 'es-toolkit' import { Info, Key, Maximize2, Minimize2, Sparkles, Shield } from 'lucide-react' import { MlKem768 } from 'mlkem' -import { useCallback, useEffect, useState } from 'react' +import { Suspense, lazy, useCallback, useEffect, useState } from 'react' import { UseFormReturn } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -92,9 +92,13 @@ const createDefaultVlessOptions = (): VlessBuilderOptions => ({ includeClientPadding: false, }) +const MonacoEditor = lazy(() => import('@monaco-editor/react')) +const MobileJsonAceEditor = lazy(() => import('@/components/common/mobile-json-ace-editor')) + export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, editingCore, editingCoreId }: CoreConfigModalProps) { const { t } = useTranslation() const dir = useDirDetection() + const isMobile = useIsMobile() const { resolvedTheme } = useTheme() const [validation, setValidation] = useState({ isValid: true }) const [isEditorReady, setIsEditorReady] = useState(false) @@ -140,6 +144,43 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit setIsResultsDialogOpen(true) }, []) + const relayoutEditor = useCallback( + (editor = editorInstance) => { + if (!editor) return + if (typeof editor.layout === 'function') { + editor.layout() + } + if (typeof editor.resize === 'function') { + editor.resize() + } + }, + [editorInstance], + ) + + const validateJsonContent = useCallback( + (value: string, showToast = false) => { + try { + JSON.parse(value) + setValidation({ isValid: true }) + return true + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Invalid JSON' + setValidation({ + isValid: false, + error: errorMessage, + }) + if (showToast) { + toast.error(errorMessage, { + duration: 3000, + position: 'bottom-right', + }) + } + return false + } + }, + [], + ) + // Handle fullscreen toggle with editor resize const handleToggleFullscreen = useCallback(() => { setIsEditorFullscreen(prev => { @@ -147,16 +188,13 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit // Force editor layout update when toggling fullscreen setTimeout(() => { - if (editorInstance) { - editorInstance.layout() - } - // Also trigger window resize event for Monaco to recalculate + relayoutEditor() window.dispatchEvent(new Event('resize')) }, 50) return newValue }) - }, [editorInstance]) + }, [relayoutEditor]) const handleEditorValidation = useCallback( (markers: any[]) => { @@ -172,24 +210,18 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit position: 'bottom-right', }) } else { - try { - // Additional validation - try parsing the JSON - JSON.parse(form.getValues().config) - setValidation({ isValid: true }) - } catch (e) { - const errorMessage = e instanceof Error ? e.message : 'Invalid JSON' - setValidation({ - isValid: false, - error: errorMessage, - }) - toast.error(errorMessage, { - duration: 3000, - position: 'bottom-right', - }) - } + validateJsonContent(form.getValues().config, true) } }, - [form], + [form, validateJsonContent], + ) + + const handleAceEditorChange = useCallback( + (value: string, onChange: (value: string) => void) => { + onChange(value) + validateJsonContent(value) + }, + [validateJsonContent], ) // Debounce config changes to improve performance @@ -226,10 +258,20 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit // This ensures the editor properly calculates its dimensions on first load requestAnimationFrame(() => { if (editor) { - editor.layout() + if (typeof editor.layout === 'function') { + editor.layout() + } + if (typeof editor.resize === 'function') { + editor.resize() + } // Also trigger a resize after a short delay to handle mobile viewport adjustments setTimeout(() => { - editor.layout() + if (typeof editor.layout === 'function') { + editor.layout() + } + if (typeof editor.resize === 'function') { + editor.resize() + } }, 100) } }) @@ -610,14 +652,15 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit // Force editor resize on mobile after modal opens // This ensures the editor properly renders on first load setTimeout(() => { - const editorElement = document.querySelector('.monaco-editor') + const editorSelector = isMobile ? '.ace_editor' : '.monaco-editor' + const editorElement = document.querySelector(editorSelector) if (editorElement) { // Trigger a resize event window.dispatchEvent(new Event('resize')) } }, 300) } - }, [isDialogOpen, editingCore, form, defaultConfig]) + }, [isDialogOpen, editingCore, form, defaultConfig, isMobile]) // Cleanup on modal close useEffect(() => { @@ -705,16 +748,18 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit // Add this useEffect to inject the styles useEffect(() => { + if (isMobile) return const styleElement = document.createElement('style') styleElement.textContent = styles document.head.appendChild(styleElement) return () => { document.head.removeChild(styleElement) } - }, []) + }, [isMobile]) // Handle Monaco Editor web component registration errors useEffect(() => { + if (isMobile) return const originalError = console.error console.error = (...args) => { // Suppress the specific web component registration error @@ -727,44 +772,90 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit return () => { console.error = originalError } - }, []) + }, [isMobile]) // Handle window resize for editor layout updates useEffect(() => { const handleResize = () => { // Force editor to recalculate its dimensions setTimeout(() => { - if (editorInstance) { - editorInstance.layout() - } + relayoutEditor() }, 100) } - - window.addEventListener('resize', handleResize) - - // Also listen for orientation changes on mobile - window.addEventListener('orientationchange', () => { + const handleOrientationChange = () => { setTimeout(() => { - if (editorInstance) { - editorInstance.layout() - } + relayoutEditor() }, 300) - }) + } + + window.addEventListener('resize', handleResize) + window.addEventListener('orientationchange', handleOrientationChange) return () => { window.removeEventListener('resize', handleResize) - window.removeEventListener('orientationchange', handleResize) + window.removeEventListener('orientationchange', handleOrientationChange) } - }, [editorInstance]) + }, [relayoutEditor]) // Trigger layout update when fullscreen state changes useEffect(() => { if (editorInstance && isEditorReady) { setTimeout(() => { - editorInstance.layout() + relayoutEditor() }, 150) } - }, [isEditorFullscreen, editorInstance, isEditorReady]) + }, [isEditorFullscreen, editorInstance, isEditorReady, relayoutEditor]) + + const monacoEditorOptions = { + minimap: { enabled: false }, + fontSize: 14, + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + lineNumbers: 'on', + roundedSelection: true, + scrollBeyondLastLine: false, + automaticLayout: true, + formatOnPaste: true, + formatOnType: true, + renderWhitespace: 'none', + wordWrap: 'on', + folding: true, + suggestOnTriggerCharacters: true, + quickSuggestions: true, + renderLineHighlight: 'all', + scrollbar: { + vertical: 'visible', + horizontal: 'visible', + useShadows: false, + verticalScrollbarSize: 10, + horizontalScrollbarSize: 10, + }, + contextmenu: true, + copyWithSyntaxHighlighting: false, + multiCursorModifier: 'alt', + accessibilitySupport: 'on', + mouseWheelZoom: true, + quickSuggestionsDelay: 0, + occurrencesHighlight: 'singleFile', + wordBasedSuggestions: 'currentDocument', + suggest: { + showWords: true, + showSnippets: true, + showClasses: true, + showFunctions: true, + showVariables: true, + showProperties: true, + showColors: true, + showFiles: true, + showReferences: true, + showFolders: true, + showTypeParameters: true, + showEnums: true, + showConstructors: true, + showDeprecated: true, + showEnumMembers: true, + showKeywords: true, + }, + } as const // VLESS Advanced Settings Modal Component const renderVlessAdvancedModal = () => { @@ -1224,66 +1315,29 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit
- + {isMobile ? ( + }> + handleAceEditorChange(value, field.onChange)} + onLoad={handleEditorDidMount} + /> + + ) : ( + }> + field.onChange(value ?? '')} + onValidate={handleEditorValidation} + onMount={handleEditorDidMount} + options={monacoEditorOptions} + /> + + )}
) : ( @@ -1301,65 +1355,29 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit )}
- + {isMobile ? ( + }> + handleAceEditorChange(value, field.onChange)} + onLoad={handleEditorDidMount} + /> + + ) : ( + }> + field.onChange(value ?? '')} + onValidate={handleEditorValidation} + onMount={handleEditorDidMount} + options={monacoEditorOptions} + /> + + )}
)} diff --git a/dashboard/src/components/dialogs/subscription-modal.tsx b/dashboard/src/components/dialogs/subscription-modal.tsx index a537d85f4..9b7ab1b14 100644 --- a/dashboard/src/components/dialogs/subscription-modal.tsx +++ b/dashboard/src/components/dialogs/subscription-modal.tsx @@ -22,6 +22,23 @@ interface ConfigItem { } const CONFIGS_PER_PAGE = 5 +const LINKS_FETCH_TIMEOUT_MS = 8000 + +const buildPanelFallbackUrl = (url: string): string | null => { + try { + const parsedUrl = new URL(url, window.location.origin) + if (parsedUrl.origin === window.location.origin) return null + + return `${window.location.origin}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}` + } catch (error) { + console.error('Failed to build panel fallback url:', error) + return null + } +} + +const isTimeoutError = (error: unknown): boolean => { + return error instanceof Error && error.name === 'AbortError' +} const extractNameFromConfigURL = (url: string): string | null => { const namePattern = /#([^#]*)/ @@ -94,17 +111,47 @@ const SubscriptionModal: FC = memo(({ subscribeUrl, user const subscribeQrLink = sublink + const fetchLinksWithTimeoutFallback = useCallback(async () => { + const linksUrl = `${sublink}/links` + + const fetchWithTimeout = async (url: string) => { + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), LINKS_FETCH_TIMEOUT_MS) + + try { + const response = await fetch(url, { signal: controller.signal }) + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + return response.text() + } finally { + window.clearTimeout(timeoutId) + } + } + + try { + return await fetchWithTimeout(linksUrl) + } catch (error) { + if (!isTimeoutError(error)) { + throw error + } + + const fallbackUrl = buildPanelFallbackUrl(linksUrl) + if (!fallbackUrl) { + throw error + } + + return fetchWithTimeout(fallbackUrl) + } + }, [sublink]) + const fetchConfigs = useCallback(async () => { if (!subscribeUrl) return setIsLoading(true) setError(null) try { - const response = await fetch(`${sublink}/links`) - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - const text = await response.text() + const text = await fetchLinksWithTimeoutFallback() const configLines = text.split('\n').filter(line => line.trim() !== '') setConfigs( configLines.map(config => ({ @@ -120,7 +167,7 @@ const SubscriptionModal: FC = memo(({ subscribeUrl, user } finally { setIsLoading(false) } - }, [subscribeUrl, sublink, t]) + }, [fetchLinksWithTimeoutFallback, subscribeUrl, t]) useEffect(() => { fetchConfigs() @@ -152,11 +199,7 @@ const SubscriptionModal: FC = memo(({ subscribeUrl, user const handleCopyAllConfigs = useCallback(async () => { try { - const response = await fetch(`${sublink}/links`) - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - const content = await response.text() + const content = await fetchLinksWithTimeoutFallback() await navigator.clipboard.writeText(content) setAllConfigsCopied(true) toast.success(t('usersTable.copied', { defaultValue: 'Copied' })) @@ -164,7 +207,7 @@ const SubscriptionModal: FC = memo(({ subscribeUrl, user } catch (error) { toast.error(t('copyFailed', { defaultValue: 'Failed to copy' })) } - }, [sublink, t]) + }, [fetchLinksWithTimeoutFallback, t]) const handleShowConfigQR = (config: ConfigItem) => { setSelectedConfigQR(config) @@ -187,14 +230,11 @@ const SubscriptionModal: FC = memo(({ subscribeUrl, user -
+
{/* Subscription QR Code Section */}
-
- {t('subscriptionModal.subscriptionLink', { defaultValue: 'Subscription Link' })} -
-
- +
+
@@ -227,7 +267,7 @@ const SubscriptionModal: FC = memo(({ subscribeUrl, user ) : ( <> {/* Configs List */} -
+
{currentConfigs.map((item, index) => (
diff --git a/dashboard/src/components/dialogs/usage-modal.tsx b/dashboard/src/components/dialogs/usage-modal.tsx index 8ede0aa12..9b3b94cc1 100644 --- a/dashboard/src/components/dialogs/usage-modal.tsx +++ b/dashboard/src/components/dialogs/usage-modal.tsx @@ -10,8 +10,9 @@ import { DateRange } from 'react-day-picker' import { TimeRangeSelector } from '@/components/common/time-range-selector' import { Button } from '../ui/button' import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '../ui/select' -import { ResponsiveContainer, TooltipProps, Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell, Pie, PieChart as RechartsPieChart } from 'recharts' +import { ResponsiveContainer, TooltipProps, Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell, Pie, PieChart as RechartsPieChart } from 'recharts' import useDirDetection from '@/hooks/use-dir-detection' +import { useChartViewType } from '@/hooks/use-chart-view-type' import { useTheme } from '@/components/common/theme-provider' import NodeStatsModal from './node-stats-modal' import { @@ -192,6 +193,7 @@ const UsageModal = ({ open, onClose, username }: UsageModalProps) => { const [currentDataIndex, setCurrentDataIndex] = useState(0) const [chartData, setChartData] = useState(null) const [chartView, setChartView] = useState<'bar' | 'pie'>('bar') + const chartViewType = useChartViewType() // Get current admin to check permissions const { data: currentAdmin } = useGetCurrentAdmin() @@ -575,6 +577,31 @@ const UsageModal = ({ open, onClose, username }: UsageModalProps) => { setCustomRange(undefined) }, []) + const handleTrafficChartClick = useCallback( + (data: any) => { + if (!processedChartData || processedChartData.length === 0) return + + const clickedIndex = typeof data?.activeTooltipIndex === 'number' ? data.activeTooltipIndex : -1 + const clickedData = data?.activePayload?.[0]?.payload ?? (clickedIndex >= 0 ? processedChartData[clickedIndex] : undefined) + if (!clickedData) return + + if (allNodesSelected) { + const activeNodesCount = Object.keys(clickedData).filter( + key => !key.startsWith('_') && key !== 'time' && key !== '_period_start' && key !== 'usage' && Number(clickedData[key] || 0) > 0, + ).length + if (activeNodesCount === 0) return + } else if (Number(clickedData.usage || 0) <= 0) { + return + } + + const resolvedIndex = clickedIndex >= 0 ? clickedIndex : processedChartData.findIndex(item => item._period_start === clickedData._period_start) + setCurrentDataIndex(resolvedIndex >= 0 ? resolvedIndex : 0) + setSelectedData(clickedData) + setModalOpen(true) + }, + [processedChartData, allNodesSelected], + ) + return ( @@ -611,7 +638,7 @@ const UsageModal = ({ open, onClose, username }: UsageModalProps) => {
- )} - - - - -
- - - @@ -2461,8 +2420,67 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse {renderUserMetaPanel('mt-4 lg:hidden')}
{/* Cancel/Create buttons - always visible */} -
-
+
+ {editingUser && ( + + + + + setActionsMenuOpen(false)} + onPointerDownOutside={() => setActionsMenuOpen(false)} + onInteractOutside={() => setActionsMenuOpen(false)} + > + {isSudo && ( + { + setActionsMenuOpen(false) + setUserAllIPsModalOpen(true) + }}> + + {t('userAllIPs.ipAddresses', { defaultValue: 'IP addresses' })} + + )} + { + setActionsMenuOpen(false) + setUsageModalOpen(true) + }}> + + {t('userDialog.usage', { defaultValue: 'Usage' })} + + { + setActionsMenuOpen(false) + setSubscriptionClientsModalOpen(true) + }}> + + {t('subscriptionClients.clients', { defaultValue: 'Clients' })} + + { + setActionsMenuOpen(false) + setRevokeSubDialogOpen(true) + }}> + + {t('userDialog.revokeSubscription', { defaultValue: 'Revoke subscription' })} + + { + setActionsMenuOpen(false) + setResetUsageDialogOpen(true) + }}> + + {t('userDialog.resetUsage', { defaultValue: 'Reset usage' })} + + + + )} +
diff --git a/dashboard/src/components/statistics/system-statistics-section.tsx b/dashboard/src/components/statistics/system-statistics-section.tsx index 5c81dbbf7..bfb706c7a 100644 --- a/dashboard/src/components/statistics/system-statistics-section.tsx +++ b/dashboard/src/components/statistics/system-statistics-section.tsx @@ -191,16 +191,14 @@ export default function SystemStatisticsSection({ currentStats }: SystemStatisti
-
- - {currentStats ? ( - - {formatBytes(memory.used, 1, false, false, 'GB')}/{formatBytes(memory.total, 1, true, false, 'GB')} - ({memoryPercent.toFixed(1)}%) - - ) : ( - 0 - )} +
+ + + {formatBytes(memory.used, 1, false, false, 'GB')}/{formatBytes(memory.total, 1, true, false, 'GB')} + + + + {memoryPercent.toFixed(1)}%
@@ -208,7 +206,7 @@ export default function SystemStatisticsSection({ currentStats }: SystemStatisti
{/* Total Traffic / Network Speed (depends on whether it's master or node stats) */} -
+
) : ( - {formatBytes(getTotalTrafficValue() || 0, 1)} + {formatBytes(getTotalTrafficValue() || 0, 1)} )}
diff --git a/dashboard/src/components/templates/use-user-templates-list-columns.tsx b/dashboard/src/components/templates/use-user-templates-list-columns.tsx index f5bf4dbd7..90811ecb7 100644 --- a/dashboard/src/components/templates/use-user-templates-list-columns.tsx +++ b/dashboard/src/components/templates/use-user-templates-list-columns.tsx @@ -22,7 +22,13 @@ export const useUserTemplatesListColumns = ({ onEdit, onToggleStatus }: UseUserT header: t('name', { defaultValue: 'Name' }), width: '3fr', cell: template => ( -
+
{ + event.stopPropagation() + onEdit(template) + }} + > {template.name}
diff --git a/dashboard/src/components/templates/user-template-actions-menu.tsx b/dashboard/src/components/templates/user-template-actions-menu.tsx index fa0f19801..c7678636f 100644 --- a/dashboard/src/components/templates/user-template-actions-menu.tsx +++ b/dashboard/src/components/templates/user-template-actions-menu.tsx @@ -116,7 +116,7 @@ export default function UserTemplateActionsMenu({ template, onEdit, onToggleStat onToggleStatus(template) }} > - + {template.is_disabled ? t('enable') : t('disable')} diff --git a/dashboard/src/components/users/action-buttons.tsx b/dashboard/src/components/users/action-buttons.tsx index 694e8ddbf..17ffc2701 100644 --- a/dashboard/src/components/users/action-buttons.tsx +++ b/dashboard/src/components/users/action-buttons.tsx @@ -46,6 +46,7 @@ const ActionButtons: FC = ({ user }) => { const [isActiveNextPlanModalOpen, setIsActiveNextPlanModalOpen] = useState(false) const [isSubscriptionClientsModalOpen, setSubscriptionClientsModalOpen] = useState(false) const [isUserAllIPsModalOpen, setUserAllIPsModalOpen] = useState(false) + const [isActionsMenuOpen, setActionsMenuOpen] = useState(false) const queryClient = useQueryClient() const { t } = useTranslation() const dir = useDirDetection() @@ -369,8 +370,16 @@ const ActionButtons: FC = ({ user }) => { const handleLinksCopy = async (link: string, type: string, icon: string) => { try { - const content = await fetchContent(link) - copy(content) + if (navigator.clipboard?.write) { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/plain': fetchContent(link).then(content => new Blob([content], { type: 'text/plain' })), + }), + ]) + } else { + const content = await fetchContent(link) + copy(content) + } toast.success(`${icon} ${type} ${t('usersTable.copied', { defaultValue: 'Copied to clipboard' })}`) } catch (error) { toast.error(t('copyFailed', { defaultValue: 'Failed to copy content' })) @@ -450,28 +459,33 @@ const ActionButtons: FC = ({ user }) => { {copied ? t('usersTable.copied') : t('usersTable.copyConfigs')} - + - + setActionsMenuOpen(false)} + onInteractOutside={() => setActionsMenuOpen(false)} + onEscapeKeyDown={() => setActionsMenuOpen(false)} + > {/* Edit */} - + {t('edit')} {/* QR Code */} - + QR Code {/* Set Owner: only for sudo admins */} {currentAdmin?.is_sudo && ( - + {t('setOwnerModal.title')} @@ -479,7 +493,7 @@ const ActionButtons: FC = ({ user }) => { {/* Copy Core Username for sudo admins */} {currentAdmin?.is_sudo && ( - + {t('coreUsername')} @@ -488,40 +502,40 @@ const ActionButtons: FC = ({ user }) => { {/* Revoke Sub */} - + {t('userDialog.revokeSubscription')} {/* Reset Usage */} - + {t('userDialog.resetUsage')} {/* Usage State */} - + {t('userDialog.usage')} {/* Active Next Plan */} {user.next_plan && ( - + {t('usersTable.activeNextPlanSubmit')} )} {/* Subscription Info */} - setSubscriptionClientsModalOpen(true)}> + setSubscriptionClientsModalOpen(true)}> {t('subscriptionClients.clients', { defaultValue: 'Clients' })} {/* View All IPs: only for sudo admins */} {currentAdmin?.is_sudo && ( - setUserAllIPsModalOpen(true)}> + setUserAllIPsModalOpen(true)}> {t('userAllIPs.ipAddresses', { defaultValue: 'IP addresses' })} @@ -530,7 +544,7 @@ const ActionButtons: FC = ({ user }) => { {/* Trash */} - + {t('remove')} diff --git a/dashboard/src/components/users/columns.tsx b/dashboard/src/components/users/columns.tsx index 04c28f0cc..7ed9da14a 100644 --- a/dashboard/src/components/users/columns.tsx +++ b/dashboard/src/components/users/columns.tsx @@ -17,12 +17,14 @@ export const setupColumns = ({ filters, handleStatusFilter, dir, + showCreatedBy, }: { t: (key: string) => string handleSort: (column: string, fromDropdown?: boolean) => void filters: { sort: string; status?: UserStatus | null; [key: string]: unknown } handleStatusFilter: (value: string | UserStatus) => void dir: string + showCreatedBy: boolean }): ColumnDef[] => [ { accessorKey: 'username', @@ -91,7 +93,7 @@ export const setupColumns = ({ {row.getValue('username')} {onlineTimeText && {onlineTimeText}}
- {row.original.admin?.username && ( + {showCreatedBy && row.original.admin?.username && ( {t('created')} {t('by')} diff --git a/dashboard/src/components/users/users-statistics.tsx b/dashboard/src/components/users/users-statistics.tsx index 08d341516..8f3a8ec44 100644 --- a/dashboard/src/components/users/users-statistics.tsx +++ b/dashboard/src/components/users/users-statistics.tsx @@ -84,7 +84,7 @@ const UsersStatistics = () => {
{/* Total Users */} -
+
{ const [selectedUser, setSelectedUser] = useState(null) const [isAdvanceSearchOpen, setIsAdvanceSearchOpen] = useState(false) const [isSorting, setIsSorting] = useState(false) + const [showCreatedBy, setShowCreatedBy] = useState(getUsersShowCreatedBy()) const [filters, setFilters] = useState<{ limit: number @@ -171,6 +172,7 @@ const UsersTable = memo(() => { return { is_username: !urlParams.isProtocol, is_protocol: urlParams.isProtocol, + show_created_by: getUsersShowCreatedBy(), admin: urlParams.admin || [], group: urlParams.group || [], status: urlParams.status || '0', @@ -244,8 +246,9 @@ const UsersTable = memo(() => { advanceSearchForm.setValue('status', filters.status || '0') advanceSearchForm.setValue('admin', filters.admin || []) advanceSearchForm.setValue('group', filters.group || []) + advanceSearchForm.setValue('show_created_by', showCreatedBy) } - }, [isAdvanceSearchOpen, filters.status, filters.admin, filters.group, advanceSearchForm]) + }, [isAdvanceSearchOpen, filters.status, filters.admin, filters.group, showCreatedBy, advanceSearchForm]) const { data: usersData, @@ -468,12 +471,17 @@ const UsersTable = memo(() => { const columns = setupColumns({ t, dir, + showCreatedBy: isSudo && showCreatedBy, handleSort, filters: filters as { sort: string; status?: UserStatus | null; [key: string]: unknown }, handleStatusFilter, }) const handleAdvanceSearchSubmit = (values: AdvanceSearchFormValue) => { + if (isSudo) { + setShowCreatedBy(values.show_created_by) + setUsersShowCreatedBy(values.show_created_by) + } setFilters(prev => ({ ...prev, admin: values.admin && values.admin.length > 0 ? values.admin : undefined, @@ -509,6 +517,7 @@ const UsersTable = memo(() => { advanceSearchForm.reset({ is_username: true, is_protocol: false, + show_created_by: showCreatedBy, admin: [], group: [], status: '0', diff --git a/dashboard/src/hooks/use-chart-view-type.ts b/dashboard/src/hooks/use-chart-view-type.ts new file mode 100644 index 000000000..14a47ff97 --- /dev/null +++ b/dashboard/src/hooks/use-chart-view-type.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react' +import { CHART_VIEW_TYPE_CHANGE_EVENT, getChartViewTypePreference, type ChartViewType } from '@/utils/userPreferenceStorage' + +export const useChartViewType = () => { + const [chartViewType, setChartViewType] = useState(() => getChartViewTypePreference()) + + useEffect(() => { + const syncChartViewType = () => { + setChartViewType(getChartViewTypePreference()) + } + + window.addEventListener('storage', syncChartViewType) + window.addEventListener(CHART_VIEW_TYPE_CHANGE_EVENT, syncChartViewType as EventListener) + + return () => { + window.removeEventListener('storage', syncChartViewType) + window.removeEventListener(CHART_VIEW_TYPE_CHANGE_EVENT, syncChartViewType as EventListener) + } + }, []) + + return chartViewType +} diff --git a/dashboard/src/pages/_dashboard.admins.tsx b/dashboard/src/pages/_dashboard.admins.tsx index 95ca313fe..13fa946f7 100644 --- a/dashboard/src/pages/_dashboard.admins.tsx +++ b/dashboard/src/pages/_dashboard.admins.tsx @@ -94,12 +94,16 @@ export default function AdminsPage() { queryClient.invalidateQueries({ queryKey: ['/api/admins'], }) - } catch (error) { + } catch (error: any) { + const status = error?.status ?? error?.response?.status + const backendDetail = error?.data?.detail ?? error?.response?._data?.detail ?? error?.response?.data?.detail + const defaultDescription = t(admin.is_disabled ? 'admins.enableFailed' : 'admins.disableFailed', { + name: admin.username, + defaultValue: `Failed to ${admin.is_disabled ? 'enable' : 'disable'} admin "{name}"`, + }) + toast.error(t('error', { defaultValue: 'Error' }), { - description: t(admin.is_disabled ? 'admins.enableFailed' : 'admins.disableFailed', { - name: admin.username, - defaultValue: `Failed to ${admin.is_disabled ? 'enable' : 'disable'} admin "{name}"`, - }), + description: status === 403 && typeof backendDetail === 'string' && backendDetail.trim().length > 0 ? backendDetail : defaultDescription, }) } } diff --git a/dashboard/src/pages/_dashboard.settings.theme.tsx b/dashboard/src/pages/_dashboard.settings.theme.tsx index 46a63f596..5ce1f7d75 100644 --- a/dashboard/src/pages/_dashboard.settings.theme.tsx +++ b/dashboard/src/pages/_dashboard.settings.theme.tsx @@ -5,11 +5,11 @@ import { useTheme, colorThemes, type ColorTheme, type Radius } from '@/component import { useEffect, useState } from 'react' import { toast } from 'sonner' import { cn } from '@/lib/utils' -import { CheckCircle2, SunMoon, Palette, Ruler, Eye, RotateCcw, Sun, Moon, Monitor, CalendarClock, Languages } from 'lucide-react' +import { CheckCircle2, SunMoon, Palette, Ruler, Eye, RotateCcw, Sun, Moon, Monitor, CalendarClock, Languages, BarChart3, TrendingUp } from 'lucide-react' import { Button } from '@/components/ui/button' import useDirDetection from '@/hooks/use-dir-detection' import { Switch } from '@/components/ui/switch' -import { getDatePickerPreference, setDatePickerPreference, type DatePickerPreference } from '@/utils/userPreferenceStorage' +import { getDatePickerPreference, getChartViewTypePreference, setDatePickerPreference, setChartViewTypePreference, type DatePickerPreference, type ChartViewType } from '@/utils/userPreferenceStorage' const colorThemeData = [ { name: 'default', label: 'theme.default', dot: '#2563eb' }, @@ -37,12 +37,20 @@ const modeIcons: Record<(typeof modeOptions)[number], JSX.Element> = { system: , } +const chartViewOptions = ['bar', 'area'] as const + +const chartViewIcons: Record<(typeof chartViewOptions)[number], JSX.Element> = { + bar: , + area: , +} + export default function ThemeSettings() { const { t, i18n } = useTranslation() const { theme, colorTheme, radius, resolvedTheme, setTheme, setColorTheme, setRadius, resetToDefaults, isSystemTheme } = useTheme() const dir = useDirDetection() const [isResetting, setIsResetting] = useState(false) const [datePickerPreference, setDatePickerPreferenceState] = useState('locale') + const [chartViewType, setChartViewTypeState] = useState('bar') const isDatePickerFollowingLocale = datePickerPreference === 'locale' const defaultManualDatePreference: Exclude = i18n.language === 'fa' ? 'persian' : 'gregorian' const datePickerModeCopy: Record = { @@ -50,9 +58,14 @@ export default function ThemeSettings() { gregorian: t('theme.datePickerModeGregorian'), persian: t('theme.datePickerModePersian'), } + const chartViewTypeCopy: Record = { + bar: t('theme.chartViewBar'), + area: t('theme.chartViewArea'), + } useEffect(() => { setDatePickerPreferenceState(getDatePickerPreference()) + setChartViewTypeState(getChartViewTypePreference()) }, []) const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => { @@ -128,10 +141,23 @@ export default function ThemeSettings() { persistDatePickerPreference(preference) } + const handleChartViewTypeChange = (viewType: ChartViewType) => { + setChartViewTypeState(viewType) + setChartViewTypePreference(viewType) + toast.success(t('success'), { + description: `📊 ${t('theme.chartViewSaved')} • ${chartViewTypeCopy[viewType]}`, + duration: 2000, + }) + } + const handleResetToDefaults = async () => { setIsResetting(true) try { resetToDefaults() + setDatePickerPreferenceState('locale') + setDatePickerPreference('locale') + setChartViewTypeState('bar') + setChartViewTypePreference('bar') toast.success(t('success'), { description: '🔄 ' + t('theme.resetSuccess'), duration: 3000, @@ -308,6 +334,42 @@ export default function ThemeSettings() {
+
+
+
+ +

{t('theme.chartViewType')}

+
+

{t('theme.chartViewDescription')}

+
+ handleChartViewTypeChange(value as ChartViewType)} className="grid gap-2 sm:grid-cols-2"> + {chartViewOptions.map(option => ( +
+ + +
+ ))} +
+
+
diff --git a/dashboard/src/pages/_dashboard.templates.tsx b/dashboard/src/pages/_dashboard.templates.tsx index 3ba250f77..50ad522ea 100644 --- a/dashboard/src/pages/_dashboard.templates.tsx +++ b/dashboard/src/pages/_dashboard.templates.tsx @@ -167,6 +167,7 @@ export default function UserTemplates() { isLoading={isCurrentlyLoading} loadingRows={6} className="gap-3" + onRowClick={handleEdit} mode={viewMode} showEmptyState={false} gridClassName="transform-gpu animate-slide-up" diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts index 5b26b2f1a..c9d9b792e 100644 --- a/dashboard/src/service/api/index.ts +++ b/dashboard/src/service/api/index.ts @@ -257,13 +257,6 @@ export type XrayMuxSettingsOutputXudpConcurrency = number | null export type XrayMuxSettingsOutputConcurrency = number | null -export interface XrayMuxSettingsOutput { - enabled?: boolean - concurrency?: XrayMuxSettingsOutputConcurrency - xudpConcurrency?: XrayMuxSettingsOutputXudpConcurrency - xudpProxyUDP443?: Xudp -} - export type XrayMuxSettingsInputXudpConcurrency = number | null export type XrayMuxSettingsInputConcurrency = number | null @@ -293,6 +286,13 @@ export const Xudp = { skip: 'skip', } as const +export interface XrayMuxSettingsOutput { + enabled?: boolean + concurrency?: XrayMuxSettingsOutputConcurrency + xudpConcurrency?: XrayMuxSettingsOutputXudpConcurrency + xudpProxyUDP443?: Xudp +} + export type XTLSFlows = (typeof XTLSFlows)[keyof typeof XTLSFlows] // eslint-disable-next-line @typescript-eslint/no-redeclare @@ -444,6 +444,18 @@ export type XHttpSettingsInputXPaddingBytes = string | number | null export type XHttpSettingsInputNoGrpcHeader = boolean | null +export type XHttpModes = (typeof XHttpModes)[keyof typeof XHttpModes] + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const XHttpModes = { + auto: 'auto', + 'packet-up': 'packet-up', + 'stream-up': 'stream-up', + 'stream-one': 'stream-one', +} as const + +export type XHttpSettingsInputMode = XHttpModes | null + export interface XHttpSettingsInput { mode?: XHttpSettingsInputMode no_grpc_header?: XHttpSettingsInputNoGrpcHeader @@ -467,18 +479,6 @@ export interface XHttpSettingsInput { download_settings?: XHttpSettingsInputDownloadSettings } -export type XHttpModes = (typeof XHttpModes)[keyof typeof XHttpModes] - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const XHttpModes = { - auto: 'auto', - 'packet-up': 'packet-up', - 'stream-up': 'stream-up', - 'stream-one': 'stream-one', -} as const - -export type XHttpSettingsInputMode = XHttpModes | null - export interface WorkersHealth { scheduler: WorkerHealth node: WorkerHealth @@ -885,22 +885,22 @@ export interface UserModify { status?: UserModifyStatus } -export type UserIPListAllNodes = { [key: string]: UserIPList | null } +export type UserIPListIps = { [key: string]: number } /** - * User IP lists for all nodes + * User IP list - mapping of IP addresses to connection counts */ -export interface UserIPListAll { - nodes: UserIPListAllNodes +export interface UserIPList { + ips: UserIPListIps } -export type UserIPListIps = { [key: string]: number } +export type UserIPListAllNodes = { [key: string]: UserIPList | null } /** - * User IP list - mapping of IP addresses to connection counts + * User IP lists for all nodes */ -export interface UserIPList { - ips: UserIPListIps +export interface UserIPListAll { + nodes: UserIPListAllNodes } export type UserCreateStatus = UserStatusCreate | null @@ -1304,15 +1304,6 @@ export const ProxyHostALPN = { h3: 'h3', } as const -export type ECHQueryStrategy = (typeof ECHQueryStrategy)[keyof typeof ECHQueryStrategy] - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const ECHQueryStrategy = { - none: 'none', - half: 'half', - full: 'full', -} as const - export type Platform = (typeof Platform)[keyof typeof Platform] // eslint-disable-next-line @typescript-eslint/no-redeclare @@ -1394,6 +1385,19 @@ export interface NotificationEnable { percentage_reached?: boolean } +/** + * Per-object notification channels + */ +export interface NotificationChannels { + admin?: NotificationChannel + core?: NotificationChannel + group?: NotificationChannel + host?: NotificationChannel + node?: NotificationChannel + user?: NotificationChannel + user_template?: NotificationChannel +} + export type NotificationChannelDiscordWebhookUrl = string | null export type NotificationChannelTelegramTopicId = number | null @@ -1409,19 +1413,6 @@ export interface NotificationChannel { discord_webhook_url?: NotificationChannelDiscordWebhookUrl } -/** - * Per-object notification channels - */ -export interface NotificationChannels { - admin?: NotificationChannel - core?: NotificationChannel - group?: NotificationChannel - host?: NotificationChannel - node?: NotificationChannel - user?: NotificationChannel - user_template?: NotificationChannel -} - export interface NotFound { detail?: string } @@ -1592,8 +1583,6 @@ export type NodeModifyKeepAlive = number | null export type NodeModifyServerCa = string | null -export type NodeModifyConnectionType = NodeConnectionType | null - export type NodeModifyUsageCoefficient = number | null export type NodeModifyPort = number | null @@ -1638,6 +1627,8 @@ export const NodeConnectionType = { rest: 'rest', } as const +export type NodeModifyConnectionType = NodeConnectionType | null + export interface NodeCreate { name: string address: string @@ -1793,11 +1784,6 @@ export interface HTTPException { detail: string } -export interface GroupsResponse { - groups: GroupResponse[] - total: number -} - /** * Lightweight group model with only id and name for performance. */ @@ -1828,6 +1814,11 @@ export interface GroupResponse { total_users?: number } +export interface GroupsResponse { + groups: GroupResponse[] + total: number +} + export type GroupModifyInboundTags = string[] | null export interface GroupModify { @@ -1900,6 +1891,15 @@ export interface ExtraSettings { method?: ExtraSettingsMethod } +export type ECHQueryStrategy = (typeof ECHQueryStrategy)[keyof typeof ECHQueryStrategy] + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ECHQueryStrategy = { + none: 'none', + half: 'half', + full: 'full', +} as const + export interface DownloadLink { /** @maxLength 64 */ name: string @@ -1940,10 +1940,10 @@ export type CreateHostVerifyPeerCertByName = string[] | null export type CreateHostPinnedPeerCertSha256 = string | null -export type CreateHostEchConfigList = string | null - export type CreateHostEchQueryStrategy = ECHQueryStrategy | null +export type CreateHostEchConfigList = string | null + export type CreateHostStatus = UserStatus[] | null export type CreateHostVlessRoute = string | null @@ -2022,6 +2022,11 @@ export interface CoresSimpleResponse { total: number } +export interface CoreResponseList { + count: number + cores?: CoreResponse[] +} + export type CoreResponseConfig = { [key: string]: unknown } export interface CoreResponse { @@ -2033,11 +2038,6 @@ export interface CoreResponse { created_at: string } -export interface CoreResponseList { - count: number - cores?: CoreResponse[] -} - export type CoreCreateFallbacksInboundTags = unknown[] | null export type CoreCreateExcludeInboundTags = unknown[] | null @@ -2176,10 +2176,10 @@ export type BaseHostVerifyPeerCertByName = string[] | null export type BaseHostPinnedPeerCertSha256 = string | null -export type BaseHostEchConfigList = string | null - export type BaseHostEchQueryStrategy = ECHQueryStrategy | null +export type BaseHostEchConfigList = string | null + export type BaseHostStatus = UserStatus[] | null export type BaseHostVlessRoute = string | null @@ -2689,7 +2689,7 @@ export const adminMiniAppToken = (signal?: AbortSignal) => { export const getAdminMiniAppTokenMutationOptions = < TData = Awaited>, - TError = ErrorType, + TError = ErrorType, TContext = unknown, >(options?: { mutation?: UseMutationOptions @@ -2710,12 +2710,16 @@ export const getAdminMiniAppTokenMutationOptions = < export type AdminMiniAppTokenMutationResult = NonNullable>> -export type AdminMiniAppTokenMutationError = ErrorType +export type AdminMiniAppTokenMutationError = ErrorType /** * @summary Admin Mini App Token */ -export const useAdminMiniAppToken = >, TError = ErrorType, TContext = unknown>(options?: { +export const useAdminMiniAppToken = < + TData = Awaited>, + TError = ErrorType, + TContext = unknown, +>(options?: { mutation?: UseMutationOptions }): UseMutationResult => { const mutationOptions = getAdminMiniAppTokenMutationOptions(options) @@ -2833,7 +2837,7 @@ export const modifyAdmin = (username: string, adminModify: BodyType export const getModifyAdminMutationOptions = < TData = Awaited>, - TError = ErrorType, + TError = ErrorType, TContext = unknown, >(options?: { mutation?: UseMutationOptions }, TContext> @@ -2856,12 +2860,16 @@ export const getModifyAdminMutationOptions = < export type ModifyAdminMutationResult = NonNullable>> export type ModifyAdminMutationBody = BodyType -export type ModifyAdminMutationError = ErrorType +export type ModifyAdminMutationError = ErrorType /** * @summary Modify Admin */ -export const useModifyAdmin = >, TError = ErrorType, TContext = unknown>(options?: { +export const useModifyAdmin = < + TData = Awaited>, + TError = ErrorType, + TContext = unknown, +>(options?: { mutation?: UseMutationOptions }, TContext> }): UseMutationResult }, TContext> => { const mutationOptions = getModifyAdminMutationOptions(options) diff --git a/dashboard/src/utils/userPreferenceStorage.ts b/dashboard/src/utils/userPreferenceStorage.ts index 3c7aa8644..7e643ed58 100644 --- a/dashboard/src/utils/userPreferenceStorage.ts +++ b/dashboard/src/utils/userPreferenceStorage.ts @@ -4,11 +4,18 @@ const NUM_ITEMS_PER_PAGE_DEFAULT = 10 const USERS_AUTO_REFRESH_INTERVAL_KEY = 'pasarguard-users-auto-refresh-interval' const DEFAULT_USERS_AUTO_REFRESH_INTERVAL_SECONDS = 0 +const USERS_SHOW_CREATED_BY_KEY = 'pasarguard-users-show-created-by' +const DEFAULT_USERS_SHOW_CREATED_BY = true +const CHART_VIEW_TYPE_KEY = 'pasarguard-chart-view-type' export const DATE_PICKER_PREFERENCE_KEY = 'pasarguard-date-picker-preference' export type DatePickerPreference = 'locale' | 'gregorian' | 'persian' const DEFAULT_DATE_PICKER_PREFERENCE: DatePickerPreference = 'locale' +export const CHART_VIEW_TYPE_CHANGE_EVENT = 'pasarguard-chart-view-type-change' +export type ChartViewType = 'bar' | 'area' +const DEFAULT_CHART_VIEW_TYPE: ChartViewType = 'bar' + // Generic function for any table type export const getItemsPerPageLimitSize = (tableType: 'users' | 'admins' = 'users') => { const storageKey = tableType === 'users' ? NUM_USERS_PER_PAGE_LOCAL_STORAGE_KEY : NUM_ADMINS_PER_PAGE_LOCAL_STORAGE_KEY @@ -39,6 +46,18 @@ export const setUsersAutoRefreshIntervalSeconds = (seconds: number) => { localStorage.setItem(USERS_AUTO_REFRESH_INTERVAL_KEY, seconds.toString()) } +export const getUsersShowCreatedBy = () => { + if (typeof localStorage === 'undefined') return DEFAULT_USERS_SHOW_CREATED_BY + const storedValue = localStorage.getItem(USERS_SHOW_CREATED_BY_KEY) + if (storedValue === null) return DEFAULT_USERS_SHOW_CREATED_BY + return storedValue === 'true' +} + +export const setUsersShowCreatedBy = (value: boolean) => { + if (typeof localStorage === 'undefined') return + localStorage.setItem(USERS_SHOW_CREATED_BY_KEY, value ? 'true' : 'false') +} + export const getDatePickerPreference = (): DatePickerPreference => { if (typeof localStorage === 'undefined') return DEFAULT_DATE_PICKER_PREFERENCE const storedValue = localStorage.getItem(DATE_PICKER_PREFERENCE_KEY) @@ -52,3 +71,20 @@ export const setDatePickerPreference = (preference: DatePickerPreference) => { if (typeof localStorage === 'undefined') return localStorage.setItem(DATE_PICKER_PREFERENCE_KEY, preference) } + +export const getChartViewTypePreference = (): ChartViewType => { + if (typeof localStorage === 'undefined') return DEFAULT_CHART_VIEW_TYPE + const storedValue = localStorage.getItem(CHART_VIEW_TYPE_KEY) + if (storedValue === 'bar' || storedValue === 'area') { + return storedValue + } + return DEFAULT_CHART_VIEW_TYPE +} + +export const setChartViewTypePreference = (viewType: ChartViewType) => { + if (typeof localStorage === 'undefined') return + localStorage.setItem(CHART_VIEW_TYPE_KEY, viewType) + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(CHART_VIEW_TYPE_CHANGE_EVENT, { detail: viewType })) + } +} diff --git a/install_service.sh b/install_service.sh index 73980a5f5..97695fd43 100755 --- a/install_service.sh +++ b/install_service.sh @@ -14,7 +14,7 @@ Documentation=$SERVICE_DOCUMENTATION After=network.target nss-lookup.target [Service] -ExecStart=/usr/bin/env python3 $MAIN_PY_PATH +ExecStart=$PWD/.venv/bin/python3 $MAIN_PY_PATH Restart=on-failure WorkingDirectory=$PWD diff --git a/tests/api/helpers.py b/tests/api/helpers.py index d4ffef3cc..93ff38d7c 100644 --- a/tests/api/helpers.py +++ b/tests/api/helpers.py @@ -190,6 +190,8 @@ def create_user_template( extra_settings: dict[str, Any] | None = None, status_value: str = "active", reset_usages: bool = True, + username_prefix: str | None = None, + username_suffix: str | None = None, ) -> dict: payload = { "name": name or unique_name("user_template"), @@ -200,6 +202,10 @@ def create_user_template( "status": status_value, "reset_usages": reset_usages, } + if username_prefix is not None: + payload["username_prefix"] = username_prefix + if username_suffix is not None: + payload["username_suffix"] = username_suffix response = client.post("/api/user_template", headers=auth_headers(access_token), json=payload) assert response.status_code == status.HTTP_201_CREATED return response.json() diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py index e2bb1947e..67d4191c2 100644 --- a/tests/api/test_admin.py +++ b/tests/api/test_admin.py @@ -273,6 +273,38 @@ def test_sudo_admin_can_modify_self(access_token): delete_admin(access_token, sudo_admin["username"]) +def test_sudo_admin_cannot_disable_self(access_token): + """A sudo admin cannot disable their own account.""" + sudo_admin = create_admin(access_token) + set_admin_sudo(sudo_admin["username"], True) + try: + login_response = client.post( + url="/api/admin/token", + data={ + "username": sudo_admin["username"], + "password": sudo_admin["password"], + "grant_type": "password", + }, + ) + assert login_response.status_code == status.HTTP_200_OK + sudo_token = login_response.json()["access_token"] + + response = client.put( + url=f"/api/admin/{sudo_admin['username']}", + json={ + "is_sudo": True, + "is_disabled": True, + }, + headers={"Authorization": f"Bearer {sudo_token}"}, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "You're not allowed to disable your own account." + finally: + set_admin_sudo(sudo_admin["username"], False) + delete_admin(access_token, sudo_admin["username"]) + + def test_sudo_admin_cannot_modify_other_sudo_admin(access_token): """A sudo admin cannot edit another sudo admin account.""" sudo_admin_a = create_admin(access_token) diff --git a/tests/api/test_user.py b/tests/api/test_user.py index 88685aa35..d68ca55fe 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -439,6 +439,52 @@ def test_bulk_create_users_from_template_sequence(access_token): cleanup_groups(access_token, core, groups) +def test_bulk_create_users_from_template_sequence_with_template_affixes(access_token): + core, groups = setup_groups(access_token, 1) + prefix = "pre_" + suffix = "_suf" + template = create_user_template( + access_token, + group_ids=[groups[0]["id"]], + username_prefix=prefix, + username_suffix=suffix, + ) + base_username = unique_name("bulk_template_affix_seq") + count = 2 + start_number = 7 + expected_usernames: list[str] = [] + + try: + response = client.post( + "/api/users/bulk/from_template", + headers={"Authorization": f"Bearer {access_token}"}, + json={ + "user_template_id": template["id"], + "strategy": "sequence", + "username": base_username, + "count": count, + "start_number": start_number, + }, + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["created"] == count + assert len(response.json()["subscription_urls"]) == count + + expected_usernames = [f"{prefix}{base_username}{suffix}{start_number + idx}" for idx in range(count)] + + for username in expected_usernames: + user_response = client.get(f"/api/user/{username}", headers={"Authorization": f"Bearer {access_token}"}) + assert user_response.status_code == status.HTTP_200_OK + assert user_response.json()["data_limit"] == template["data_limit"] + assert user_response.json()["status"] == template["status"] + finally: + for username in expected_usernames: + delete_user(access_token, username) + delete_user_template(access_token, template["id"]) + cleanup_groups(access_token, core, groups) + + def test_bulk_create_users_from_template_random(access_token): core, groups = setup_groups(access_token, 1) template = create_user_template(access_token, group_ids=[groups[0]["id"]])