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 = ({ active, activeLabel, inactiveLabel }) => { +export const BooleanPill: FC = ({ + active, + activeLabel, + inactiveLabel, + activeVariant = "positive", + inactiveVariant = "destructive" +}) => { return ( - + {active ? activeLabel : inactiveLabel} ) 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 = ({ label, value }) => ( {label} {value} ); + 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 { + actions: ColumnActions, + row: Row, +} + +export function NoPropogationButton({ children, actions, row }: NoPropogationButtonProps & PropsWithChildren) { + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + actions.onOpen(row.original); + } + }, [actions, row.original]); + + return ( +
e.stopPropagation()} + onKeyDown={handleKeyDown} + tabIndex={0} + role="button" + > + {children} +
+ ) +} + 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 = ({ + open, onOpenChange, children +}) => { + const { t } = useTranslation(); + return ( + + + + {t("settings")} + + + {children} + + + + ) +} 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 (

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 ( + 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 ( + ( + + + + +
+ + {t("page.admins.all-services-access")} + +
+
+ )} + /> + ) +} 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 ( + ( + + + + +
+ + {t("enabled")} + +
+
+ )} + /> + ) +} 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 ( + ( + + + + +
+ + {t("page.admins.modify-users-access")} + +
+
+ )} + /> + ) +} 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 ( + ( + + {t("password")} + + {change ? + () + : ( + + ) + } + + + + )} + /> + ); +}; 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> = ({ + disabled, +}) => { + const form = useFormContext(); + const { t } = useTranslation(); + + return ( + ( + + {t("page.admins.subscription-url-prefix")} + + + + + + )} + /> + ); +}; 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 ( + ( + + + + +
+ + {t("page.admins.sudo-privilage")} + +
+
+ )} + /> + ) +} 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 = ({ + 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(entity === null); + if (entity !== null) + form.setValue("password", null, + { + shouldTouch: true, + shouldDirty: true, + }); + + useEffect(() => { + if (!open) onClose(); + }, [open, onClose]); + + return ( + + + + + + {t(entity ? "page.admins.dialogs.edition.title" : "page.admins.dialogs.creation.title")} + + +
+ +
+
+ + + + + + + +
+ + + +
+ +
+ +
+
+
+ ); +}; 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 = ({ admin: entity }) => { + const { t } = useTranslation(); + + return ( + + + {t("admin_info")} + + + + + + } + /> + } + /> + + +
+
+
+ ); +}; 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 = ({ + onOpenChange, + open, + entity, + onClose, + isPending, +}) => { + const { t } = useTranslation(); + + useEffect(() => { + if (!open) onClose(); + }, [open, onClose]); + + return ( + + + + + {t("admin_info")} + + + {t("services")} + + + {t("users")} + + + + + + + {/* */} + Services + + + Users + + + ) : (
Not Found
)} + Skeleton={} + isFetching={isPending} + /> +
+ ); +}; 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 = ({ admin }) => { + const { t } = useTranslation(); + return ( + + ) +} 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 = ({ admin }) => { + const { t } = useTranslation(); + return ( + + ) +} 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): ColumnDef[] => [ + { + accessorKey: "username", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "enabled", + enableSorting: false, + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + accessorKey: "is_sudo", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + id: "actions", + cell: ({ row }) => { + return ( + + + + ); + }, + } +]; 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 ( + 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(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 { + 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 { + 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 { + 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 { + 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 { + 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; 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 { + 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 = ({ onOpenChange, }, [open, onClose]); return ( - - - - - {t('page.hosts.settings.title')} - - - - - - - - - - - - -
-
-
+ + + + + + + + + + + +
+
) } 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[] => ([ +export const columns = (actions: ColumnActions): ColumnDef[] => ([ { accessorKey: "remark", header: ({ column }) => , @@ -26,6 +25,12 @@ export const columns = (actions: ColumnAction): ColumnDef[] => ([ }, { id: "actions", - cell: ({ row }) => - }, + cell: ({ row }) => { + return ( + + + + ); + }, + } ]); 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 = ({ }, [open, onClose]); return ( - - - - - {t("page.nodes.settings.title")} - -
-

- {t("page.nodes.settings.detail")} -

- -
- - - - {t("logs")} - - - {t("config")} - - - - - - - - - - - - - -
-
-
+ +
+

+ {t("page.nodes.settings.detail")} +

+ +
+ + + + {t("logs")} + + + {t("config")} + + + + + + + + + + + + + +
); }; 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[] => ([ +export const columns = (actions: ColumnActions): ColumnDef[] => ([ { accessorKey: "name", header: ({ column }) => , @@ -32,6 +31,12 @@ export const columns = (actions: ColumnAction): ColumnDef[] => ([ }, { id: "actions", - cell: ({ row }) => - }, + cell: ({ row }) => { + return ( + + + + ); + }, + } ]); 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 = ({ + onOpenChange, open, entity, onClose +}) => { + const { t } = useTranslation(); + + useEffect(() => { + if (!open) onClose(); + }, [open, onClose]); + + return ( + + + + {t('inbounds')} + {t('users')} + + + + + + + + + + ) +} 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): ColumnDef[] => ([ + { + accessorKey: "name", + header: ({ column }) => , + }, + { + accessorKey: "users", + header: ({ column }) => , + cell: ({ row }) => `${row.original.user_ids.length}` + }, + { + accessorKey: "inbounds", + header: ({ column }) => , + cell: ({ row }) => `${row.original.inbound_ids.length}` + }, + { + id: "actions", + cell: ({ row }) => { + return ( + + + + ); + }, + } +]); 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 = ({ - onOpenChange, open, entity, onClose -}) => { - const { t } = useTranslation(); - - useEffect(() => { - if (!open) onClose(); - }, [open, onClose]); - - return ( - - - - - {t('page.services.settings.title')} - - - - - {t('inbounds')} - {t('users')} - - - - - - - - - - - ) -} 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[] => ([ - { - accessorKey: "name", - header: ({ column }) => , - }, - { - accessorKey: "users", - header: ({ column }) => , - cell: ({ row }) => `${row.original.user_ids.length}` - }, - { - accessorKey: "inbounds", - header: ({ column }) => , - cell: ({ row }) => `${row.original.inbound_ids.length}` - }, - { - id: "actions", - cell: ({ 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: , + 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 = ({ }, [open, onClose]); return ( - - - - {t("settings")} - - - - - - {t("user_info")} - - - {t("services")} - - - {t("subscription")} - - - - -
- - -
-
- - - - - - - -
- - ) : (
Not Found
)} - Skeleton={} - isFetching={isPending} - /> -
-
+ + + + + + {t("user_info")} + + + {t("services")} + + + {t("subscription")} + + + + +
+ + +
+
+ + + + + + + +
+ + ) : (
Not Found
)} + Skeleton={} + isFetching={isPending} + /> +
); }; 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 = ({ label, value }) => ( - - {label} - {value} - -); - 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[] => [ +export const columns = (actions: ColumnActions): ColumnDef[] => [ { accessorKey: "username", header: ({ column }) => ( @@ -79,38 +75,22 @@ export const columns = (actions: ColumnAction): ColumnDef[] => [ }, { id: "actions", - cell: ({ row }) => { - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - actions.onOpen(row.original); - } - }; - - return ( -
e.stopPropagation()} - onKeyDown={handleKeyDown} - tabIndex={0} - role="button" - > - - -
- ); - }, + cell: ({ row }) => ( + + + + + ) } ]; 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 ( + + + + + {t('admins')} + + + + + }> + + + + + + ) +}; + + +export const Route = createFileRoute('/_dashboard/admins')({ + component: () => , +}) 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 ( + + + + + + ) +} + +export const Route = createFileRoute('/_dashboard/admins/$adminId')({ + loader: async ({ params }) => { + queryClient.ensureQueryData(adminQueryOptions({ username: params.adminId })) + return { username: params.adminId }; + }, + errorComponent: () => ( + + Admin not found + + ), + 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) && ( + 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 && ( + 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 && ( + 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 ( + navigate({ to: "/admins" })} + /> + ); +} + +export const Route = createFileRoute("/_dashboard/admins/create")({ + component: AdminCreate, +});