gitscope
← marzneshin / Diff · 4708fcb → 4708fcb
Changes 4708fcb..4708fcb
Patch commit
diff --git a/app/db/crud.py b/app/db/crud.py
index d584f23..97c8edf 100644
--- a/app/db/crud.py
+++ b/app/db/crud.py
@@ -2,6 +2,7 @@ import json
import secrets
from datetime import datetime, timedelta
from enum import Enum
+from types import NoneType
from typing import List, Optional, Tuple, Union
from sqlalchemy import and_, delete, update, select
@@ -21,7 +22,7 @@ from app.db.models import (
System,
User,
)
-from app.models.admin import AdminCreate, AdminModify, AdminPartialModify
+from app.models.admin import AdminCreate, AdminPartialModify
from app.models.node import (
NodeCreate,
NodeModify,
@@ -229,6 +230,7 @@ def get_users(
activated: bool | None = None,
expired: bool | None = None,
data_limit_reached: bool | None = None,
+ enabled: bool | None = None,
) -> Union[List[User], Tuple[List[User], int]]:
query = db.query(User)
@@ -266,6 +268,9 @@ def get_users(
if isinstance(data_limit_reached, bool):
query = query.filter(User.data_limit_reached == data_limit_reached)
+ if isinstance(enabled, bool):
+ query = query.filter(User.enabled == enabled)
+
if admin:
query = query.filter(User.admin == admin)
@@ -344,7 +349,17 @@ def get_users_count(
return query.count()
-def create_user(db: Session, user: UserCreate, admin: Admin = None):
+def create_user(
+ db: Session,
+ user: UserCreate,
+ admin: Admin = None,
+ allowed_services: list | None = None,
+):
+ service_ids = (
+ [sid for sid in user.service_ids if sid in allowed_services]
+ if allowed_services is not None
+ else user.service_ids
+ )
dbuser = User(
username=user.username,
key=user.key,
@@ -353,7 +368,7 @@ def create_user(db: Session, user: UserCreate, admin: Admin = None):
usage_duration=user.usage_duration,
activation_deadline=user.activation_deadline,
services=db.query(Service)
- .filter(Service.id.in_(user.service_ids))
+ .filter(Service.id.in_(service_ids))
.all(), # user.services,
data_limit=(user.data_limit or None),
admin=admin,
@@ -372,7 +387,12 @@ def remove_user(db: Session, dbuser: User):
db.commit()
-def update_user(db: Session, dbuser: User, modify: UserModify):
+def update_user(
+ db: Session,
+ dbuser: User,
+ modify: UserModify,
+ allowed_services: list | None = None,
+):
if modify.data_limit is not None:
dbuser.data_limit = modify.data_limit or None
@@ -395,8 +415,15 @@ def update_user(db: Session, dbuser: User, modify: UserModify):
dbuser.usage_duration = modify.usage_duration
if modify.service_ids is not None:
+ if allowed_services is not None:
+ service_ids = [
+ sid for sid in modify.service_ids if sid in allowed_services
+ ]
+ else:
+ service_ids = modify.service_ids
+
dbuser.services = (
- db.query(Service).filter(Service.id.in_(modify.service_ids)).all()
+ db.query(Service).filter(Service.id.in_(service_ids)).all()
)
dbuser.edit_at = datetime.utcnow()
@@ -472,7 +499,7 @@ def get_tls_certificate(db: Session):
return db.query(TLS).first()
-def get_admin(db: Session, username: str):
+def get_admin(db: Session, username: str) -> Admin | None:
return db.query(Admin).filter(Admin.username == username).first()
@@ -481,6 +508,13 @@ def create_admin(db: Session, admin: AdminCreate):
username=admin.username,
hashed_password=admin.hashed_password,
is_sudo=admin.is_sudo,
+ enabled=admin.enabled,
+ all_services_access=admin.all_services_access,
+ modify_users_access=admin.modify_users_access,
+ services=db.query(Service)
+ .filter(Service.id.in_(admin.service_ids))
+ .all(),
+ subscription_url_prefix=admin.subscription_url_prefix,
)
db.add(dbadmin)
db.commit()
@@ -488,11 +522,27 @@ def create_admin(db: Session, admin: AdminCreate):
return dbadmin
-def update_admin(db: Session, dbadmin: Admin, modified_admin: AdminModify):
- dbadmin.is_sudo = modified_admin.is_sudo
- if dbadmin.hashed_password != modified_admin.hashed_password:
- dbadmin.hashed_password = modified_admin.hashed_password
- dbadmin.password_reset_at = datetime.utcnow()
+def update_admin(
+ db: Session, dbadmin: Admin, modifications: AdminPartialModify
+):
+ for attribute in [
+ "is_sudo",
+ "hashed_password",
+ "enabled",
+ "all_services_access",
+ "modify_users_access",
+ "subscription_url_prefix",
+ ]:
+ if not isinstance(getattr(modifications, attribute), NoneType):
+ setattr(dbadmin, attribute, getattr(modifications, attribute))
+ if attribute == "hashed_password":
+ dbadmin.password_reset_at = datetime.utcnow()
+ if isinstance(modifications.service_ids, list):
+ dbadmin.services = (
+ db.query(Service)
+ .filter(Service.id.in_(modifications.service_ids))
+ .all()
+ )
db.commit()
db.refresh(dbadmin)
return dbadmin
diff --git a/app/db/migrations/versions/3acfdeb79b78_add_admin_services.py b/app/db/migrations/versions/3acfdeb79b78_add_admin_services.py
new file mode 100644
index 0000000..b62121b
--- /dev/null
+++ b/app/db/migrations/versions/3acfdeb79b78_add_admin_services.py
@@ -0,0 +1,80 @@
+"""Add services and subscription url prefix to admins
+
+Revision ID: 3acfdeb79b78
+Revises: a83e4dd22672
+Create Date: 2024-07-08 12:50:12.570534
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "3acfdeb79b78"
+down_revision = "a83e4dd22672"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "admins_services",
+ sa.Column("admin_id", sa.Integer(), nullable=False),
+ sa.Column("service_id", sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["admin_id"],
+ ["admins.id"],
+ ),
+ sa.ForeignKeyConstraint(
+ ["service_id"],
+ ["services.id"],
+ ),
+ sa.PrimaryKeyConstraint("admin_id", "service_id"),
+ )
+ op.add_column(
+ "admins",
+ sa.Column(
+ "all_services_access",
+ sa.Boolean(),
+ server_default=sa.sql.false(),
+ nullable=False,
+ ),
+ )
+ op.add_column(
+ "admins",
+ sa.Column(
+ "modify_users_access",
+ sa.Boolean(),
+ server_default=sa.sql.true(),
+ nullable=False,
+ ),
+ )
+ op.add_column(
+ "admins",
+ sa.Column(
+ "enabled",
+ sa.Boolean(),
+ server_default=sa.sql.true(),
+ nullable=False,
+ ),
+ )
+ op.add_column(
+ "admins",
+ sa.Column(
+ "subscription_url_prefix",
+ sa.String(length=256),
+ nullable=False,
+ server_default="",
+ ),
+ )
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column("admins", "subscription_url_prefix")
+ op.drop_column("admins", "all_services_access")
+ op.drop_column("admins", "enabled")
+ op.drop_column("admins", "modify_users_access")
+ op.drop_table("admins_services")
+ # ### end Alembic commands ###
diff --git a/app/db/models.py b/app/db/models.py
index 4a92a9a..7c03595 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -1,4 +1,5 @@
import os
+import secrets
from datetime import datetime
import sqlalchemy.sql
@@ -17,11 +18,13 @@ from sqlalchemy import (
JSON,
and_,
func,
+ select,
)
from sqlalchemy.ext.hybrid import hybrid_property
-from sqlalchemy.orm import relationship
+from sqlalchemy.orm import relationship, column_property
from sqlalchemy.sql.expression import text
+from app.config.env import SUBSCRIPTION_URL_PREFIX
from app.db.base import Base
from app.models.node import NodeStatus
from app.models.proxy import (
@@ -37,18 +40,12 @@ from app.models.user import (
UserExpireStrategy,
)
-
-class Admin(Base):
- __tablename__ = "admins"
-
- id = Column(Integer, primary_key=True)
- username = Column(String(32), unique=True, index=True)
- hashed_password = Column(String(128))
- users = relationship("User", back_populates="admin")
- created_at = Column(DateTime, default=datetime.utcnow)
- is_sudo = Column(Boolean, default=False)
- password_reset_at = Column(DateTime)
-
+admins_services = Table(
+ "admins_services",
+ Base.metadata,
+ Column("admin_id", ForeignKey("admins.id"), primary_key=True),
+ Column("service_id", ForeignKey("services.id"), primary_key=True),
+)
inbounds_services = Table(
"inbounds_services",
@@ -65,10 +62,68 @@ users_services = Table(
)
+class Admin(Base):
+ __tablename__ = "admins"
+
+ id = Column(Integer, primary_key=True)
+ username = Column(String(32), unique=True, index=True)
+ hashed_password = Column(String(128))
+ users = relationship("User", back_populates="admin")
+ services = relationship(
+ "Service",
+ secondary=admins_services,
+ back_populates="admins",
+ lazy="joined",
+ )
+ enabled = Column(
+ Boolean,
+ nullable=False,
+ default=True,
+ server_default=sqlalchemy.sql.true(),
+ )
+ all_services_access = Column(
+ Boolean,
+ nullable=False,
+ default=False,
+ server_default=sqlalchemy.sql.false(),
+ )
+ modify_users_access = Column(
+ Boolean,
+ nullable=False,
+ default=True,
+ server_default=sqlalchemy.sql.true(),
+ )
+ created_at = Column(DateTime, default=datetime.utcnow)
+ is_sudo = Column(Boolean, default=False)
+ password_reset_at = Column(DateTime)
+ subscription_url_prefix = Column(
+ String(256),
+ nullable=False,
+ default="",
+ server_default=sqlalchemy.sql.text(""),
+ )
+
+ @property
+ def service_ids(self):
+ return [service.id for service in self.services]
+
+ @classmethod
+ def __declare_last__(cls):
+ cls.users_data_usage = column_property(
+ select(func.coalesce(func.sum(User.lifetime_used_traffic), 0))
+ .where(User.admin_id == cls.id)
+ .correlate_except(User)
+ .scalar_subquery()
+ )
+
+
class Service(Base):
__tablename__ = "services"
id = Column(Integer, primary_key=True)
name = Column(String(64))
+ admins = relationship(
+ "Admin", secondary=admins_services, back_populates="services"
+ )
users = relationship(
"User", secondary=users_services, back_populates="services"
)
@@ -193,6 +248,14 @@ class User(Base):
def status(self):
return UserStatus.ACTIVE if self.is_active else UserStatus.INACTIVE
+ @property
+ def subscription_url(self):
+ prefix = self.admin.subscription_url_prefix or SUBSCRIPTION_URL_PREFIX
+ return (
+ prefix.replace("*", secrets.token_hex(8))
+ + f"/sub/{self.username}/{self.key}"
+ )
+
class Inbound(Base):
__tablename__ = "inbounds"
@@ -250,7 +313,7 @@ class InboundHost(Base):
Boolean,
default=False,
nullable=False,
- server_default=sqlalchemy.sql.true(),
+ server_default=sqlalchemy.sql.false(),
)
fragment = Column(JSON())
diff --git a/app/dependencies.py b/app/dependencies.py
index 645ad22..d649211 100644
--- a/app/dependencies.py
+++ b/app/dependencies.py
@@ -31,6 +31,9 @@ def get_admin(
if not created_at or dbadmin.password_reset_at > created_at:
return
+ if not dbadmin.is_sudo and not dbadmin.enabled:
+ return
+
return Admin.model_validate(dbadmin)
@@ -86,6 +89,13 @@ def get_user(
return db_user
+def user_modification_access(
+ admin: Annotated[Admin, Depends(get_current_admin)]
+):
+ if not admin.is_sudo and not admin.modify_users_access:
+ raise HTTPException(status_code=403, detail="You're not allowed")
+
+
def parse_start_date(start: str | None = None):
if not start:
return datetime.fromtimestamp(
@@ -109,3 +119,4 @@ SudoAdminDep = Annotated[Admin, Depends(sudo_admin)]
DBDep = Annotated[Session, Depends(get_db)]
StartDateDep = Annotated[datetime, Depends(parse_start_date)]
EndDateDep = Annotated[datetime, Depends(parse_end_date)]
+ModifyUsersAccess = Annotated[None, Depends(user_modification_access)]
diff --git a/app/models/admin.py b/app/models/admin.py
index 998de2f..ea3cc03 100644
--- a/app/models/admin.py
+++ b/app/models/admin.py
@@ -1,5 +1,3 @@
-from typing import Optional
-
from fastapi.security import OAuth2PasswordBearer
from passlib.context import CryptContext
from pydantic import ConfigDict, BaseModel
@@ -20,10 +18,16 @@ class Admin(BaseModel):
id: int | None = None
username: str
is_sudo: bool
+ enabled: bool = True
+ all_services_access: bool = False
+ modify_users_access: bool = True
+ service_ids: list = []
+ subscription_url_prefix: str = ""
model_config = ConfigDict(from_attributes=True)
class AdminCreate(Admin):
+ username: str
password: str
@property
@@ -31,19 +35,37 @@ class AdminCreate(Admin):
return pwd_context.hash(self.password)
-class AdminModify(BaseModel):
+class AdminResponse(Admin):
+ id: int
+ users_data_usage: int
+
+
+class AdminModify(Admin):
password: str
is_sudo: bool
@property
def hashed_password(self):
- return pwd_context.hash(self.password)
+ return (
+ pwd_context.hash(self.password)
+ if self.password is not None
+ else None
+ )
class AdminPartialModify(AdminModify):
- __annotations__ = {
- k: Optional[v] for k, v in AdminModify.__annotations__.items()
- }
+ """__annotations__ = {
+ k: v | None for k, v in AdminModify.__annotations__.items()
+ }"""
+
+ password: str | None = None
+ username: str | None = None
+ is_sudo: bool | None = None
+ enabled: bool | None = None
+ all_services_access: bool | None = None
+ modify_users_access: bool | None = None
+ service_ids: list | None = None
+ subscription_url_prefix: str | None = None
class AdminInDB(Admin):
diff --git a/app/models/user.py b/app/models/user.py
index 2c84436..de56730 100644
--- a/app/models/user.py
+++ b/app/models/user.py
@@ -7,13 +7,10 @@ from pydantic import (
ConfigDict,
BaseModel,
Field,
- computed_field,
StringConstraints,
model_validator,
)
-from app.config.env import SUBSCRIPTION_URL_PREFIX
-
USERNAME_REGEXP = r"^\w{3,32}$"
@@ -128,16 +125,10 @@ class UserResponse(User):
lifetime_used_traffic: int
created_at: datetime
service_ids: list[int]
+ subscription_url: str
model_config = ConfigDict(from_attributes=True)
- @computed_field
- @property
- def subscription_url(self) -> str:
- salt = secrets.token_hex(8)
- url_prefix = SUBSCRIPTION_URL_PREFIX.replace("*", salt)
- return f"{url_prefix}/sub/{self.username}/{self.key}"
-
class UserUsageResponse(BaseModel):
node_id: int | None = None
diff --git a/app/routes/admin.py b/app/routes/admin.py
index bdf814e..e4cc4b7 100644
--- a/app/routes/admin.py
+++ b/app/routes/admin.py
@@ -8,9 +8,19 @@ from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import paginate
from app.db import Session, crud
-from app.db.models import Admin as DBAdmin
+from app.db.models import Admin as DBAdmin, Service, User
from app.dependencies import AdminDep, SudoAdminDep, DBDep
-from app.models.admin import Admin, AdminCreate, AdminInDB, AdminModify, Token
+from app.marznode.operations import update_user
+from app.models.admin import (
+ Admin,
+ AdminCreate,
+ AdminInDB,
+ Token,
+ AdminPartialModify,
+ AdminResponse,
+)
+from app.models.service import ServiceResponse
+from app.models.user import UserResponse
from app.utils.auth import create_admin_token
router = APIRouter(tags=["Admin"], prefix="/admins")
@@ -30,7 +40,7 @@ def authenticate_admin(
)
-@router.get("", response_model=Page[Admin])
+@router.get("", response_model=Page[AdminResponse])
def get_admins(db: DBDep, admin: SudoAdminDep, username: str | None = None):
query = db.query(DBAdmin)
if username:
@@ -75,21 +85,22 @@ def admin_token(
)
-@router.put("/{username}", response_model=Admin)
-def modify_admin(
- username: str, modified_admin: AdminModify, db: DBDep, admin: AdminDep
+@router.get("/{username}", response_model=AdminResponse)
+def get_admin(
+ username: str,
+ db: DBDep,
+ admin: SudoAdminDep,
):
- if not (admin.is_sudo or admin.username == username):
- raise HTTPException(status_code=403, detail="You're not allowed")
+ return crud.get_admin(db, username)
- # If a non-sudoer admin is making itself a sudoer
- if (admin.username == username) and (
- modified_admin.is_sudo and not admin.is_sudo
- ):
- raise HTTPException(
- status_code=403, detail="You can't make yourself sudoer!"
- )
+@router.put("/{username}", response_model=AdminResponse)
+def modify_admin(
+ username: str,
+ modified_admin: AdminPartialModify,
+ db: DBDep,
+ admin: SudoAdminDep,
+):
dbadmin = crud.get_admin(db, username)
if not dbadmin:
raise HTTPException(status_code=404, detail="Admin not found")
@@ -105,6 +116,85 @@ def modify_admin(
return dbadmin
+@router.get("/{username}/services", response_model=Page[ServiceResponse])
+def get_admin_services(username: str, db: DBDep, admin: SudoAdminDep):
+ """
+ Get user services
+ """
+ db_admin = crud.get_admin(db, username)
+ if not db_admin:
+ raise HTTPException(status_code=404, detail="Admin not found")
+
+ if db_admin.is_sudo or db_admin.all_services_access:
+ query = db.query(Service)
+ else:
+ query = (
+ db.query(Service)
+ .join(Service.admins)
+ .where(DBAdmin.id == db_admin.id)
+ )
+
+ return paginate(query)
+
+
+@router.get("/{username}/users", response_model=Page[UserResponse])
+def get_admin_users(username: str, db: DBDep, admin: SudoAdminDep):
+ """
+ Get user services
+ """
+ db_admin = crud.get_admin(db, username)
+ if not db_admin:
+ raise HTTPException(status_code=404, detail="Admin not found")
+
+ query = db.query(User).where(User.admin_id == db_admin.id)
+
+ return paginate(query)
+
+
+@router.get("/{username}/disable_users", response_model=AdminResponse)
+async def disable_users(username: str, db: DBDep, admin: SudoAdminDep):
+ db_admin = crud.get_admin(db, username)
+ if not db_admin:
+ raise HTTPException(status_code=404, detail="Admin not found")
+
+ if db_admin.is_sudo and db_admin.username != admin.username:
+ raise HTTPException(
+ status_code=403,
+ detail="You're not allowed.",
+ )
+
+ for user in crud.get_users(db, admin=db_admin, enabled=True):
+ if user.activated:
+ update_user(user, remove=True)
+ user.enabled = False
+ user.activated = False
+ db.commit()
+
+ return db_admin
+
+
+@router.get("/{username}/enable_users", response_model=AdminResponse)
+async def enable_users(username: str, db: DBDep, admin: SudoAdminDep):
+ db_admin = crud.get_admin(db, username)
+ if not db_admin:
+ raise HTTPException(status_code=404, detail="Admin not found")
+
+ if db_admin.is_sudo and db_admin.username != admin.username:
+ raise HTTPException(
+ status_code=403,
+ detail="You're not allowed.",
+ )
+
+ for user in crud.get_users(db, admin=db_admin, enabled=False):
+ user.enabled = True
+ if user.is_active:
+ update_user(user)
+ user.activated = True
+ db.commit()
+
+ return db_admin
+
+
@router.delete("/{username}")
def remove_admin(username: str, db: DBDep, admin: SudoAdminDep):
dbadmin = crud.get_admin(db, username)
diff --git a/app/routes/service.py b/app/routes/service.py
index 9a384bf..e38b839 100644
--- a/app/routes/service.py
+++ b/app/routes/service.py
@@ -18,8 +18,13 @@ router = APIRouter(prefix="/services", tags=["Service"])
@router.get("", response_model=Page[ServiceResponse])
def get_services(db: DBDep, admin: AdminDep, name: str = Query(None)):
query = db.query(Service)
+
if name:
query = query.filter(Service.name.ilike(f"%{name}%"))
+
+ if not admin.is_sudo and not admin.all_services_access:
+ query = query.filter(Service.id.in_(admin.service_ids))
+
return paginate(query)
@@ -49,6 +54,9 @@ def get_service(id: int, db: DBDep, admin: AdminDep):
if not dbservice:
raise HTTPException(status_code=404, detail="Service not found")
+ if id not in admin.service_ids:
+ raise HTTPException(status_code=403, detail="You're not allowed")
+
return dbservice
diff --git a/app/routes/user.py b/app/routes/user.py
index ea2c862..4ce6bb7 100644
--- a/app/routes/user.py
+++ b/app/routes/user.py
@@ -19,6 +19,7 @@ from app.dependencies import (
UserDep,
StartDateDep,
EndDateDep,
+ ModifyUsersAccess,
)
from app.models.service import ServiceResponse
from app.models.user import (
@@ -111,7 +112,14 @@ async def add_user(new_user: UserCreate, db: DBDep, admin: AdminDep):
try:
db_user = crud.create_user(
- db, new_user, admin=crud.get_admin(db, admin.username)
+ db,
+ new_user,
+ admin=crud.get_admin(db, admin.username),
+ allowed_services=(
+ admin.service_ids
+ if not admin.is_sudo and not admin.all_services_access
+ else None
+ ),
)
except sqlalchemy.exc.IntegrityError:
db.rollback()
@@ -138,7 +146,12 @@ async def reset_users_data_usage(db: DBDep, admin: SudoAdminDep):
@router.delete("/expired")
-async def delete_expired(passed_time: int, db: DBDep, admin: AdminDep):
+async def delete_expired(
+ passed_time: int,
+ db: DBDep,
+ admin: AdminDep,
+ modify_access: ModifyUsersAccess,
+):
"""
Delete expired users
- **passed_time** must be a timestamp
@@ -187,7 +200,11 @@ def get_user(db_user: UserDep):
@router.put("/{username}", response_model=UserResponse)
async def modify_user(
- db_user: UserDep, modifications: UserModify, db: DBDep, admin: AdminDep
+ db_user: UserDep,
+ modifications: UserModify,
+ db: DBDep,
+ admin: AdminDep,
+ modify_access: ModifyUsersAccess,
):
"""
Modify a user
@@ -197,7 +214,16 @@ async def modify_user(
active_before = db_user.is_active
old_inbounds = {(i.node_id, i.protocol, i.tag) for i in db_user.inbounds}
- new_user = crud.update_user(db, db_user, modifications)
+ new_user = crud.update_user(
+ db,
+ db_user,
+ modifications,
+ allowed_services=(
+ admin.service_ids
+ if not admin.is_sudo and not admin.all_services_access
+ else None
+ ),
+ )
active_after = new_user.is_active
new_inbounds = {(i.node_id, i.protocol, i.tag) for i in new_user.inbounds}
@@ -241,7 +267,12 @@ async def modify_user(
@router.delete("/{username}")
-async def remove_user(db_user: UserDep, db: DBDep, admin: AdminDep):
+async def remove_user(
+ db_user: UserDep,
+ db: DBDep,
+ admin: AdminDep,
+ modify_access: ModifyUsersAccess,
+):
"""
Remove a user
"""
@@ -258,24 +289,30 @@ async def remove_user(db_user: UserDep, db: DBDep, admin: AdminDep):
@router.get("/{username}/services", response_model=Page[ServiceResponse])
-def get_user_services(username: str, db: DBDep):
+def get_user_services(user: UserDep, db: DBDep, admin: AdminDep):
"""
Get user services
"""
- user = crud.get_user(db, username)
-
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
query = (
- db.query(Service).join(Service.users).where(User.username == username)
+ db.query(Service)
+ .join(Service.users)
+ .where(User.username == user.username)
)
+ if not admin.is_sudo and not admin.all_services_access:
+ query.filter(Service.id.in_(admin.service_ids))
+
return paginate(query)
@router.post("/{username}/reset", response_model=UserResponse)
-async def reset_user_data_usage(db_user: UserDep, db: DBDep, admin: AdminDep):
+async def reset_user_data_usage(
+ db_user: UserDep,
+ db: DBDep,
+ admin: AdminDep,
+ modify_access: ModifyUsersAccess,
+):
"""
Reset user data usage
"""
@@ -296,7 +333,12 @@ async def reset_user_data_usage(db_user: UserDep, db: DBDep, admin: AdminDep):
@router.post("/{username}/enable", response_model=UserResponse)
-async def enable_user(db_user: UserDep, db: DBDep, admin: AdminDep):
+async def enable_user(
+ db_user: UserDep,
+ db: DBDep,
+ admin: AdminDep,
+ modify_access: ModifyUsersAccess,
+):
"""
Enables a user
"""
@@ -316,7 +358,12 @@ async def enable_user(db_user: UserDep, db: DBDep, admin: AdminDep):
@router.post("/{username}/disable", response_model=UserResponse)
-async def disable_user(db_user: UserDep, db: DBDep, admin: AdminDep):
+async def disable_user(
+ db_user: UserDep,
+ db: DBDep,
+ admin: AdminDep,
+ modify_access: ModifyUsersAccess,
+):
"""
Disables a user
"""
@@ -337,7 +384,10 @@ async def disable_user(db_user: UserDep, db: DBDep, admin: AdminDep):
@router.post("/{username}/revoke_sub", response_model=UserResponse)
async def revoke_user_subscription(
- db_user: UserDep, db: DBDep, admin: AdminDep
+ db_user: UserDep,
+ db: DBDep,
+ admin: AdminDep,
+ modify_access: ModifyUsersAccess,
):
"""
Revoke users subscription (Subscription link and proxies)
diff --git a/dashboard/public/locales/en.json b/dashboard/public/locales/en.json
index 6bbabff..b9ff05a 100644
--- a/dashboard/public/locales/en.json
+++ b/dashboard/public/locales/en.json
@@ -32,6 +32,7 @@
"alpn": "ALPN",
"security": "Security",
"user_info": "User Info",
+ "admin_info": "Admin Info",
"subscription": "Subscription",
"subscription_link": "Subscription Link",
"fingerprint": "Fingerprint",
@@ -66,6 +67,10 @@
"valid": "Valid",
"finished": "Finished",
"expired": "Expired",
+ "change": "Change",
+ "admins": "Admins",
+ "normal": "Normal",
+ "sudo": "Sudo",
"support-us": {
"title": "Support Us",
"desc": "Marzneshin is free and open-source software (FOSS), join us and show your support by donating.",
@@ -204,6 +209,24 @@
}
}
},
+ "admins": {
+ "permission": "Permission",
+ "dialogs": {
+ "creation": {
+ "title": "Create admin"
+ },
+ "edition": {
+ "title": "Edit admin"
+ },
+ "deletion": {
+ "title": "Delete admin"
+ }
+ },
+ "modify-users-access": "Modify users access",
+ "subscription-url-prefix": "Subscription URL prefix",
+ "sudo-privilage": "Sudo privilage",
+ "all-services-access": "All services access"
+ },
"hosts": {
"fragment": {
"interval": "Interval",
diff --git a/dashboard/src/components/boolean-pill/index.tsx b/dashboard/src/components/boolean-pill/index.tsx
index 18b5aff..b8621a1 100644
--- a/dashboard/src/components/boolean-pill/index.tsx
+++ b/dashboard/src/components/boolean-pill/index.tsx
@@ -1,15 +1,23 @@
import type { FC } from "react";
-import { Badge } from "@marzneshin/components";
+import { Badge, BadgeVariantKeys } from "@marzneshin/components";
interface BooleanPillProps {
active: boolean;
activeLabel: string;
+ activeVariant?: BadgeVariantKeys;
inactiveLabel: string;
+ inactiveVariant?: BadgeVariantKeys;
}
-export const BooleanPill: FC<BooleanPillProps> = ({ active, activeLabel, inactiveLabel }) => {
+export const BooleanPill: FC<BooleanPillProps> = ({
+ active,
+ activeLabel,
+ inactiveLabel,
+ activeVariant = "positive",
+ inactiveVariant = "destructive"
+}) => {
return (
- <Badge variant={active ? "positive" : "destructive"}>
+ <Badge variant={active ? activeVariant : inactiveVariant}>
{active ? activeLabel : inactiveLabel}
</Badge>
)
diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts
index 2949f2b..9f39c9a 100644
--- a/dashboard/src/components/index.ts
+++ b/dashboard/src/components/index.ts
@@ -1,4 +1,5 @@
export * from "./ui";
+export * from "./settings-dialog";
export * from "./header";
export * from "./sidebar";
export * from "./data-table";
@@ -16,3 +17,5 @@ export * from "./logo";
export * from "./awaiting";
export * from "./boolean-pill";
export * from "./info-table";
+export * from "./skeletons";
+export * from "./no-propogation-button";
diff --git a/dashboard/src/components/info-table/table-row-with-cell.tsx b/dashboard/src/components/info-table/table-row-with-cell.tsx
index 8236c98..3f84fb1 100644
--- a/dashboard/src/components/info-table/table-row-with-cell.tsx
+++ b/dashboard/src/components/info-table/table-row-with-cell.tsx
@@ -1,15 +1,15 @@
-import {
- TableCell,
- TableRow,
-} from "@marzneshin/components";
+import { TableCell, TableRow } from "@marzneshin/components";
import { FC } from "react";
-export const TableRowWithCell: FC<{
- label: string;
- value?: string | number | JSX.Element | null;
-}> = ({ label, value }) => (
+interface TableRowWithCellProps {
+ label: string | JSX.Element;
+ value?: null | string | number | JSX.Element;
+}
+
+export const TableRowWithCell: FC<TableRowWithCellProps> = ({ label, value }) => (
<TableRow>
<TableCell>{label}</TableCell>
<TableCell>{value}</TableCell>
</TableRow>
);
+
diff --git a/dashboard/src/components/no-propogation-button/index.tsx b/dashboard/src/components/no-propogation-button/index.tsx
new file mode 100644
index 0000000..5154c31
--- /dev/null
+++ b/dashboard/src/components/no-propogation-button/index.tsx
@@ -0,0 +1,33 @@
+import { Row } from "@tanstack/react-table"
+import { type PropsWithChildren, useCallback } from 'react'
+import {
+ type ColumnActions
+} from "@marzneshin/features/entity-table";
+
+interface NoPropogationButtonProps<T> {
+ actions: ColumnActions<T>,
+ row: Row<T>,
+}
+
+export function NoPropogationButton<T>({ children, actions, row }: NoPropogationButtonProps<T> & PropsWithChildren) {
+ const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ e.stopPropagation();
+ actions.onOpen(row.original);
+ }
+ }, [actions, row.original]);
+
+ return (
+ <div
+ className="flex flex-row gap-2 items-center"
+ onClick={(e) => e.stopPropagation()}
+ onKeyDown={handleKeyDown}
+ tabIndex={0}
+ role="button"
+ >
+ {children}
+ </div>
+ )
+}
+
diff --git a/dashboard/src/components/settings-dialog/index.tsx b/dashboard/src/components/settings-dialog/index.tsx
new file mode 100644
index 0000000..f406642
--- /dev/null
+++ b/dashboard/src/components/settings-dialog/index.tsx
@@ -0,0 +1,32 @@
+import { FC, PropsWithChildren } from "react";
+import {
+ ScrollArea,
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+} from "@marzneshin/components";
+import { useTranslation } from "react-i18next";
+
+export interface SettingsDialogProps {
+ onOpenChange: (state: boolean) => void;
+ open: boolean;
+}
+
+export const SettingsDialog: FC<SettingsDialogProps & PropsWithChildren> = ({
+ open, onOpenChange, children
+}) => {
+ const { t } = useTranslation();
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="sm:min-w-full md:min-w-[700px] space-y-5">
+ <SheetHeader >
+ <SheetTitle>{t("settings")}</SheetTitle>
+ </SheetHeader>
+ <ScrollArea className="flex flex-col gap-4 h-full">
+ {children}
+ </ScrollArea>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/dashboard/src/components/skeletons/index.ts b/dashboard/src/components/skeletons/index.ts
new file mode 100644
index 0000000..91f0216
--- /dev/null
+++ b/dashboard/src/components/skeletons/index.ts
@@ -0,0 +1 @@
+export * from "./settings-dialog.tsx";
diff --git a/dashboard/src/features/users/components/dialogs/settings/skeleton.tsx b/dashboard/src/components/skeletons/settings-dialog.tsx
similarity index 96%
rename from dashboard/src/features/users/components/dialogs/settings/skeleton.tsx
rename to dashboard/src/components/skeletons/settings-dialog.tsx
index 649aa11..4b42a61 100644
--- a/dashboard/src/features/users/components/dialogs/settings/skeleton.tsx
+++ b/dashboard/src/components/skeletons/settings-dialog.tsx
@@ -1,6 +1,6 @@
import { Skeleton } from "@marzneshin/components";
-export const UserInfoSkeleton = () => {
+export const SettingsInfoSkeleton = () => {
return (
<div className="p-5 bg-gray-900 rounded-lg w-96">
<h2 className="text-white mb-5">
diff --git a/dashboard/src/features/admins/components/dialogs/delete-confirmation/index.tsx b/dashboard/src/features/admins/components/dialogs/delete-confirmation/index.tsx
new file mode 100644
index 0000000..1a0e4e2
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/delete-confirmation/index.tsx
@@ -0,0 +1,31 @@
+import { DeleteConfirmation } from "@marzneshin/components";
+import { type FC, useEffect } from "react";
+import {
+ type AdminType,
+ useAdminsDeletionMutation,
+} from "@marzneshin/features/admins";
+
+interface AdminsDeleteConfirmationDialogProps {
+ onOpenChange: (state: boolean) => void;
+ open: boolean;
+ entity: AdminType;
+ onClose: () => void;
+}
+
+export const AdminsDeleteConfirmationDialog: FC<
+ AdminsDeleteConfirmationDialogProps
+> = ({ onOpenChange, open, entity, onClose }) => {
+ const deleteMutation = useAdminsDeletionMutation();
+
+ useEffect(() => {
+ if (!open) onClose();
+ }, [open, onClose]);
+
+ return (
+ <DeleteConfirmation
+ open={open}
+ onOpenChange={onOpenChange}
+ action={() => deleteMutation.mutate(entity)}
+ />
+ );
+};
diff --git a/dashboard/src/features/admins/components/dialogs/index.ts b/dashboard/src/features/admins/components/dialogs/index.ts
new file mode 100644
index 0000000..2e13901
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/index.ts
@@ -0,0 +1,3 @@
+export * from './mutation'
+export * from './settings'
+export * from './delete-confirmation'
diff --git a/dashboard/src/features/admins/components/dialogs/mutation/fields/all-services-access.tsx b/dashboard/src/features/admins/components/dialogs/mutation/fields/all-services-access.tsx
new file mode 100644
index 0000000..d25f442
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/mutation/fields/all-services-access.tsx
@@ -0,0 +1,35 @@
+import {
+ FormField,
+ FormItem,
+ FormLabel,
+ Checkbox,
+ FormControl,
+} from "@marzneshin/components";
+import { useFormContext } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+export const AllServicesAccessField = () => {
+ const { t } = useTranslation()
+ const form = useFormContext();
+ return (
+ <FormField
+ control={form.control}
+ name="all_services_access"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>
+ {t("page.admins.all-services-access")}
+ </FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ )
+}
diff --git a/dashboard/src/features/admins/components/dialogs/mutation/fields/enabled.tsx b/dashboard/src/features/admins/components/dialogs/mutation/fields/enabled.tsx
new file mode 100644
index 0000000..1efa342
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/mutation/fields/enabled.tsx
@@ -0,0 +1,35 @@
+import {
+ FormField,
+ FormItem,
+ FormLabel,
+ Checkbox,
+ FormControl,
+} from "@marzneshin/components";
+import { useFormContext } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+export const EnabledField = () => {
+ const { t } = useTranslation()
+ const form = useFormContext();
+ return (
+ <FormField
+ control={form.control}
+ name="enabled"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>
+ {t("enabled")}
+ </FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ )
+}
diff --git a/dashboard/src/features/admins/components/dialogs/mutation/fields/index.ts b/dashboard/src/features/admins/components/dialogs/mutation/fields/index.ts
new file mode 100644
index 0000000..0c2e1ea
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/mutation/fields/index.ts
@@ -0,0 +1,6 @@
+export * from "./password"
+export * from "./sudo-privilage"
+export * from "./all-services-access"
+export * from "./enabled"
+export * from "./modify-users-access"
+export * from "./subscription-url-prefix"
diff --git a/dashboard/src/features/admins/components/dialogs/mutation/fields/modify-users-access.tsx b/dashboard/src/features/admins/components/dialogs/mutation/fields/modify-users-access.tsx
new file mode 100644
index 0000000..6726f55
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/mutation/fields/modify-users-access.tsx
@@ -0,0 +1,35 @@
+import {
+ FormField,
+ FormItem,
+ FormLabel,
+ Checkbox,
+ FormControl,
+} from "@marzneshin/components";
+import { useFormContext } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+export const ModifyUsersAccessField = () => {
+ const { t } = useTranslation()
+ const form = useFormContext();
+ return (
+ <FormField
+ control={form.control}
+ name="modify_users_access"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>
+ {t("page.admins.modify-users-access")}
+ </FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ )
+}
diff --git a/dashboard/src/features/admins/components/dialogs/mutation/fields/password.tsx b/dashboard/src/features/admins/components/dialogs/mutation/fields/password.tsx
new file mode 100644
index 0000000..7523995
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/mutation/fields/password.tsx
@@ -0,0 +1,49 @@
+import {
+ Button,
+ FormControl,
+ FormField,
+ FormItem,
+ Input,
+ FormLabel,
+ FormMessage,
+} from "@marzneshin/components";
+import { type FC } from "react";
+import { useFormContext } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+export const PasswordField: FC<{ change: boolean, handleChange: (s: boolean) => void }> = ({
+ change, handleChange
+}) => {
+ const form = useFormContext();
+ const { t } = useTranslation();
+
+ return (
+ <FormField
+ control={form.control}
+ name="password"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("password")}</FormLabel>
+ <FormControl>
+ {change ?
+ (<Input {...field} />)
+ : (
+ <Button
+ className="border w-full"
+ variant="ghost"
+ onClick={(e) => {
+ e.preventDefault();
+ handleChange(true);
+ }}
+ >
+ {t("change")}
+ </Button>
+ )
+ }
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ );
+};
diff --git a/dashboard/src/features/admins/components/dialogs/mutation/fields/subscription-url-prefix.tsx b/dashboard/src/features/admins/components/dialogs/mutation/fields/subscription-url-prefix.tsx
new file mode 100644
index 0000000..89516cb
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/mutation/fields/subscription-url-prefix.tsx
@@ -0,0 +1,34 @@
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ Input,
+ FormLabel,
+ FormMessage,
+} from "@marzneshin/components";
+import type { FC, InputHTMLAttributes } from "react";
+import { useFormContext } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+export const SubscriptionUrlPrefixField: FC<InputHTMLAttributes<HTMLElement>> = ({
+ disabled,
+}) => {
+ const form = useFormContext();
+ const { t } = useTranslation();
+
+ return (
+ <FormField
+ control={form.control}
+ name="subscription_url_prefix"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("page.admins.subscription-url-prefix")}</FormLabel>
+ <FormControl>
+ <Input disabled={disabled} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ );
+};
diff --git a/dashboard/src/features/admins/components/dialogs/mutation/fields/sudo-privilage.tsx b/dashboard/src/features/admins/components/dialogs/mutation/fields/sudo-privilage.tsx
new file mode 100644
index 0000000..0a3092e
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/mutation/fields/sudo-privilage.tsx
@@ -0,0 +1,35 @@
+import {
+ FormField,
+ FormItem,
+ FormLabel,
+ Checkbox,
+ FormControl,
+} from "@marzneshin/components";
+import { useFormContext } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+export const SudoPrivilageField = () => {
+ const { t } = useTranslation()
+ const form = useFormContext();
+ return (
+ <FormField
+ control={form.control}
+ name="is_sudo"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>
+ {t("page.admins.sudo-privilage")}
+ </FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ )
+}
diff --git a/dashboard/src/features/admins/components/dialogs/mutation/index.tsx b/dashboard/src/features/admins/components/dialogs/mutation/index.tsx
new file mode 100644
index 0000000..fba552b
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/mutation/index.tsx
@@ -0,0 +1,116 @@
+import { type FC, useState, useEffect, useCallback } from "react";
+import {
+ DialogTitle,
+ DialogContent,
+ Dialog,
+ DialogHeader,
+ Form,
+ Button,
+ ScrollArea,
+ VStack,
+} from "@marzneshin/components";
+import { useTranslation } from "react-i18next";
+import {
+ useAdminsCreationMutation,
+ useAdminsUpdateMutation,
+ AdminMutationSchema,
+ type AdminMutationType,
+ AdminType,
+} from "@marzneshin/features/admins";
+import { ServicesField } from "@marzneshin/features/services";
+import { UsernameField } from "@marzneshin/features/users";
+import {
+ PasswordField,
+ EnabledField,
+ ModifyUsersAccessField,
+ SubscriptionUrlPrefixField,
+ SudoPrivilageField,
+ AllServicesAccessField,
+} from "./fields";
+import { useMutationDialog } from "@marzneshin/hooks";
+
+interface AdminsMutationDialogProps {
+ open: boolean;
+ onOpenChange: (state: boolean) => void;
+ onClose: () => void;
+ entity?: AdminType | null;
+}
+
+export const AdminsMutationDialog: FC<AdminsMutationDialogProps> = ({
+ open,
+ onOpenChange,
+ onClose,
+ entity = null,
+}) => {
+ const { t } = useTranslation();
+ const getDefaultValues = useCallback(
+ (): AdminMutationType => ({
+ service_ids: [],
+ username: "",
+ password: null,
+ is_sudo: false,
+ enabled: true,
+ }),
+ [],
+ );
+
+ const { form, handleSubmit } = useMutationDialog({
+ entity,
+ onOpenChange,
+ createMutation: useAdminsCreationMutation(),
+ updateMutation: useAdminsUpdateMutation(),
+ schema: AdminMutationSchema,
+ getDefaultValue: getDefaultValues,
+ });
+
+ const [change, setChange] = useState<boolean>(entity === null);
+ if (entity !== null)
+ form.setValue("password", null,
+ {
+ shouldTouch: true,
+ shouldDirty: true,
+ });
+
+ useEffect(() => {
+ if (!open) onClose();
+ }, [open, onClose]);
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange} defaultOpen={true}>
+ <DialogContent className="min-w-full h-full md:h-auto md:min-w-[42rem]">
+ <ScrollArea className="flex flex-col justify-between h-full ">
+ <DialogHeader className="mb-3">
+ <DialogTitle className="text-primary">
+ {t(entity ? "page.admins.dialogs.edition.title" : "page.admins.dialogs.creation.title")}
+ </DialogTitle>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={handleSubmit} >
+ <div className="flex-col grid-cols-2 gap-2 sm:flex md:grid h-full">
+ <div className="space-y-3">
+ <UsernameField disabled={!!entity} />
+ <PasswordField change={change} handleChange={setChange} />
+ <SubscriptionUrlPrefixField />
+ <EnabledField />
+ <ModifyUsersAccessField />
+ <SudoPrivilageField />
+ <AllServicesAccessField />
+ </div>
+ <VStack>
+ <ServicesField />
+ </VStack>
+ </div>
+ <Button
+ className="mt-3 w-full font-semibold"
+ type="submit"
+ disabled={form.formState.isSubmitting}
+ >
+ {t("submit")}
+ </Button>
+ </form>
+ </Form>
+ </ScrollArea>
+ </DialogContent>
+ </Dialog>
+ );
+};
diff --git a/dashboard/src/features/admins/components/dialogs/settings/admin-info/index.ts b/dashboard/src/features/admins/components/dialogs/settings/admin-info/index.ts
new file mode 100644
index 0000000..302847a
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/settings/admin-info/index.ts
@@ -0,0 +1 @@
+export * from './table'
diff --git a/dashboard/src/features/admins/components/dialogs/settings/admin-info/table.tsx b/dashboard/src/features/admins/components/dialogs/settings/admin-info/table.tsx
new file mode 100644
index 0000000..22b7b42
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/settings/admin-info/table.tsx
@@ -0,0 +1,44 @@
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ Table,
+ TableBody,
+ TableRowWithCell,
+} from "@marzneshin/components";
+import { FC } from "react";
+import {
+ type AdminProp,
+ AdminEnabledPill,
+ AdminPermissionPill
+} from "@marzneshin/features/admins";
+import { useTranslation } from "react-i18next";
+
+export const AdminInfoTable: FC<AdminProp> = ({ admin: entity }) => {
+ const { t } = useTranslation();
+
+ return (
+ <Card>
+ <CardHeader className="flex flex-row justify-between items-center w-full">
+ <CardTitle>{t("admin_info")}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableBody>
+ <TableRowWithCell label={t("username")} value={entity.username} />
+ <TableRowWithCell
+ label={t("enabled")}
+ value={<AdminEnabledPill admin={entity} />}
+ />
+ <TableRowWithCell
+ label={t("page.admins.permission")}
+ value={<AdminPermissionPill admin={entity} />}
+ />
+
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ );
+};
diff --git a/dashboard/src/features/admins/components/dialogs/settings/index.tsx b/dashboard/src/features/admins/components/dialogs/settings/index.tsx
new file mode 100644
index 0000000..b5c7668
--- /dev/null
+++ b/dashboard/src/features/admins/components/dialogs/settings/index.tsx
@@ -0,0 +1,74 @@
+import {
+ // Button,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+ Awaiting,
+ SettingsInfoSkeleton,
+ SettingsDialogProps,
+ SettingsDialog,
+} from "@marzneshin/components";
+import {
+ type AdminType
+} from "@marzneshin/features/admins";
+import { type FC, useEffect } from "react";
+import { AdminInfoTable } from "./admin-info";
+import { useTranslation } from "react-i18next";
+
+interface AdminsSettingsDialogProps extends SettingsDialogProps {
+ entity: AdminType | null;
+ onClose: () => void;
+ isPending: boolean;
+}
+
+export const AdminsSettingsDialog: FC<AdminsSettingsDialogProps> = ({
+ onOpenChange,
+ open,
+ entity,
+ onClose,
+ isPending,
+}) => {
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ if (!open) onClose();
+ }, [open, onClose]);
+
+ return (
+ <SettingsDialog open={open} onOpenChange={onOpenChange}>
+ <Awaiting
+ Component={entity ? (
+ <Tabs defaultValue="info" className="w-full h-full">
+ <TabsList className="w-full bg-accent">
+ <TabsTrigger className="w-full" value="info">
+ {t("admin_info")}
+ </TabsTrigger>
+ <TabsTrigger className="w-full" value="services">
+ {t("services")}
+ </TabsTrigger>
+ <TabsTrigger className="w-full" value="subscription">
+ {t("users")}
+ </TabsTrigger>
+ </TabsList>
+ <TabsContent
+ value="info"
+ className="flex flex-col gap-2 w-full h-full"
+ >
+ <AdminInfoTable admin={entity} />
+ </TabsContent>
+ <TabsContent value="services">
+ {/* <AdminServicesTable admin={entity} /> */}
+ Services
+ </TabsContent>
+ <TabsContent value="users">
+ Users
+ </TabsContent>
+ </Tabs>
+ ) : (<div>Not Found</div>)}
+ Skeleton={<SettingsInfoSkeleton />}
+ isFetching={isPending}
+ />
+ </SettingsDialog>
+ );
+};
diff --git a/dashboard/src/features/admins/components/index.ts b/dashboard/src/features/admins/components/index.ts
new file mode 100644
index 0000000..830bbf2
--- /dev/null
+++ b/dashboard/src/features/admins/components/index.ts
@@ -0,0 +1,3 @@
+export * from "./pills";
+export * from "./tables";
+export * from "./dialogs";
diff --git a/dashboard/src/features/admins/components/pills/admin-enabled-pill.tsx b/dashboard/src/features/admins/components/pills/admin-enabled-pill.tsx
new file mode 100644
index 0000000..4ca3943
--- /dev/null
+++ b/dashboard/src/features/admins/components/pills/admin-enabled-pill.tsx
@@ -0,0 +1,15 @@
+import { type FC } from "react";
+import { BooleanPill } from "@marzneshin/components";
+import { useTranslation } from "react-i18next";
+import { AdminProp } from "@marzneshin/features/admins";
+
+export const AdminEnabledPill: FC<AdminProp> = ({ admin }) => {
+ const { t } = useTranslation();
+ return (
+ <BooleanPill
+ active={admin.enabled}
+ activeLabel={t('enabled')}
+ inactiveLabel={t('disabled')}
+ />
+ )
+}
diff --git a/dashboard/src/features/admins/components/pills/admin-permission-pill.tsx b/dashboard/src/features/admins/components/pills/admin-permission-pill.tsx
new file mode 100644
index 0000000..5fbf539
--- /dev/null
+++ b/dashboard/src/features/admins/components/pills/admin-permission-pill.tsx
@@ -0,0 +1,17 @@
+import { type FC } from "react";
+import { BooleanPill } from "@marzneshin/components";
+import { useTranslation } from "react-i18next";
+import { AdminProp } from "@marzneshin/features/admins";
+
+export const AdminPermissionPill: FC<AdminProp> = ({ admin }) => {
+ const { t } = useTranslation();
+ return (
+ <BooleanPill
+ active={admin.is_sudo}
+ activeLabel={t('sudo')}
+ activeVariant="royal"
+ inactiveLabel={t('normal')}
+ inactiveVariant="warning"
+ />
+ )
+}
diff --git a/dashboard/src/features/admins/components/pills/index.ts b/dashboard/src/features/admins/components/pills/index.ts
new file mode 100644
index 0000000..79a63ab
--- /dev/null
+++ b/dashboard/src/features/admins/components/pills/index.ts
@@ -0,0 +1,2 @@
+export * from "./admin-enabled-pill";
+export * from "./admin-permission-pill";
diff --git a/dashboard/src/features/admins/components/tables/admins/columns.tsx b/dashboard/src/features/admins/components/tables/admins/columns.tsx
new file mode 100644
index 0000000..db79218
--- /dev/null
+++ b/dashboard/src/features/admins/components/tables/admins/columns.tsx
@@ -0,0 +1,55 @@
+import type { ColumnDef } from "@tanstack/react-table";
+import {
+ type AdminType,
+ AdminEnabledPill,
+ AdminPermissionPill,
+} from "@marzneshin/features/admins";
+import { DataTableColumnHeader } from "@marzneshin/components/data-table/column-header";
+import i18n from "@marzneshin/features/i18n";
+import {
+ DataTableActionsCell,
+ NoPropogationButton,
+} from "@marzneshin/components";
+import {
+ type ColumnActions
+} from "@marzneshin/features/entity-table";
+
+export const columns = (actions: ColumnActions<AdminType>): ColumnDef<AdminType>[] => [
+ {
+ accessorKey: "username",
+ header: ({ column }) => (
+ <DataTableColumnHeader title={i18n.t("username")} column={column} />
+ ),
+ },
+ {
+ accessorKey: "enabled",
+ enableSorting: false,
+ header: ({ column }) => (
+ <DataTableColumnHeader
+ title={i18n.t("enabled")}
+ column={column}
+ />
+ ),
+ cell: ({ row }) => <AdminEnabledPill admin={row.original} />,
+ },
+ {
+ accessorKey: "is_sudo",
+ header: ({ column }) => (
+ <DataTableColumnHeader
+ title={i18n.t("page.admins.permission")}
+ column={column}
+ />
+ ),
+ cell: ({ row }) => <AdminPermissionPill admin={row.original} />,
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => {
+ return (
+ <NoPropogationButton row={row} actions={actions}>
+ <DataTableActionsCell {...actions} row={row} />
+ </NoPropogationButton>
+ );
+ },
+ }
+];
diff --git a/dashboard/src/features/admins/components/tables/admins/index.tsx b/dashboard/src/features/admins/components/tables/admins/index.tsx
new file mode 100644
index 0000000..d6085cd
--- /dev/null
+++ b/dashboard/src/features/admins/components/tables/admins/index.tsx
@@ -0,0 +1,37 @@
+import type { FC } from "react";
+import { fetchAdmins, AdminsQueryFetchKey } from "@marzneshin/features/admins";
+import { columns } from "./columns";
+import { EntityTable } from "@marzneshin/features/entity-table";
+import { useNavigate } from "@tanstack/react-router";
+
+export const AdminsTable: FC = () => {
+ const navigate = useNavigate({ from: "/admins" });
+ return (
+ <EntityTable
+ fetchEntity={fetchAdmins}
+ manualSorting={false}
+ columnsFn={columns}
+ filteredColumn="username"
+ entityKey={AdminsQueryFetchKey}
+ onCreate={() => navigate({ to: "/admins/create" })}
+ onOpen={(entity) =>
+ navigate({
+ to: "/admins/$adminId",
+ params: { adminId: entity.username },
+ })
+ }
+ onEdit={(entity) =>
+ navigate({
+ to: "/admins/$adminId/edit",
+ params: { adminId: entity.username },
+ })
+ }
+ onDelete={(entity) =>
+ navigate({
+ to: "/admins/$adminId/delete",
+ params: { adminId: entity.username },
+ })
+ }
+ />
+ );
+};
diff --git a/dashboard/src/features/admins/components/tables/index.ts b/dashboard/src/features/admins/components/tables/index.ts
new file mode 100644
index 0000000..dcd7f9b
--- /dev/null
+++ b/dashboard/src/features/admins/components/tables/index.ts
@@ -0,0 +1 @@
+export * from './admins'
diff --git a/dashboard/src/features/admins/contexts/router.ts b/dashboard/src/features/admins/contexts/router.ts
new file mode 100644
index 0000000..41933a5
--- /dev/null
+++ b/dashboard/src/features/admins/contexts/router.ts
@@ -0,0 +1,14 @@
+import { createContext, useContext } from "react";
+import { AdminType } from "@marzneshin/features/admins";
+
+interface RouterAdminContextProps {
+ admin: AdminType | null;
+ isPending?: boolean;
+}
+
+export const RouterAdminContext = createContext<RouterAdminContextProps | null>(null);
+
+export const useRouterAdminContext = () => {
+ const ctx = useContext(RouterAdminContext);
+ return ctx;
+};
diff --git a/dashboard/src/features/admins/index.ts b/dashboard/src/features/admins/index.ts
new file mode 100644
index 0000000..205aaa9
--- /dev/null
+++ b/dashboard/src/features/admins/index.ts
@@ -0,0 +1,4 @@
+export * from "./components";
+export * from "./types";
+export * from "./services";
+export * from "./contexts/router";
diff --git a/dashboard/src/features/admins/services/admin.query.ts b/dashboard/src/features/admins/services/admin.query.ts
new file mode 100644
index 0000000..4612322
--- /dev/null
+++ b/dashboard/src/features/admins/services/admin.query.ts
@@ -0,0 +1,27 @@
+import { queryOptions, useQuery } from "@tanstack/react-query";
+import { fetch } from "@marzneshin/utils";
+import type { AdminType } from "../types";
+
+export async function fetchAdmin({
+ queryKey,
+}: { queryKey: [string, string] }): Promise<AdminType> {
+ return fetch(`/admins/${queryKey[1]}`);
+}
+
+export const AdminQueryFetchKey = "admins";
+
+export const adminQueryOptions = ({ username }: { username: string }) => {
+ return queryOptions({
+ queryKey: [AdminQueryFetchKey, username],
+ queryFn: fetchAdmin,
+ initialData: null,
+ });
+};
+
+export const useAdminQuery = ({ username }: { username: string }) => {
+ return useQuery({
+ queryKey: [AdminQueryFetchKey, username],
+ queryFn: fetchAdmin,
+ initialData: null,
+ });
+};
diff --git a/dashboard/src/features/admins/services/admins.query.ts b/dashboard/src/features/admins/services/admins.query.ts
new file mode 100644
index 0000000..eedc965
--- /dev/null
+++ b/dashboard/src/features/admins/services/admins.query.ts
@@ -0,0 +1,35 @@
+import { useQuery } from "@tanstack/react-query";
+import { fetch } from "@marzneshin/utils";
+import { AdminType } from "@marzneshin/features/admins";
+import {
+ FetchEntityReturn,
+ UseEntityQueryProps,
+ EntityQueryKeyType
+} from "@marzneshin/features/entity-table";
+
+export async function fetchAdmins({ queryKey }: EntityQueryKeyType): FetchEntityReturn<AdminType> {
+ return fetch(`/admins`, {
+ query: {
+ page: queryKey[1],
+ size: queryKey[2],
+ username: queryKey[3],
+ }
+ }).then((result) => {
+ return {
+ entity: result.items,
+ pageCount: result.pages
+ };
+ });
+}
+
+export const AdminsQueryFetchKey = "users";
+
+export const useAdminsQuery = ({
+ page, size, search = ""
+}: UseEntityQueryProps) => {
+ return useQuery({
+ queryKey: [AdminsQueryFetchKey, page, size, search],
+ queryFn: fetchAdmins,
+ initialData: { entity: [], pageCount: 0 }
+ })
+}
diff --git a/dashboard/src/features/admins/services/create-admin.mutate.ts b/dashboard/src/features/admins/services/create-admin.mutate.ts
new file mode 100644
index 0000000..50e77ad
--- /dev/null
+++ b/dashboard/src/features/admins/services/create-admin.mutate.ts
@@ -0,0 +1,40 @@
+import { AdminType, AdminMutationType, AdminsQueryFetchKey } from "@marzneshin/features/admins";
+import { useMutation } from "@tanstack/react-query";
+import { fetch, queryClient } from "@marzneshin/utils";
+import { toast } from "sonner";
+import i18n from "@marzneshin/features/i18n";
+
+export async function fetchCreateAdmin(admin: AdminMutationType): Promise<AdminType> {
+ return fetch('/admins', { method: 'post', body: admin }).then((admin) => {
+ return admin;
+ });
+}
+
+const handleError = (error: Error, value: AdminMutationType) => {
+ toast.error(
+ i18n.t('events.create.error', { name: value.username }),
+ {
+ description: error.message
+ })
+}
+
+const handleSuccess = (value: AdminType) => {
+ toast.success(
+ i18n.t('events.create.success.title', { name: value.username }),
+ {
+ description: i18n.t('events.create.success.desc')
+ })
+ queryClient.invalidateQueries({ queryKey: [AdminsQueryFetchKey] })
+}
+
+
+const AdminsCreateFetchKey = "admins";
+
+export const useAdminsCreationMutation = () => {
+ return useMutation({
+ mutationKey: [AdminsCreateFetchKey],
+ mutationFn: fetchCreateAdmin,
+ onError: handleError,
+ onSuccess: handleSuccess,
+ })
+}
diff --git a/dashboard/src/features/admins/services/delete-admin.mutate.ts b/dashboard/src/features/admins/services/delete-admin.mutate.ts
new file mode 100644
index 0000000..1d6c7e8
--- /dev/null
+++ b/dashboard/src/features/admins/services/delete-admin.mutate.ts
@@ -0,0 +1,40 @@
+import { AdminType, AdminsQueryFetchKey } from "@marzneshin/features/admins";
+import { useMutation } from "@tanstack/react-query";
+import { fetch, queryClient } from "@marzneshin/utils";
+import { toast } from "sonner";
+import i18n from "@marzneshin/features/i18n";
+
+export async function fetchDeleteAdmin(admin: AdminType): Promise<AdminType> {
+ return fetch(`/admins/${admin.username}`, { method: 'delete' }).then((admin) => {
+ return admin;
+ });
+}
+
+const AdminsDeleteFetchKey = "admins";
+
+
+const handleError = (error: Error, value: AdminType) => {
+ toast.error(
+ i18n.t('events.delete.error', { name: value.username }),
+ {
+ description: error.message
+ })
+}
+
+const handleSuccess = (value: AdminType) => {
+ toast.success(
+ i18n.t('events.delete.success.title', { name: value.username }),
+ {
+ description: i18n.t('events.delete.success.desc')
+ })
+ queryClient.invalidateQueries({ queryKey: [AdminsQueryFetchKey] })
+}
+
+export const useAdminsDeletionMutation = () => {
+ return useMutation({
+ mutationKey: [AdminsDeleteFetchKey],
+ mutationFn: fetchDeleteAdmin,
+ onError: handleError,
+ onSuccess: handleSuccess,
+ })
+}
diff --git a/dashboard/src/features/admins/services/index.ts b/dashboard/src/features/admins/services/index.ts
new file mode 100644
index 0000000..ace51db
--- /dev/null
+++ b/dashboard/src/features/admins/services/index.ts
@@ -0,0 +1,5 @@
+export * from "./admins.query";
+export * from "./admin.query";
+export * from "./update-admin.mutate";
+export * from "./create-admin.mutate";
+export * from "./delete-admin.mutate";
diff --git a/dashboard/src/features/admins/services/update-admin.mutate.ts b/dashboard/src/features/admins/services/update-admin.mutate.ts
new file mode 100644
index 0000000..5cac34a
--- /dev/null
+++ b/dashboard/src/features/admins/services/update-admin.mutate.ts
@@ -0,0 +1,44 @@
+import { useMutation } from "@tanstack/react-query";
+import { fetch, queryClient } from "@marzneshin/utils";
+import { toast } from "sonner";
+import i18n from "@marzneshin/features/i18n";
+import {
+ AdminsQueryFetchKey,
+ AdminMutationType,
+ AdminType
+} from "@marzneshin/features/admins";
+
+export async function updateAdmin(admin: AdminMutationType): Promise<AdminType> {
+ return fetch(`/admins/${admin.username}`, { method: 'put', body: admin }).then((admin) => {
+ return admin;
+ });
+}
+
+const handleError = (error: Error, value: AdminMutationType) => {
+ toast.error(
+ i18n.t('events.update.error', { name: value.username }),
+ {
+ description: error.message
+ })
+}
+
+const handleSuccess = (value: AdminType) => {
+ toast.success(
+ i18n.t('events.update.success.title', { name: value.username }),
+ {
+ description: i18n.t('events.update.success.desc')
+ })
+ queryClient.invalidateQueries({ queryKey: [AdminsQueryFetchKey] })
+}
+
+
+const AdminsUpdateFetchKey = "admins";
+
+export const useAdminsUpdateMutation = () => {
+ return useMutation({
+ mutationKey: [AdminsUpdateFetchKey],
+ mutationFn: updateAdmin,
+ onError: handleError,
+ onSuccess: handleSuccess,
+ })
+}
diff --git a/dashboard/src/features/admins/types/admin-prop.ts b/dashboard/src/features/admins/types/admin-prop.ts
new file mode 100644
index 0000000..05220ad
--- /dev/null
+++ b/dashboard/src/features/admins/types/admin-prop.ts
@@ -0,0 +1,5 @@
+import { AdminType } from "@marzneshin/features/admins";
+
+export interface AdminProp {
+ admin: AdminType;
+}
diff --git a/dashboard/src/features/admins/types/admin.ts b/dashboard/src/features/admins/types/admin.ts
new file mode 100644
index 0000000..a209316
--- /dev/null
+++ b/dashboard/src/features/admins/types/admin.ts
@@ -0,0 +1,12 @@
+
+export interface AdminType {
+ id: number;
+ username: string;
+ enabled: boolean;
+ is_sudo: boolean;
+ all_services_access: boolean;
+ modify_users_access: boolean;
+ service_ids: number[];
+ subscription_url_prefix: string;
+}
+
diff --git a/dashboard/src/features/admins/types/index.ts b/dashboard/src/features/admins/types/index.ts
new file mode 100644
index 0000000..86ffd2b
--- /dev/null
+++ b/dashboard/src/features/admins/types/index.ts
@@ -0,0 +1,3 @@
+export * from "./admin";
+export * from "./schema";
+export * from "./admin-prop";
diff --git a/dashboard/src/features/admins/types/schema.ts b/dashboard/src/features/admins/types/schema.ts
new file mode 100644
index 0000000..2e305eb
--- /dev/null
+++ b/dashboard/src/features/admins/types/schema.ts
@@ -0,0 +1,17 @@
+import { z } from 'zod';
+
+export const AdminMutationSchema = z.object({
+ username: z.string().min(1, { message: 'Username is required' }),
+ is_sudo: z.boolean(),
+ enabled: z.boolean().optional(),
+ all_services_access: z.boolean().optional(),
+ modify_users_access: z.boolean().optional(),
+ subscription_url_prefix: z.string().optional(),
+ password: z.string().nullable(),
+ service_ids: z
+ .array(z.number().or(z.string()))
+ .transform((v) => v.map(Number))
+ .optional()
+});
+
+export type AdminMutationType = z.infer<typeof AdminMutationSchema>;
diff --git a/dashboard/src/features/entity-table/index.ts b/dashboard/src/features/entity-table/index.ts
index f4af314..5f7b69d 100644
--- a/dashboard/src/features/entity-table/index.ts
+++ b/dashboard/src/features/entity-table/index.ts
@@ -1,4 +1,4 @@
export * from "./entity-table";
export * from "./sidebar-entity-table";
export * from "./hooks";
-
+export * from "./types";
diff --git a/dashboard/src/features/entity-table/types/column-actions.ts b/dashboard/src/features/entity-table/types/column-actions.ts
new file mode 100644
index 0000000..f185a68
--- /dev/null
+++ b/dashboard/src/features/entity-table/types/column-actions.ts
@@ -0,0 +1,6 @@
+
+export interface ColumnActions<T> {
+ onDelete: (entity: T) => void;
+ onOpen: (entity: T) => void;
+ onEdit: (entity: T) => void;
+}
diff --git a/dashboard/src/features/entity-table/types/index.ts b/dashboard/src/features/entity-table/types/index.ts
new file mode 100644
index 0000000..31012e2
--- /dev/null
+++ b/dashboard/src/features/entity-table/types/index.ts
@@ -0,0 +1 @@
+export * from "./column-actions"
diff --git a/dashboard/src/features/hosts/dialogs/settings/index.tsx b/dashboard/src/features/hosts/dialogs/settings/index.tsx
index 26b663e..946d31f 100644
--- a/dashboard/src/features/hosts/dialogs/settings/index.tsx
+++ b/dashboard/src/features/hosts/dialogs/settings/index.tsx
@@ -1,9 +1,7 @@
import { type FC, useEffect } from "react";
import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
+ SettingsDialogProps,
+ SettingsDialog,
Table,
TableBody,
TableRowWithCell,
@@ -11,7 +9,7 @@ import {
import { useTranslation } from "react-i18next"
import { HostType } from "../.."
-interface HostsSettingsDialogProps {
+interface HostsSettingsDialogProps extends SettingsDialogProps {
onOpenChange: (state: boolean) => void
onClose: () => void
open: boolean
@@ -26,25 +24,18 @@ export const HostSettingsDialog: FC<HostsSettingsDialogProps> = ({ onOpenChange,
}, [open, onClose]);
return (
- <Sheet open={open} onOpenChange={onOpenChange}>
- <SheetContent className="sm:min-w-full md:min-w-[700px]" >
- <SheetHeader>
- <SheetTitle>
- {t('page.hosts.settings.title')}
- </SheetTitle>
- </SheetHeader>
- <Table>
- <TableBody>
- <TableRowWithCell label={t('remark')} value={entity.remark} />
- <TableRowWithCell label={t('address')} value={entity.address} />
- <TableRowWithCell label={t('port')} value={entity.port} />
- <TableRowWithCell label={t('host')} value={entity.host} />
- <TableRowWithCell label={t('path')} value={entity.path} />
- <TableRowWithCell label={t('sni')} value={entity.sni} />
- <TableRowWithCell label={t('alpn')} value={entity.alpn} />
- </TableBody>
- </Table>
- </SheetContent>
- </Sheet>
+ <SettingsDialog open={open} onOpenChange={onOpenChange}>
+ <Table>
+ <TableBody>
+ <TableRowWithCell label={t('remark')} value={entity.remark} />
+ <TableRowWithCell label={t('address')} value={entity.address} />
+ <TableRowWithCell label={t('port')} value={entity.port} />
+ <TableRowWithCell label={t('host')} value={entity.host} />
+ <TableRowWithCell label={t('path')} value={entity.path} />
+ <TableRowWithCell label={t('sni')} value={entity.sni} />
+ <TableRowWithCell label={t('alpn')} value={entity.alpn} />
+ </TableBody>
+ </Table>
+ </SettingsDialog>
)
}
diff --git a/dashboard/src/features/hosts/tables/inbound-hosts/columns.tsx b/dashboard/src/features/hosts/tables/inbound-hosts/columns.tsx
index 7be05c4..ba3dcb6 100644
--- a/dashboard/src/features/hosts/tables/inbound-hosts/columns.tsx
+++ b/dashboard/src/features/hosts/tables/inbound-hosts/columns.tsx
@@ -2,16 +2,15 @@ import { ColumnDef } from "@tanstack/react-table"
import { HostType } from "@marzneshin/features/hosts"
import { DataTableColumnHeader } from "@marzneshin/components/data-table/column-header"
import i18n from "@marzneshin/features/i18n"
-import { DataTableActionsCell } from "@marzneshin/components"
+import {
+ type ColumnActions
+} from "@marzneshin/features/entity-table";
+import {
+ DataTableActionsCell,
+ NoPropogationButton,
+} from "@marzneshin/components"
-interface ColumnAction {
- onDelete: (host: HostType) => void;
- onOpen: (host: HostType) => void;
- onEdit: (host: HostType) => void;
-}
-
-
-export const columns = (actions: ColumnAction): ColumnDef<HostType>[] => ([
+export const columns = (actions: ColumnActions<HostType>): ColumnDef<HostType>[] => ([
{
accessorKey: "remark",
header: ({ column }) => <DataTableColumnHeader title={i18n.t('name')} column={column} />,
@@ -26,6 +25,12 @@ export const columns = (actions: ColumnAction): ColumnDef<HostType>[] => ([
},
{
id: "actions",
- cell: ({ row }) => <DataTableActionsCell {...actions} row={row} />
- },
+ cell: ({ row }) => {
+ return (
+ <NoPropogationButton row={row} actions={actions}>
+ <DataTableActionsCell {...actions} row={row} />
+ </NoPropogationButton>
+ );
+ },
+ }
]);
diff --git a/dashboard/src/features/nodes/dialogs/settings/index.tsx b/dashboard/src/features/nodes/dialogs/settings/index.tsx
index 992c20b..de5c617 100644
--- a/dashboard/src/features/nodes/dialogs/settings/index.tsx
+++ b/dashboard/src/features/nodes/dialogs/settings/index.tsx
@@ -1,11 +1,8 @@
import {
Card,
CardContent,
- ScrollArea,
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
+ SettingsDialogProps,
+ SettingsDialog,
Tabs,
TabsContent,
TabsList,
@@ -18,9 +15,7 @@ import { LogContainer } from "../../log";
import { NodesDetailTable } from "../../tables/detail-table";
import { NodeConfigEditor } from "./node-config-editor";
-interface NodesSettingsDialogProps {
- onOpenChange: (state: boolean) => void;
- open: boolean;
+interface NodesSettingsDialogProps extends SettingsDialogProps {
entity: NodeType;
onClose: () => void;
}
@@ -38,40 +33,33 @@ export const NodesSettingsDialog: FC<NodesSettingsDialogProps> = ({
}, [open, onClose]);
return (
- <Sheet open={open} onOpenChange={onOpenChange}>
- <SheetContent className="sm:min-w-full md:min-w-[600px] ">
- <ScrollArea className="h-full w-full">
- <SheetHeader>
- <SheetTitle>{t("page.nodes.settings.title")}</SheetTitle>
- </SheetHeader>
- <div className="my-4">
- <h1 className="font-medium font-header">
- {t("page.nodes.settings.detail")}
- </h1>
- <NodesDetailTable node={entity} />
- </div>
- <Tabs className="my-3 max-w-full h-full" defaultValue="logs">
- <TabsList className="w-full">
- <TabsTrigger className="w-full" value="logs">
- {t("logs")}
- </TabsTrigger>
- <TabsTrigger className="w-full" value="config">
- {t("config")}
- </TabsTrigger>
- </TabsList>
- <TabsContent value="logs" className="h-full w-full">
- <LogContainer node={entity} />
- </TabsContent>
- <TabsContent value="config" className="h-full z-51">
- <Card>
- <CardContent className="p-2 h-1/2 flex flex-col gap-2">
- <NodeConfigEditor entity={entity} />
- </CardContent>
- </Card>
- </TabsContent>
- </Tabs>
- </ScrollArea>
- </SheetContent>
- </Sheet>
+ <SettingsDialog open={open} onOpenChange={onOpenChange}>
+ <div className="my-4">
+ <h1 className="font-medium font-header">
+ {t("page.nodes.settings.detail")}
+ </h1>
+ <NodesDetailTable node={entity} />
+ </div>
+ <Tabs className="my-3 max-w-full h-full" defaultValue="logs">
+ <TabsList className="w-full">
+ <TabsTrigger className="w-full" value="logs">
+ {t("logs")}
+ </TabsTrigger>
+ <TabsTrigger className="w-full" value="config">
+ {t("config")}
+ </TabsTrigger>
+ </TabsList>
+ <TabsContent value="logs" className="h-full w-full">
+ <LogContainer node={entity} />
+ </TabsContent>
+ <TabsContent value="config" className="h-full z-51">
+ <Card>
+ <CardContent className="p-2 h-1/2 flex flex-col gap-2">
+ <NodeConfigEditor entity={entity} />
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </Tabs>
+ </SettingsDialog>
);
};
diff --git a/dashboard/src/features/nodes/tables/columns.tsx b/dashboard/src/features/nodes/tables/columns.tsx
index c20ce52..d68b99c 100644
--- a/dashboard/src/features/nodes/tables/columns.tsx
+++ b/dashboard/src/features/nodes/tables/columns.tsx
@@ -2,16 +2,15 @@ import { ColumnDef } from "@tanstack/react-table"
import { NodesStatusBadge, NodeType, NodesStatus } from "@marzneshin/features/nodes"
import { DataTableColumnHeader } from "@marzneshin/components/data-table/column-header"
import i18n from "@marzneshin/features/i18n"
-import { DataTableActionsCell } from "@marzneshin/components"
+import {
+ type ColumnActions
+} from "@marzneshin/features/entity-table";
+import {
+ DataTableActionsCell,
+ NoPropogationButton,
+} from "@marzneshin/components"
-interface ColumnAction {
- onDelete: (node: NodeType) => void;
- onOpen: (node: NodeType) => void;
- onEdit: (node: NodeType) => void;
-}
-
-
-export const columns = (actions: ColumnAction): ColumnDef<NodeType>[] => ([
+export const columns = (actions: ColumnActions<NodeType>): ColumnDef<NodeType>[] => ([
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader title={i18n.t('name')} column={column} />,
@@ -32,6 +31,12 @@ export const columns = (actions: ColumnAction): ColumnDef<NodeType>[] => ([
},
{
id: "actions",
- cell: ({ row }) => <DataTableActionsCell {...actions} row={row} />
- },
+ cell: ({ row }) => {
+ return (
+ <NoPropogationButton row={row} actions={actions}>
+ <DataTableActionsCell {...actions} row={row} />
+ </NoPropogationButton>
+ );
+ },
+ }
]);
diff --git a/dashboard/src/features/services/dialogs/delete-confirmation.dialog.tsx b/dashboard/src/features/services/components/dialogs/delete-confirmation.dialog.tsx
similarity index 100%
rename from dashboard/src/features/services/dialogs/delete-confirmation.dialog.tsx
rename to dashboard/src/features/services/components/dialogs/delete-confirmation.dialog.tsx
diff --git a/dashboard/src/features/services/dialogs/index.ts b/dashboard/src/features/services/components/dialogs/index.ts
similarity index 100%
rename from dashboard/src/features/services/dialogs/index.ts
rename to dashboard/src/features/services/components/dialogs/index.ts
diff --git a/dashboard/src/features/services/dialogs/mutation/fields/inbounds.tsx b/dashboard/src/features/services/components/dialogs/mutation/fields/inbounds.tsx
similarity index 100%
rename from dashboard/src/features/services/dialogs/mutation/fields/inbounds.tsx
rename to dashboard/src/features/services/components/dialogs/mutation/fields/inbounds.tsx
diff --git a/dashboard/src/features/services/dialogs/mutation/fields/index.ts b/dashboard/src/features/services/components/dialogs/mutation/fields/index.ts
similarity index 100%
rename from dashboard/src/features/services/dialogs/mutation/fields/index.ts
rename to dashboard/src/features/services/components/dialogs/mutation/fields/index.ts
diff --git a/dashboard/src/features/services/dialogs/mutation/fields/name.tsx b/dashboard/src/features/services/components/dialogs/mutation/fields/name.tsx
similarity index 100%
rename from dashboard/src/features/services/dialogs/mutation/fields/name.tsx
rename to dashboard/src/features/services/components/dialogs/mutation/fields/name.tsx
diff --git a/dashboard/src/features/services/dialogs/mutation/index.tsx b/dashboard/src/features/services/components/dialogs/mutation/index.tsx
similarity index 100%
rename from dashboard/src/features/services/dialogs/mutation/index.tsx
rename to dashboard/src/features/services/components/dialogs/mutation/index.tsx
diff --git a/dashboard/src/features/services/components/dialogs/settings.dialog.tsx b/dashboard/src/features/services/components/dialogs/settings.dialog.tsx
new file mode 100644
index 0000000..7daa68c
--- /dev/null
+++ b/dashboard/src/features/services/components/dialogs/settings.dialog.tsx
@@ -0,0 +1,47 @@
+import {
+ SettingsDialogProps,
+ SettingsDialog,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@marzneshin/components"
+import { type FC, useEffect } from "react"
+import { useTranslation } from "react-i18next"
+import {
+ ServicesUsersTable,
+ ServiceInboundsTable,
+ ServiceType
+} from "@marzneshin/features/services";
+
+interface ServicecSettingsDialogProps extends SettingsDialogProps {
+ entity: ServiceType
+ onClose: () => void;
+}
+
+export const ServiceSettingsDialog: FC<ServicecSettingsDialogProps> = ({
+ onOpenChange, open, entity, onClose
+}) => {
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ if (!open) onClose();
+ }, [open, onClose]);
+
+ return (
+ <SettingsDialog open={open} onOpenChange={onOpenChange}>
+ <Tabs className="my-3 w-full h-full" defaultValue="inbounds">
+ <TabsList className="w-full">
+ <TabsTrigger className="w-full" value="inbounds">{t('inbounds')}</TabsTrigger>
+ <TabsTrigger className="w-full" value="users">{t('users')}</TabsTrigger>
+ </TabsList>
+ <TabsContent value="inbounds" className="h-full">
+ <ServiceInboundsTable service={entity} />
+ </TabsContent>
+ <TabsContent value="users" className="h-full">
+ <ServicesUsersTable service={entity} />
+ </TabsContent>
+ </Tabs>
+ </SettingsDialog>
+ )
+}
diff --git a/dashboard/src/features/services/components/form-fields/index.ts b/dashboard/src/features/services/components/form-fields/index.ts
new file mode 100644
index 0000000..b2221a9
--- /dev/null
+++ b/dashboard/src/features/services/components/form-fields/index.ts
@@ -0,0 +1 @@
+export * from "./services";
diff --git a/dashboard/src/features/users/components/dialogs/mutation/fields/services.tsx b/dashboard/src/features/services/components/form-fields/services.tsx
similarity index 100%
rename from dashboard/src/features/users/components/dialogs/mutation/fields/services.tsx
rename to dashboard/src/features/services/components/form-fields/services.tsx
diff --git a/dashboard/src/features/services/components/index.ts b/dashboard/src/features/services/components/index.ts
new file mode 100644
index 0000000..6e5af6b
--- /dev/null
+++ b/dashboard/src/features/services/components/index.ts
@@ -0,0 +1,3 @@
+export * from "./form-fields";
+export * from "./tables";
+export * from "./dialogs";
diff --git a/dashboard/src/features/services/tables/inbounds/columns.tsx b/dashboard/src/features/services/components/tables/inbounds/columns.tsx
similarity index 100%
rename from dashboard/src/features/services/tables/inbounds/columns.tsx
rename to dashboard/src/features/services/components/tables/inbounds/columns.tsx
diff --git a/dashboard/src/features/services/tables/inbounds/index.tsx b/dashboard/src/features/services/components/tables/inbounds/index.tsx
similarity index 95%
rename from dashboard/src/features/services/tables/inbounds/index.tsx
rename to dashboard/src/features/services/components/tables/inbounds/index.tsx
index 5d7cebb..9b7362a 100644
--- a/dashboard/src/features/services/tables/inbounds/index.tsx
+++ b/dashboard/src/features/services/components/tables/inbounds/index.tsx
@@ -2,7 +2,7 @@ import { type FC, useCallback, useEffect, useState } from "react";
import { useInboundsQuery } from "@marzneshin/features/inbounds";
import { Button, DataTable } from "@marzneshin/components";
import { columns } from "./columns";
-import { type ServiceType, useServicesUpdateMutation } from "../..";
+import { type ServiceType, useServicesUpdateMutation } from "@marzneshin/features/services";
import { useTranslation } from "react-i18next";
import type { RowSelectionState } from "@tanstack/react-table";
diff --git a/dashboard/src/features/services/components/tables/index.ts b/dashboard/src/features/services/components/tables/index.ts
new file mode 100644
index 0000000..3c48145
--- /dev/null
+++ b/dashboard/src/features/services/components/tables/index.ts
@@ -0,0 +1,4 @@
+export * from './users'
+export * from './inbounds'
+export * from './services'
+export * from './services/columns'
diff --git a/dashboard/src/features/services/components/tables/services/columns.tsx b/dashboard/src/features/services/components/tables/services/columns.tsx
new file mode 100644
index 0000000..fdd7eb5
--- /dev/null
+++ b/dashboard/src/features/services/components/tables/services/columns.tsx
@@ -0,0 +1,36 @@
+import { ColumnDef } from "@tanstack/react-table";
+import { ServiceType } from "@marzneshin/features/services";
+import { DataTableColumnHeader } from "@marzneshin/components/data-table/column-header";
+import i18n from "@marzneshin/features/i18n";
+import {
+ DataTableActionsCell,
+ NoPropogationButton,
+} from "@marzneshin/components";
+import { ColumnActions } from "@marzneshin/features/entity-table";
+
+export const columns = (actions: ColumnActions<ServiceType>): ColumnDef<ServiceType>[] => ([
+ {
+ accessorKey: "name",
+ header: ({ column }) => <DataTableColumnHeader title={i18n.t('name')} column={column} />,
+ },
+ {
+ accessorKey: "users",
+ header: ({ column }) => <DataTableColumnHeader title={i18n.t('users')} column={column} />,
+ cell: ({ row }) => `${row.original.user_ids.length}`
+ },
+ {
+ accessorKey: "inbounds",
+ header: ({ column }) => <DataTableColumnHeader title={i18n.t('inbounds')} column={column} />,
+ cell: ({ row }) => `${row.original.inbound_ids.length}`
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => {
+ return (
+ <NoPropogationButton row={row} actions={actions}>
+ <DataTableActionsCell {...actions} row={row} />
+ </NoPropogationButton>
+ );
+ },
+ }
+]);
diff --git a/dashboard/src/features/services/tables/services/index.tsx b/dashboard/src/features/services/components/tables/services/index.tsx
similarity index 100%
rename from dashboard/src/features/services/tables/services/index.tsx
rename to dashboard/src/features/services/components/tables/services/index.tsx
diff --git a/dashboard/src/features/services/tables/users/columns.tsx b/dashboard/src/features/services/components/tables/users/columns.tsx
similarity index 100%
rename from dashboard/src/features/services/tables/users/columns.tsx
rename to dashboard/src/features/services/components/tables/users/columns.tsx
diff --git a/dashboard/src/features/services/tables/users/index.tsx b/dashboard/src/features/services/components/tables/users/index.tsx
similarity index 81%
rename from dashboard/src/features/services/tables/users/index.tsx
rename to dashboard/src/features/services/components/tables/users/index.tsx
index aa5a219..3518a7e 100644
--- a/dashboard/src/features/services/tables/users/index.tsx
+++ b/dashboard/src/features/services/components/tables/users/index.tsx
@@ -2,8 +2,7 @@
import { FC } from "react";
import { DataTable } from "@marzneshin/components";
import { columns } from "./columns";
-import { useUsersServiceQuery } from "../../services/service-users.query";
-import { ServiceType } from "../..";
+import { useUsersServiceQuery, type ServiceType } from "@marzneshin/features/services";
interface ServicesUsersTableProps {
service: ServiceType
diff --git a/dashboard/src/features/services/contexts/index.ts b/dashboard/src/features/services/contexts/index.ts
new file mode 100644
index 0000000..4196751
--- /dev/null
+++ b/dashboard/src/features/services/contexts/index.ts
@@ -0,0 +1 @@
+export * from "./router-context";
diff --git a/dashboard/src/features/services/router-context/index.ts b/dashboard/src/features/services/contexts/router-context.ts
similarity index 100%
rename from dashboard/src/features/services/router-context/index.ts
rename to dashboard/src/features/services/contexts/router-context.ts
diff --git a/dashboard/src/features/services/dialogs/settings.dialog.tsx b/dashboard/src/features/services/dialogs/settings.dialog.tsx
deleted file mode 100644
index e2f575a..0000000
--- a/dashboard/src/features/services/dialogs/settings.dialog.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
-} from "@marzneshin/components"
-import { type FC, useEffect } from "react"
-import { useTranslation } from "react-i18next"
-import { ServiceType } from "..";
-import { ServiceInboundsTable } from "../tables/inbounds";
-import { ServicesUsersTable } from "../tables/users";
-
-interface ServicecSettingsDialogProps {
- onOpenChange: (state: boolean) => void
- open: boolean
- entity: ServiceType
- onClose: () => void;
-}
-
-export const ServiceSettingsDialog: FC<ServicecSettingsDialogProps> = ({
- onOpenChange, open, entity, onClose
-}) => {
- const { t } = useTranslation();
-
- useEffect(() => {
- if (!open) onClose();
- }, [open, onClose]);
-
- return (
- <Sheet open={open} onOpenChange={onOpenChange}>
- <SheetContent className="sm:min-w-full md:min-w-[700px]" >
- <SheetHeader>
- <SheetTitle>
- {t('page.services.settings.title')}
- </SheetTitle>
- </SheetHeader>
- <Tabs className="my-3 w-full h-full" defaultValue="inbounds">
- <TabsList className="w-full">
- <TabsTrigger className="w-full" value="inbounds">{t('inbounds')}</TabsTrigger>
- <TabsTrigger className="w-full" value="users">{t('users')}</TabsTrigger>
- </TabsList>
- <TabsContent value="inbounds" className="h-full">
- <ServiceInboundsTable service={entity} />
- </TabsContent>
- <TabsContent value="users" className="h-full">
- <ServicesUsersTable service={entity} />
- </TabsContent>
- </Tabs>
- </SheetContent>
- </Sheet>
- )
-}
diff --git a/dashboard/src/features/services/index.ts b/dashboard/src/features/services/index.ts
index 95ecaf6..8021c5b 100644
--- a/dashboard/src/features/services/index.ts
+++ b/dashboard/src/features/services/index.ts
@@ -1,5 +1,4 @@
export * from './types'
-export * from './tables'
+export * from './components'
export * from './services'
-export * from './dialogs'
-export * from './router-context'
+export * from './contexts'
diff --git a/dashboard/src/features/services/services/index.ts b/dashboard/src/features/services/services/index.ts
index 4739311..82f6642 100644
--- a/dashboard/src/features/services/services/index.ts
+++ b/dashboard/src/features/services/services/index.ts
@@ -1,5 +1,7 @@
-export * from './services.query'
-export * from './service.query'
-export * from './create-service.mutate'
-export * from './delete-service.mutate'
-export * from './update-service.mutate'
+export * from "./create-service.mutate";
+export * from "./delete-service.mutate";
+export * from "./service-inbounds.query";
+export * from "./service-users.query";
+export * from "./service.query";
+export * from "./services.query";
+export * from "./update-service.mutate";
diff --git a/dashboard/src/features/services/services/service-inbounds.query.ts b/dashboard/src/features/services/services/service-inbounds.query.ts
index 9fed833..2d6419b 100644
--- a/dashboard/src/features/services/services/service-inbounds.query.ts
+++ b/dashboard/src/features/services/services/service-inbounds.query.ts
@@ -25,7 +25,7 @@ export async function fetchServiceInbounds({
});
}
-export const ServicesQueryFetchKey = "services";
+const ServicesQueryFetchKey = "services";
export const useInboundsServiceQuery = ({
serviceId, page = 1, size = 50
diff --git a/dashboard/src/features/services/services/service-users.query.ts b/dashboard/src/features/services/services/service-users.query.ts
index 9b3a3c1..201ae98 100644
--- a/dashboard/src/features/services/services/service-users.query.ts
+++ b/dashboard/src/features/services/services/service-users.query.ts
@@ -23,7 +23,7 @@ export async function fetchServiceUsers({ queryKey }: FetchServiceUsersType): Pr
});
}
-export const ServicesQueryFetchKey = "services";
+const ServicesQueryFetchKey = "services";
export const useUsersServiceQuery = ({
serviceId, page = 1, size = 50
diff --git a/dashboard/src/features/services/services/service.query.ts b/dashboard/src/features/services/services/service.query.ts
index e1e4442..166847e 100644
--- a/dashboard/src/features/services/services/service.query.ts
+++ b/dashboard/src/features/services/services/service.query.ts
@@ -14,7 +14,7 @@ export async function fetchService({
});
}
-export const ServiceQueryFetchKey = "services";
+const ServiceQueryFetchKey = "services";
export const useServiceQuery = ({ serviceId }: { serviceId: number }) => {
return useQuery({
diff --git a/dashboard/src/features/services/tables/index.ts b/dashboard/src/features/services/tables/index.ts
deleted file mode 100644
index 044162c..0000000
--- a/dashboard/src/features/services/tables/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-// export * from './users.table'
-// export * from './inbounds.table'
-export * from './services'
-export * from './services/columns'
diff --git a/dashboard/src/features/services/tables/services/columns.tsx b/dashboard/src/features/services/tables/services/columns.tsx
deleted file mode 100644
index f8b92f4..0000000
--- a/dashboard/src/features/services/tables/services/columns.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { ColumnDef } from "@tanstack/react-table"
-import { ServiceType } from "@marzneshin/features/services"
-import { DataTableColumnHeader } from "@marzneshin/components/data-table/column-header"
-import i18n from "@marzneshin/features/i18n"
-import { DataTableActionsCell } from "@marzneshin/components"
-
-interface ColumnAction {
- onDelete: (service: ServiceType) => void;
- onOpen: (node: ServiceType) => void;
- onEdit: (node: ServiceType) => void;
-}
-
-
-export const columns = (actions: ColumnAction): ColumnDef<ServiceType>[] => ([
- {
- accessorKey: "name",
- header: ({ column }) => <DataTableColumnHeader title={i18n.t('name')} column={column} />,
- },
- {
- accessorKey: "users",
- header: ({ column }) => <DataTableColumnHeader title={i18n.t('users')} column={column} />,
- cell: ({ row }) => `${row.original.user_ids.length}`
- },
- {
- accessorKey: "inbounds",
- header: ({ column }) => <DataTableColumnHeader title={i18n.t('inbounds')} column={column} />,
- cell: ({ row }) => `${row.original.inbound_ids.length}`
- },
- {
- id: "actions",
- cell: ({ row }) => <DataTableActionsCell {...actions} row={row} />
- },
-]);
diff --git a/dashboard/src/features/sidebar/items.tsx b/dashboard/src/features/sidebar/items.tsx
index ed8cd83..bdc6b82 100644
--- a/dashboard/src/features/sidebar/items.tsx
+++ b/dashboard/src/features/sidebar/items.tsx
@@ -1,5 +1,5 @@
import { SidebarObject } from '@marzneshin/components';
-import { Box, Home, Server, ServerCog, Settings, UsersIcon } from 'lucide-react';
+import { Box, Home, ShieldCheck, Server, ServerCog, Settings, UsersIcon } from 'lucide-react';
export const sidebarItems: SidebarObject = {
Dashboard: [
@@ -37,6 +37,12 @@ export const sidebarItems: SidebarObject = {
},
],
System: [
+ {
+ title: 'Admins',
+ to: '/admins',
+ icon: <ShieldCheck />,
+ isParent: false,
+ },
{
title: 'Settings',
to: '/settings',
diff --git a/dashboard/src/features/users/components/dialogs/mutation/fields/index.ts b/dashboard/src/features/users/components/dialogs/mutation/fields/index.ts
index c050de0..fce3bb6 100644
--- a/dashboard/src/features/users/components/dialogs/mutation/fields/index.ts
+++ b/dashboard/src/features/users/components/dialogs/mutation/fields/index.ts
@@ -1,8 +1,6 @@
export * from "./data-limit"
export * from "./expire-date"
export * from "./note"
-export * from "./services"
export * from "./data-limit-reset-strategy"
-export * from "./username"
export * from "./activation-deadline"
export * from "./usage-duration"
diff --git a/dashboard/src/features/users/components/dialogs/mutation/index.tsx b/dashboard/src/features/users/components/dialogs/mutation/index.tsx
index f43bab3..1eaf1c5 100644
--- a/dashboard/src/features/users/components/dialogs/mutation/index.tsx
+++ b/dashboard/src/features/users/components/dialogs/mutation/index.tsx
@@ -11,14 +11,15 @@ import {
VStack,
} from "@marzneshin/components";
import { useTranslation } from "react-i18next";
-import type { UserMutationType } from "@marzneshin/features/users";
+import { UsernameField, type UserMutationType } from "@marzneshin/features/users";
import {
DATA_LIMIT_METRIC,
useUsersCreationMutation,
useUsersUpdateMutation,
UserSchema,
} from "@marzneshin/features/users";
-import { UsernameField, NoteField, ServicesField } from "./fields";
+import { ServicesField } from "@marzneshin/features/services";
+import { NoteField } from "./fields";
import { useMutationDialog } from "@marzneshin/hooks";
import { DataLimitFields, ExpirationMethodFields } from "./sections";
diff --git a/dashboard/src/features/users/components/dialogs/settings/index.tsx b/dashboard/src/features/users/components/dialogs/settings/index.tsx
index 9ffa521..9835530 100644
--- a/dashboard/src/features/users/components/dialogs/settings/index.tsx
+++ b/dashboard/src/features/users/components/dialogs/settings/index.tsx
@@ -1,15 +1,14 @@
import {
Button,
ScrollArea,
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Awaiting,
+ SettingsInfoSkeleton,
+ SettingsDialogProps,
+ SettingsDialog,
} from "@marzneshin/components";
import {
UserServicesTable,
@@ -24,11 +23,8 @@ import {
QRCodeSection,
UserInfoTable
} from "./user-info";
-import { UserInfoSkeleton } from "./skeleton";
-interface UsersSettingsDialogProps {
- onOpenChange: (state: boolean) => void;
- open: boolean;
+interface UsersSettingsDialogProps extends SettingsDialogProps {
entity: UserType | null;
onClose: () => void;
isPending: boolean;
@@ -49,52 +45,47 @@ export const UsersSettingsDialog: FC<UsersSettingsDialogProps> = ({
}, [open, onClose]);
return (
- <Sheet open={open} onOpenChange={onOpenChange}>
- <SheetContent className="sm:min-w-full md:min-w-[700px] space-y-5">
- <SheetHeader >
- <SheetTitle>{t("settings")}</SheetTitle>
- </SheetHeader>
- <Awaiting
- Component={entity ? (
- <ScrollArea className="flex flex-col gap-4 h-full">
- <Tabs defaultValue="info" className="w-full h-full">
- <TabsList className="w-full bg-accent">
- <TabsTrigger className="w-full" value="info">
- {t("user_info")}
- </TabsTrigger>
- <TabsTrigger className="w-full" value="services">
- {t("services")}
- </TabsTrigger>
- <TabsTrigger className="w-full" value="subscription">
- {t("subscription")}
- </TabsTrigger>
- </TabsList>
- <TabsContent
- value="info"
- className="flex flex-col gap-2 w-full h-full"
- >
- <UserInfoTable user={entity} />
- <div className="hstack justify-center items-center gap-2">
- <Button className="w-1/2" onClick={() => resetUsage(entity)}>
- {t("page.users.reset_usage")}
- </Button>
- <UserStatusEnableButton user={entity} />
- </div>
- </TabsContent>
- <TabsContent value="services">
- <UserServicesTable user={entity} />
- </TabsContent>
- <TabsContent value="subscription">
- <QRCodeSection entity={entity} />
- <SubscriptionActions entity={entity} />
- </TabsContent>
- </Tabs>
- </ScrollArea>
- ) : (<div>Not Found</div>)}
- Skeleton={<UserInfoSkeleton />}
- isFetching={isPending}
- />
- </SheetContent>
- </Sheet>
+ <SettingsDialog open={open} onOpenChange={onOpenChange}>
+ <Awaiting
+ Component={entity ? (
+ <ScrollArea className="flex flex-col gap-4 h-full">
+ <Tabs defaultValue="info" className="w-full h-full">
+ <TabsList className="w-full bg-accent">
+ <TabsTrigger className="w-full" value="info">
+ {t("user_info")}
+ </TabsTrigger>
+ <TabsTrigger className="w-full" value="services">
+ {t("services")}
+ </TabsTrigger>
+ <TabsTrigger className="w-full" value="subscription">
+ {t("subscription")}
+ </TabsTrigger>
+ </TabsList>
+ <TabsContent
+ value="info"
+ className="flex flex-col gap-2 w-full h-full"
+ >
+ <UserInfoTable user={entity} />
+ <div className="hstack justify-center items-center gap-2">
+ <Button className="w-1/2" onClick={() => resetUsage(entity)}>
+ {t("page.users.reset_usage")}
+ </Button>
+ <UserStatusEnableButton user={entity} />
+ </div>
+ </TabsContent>
+ <TabsContent value="services">
+ <UserServicesTable user={entity} />
+ </TabsContent>
+ <TabsContent value="subscription">
+ <QRCodeSection entity={entity} />
+ <SubscriptionActions entity={entity} />
+ </TabsContent>
+ </Tabs>
+ </ScrollArea>
+ ) : (<div>Not Found</div>)}
+ Skeleton={<SettingsInfoSkeleton />}
+ isFetching={isPending}
+ />
+ </SettingsDialog>
);
};
diff --git a/dashboard/src/features/users/components/date-field.tsx b/dashboard/src/features/users/components/form-fields/date.tsx
similarity index 100%
rename from dashboard/src/features/users/components/date-field.tsx
rename to dashboard/src/features/users/components/form-fields/date.tsx
diff --git a/dashboard/src/features/users/components/form-fields/index.ts b/dashboard/src/features/users/components/form-fields/index.ts
new file mode 100644
index 0000000..c72532b
--- /dev/null
+++ b/dashboard/src/features/users/components/form-fields/index.ts
@@ -0,0 +1,2 @@
+export * from "./username";
+export * from "./date";
diff --git a/dashboard/src/features/users/components/dialogs/mutation/fields/username.tsx b/dashboard/src/features/users/components/form-fields/username.tsx
similarity index 100%
rename from dashboard/src/features/users/components/dialogs/mutation/fields/username.tsx
rename to dashboard/src/features/users/components/form-fields/username.tsx
diff --git a/dashboard/src/features/users/components/index.ts b/dashboard/src/features/users/components/index.ts
index e08264e..f522142 100644
--- a/dashboard/src/features/users/components/index.ts
+++ b/dashboard/src/features/users/components/index.ts
@@ -1,6 +1,6 @@
export * from './users-status'
export * from './services-card'
-export * from './date-field'
+export * from './form-fields'
export * from './online-status'
export * from './used-user-traffic'
export * from "./tables"
diff --git a/dashboard/src/features/users/components/table-row-with-cell.tsx b/dashboard/src/features/users/components/table-row-with-cell.tsx
deleted file mode 100644
index 2bcdcb3..0000000
--- a/dashboard/src/features/users/components/table-row-with-cell.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { TableCell, TableRow } from "@marzneshin/components";
-import { FC } from "react";
-
-interface TableRowWithCellProps {
- label: string | JSX.Element;
- value: string | number | JSX.Element;
-}
-
-export const TableRowWithCell: FC<TableRowWithCellProps> = ({ label, value }) => (
- <TableRow>
- <TableCell>{label}</TableCell>
- <TableCell>{value}</TableCell>
- </TableRow>
-);
-
diff --git a/dashboard/src/features/users/components/tables/users/columns.tsx b/dashboard/src/features/users/components/tables/users/columns.tsx
index 61e6ba5..4052e68 100644
--- a/dashboard/src/features/users/components/tables/users/columns.tsx
+++ b/dashboard/src/features/users/components/tables/users/columns.tsx
@@ -13,17 +13,13 @@ import {
CopyToClipboardButton,
DataTableActionsCell,
buttonVariants,
+ NoPropogationButton,
} from "@marzneshin/components";
import { LinkIcon } from "lucide-react";
import { getSubscriptionLink } from "@marzneshin/utils";
+import { type ColumnActions } from "@marzneshin/features/entity-table";
-interface ColumnAction {
- onDelete: (user: UserType) => void;
- onOpen: (user: UserType) => void;
- onEdit: (user: UserType) => void;
-}
-
-export const columns = (actions: ColumnAction): ColumnDef<UserType>[] => [
+export const columns = (actions: ColumnActions<UserType>): ColumnDef<UserType>[] => [
{
accessorKey: "username",
header: ({ column }) => (
@@ -79,38 +75,22 @@ export const columns = (actions: ColumnAction): ColumnDef<UserType>[] => [
},
{
id: "actions",
- cell: ({ row }) => {
- const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- e.stopPropagation();
- actions.onOpen(row.original);
- }
- };
-
- return (
- <div
- className="flex flex-row gap-2 items-center"
- onClick={(e) => e.stopPropagation()}
- onKeyDown={handleKeyDown}
- tabIndex={0}
- role="button"
- >
- <CopyToClipboardButton
- text={getSubscriptionLink(row.original.subscription_url)}
- successMessage={i18n.t(
- "page.users.settings.subscription_link.copied",
- )}
- copyIcon={LinkIcon}
- className={buttonVariants({
- variant: "secondary",
- className: "size-8",
- })}
- tooltipMsg={i18n.t("page.users.settings.subscription_link.copy")}
- />
- <DataTableActionsCell {...actions} row={row} />
- </div>
- );
- },
+ cell: ({ row }) => (
+ <NoPropogationButton row={row} actions={actions}>
+ <CopyToClipboardButton
+ text={getSubscriptionLink(row.original.subscription_url)}
+ successMessage={i18n.t(
+ "page.users.settings.subscription_link.copied",
+ )}
+ copyIcon={LinkIcon}
+ className={buttonVariants({
+ variant: "secondary",
+ className: "size-8",
+ })}
+ tooltipMsg={i18n.t("page.users.settings.subscription_link.copy")}
+ />
+ <DataTableActionsCell {...actions} row={row} />
+ </NoPropogationButton>
+ )
}
];
diff --git a/dashboard/src/routeTree.gen.ts b/dashboard/src/routeTree.gen.ts
index 2211048..ef5ba0f 100644
--- a/dashboard/src/routeTree.gen.ts
+++ b/dashboard/src/routeTree.gen.ts
@@ -23,10 +23,13 @@ import { Route as DashboardServicesServiceIdImport } from './routes/_dashboard/s
import { Route as DashboardNodesCreateImport } from './routes/_dashboard/nodes/create'
import { Route as DashboardNodesNodeIdImport } from './routes/_dashboard/nodes/$nodeId'
import { Route as DashboardHostsHostIdImport } from './routes/_dashboard/hosts/$hostId'
+import { Route as DashboardAdminsCreateImport } from './routes/_dashboard/admins/create'
+import { Route as DashboardAdminsAdminIdImport } from './routes/_dashboard/admins/$adminId'
import { Route as DashboardUsersUserIdIndexImport } from './routes/_dashboard/users/$userId/index'
import { Route as DashboardServicesServiceIdIndexImport } from './routes/_dashboard/services/$serviceId/index'
import { Route as DashboardNodesNodeIdIndexImport } from './routes/_dashboard/nodes/$nodeId/index'
import { Route as DashboardHostsHostIdIndexImport } from './routes/_dashboard/hosts/$hostId/index'
+import { Route as DashboardAdminsAdminIdIndexImport } from './routes/_dashboard/admins/$adminId/index'
import { Route as DashboardUsersUserIdEditImport } from './routes/_dashboard/users/$userId/edit'
import { Route as DashboardUsersUserIdDeleteImport } from './routes/_dashboard/users/$userId/delete'
import { Route as DashboardServicesServiceIdEditImport } from './routes/_dashboard/services/$serviceId/edit'
@@ -36,6 +39,8 @@ import { Route as DashboardNodesNodeIdDeleteImport } from './routes/_dashboard/n
import { Route as DashboardHostsInboundIdCreateImport } from './routes/_dashboard/hosts/$inboundId/create'
import { Route as DashboardHostsHostIdEditImport } from './routes/_dashboard/hosts/$hostId/edit'
import { Route as DashboardHostsHostIdDeleteImport } from './routes/_dashboard/hosts/$hostId/delete'
+import { Route as DashboardAdminsAdminIdEditImport } from './routes/_dashboard/admins/$adminId/edit'
+import { Route as DashboardAdminsAdminIdDeleteImport } from './routes/_dashboard/admins/$adminId/delete'
// Create Virtual Routes
@@ -45,6 +50,7 @@ const DashboardSettingsLazyImport = createFileRoute('/_dashboard/settings')()
const DashboardServicesLazyImport = createFileRoute('/_dashboard/services')()
const DashboardNodesLazyImport = createFileRoute('/_dashboard/nodes')()
const DashboardHostsLazyImport = createFileRoute('/_dashboard/hosts')()
+const DashboardAdminsLazyImport = createFileRoute('/_dashboard/admins')()
// Create/Update Routes
@@ -100,6 +106,13 @@ const DashboardHostsLazyRoute = DashboardHostsLazyImport.update({
import('./routes/_dashboard/hosts.lazy').then((d) => d.Route),
)
+const DashboardAdminsLazyRoute = DashboardAdminsLazyImport.update({
+ path: '/admins',
+ getParentRoute: () => DashboardRoute,
+} as any).lazy(() =>
+ import('./routes/_dashboard/admins.lazy').then((d) => d.Route),
+)
+
const AuthLoginRoute = AuthLoginImport.update({
path: '/login',
getParentRoute: () => AuthRoute,
@@ -142,6 +155,16 @@ const DashboardHostsHostIdRoute = DashboardHostsHostIdImport.update({
getParentRoute: () => DashboardHostsLazyRoute,
} as any)
+const DashboardAdminsCreateRoute = DashboardAdminsCreateImport.update({
+ path: '/create',
+ getParentRoute: () => DashboardAdminsLazyRoute,
+} as any)
+
+const DashboardAdminsAdminIdRoute = DashboardAdminsAdminIdImport.update({
+ path: '/$adminId',
+ getParentRoute: () => DashboardAdminsLazyRoute,
+} as any)
+
const DashboardUsersUserIdIndexRoute = DashboardUsersUserIdIndexImport.update({
path: '/',
getParentRoute: () => DashboardUsersUserIdRoute,
@@ -163,6 +186,12 @@ const DashboardHostsHostIdIndexRoute = DashboardHostsHostIdIndexImport.update({
getParentRoute: () => DashboardHostsHostIdRoute,
} as any)
+const DashboardAdminsAdminIdIndexRoute =
+ DashboardAdminsAdminIdIndexImport.update({
+ path: '/',
+ getParentRoute: () => DashboardAdminsAdminIdRoute,
+ } as any)
+
const DashboardUsersUserIdEditRoute = DashboardUsersUserIdEditImport.update({
path: '/edit',
getParentRoute: () => DashboardUsersUserIdRoute,
@@ -217,6 +246,19 @@ const DashboardHostsHostIdDeleteRoute = DashboardHostsHostIdDeleteImport.update(
} as any,
)
+const DashboardAdminsAdminIdEditRoute = DashboardAdminsAdminIdEditImport.update(
+ {
+ path: '/edit',
+ getParentRoute: () => DashboardAdminsAdminIdRoute,
+ } as any,
+)
+
+const DashboardAdminsAdminIdDeleteRoute =
+ DashboardAdminsAdminIdDeleteImport.update({
+ path: '/delete',
+ getParentRoute: () => DashboardAdminsAdminIdRoute,
+ } as any)
+
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@@ -242,6 +284,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthLoginImport
parentRoute: typeof AuthImport
}
+ '/_dashboard/admins': {
+ id: '/_dashboard/admins'
+ path: '/admins'
+ fullPath: '/admins'
+ preLoaderRoute: typeof DashboardAdminsLazyImport
+ parentRoute: typeof DashboardImport
+ }
'/_dashboard/hosts': {
id: '/_dashboard/hosts'
path: '/hosts'
@@ -284,6 +333,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardIndexLazyImport
parentRoute: typeof DashboardImport
}
+ '/_dashboard/admins/$adminId': {
+ id: '/_dashboard/admins/$adminId'
+ path: '/$adminId'
+ fullPath: '/admins/$adminId'
+ preLoaderRoute: typeof DashboardAdminsAdminIdImport
+ parentRoute: typeof DashboardAdminsLazyImport
+ }
+ '/_dashboard/admins/create': {
+ id: '/_dashboard/admins/create'
+ path: '/create'
+ fullPath: '/admins/create'
+ preLoaderRoute: typeof DashboardAdminsCreateImport
+ parentRoute: typeof DashboardAdminsLazyImport
+ }
'/_dashboard/hosts/$hostId': {
id: '/_dashboard/hosts/$hostId'
path: '/$hostId'
@@ -333,6 +396,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardUsersCreateImport
parentRoute: typeof DashboardUsersLazyImport
}
+ '/_dashboard/admins/$adminId/delete': {
+ id: '/_dashboard/admins/$adminId/delete'
+ path: '/delete'
+ fullPath: '/admins/$adminId/delete'
+ preLoaderRoute: typeof DashboardAdminsAdminIdDeleteImport
+ parentRoute: typeof DashboardAdminsAdminIdImport
+ }
+ '/_dashboard/admins/$adminId/edit': {
+ id: '/_dashboard/admins/$adminId/edit'
+ path: '/edit'
+ fullPath: '/admins/$adminId/edit'
+ preLoaderRoute: typeof DashboardAdminsAdminIdEditImport
+ parentRoute: typeof DashboardAdminsAdminIdImport
+ }
'/_dashboard/hosts/$hostId/delete': {
id: '/_dashboard/hosts/$hostId/delete'
path: '/delete'
@@ -396,6 +473,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardUsersUserIdEditImport
parentRoute: typeof DashboardUsersUserIdImport
}
+ '/_dashboard/admins/$adminId/': {
+ id: '/_dashboard/admins/$adminId/'
+ path: '/'
+ fullPath: '/admins/$adminId/'
+ preLoaderRoute: typeof DashboardAdminsAdminIdIndexImport
+ parentRoute: typeof DashboardAdminsAdminIdImport
+ }
'/_dashboard/hosts/$hostId/': {
id: '/_dashboard/hosts/$hostId/'
path: '/'
@@ -432,6 +516,14 @@ declare module '@tanstack/react-router' {
export const routeTree = rootRoute.addChildren({
AuthRoute: AuthRoute.addChildren({ AuthLoginRoute }),
DashboardRoute: DashboardRoute.addChildren({
+ DashboardAdminsLazyRoute: DashboardAdminsLazyRoute.addChildren({
+ DashboardAdminsAdminIdRoute: DashboardAdminsAdminIdRoute.addChildren({
+ DashboardAdminsAdminIdDeleteRoute,
+ DashboardAdminsAdminIdEditRoute,
+ DashboardAdminsAdminIdIndexRoute,
+ }),
+ DashboardAdminsCreateRoute,
+ }),
DashboardHostsLazyRoute: DashboardHostsLazyRoute.addChildren({
DashboardHostsHostIdRoute: DashboardHostsHostIdRoute.addChildren({
DashboardHostsHostIdDeleteRoute,
@@ -491,6 +583,7 @@ export const routeTree = rootRoute.addChildren({
"/_dashboard": {
"filePath": "_dashboard.tsx",
"children": [
+ "/_dashboard/admins",
"/_dashboard/hosts",
"/_dashboard/nodes",
"/_dashboard/services",
@@ -503,6 +596,14 @@ export const routeTree = rootRoute.addChildren({
"filePath": "_auth/login.tsx",
"parent": "/_auth"
},
+ "/_dashboard/admins": {
+ "filePath": "_dashboard/admins.lazy.tsx",
+ "parent": "/_dashboard",
+ "children": [
+ "/_dashboard/admins/$adminId",
+ "/_dashboard/admins/create"
+ ]
+ },
"/_dashboard/hosts": {
"filePath": "_dashboard/hosts.lazy.tsx",
"parent": "/_dashboard",
@@ -543,6 +644,19 @@ export const routeTree = rootRoute.addChildren({
"filePath": "_dashboard/index.lazy.tsx",
"parent": "/_dashboard"
},
+ "/_dashboard/admins/$adminId": {
+ "filePath": "_dashboard/admins/$adminId.tsx",
+ "parent": "/_dashboard/admins",
+ "children": [
+ "/_dashboard/admins/$adminId/delete",
+ "/_dashboard/admins/$adminId/edit",
+ "/_dashboard/admins/$adminId/"
+ ]
+ },
+ "/_dashboard/admins/create": {
+ "filePath": "_dashboard/admins/create.tsx",
+ "parent": "/_dashboard/admins"
+ },
"/_dashboard/hosts/$hostId": {
"filePath": "_dashboard/hosts/$hostId.tsx",
"parent": "/_dashboard/hosts",
@@ -591,6 +705,14 @@ export const routeTree = rootRoute.addChildren({
"filePath": "_dashboard/users/create.tsx",
"parent": "/_dashboard/users"
},
+ "/_dashboard/admins/$adminId/delete": {
+ "filePath": "_dashboard/admins/$adminId/delete.tsx",
+ "parent": "/_dashboard/admins/$adminId"
+ },
+ "/_dashboard/admins/$adminId/edit": {
+ "filePath": "_dashboard/admins/$adminId/edit.tsx",
+ "parent": "/_dashboard/admins/$adminId"
+ },
"/_dashboard/hosts/$hostId/delete": {
"filePath": "_dashboard/hosts/$hostId/delete.tsx",
"parent": "/_dashboard/hosts/$hostId"
@@ -627,6 +749,10 @@ export const routeTree = rootRoute.addChildren({
"filePath": "_dashboard/users/$userId/edit.tsx",
"parent": "/_dashboard/users/$userId"
},
+ "/_dashboard/admins/$adminId/": {
+ "filePath": "_dashboard/admins/$adminId/index.tsx",
+ "parent": "/_dashboard/admins/$adminId"
+ },
"/_dashboard/hosts/$hostId/": {
"filePath": "_dashboard/hosts/$hostId/index.tsx",
"parent": "/_dashboard/hosts/$hostId"
diff --git a/dashboard/src/routes/_dashboard/admins.lazy.tsx b/dashboard/src/routes/_dashboard/admins.lazy.tsx
new file mode 100644
index 0000000..881498b
--- /dev/null
+++ b/dashboard/src/routes/_dashboard/admins.lazy.tsx
@@ -0,0 +1,39 @@
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ Page,
+ Loading,
+} from '@marzneshin/components';
+import { SudoRoute } from '@marzneshin/features/sudo-routes'
+import { createFileRoute, Outlet } from '@tanstack/react-router'
+import { AdminsTable } from '@marzneshin/features/admins'
+import { type FC, Suspense } from 'react';
+import { useTranslation } from 'react-i18next';
+
+export const AdminsPage: FC = () => {
+ const { t } = useTranslation();
+ return (
+ <Page>
+ <Card className="border-0 sm:w-screen md:w-full">
+ <CardHeader>
+ <CardTitle>
+ {t('admins')}
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <AdminsTable />
+ <Suspense fallback={<Loading />}>
+ <Outlet />
+ </Suspense>
+ </CardContent>
+ </Card>
+ </Page>
+ )
+};
+
+
+export const Route = createFileRoute('/_dashboard/admins')({
+ component: () => <SudoRoute><AdminsPage /></SudoRoute>,
+})
diff --git a/dashboard/src/routes/_dashboard/admins/$adminId.tsx b/dashboard/src/routes/_dashboard/admins/$adminId.tsx
new file mode 100644
index 0000000..828be3b
--- /dev/null
+++ b/dashboard/src/routes/_dashboard/admins/$adminId.tsx
@@ -0,0 +1,41 @@
+import {
+ createFileRoute,
+ Outlet,
+} from "@tanstack/react-router";
+import { useSuspenseQuery } from "@tanstack/react-query";
+import { queryClient } from "@marzneshin/utils";
+import {
+ RouterAdminContext,
+ adminQueryOptions,
+} from "@marzneshin/features/admins";
+import { Suspense, useMemo } from "react";
+import {
+ AlertDialog,
+ AlertDialogContent,
+} from "@marzneshin/components";
+
+const AdminProvider = () => {
+ const { username } = Route.useLoaderData()
+ const { data: admin, isPending } = useSuspenseQuery(adminQueryOptions({ username }))
+ const value = useMemo(() => ({ admin, isPending }), [admin, isPending])
+ return (
+ <RouterAdminContext.Provider value={value}>
+ <Suspense>
+ <Outlet />
+ </Suspense>
+ </RouterAdminContext.Provider>
+ )
+}
+
+export const Route = createFileRoute('/_dashboard/admins/$adminId')({
+ loader: async ({ params }) => {
+ queryClient.ensureQueryData(adminQueryOptions({ username: params.adminId }))
+ return { username: params.adminId };
+ },
+ errorComponent: () => (
+ <AlertDialog open={true}>
+ <AlertDialogContent>Admin not found</AlertDialogContent>
+ </AlertDialog>
+ ),
+ component: AdminProvider,
+})
diff --git a/dashboard/src/routes/_dashboard/admins/$adminId/delete.tsx b/dashboard/src/routes/_dashboard/admins/$adminId/delete.tsx
new file mode 100644
index 0000000..c03b955
--- /dev/null
+++ b/dashboard/src/routes/_dashboard/admins/$adminId/delete.tsx
@@ -0,0 +1,28 @@
+import {
+ createFileRoute,
+ useNavigate,
+} from "@tanstack/react-router";
+import {
+ AdminsDeleteConfirmationDialog,
+ useRouterAdminContext,
+} from "@marzneshin/features/admins";
+import { useDialog } from "@marzneshin/hooks";
+
+const AdminDelete = () => {
+ const [deleteDialogOpen, setDeleteDialogOpen] = useDialog(true);
+ const value = useRouterAdminContext()
+ const navigate = useNavigate({ from: "/admins/$adminId/delete" });
+
+ return !!(value?.admin) && (
+ <AdminsDeleteConfirmationDialog
+ open={deleteDialogOpen}
+ onOpenChange={setDeleteDialogOpen}
+ entity={value.admin}
+ onClose={() => navigate({ to: "/admins" })}
+ />
+ );
+}
+
+export const Route = createFileRoute('/_dashboard/admins/$adminId/delete')({
+ component: AdminDelete,
+})
diff --git a/dashboard/src/routes/_dashboard/admins/$adminId/edit.tsx b/dashboard/src/routes/_dashboard/admins/$adminId/edit.tsx
new file mode 100644
index 0000000..dcc3f53
--- /dev/null
+++ b/dashboard/src/routes/_dashboard/admins/$adminId/edit.tsx
@@ -0,0 +1,29 @@
+import {
+ createFileRoute,
+ useNavigate,
+} from "@tanstack/react-router";
+import {
+ AdminsMutationDialog,
+ useRouterAdminContext,
+} from "@marzneshin/features/admins";
+import { useDialog } from "@marzneshin/hooks";
+
+
+const AdminEdit = () => {
+ const [editDialogOpen, setEditDialogOpen] = useDialog(true);
+ const value = useRouterAdminContext()
+ const navigate = useNavigate({ from: "/admins/$adminId/edit" });
+
+ return value && (
+ <AdminsMutationDialog
+ open={editDialogOpen}
+ onOpenChange={setEditDialogOpen}
+ entity={value.admin}
+ onClose={() => navigate({ to: "/admins" })}
+ />
+ );
+}
+
+export const Route = createFileRoute('/_dashboard/admins/$adminId/edit')({
+ component: AdminEdit
+})
diff --git a/dashboard/src/routes/_dashboard/admins/$adminId/index.tsx b/dashboard/src/routes/_dashboard/admins/$adminId/index.tsx
new file mode 100644
index 0000000..f1a750f
--- /dev/null
+++ b/dashboard/src/routes/_dashboard/admins/$adminId/index.tsx
@@ -0,0 +1,29 @@
+import {
+ createFileRoute,
+ useNavigate,
+} from "@tanstack/react-router";
+import {
+ useRouterAdminContext,
+ AdminsSettingsDialog,
+} from "@marzneshin/features/admins";
+import { useDialog } from "@marzneshin/hooks";
+
+const AdminOpen = () => {
+ const [settingsDialogOpen, setSettingsDialogOpen] = useDialog(true);
+ const value = useRouterAdminContext()
+ const navigate = useNavigate({ from: "/admins/$adminId" });
+
+ return value && (
+ <AdminsSettingsDialog
+ open={settingsDialogOpen}
+ onOpenChange={setSettingsDialogOpen}
+ entity={value.admin}
+ isPending={!!value.isPending}
+ onClose={() => navigate({ to: "/admins" })}
+ />
+ );
+}
+
+export const Route = createFileRoute('/_dashboard/admins/$adminId/')({
+ component: AdminOpen
+})
diff --git a/dashboard/src/routes/_dashboard/admins/create.tsx b/dashboard/src/routes/_dashboard/admins/create.tsx
new file mode 100644
index 0000000..9bfcb98
--- /dev/null
+++ b/dashboard/src/routes/_dashboard/admins/create.tsx
@@ -0,0 +1,19 @@
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+import { AdminsMutationDialog } from "@marzneshin/features/admins";
+import { useDialog } from "@marzneshin/hooks";
+
+const AdminCreate = () => {
+ const [mutationDialogOpen, setMutationDialogOpen] = useDialog(true);
+ const navigate = useNavigate({ from: "/admins/create" });
+ return (
+ <AdminsMutationDialog
+ open={mutationDialogOpen}
+ onOpenChange={setMutationDialogOpen}
+ onClose={() => navigate({ to: "/admins" })}
+ />
+ );
+}
+
+export const Route = createFileRoute("/_dashboard/admins/create")({
+ component: AdminCreate,
+});