From abcf074c7b78205be32e5ea1807e41e9f001e50d Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Thu, 14 May 2026 15:35:29 +0330
Subject: [PATCH 1/6] feat: implement user, subscription, and HWID management
models and API infrastructure
---
app/db/crud/hwid.py | 69 ++++++++++++++++
app/db/crud/user.py | 19 ++++-
...f02194c811d6_add_hwid_models_and_fields.py | 73 +++++++++++++++++
app/db/models.py | 32 +++++++-
app/models/settings.py | 9 +++
app/models/subscription.py | 9 +++
app/models/user.py | 18 +++++
app/models/user_template.py | 1 +
app/operation/hwid.py | 25 ++++++
app/operation/subscription.py | 80 +++++++++++++++++--
app/operation/user.py | 43 ++++++++--
app/routers/__init__.py | 17 +++-
app/routers/dependencies/__init__.py | 3 +-
app/routers/dependencies/_common.py | 48 ++++++++++-
app/routers/dependencies/subscription.py | 6 +-
app/routers/hwid.py | 44 ++++++++++
app/routers/subscription.py | 8 +-
app/settings/__init__.py | 10 +++
tests/api/conftest.py | 19 +++++
tests/api/test_hwid.py | 71 ++++++++++++++++
20 files changed, 581 insertions(+), 23 deletions(-)
create mode 100644 app/db/crud/hwid.py
create mode 100644 app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py
create mode 100644 app/operation/hwid.py
create mode 100644 app/routers/hwid.py
create mode 100644 tests/api/test_hwid.py
diff --git a/app/db/crud/hwid.py b/app/db/crud/hwid.py
new file mode 100644
index 000000000..11688550e
--- /dev/null
+++ b/app/db/crud/hwid.py
@@ -0,0 +1,69 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import delete, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import UserHWID
+
+
+async def get_user_hwids(db: AsyncSession, user_id: int) -> list[UserHWID]:
+ """Retrieve all HWIDs registered for a specific user."""
+ stmt = select(UserHWID).where(UserHWID.user_id == user_id).order_by(UserHWID.created_at.desc())
+ result = await db.execute(stmt)
+ return list(result.scalars().all())
+
+
+async def get_user_hwid_by_value(db: AsyncSession, user_id: int, hwid_str: str) -> UserHWID | None:
+ """Retrieve a specific HWID for a user by its value."""
+ stmt = select(UserHWID).where(UserHWID.user_id == user_id, UserHWID.hwid == hwid_str)
+ return (await db.execute(stmt)).scalar_one_or_none()
+
+
+async def get_user_hwid_count(db: AsyncSession, user_id: int) -> int:
+ """Count the number of HWIDs registered for a user."""
+ stmt = select(func.count(UserHWID.id)).where(UserHWID.user_id == user_id)
+ return (await db.execute(stmt)).scalar_one()
+
+
+async def register_user_hwid(
+ db: AsyncSession,
+ user_id: int,
+ hwid: str,
+ device_os: str | None = None,
+ os_version: str | None = None,
+ device_model: str | None = None,
+) -> UserHWID:
+ """Register a new HWID for a user."""
+ new_hwid = UserHWID(
+ user_id=user_id,
+ hwid=hwid,
+ device_os=device_os[:256] if device_os else None,
+ os_version=os_version[:128] if os_version else None,
+ device_model=device_model[:256] if device_model else None,
+ )
+ db.add(new_hwid)
+ await db.commit()
+ await db.refresh(new_hwid)
+ return new_hwid
+
+
+async def update_hwid_last_used(db: AsyncSession, hwid_obj: UserHWID) -> None:
+ """Update the last_used_at timestamp for an HWID."""
+ hwid_obj.last_used_at = datetime.now(timezone.utc)
+ await db.commit()
+
+
+async def delete_user_hwid(db: AsyncSession, user_id: int, hwid: str) -> bool:
+ """Delete a specific HWID for a user by its value. Returns True if deleted."""
+ stmt = delete(UserHWID).where(UserHWID.user_id == user_id, UserHWID.hwid == hwid)
+ result = await db.execute(stmt)
+ await db.commit()
+ return result.rowcount > 0
+
+
+async def reset_user_hwids(db: AsyncSession, user_id: int) -> int:
+ """Delete all HWIDs for a user. Returns the number of HWIDs deleted."""
+ stmt = delete(UserHWID).where(UserHWID.user_id == user_id)
+ result = await db.execute(stmt)
+ await db.commit()
+ return result.rowcount
diff --git a/app/db/crud/user.py b/app/db/crud/user.py
index 7acd88a65..97adda4d7 100644
--- a/app/db/crud/user.py
+++ b/app/db/crud/user.py
@@ -790,7 +790,9 @@ async def create_user(db: AsyncSession, new_user: UserCreate, groups: list[Group
db_user.groups = groups
db_user.expire = new_user.expire or None
db_user.on_hold_timeout = new_user.on_hold_timeout or None
- db_user.proxy_settings = new_user.proxy_settings.dict()
+
+ if new_user.hwid_limit is not None:
+ db_user.hwid_limit = new_user.hwid_limit
db.add(db_user)
await db.flush()
@@ -821,6 +823,7 @@ async def create_users_bulk(
db_user.groups = list(groups)
db_user.expire = new_user.expire or None
db_user.on_hold_timeout = new_user.on_hold_timeout or None
+ db_user.hwid_limit = new_user.hwid_limit if new_user.hwid_limit is not None else None
db_user.proxy_settings = new_user.proxy_settings.dict()
db_users.append(db_user)
@@ -961,6 +964,9 @@ async def modify_user(
if modify.on_hold_expire_duration is not None:
db_user.on_hold_expire_duration = modify.on_hold_expire_duration
+ if modify.hwid_limit is not None:
+ db_user.hwid_limit = modify.hwid_limit
+
if modify.next_plan is not None:
db_user.next_plan = NextPlan(
user_id=db_user.id,
@@ -1173,7 +1179,9 @@ async def bulk_revoke_user_sub(db: AsyncSession, users: list[User]) -> list[User
return users
-async def user_sub_update(db: AsyncSession, user_id: int, user_agent: str, ip: str | None = None) -> None:
+async def user_sub_update(
+ db: AsyncSession, user_id: int, user_agent: str, ip: str | None = None, hwid: str | None = None
+) -> None:
"""
Updates the user's subscription details.
@@ -1182,12 +1190,15 @@ async def user_sub_update(db: AsyncSession, user_id: int, user_agent: str, ip: s
user_id (int): The user id whose subscription is to be updated.
user_agent (str): The user agent string.
ip (str | None): The client IP address.
-
+ hwid (str | None): The hardware ID of the client.
"""
# Clamp to column length; some clients send very long strings (e.g. encoded configs) as User-Agent.
sanitized_user_agent = (user_agent or "")[:_USER_AGENT_MAX_LEN]
sanitized_ip = (ip or "")[:_SUBSCRIPTION_UPDATE_IP_MAX_LEN] or None
- agent = UserSubscriptionUpdate(user_id=user_id, user_agent=sanitized_user_agent, ip=sanitized_ip)
+ sanitized_hwid = (hwid or "")[:256] or None
+ agent = UserSubscriptionUpdate(
+ user_id=user_id, user_agent=sanitized_user_agent, ip=sanitized_ip, hwid=sanitized_hwid
+ )
db.add(agent)
await db.commit()
diff --git a/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py b/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py
new file mode 100644
index 000000000..a2b2a0592
--- /dev/null
+++ b/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py
@@ -0,0 +1,73 @@
+"""Add HWID support
+
+Revision ID: f02194c811d6
+Revises: 73c78c6a9b24
+Create Date: 2026-05-14 14:23:22.927015
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import app.db.compiles_types
+
+
+# revision identifiers, used by Alembic.
+revision = 'f02194c811d6'
+down_revision = '73c78c6a9b24'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'user_hwids',
+ sa.Column('id', app.db.compiles_types.SqliteCompatibleBigInteger(), autoincrement=True, nullable=False),
+ sa.Column('user_id', app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False),
+ sa.Column('hwid', sa.String(length=256), nullable=False),
+ sa.Column('device_os', sa.String(length=256), nullable=True),
+ sa.Column('os_version', sa.String(length=128), nullable=True),
+ sa.Column('device_model', sa.String(length=256), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_hwids_user_id_users'), ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_user_hwids')),
+ sa.UniqueConstraint('user_id', 'hwid', name=op.f('uq_user_hwids_user_id')),
+ )
+ with op.batch_alter_table('user_hwids', schema=None) as batch_op:
+ batch_op.create_index('ix_user_hwids_user_id', ['user_id'], unique=False)
+ batch_op.create_index('ix_user_hwids_hwid', ['hwid'], unique=False)
+ batch_op.create_index('ix_user_hwids_created_at', ['created_at'], unique=False)
+ batch_op.create_index('ix_user_hwids_last_used_at', ['last_used_at'], unique=False)
+
+ with op.batch_alter_table('settings', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('hwid', sa.JSON(), server_default='{}', nullable=False))
+
+ with op.batch_alter_table('user_subscription_updates', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('hwid', sa.String(length=256), nullable=True))
+
+ with op.batch_alter_table('user_templates', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('hwid_limit', sa.BigInteger(), nullable=True))
+
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('hwid_limit', sa.BigInteger(), nullable=True))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.drop_column('hwid_limit')
+
+ with op.batch_alter_table('user_templates', schema=None) as batch_op:
+ batch_op.drop_column('hwid_limit')
+
+ with op.batch_alter_table('user_subscription_updates', schema=None) as batch_op:
+ batch_op.drop_column('hwid')
+
+ with op.batch_alter_table('settings', schema=None) as batch_op:
+ batch_op.drop_column('hwid')
+
+ with op.batch_alter_table('user_hwids', schema=None) as batch_op:
+ batch_op.drop_index('ix_user_hwids_last_used_at')
+ batch_op.drop_index('ix_user_hwids_created_at')
+ batch_op.drop_index('ix_user_hwids_hwid')
+ batch_op.drop_index('ix_user_hwids_user_id')
+
+ op.drop_table('user_hwids')
diff --git a/app/db/models.py b/app/db/models.py
index 69c892889..32576f644 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -162,8 +162,11 @@ class User(Base):
next_plan: Mapped[Optional["NextPlan"]] = relationship(
uselist=False, back_populates="user", cascade="all, delete-orphan", init=False
)
+ hwids: Mapped[List["UserHWID"]] = relationship(back_populates="user", cascade="all, delete-orphan", init=False)
groups: Mapped[List["Group"]] = relationship(secondary=users_groups_association, back_populates="users", init=False)
- proxy_settings: Mapped[Dict[str, Any]] = mapped_column(JSON(True), server_default=text("'{}'"), default=lambda: {})
+ proxy_settings: Mapped[Dict[str, Any]] = mapped_column(
+ JSON(True), server_default=text("'{}'"), default_factory=dict
+ )
status: Mapped[UserStatus] = mapped_column(SQLEnum(UserStatus), default=UserStatus.active)
used_traffic: Mapped[int] = mapped_column(BigInteger, default=0)
data_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
@@ -179,6 +182,7 @@ class User(Base):
on_hold_expire_duration: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
on_hold_timeout: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
auto_delete_in_days: Mapped[Optional[int]] = mapped_column(default=None)
+ hwid_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
edit_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
last_status_change: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
@@ -332,6 +336,30 @@ class UserSubscriptionUpdate(Base):
created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
user_agent: Mapped[str] = mapped_column(String(512))
ip: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, default=None)
+ hwid: Mapped[Optional[str]] = mapped_column(String(256), nullable=True, default=None)
+
+
+class UserHWID(Base):
+ __tablename__ = "user_hwids"
+ __table_args__ = (
+ UniqueConstraint("user_id", "hwid"),
+ Index("ix_user_hwids_user_id", "user_id"),
+ Index("ix_user_hwids_hwid", "hwid"),
+ Index("ix_user_hwids_created_at", "created_at"),
+ Index("ix_user_hwids_last_used_at", "last_used_at"),
+ )
+
+ id: Mapped[int] = id_column()
+ user_id: Mapped[int] = fk_id_column("users.id", ondelete="CASCADE")
+ user: Mapped["User"] = relationship(back_populates="hwids", init=False)
+ hwid: Mapped[str] = mapped_column(String(256), nullable=False)
+ device_os: Mapped[Optional[str]] = mapped_column(String(256), default=None)
+ os_version: Mapped[Optional[str]] = mapped_column(String(128), default=None)
+ device_model: Mapped[Optional[str]] = mapped_column(String(256), default=None)
+ created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
+ last_used_at: Mapped[dt] = mapped_column(
+ DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False
+ )
template_group_association = Table(
@@ -378,6 +406,7 @@ class UserTemplate(Base):
)
groups: Mapped[List["Group"]] = relationship(secondary=template_group_association, back_populates="templates")
data_limit: Mapped[int] = mapped_column(BigInteger, default=0)
+ hwid_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
expire_duration: Mapped[int] = mapped_column(BigInteger, default=0) # in seconds
on_hold_timeout: Mapped[Optional[int]] = mapped_column(default=None)
status: Mapped[UserStatusCreate] = mapped_column(SQLEnum(UserStatusCreate), default=UserStatusCreate.active)
@@ -790,4 +819,5 @@ class Settings(Base):
notification_settings: Mapped[dict] = mapped_column(JSON())
notification_enable: Mapped[dict] = mapped_column(JSON())
subscription: Mapped[dict] = mapped_column(JSON())
+ hwid: Mapped[dict] = mapped_column(JSON(), server_default="{}")
general: Mapped[dict] = mapped_column(JSON())
diff --git a/app/models/settings.py b/app/models/settings.py
index ba20305dd..55ca8fef9 100644
--- a/app/models/settings.py
+++ b/app/models/settings.py
@@ -285,6 +285,14 @@ def validate_recommended_apps(cls, v: list[Application]) -> list[Application]:
return v
+class HWIDSettings(BaseModel):
+ enabled: bool = Field(default=False)
+ forced: bool = Field(default=False)
+ fallback_limit: int = Field(default=0, ge=0)
+ min_limit: int = Field(default=0, ge=0)
+ max_limit: int = Field(default=0, ge=0)
+
+
class General(BaseModel):
default_flow: XTLSFlows = Field(default=XTLSFlows.NONE)
default_method: ShadowsocksMethods = Field(default=ShadowsocksMethods.CHACHA20_POLY1305)
@@ -297,6 +305,7 @@ class SettingsSchema(BaseModel):
notification_settings: NotificationSettings | None = Field(default=None)
notification_enable: NotificationEnable | None = Field(default=None)
subscription: Subscription | None = Field(default=None)
+ hwid: HWIDSettings | None = Field(default=None)
general: General | None = Field(default=None)
model_config = ConfigDict(from_attributes=True)
diff --git a/app/models/subscription.py b/app/models/subscription.py
index 51c589294..1f0e8cfef 100644
--- a/app/models/subscription.py
+++ b/app/models/subscription.py
@@ -285,3 +285,12 @@ def validate_datetimes(cls, value):
if not value:
return value
return fix_datetime_timezone(value)
+
+
+class SubscriptionHeaders(BaseModel):
+ x_hwid: str | None = Field(default=None, alias="X-HWID")
+ x_device_os: str | None = Field(default=None, alias="X-Device-OS")
+ x_ver_os: str | None = Field(default=None, alias="X-Ver-OS")
+ x_device_model: str | None = Field(default=None, alias="X-Device-Model")
+
+ model_config = {"populate_by_name": True}
diff --git a/app/models/user.py b/app/models/user.py
index 7649d5ce6..220eb1496 100644
--- a/app/models/user.py
+++ b/app/models/user.py
@@ -37,6 +37,7 @@ class User(BaseModel):
on_hold_timeout: dt | int | None = Field(default=None)
group_ids: list[int] | None = Field(default_factory=list)
auto_delete_in_days: int | None = Field(default=None)
+ hwid_limit: int | None = Field(default=None)
next_plan: NextPlanModel | None = Field(default=None)
@@ -318,6 +319,7 @@ class UserSubscriptionUpdateSchema(BaseModel):
created_at: dt
user_agent: str
ip: str | None = Field(default=None)
+ hwid: str | None = Field(default=None)
model_config = ConfigDict(from_attributes=True)
@@ -345,6 +347,22 @@ class UserSubscriptionUpdateChart(BaseModel):
segments: list[UserSubscriptionUpdateChartSegment] = Field(default_factory=list)
+class UserHWIDResponse(BaseModel):
+ id: int
+ hwid: str
+ device_os: str | None = None
+ os_version: str | None = None
+ device_model: str | None = None
+ created_at: dt
+ last_used_at: dt
+ model_config = ConfigDict(from_attributes=True)
+
+
+class UserHWIDListResponse(BaseModel):
+ hwids: list[UserHWIDResponse]
+ count: int
+
+
class RemoveUsersResponse(BaseModel):
users: list[str]
count: int
diff --git a/app/models/user_template.py b/app/models/user_template.py
index f6f6abdc4..b2c49cb75 100644
--- a/app/models/user_template.py
+++ b/app/models/user_template.py
@@ -22,6 +22,7 @@ def dict(self, *, no_obj=True, **kwargs):
class UserTemplate(BaseModel):
name: str | None = None
data_limit: int | None = Field(ge=0, default=None, description="data_limit can be 0 or greater")
+ hwid_limit: int | None = Field(default=None)
expire_duration: int | None = Field(
ge=0, default=None, description="expire_duration can be 0 or greater in seconds"
)
diff --git a/app/operation/hwid.py b/app/operation/hwid.py
new file mode 100644
index 000000000..3bde12520
--- /dev/null
+++ b/app/operation/hwid.py
@@ -0,0 +1,25 @@
+from app.db import AsyncSession
+from app.db.crud.hwid import delete_user_hwid, get_user_hwids, reset_user_hwids
+from app.models.admin import AdminDetails
+from app.models.user import UserHWIDListResponse, UserHWIDResponse
+from app.operation import BaseOperation
+
+
+class HWIDOperation(BaseOperation):
+ async def get_user_hwids(self, db: AsyncSession, user_id: int, admin: AdminDetails) -> UserHWIDListResponse:
+ db_user = await self.get_validated_user_by_id(db, user_id, admin)
+ hwids = await get_user_hwids(db, db_user.id)
+ hwid_responses = [UserHWIDResponse.model_validate(h) for h in hwids]
+ return UserHWIDListResponse(hwids=hwid_responses, count=len(hwid_responses))
+
+ async def delete_user_hwid(self, db: AsyncSession, user_id: int, hwid: str, admin: AdminDetails) -> dict:
+ db_user = await self.get_validated_user_by_id(db, user_id, admin)
+ deleted = await delete_user_hwid(db, db_user.id, hwid)
+ if not deleted:
+ await self.raise_error(message="HWID not found", code=404)
+ return {}
+
+ async def reset_user_hwids(self, db: AsyncSession, user_id: int, admin: AdminDetails) -> dict:
+ db_user = await self.get_validated_user_by_id(db, user_id, admin)
+ count = await reset_user_hwids(db, db_user.id)
+ return {"count": count}
diff --git a/app/operation/subscription.py b/app/operation/subscription.py
index 18b0005b1..6c937a90f 100644
--- a/app/operation/subscription.py
+++ b/app/operation/subscription.py
@@ -7,13 +7,19 @@
from app.db import AsyncSession
from app.db.crud.user import get_user_usages, user_sub_update
+from app.db.crud.hwid import (
+ get_user_hwid_by_value,
+ get_user_hwid_count,
+ register_user_hwid,
+ update_hwid_last_used,
+)
from app.db.models import User
from app.models.admin import AdminDetails
-from app.models.settings import Application, ConfigFormat, SubRule, Subscription as SubSettings
+from app.models.settings import Application, ConfigFormat, SubRule, Subscription as SubSettings, HWIDSettings
from app.models.stats import UserUsageStatsList
from app.models.subscription import SubscriptionUsageQuery
from app.models.user import SubscriptionUserResponse, UsersResponseWithInbounds
-from app.settings import subscription_settings
+from app.settings import subscription_settings, hwid_settings
from app.subscription.share import encode_title, generate_subscription, setup_format_variables
from app.templates import render_template
from config import template_settings, wireguard_settings
@@ -247,6 +253,41 @@ async def fetch_config(self, user: UsersResponseWithInbounds, client_type: Confi
config["media_type"],
)
+ async def validate_and_register_hwid(
+ self,
+ db: AsyncSession,
+ user_id: int,
+ user_hwid_limit: int | None,
+ x_hwid: str | None,
+ x_device_os: str | None,
+ x_ver_os: str | None,
+ x_device_model: str | None,
+ ):
+ hwid_conf: HWIDSettings = await hwid_settings()
+ if not hwid_conf.enabled:
+ return
+
+ if not x_hwid:
+ if hwid_conf.forced:
+ await self.raise_error(message="HWID header required", code=403)
+ return
+
+ existing_hwid = await get_user_hwid_by_value(db, user_id, x_hwid)
+ if existing_hwid:
+ await update_hwid_last_used(db, existing_hwid)
+ return
+
+ # It's a new HWID, check limit
+ limit = user_hwid_limit if user_hwid_limit is not None else hwid_conf.fallback_limit
+ if limit == 0:
+ pass # unlimited
+ else:
+ current_count = await get_user_hwid_count(db, user_id)
+ if current_count >= limit:
+ await self.raise_error(message="Device limit reached", code=403)
+
+ await register_user_hwid(db, user_id, x_hwid, x_device_os, x_ver_os, x_device_model)
+
async def user_subscription(
self,
db: AsyncSession,
@@ -255,6 +296,10 @@ async def user_subscription(
user_agent: str = "",
ip: str | None = None,
request_url: str = "",
+ x_hwid: str | None = None,
+ x_device_os: str | None = None,
+ x_ver_os: str | None = None,
+ x_device_model: str | None = None,
):
"""
Provides a subscription link based on the user agent (Clash, V2Ray, etc.).
@@ -264,6 +309,10 @@ async def user_subscription(
db_user = await self.get_validated_sub(db, token)
user = await self.validated_user(db_user)
+ await self.validate_and_register_hwid(
+ db, db_user.id, db_user.hwid_limit, x_hwid, x_device_os, x_ver_os, x_device_model
+ )
+
is_browser_request = "text/html" in accept_header
if not sub_settings.disable_sub_template and is_browser_request:
@@ -300,7 +349,7 @@ async def user_subscription(
await self.raise_error(message="Client not supported", code=406)
# Update user subscription info
- await user_sub_update(db, db_user.id, user_agent, ip=ip)
+ await user_sub_update(db, db_user.id, user_agent, ip=ip, hwid=x_hwid)
conf, media_type = await self.fetch_config(user, client_type)
# If disable_sub_template is True and it's a browser request, use inline to view instead of download
@@ -345,7 +394,16 @@ async def _get_rule_response_header_variables(
return format_variables
async def user_subscription_with_client_type(
- self, db: AsyncSession, token: str, client_type: ConfigFormat, request_url: str = "", accept_header: str = ""
+ self,
+ db: AsyncSession,
+ token: str,
+ client_type: ConfigFormat,
+ request_url: str = "",
+ accept_header: str = "",
+ x_hwid: str | None = None,
+ x_device_os: str | None = None,
+ x_ver_os: str | None = None,
+ x_device_model: str | None = None,
):
"""Provides a subscription link based on the specified client type (e.g., Clash, V2Ray)."""
sub_settings: SubSettings = await subscription_settings()
@@ -358,6 +416,10 @@ async def user_subscription_with_client_type(
db_user = await self.get_validated_sub(db, token=token)
user = await self.validated_user(db_user)
+ await self.validate_and_register_hwid(
+ db, db_user.id, db_user.hwid_limit, x_hwid, x_device_os, x_ver_os, x_device_model
+ )
+
response_headers = self.create_response_headers(
user, request_url, sub_settings, extension=client_config.get(client_type, {}).get("extension", "")
)
@@ -392,13 +454,21 @@ async def user_subscription_raw(
token: str,
update_user_agent: str = "",
ip: str | None = None,
+ x_hwid: str | None = None,
+ x_device_os: str | None = None,
+ x_ver_os: str | None = None,
+ x_device_model: str | None = None,
):
sub_settings: SubSettings = await subscription_settings()
db_user = await self.get_validated_sub(db, token)
user = await self.validated_user(db_user)
+ await self.validate_and_register_hwid(
+ db, db_user.id, db_user.hwid_limit, x_hwid, x_device_os, x_ver_os, x_device_model
+ )
+
if update_user_agent:
- await user_sub_update(db, db_user.id, update_user_agent, ip=ip)
+ await user_sub_update(db, db_user.id, update_user_agent, ip=ip, hwid=x_hwid)
links = []
if sub_settings.allow_browser_config:
diff --git a/app/operation/user.py b/app/operation/user.py
index 2a7813dff..6ad0b4c34 100644
--- a/app/operation/user.py
+++ b/app/operation/user.py
@@ -90,7 +90,7 @@
)
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.settings import subscription_settings, hwid_settings
from app.utils.jwt import create_subscription_token
from app.utils.logger import get_logger
from app.utils.wireguard import (
@@ -246,14 +246,11 @@ async def _persist_bulk_users(
db_users = await create_users_bulk(db, users_to_create, groups, db_admin)
- subscription_urls: list[str] = []
+ users_list = []
for db_user in db_users:
- user: UserNotificationResponse = await self.update_user(db_user)
- asyncio.create_task(notification.create_user(user, admin))
- logger.info(f'New user "{db_user.username}" with id "{db_user.id}" added by admin "{admin.username}"')
- subscription_urls.append(user.subscription_url)
+ users_list.append(await self.validate_user(db_user))
- return subscription_urls
+ return self._build_bulk_action_response(users_list)
async def validate_user(self, db_user: User, include_subscription_url: bool = True) -> UserNotificationResponse:
user = UserNotificationResponse.model_validate(db_user)
@@ -297,6 +294,17 @@ async def _prepare_user_proxy_settings(
await self.raise_error(message=str(exc), code=400, db=db)
async def create_user(self, db: AsyncSession, new_user: UserCreate, admin: AdminDetails) -> 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 < 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 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)
@@ -320,6 +328,26 @@ async def create_user(self, db: AsyncSession, new_user: UserCreate, admin: Admin
async def _modify_user(
self, db: AsyncSession, db_user: User, modified_user: UserModify, admin: AdminDetails
) -> UserResponse:
+ 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(
+ message=f"Cannot lower HWID limit below current device count ({current_count}). Remove devices first.",
+ code=400,
+ db=db,
+ )
+
+ if modified_user.hwid_limit is not None and not admin.is_sudo:
+ 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)
+ if hwid_conf.max_limit > 0 and (
+ modified_user.hwid_limit > hwid_conf.max_limit or modified_user.hwid_limit == 0
+ ):
+ await self.raise_error(message=f"HWID limit cannot exceed {hwid_conf.max_limit}", code=400, db=db)
+
validated_groups = None
if modified_user.group_ids:
validated_groups = await self.validate_all_groups(db, modified_user)
@@ -925,6 +953,7 @@ def load_base_user_args(template: UserTemplate) -> dict:
"group_ids": template.group_ids,
"data_limit_reset_strategy": template.data_limit_reset_strategy,
"status": template.status,
+ "hwid_limit": template.hwid_limit,
}
if template.status == UserStatus.active:
diff --git a/app/routers/__init__.py b/app/routers/__init__.py
index 03df78fbe..3f547ceb5 100644
--- a/app/routers/__init__.py
+++ b/app/routers/__init__.py
@@ -1,6 +1,20 @@
from fastapi import APIRouter
-from . import admin, core, client_template, group, home, host, node, settings, subscription, system, user, user_template
+from . import (
+ admin,
+ core,
+ client_template,
+ group,
+ home,
+ host,
+ node,
+ settings,
+ subscription,
+ system,
+ user,
+ user_template,
+ hwid,
+)
api_router = APIRouter()
@@ -17,6 +31,7 @@
user.router,
subscription.router,
user_template.router,
+ hwid.router,
]
for router in routers:
diff --git a/app/routers/dependencies/__init__.py b/app/routers/dependencies/__init__.py
index e1c598a3b..4324c2fc7 100644
--- a/app/routers/dependencies/__init__.py
+++ b/app/routers/dependencies/__init__.py
@@ -10,7 +10,7 @@
get_node_stats_period_query,
get_node_usage_query,
)
-from .subscription import get_subscription_usage_query
+from .subscription import get_subscription_headers, get_subscription_usage_query
from .user import (
get_expired_users_query,
get_user_list_query,
@@ -43,6 +43,7 @@
"get_node_stats_period_query",
"get_node_usage_query",
# subscription
+ "get_subscription_headers",
"get_subscription_usage_query",
# user
"get_expired_users_query",
diff --git a/app/routers/dependencies/_common.py b/app/routers/dependencies/_common.py
index 3ac1a940c..93f696b16 100644
--- a/app/routers/dependencies/_common.py
+++ b/app/routers/dependencies/_common.py
@@ -2,7 +2,8 @@
from inspect import Parameter, Signature
from typing import Any, Callable, cast
-from fastapi import HTTPException, Query, status
+from fastapi import Header, HTTPException, Query, status
+from fastapi.params import Header as HeaderParam
from fastapi.params import Query as QueryParam
from pydantic import BaseModel, ValidationError
from pydantic_core import PydanticUndefined
@@ -68,3 +69,48 @@ def factory(**kwargs: Any) -> BaseModel:
factory_func.__signature__ = Signature(parameters)
factory_func.__name__ = f"{cls.__name__}_query_factory"
return cast(Callable[..., BaseModel], factory_func)
+
+
+def make_header_dependency(
+ cls: type[BaseModel], field_overrides: dict[str, object] | None = None
+) -> Callable[..., BaseModel]:
+ field_overrides = field_overrides or {}
+ parameters: list[Parameter] = []
+
+ for field_name, field_info in cls.model_fields.items():
+ annotation = field_info.annotation
+ if field_name in field_overrides:
+ override = field_overrides[field_name]
+ if isinstance(override, ParameterOverride):
+ annotation = override.annotation
+ default = override.default
+ else:
+ default = override
+ elif field_info.default_factory is not None:
+ default = field_info.get_default(call_default_factory=True)
+ elif field_info.default is PydanticUndefined:
+ default = Parameter.empty
+ else:
+ default = field_info.default
+
+ # Ensure everything is explicitly a Header parameter
+ if not isinstance(default, (HeaderParam, ParameterOverride)) and default is not Parameter.empty:
+ alias = field_info.alias if field_info.alias else field_name.replace("_", "-").title()
+ default = Header(default, alias=alias)
+
+ parameters.append(
+ Parameter(
+ field_name,
+ Parameter.KEYWORD_ONLY,
+ default=default,
+ annotation=annotation,
+ )
+ )
+
+ def factory(**kwargs: Any) -> BaseModel:
+ return build_query(cls, **{key: value for key, value in kwargs.items() if value is not None})
+
+ factory_func = cast(Any, factory)
+ factory_func.__signature__ = Signature(parameters)
+ factory_func.__name__ = f"{cls.__name__}_header_factory"
+ return cast(Callable[..., BaseModel], factory_func)
diff --git a/app/routers/dependencies/subscription.py b/app/routers/dependencies/subscription.py
index a13b70db5..1aee9487f 100644
--- a/app/routers/dependencies/subscription.py
+++ b/app/routers/dependencies/subscription.py
@@ -1,9 +1,9 @@
from fastapi import Query
from app.models.stats import Period
-from app.models.subscription import SubscriptionUsageQuery
+from app.models.subscription import SubscriptionHeaders, SubscriptionUsageQuery
-from ._common import make_query_dependency
+from ._common import make_header_dependency, make_query_dependency
get_subscription_usage_query = make_query_dependency(
SubscriptionUsageQuery,
@@ -13,3 +13,5 @@
"end": Query(None, examples=["2024-01-31T23:59:59+03:30"]),
},
)
+
+get_subscription_headers = make_header_dependency(SubscriptionHeaders)
diff --git a/app/routers/hwid.py b/app/routers/hwid.py
new file mode 100644
index 000000000..b55f18f07
--- /dev/null
+++ b/app/routers/hwid.py
@@ -0,0 +1,44 @@
+from fastapi import APIRouter, Depends
+
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminDetails
+from app.models.user import UserHWIDListResponse
+from app.operation import OperatorType
+from app.operation.hwid import HWIDOperation
+from app.utils import responses
+from .authentication import get_current
+
+hwid_operator = HWIDOperation(operator_type=OperatorType.API)
+router = APIRouter(tags=["User HWID"], prefix="/api/user", responses={401: responses._401})
+
+
+@router.get(
+ "/{user_id}/hwids",
+ 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)):
+ """Get user's registered hardware IDs"""
+ return await hwid_operator.get_user_hwids(db, user_id=user_id, admin=admin)
+
+
+@router.delete(
+ "/{user_id}/hwids/{hwid}",
+ 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)
+):
+ """Delete a specific hardware ID from user"""
+ return await hwid_operator.delete_user_hwid(db, user_id=user_id, hwid=hwid, admin=admin)
+
+
+@router.post(
+ "/{user_id}/hwids/reset",
+ 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)
+):
+ """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/subscription.py b/app/routers/subscription.py
index d9713fc11..4159030ca 100644
--- a/app/routers/subscription.py
+++ b/app/routers/subscription.py
@@ -9,7 +9,7 @@
from app.operation.subscription import SubscriptionOperation
from config import subscription_env_settings
-from .dependencies import get_subscription_usage_query
+from .dependencies import get_subscription_headers, get_subscription_usage_query
router = APIRouter(tags=["Subscription"], prefix=f"/{subscription_env_settings.path}")
subscription_operator = SubscriptionOperation(operator_type=OperatorType.API)
@@ -22,6 +22,7 @@ async def user_subscription(
token: str,
db: AsyncSession = Depends(get_db),
user_agent: str = Header(default=""),
+ headers=Depends(get_subscription_headers),
):
"""Provides a subscription link based on the user agent (Clash, V2Ray, etc.)."""
return await subscription_operator.user_subscription(
@@ -31,6 +32,7 @@ async def user_subscription(
user_agent=user_agent,
ip=request.client.host if request.client else None,
request_url=str(request.url),
+ **headers.model_dump(),
)
@@ -49,12 +51,14 @@ async def user_subscription_raw(
token: str,
db: AsyncSession = Depends(get_db),
update_user_agent: str = Header(default="", alias="X-Subscription-User-Agent"),
+ headers=Depends(get_subscription_headers),
):
return await subscription_operator.user_subscription_raw(
db,
token=token,
update_user_agent=update_user_agent,
ip=request.client.host if request.client else None,
+ **headers.model_dump(),
)
@@ -82,6 +86,7 @@ async def user_subscription_with_client_type(
token: str,
client_type: ConfigFormat,
db: AsyncSession = Depends(get_db),
+ headers=Depends(get_subscription_headers),
):
"""Provides a subscription link based on the specified client type (e.g., Clash, V2Ray)."""
return await subscription_operator.user_subscription_with_client_type(
@@ -90,4 +95,5 @@ async def user_subscription_with_client_type(
client_type=client_type,
request_url=str(request.url),
accept_header=request.headers.get("Accept", ""),
+ **headers.model_dump(),
)
diff --git a/app/settings/__init__.py b/app/settings/__init__.py
index 3bae33ad3..512db82f5 100644
--- a/app/settings/__init__.py
+++ b/app/settings/__init__.py
@@ -59,6 +59,15 @@ async def subscription_settings() -> settings.Subscription:
return validated_settings
+@cached()
+async def hwid_settings() -> settings.HWIDSettings:
+ async with GetDB() as db:
+ db_settings = await get_settings(db)
+
+ validated_settings = settings.HWIDSettings.model_validate(db_settings.hwid)
+ return validated_settings
+
+
@cached()
async def general_settings() -> settings.General:
async with GetDB() as db:
@@ -75,6 +84,7 @@ async def refresh_caches() -> None:
await notification_settings.cache.clear()
await notification_enable.cache.clear()
await subscription_settings.cache.clear()
+ await hwid_settings.cache.clear()
await general_settings.cache.clear()
diff --git a/tests/api/conftest.py b/tests/api/conftest.py
index 43d3f8f23..41653000e 100644
--- a/tests/api/conftest.py
+++ b/tests/api/conftest.py
@@ -1,5 +1,6 @@
from unittest.mock import AsyncMock, MagicMock
+import aiocache
import pytest
from aiorwlock import RWLock
@@ -8,6 +9,17 @@
from . import GetTestDB, TestSession, client
+# Disable caching for all tests
+def dummy_cached(*args, **kwargs):
+ def wrapper(func):
+ return func
+
+ return wrapper
+
+
+aiocache.cached = dummy_cached
+
+
@pytest.fixture(autouse=True)
def mock_db_session(monkeypatch: pytest.MonkeyPatch):
db_session = MagicMock(spec=TestSession)
@@ -428,6 +440,13 @@ def mock_settings(monkeypatch: pytest.MonkeyPatch):
},
],
},
+ "hwid": {
+ "enabled": True,
+ "forced": False,
+ "fallback_limit": 3,
+ "min_limit": 1,
+ "max_limit": 0,
+ },
"general": {"default_flow": "", "default_method": "chacha20-ietf-poly1305"},
}
db_settings = Settings(**settings)
diff --git a/tests/api/test_hwid.py b/tests/api/test_hwid.py
new file mode 100644
index 000000000..894ee3c02
--- /dev/null
+++ b/tests/api/test_hwid.py
@@ -0,0 +1,71 @@
+from fastapi import status
+from tests.api import client
+from tests.api.helpers import (
+ auth_headers,
+ create_user,
+ delete_user,
+)
+
+
+def test_hwid_workflow(access_token):
+ """
+ Test the full HWID workflow:
+ 1. Create a user
+ 2. Fetch subscription with HWID headers (Registration)
+ 3. Verify HWID is registered via Admin API
+ 4. Fetch subscription with different HWID (Limit check)
+ 5. Delete HWID via Admin API
+ 6. Reset all HWIDs for user
+ """
+ # 1. Create a user
+ user = create_user(access_token)
+ user_id = user["id"]
+ sub_url = user["subscription_url"]
+
+ try:
+ # 2. Fetch subscription with HWID headers (Registration)
+ hwid1 = "device-ios-123"
+ headers1 = {"X-HWID": hwid1, "X-Device-OS": "iOS", "X-Ver-OS": "16.5", "X-Device-Model": "iPhone 14"}
+ response = client.get(sub_url, headers=headers1)
+ assert response.status_code == status.HTTP_200_OK
+
+ # 3. Verify HWID is registered via Admin API
+ response = client.get(f"/api/user/{user_id}/hwids", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()
+ assert data["count"] == 1
+ item = data["hwids"][0]
+ assert item["hwid"] == hwid1
+ assert item["device_os"] == "iOS"
+ assert item["os_version"] == "16.5"
+ assert item["device_model"] == "iPhone 14"
+
+ # 4. Fetch subscription with different HWID (Up to limit)
+ # fallback_limit is 3 in conftest.py
+ client.get(sub_url, headers={"X-HWID": "device-2"})
+ client.get(sub_url, headers={"X-HWID": "device-3"})
+
+ response = client.get(f"/api/user/{user_id}/hwids", headers=auth_headers(access_token))
+ assert response.json()["count"] == 3
+
+ # 4b. 4th device should fail
+ response = client.get(sub_url, headers={"X-HWID": "device-4"})
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ assert "Device limit reached" in response.json()["detail"]
+
+ # 5. Delete one HWID via Admin API
+ response = client.delete(f"/api/user/{user_id}/hwids/{hwid1}", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_200_OK
+
+ response = client.get(f"/api/user/{user_id}/hwids", headers=auth_headers(access_token))
+ assert response.json()["count"] == 2
+
+ # 6. Reset all HWIDs for user
+ response = client.post(f"/api/user/{user_id}/hwids/reset", headers=auth_headers(access_token))
+ assert response.status_code == status.HTTP_200_OK
+
+ response = client.get(f"/api/user/{user_id}/hwids", headers=auth_headers(access_token))
+ assert response.json()["count"] == 0
+
+ finally:
+ delete_user(access_token, user["username"])
From bad7205a3da0cd8ce0954d7dcb6256ae7dc88b7d Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Thu, 14 May 2026 16:10:58 +0330
Subject: [PATCH 2/6] fix: tests
---
app/db/crud/user.py | 2 ++
.../versions/f02194c811d6_add_hwid_models_and_fields.py | 8 +++++++-
app/operation/user.py | 2 +-
app/utils/wireguard.py | 4 ++++
4 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/app/db/crud/user.py b/app/db/crud/user.py
index 97adda4d7..62546141e 100644
--- a/app/db/crud/user.py
+++ b/app/db/crud/user.py
@@ -794,6 +794,8 @@ async def create_user(db: AsyncSession, new_user: UserCreate, groups: list[Group
if new_user.hwid_limit is not None:
db_user.hwid_limit = new_user.hwid_limit
+ db_user.proxy_settings = new_user.proxy_settings.dict()
+
db.add(db_user)
await db.flush()
diff --git a/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py b/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py
index a2b2a0592..bf5e7c1c9 100644
--- a/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py
+++ b/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py
@@ -38,8 +38,14 @@ def upgrade() -> None:
batch_op.create_index('ix_user_hwids_created_at', ['created_at'], unique=False)
batch_op.create_index('ix_user_hwids_last_used_at', ['last_used_at'], unique=False)
+ # Fixed MySQL JSON default: Add as nullable, update, then set NOT NULL
with op.batch_alter_table('settings', schema=None) as batch_op:
- batch_op.add_column(sa.Column('hwid', sa.JSON(), server_default='{}', nullable=False))
+ batch_op.add_column(sa.Column('hwid', sa.JSON(), nullable=True))
+
+ op.execute("UPDATE settings SET hwid = '{}'")
+
+ with op.batch_alter_table('settings', schema=None) as batch_op:
+ batch_op.alter_column('hwid', nullable=False)
with op.batch_alter_table('user_subscription_updates', schema=None) as batch_op:
batch_op.add_column(sa.Column('hwid', sa.String(length=256), nullable=True))
diff --git a/app/operation/user.py b/app/operation/user.py
index 6ad0b4c34..5bbd7910c 100644
--- a/app/operation/user.py
+++ b/app/operation/user.py
@@ -250,7 +250,7 @@ async def _persist_bulk_users(
for db_user in db_users:
users_list.append(await self.validate_user(db_user))
- return self._build_bulk_action_response(users_list)
+ return [user.subscription_url for user in users_list]
async def validate_user(self, db_user: User, include_subscription_url: bool = True) -> UserNotificationResponse:
user = UserNotificationResponse.model_validate(db_user)
diff --git a/app/utils/wireguard.py b/app/utils/wireguard.py
index 3e5b186a4..596b30856 100644
--- a/app/utils/wireguard.py
+++ b/app/utils/wireguard.py
@@ -116,6 +116,10 @@ async def prepare_wireguard_proxy_settings(
if not wireguard_tags:
return proxy_settings
+ if not wireguard_settings.enabled:
+ return proxy_settings
+
+
if proxy_settings.wireguard.public_key and not proxy_settings.wireguard.private_key:
raise ValueError("wireguard private_key is required when user is assigned to a WireGuard interface")
From 9408d5b070218490935b556c940c9b81623ce144 Mon Sep 17 00:00:00 2001
From: M03ED <50927468+M03ED@users.noreply.github.com>
Date: Thu, 14 May 2026 16:14:00 +0330
Subject: [PATCH 3/6] fix: migration for mysql
---
.../versions/f02194c811d6_add_hwid_models_and_fields.py | 2 +-
app/db/models.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py b/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py
index bf5e7c1c9..1b11baffc 100644
--- a/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py
+++ b/app/db/migrations/versions/f02194c811d6_add_hwid_models_and_fields.py
@@ -45,7 +45,7 @@ def upgrade() -> None:
op.execute("UPDATE settings SET hwid = '{}'")
with op.batch_alter_table('settings', schema=None) as batch_op:
- batch_op.alter_column('hwid', nullable=False)
+ batch_op.alter_column('hwid', type_=sa.JSON(), nullable=False)
with op.batch_alter_table('user_subscription_updates', schema=None) as batch_op:
batch_op.add_column(sa.Column('hwid', sa.String(length=256), nullable=True))
diff --git a/app/db/models.py b/app/db/models.py
index 32576f644..f74bc01f7 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -819,5 +819,5 @@ class Settings(Base):
notification_settings: Mapped[dict] = mapped_column(JSON())
notification_enable: Mapped[dict] = mapped_column(JSON())
subscription: Mapped[dict] = mapped_column(JSON())
- hwid: Mapped[dict] = mapped_column(JSON(), server_default="{}")
+ hwid: Mapped[dict] = mapped_column(JSON())
general: Mapped[dict] = mapped_column(JSON())
From b9ee39da475aeaac77d58de3295428bd9eb776d8 Mon Sep 17 00:00:00 2001
From: x0sina
Date: Fri, 15 May 2026 15:47:34 +0330
Subject: [PATCH 4/6] feat(dashboard): add HWID management UI and settings
- Add HWID settings page with device registration policy configuration
- Implement hardware ID management modal for per-user HWID viewing and deletion
- Add HWID limit fields to user template and subscription forms
- Add comprehensive localization for HWID features across en, fa, ru, zh locales
- Add user-hwids-modal component for managing registered device IDs
- Add dedicated HWID settings route (_dashboard.settings.hwid.tsx)
- Update sidebar navigation to include HWID settings link
- Add HWID-related API endpoints to service layer
- Support device limit configuration with fallback, minimum, and maximum bounds
- Enable per-user HWID limit overrides with validation
---
dashboard/public/statics/locales/en.json | 62 +++
dashboard/public/statics/locales/fa.json | 64 +++
dashboard/public/statics/locales/ru.json | 64 +++
dashboard/public/statics/locales/zh.json | 64 +++
dashboard/src/app/router.tsx | 9 +
dashboard/src/components/layout/sidebar.tsx | 6 +
.../use-user-templates-list-columns.tsx | 15 +
.../templates/components/user-template.tsx | 10 +
.../templates/dialogs/user-template-modal.tsx | 32 +-
.../templates/forms/user-template-form.ts | 2 +
.../users/components/action-buttons.tsx | 21 +-
.../features/users/components/users-table.tsx | 2 +
.../users/dialogs/user-hwids-modal.tsx | 244 +++++++++++
.../src/features/users/dialogs/user-modal.tsx | 281 +++++++------
.../user-subscription-clients-modal.tsx | 32 +-
.../src/features/users/forms/user-form.ts | 2 +
.../src/pages/_dashboard.bulk.create.tsx | 12 +
.../src/pages/_dashboard.settings.hwid.tsx | 230 +++++++++++
dashboard/src/pages/_dashboard.settings.tsx | 6 +-
.../src/pages/_dashboard.templates.user.tsx | 2 +
dashboard/src/service/api/index.ts | 387 +++++++++++++++---
21 files changed, 1368 insertions(+), 179 deletions(-)
create mode 100644 dashboard/src/features/users/dialogs/user-hwids-modal.tsx
create mode 100644 dashboard/src/pages/_dashboard.settings.hwid.tsx
diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json
index cb887766b..a4b248e47 100644
--- a/dashboard/public/statics/locales/en.json
+++ b/dashboard/public/statics/locales/en.json
@@ -345,6 +345,45 @@
"resetToDefault": "Reset to Default",
"resetToDefaultSuccess": "Subscription rules reset to default"
},
+ "hwid": {
+ "title": "HWID",
+ "description": "Configure hardware ID registration and device limits",
+ "loadError": "Failed to load HWID settings",
+ "saveSuccess": "HWID settings saved successfully",
+ "saveFailed": "Failed to save HWID settings",
+ "cancelSuccess": "Changes cancelled and original HWID settings restored",
+ "policy": {
+ "title": "Device registration policy",
+ "description": "Control subscription access by registered hardware IDs."
+ },
+ "enabled": {
+ "title": "Enable HWID checks",
+ "description": "Register and enforce device IDs on subscription requests."
+ },
+ "forced": {
+ "title": "Require HWID header",
+ "description": "Reject subscription requests that do not send X-HWID."
+ },
+ "limits": {
+ "title": "Device limits",
+ "description": "Set the default device count and optional bounds for user HWID limits."
+ },
+ "fallbackLimit": {
+ "title": "Fallback limit",
+ "description": "Used when a user does not have an explicit HWID limit."
+ },
+ "minLimit": {
+ "title": "Minimum limit",
+ "description": "Lower bound applied to per-user limits. Use 0 to disable."
+ },
+ "maxLimit": {
+ "title": "Maximum limit",
+ "description": "Upper bound applied to per-user limits. Use 0 to disable."
+ },
+ "validation": {
+ "minMax": "Minimum limit cannot be greater than maximum limit"
+ }
+ },
"telegram": {
"title": "Telegram",
"description": "Configure Telegram bot integration and related settings for your system",
@@ -729,6 +768,8 @@
"prefix": "Username Prefix",
"suffix": "Username Suffix",
"dataLimit": "Data Limit",
+ "hwidLimit": "HWID Limit",
+ "hwidLimitPlaceholder": "Default, 0 = unlimited",
"expire": "Expire duration",
"onHoldTimeout": "OnHold Timeout",
"method": "Method",
@@ -1313,6 +1354,25 @@
"revokeUserSub.prompt": "Are you sure you want to revoke «{{username}}»'s subscription?",
"revokeUserSub.success": "{{username}}'s subscription has revoked successfully.",
"revokeUserSub.title": "Revoke User Subscription",
+ "hwids": {
+ "title": "Hardware IDs",
+ "description": "Manage this user's registered hardware IDs.",
+ "copy": "Copy hardware ID",
+ "copied": "Hardware ID copied",
+ "createdAt": "Created at",
+ "lastUsedAt": "Last used at",
+ "reset": "Reset all",
+ "loadFailed": "Failed to load hardware IDs",
+ "empty": "No hardware IDs have been registered yet",
+ "deleteTitle": "Remove hardware ID",
+ "deletePrompt": "This device will need to register again on its next subscription request.",
+ "deleteSuccess": "Hardware ID removed",
+ "deleteFailed": "Failed to remove hardware ID",
+ "resetTitle": "Reset all hardware IDs",
+ "resetPrompt": "All registered devices for this user will be removed.",
+ "resetSuccess": "All hardware IDs reset",
+ "resetFailed": "Failed to reset hardware IDs"
+ },
"subscriptionClients": {
"title": "Subscription Clients",
"viewAllClients": "View Clients",
@@ -1362,6 +1422,8 @@
"absolute": "Absolute",
"custom": "Custom",
"dataLimit": "Data Limit",
+ "hwidLimit": "HWID Limit",
+ "hwidLimitPlaceholder": "Fallback, 0 = unlimited",
"days": "Days",
"editUser": "Modify user",
"editUserTitle": "Modify user",
diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json
index 22634902d..9d8744ef2 100644
--- a/dashboard/public/statics/locales/fa.json
+++ b/dashboard/public/statics/locales/fa.json
@@ -225,6 +225,45 @@
"resetToDefault": "بازگشت به پیشفرض",
"resetToDefaultSuccess": "قوانین اشتراک به پیشفرض بازنشانی شد"
},
+ "hwid": {
+ "title": "شناسه سختافزار",
+ "description": "پیکربندی ثبت شناسه سختافزار و محدودیت دستگاهها",
+ "loadError": "بارگیری تنظیمات شناسه سختافزار ناموفق بود",
+ "saveSuccess": "تنظیمات شناسه سختافزار با موفقیت ذخیره شد",
+ "saveFailed": "ذخیره تنظیمات شناسه سختافزار ناموفق بود",
+ "cancelSuccess": "تغییرات لغو شد و تنظیمات اصلی شناسه سختافزار بازیابی شد",
+ "policy": {
+ "title": "سیاست ثبت دستگاه",
+ "description": "دسترسی به اشتراک را بر اساس شناسههای سختافزاری ثبتشده کنترل کنید."
+ },
+ "enabled": {
+ "title": "فعالسازی بررسی شناسه سختافزار",
+ "description": "شناسه دستگاهها را هنگام درخواست اشتراک ثبت و اعمال کنید."
+ },
+ "forced": {
+ "title": "الزام هدر شناسه سختافزار",
+ "description": "درخواستهای اشتراکی را که هدر X-HWID ارسال نمیکنند رد کنید."
+ },
+ "limits": {
+ "title": "محدودیت دستگاهها",
+ "description": "تعداد پیشفرض دستگاهها و کرانهای اختیاری محدودیت شناسه سختافزار کاربران را تنظیم کنید."
+ },
+ "fallbackLimit": {
+ "title": "محدودیت پیشفرض",
+ "description": "زمانی استفاده میشود که کاربر محدودیت شناسه سختافزار اختصاصی ندارد."
+ },
+ "minLimit": {
+ "title": "حداقل محدودیت",
+ "description": "کران پایین برای محدودیتهای هر کاربر. برای غیرفعالسازی ۰ وارد کنید."
+ },
+ "maxLimit": {
+ "title": "حداکثر محدودیت",
+ "description": "کران بالا برای محدودیتهای هر کاربر. برای غیرفعالسازی ۰ وارد کنید."
+ },
+ "validation": {
+ "minMax": "حداقل محدودیت نمیتواند از حداکثر محدودیت بیشتر باشد"
+ }
+ },
"telegram": {
"title": "تلگرام",
"description": "پیکربندی ادغام ربات تلگرام و تنظیمات مرتبط برای سیستم شما",
@@ -593,6 +632,8 @@
"prefix": "پیشوند نام کاربری",
"suffix": "پسوند نام کاربری",
"dataLimit": "محدودیت داده",
+ "hwidLimit": "محدودیت شناسه سختافزار",
+ "hwidLimitPlaceholder": "پیشفرض، ۰ = نامحدود",
"expire": "مدت انقضا",
"onHoldTimeout": "زمان انتظار توقف",
"method": "روش",
@@ -1161,6 +1202,25 @@
"revokeUserSub.prompt": "آیا مطمئن هستید که می خواهید اشتراک «{{username}}» را بازنشانی کنید؟",
"revokeUserSub.success": "اشتراک {{username}} با موفقیت بازنشانی شد.",
"revokeUserSub.title": "بازنشانی اشتراک کاربر",
+ "hwids": {
+ "title": "شناسههای سختافزار",
+ "description": "شناسههای سختافزاری ثبتشده این کاربر را مدیریت کنید.",
+ "copy": "کپی شناسه سختافزار",
+ "copied": "شناسه سختافزار کپی شد",
+ "createdAt": "ایجاد شده",
+ "lastUsedAt": "آخرین استفاده",
+ "reset": "بازنشانی همه",
+ "loadFailed": "بارگیری شناسههای سختافزار ناموفق بود",
+ "empty": "هنوز شناسه سختافزاری ثبت نشده است",
+ "deleteTitle": "حذف شناسه سختافزار",
+ "deletePrompt": "این دستگاه در درخواست بعدی اشتراک باید دوباره ثبت شود.",
+ "deleteSuccess": "شناسه سختافزار حذف شد",
+ "deleteFailed": "حذف شناسه سختافزار ناموفق بود",
+ "resetTitle": "بازنشانی همه شناسههای سختافزار",
+ "resetPrompt": "همه دستگاههای ثبتشده این کاربر حذف میشوند.",
+ "resetSuccess": "همه شناسههای سختافزار بازنشانی شدند",
+ "resetFailed": "بازنشانی شناسههای سختافزار ناموفق بود"
+ },
"subscriptionClients": {
"title": "کلاینتهای اشتراک",
"viewAllClients": "مشاهده کلاینتها",
@@ -1209,6 +1269,8 @@
"userDialog.absolute": "مطلق",
"userDialog.custom": "انتخابی",
"userDialog.dataLimit": "حد مصرف داده",
+ "userDialog.hwidLimit": "محدودیت شناسه سختافزار",
+ "userDialog.hwidLimitPlaceholder": "پیشفرض، ۰ = نامحدود",
"userDialog.days": "روزها",
"userDialog.editUser": "ویرایش کاربر",
"userDialog.editUserTitle": "ویرایش کاربر",
@@ -2394,6 +2456,8 @@
"userDialog": {
"revokeSubscription": "بازنشانی اشتراک",
"usage": "مصرف",
+ "hwidLimit": "محدودیت شناسه سختافزار",
+ "hwidLimitPlaceholder": "پیشفرض، ۰ = نامحدود",
"deleteSuccess": "کاربر «{{name}}» با موفقیت حذف شد.",
"resetUsageSuccess": "مصرف کاربر «{{name}}» بازنشانی شد.",
"revokeSubSuccess": "اشتراک کاربر «{{name}}» بازنشانی شد.",
diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json
index 2aa207312..a04384e06 100644
--- a/dashboard/public/statics/locales/ru.json
+++ b/dashboard/public/statics/locales/ru.json
@@ -357,6 +357,45 @@
"resetToDefault": "Сбросить к умолчанию",
"resetToDefaultSuccess": "Правила подписки сброшены к умолчанию"
},
+ "hwid": {
+ "title": "Аппаратный ID",
+ "description": "Настройка регистрации аппаратных ID и лимитов устройств",
+ "loadError": "Не удалось загрузить настройки HWID",
+ "saveSuccess": "Настройки HWID успешно сохранены",
+ "saveFailed": "Не удалось сохранить настройки HWID",
+ "cancelSuccess": "Изменения отменены, исходные настройки HWID восстановлены",
+ "policy": {
+ "title": "Политика регистрации устройств",
+ "description": "Управляйте доступом к подписке по зарегистрированным аппаратным ID."
+ },
+ "enabled": {
+ "title": "Включить проверки HWID",
+ "description": "Регистрируйте и проверяйте ID устройств в запросах подписки."
+ },
+ "forced": {
+ "title": "Требовать заголовок HWID",
+ "description": "Отклонять запросы подписки без заголовка X-HWID."
+ },
+ "limits": {
+ "title": "Лимиты устройств",
+ "description": "Задайте число устройств по умолчанию и необязательные границы лимитов HWID для пользователей."
+ },
+ "fallbackLimit": {
+ "title": "Лимит по умолчанию",
+ "description": "Используется, когда у пользователя нет явного лимита HWID."
+ },
+ "minLimit": {
+ "title": "Минимальный лимит",
+ "description": "Нижняя граница для лимитов пользователей. Укажите 0, чтобы отключить."
+ },
+ "maxLimit": {
+ "title": "Максимальный лимит",
+ "description": "Верхняя граница для лимитов пользователей. Укажите 0, чтобы отключить."
+ },
+ "validation": {
+ "minMax": "Минимальный лимит не может быть больше максимального"
+ }
+ },
"telegram": {
"title": "Telegram",
"description": "Настройка интеграции Telegram бота и связанных настроек для вашей системы",
@@ -715,6 +754,8 @@
"prefix": "Префикс имени пользователя",
"suffix": "Суффикс имени пользователя",
"dataLimit": "Лимит данных",
+ "hwidLimit": "Лимит HWID",
+ "hwidLimitPlaceholder": "По умолчанию, 0 = без лимита",
"expire": "Срок действия",
"onHoldTimeout": "Тайм-аут при ожидании",
"method": "Метод",
@@ -941,6 +982,25 @@
"revokeUserSub.prompt": "Вы уверены, что хотите отозвать подписку для пользователя «{{username}}»?",
"revokeUserSub.success": "Подписка пользователя {{username}} успешно отозвана.",
"revokeUserSub.title": "Отозвать подписку пользователя",
+ "hwids": {
+ "title": "Аппаратные ID",
+ "description": "Управляйте зарегистрированными аппаратными ID этого пользователя.",
+ "copy": "Скопировать аппаратный ID",
+ "copied": "Аппаратный ID скопирован",
+ "createdAt": "Создан",
+ "lastUsedAt": "Последнее использование",
+ "reset": "Сбросить все",
+ "loadFailed": "Не удалось загрузить аппаратные ID",
+ "empty": "Аппаратные ID еще не зарегистрированы",
+ "deleteTitle": "Удалить аппаратный ID",
+ "deletePrompt": "Это устройство должно будет зарегистрироваться снова при следующем запросе подписки.",
+ "deleteSuccess": "Аппаратный ID удален",
+ "deleteFailed": "Не удалось удалить аппаратный ID",
+ "resetTitle": "Сбросить все аппаратные ID",
+ "resetPrompt": "Все зарегистрированные устройства этого пользователя будут удалены.",
+ "resetSuccess": "Все аппаратные ID сброшены",
+ "resetFailed": "Не удалось сбросить аппаратные ID"
+ },
"subscriptionClients": {
"title": "Клиенты подписки",
"viewAllClients": "Показать клиентов",
@@ -989,6 +1049,8 @@
"userDialog.absolute": "Абсолютно",
"userDialog.custom": "Пользовательский",
"userDialog.dataLimit": "Лимит трафика",
+ "userDialog.hwidLimit": "Лимит HWID",
+ "userDialog.hwidLimitPlaceholder": "Резерв, 0 = без лимита",
"userDialog.days": "Дни",
"userDialog.editUser": "Редактировать",
"userDialog.editUserTitle": "Редактировать пользователя",
@@ -2336,6 +2398,8 @@
"userDialog": {
"revokeSubscription": "Отозвать подписку",
"usage": "Использование",
+ "hwidLimit": "Лимит HWID",
+ "hwidLimitPlaceholder": "Резерв, 0 = без лимита",
"deleteSuccess": "Пользователь «{{name}}» был успешно удалён.",
"resetUsageSuccess": "Использование пользователя «{{name}}» было сброшено.",
"revokeSubSuccess": "Подписка пользователя «{{name}}» была отозвана.",
diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json
index 1cbe2f072..6df4ec5a1 100644
--- a/dashboard/public/statics/locales/zh.json
+++ b/dashboard/public/statics/locales/zh.json
@@ -322,6 +322,45 @@
"resetToDefault": "重置为默认",
"resetToDefaultSuccess": "订阅规则已重置为默认"
},
+ "hwid": {
+ "title": "硬件 ID",
+ "description": "配置硬件 ID 注册和设备限制",
+ "loadError": "加载 HWID 设置失败",
+ "saveSuccess": "HWID 设置已保存",
+ "saveFailed": "保存 HWID 设置失败",
+ "cancelSuccess": "已取消更改并恢复原始 HWID 设置",
+ "policy": {
+ "title": "设备注册策略",
+ "description": "按已注册的硬件 ID 控制订阅访问。"
+ },
+ "enabled": {
+ "title": "启用 HWID 检查",
+ "description": "在订阅请求中注册并校验设备 ID。"
+ },
+ "forced": {
+ "title": "要求 HWID 请求头",
+ "description": "拒绝未发送 X-HWID 的订阅请求。"
+ },
+ "limits": {
+ "title": "设备限制",
+ "description": "设置默认设备数量,以及用户 HWID 限制的可选边界。"
+ },
+ "fallbackLimit": {
+ "title": "默认限制",
+ "description": "当用户没有明确的 HWID 限制时使用。"
+ },
+ "minLimit": {
+ "title": "最小限制",
+ "description": "应用到每个用户限制的下限。使用 0 可禁用。"
+ },
+ "maxLimit": {
+ "title": "最大限制",
+ "description": "应用到每个用户限制的上限。使用 0 可禁用。"
+ },
+ "validation": {
+ "minMax": "最小限制不能大于最大限制"
+ }
+ },
"telegram": {
"title": "Telegram",
"description": "配置 Telegram 机器人集成和系统相关设置",
@@ -729,6 +768,8 @@
"prefix": "用户名前缀",
"suffix": "用户名后缀",
"dataLimit": "数据限制",
+ "hwidLimit": "HWID 限制",
+ "hwidLimitPlaceholder": "默认,0 = 无限制",
"expire": "过期时间",
"onHoldTimeout": "挂起超时",
"method": "方法",
@@ -1295,6 +1336,25 @@
"revokeUserSub.prompt": "您确定要撤销用户 «{{username}}» 的订阅吗?",
"revokeUserSub.success": "成功撤销用户 {{username}} 的订阅。",
"revokeUserSub.title": "撤销用户订阅",
+ "hwids": {
+ "title": "硬件 ID",
+ "description": "管理此用户已注册的硬件 ID。",
+ "copy": "复制硬件 ID",
+ "copied": "硬件 ID 已复制",
+ "createdAt": "创建时间",
+ "lastUsedAt": "最后使用时间",
+ "reset": "全部重置",
+ "loadFailed": "加载硬件 ID 失败",
+ "empty": "尚未注册硬件 ID",
+ "deleteTitle": "删除硬件 ID",
+ "deletePrompt": "此设备需要在下次订阅请求时重新注册。",
+ "deleteSuccess": "硬件 ID 已删除",
+ "deleteFailed": "删除硬件 ID 失败",
+ "resetTitle": "重置所有硬件 ID",
+ "resetPrompt": "将删除此用户的所有已注册设备。",
+ "resetSuccess": "所有硬件 ID 已重置",
+ "resetFailed": "重置硬件 ID 失败"
+ },
"subscriptionClients": {
"title": "订阅客户端",
"viewAllClients": "查看客户端",
@@ -1343,6 +1403,8 @@
"userDialog.absolute": "选择范围",
"userDialog.custom": "自定义",
"userDialog.dataLimit": "流量限制",
+ "userDialog.hwidLimit": "HWID 限制",
+ "userDialog.hwidLimitPlaceholder": "备用,0 = 无限制",
"userDialog.days": "天",
"userDialog.editUser": "修改",
"userDialog.editUserTitle": "用户编辑",
@@ -2408,6 +2470,8 @@
"userDialog": {
"revokeSubscription": "吊销订阅",
"usage": "用量",
+ "hwidLimit": "HWID 限制",
+ "hwidLimitPlaceholder": "备用,0 = 无限制",
"deleteSuccess": "用户「{{name}}」已成功删除。",
"resetUsageSuccess": "用户「{{name}}」的用量已重置。",
"revokeSubSuccess": "用户「{{name}}」的订阅已撤销。",
diff --git a/dashboard/src/app/router.tsx b/dashboard/src/app/router.tsx
index 5ca6d663f..0bef294ee 100644
--- a/dashboard/src/app/router.tsx
+++ b/dashboard/src/app/router.tsx
@@ -29,6 +29,7 @@ const Settings = lazyWithChunkRecovery(() => import('../pages/_dashboard.setting
const CleanupSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.cleanup'))
const DiscordSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.discord'))
const GeneralSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.general'))
+const HwidSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.hwid'))
const NotificationSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.notifications'))
const SubscriptionSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.subscriptions'))
const TelegramSettings = lazyWithChunkRecovery(() => import('../pages/_dashboard.settings.telegram'))
@@ -248,6 +249,14 @@ export const router = createHashRouter([
),
},
+ {
+ path: '/settings/hwid',
+ element: (
+ }>
+
+
+ ),
+ },
{
path: '/settings/telegram',
element: (
diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx
index 3bac4643e..0a25a12c6 100644
--- a/dashboard/src/components/layout/sidebar.tsx
+++ b/dashboard/src/components/layout/sidebar.tsx
@@ -29,6 +29,7 @@ import {
Database,
FileCode2,
FileUser,
+ Fingerprint,
GithubIcon,
Group,
Layers,
@@ -253,6 +254,11 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
url: '/settings/subscriptions',
icon: ListTodo,
},
+ {
+ title: 'settings.hwid.title',
+ url: '/settings/hwid',
+ icon: Fingerprint
+ },
{
title: 'settings.telegram.title',
url: '/settings/telegram',
diff --git a/dashboard/src/features/templates/components/use-user-templates-list-columns.tsx b/dashboard/src/features/templates/components/use-user-templates-list-columns.tsx
index d36774506..012d85f82 100644
--- a/dashboard/src/features/templates/components/use-user-templates-list-columns.tsx
+++ b/dashboard/src/features/templates/components/use-user-templates-list-columns.tsx
@@ -60,6 +60,21 @@ export const useUserTemplatesListColumns = ({ onEdit, onToggleStatus }: UseUserT
),
hideOnMobile: true,
},
+ {
+ id: 'hwidLimit',
+ header: t('templates.hwidLimit', { defaultValue: 'HWID' }),
+ width: '1fr',
+ cell: template => (
+
+ {template.hwid_limit === null || template.hwid_limit === undefined
+ ? t('default', { defaultValue: 'Default' })
+ : template.hwid_limit === 0
+ ?
+ : template.hwid_limit}
+
+ ),
+ hideOnMobile: true,
+ },
{
id: 'actions',
header: '',
diff --git a/dashboard/src/features/templates/components/user-template.tsx b/dashboard/src/features/templates/components/user-template.tsx
index 0e7e28e5f..9de59f53d 100644
--- a/dashboard/src/features/templates/components/user-template.tsx
+++ b/dashboard/src/features/templates/components/user-template.tsx
@@ -41,6 +41,16 @@ const UserTemplate = ({
{t('userDialog.dataLimit')}:{' '}
{!template.data_limit || template.data_limit === 0 ? : formatBytes(template.data_limit ? template.data_limit : 0)}
+
+ {t('templates.hwidLimit', { defaultValue: 'HWID Limit' })}:{' '}
+
+ {template.hwid_limit === null || template.hwid_limit === undefined
+ ? t('default', { defaultValue: 'Default' })
+ : template.hwid_limit === 0
+ ?
+ : template.hwid_limit}
+
+
{t('expire')}:
diff --git a/dashboard/src/features/templates/dialogs/user-template-modal.tsx b/dashboard/src/features/templates/dialogs/user-template-modal.tsx
index 30c763f9b..b1c2d72a3 100644
--- a/dashboard/src/features/templates/dialogs/user-template-modal.tsx
+++ b/dashboard/src/features/templates/dialogs/user-template-modal.tsx
@@ -176,10 +176,12 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed
const status = values.status ?? UserStatusCreate.active
const normalizedDataLimitGb = Number(values.data_limit ?? 0)
const hasDataLimit = Number.isFinite(normalizedDataLimitGb) && normalizedDataLimitGb > 0
+ const normalizedHwidLimit = values.hwid_limit == null ? null : Number(values.hwid_limit)
// Build payload according to UserTemplateCreate interface
const submitData = {
name: values.name,
data_limit: hasDataLimit ? gbToBytes(normalizedDataLimitGb as any) : 0,
+ hwid_limit: normalizedHwidLimit == null ? null : Number.isFinite(normalizedHwidLimit) ? Math.floor(normalizedHwidLimit) : null,
expire_duration: values.expire_duration,
username_prefix: values.username_prefix || '',
username_suffix: values.username_suffix || '',
@@ -191,9 +193,9 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed
extra_settings:
values.method || values.flow
? {
- method: values.method,
- flow: values.flow,
- }
+ method: values.method,
+ flow: values.flow,
+ }
: undefined,
}
@@ -228,6 +230,7 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed
const fields = [
'name',
'data_limit',
+ 'hwid_limit',
'expire_duration',
'username_prefix',
'username_suffix',
@@ -400,6 +403,29 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed
)}
/>
+
+ (
+
+ {t('templates.hwidLimit', { defaultValue: 'HWID Limit' })}
+
+
+
+
+
+ )}
+ />
+
username_prefix: '',
username_suffix: '',
data_limit: 0,
+ hwid_limit: undefined,
expire_duration: 0,
method: ShadowsocksMethods['chacha20-ietf-poly1305'],
flow: XTLSFlows[''],
diff --git a/dashboard/src/features/users/components/action-buttons.tsx b/dashboard/src/features/users/components/action-buttons.tsx
index e194e3d11..e5f2e2a30 100644
--- a/dashboard/src/features/users/components/action-buttons.tsx
+++ b/dashboard/src/features/users/components/action-buttons.tsx
@@ -5,7 +5,7 @@ import useDirDetection from '@/hooks/use-dir-detection'
import { type UseEditFormValues } from '@/features/users/forms/user-form'
import { useActiveNextPlanById, useGetCurrentAdmin, useRemoveUserById, useResetUserDataUsageById, useRevokeUserSubscriptionById, UserResponse, UsersResponse } from '@/service/api'
import { useQueryClient } from '@tanstack/react-query'
-import { Cat, Check, Copy, Cpu, EllipsisVertical, GlobeLock, Link2Off, ListStart, ListTree, Network, Pencil, PieChart, QrCode, RefreshCcw, Trash2, UserCog, Users } from 'lucide-react'
+import { Cat, Check, Copy, Cpu, EllipsisVertical, Fingerprint, GlobeLock, Link2Off, ListStart, ListTree, Network, Pencil, PieChart, QrCode, RefreshCcw, Trash2, UserCog, Users } from 'lucide-react'
import { WireguardIcon, XrayIcon, SingboxIcon, MihomoIcon } from '@/components/icons/format-icons'
import { Code } from 'lucide-react'
import { FC, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
@@ -19,6 +19,7 @@ import SubscriptionModal from '@/features/subscriptions/dialogs/subscription-mod
import SetOwnerModal from '@/features/users/dialogs/set-owner-modal'
import UsageModal from '@/features/users/dialogs/usage-modal'
import UserModal from '@/features/users/dialogs/user-modal'
+import { UserHwidsModal } from '@/features/users/dialogs/user-hwids-modal'
import { UserSubscriptionClientsModal } from '@/features/users/dialogs/user-subscription-clients-modal'
import UserAllIPsModal from '@/features/users/dialogs/user-all-ips-modal'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@@ -50,6 +51,7 @@ type ActionButtonsModalState = {
isSetOwnerModalOpen: boolean
isActiveNextPlanModalOpen: boolean
isSubscriptionClientsModalOpen: boolean
+ isHwidsModalOpen: boolean
isUserAllIPsModalOpen: boolean
}
@@ -72,6 +74,7 @@ const createDefaultModalState = (user: UserResponse): ActionButtonsModalState =>
isSetOwnerModalOpen: false,
isActiveNextPlanModalOpen: false,
isSubscriptionClientsModalOpen: false,
+ isHwidsModalOpen: false,
isUserAllIPsModalOpen: false,
})
@@ -96,6 +99,7 @@ const hasOpenModal = (state: ActionButtonsModalState) =>
state.isSetOwnerModalOpen ||
state.isActiveNextPlanModalOpen ||
state.isSubscriptionClientsModalOpen ||
+ state.isHwidsModalOpen ||
state.isUserAllIPsModalOpen
const notifyGlobalListeners = () => {
@@ -193,6 +197,7 @@ const buildUserEditFormValues = (user: UserResponse): UseEditFormValues => ({
username: user.username,
status: user.status === 'active' || user.status === 'on_hold' || user.status === 'disabled' ? (user.status as UseEditFormValues['status']) : 'active',
data_limit: user.data_limit ? bytesToFormGigabytes(Number(user.data_limit)) : 0,
+ hwid_limit: user.hwid_limit ?? undefined,
expire: normalizeDatePickerValueForEditForm(user.expire),
note: user.note || '',
data_limit_reset_strategy: user.data_limit_reset_strategy || undefined,
@@ -247,6 +252,7 @@ const ActionButtons: FC = ({ user, isModalHost = true, rende
isSetOwnerModalOpen,
isActiveNextPlanModalOpen,
isSubscriptionClientsModalOpen,
+ isHwidsModalOpen,
isUserAllIPsModalOpen,
} = modalState
@@ -259,6 +265,7 @@ const ActionButtons: FC = ({ user, isModalHost = true, rende
const setSetOwnerModalOpen = useCallback((value: boolean) => setModalState({ isSetOwnerModalOpen: value }), [setModalState])
const setIsActiveNextPlanModalOpen = useCallback((value: boolean) => setModalState({ isActiveNextPlanModalOpen: value }), [setModalState])
const setSubscriptionClientsModalOpen = useCallback((value: boolean) => setModalState({ isSubscriptionClientsModalOpen: value }), [setModalState])
+ const setHwidsModalOpen = useCallback((value: boolean) => setModalState({ isHwidsModalOpen: value }), [setModalState])
const setUserAllIPsModalOpen = useCallback((value: boolean) => setModalState({ isUserAllIPsModalOpen: value }), [setModalState])
useEffect(() => {
@@ -750,6 +757,11 @@ const ActionButtons: FC = ({ user, isModalHost = true, rende
{t('subscriptionClients.clients', { defaultValue: 'Clients' })}
+ setHwidsModalOpen(true)}>
+
+ {t('hwids.title', { defaultValue: 'Hardware IDs' })}
+
+
{/* View All IPs: only for sudo admins */}
{currentAdmin?.is_sudo && (
setUserAllIPsModalOpen(true)}>
@@ -873,6 +885,13 @@ const ActionButtons: FC = ({ user, isModalHost = true, rende
username={user.username}
/>
+
+
{/* UserAllIPsModal: only for sudo admins */}
{currentAdmin?.is_sudo && }
diff --git a/dashboard/src/features/users/components/users-table.tsx b/dashboard/src/features/users/components/users-table.tsx
index 281362584..5a680b538 100644
--- a/dashboard/src/features/users/components/users-table.tsx
+++ b/dashboard/src/features/users/components/users-table.tsx
@@ -315,6 +315,7 @@ const UsersTable = memo(() => {
username: selectedUser?.username,
status: selectedUser?.status === 'active' || selectedUser?.status === 'on_hold' || selectedUser?.status === 'disabled' ? selectedUser?.status : 'active',
data_limit: selectedUser?.data_limit ? bytesToFormGigabytes(Number(selectedUser.data_limit)) : undefined,
+ hwid_limit: selectedUser?.hwid_limit ?? undefined,
expire: normalizeDatePickerValueForEditForm(selectedUser?.expire),
note: selectedUser?.note || '',
data_limit_reset_strategy: selectedUser?.data_limit_reset_strategy || undefined,
@@ -339,6 +340,7 @@ const UsersTable = memo(() => {
username: selectedUser.username,
status: selectedUser.status === 'active' || selectedUser.status === 'on_hold' || selectedUser.status === 'disabled' ? selectedUser.status : 'active',
data_limit: selectedUser.data_limit ? bytesToFormGigabytes(Number(selectedUser.data_limit)) : 0,
+ hwid_limit: selectedUser.hwid_limit ?? undefined,
expire: normalizeDatePickerValueForEditForm(selectedUser.expire),
note: selectedUser.note || '',
data_limit_reset_strategy: selectedUser.data_limit_reset_strategy || undefined,
diff --git a/dashboard/src/features/users/dialogs/user-hwids-modal.tsx b/dashboard/src/features/users/dialogs/user-hwids-modal.tsx
new file mode 100644
index 000000000..aba3c079b
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/user-hwids-modal.tsx
@@ -0,0 +1,244 @@
+import { CopyButton } from '@/components/common/copy-button'
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+import useDirDetection from '@/hooks/use-dir-detection'
+import { getGetUserHwidsQueryKey, useDeleteUserHwid, useGetUserHwids, useResetUserHwids, type UserHWIDResponse } from '@/service/api'
+import { dateUtils } from '@/utils/dateFormatter'
+import { useQueryClient } from '@tanstack/react-query'
+import { Fingerprint, Laptop, RefreshCw, Smartphone, Trash2 } from 'lucide-react'
+import { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+interface UserHwidsModalProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ userId: number
+ username?: string
+}
+
+const formatHwidDate = (value?: string | number | null) => {
+ if (!value) return null
+ return dateUtils.toDayjs(value).format('YYYY-MM-DD HH:mm')
+}
+
+const getDeviceIcon = (deviceOs?: string | null) => {
+ const os = deviceOs?.toLowerCase() || ''
+ if (os.includes('android') || os.includes('ios') || os.includes('iphone') || os.includes('ipad')) {
+ return Smartphone
+ }
+ return Laptop
+}
+
+export function UserHwidsModal({ isOpen, onOpenChange, userId, username }: UserHwidsModalProps) {
+ const { t } = useTranslation()
+ const dir = useDirDetection()
+ const queryClient = useQueryClient()
+ const [hwidToDelete, setHwidToDelete] = useState(null)
+ const [isResetDialogOpen, setResetDialogOpen] = useState(false)
+
+ const queryKey = useMemo(() => getGetUserHwidsQueryKey(userId), [userId])
+ const { data, isLoading, error } = useGetUserHwids(userId, {
+ query: {
+ enabled: isOpen && !!userId,
+ },
+ })
+
+ const invalidateHwids = () => queryClient.invalidateQueries({ queryKey })
+
+ const deleteMutation = useDeleteUserHwid({
+ mutation: {
+ onSuccess: () => {
+ toast.success(t('hwids.deleteSuccess', { defaultValue: 'Hardware ID removed' }))
+ setHwidToDelete(null)
+ invalidateHwids()
+ },
+ onError: (deleteError: any) => {
+ toast.error(t('hwids.deleteFailed', { defaultValue: 'Failed to remove hardware ID' }), {
+ description: deleteError?.data?.detail || deleteError?.message || '',
+ })
+ },
+ },
+ })
+
+ const resetMutation = useResetUserHwids({
+ mutation: {
+ onSuccess: () => {
+ toast.success(t('hwids.resetSuccess', { defaultValue: 'All hardware IDs reset' }))
+ setResetDialogOpen(false)
+ invalidateHwids()
+ },
+ onError: (resetError: any) => {
+ toast.error(t('hwids.resetFailed', { defaultValue: 'Failed to reset hardware IDs' }), {
+ description: resetError?.data?.detail || resetError?.message || '',
+ })
+ },
+ },
+ })
+
+ const hwids = data?.hwids || []
+
+ const renderHwidCard = (item: UserHWIDResponse) => {
+ const DeviceIcon = getDeviceIcon(item.device_os)
+ const createdAt = formatHwidDate(item.created_at)
+ const lastUsedAt = formatHwidDate(item.last_used_at)
+
+ return (
+
+
+
+
+
+
+
+
+
+ {item.hwid}
+
+
+
+
+ {item.hwid}
+
+
+
+
+
+ {item.device_os && {item.device_os}}
+ {item.os_version && v{item.os_version}}
+ {item.device_model && {item.device_model}}
+
+
+ {createdAt && (
+
+ {t('hwids.createdAt', { defaultValue: 'Created' })}: {createdAt}
+
+ )}
+ {lastUsedAt && (
+
+ {t('hwids.lastUsedAt', { defaultValue: 'Last used' })}: {lastUsedAt}
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/dashboard/src/features/users/dialogs/user-modal.tsx b/dashboard/src/features/users/dialogs/user-modal.tsx
index 732c2b870..621a28f5a 100644
--- a/dashboard/src/features/users/dialogs/user-modal.tsx
+++ b/dashboard/src/features/users/dialogs/user-modal.tsx
@@ -4,6 +4,7 @@ import GroupsSelector from '@/components/common/groups-selector'
import { TimeUnitSelect, TIME_UNIT_SECONDS, type TimeUnit } from '@/components/common/time-unit-select'
import UsageModal from '@/features/users/dialogs/usage-modal'
import UserAllIPsModal from '@/features/users/dialogs/user-all-ips-modal'
+import { UserHwidsModal } from '@/features/users/dialogs/user-hwids-modal'
import { UserSubscriptionClientsModal } from '@/features/users/dialogs/user-subscription-clients-modal'
import { type UseEditFormValues, type UseFormValues, userCreateObjectSchema, userCreateSchema, userEditObjectSchema, userEditSchema } from '@/features/users/forms/user-form'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
@@ -43,7 +44,7 @@ import { bytesToFormGigabytes, formatBytes, gbToBytes } from '@/utils/formatByte
import { invalidateUserMetricsQueries, upsertUserInUsersCache } from '@/utils/usersCache'
import { generateWireGuardKeyPair, getWireGuardPublicKey } from '@/utils/wireguard'
import { useQuery, useQueryClient } from '@tanstack/react-query'
-import { CalendarClock, CalendarPlus, ChevronDown, EllipsisVertical, Info, Layers, Link2Off, ListStart, Lock, Network, PieChart, RefreshCcw, Group, Users, Pencil, UserRoundPlus } from 'lucide-react'
+import { CalendarClock, CalendarPlus, ChevronDown, EllipsisVertical, Fingerprint, Info, Layers, Link2Off, ListStart, Lock, Network, PieChart, RefreshCcw, Group, Users, Pencil, UserRoundPlus } from 'lucide-react'
import React, { useEffect, useState } from 'react'
import { UseFormReturn } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
@@ -322,6 +323,7 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
const [isRevokeSubDialogOpen, setRevokeSubDialogOpen] = useState(false)
const [isUserAllIPsModalOpen, setUserAllIPsModalOpen] = useState(false)
const [isUsageModalOpen, setUsageModalOpen] = useState(false)
+ const [isHwidsModalOpen, setHwidsModalOpen] = useState(false)
const [isSubscriptionClientsModalOpen, setSubscriptionClientsModalOpen] = useState(false)
const [isActionsMenuOpen, setActionsMenuOpen] = useState(false)
const [onHoldExpireUnit, setOnHoldExpireUnit] = useState('days')
@@ -955,6 +957,7 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
const preparedValues = {
...valuesWithoutNextPlan,
data_limit: typeof values.data_limit === 'string' ? parseFloat(values.data_limit) : values.data_limit,
+ hwid_limit: typeof values.hwid_limit === 'string' ? parseFloat(values.hwid_limit) : values.hwid_limit,
on_hold_expire_duration:
status === 'on_hold' && values.on_hold_expire_duration
? typeof values.on_hold_expire_duration === 'string'
@@ -980,6 +983,7 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
const sendValues: any = {
...preparedValues,
data_limit: gbToBytes(normalizedDataLimitGb as any),
+ hwid_limit: preparedValues.hwid_limit == null ? null : Number.isFinite(Number(preparedValues.hwid_limit)) ? Math.round(Number(preparedValues.hwid_limit)) : null,
data_limit_reset_strategy: hasDataLimit ? preparedValues.data_limit_reset_strategy : 'no_reset',
expire: preparedValues.expire,
...(hasProxySettings ? { proxy_settings: cleanedProxySettings } : {}),
@@ -1059,7 +1063,7 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
setActiveTab('groups')
setSelectedTemplateId(null)
} catch (error: any) {
- const fields = ['username', 'data_limit', 'expire', 'note', 'data_limit_reset_strategy', 'on_hold_expire_duration', 'on_hold_timeout', 'group_ids']
+ const fields = ['username', 'data_limit', 'hwid_limit', 'expire', 'note', 'data_limit_reset_strategy', 'on_hold_expire_duration', 'on_hold_timeout', 'group_ids']
handleError({ error, fields, form, contextKey: 'users' })
} finally {
setLoading(false)
@@ -1515,132 +1519,165 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
{/* Data limit and expire fields - show data_limit only when no template is selected */}
{activeTab === 'groups' && (
-
- {!selectedTemplateId && (
- <>
-
(
-
- {t('userDialog.dataLimit', { defaultValue: 'Data Limit (GB)' })}
-
- {
- const nextValue = value ?? 0
- field.onChange(nextValue)
- handleFieldChange('data_limit', nextValue)
- }}
- />
-
- {field.value !== null && field.value !== undefined && field.value > 0 && field.value < 1 && (
-
- {formatBytes(Math.round(field.value * 1024 * 1024 * 1024))}
-
- )}
-
-
- )}
- />
- {form.watch('data_limit') !== undefined && form.watch('data_limit') !== null && Number(form.watch('data_limit')) > 0 && (
- (
-
- {t('userDialog.periodicUsageReset', { defaultValue: 'Periodic Usage Reset' })}
-
-
+
{tabs.map(tab => (
@@ -2349,6 +2386,15 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
{t('subscriptionClients.clients', { defaultValue: 'Clients' })}
+
{
+ setActionsMenuOpen(false)
+ setHwidsModalOpen(true)
+ }}
+ >
+
+ {t('hwids.title', { defaultValue: 'Hardware IDs' })}
+
{
setActionsMenuOpen(false)
@@ -2482,6 +2528,7 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
{isSudo && currentUsername && }
{currentUserId && setUsageModalOpen(false)} userId={currentUserId} />}
+ {currentUserId && }
{currentUserId && }
)
diff --git a/dashboard/src/features/users/dialogs/user-subscription-clients-modal.tsx b/dashboard/src/features/users/dialogs/user-subscription-clients-modal.tsx
index 21d0173e8..f097bfcf2 100644
--- a/dashboard/src/features/users/dialogs/user-subscription-clients-modal.tsx
+++ b/dashboard/src/features/users/dialogs/user-subscription-clients-modal.tsx
@@ -6,7 +6,7 @@ import { CopyButton } from '@/components/common/copy-button'
import { useGetUserSubUpdateListById, UserSubscriptionUpdateSchema } from '@/service/api'
import { parseUserAgent, formatClientInfo } from '@/utils/userAgentParser'
import { dateUtils } from '@/utils/dateFormatter'
-import { Monitor, Smartphone, Globe, HelpCircle, Users, ChevronLeft, ChevronRight, Tv, Network } from 'lucide-react'
+import { Monitor, Smartphone, Globe, HelpCircle, Users, ChevronLeft, ChevronRight, Tv, Network, Fingerprint } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs'
@@ -327,6 +327,7 @@ export const UserSubscriptionClientsModal: FC
const version = detectVersion(update.user_agent)
const os = detectOS(update.user_agent, clientInfo)
const ipAddress = update.ip?.trim()
+ const hwid = update.hwid?.trim()
return (
@@ -381,6 +382,35 @@ export const UserSubscriptionClientsModal: FC
)}
+ {hwid && (
+
+
+
+
{t('settings.hwid.title')}
+
+
+
+ {hwid}
+
+
+
+
+ {hwid}
+
+
+
+
+
+
+ )}
+
diff --git a/dashboard/src/features/users/forms/user-form.ts b/dashboard/src/features/users/forms/user-form.ts
index 715d8f9aa..0a597b936 100644
--- a/dashboard/src/features/users/forms/user-form.ts
+++ b/dashboard/src/features/users/forms/user-form.ts
@@ -51,6 +51,7 @@ const userSharedSchemaShape = {
username: z.string().min(3, 'validation.minLength').max(128, 'validation.maxLength'),
group_ids: z.array(z.number()).min(1, { message: 'validation.required' }),
data_limit: z.number().min(0),
+ hwid_limit: z.number().min(0).nullable().optional(),
expire: z.union([z.string(), z.number(), z.null()]).optional(),
note: z.string().optional(),
proxy_settings: proxyTableInputSchema.optional(),
@@ -99,6 +100,7 @@ export const getDefaultUserForm = async () => {
username: '',
status: 'active',
data_limit: 0,
+ hwid_limit: undefined,
expire: '',
note: '',
group_ids: [],
diff --git a/dashboard/src/pages/_dashboard.bulk.create.tsx b/dashboard/src/pages/_dashboard.bulk.create.tsx
index 8464d51ed..8ae5ebafa 100644
--- a/dashboard/src/pages/_dashboard.bulk.create.tsx
+++ b/dashboard/src/pages/_dashboard.bulk.create.tsx
@@ -438,6 +438,18 @@ export default function BulkCreateUsersPage() {
{formatBytes(selectedTemplate.data_limit)}
)}
+ {selectedTemplate.hwid_limit !== undefined && (
+
+ {t('templates.hwidLimit', { defaultValue: 'HWID Limit' })}:
+
+ {selectedTemplate.hwid_limit === null
+ ? t('default', { defaultValue: 'Default' })
+ : selectedTemplate.hwid_limit === 0
+ ? t('unlimited', { defaultValue: 'Unlimited' })
+ : selectedTemplate.hwid_limit}
+
+
+ )}
{selectedTemplate.expire_duration !== null && selectedTemplate.expire_duration !== undefined && (
{t('expire')}:
diff --git a/dashboard/src/pages/_dashboard.settings.hwid.tsx b/dashboard/src/pages/_dashboard.settings.hwid.tsx
new file mode 100644
index 000000000..e4f50cf31
--- /dev/null
+++ b/dashboard/src/pages/_dashboard.settings.hwid.tsx
@@ -0,0 +1,230 @@
+import { DecimalInput } from '@/components/common/decimal-input'
+import { SubscriptionFormActions } from '@/features/subscriptions/components/subscription-form-actions'
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Switch } from '@/components/ui/switch'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { Fingerprint } from 'lucide-react'
+import { useMemo } from 'react'
+import { useForm } from 'react-hook-form'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import { z } from 'zod'
+import { useSettingsContext } from './_dashboard.settings'
+
+const hwidSettingsSchema = z
+ .object({
+ enabled: z.boolean().default(false),
+ forced: z.boolean().default(false),
+ fallback_limit: z.number().min(0).default(0),
+ min_limit: z.number().min(0).default(0),
+ max_limit: z.number().min(0).default(0),
+ })
+ .superRefine((data, ctx) => {
+ if (data.max_limit > 0 && data.min_limit > data.max_limit) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'settings.hwid.validation.minMax',
+ path: ['min_limit'],
+ })
+ }
+ })
+
+type HwidSettingsFormInput = z.input
+
+const defaultValues: HwidSettingsFormInput = {
+ enabled: false,
+ forced: false,
+ fallback_limit: 0,
+ min_limit: 0,
+ max_limit: 0,
+}
+
+export default function HwidSettings() {
+ const { t } = useTranslation()
+ const { settings, isLoading, error, updateSettings, isSaving } = useSettingsContext()
+
+ const formValues = useMemo(() => {
+ const hwid = settings?.hwid
+ if (!hwid) return defaultValues
+ return {
+ enabled: hwid.enabled ?? false,
+ forced: hwid.forced ?? false,
+ fallback_limit: hwid.fallback_limit ?? 0,
+ min_limit: hwid.min_limit ?? 0,
+ max_limit: hwid.max_limit ?? 0,
+ }
+ }, [settings?.hwid])
+
+ const form = useForm({
+ resolver: zodResolver(hwidSettingsSchema),
+ values: formValues,
+ })
+
+ const onSubmit = async (data: HwidSettingsFormInput) => {
+ try {
+ await updateSettings({
+ hwid: {
+ enabled: data.enabled,
+ forced: data.enabled ? data.forced : false,
+ fallback_limit: Math.floor(data.fallback_limit ?? 0),
+ min_limit: Math.floor(data.min_limit ?? 0),
+ max_limit: Math.floor(data.max_limit ?? 0),
+ },
+ })
+ } catch {
+ // Error handling is done in the parent context.
+ }
+ }
+
+ const handleCancel = () => {
+ form.reset(formValues)
+ toast.success(t('settings.hwid.cancelSuccess', { defaultValue: 'Changes cancelled and original HWID settings restored' }))
+ }
+
+ const hwidEnabled = form.watch('enabled')
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
{t('settings.hwid.loadError', { defaultValue: 'Failed to load HWID settings' })}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/dashboard/src/pages/_dashboard.settings.tsx b/dashboard/src/pages/_dashboard.settings.tsx
index 66d3cf230..88742ac5d 100644
--- a/dashboard/src/pages/_dashboard.settings.tsx
+++ b/dashboard/src/pages/_dashboard.settings.tsx
@@ -3,7 +3,7 @@ import { useAdmin } from '@/hooks/use-admin'
import { cn } from '@/lib/utils'
import { useGetSettings, useModifySettings } from '@/service/api'
import { useQueryClient } from '@tanstack/react-query'
-import { Bell, Database, ListTodo, LucideIcon, MessageCircle, Palette, Send, Settings as SettingsIcon, Webhook } from 'lucide-react'
+import { Bell, Database, Fingerprint, ListTodo, LucideIcon, MessageCircle, Palette, Send, Settings as SettingsIcon, Webhook } from 'lucide-react'
import { createContext, useCallback, useContext, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Outlet, useLocation, useNavigate } from 'react-router'
@@ -40,6 +40,7 @@ const sudoTabs: Tab[] = [
{ id: 'general', label: 'settings.general.title', icon: SettingsIcon, url: '/settings/general' },
{ id: 'notifications', label: 'settings.notifications.title', icon: Bell, url: '/settings/notifications' },
{ id: 'subscriptions', label: 'settings.subscriptions.title', icon: ListTodo, url: '/settings/subscriptions' },
+ { id: 'hwid', label: 'settings.hwid.title', icon: Fingerprint, url: '/settings/hwid' },
{ id: 'telegram', label: 'settings.telegram.title', icon: Send, url: '/settings/telegram' },
{ id: 'discord', label: 'settings.discord.title', icon: MessageCircle, url: '/settings/discord' },
{ id: 'webhook', label: 'settings.webhook.title', icon: Webhook, url: '/settings/webhook' },
@@ -170,6 +171,9 @@ export default function Settings() {
filteredData = data
}
break
+ case 'hwid':
+ filteredData = data.hwid ? { data: { hwid: data.hwid } } : data
+ break
case 'telegram':
// Add telegram specific filtering if needed
filteredData = { data: data }
diff --git a/dashboard/src/pages/_dashboard.templates.user.tsx b/dashboard/src/pages/_dashboard.templates.user.tsx
index 51bf151af..f6c51fcc7 100644
--- a/dashboard/src/pages/_dashboard.templates.user.tsx
+++ b/dashboard/src/pages/_dashboard.templates.user.tsx
@@ -69,6 +69,7 @@ export default function UserTemplates() {
name: userTemplate.name || undefined,
status: userTemplate.status || undefined,
data_limit: bytesToFormGigabytes(userTemplate.data_limit),
+ hwid_limit: userTemplate.hwid_limit ?? undefined,
expire_duration: userTemplate.expire_duration || undefined,
method: userTemplate.extra_settings?.method || undefined,
flow: userTemplate.extra_settings?.flow || undefined,
@@ -90,6 +91,7 @@ export default function UserTemplates() {
data: {
name: template.name,
data_limit: template.data_limit,
+ hwid_limit: template.hwid_limit,
expire_duration: template.expire_duration,
username_prefix: template.username_prefix,
username_suffix: template.username_suffix,
diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts
index f3917ad6e..d469d3310 100644
--- a/dashboard/src/service/api/index.ts
+++ b/dashboard/src/service/api/index.ts
@@ -368,17 +368,17 @@ 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
-export interface XrayMuxSettingsInput {
- enabled?: boolean
- concurrency?: XrayMuxSettingsInputConcurrency
- xudp_concurrency?: XrayMuxSettingsInputXudpConcurrency
- xudp_proxy_udp_443?: Xudp
-}
-
export interface XrayFragmentSettings {
/** @pattern ^(:?tlshello|[\d-]{1,16})$ */
packets: string
@@ -397,11 +397,11 @@ export const Xudp = {
skip: 'skip',
} as const
-export interface XrayMuxSettingsOutput {
+export interface XrayMuxSettingsInput {
enabled?: boolean
- concurrency?: XrayMuxSettingsOutputConcurrency
- xudpConcurrency?: XrayMuxSettingsOutputXudpConcurrency
- xudpProxyUDP443?: Xudp
+ concurrency?: XrayMuxSettingsInputConcurrency
+ xudp_concurrency?: XrayMuxSettingsInputXudpConcurrency
+ xudp_proxy_udp_443?: Xudp
}
export type XTLSFlows = (typeof XTLSFlows)[keyof typeof XTLSFlows]
@@ -463,7 +463,7 @@ export type XHttpSettingsOutputScMinPostsIntervalMs = string | null
export type XHttpSettingsOutputScMaxEachPostBytes = string | null
-export type XHttpSettingsOutputUplinkChunkSize = number | null
+export type XHttpSettingsOutputUplinkChunkSize = string | null
export type XHttpSettingsOutputUplinkDataKey = string | null
@@ -526,7 +526,7 @@ export type XHttpSettingsInputScMinPostsIntervalMs = string | number | null
export type XHttpSettingsInputScMaxEachPostBytes = string | number | null
-export type XHttpSettingsInputUplinkChunkSize = number | null
+export type XHttpSettingsInputUplinkChunkSize = string | number | null
export type XHttpSettingsInputUplinkDataKey = string | null
@@ -591,11 +591,6 @@ export interface XHttpSettingsInput {
download_settings?: XHttpSettingsInputDownloadSettings
}
-export interface WorkersHealth {
- scheduler: WorkerHealth
- node: WorkerHealth
-}
-
export type WorkerHealthError = string | null
export type WorkerHealthResponseTimeMs = number | null
@@ -606,6 +601,11 @@ export interface WorkerHealth {
error?: WorkerHealthError
}
+export interface WorkersHealth {
+ scheduler: WorkerHealth
+ node: WorkerHealth
+}
+
export type WireGuardSettingsPublicKey = string | null
export type WireGuardSettingsPrivateKey = string | null
@@ -715,13 +715,6 @@ export const UsernameGenerationStrategy = {
export type UserUsageStatsListPeriod = Period | null
-export interface UserUsageStat {
- total_traffic: number
- period_start: string
-}
-
-export type UserUsageStatsListStats = { [key: string]: UserUsageStat[] }
-
export interface UserUsageStatsList {
period?: UserUsageStatsListPeriod
start: string
@@ -729,6 +722,13 @@ export interface UserUsageStatsList {
stats: UserUsageStatsListStats
}
+export interface UserUsageStat {
+ total_traffic: number
+ period_start: string
+}
+
+export type UserUsageStatsListStats = { [key: string]: UserUsageStat[] }
+
export type UserTemplateSimpleName = string | null
/**
@@ -766,6 +766,8 @@ export type UserTemplateResponseUsernamePrefix = string | null
*/
export type UserTemplateResponseExpireDuration = number | null
+export type UserTemplateResponseHwidLimit = number | null
+
/**
* data_limit can be 0 or greater
*/
@@ -777,6 +779,7 @@ export interface UserTemplateResponse {
name?: UserTemplateResponseName
/** data_limit can be 0 or greater */
data_limit?: UserTemplateResponseDataLimit
+ hwid_limit?: UserTemplateResponseHwidLimit
/** expire_duration can be 0 or greater in seconds */
expire_duration?: UserTemplateResponseExpireDuration
username_prefix?: UserTemplateResponseUsernamePrefix
@@ -812,6 +815,8 @@ export type UserTemplateModifyUsernamePrefix = string | null
*/
export type UserTemplateModifyExpireDuration = number | null
+export type UserTemplateModifyHwidLimit = number | null
+
/**
* data_limit can be 0 or greater
*/
@@ -823,6 +828,7 @@ export interface UserTemplateModify {
name?: UserTemplateModifyName
/** data_limit can be 0 or greater */
data_limit?: UserTemplateModifyDataLimit
+ hwid_limit?: UserTemplateModifyHwidLimit
/** expire_duration can be 0 or greater in seconds */
expire_duration?: UserTemplateModifyExpireDuration
username_prefix?: UserTemplateModifyUsernamePrefix
@@ -855,6 +861,8 @@ export type UserTemplateCreateUsernamePrefix = string | null
*/
export type UserTemplateCreateExpireDuration = number | null
+export type UserTemplateCreateHwidLimit = number | null
+
/**
* data_limit can be 0 or greater
*/
@@ -866,6 +874,7 @@ export interface UserTemplateCreate {
name?: UserTemplateCreateName
/** data_limit can be 0 or greater */
data_limit?: UserTemplateCreateDataLimit
+ hwid_limit?: UserTemplateCreateHwidLimit
/** expire_duration can be 0 or greater in seconds */
expire_duration?: UserTemplateCreateExpireDuration
username_prefix?: UserTemplateCreateUsernamePrefix
@@ -879,12 +888,15 @@ export interface UserTemplateCreate {
is_disabled?: UserTemplateCreateIsDisabled
}
+export type UserSubscriptionUpdateSchemaHwid = string | null
+
export type UserSubscriptionUpdateSchemaIp = string | null
export interface UserSubscriptionUpdateSchema {
created_at: string
user_agent: string
ip?: UserSubscriptionUpdateSchemaIp
+ hwid?: UserSubscriptionUpdateSchemaHwid
}
export interface UserSubscriptionUpdateList {
@@ -938,6 +950,8 @@ export type UserResponseEditAt = string | null
export type UserResponseNextPlan = NextPlanModel | null
+export type UserResponseHwidLimit = number | null
+
export type UserResponseAutoDeleteInDays = number | null
export type UserResponseGroupIds = number[] | null
@@ -968,6 +982,7 @@ export interface UserResponse {
on_hold_timeout?: UserResponseOnHoldTimeout
group_ids?: UserResponseGroupIds
auto_delete_in_days?: UserResponseAutoDeleteInDays
+ hwid_limit?: UserResponseHwidLimit
next_plan?: UserResponseNextPlan
id: number
username: string
@@ -995,6 +1010,8 @@ export type UserModifyStatus = UserStatus | null
export type UserModifyNextPlan = NextPlanModel | null
+export type UserModifyHwidLimit = number | null
+
export type UserModifyAutoDeleteInDays = number | null
export type UserModifyGroupIds = number[] | null
@@ -1027,10 +1044,20 @@ export interface UserModify {
on_hold_timeout?: UserModifyOnHoldTimeout
group_ids?: UserModifyGroupIds
auto_delete_in_days?: UserModifyAutoDeleteInDays
+ hwid_limit?: UserModifyHwidLimit
next_plan?: UserModifyNextPlan
status?: UserModifyStatus
}
+export type UserIPListIps = { [key: string]: number }
+
+/**
+ * User IP list - mapping of IP addresses to connection counts
+ */
+export interface UserIPList {
+ ips: UserIPListIps
+}
+
export type UserIPListAllNodes = { [key: string]: UserIPList | null }
/**
@@ -1040,19 +1067,33 @@ export interface UserIPListAll {
nodes: UserIPListAllNodes
}
-export type UserIPListIps = { [key: string]: number }
+export type UserHWIDResponseDeviceModel = string | null
-/**
- * User IP list - mapping of IP addresses to connection counts
- */
-export interface UserIPList {
- ips: UserIPListIps
+export type UserHWIDResponseOsVersion = string | null
+
+export type UserHWIDResponseDeviceOs = string | null
+
+export interface UserHWIDResponse {
+ id: number
+ hwid: string
+ device_os?: UserHWIDResponseDeviceOs
+ os_version?: UserHWIDResponseOsVersion
+ device_model?: UserHWIDResponseDeviceModel
+ created_at: string
+ last_used_at: string
+}
+
+export interface UserHWIDListResponse {
+ hwids: UserHWIDResponse[]
+ count: number
}
export type UserCreateStatus = UserStatus | null
export type UserCreateNextPlan = NextPlanModel | null
+export type UserCreateHwidLimit = number | null
+
export type UserCreateAutoDeleteInDays = number | null
export type UserCreateGroupIds = number[] | null
@@ -1083,6 +1124,7 @@ export interface UserCreate {
on_hold_timeout?: UserCreateOnHoldTimeout
group_ids?: UserCreateGroupIds
auto_delete_in_days?: UserCreateAutoDeleteInDays
+ hwid_limit?: UserCreateHwidLimit
next_plan?: UserCreateNextPlan
username: string
status?: UserCreateStatus
@@ -1244,6 +1286,8 @@ export type SubscriptionUserResponseEditAt = string | null
export type SubscriptionUserResponseNextPlan = NextPlanModel | null
+export type SubscriptionUserResponseHwidLimit = number | null
+
export type SubscriptionUserResponseGroupIds = number[] | null
export type SubscriptionUserResponseOnHoldTimeout = string | number | null
@@ -1268,6 +1312,7 @@ export interface SubscriptionUserResponse {
on_hold_expire_duration?: SubscriptionUserResponseOnHoldExpireDuration
on_hold_timeout?: SubscriptionUserResponseOnHoldTimeout
group_ids?: SubscriptionUserResponseGroupIds
+ hwid_limit?: SubscriptionUserResponseHwidLimit
next_plan?: SubscriptionUserResponseNextPlan
id: number
username: string
@@ -1364,6 +1409,8 @@ export interface ShadowsocksSettings {
export type SettingsSchemaGeneral = General | null
+export type SettingsSchemaHwid = HWIDSettings | null
+
export type SettingsSchemaSubscription = Subscription | null
export type SettingsSchemaNotificationEnable = NotificationEnable | null
@@ -1383,6 +1430,7 @@ export interface SettingsSchema {
notification_settings?: SettingsSchemaNotificationSettings
notification_enable?: SettingsSchemaNotificationEnable
subscription?: SettingsSchemaSubscription
+ hwid?: SettingsSchemaHwid
general?: SettingsSchemaGeneral
}
@@ -1534,31 +1582,6 @@ export type NotificationSettingsTelegramChatId = number | null
export type NotificationSettingsTelegramApiToken = string | null
-export interface NotificationEnable {
- admin?: AdminNotificationEnable
- core?: BaseNotificationEnable
- group?: BaseNotificationEnable
- host?: HostNotificationEnable
- node?: NodeNotificationEnable
- user?: UserNotificationEnable
- user_template?: BaseNotificationEnable
- days_left?: boolean
- 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 interface NotificationSettings {
notify_telegram?: boolean
notify_discord?: boolean
@@ -1572,6 +1595,18 @@ export interface NotificationSettings {
max_retries: number
}
+export interface NotificationEnable {
+ admin?: AdminNotificationEnable
+ core?: BaseNotificationEnable
+ group?: BaseNotificationEnable
+ host?: HostNotificationEnable
+ node?: NodeNotificationEnable
+ user?: UserNotificationEnable
+ user_template?: BaseNotificationEnable
+ days_left?: boolean
+ percentage_reached?: boolean
+}
+
export type NotificationChannelDiscordWebhookUrl = string | null
export type NotificationChannelTelegramTopicId = number | null
@@ -1587,6 +1622,19 @@ 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
}
@@ -1950,6 +1998,17 @@ export interface HostNotificationEnable {
modify_hosts?: boolean
}
+export interface HWIDSettings {
+ enabled?: boolean
+ forced?: boolean
+ /** @minimum 0 */
+ fallback_limit?: number
+ /** @minimum 0 */
+ min_limit?: number
+ /** @minimum 0 */
+ max_limit?: number
+}
+
export interface HTTPValidationError {
detail?: ValidationError[]
}
@@ -11512,6 +11571,68 @@ export function useUserSubscriptionInfo {
+ return orvalFetcher({ url: `/sub/${token}/raw`, method: 'GET', signal })
+}
+
+export const getUserSubscriptionRawQueryKey = (token: string) => {
+ return [`/sub/${token}/raw`] as const
+}
+
+export const getUserSubscriptionRawQueryOptions = >, TError = ErrorType>(
+ token: string,
+ options?: { query?: Partial>, TError, TData>> },
+) => {
+ const { query: queryOptions } = options ?? {}
+
+ const queryKey = queryOptions?.queryKey ?? getUserSubscriptionRawQueryKey(token)
+
+ const queryFn: QueryFunction>> = ({ signal }) => userSubscriptionRaw(token, signal)
+
+ return { queryKey, queryFn, enabled: !!token, ...queryOptions } as UseQueryOptions>, TError, TData> & { queryKey: DataTag }
+}
+
+export type UserSubscriptionRawQueryResult = NonNullable>>
+export type UserSubscriptionRawQueryError = ErrorType
+
+export function useUserSubscriptionRaw>, TError = ErrorType>(
+ token: string,
+ options: {
+ query: Partial>, TError, TData>> &
+ Pick>, TError, TData>, 'initialData'>
+ },
+): DefinedUseQueryResult & { queryKey: DataTag }
+export function useUserSubscriptionRaw>, TError = ErrorType>(
+ token: string,
+ options?: {
+ query?: Partial>, TError, TData>> &
+ Pick>, TError, TData>, 'initialData'>
+ },
+): UseQueryResult & { queryKey: DataTag }
+export function useUserSubscriptionRaw>, TError = ErrorType>(
+ token: string,
+ options?: { query?: Partial>, TError, TData>> },
+): UseQueryResult & { queryKey: DataTag }
+/**
+ * @summary User Subscription Raw
+ */
+
+export function useUserSubscriptionRaw>, TError = ErrorType>(
+ token: string,
+ options?: { query?: Partial>, TError, TData>> },
+): UseQueryResult & { queryKey: DataTag } {
+ const queryOptions = getUserSubscriptionRawQueryOptions(token, options)
+
+ const query = useQuery(queryOptions) as UseQueryResult & { queryKey: DataTag }
+
+ query.queryKey = queryOptions.queryKey
+
+ return query
+}
+
/**
* Get applications available for user's subscription.
* @summary User Subscription Apps
@@ -12199,3 +12320,157 @@ export const useBulkEnableUserTemplates = <
return useMutation(mutationOptions)
}
+
+/**
+ * Get user's registered hardware IDs
+ * @summary Get User Hwids
+ */
+export const getUserHwids = (userId: number, signal?: AbortSignal) => {
+ return orvalFetcher({ url: `/api/user/${userId}/hwids`, method: 'GET', signal })
+}
+
+export const getGetUserHwidsQueryKey = (userId: number) => {
+ return [`/api/user/${userId}/hwids`] as const
+}
+
+export const getGetUserHwidsQueryOptions = >, TError = ErrorType>(
+ userId: number,
+ options?: { query?: Partial>, TError, TData>> },
+) => {
+ const { query: queryOptions } = options ?? {}
+
+ const queryKey = queryOptions?.queryKey ?? getGetUserHwidsQueryKey(userId)
+
+ const queryFn: QueryFunction>> = ({ signal }) => getUserHwids(userId, signal)
+
+ return { queryKey, queryFn, enabled: !!userId, ...queryOptions } as UseQueryOptions>, TError, TData> & { queryKey: DataTag }
+}
+
+export type GetUserHwidsQueryResult = NonNullable>>
+export type GetUserHwidsQueryError = ErrorType
+
+export function useGetUserHwids>, TError = ErrorType>(
+ userId: number,
+ options: {
+ query: Partial>, TError, TData>> & Pick>, TError, TData>, 'initialData'>
+ },
+): DefinedUseQueryResult & { queryKey: DataTag }
+export function useGetUserHwids>, TError = ErrorType>(
+ userId: number,
+ options?: {
+ query?: Partial>, TError, TData>> &
+ Pick>, TError, TData>, 'initialData'>
+ },
+): UseQueryResult & { queryKey: DataTag }
+export function useGetUserHwids>, TError = ErrorType>(
+ userId: number,
+ options?: { query?: Partial>, TError, TData>> },
+): UseQueryResult & { queryKey: DataTag }
+/**
+ * @summary Get User Hwids
+ */
+
+export function useGetUserHwids>, TError = ErrorType>(
+ userId: number,
+ options?: { query?: Partial>, TError, TData>> },
+): UseQueryResult & { queryKey: DataTag } {
+ const queryOptions = getGetUserHwidsQueryOptions(userId, options)
+
+ const query = useQuery(queryOptions) as UseQueryResult & { queryKey: DataTag }
+
+ query.queryKey = queryOptions.queryKey
+
+ return query
+}
+
+/**
+ * Delete a specific hardware ID from user
+ * @summary Delete User Hwid
+ */
+export const deleteUserHwid = (userId: number, hwid: string) => {
+ return orvalFetcher({ url: `/api/user/${userId}/hwids/${hwid}`, method: 'DELETE' })
+}
+
+export const getDeleteUserHwidMutationOptions = <
+ TData = Awaited>,
+ TError = ErrorType,
+ TContext = unknown,
+>(options?: {
+ mutation?: UseMutationOptions
+}) => {
+ const mutationKey = ['deleteUserHwid']
+ const { mutation: mutationOptions } = options
+ ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey
+ ? options
+ : { ...options, mutation: { ...options.mutation, mutationKey } }
+ : { mutation: { mutationKey } }
+
+ const mutationFn: MutationFunction>, { userId: number; hwid: string }> = props => {
+ const { userId, hwid } = props ?? {}
+
+ return deleteUserHwid(userId, hwid)
+ }
+
+ return { mutationFn, ...mutationOptions } as UseMutationOptions
+}
+
+export type DeleteUserHwidMutationResult = NonNullable>>
+
+export type DeleteUserHwidMutationError = ErrorType
+
+/**
+ * @summary Delete User Hwid
+ */
+export const useDeleteUserHwid = >, TError = ErrorType, TContext = unknown>(options?: {
+ mutation?: UseMutationOptions
+}): UseMutationResult => {
+ const mutationOptions = getDeleteUserHwidMutationOptions(options)
+
+ return useMutation(mutationOptions)
+}
+
+/**
+ * Delete all hardware IDs for user
+ * @summary Reset User Hwids
+ */
+export const resetUserHwids = (userId: number, signal?: AbortSignal) => {
+ return orvalFetcher({ url: `/api/user/${userId}/hwids/reset`, method: 'POST', signal })
+}
+
+export const getResetUserHwidsMutationOptions = <
+ TData = Awaited>,
+ TError = ErrorType,
+ TContext = unknown,
+>(options?: {
+ mutation?: UseMutationOptions
+}) => {
+ const mutationKey = ['resetUserHwids']
+ const { mutation: mutationOptions } = options
+ ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey
+ ? options
+ : { ...options, mutation: { ...options.mutation, mutationKey } }
+ : { mutation: { mutationKey } }
+
+ const mutationFn: MutationFunction>, { userId: number }> = props => {
+ const { userId } = props ?? {}
+
+ return resetUserHwids(userId)
+ }
+
+ return { mutationFn, ...mutationOptions } as UseMutationOptions
+}
+
+export type ResetUserHwidsMutationResult = NonNullable>>
+
+export type ResetUserHwidsMutationError = ErrorType
+
+/**
+ * @summary Reset User Hwids
+ */
+export const useResetUserHwids = >, TError = ErrorType, TContext = unknown>(options?: {
+ mutation?: UseMutationOptions
+}): UseMutationResult => {
+ const mutationOptions = getResetUserHwidsMutationOptions(options)
+
+ return useMutation(mutationOptions)
+}
From 2f31eac872962676a50f3d6ab9bda09d91ed5d30 Mon Sep 17 00:00:00 2001
From: x0sina
Date: Fri, 15 May 2026 15:49:40 +0330
Subject: [PATCH 5/6] fix(dashboard): remove Fingerprint icon from HWID
settings
---
dashboard/src/pages/_dashboard.settings.hwid.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/dashboard/src/pages/_dashboard.settings.hwid.tsx b/dashboard/src/pages/_dashboard.settings.hwid.tsx
index e4f50cf31..814b19305 100644
--- a/dashboard/src/pages/_dashboard.settings.hwid.tsx
+++ b/dashboard/src/pages/_dashboard.settings.hwid.tsx
@@ -4,7 +4,6 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { zodResolver } from '@hookform/resolvers/zod'
-import { Fingerprint } from 'lucide-react'
import { useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
@@ -113,8 +112,7 @@ export default function HwidSettings() {