gitscope
← marzneshin / Diff · 857e289 → 857e289
Changes 857e289..857e289
Patch commit
diff --git a/app/models/user.py b/app/models/user.py
index 28ad3f1..7a79847 100644
--- a/app/models/user.py
+++ b/app/models/user.py
@@ -2,18 +2,16 @@ import re
import secrets
from datetime import datetime
from enum import Enum
-from typing import List, Optional, Union, Annotated
+from typing import List, Optional, Union, Annotated, Literal
from pydantic import (
field_validator,
ConfigDict,
BaseModel,
Field,
- validator,
computed_field,
- ValidationInfo,
- constr,
StringConstraints,
+ model_validator,
)
from config import XRAY_SUBSCRIPTION_URL_PREFIX
@@ -34,9 +32,6 @@ class UserStatus(str, Enum):
on_hold = "on_hold"
-# from app.utils.share import generate_v2ray_links
-
-
class UserStatusModify(str, Enum):
active = "active"
disabled = "disabled"
@@ -60,7 +55,7 @@ class UserBase(BaseModel):
# proxies: Dict[ProxyTypes, ProxySettings] = {}
id: Optional[int] = None
username: Annotated[str, StringConstraints(to_lower=True)]
- expire: Optional[datetime] = Field(None, nullable=True)
+ expire: Union[datetime, None, Literal[0]] = Field(None)
key: str = Field(default_factory=lambda: secrets.token_hex(16))
data_limit: Optional[int] = Field(
ge=0, default=None, description="data_limit can be 0 or greater"
@@ -68,13 +63,12 @@ class UserBase(BaseModel):
data_limit_reset_strategy: UserDataLimitResetStrategy = (
UserDataLimitResetStrategy.no_reset
)
- # inbounds: Dict[ProxyTypes, List[str]] = {}
- note: Optional[str] = Field(None, nullable=True)
- sub_updated_at: Optional[datetime] = Field(None, nullable=True)
- sub_last_user_agent: Optional[str] = Field(None, nullable=True)
- online_at: Optional[datetime] = Field(None, nullable=True)
- on_hold_expire_duration: Optional[int] = Field(None, nullable=True)
- on_hold_timeout: Optional[Union[datetime, None]] = Field(None, nullable=True)
+ note: Optional[str] = Field(None)
+ sub_updated_at: Optional[datetime] = Field(None)
+ sub_last_user_agent: Optional[str] = Field(None)
+ online_at: Optional[datetime] = Field(None)
+ on_hold_expire_duration: Optional[int] = Field(None)
+ on_hold_timeout: Optional[datetime] = Field(None)
@field_validator("username")
@classmethod
@@ -92,14 +86,6 @@ class UserBase(BaseModel):
raise ValueError("User's note can be a maximum of 500 character")
return v
- @field_validator("on_hold_expire_duration", "on_hold_timeout", check_fields=False)
- @classmethod
- def validate_timeout(cls, v: Optional[int]):
- # Check if expire is 0 or None and timeout is not 0 or None
- if v in (0, None):
- return None
- return v
-
model_config = ConfigDict(from_attributes=True)
@@ -112,7 +98,7 @@ class User(UserBase):
class UserCreate(User):
- status: UserStatusCreate = Field(None, validate_default=True)
+ status: UserStatusCreate = Field(UserStatusCreate.active)
services: List[int] = Field([])
model_config = ConfigDict(
json_schema_extra={
@@ -130,27 +116,36 @@ class UserCreate(User):
}
)
- # TODO[pydantic]: We couldn't refactor the `validator`, please replace it by `field_validator` manually.
- # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information.
- # @validator("status", pre=True, always=True)
- # def validate_status(cls, value):
-
- # TODO[pydantic]: We couldn't refactor the `validator`, please replace it by `field_validator` manually.
- # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information.
- @field_validator("status", mode="before")
- def validate_status(cls, status: str, info: ValidationInfo) -> str:
- if not status or status not in UserStatusCreate.__members__:
- return UserStatusCreate.active # Set to the default if not valid
- on_hold_expire = info.data.get("on_hold_expire_duration")
- expire = info.data.get("expire")
- if status == UserStatusCreate.on_hold:
- if on_hold_expire == 0 or on_hold_expire is None:
+ @model_validator(mode="after")
+ def validate_expiry(self):
+ if self.status == UserStatusCreate.on_hold:
+ if not self.on_hold_expire_duration:
raise ValueError(
"User cannot be on hold without a valid on_hold_expire_duration."
)
- if expire:
+ if self.expire:
raise ValueError("User cannot be on hold with specified expire.")
- return status
+ elif self.status == UserStatusCreate.active:
+ if not self.expire:
+ raise ValueError("expire field should be set when status is active.")
+ if self.on_hold_expire_duration or self.on_hold_timeout:
+ raise ValueError(
+ "on hold parametrs set when user status isn't on_hold."
+ )
+ return self
+
+ @field_validator("expire")
+ @classmethod
+ def validate_note(cls, v: Union[datetime, None, Literal[0]]):
+ if isinstance(v, datetime) and (
+ v.tzinfo is not None
+ ):
+ raise ValueError(
+ "Expire date should be offset naive, and preferably in utc timezone."
+ )
+ if isinstance(v, datetime) and v < datetime.utcnow():
+ raise ValueError("Expire date should be in the future, not in the past.")
+ return v
class UserModify(User):
@@ -173,20 +168,24 @@ class UserModify(User):
}
)
- # TODO[pydantic]: We couldn't refactor the `validator`, please replace it by `field_validator` manually.
- # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information.
- @validator("status", pre=True, always=True)
- def validate_status(cls, status, values):
- on_hold_expire = values.get("on_hold_expire_duration")
- expire = values.get("expire")
- if status == UserStatusCreate.on_hold:
- if not on_hold_expire:
- raise ValueError(
- "User cannot be on hold without a valid on_hold_expire_duration."
- )
- if expire:
- raise ValueError("User cannot be on hold with specified expire.")
- return status
+ @model_validator(mode="after")
+ def validate_expiry(self):
+ if self.on_hold_expire_duration and self.expire:
+ raise ValueError(
+ "can't set both expire and on hold expire at the same time."
+ )
+ return self
+
+ @field_validator("expire")
+ @classmethod
+ def validate_note(cls, v: Union[datetime, None, Literal[0]]):
+ if isinstance(v, datetime) and v.tzinfo is not None:
+ raise ValueError(
+ "Expire date should be offset naive, and preferably in utc timezone."
+ )
+ if isinstance(v, datetime) and v < datetime.utcnow():
+ raise ValueError("Expire date should be in the future, not in the past.")
+ return v
class UserResponse(User):
@@ -211,7 +210,7 @@ class UserResponse(User):
@property
def subscription_url(self) -> str:
salt = secrets.token_hex(8)
- url_prefix = (XRAY_SUBSCRIPTION_URL_PREFIX).replace("*", salt)
+ url_prefix = XRAY_SUBSCRIPTION_URL_PREFIX.replace("*", salt)
return f"{url_prefix}/sub/{self.username}/{self.key}"
diff --git a/app/telegram/handlers/report.py b/app/telegram/handlers/report.py
index 2b12af2..b8a6b4b 100644
--- a/app/telegram/handlers/report.py
+++ b/app/telegram/handlers/report.py
@@ -1,4 +1,3 @@
-import datetime
import logging
from datetime import datetime
@@ -33,7 +32,7 @@ async def report_new_user(
user_id: int,
username: str,
by: str,
- expire_date: int,
+ expire_date: datetime,
data_limit: int,
services: list,
):
@@ -50,9 +49,7 @@ async def report_new_user(
username=escape_html(username),
data_limit=readable_size(data_limit) if data_limit else "Unlimited",
expire_date=(
- datetime.fromtimestamp(expire_date).strftime("%H:%M:%S %Y-%m-%d")
- if expire_date
- else "Never"
+ expire_date.strftime("%H:%M:%S %Y-%m-%d") if expire_date else "Never"
),
services="" if not services else ", ".join([s.name for s in services]),
)
@@ -66,7 +63,7 @@ async def report_new_user(
async def report_user_modification(
- username: str, expire_date: int, data_limit: int, services: list, by: str
+ username: str, expire_date: datetime, data_limit: int, services: list, by: str
):
text = """\
✏️ <b>#Modified</b>
@@ -82,9 +79,7 @@ async def report_user_modification(
username=escape_html(username),
data_limit=readable_size(data_limit) if data_limit else "Unlimited",
expire_date=(
- datetime.fromtimestamp(expire_date).strftime("%H:%M:%S %Y-%m-%d")
- if expire_date
- else "Never"
+ expire_date.strftime("%H:%M:%S %Y-%m-%d") if expire_date else "Never"
),
services=", ".join([s.name for s in services]),
)
diff --git a/app/utils/helpers.py b/app/utils/helpers.py
index a324e53..507c772 100644
--- a/app/utils/helpers.py
+++ b/app/utils/helpers.py
@@ -1,9 +1,9 @@
-from datetime import datetime as dt
+from datetime import datetime
def calculate_usage_percent(used_traffic: int, data_limit: int) -> float:
return (used_traffic * 100) / data_limit
-def calculate_expiration_days(expire: int) -> int:
- return (dt.fromtimestamp(expire) - dt.utcnow()).days
+def calculate_expiration_days(expire: datetime) -> int:
+ return (expire - datetime.utcnow()).days
diff --git a/dashboard/package.json b/dashboard/package.json
index bf8b003..87ae446 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -68,6 +68,7 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.1",
"react-i18next": "^14.1.0",
+ "react-qr-code": "^2.0.12",
"react-resizable-panels": "^2.0.13",
"react-use-websocket": "^4.8.1",
"remirror": "^2.0.39",
@@ -79,6 +80,7 @@
"zustand": "^4.5.2"
},
"devDependencies": {
+ "@hookform/devtools": "^4.3.1",
"@tanstack/router-cli": "^1.20.0",
"@tanstack/router-devtools": "^1.20.1",
"@tanstack/router-vite-plugin": "^1.20.2",
diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml
index 97a34a5..fdb1a4b 100644
--- a/dashboard/pnpm-lock.yaml
+++ b/dashboard/pnpm-lock.yaml
@@ -170,6 +170,9 @@ dependencies:
react-i18next:
specifier: ^14.1.0
version: 14.1.0(i18next@23.10.1)(react-dom@18.2.0)(react@18.2.0)
+ react-qr-code:
+ specifier: ^2.0.12
+ version: 2.0.12(react@18.2.0)
react-resizable-panels:
specifier: ^2.0.13
version: 2.0.13(react-dom@18.2.0)(react@18.2.0)
@@ -199,6 +202,9 @@ dependencies:
version: 4.5.2(@types/react@18.2.64)(react@18.2.0)
devDependencies:
+ '@hookform/devtools':
+ specifier: ^4.3.1
+ version: 4.3.1(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/router-cli':
specifier: ^1.20.0
version: 1.20.0
@@ -288,7 +294,6 @@ packages:
dependencies:
'@babel/highlight': 7.24.2
picocolors: 1.0.0
- dev: false
/@babel/compat-data@7.24.1:
resolution: {integrity: sha512-Pc65opHDliVpRHuKfzI+gSA4zcgr65O4cl64fFJIWEEh8JoHIHh0Oez1Eo8Arz8zq/JhgKodQaxEwUPRtZylVA==}
@@ -364,7 +369,6 @@ packages:
engines: {node: '>=6.9.0'}
dependencies:
'@babel/types': 7.24.0
- dev: false
/@babel/helper-module-transforms@7.23.3(@babel/core@7.24.3):
resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==}
@@ -402,12 +406,10 @@ packages:
/@babel/helper-string-parser@7.24.1:
resolution: {integrity: sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==}
engines: {node: '>=6.9.0'}
- dev: false
/@babel/helper-validator-identifier@7.22.20:
resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==}
engines: {node: '>=6.9.0'}
- dev: false
/@babel/helper-validator-option@7.23.5:
resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==}
@@ -433,7 +435,6 @@ packages:
chalk: 2.4.2
js-tokens: 4.0.0
picocolors: 1.0.0
- dev: false
/@babel/parser@7.24.1:
resolution: {integrity: sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==}
@@ -525,7 +526,6 @@ packages:
'@babel/helper-string-parser': 7.24.1
'@babel/helper-validator-identifier': 7.22.20
to-fast-properties: 2.0.0
- dev: false
/@codemirror/language@6.10.1:
resolution: {integrity: sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==}
@@ -550,6 +550,122 @@ packages:
w3c-keyname: 2.2.8
dev: false
+ /@emotion/babel-plugin@11.11.0:
+ resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==}
+ dependencies:
+ '@babel/helper-module-imports': 7.24.3
+ '@babel/runtime': 7.24.0
+ '@emotion/hash': 0.9.1
+ '@emotion/memoize': 0.8.1
+ '@emotion/serialize': 1.1.4
+ babel-plugin-macros: 3.1.0
+ convert-source-map: 1.9.0
+ escape-string-regexp: 4.0.0
+ find-root: 1.1.0
+ source-map: 0.5.7
+ stylis: 4.2.0
+ dev: true
+
+ /@emotion/cache@11.11.0:
+ resolution: {integrity: sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==}
+ dependencies:
+ '@emotion/memoize': 0.8.1
+ '@emotion/sheet': 1.2.2
+ '@emotion/utils': 1.2.1
+ '@emotion/weak-memoize': 0.3.1
+ stylis: 4.2.0
+ dev: true
+
+ /@emotion/hash@0.9.1:
+ resolution: {integrity: sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==}
+ dev: true
+
+ /@emotion/is-prop-valid@1.2.2:
+ resolution: {integrity: sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==}
+ dependencies:
+ '@emotion/memoize': 0.8.1
+ dev: true
+
+ /@emotion/memoize@0.8.1:
+ resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==}
+ dev: true
+
+ /@emotion/react@11.11.4(@types/react@18.2.64)(react@18.2.0):
+ resolution: {integrity: sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: '>=16.8.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.24.0
+ '@emotion/babel-plugin': 11.11.0
+ '@emotion/cache': 11.11.0
+ '@emotion/serialize': 1.1.4
+ '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0)
+ '@emotion/utils': 1.2.1
+ '@emotion/weak-memoize': 0.3.1
+ '@types/react': 18.2.64
+ hoist-non-react-statics: 3.3.2
+ react: 18.2.0
+ dev: true
+
+ /@emotion/serialize@1.1.4:
+ resolution: {integrity: sha512-RIN04MBT8g+FnDwgvIUi8czvr1LU1alUMI05LekWB5DGyTm8cCBMCRpq3GqaiyEDRptEXOyXnvZ58GZYu4kBxQ==}
+ dependencies:
+ '@emotion/hash': 0.9.1
+ '@emotion/memoize': 0.8.1
+ '@emotion/unitless': 0.8.1
+ '@emotion/utils': 1.2.1
+ csstype: 3.1.3
+ dev: true
+
+ /@emotion/sheet@1.2.2:
+ resolution: {integrity: sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==}
+ dev: true
+
+ /@emotion/styled@11.11.5(@emotion/react@11.11.4)(@types/react@18.2.64)(react@18.2.0):
+ resolution: {integrity: sha512-/ZjjnaNKvuMPxcIiUkf/9SHoG4Q196DRl1w82hQ3WCsjo1IUR8uaGWrC6a87CrYAW0Kb/pK7hk8BnLgLRi9KoQ==}
+ peerDependencies:
+ '@emotion/react': ^11.0.0-rc.0
+ '@types/react': '*'
+ react: '>=16.8.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.24.0
+ '@emotion/babel-plugin': 11.11.0
+ '@emotion/is-prop-valid': 1.2.2
+ '@emotion/react': 11.11.4(@types/react@18.2.64)(react@18.2.0)
+ '@emotion/serialize': 1.1.4
+ '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0)
+ '@emotion/utils': 1.2.1
+ '@types/react': 18.2.64
+ react: 18.2.0
+ dev: true
+
+ /@emotion/unitless@0.8.1:
+ resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==}
+ dev: true
+
+ /@emotion/use-insertion-effect-with-fallbacks@1.0.1(react@18.2.0):
+ resolution: {integrity: sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==}
+ peerDependencies:
+ react: '>=16.8.0'
+ dependencies:
+ react: 18.2.0
+ dev: true
+
+ /@emotion/utils@1.2.1:
+ resolution: {integrity: sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==}
+ dev: true
+
+ /@emotion/weak-memoize@0.3.1:
+ resolution: {integrity: sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==}
+ dev: true
+
/@esbuild/aix-ppc64@0.19.12:
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
engines: {node: '>=12'}
@@ -864,6 +980,26 @@ packages:
react: 18.2.0
dev: false
+ /@hookform/devtools@4.3.1(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-CrWxEoHQZaOXJZVQ8KBgOuAa8p2LI8M0DAN5GTRTmdCieRwFVjVDEmuTAVazWVRRkpEQSgSt3KYp7VmmqXdEnw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18
+ react-dom: ^16.8.0 || ^17 || ^18
+ dependencies:
+ '@emotion/react': 11.11.4(@types/react@18.2.64)(react@18.2.0)
+ '@emotion/styled': 11.11.5(@emotion/react@11.11.4)(@types/react@18.2.64)(react@18.2.0)
+ '@types/lodash': 4.17.0
+ little-state-machine: 4.8.0(react@18.2.0)
+ lodash: 4.17.21
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ react-simple-animate: 3.5.2(react-dom@18.2.0)
+ use-deep-compare-effect: 1.8.1(react@18.2.0)
+ uuid: 8.3.2
+ transitivePeerDependencies:
+ - '@types/react'
+ dev: true
+
/@hookform/resolvers@3.3.4(react-hook-form@7.51.1):
resolution: {integrity: sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==}
peerDependencies:
@@ -5828,6 +5964,10 @@ packages:
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
dev: true
+ /@types/lodash@4.17.0:
+ resolution: {integrity: sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==}
+ dev: true
+
/@types/marked@4.3.2:
resolution: {integrity: sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==}
dev: false
@@ -5849,6 +5989,10 @@ packages:
resolution: {integrity: sha512-5PjwB0uP2XDp3nt5u5NJAG2DORHIRClPzWT/TTZhJ2Ekwe8M5bA9tvPdi9NO/n2uvu2/ictat8kgqvLfcIE1SA==}
dev: false
+ /@types/parse-json@4.0.2:
+ resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
+ dev: true
+
/@types/prismjs@1.26.3:
resolution: {integrity: sha512-A0D0aTXvjlqJ5ZILMz3rNfDBOx9hHxLZYv2by47Sm/pqW35zzjusrZTryatjN/Rf8Us2gZrJD+KeHbUSTux1Cw==}
dev: false
@@ -6151,7 +6295,6 @@ packages:
engines: {node: '>=4'}
dependencies:
color-convert: 1.9.3
- dev: false
/ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
@@ -6228,6 +6371,15 @@ packages:
object.omit: 3.0.0
dev: false
+ /babel-plugin-macros@3.1.0:
+ resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
+ engines: {node: '>=10', npm: '>=6'}
+ dependencies:
+ '@babel/runtime': 7.24.0
+ cosmiconfig: 7.1.0
+ resolve: 1.22.8
+ dev: true
+
/balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -6305,7 +6457,6 @@ packages:
ansi-styles: 3.2.1
escape-string-regexp: 1.0.5
supports-color: 5.5.0
- dev: false
/chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -6384,7 +6535,6 @@ packages:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
dependencies:
color-name: 1.1.3
- dev: false
/color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
@@ -6394,7 +6544,6 @@ packages:
/color-name@1.1.3:
resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
- dev: false
/color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
@@ -6434,6 +6583,10 @@ packages:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true
+ /convert-source-map@1.9.0:
+ resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
+ dev: true
+
/convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
dev: false
@@ -6444,6 +6597,17 @@ packages:
toggle-selection: 1.0.6
dev: false
+ /cosmiconfig@7.1.0:
+ resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
+ engines: {node: '>=10'}
+ dependencies:
+ '@types/parse-json': 4.0.2
+ import-fresh: 3.3.0
+ parse-json: 5.2.0
+ path-type: 4.0.0
+ yaml: 1.10.2
+ dev: true
+
/cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
dependencies:
@@ -6525,6 +6689,11 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
+ /dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+ dev: true
+
/destr@2.0.3:
resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==}
dev: false
@@ -6607,6 +6776,12 @@ packages:
resolution: {integrity: sha512-1GkKJPXP6tVkYJHOBSJHoGOr/6uaDxZ9xJ6H7m6PfdGXTmQgbALHLWaVRY4Gi/qf5x/gT/NUXLPuSHYLqtLtrQ==}
dev: false
+ /error-ex@1.3.2:
+ resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
+ dependencies:
+ is-arrayish: 0.2.1
+ dev: true
+
/esbuild@0.19.12:
resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==}
engines: {node: '>=12'}
@@ -6645,7 +6820,6 @@ packages:
/escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
- dev: false
/escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
@@ -6829,6 +7003,10 @@ packages:
dependencies:
to-regex-range: 5.0.1
+ /find-root@1.1.0:
+ resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
+ dev: true
+
/find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -7008,7 +7186,6 @@ packages:
/has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
- dev: false
/has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
@@ -7035,6 +7212,12 @@ packages:
space-separated-tokens: 1.1.5
dev: false
+ /hoist-non-react-statics@3.3.2:
+ resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
+ dependencies:
+ react-is: 16.13.1
+ dev: true
+
/html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
dependencies:
@@ -7131,6 +7314,10 @@ packages:
is-decimal: 1.0.4
dev: false
+ /is-arrayish@0.2.1:
+ resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
+ dev: true
+
/is-arrayish@0.3.2:
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
dev: false
@@ -7261,6 +7448,10 @@ packages:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
dev: true
+ /json-parse-even-better-errors@2.3.1:
+ resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
+ dev: true
+
/json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
dev: true
@@ -7312,6 +7503,14 @@ packages:
/lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+ /little-state-machine@4.8.0(react@18.2.0):
+ resolution: {integrity: sha512-xfi5+iDxTLhu0hbnNubUs+qoQQqxhtEZeObP5ELjUlHnl74bbasY7mOonsGQrAouyrbag3ebNLSse5xX1T7buQ==}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18
+ dependencies:
+ react: 18.2.0
+ dev: true
+
/local-pkg@0.5.0:
resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==}
engines: {node: '>=14'}
@@ -7359,7 +7558,6 @@ packages:
/lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
- dev: false
/loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
@@ -7674,6 +7872,16 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
+ /parse-json@5.2.0:
+ resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
+ engines: {node: '>=8'}
+ dependencies:
+ '@babel/code-frame': 7.24.2
+ error-ex: 1.3.2
+ json-parse-even-better-errors: 2.3.1
+ lines-and-columns: 1.2.4
+ dev: true
+
/path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -8022,6 +8230,10 @@ packages:
engines: {node: '>=6'}
dev: true
+ /qr.js@0.0.0:
+ resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
+ dev: false
+
/querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
dev: false
@@ -8089,12 +8301,25 @@ packages:
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
- dev: false
/react-is@18.2.0:
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
dev: true
+ /react-qr-code@2.0.12(react@18.2.0):
+ resolution: {integrity: sha512-k+pzP5CKLEGBRwZsDPp98/CAJeXlsYRHM2iZn1Sd5Th/HnKhIZCSg27PXO58zk8z02RaEryg+60xa4vyywMJwg==}
+ peerDependencies:
+ react: ^16.x || ^17.x || ^18.x
+ react-native-svg: '*'
+ peerDependenciesMeta:
+ react-native-svg:
+ optional: true
+ dependencies:
+ prop-types: 15.8.1
+ qr.js: 0.0.0
+ react: 18.2.0
+ dev: false
+
/react-remove-scroll-bar@2.3.6(@types/react@18.2.64)(react@18.2.0):
resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==}
engines: {node: '>=10'}
@@ -8159,6 +8384,14 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
+ /react-simple-animate@3.5.2(react-dom@18.2.0):
+ resolution: {integrity: sha512-xLE65euP920QMTOmv5haPlml+hmOPDkbIr5WeF7ADIXWBYt5kW/vwpNfWg8EKMab8aeDxIZ6QjffVh8v2dUyhg==}
+ peerDependencies:
+ react-dom: ^16.8.0 || ^17 || ^18
+ dependencies:
+ react-dom: 18.2.0(react@18.2.0)
+ dev: true
+
/react-style-singleton@2.2.1(@types/react@18.2.64)(react@18.2.0):
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
engines: {node: '>=10'}
@@ -8474,6 +8707,11 @@ packages:
resolution: {integrity: sha512-9vC2SfsJzlej6MAaMPLu8HiBSHGdRAJ9hVFYN1ibZoNkeanmDmLUcIrj6G9DGL7XMJ54AKg/G75akXl1/izTOw==}
engines: {node: '>=0.10.0'}
+ /source-map@0.5.7:
+ resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
/space-separated-tokens@1.1.5:
resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==}
dev: false
@@ -8538,6 +8776,10 @@ packages:
resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==}
dev: false
+ /stylis@4.2.0:
+ resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
+ dev: true
+
/sucrase@3.35.0:
resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -8556,7 +8798,6 @@ packages:
engines: {node: '>=4'}
dependencies:
has-flag: 3.0.0
- dev: false
/supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
@@ -8691,7 +8932,6 @@ packages:
/to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'}
- dev: false
/to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
@@ -8833,6 +9073,17 @@ packages:
react: 18.2.0
dev: false
+ /use-deep-compare-effect@1.8.1(react@18.2.0):
+ resolution: {integrity: sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==}
+ engines: {node: '>=10', npm: '>=6'}
+ peerDependencies:
+ react: '>=16.13'
+ dependencies:
+ '@babel/runtime': 7.24.0
+ dequal: 2.0.3
+ react: 18.2.0
+ dev: true
+
/use-isomorphic-layout-effect@1.1.2(@types/react@18.2.64)(react@18.2.0):
resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
peerDependencies:
@@ -8886,6 +9137,11 @@ packages:
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ /uuid@8.3.2:
+ resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ hasBin: true
+ dev: true
+
/vaul@0.9.0(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-bZSySGbAHiTXmZychprnX/dE0EsSige88xtyyL3/MCRbrFotRPQZo7UdydGXZWw+CKbNOw5Ow8gwAo93/nB/Cg==}
peerDependencies:
@@ -9133,6 +9389,11 @@ packages:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
dev: true
+ /yaml@1.10.2:
+ resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
+ engines: {node: '>= 6'}
+ dev: true
+
/yaml@2.4.1:
resolution: {integrity: sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==}
engines: {node: '>= 14'}
diff --git a/dashboard/public/locales/en.json b/dashboard/public/locales/en.json
index b29c35a..30ca1c6 100644
--- a/dashboard/public/locales/en.json
+++ b/dashboard/public/locales/en.json
@@ -28,6 +28,10 @@
"host": "Host",
"alpn": "ALPN",
"security": "Security",
+ "user_info": "Users Info",
+ "subscription": "Subscription",
+ "subscription_link": "Subscription Link",
+ "fingerprint": "Fingerprint",
"page": {
"settings": {
"certificate": {
@@ -96,7 +100,24 @@
"data_limit": "Data Limit",
"used_traffic": "Used Traffic",
"data_limit_reset_strategy": "Data Limit Reset Strategy",
+ "online_at": "Online At",
"expire_date": "Expire Date",
+ "determined_expire": "Determined",
+ "onhold_expire": "On Hold",
+ "on_hold_expire_duration": "On Hold",
+ "on_hold_timeout": "On Hold Timeout",
+ "settings": {
+ "subscription_link": {
+ "copied": "Subscription Link Copied",
+ "copy": "Copy Subscription Link",
+ "error": "Unable to copy Subscription Link"
+ }
+ },
+ "services-alert": {
+ "title": "No Service were found",
+ "desc": "Make sure you create services to enable your users with inbounds to connect with.",
+ "click": "Click Here"
+ },
"dialogs": {
"edition": {
"title": "Users Edition"
diff --git a/dashboard/src/components/copy-button/index.tsx b/dashboard/src/components/copy-button/index.tsx
new file mode 100644
index 0000000..0f11897
--- /dev/null
+++ b/dashboard/src/components/copy-button/index.tsx
@@ -0,0 +1,60 @@
+import { Button } from '@marzneshin/components';
+import CopyToClipboard from 'react-copy-to-clipboard';
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { CopyCheck, CopyX, ClipboardCopy, LucideIcon } from 'lucide-react';
+import { toast } from 'sonner';
+import { cn } from '@marzneshin/utils';
+
+interface CopyToClipboardButtonProps {
+ text: string;
+ disabled?: boolean;
+ successMessage: string;
+ copyIcon?: LucideIcon;
+ copyLabel: string;
+ errorLabel: string;
+ className?: string;
+}
+
+export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({
+ text,
+ disabled = false,
+ successMessage,
+ copyIcon,
+ copyLabel,
+ errorLabel, className
+}) => {
+ const { t } = useTranslation();
+ const [copied, setCopied] = useState(false);
+ const [healthy, setHealthy] = useState(false);
+
+ useEffect(() => {
+ if (copied) {
+ toast.success(t(successMessage));
+ setTimeout(() => {
+ setCopied(false);
+ }, 1000);
+ }
+ }, [copied, successMessage, t]);
+
+ useEffect(() => {
+ setHealthy(text !== '');
+ }, [text]);
+
+ const handleClick = () => {
+ if (!disabled) {
+ setCopied(true);
+ }
+ };
+
+ const Icon = copied ? CopyCheck : (healthy ? (copyIcon ? copyIcon : ClipboardCopy) : CopyX)
+
+ return (
+ <CopyToClipboard text={text}>
+ <Button className={cn(className, "p-2 flex flex-row items-center gap-2")} disabled={disabled || !healthy} onClick={handleClick}>
+ <Icon />
+ {t(copied ? successMessage : (healthy ? copyLabel : errorLabel))}
+ </Button>
+ </CopyToClipboard>
+ );
+};
diff --git a/dashboard/src/components/entity-table/index.tsx b/dashboard/src/components/entity-table/index.tsx
index f59ce5c..a01d594 100644
--- a/dashboard/src/components/entity-table/index.tsx
+++ b/dashboard/src/components/entity-table/index.tsx
@@ -23,7 +23,7 @@ interface EntityTableProps<T> {
useQuery: () => { data: T[] };
MutationDialog: FC<MutationDialogProps<T>>;
DeleteConfirmationDialog: FC<DeleteConfirmationDialogProps<T>>;
- SettingsDialog: FC<SettingsDialogProps<T>>;
+ SettingsDialog: FC<SettingsDialogProps<T | any>>;
columns: any;
filteredColumn: string;
}
diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts
index 5f3b6e2..ae8b036 100644
--- a/dashboard/src/components/index.ts
+++ b/dashboard/src/components/index.ts
@@ -6,3 +6,4 @@ export * from './delete-confirmation-dialog'
export * from './nav-dir'
export * from './header'
export * from './entity-table'
+export * from './copy-button'
diff --git a/dashboard/src/components/ui/badge.tsx b/dashboard/src/components/ui/badge.tsx
index a62d2b3..04ed41c 100644
--- a/dashboard/src/components/ui/badge.tsx
+++ b/dashboard/src/components/ui/badge.tsx
@@ -14,6 +14,14 @@ const badgeVariants = cva(
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ royal:
+ "border-transparent bg-indigo-300 text-indigo-700 hover:bg-indigo/80",
+ positive:
+ "border-transparent bg-emerald-300 text-emerald-700 hover:bg-emerald/80",
+ disabled:
+ "border-transparent bg-gray-400 text-gray-800 hover:bg-gray/80",
+ warning:
+ "border-transparent bg-amber-300 text-amber-700 hover:bg-amber/80",
outline: "text-foreground",
},
},
@@ -25,7 +33,7 @@ const badgeVariants = cva(
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
- VariantProps<typeof badgeVariants> {}
+ VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
@@ -33,4 +41,14 @@ function Badge({ className, variant, ...props }: BadgeProps) {
)
}
-export { Badge, badgeVariants }
+type BadgeVariantKeys =
+ | 'default'
+ | 'secondary'
+ | 'destructive'
+ | 'royal'
+ | 'positive'
+ | 'disabled'
+ | 'warning'
+ | 'outline';
+
+export { Badge, badgeVariants, type BadgeVariantKeys }
diff --git a/dashboard/src/features/nodes/dialogs/mutation.dialog.tsx b/dashboard/src/features/nodes/dialogs/mutation.dialog.tsx
index 5d619f6..4466d43 100644
--- a/dashboard/src/features/nodes/dialogs/mutation.dialog.tsx
+++ b/dashboard/src/features/nodes/dialogs/mutation.dialog.tsx
@@ -1,4 +1,4 @@
-import { FC, useEffect } from "react";
+import { FC } from "react";
import {
DialogTitle,
DialogContent,
@@ -14,10 +14,9 @@ import {
Checkbox,
Button
} from '@marzneshin/components';
-import { useForm } from 'react-hook-form';
-import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'react-i18next';
import { NodeType, NodeSchema, useNodesCreationMutation, useNodesUpdateMutation } from "..";
+import { useMutationDialog } from "@marzneshin/hooks";
interface MutationDialogProps {
entity: NodeType | null;
@@ -25,7 +24,7 @@ interface MutationDialogProps {
onOpenChange: (state: boolean) => void;
}
-const getDefaultValues = (): NodeType => ({
+const getDefaultValue = (): NodeType => ({
name: '',
address: '',
status: 'none',
@@ -39,28 +38,17 @@ export const MutationDialog: FC<MutationDialogProps> = ({
open,
onOpenChange,
}) => {
- const form = useForm({
- defaultValues: entity ? entity : getDefaultValues(),
- resolver: zodResolver(NodeSchema)
- });
const updateMutation = useNodesUpdateMutation();
const createMutation = useNodesCreationMutation();
const { t } = useTranslation();
-
- const submit = (values: NodeType) => {
- if (entity) {
- updateMutation.mutate(values);
- } else {
- createMutation.mutate(values);
- }
- onOpenChange(false);
- };
-
- useEffect(() => {
- if (entity) form.reset(entity);
- else form.reset(getDefaultValues());
- }, [entity, form]);
-
+ const { form, handleSubmit } = useMutationDialog({
+ onOpenChange,
+ entity,
+ schema: NodeSchema,
+ createMutation,
+ updateMutation,
+ getDefaultValue
+ })
return (
<Dialog open={open} onOpenChange={onOpenChange} defaultOpen={true}>
<DialogContent>
@@ -72,7 +60,7 @@ export const MutationDialog: FC<MutationDialogProps> = ({
</DialogTitle>
</DialogHeader>
<Form {...form}>
- <form onSubmit={form.handleSubmit(submit)}>
+ <form onSubmit={handleSubmit}>
<FormField
control={form.control}
name="name"
diff --git a/dashboard/src/features/services/dialogs/mutation.dialog.tsx b/dashboard/src/features/services/dialogs/mutation.dialog.tsx
index ccbc48f..5cfe9ea 100644
--- a/dashboard/src/features/services/dialogs/mutation.dialog.tsx
+++ b/dashboard/src/features/services/dialogs/mutation.dialog.tsx
@@ -16,19 +16,18 @@ import {
Checkbox,
Badge
} from '@marzneshin/components'
-import { FC, useEffect } from 'react'
+import { FC } from 'react'
import {
ServiceType,
useServicesCreationMutation,
useServicesUpdateMutation,
} from '@marzneshin/features/services'
import { useInboundsQuery } from '@marzneshin/features/inbounds'
-import { useForm } from 'react-hook-form'
-import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { cn } from '@marzneshin/utils'
import { Box, GlobeLock } from 'lucide-react'
+import { useMutationDialog } from '@marzneshin/hooks'
export const ServiceSchema = z.object({
id: z.number().optional(),
@@ -55,28 +54,15 @@ export const MutationDialog: FC<MutationDialogProps> = ({ entity, open, onOpenCh
const updateMutation = useServicesUpdateMutation();
const createMutation = useServicesCreationMutation();
const { data: inbounds } = useInboundsQuery()
- const form = useForm({
- defaultValues: isEditing ? entity : getDefaultValues(),
- resolver: zodResolver(ServiceSchema)
+ const { form, handleSubmit } = useMutationDialog({
+ entity,
+ updateMutation,
+ createMutation,
+ getDefaultValue: getDefaultValues,
+ schema: ServiceSchema,
+ onOpenChange,
})
- const { t } = useTranslation();
-
- const submit = (values: ServiceCreateType | ServiceType) => {
- if (isEditing) {
- updateMutation.mutate(values as ServiceType)
- } else {
- createMutation.mutate(values)
- }
- onOpenChange(false);
- }
-
- useEffect(() => {
- if (isEditing)
- form.reset(entity);
- else
- form.reset(getDefaultValues())
- }, [entity, form, isEditing])
-
+ const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange} defaultOpen={true}>
<DialogContent>
@@ -88,7 +74,7 @@ export const MutationDialog: FC<MutationDialogProps> = ({ entity, open, onOpenCh
</DialogTitle>
</DialogHeader>
<Form {...form} >
- <form onSubmit={form.handleSubmit(submit)} >
+ <form onSubmit={handleSubmit} >
<FormField
control={form.control}
name="name"
@@ -125,7 +111,7 @@ export const MutationDialog: FC<MutationDialogProps> = ({ entity, open, onOpenCh
? field.onChange([...field.value, inbound.id])
: field.onChange(
field.value?.filter(
- (value) => value !== inbound.id
+ (value: number) => value !== inbound.id
)
)
}}
diff --git a/dashboard/src/features/services/services/create-service.mutate.ts b/dashboard/src/features/services/services/create-service.mutate.ts
index 060cde9..33ea320 100644
--- a/dashboard/src/features/services/services/create-service.mutate.ts
+++ b/dashboard/src/features/services/services/create-service.mutate.ts
@@ -1,16 +1,16 @@
-import { ServiceCreateType, ServicesQueryFetchKey } from "@marzneshin/features/services";
+import { ServiceMutationType, ServicesQueryFetchKey } from "@marzneshin/features/services";
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 fetchCreateService(service: ServiceCreateType): Promise<ServiceCreateType> {
+export async function fetchCreateService(service: ServiceMutationType): Promise<ServiceMutationType> {
return fetch('/services', { method: 'post', body: service }).then((service) => {
return service;
});
}
-const handleError = (error: Error, value: ServiceCreateType) => {
+const handleError = (error: Error, value: ServiceMutationType) => {
toast.error(
i18n.t('events.create.error', { name: value.name }),
{
@@ -18,7 +18,7 @@ const handleError = (error: Error, value: ServiceCreateType) => {
})
}
-const handleSuccess = (value: ServiceCreateType) => {
+const handleSuccess = (value: ServiceMutationType) => {
toast.success(
i18n.t('events.create.success.title', { name: value.name }),
{
diff --git a/dashboard/src/features/services/services/update-service.mutate.ts b/dashboard/src/features/services/services/update-service.mutate.ts
index 5e4ffd3..f788e4c 100644
--- a/dashboard/src/features/services/services/update-service.mutate.ts
+++ b/dashboard/src/features/services/services/update-service.mutate.ts
@@ -3,15 +3,16 @@ import { fetch, queryClient } from "@marzneshin/utils";
import { toast } from "sonner";
import i18n from "@marzneshin/features/i18n";
import { ServicesQueryFetchKey } from "./services.query";
-import { ServiceType } from "../types";
+import { ServiceMutationType } from "../types";
-export async function updateService(node: ServiceType): Promise<ServiceType> {
+
+export async function updateService(node: ServiceMutationType): Promise<ServiceMutationType> {
return fetch(`/services/${node.id}`, { method: 'put', body: node }).then((service) => {
return service;
});
}
-const handleError = (error: Error, value: ServiceType) => {
+const handleError = (error: Error, value: ServiceMutationType) => {
toast.error(
i18n.t('events.update.error', { name: value.name }),
{
@@ -19,7 +20,7 @@ const handleError = (error: Error, value: ServiceType) => {
})
}
-const handleSuccess = (value: ServiceType) => {
+const handleSuccess = (value: ServiceMutationType) => {
toast.success(
i18n.t('events.update.success.title', { name: value.name }),
{
diff --git a/dashboard/src/features/services/tables/users/columns.tsx b/dashboard/src/features/services/tables/users/columns.tsx
index 9a633c3..4c6c88f 100644
--- a/dashboard/src/features/services/tables/users/columns.tsx
+++ b/dashboard/src/features/services/tables/users/columns.tsx
@@ -1,7 +1,11 @@
import { ColumnDef } from "@tanstack/react-table"
import { DataTableColumnHeader } from "@marzneshin/components/data-table/column-header"
import i18n from "@marzneshin/features/i18n"
-import { UserType/* , UserStatusBadge, statusColors */ } from "@marzneshin/features/users"
+import {
+ UserType,
+ UsersStatus,
+ UsersStatusBadge
+} from "@marzneshin/features/users"
import { CircularProgress } from "@nextui-org/progress";
import { format } from "date-fns";
import { Badge } from "@marzneshin/components";
@@ -11,11 +15,11 @@ export const columns: ColumnDef<UserType>[] = [
accessorKey: "username",
header: ({ column }) => <DataTableColumnHeader title={i18n.t('username')} column={column} />,
},
- // {
- // accessorKey: 'status',
- // header: ({ column }) => <DataTableColumnHeader title={i18n.t('status')} column={column} />,
- // cell: ({ row }) => <UserStatusBadge status={statusColors[row.original.status ? row.original.status : 'error']} />,
- // },
+ {
+ accessorKey: 'status',
+ header: ({ column }) => <DataTableColumnHeader title={i18n.t('status')} column={column} />,
+ cell: ({ row }) => <UsersStatusBadge status={UsersStatus[row.original.status ? row.original.status : 'error']} />,
+ },
{
accessorKey: "used_traffic",
header: ({ column }) => <DataTableColumnHeader title={i18n.t('page.users.used_traffic')} column={column} />,
@@ -44,6 +48,6 @@ export const columns: ColumnDef<UserType>[] = [
{
accessorKey: 'expire',
header: ({ column }) => <DataTableColumnHeader title={i18n.t('expire')} column={column} />,
- cell: ({ row }) => format(row.original.expire, "PP")
+ cell: ({ row }) => format(row.original.expire ? row.original.expire : "", "PP")
}
];
diff --git a/dashboard/src/features/services/types/index.ts b/dashboard/src/features/services/types/index.ts
index 547eac7..30eb807 100644
--- a/dashboard/src/features/services/types/index.ts
+++ b/dashboard/src/features/services/types/index.ts
@@ -1,2 +1,2 @@
export * from './services.type'
-export * from './services-create.type'
+export * from './services-mutation.type'
diff --git a/dashboard/src/features/services/types/services-create.type.ts b/dashboard/src/features/services/types/services-create.type.ts
deleted file mode 100644
index d8a4cde..0000000
--- a/dashboard/src/features/services/types/services-create.type.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { z } from "zod";
-
-export const ServiceCreateSchema = z.object({
- inbounds: z.array(z.number()),
- name: z.string().trim().min(1)
-})
-
-export type ServiceCreateType = z.infer<typeof ServiceCreateSchema>
diff --git a/dashboard/src/features/services/types/services-mutation.type.ts b/dashboard/src/features/services/types/services-mutation.type.ts
new file mode 100644
index 0000000..d6decb1
--- /dev/null
+++ b/dashboard/src/features/services/types/services-mutation.type.ts
@@ -0,0 +1,9 @@
+import { z } from "zod";
+
+export const ServiceCreateSchema = z.object({
+ inbounds: z.array(z.number()),
+ name: z.string().trim().min(1),
+ id: z.number().optional()
+})
+
+export type ServiceMutationType = z.infer<typeof ServiceCreateSchema>
diff --git a/dashboard/src/features/settings/certificate-button/index.tsx b/dashboard/src/features/settings/certificate-button/index.tsx
index 31e5389..ee83191 100644
--- a/dashboard/src/features/settings/certificate-button/index.tsx
+++ b/dashboard/src/features/settings/certificate-button/index.tsx
@@ -1,77 +1,19 @@
-
-import { Button } from '@marzneshin/components'
-import CopyToClipboard from 'react-copy-to-clipboard'
-import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next';
import { useCertificateQuery } from '../services';
-import { ClipboardCopy, CopyCheck, CopyX, LucideIcon } from 'lucide-react';
-import { toast } from 'sonner';
-
-interface ICopyAt<T> {
- copied: T
- copy: T
- noCopy: T
- healthyState: boolean
- copiedState: boolean
-}
-
-function copyAt<T>(
- { copied, copy, noCopy, healthyState, copiedState }: ICopyAt<T>
-): T {
- if (healthyState) {
- return copiedState ? copied : copy
- }
- else {
- return noCopy
- }
-}
+import { CopyToClipboardButton } from '@marzneshin/components';
+import { ClipboardCopy } from 'lucide-react';
export const CertificateButton = () => {
const { t } = useTranslation();
- const [copied, setCopied] = useState([-1, false]);
- const [healthy, setHealthy] = useState<boolean>(false);
- const [Icon, setIcon] = useState<LucideIcon>(ClipboardCopy);
- const [title, setTitle] = useState<string>('page.settings.certificate.copy');
- const { data: certificate } = useCertificateQuery()
-
- useEffect(() => {
- if (copied[1]) {
- toast.success(t('page.settings.certificate.copied'));
- setTimeout(() => {
- setCopied([-1, false]);
- }, 1000);
- }
- }, [copied, t]);
-
- useEffect(() => {
- setHealthy(certificate !== '')
- }, [certificate])
-
- useEffect(() => {
- setIcon(copyAt({
- copied: CopyCheck,
- copy: ClipboardCopy,
- noCopy: CopyX,
- healthyState: healthy,
- copiedState: copied[1] as boolean
- }))
-
- setTitle(copyAt({
- copied: 'page.settings.certificate.copied',
- copy: 'page.settings.certificate.copy',
- noCopy: 'page.settings.certificate.error',
- healthyState: healthy,
- copiedState: copied[1] as boolean
- }));
- }, [copied, healthy]);
-
+ const { data: certificate } = useCertificateQuery();
return (
- <CopyToClipboard text={certificate}>
- <Button className="p-2" disabled={!healthy} onClick={() => setCopied([0, true])}>
- <Icon />
- {t(title)}
- </Button>
- </CopyToClipboard>
- )
-}
+ <CopyToClipboardButton
+ text={certificate}
+ successMessage={t('page.settings.certificate.copied')}
+ copyIcon={ClipboardCopy}
+ copyLabel={t('page.settings.certificate.copy')}
+ errorLabel={t('page.settings.certificate.error')}
+ />
+ );
+};
diff --git a/dashboard/src/features/users/components/date-field.tsx b/dashboard/src/features/users/components/date-field.tsx
new file mode 100644
index 0000000..26da423
--- /dev/null
+++ b/dashboard/src/features/users/components/date-field.tsx
@@ -0,0 +1,79 @@
+import { FC, useEffect, useState } from 'react';
+import { format } from 'date-fns';
+import { CalendarIcon } from '@radix-ui/react-icons';
+import { cn } from '@marzneshin/utils';
+import { useFormContext, FieldValues } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+import { Popover, PopoverTrigger, FormControl, Button, Calendar, PopoverContent, FormLabel, FormItem, FormField, FormMessage } from '@marzneshin/components';
+
+interface DateFieldProps {
+ name: keyof FieldValues;
+ label: string;
+}
+
+function parseISODate(isoDateString: string | undefined): Date | undefined {
+ if (!isoDateString) return undefined;
+ const date = new Date(isoDateString);
+ return isNaN(date.getTime()) ? undefined : date;
+}
+
+export const DateField: FC<DateFieldProps> = ({ name, label }) => {
+ const { t } = useTranslation();
+ const form = useFormContext();
+ const [selectedDate, setSelectedDate] = useState<Date | undefined>(parseISODate(form.getValues(name)));
+
+ useEffect(() => {
+ const newValue = selectedDate?.toISOString().slice(0, -5);
+ const prevValue = form.getValues(name);
+ if (newValue !== prevValue) {
+ form.setValue(name, newValue, { shouldValidate: true, shouldTouch: true });
+ }
+ }, [form, name, selectedDate]);
+
+ return (
+ <FormField
+ control={form.control}
+ name={name}
+ render={({ field }) => (
+ <FormItem className="flex flex-col mt-2 w-full">
+ <FormLabel>{t(label)}</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant={"outline"}
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP")
+ ) : (
+ <span>Pick a date</span>
+ )}
+ <CalendarIcon className="ml-auto w-4 h-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="p-0 w-auto" align="start">
+ <Calendar
+ mode="single"
+ {...field}
+ selected={selectedDate}
+ onSelect={(date) => {
+ setSelectedDate(date);
+ }}
+ disabled={(date) =>
+ date < new Date()
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ );
+};
diff --git a/dashboard/src/features/users/components/index.ts b/dashboard/src/features/users/components/index.ts
new file mode 100644
index 0000000..f029635
--- /dev/null
+++ b/dashboard/src/features/users/components/index.ts
@@ -0,0 +1,3 @@
+export * from './users-status'
+export * from './services-card'
+export * from './date-field'
diff --git a/dashboard/src/features/users/components/services-card.tsx b/dashboard/src/features/users/components/services-card.tsx
new file mode 100644
index 0000000..c0d9e5b
--- /dev/null
+++ b/dashboard/src/features/users/components/services-card.tsx
@@ -0,0 +1,33 @@
+
+import { Label, Separator } from '@marzneshin/components';
+import { ServiceType } from '@marzneshin/features/services';
+import { LucideIcon, ServerIcon, UserIcon } from 'lucide-react';
+import { FC } from 'react'
+
+interface ServiceCardProps {
+ service: ServiceType
+}
+
+const ServiceProperty = ({ Icon, amount }: { Icon: LucideIcon, amount: number }) => (
+ <div className="flex flex-row items-center w-full">
+ <Icon className="w-4 h-4 font-light" />
+ {amount}
+ </div>
+)
+
+export const ServiceCard: FC<ServiceCardProps> = (
+ { service }
+) => {
+ if (service.users && service.inbounds) {
+ return (
+ <div className="w-full border rounded-md p-2 flex justify-between flex-row items-center">
+ <Label>{service.name}</Label>
+ <div className="flex flex-row items-center gap-1">
+ <ServiceProperty Icon={UserIcon} amount={service.users.length} />
+ <Separator className="w-4 rotate-[-75deg]" />
+ <ServiceProperty Icon={ServerIcon} amount={service.inbounds.length} />
+ </div>
+ </div>
+ )
+ }
+};
diff --git a/dashboard/src/features/users/components/users-status.tsx b/dashboard/src/features/users/components/users-status.tsx
new file mode 100644
index 0000000..ab82225
--- /dev/null
+++ b/dashboard/src/features/users/components/users-status.tsx
@@ -0,0 +1,76 @@
+import { FC } from 'react';
+import { Badge, Label } from '@marzneshin/components';
+import { StatusType } from '@marzneshin/types';
+import { AlarmClock, BrickWall, CalendarX, Cloud, PowerCircle, PowerOff, Radio, ServerCrash, Zap, ZapOff } from 'lucide-react';
+
+interface UsersStatusType {
+ [key: string]: StatusType;
+}
+
+export const UsersStatus: UsersStatusType = {
+ active: {
+ icon: PowerCircle,
+ label: "Active",
+ variant: "positive"
+ },
+ disabled: {
+ icon: PowerOff,
+ label: "Disabled",
+ variant: "disabled"
+ },
+ limited: {
+ icon: BrickWall,
+ label: "Limited",
+ variant: "warning"
+ },
+ expired: {
+ icon: CalendarX,
+ label: "Expired",
+ variant: "warning"
+ },
+ on_hold: {
+ icon: AlarmClock,
+ label: "On Hold",
+ variant: "royal"
+ },
+ error: {
+ icon: ServerCrash,
+ label: "Error",
+ variant: "destructive"
+ },
+ connecting: {
+ icon: Radio,
+ label: "Connecting",
+ variant: "warning"
+ },
+ connected: {
+ icon: Cloud,
+ label: "Connected",
+ variant: "positive"
+ },
+ healthy: {
+ icon: Zap,
+ label: "Healthy",
+ variant: "positive"
+ },
+ unhealthy: {
+ icon: ZapOff,
+ label: "Unhealthy",
+ variant: "destructive"
+ },
+}
+
+interface UsersStatusBadgeProps {
+ status: StatusType;
+}
+
+export const UsersStatusBadge: FC<UsersStatusBadgeProps> = ({ status }) => {
+ const { label, icon: Icon } = status;
+ return (
+ <Badge variant={status.variant} className="h-6">
+ {Icon && <Icon className="mr-1 w-5 h-4" />} <Label className="capitalize">{label}</Label>
+ </Badge>
+ );
+};
+
+export default UsersStatusBadge;
diff --git a/dashboard/src/features/users/dialogs/delete-confirmation/index.tsx b/dashboard/src/features/users/dialogs/delete-confirmation/index.tsx
new file mode 100644
index 0000000..949d139
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/delete-confirmation/index.tsx
@@ -0,0 +1,23 @@
+
+import { DeleteConfirmation } from '@marzneshin/components'
+import { FC } from 'react'
+import { UserMutationType, useUsersDeletionMutation } from '@marzneshin/features/users'
+
+interface UsersDeleteConfirmationDialogProps {
+ onOpenChange: (state: boolean) => void
+ open: boolean
+ entity: UserMutationType | null
+}
+
+export const UsersDeleteConfirmationDialog: FC<UsersDeleteConfirmationDialogProps> = ({ onOpenChange, open, entity }) => {
+ const deleteMutation = useUsersDeletionMutation();
+ if (entity !== null) {
+ return (
+ <DeleteConfirmation
+ open={open}
+ onOpenChange={onOpenChange}
+ action={() => deleteMutation.mutate(entity)}
+ />
+ )
+ }
+}
diff --git a/dashboard/src/features/users/dialogs/index.ts b/dashboard/src/features/users/dialogs/index.ts
new file mode 100644
index 0000000..2e13901
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/index.ts
@@ -0,0 +1,3 @@
+export * from './mutation'
+export * from './settings'
+export * from './delete-confirmation'
diff --git a/dashboard/src/features/users/dialogs/mutation/fields/data-limit-reset-strategy.tsx b/dashboard/src/features/users/dialogs/mutation/fields/data-limit-reset-strategy.tsx
new file mode 100644
index 0000000..6c02926
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/mutation/fields/data-limit-reset-strategy.tsx
@@ -0,0 +1,48 @@
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectValue,
+} from '@marzneshin/components';
+import { FC } from 'react'
+import { useFormContext } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+interface DataLimitResetStrategyFieldProps { }
+
+export const DataLimitResetStrategyField: FC<DataLimitResetStrategyFieldProps> = () => {
+ const { t } = useTranslation()
+ const form = useFormContext()
+ return (
+ <FormField
+ control={form.control}
+ name="data_limit_reset_strategy"
+ render={({ field }) => (
+ <FormItem className="w-full">
+ <FormLabel>{t('Data Limit Reset Strategy')}</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Pick a date" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="no_reset">None</SelectItem>
+ <SelectItem value="day">Daily</SelectItem>
+ <SelectItem value="week">Weekly</SelectItem>
+ <SelectItem value="month">Monthly</SelectItem>
+ <SelectItem value="year">Yearly</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ );
+}
diff --git a/dashboard/src/features/users/dialogs/mutation/fields/data-limit.tsx b/dashboard/src/features/users/dialogs/mutation/fields/data-limit.tsx
new file mode 100644
index 0000000..c32f414
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/mutation/fields/data-limit.tsx
@@ -0,0 +1,33 @@
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ Input,
+ FormLabel,
+ FormMessage
+} from '@marzneshin/components';
+import { FC } from 'react'
+import { useFormContext } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+interface DataLimitFieldProps { }
+
+export const DataLimitField: FC<DataLimitFieldProps> = () => {
+ const { t } = useTranslation()
+ const form = useFormContext()
+ return (
+ <FormField
+ control={form.control}
+ name="data_limit"
+ render={({ field }) => (
+ <FormItem className="w-full">
+ <FormLabel>{t('page.users.data_limit')}</FormLabel>
+ <FormControl>
+ <Input type="number" className="order-1" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ );
+}
diff --git a/dashboard/src/features/users/dialogs/mutation/fields/expire-date.tsx b/dashboard/src/features/users/dialogs/mutation/fields/expire-date.tsx
new file mode 100644
index 0000000..018cf1a
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/mutation/fields/expire-date.tsx
@@ -0,0 +1,6 @@
+import { DateField } from "@marzneshin/features/users";
+import { FC } from "react";
+
+export const ExpireDateField: FC = () => {
+ return <DateField name="expire" label="page.users.expire_date" />;
+};
diff --git a/dashboard/src/features/users/dialogs/mutation/fields/index.ts b/dashboard/src/features/users/dialogs/mutation/fields/index.ts
new file mode 100644
index 0000000..affdc85
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/mutation/fields/index.ts
@@ -0,0 +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"
diff --git a/dashboard/src/features/users/dialogs/mutation/fields/note.tsx b/dashboard/src/features/users/dialogs/mutation/fields/note.tsx
new file mode 100644
index 0000000..d84a79b
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/mutation/fields/note.tsx
@@ -0,0 +1,38 @@
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ Textarea,
+ FormLabel,
+ FormMessage
+} from '@marzneshin/components';
+import { FC } from 'react'
+import { useFormContext } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+
+interface NoteFieldProps { }
+
+export const NoteField: FC<NoteFieldProps> = () => {
+ const { t } = useTranslation()
+ const form = useFormContext()
+ return (
+ <FormField
+ control={form.control}
+ name="note"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t('note')}</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Additional information about data"
+ className="resize-none"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ );
+}
diff --git a/dashboard/src/features/users/dialogs/mutation/fields/onhold-expire-duration.tsx b/dashboard/src/features/users/dialogs/mutation/fields/onhold-expire-duration.tsx
new file mode 100644
index 0000000..32ecc7c
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/mutation/fields/onhold-expire-duration.tsx
@@ -0,0 +1,44 @@
+
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ Input,
+ FormLabel,
+ FormMessage
+} from '@marzneshin/components';
+import { FC, useEffect, useState } from 'react'
+import { useFormContext } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+interface OnHoldExpireDurationFieldProps { }
+
+export const OnHoldExpireDurationField: FC<OnHoldExpireDurationFieldProps> = () => {
+ const form = useFormContext()
+ const [duration, setDuration] = useState<number>(0)
+ useEffect(() => {
+ form.setValue('on_hold_expire_duration', duration * 86400)
+ }, [form, duration])
+ const { t } = useTranslation()
+
+ return (
+ <FormField
+ control={form.control}
+ name="on_hold_expire_duration"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t('page.users.on_hold_expire_duration')}</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ {...field}
+ value={duration}
+ onChange={(e) => setDuration(Number(e.target.value))}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ );
+}
diff --git a/dashboard/src/features/users/dialogs/mutation/fields/onhold-timeout.tsx b/dashboard/src/features/users/dialogs/mutation/fields/onhold-timeout.tsx
new file mode 100644
index 0000000..9eb5b66
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/mutation/fields/onhold-timeout.tsx
@@ -0,0 +1,7 @@
+
+import { DateField } from "@marzneshin/features/users";
+import { FC } from "react";
+
+export const OnHoldTimeoutField: FC = () => {
+ return <DateField name="on_hold_timeout" label="page.users.on_hold_timeout" />;
+};
diff --git a/dashboard/src/features/users/dialogs/mutation/fields/services.tsx b/dashboard/src/features/users/dialogs/mutation/fields/services.tsx
new file mode 100644
index 0000000..28e0f2b
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/mutation/fields/services.tsx
@@ -0,0 +1,67 @@
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ ToggleGroup,
+ ToggleGroupItem,
+ ScrollArea
+} from '@marzneshin/components';
+import { useServicesQuery } from '@marzneshin/features/services';
+import { ServiceCard } from '@marzneshin/features/users';
+import { FC } from 'react'
+import { useFormContext } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+interface ServicesFieldProps {
+ services: number[]
+ setServices: (s: number[]) => void
+}
+
+export const ServicesField: FC<ServicesFieldProps> = ({
+ services,
+ setServices
+}) => {
+ const form = useFormContext();
+ const { data } = useServicesQuery();
+ const { t } = useTranslation();
+
+ const handleToggle = (selectedServiceIds: string[]) => {
+ setServices(selectedServiceIds.map(Number));
+ };
+
+ return (
+ <FormField
+ control={form.control}
+ name="services"
+ render={() => (
+ <FormItem>
+ <FormLabel>{t('services')}</FormLabel>
+ <FormControl>
+ <ToggleGroup
+ defaultValue={services.map(String)}
+ className="h-full"
+ onValueChange={handleToggle}
+ type="multiple"
+ >
+ <ScrollArea className="flex flex-col justify-start px-1 h-[22rem]">
+ {data.map(service => (
+ <ToggleGroupItem
+ key={service.id}
+ value={String(service.id)}
+ {...form.register(`services.${service.id}`)}
+ className="px-0 mb-1 w-full"
+ >
+ <ServiceCard service={service} />
+ </ToggleGroupItem>
+ ))}
+ </ScrollArea>
+ </ToggleGroup>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ );
+};
diff --git a/dashboard/src/features/users/dialogs/mutation/fields/username.tsx b/dashboard/src/features/users/dialogs/mutation/fields/username.tsx
new file mode 100644
index 0000000..70dc2e8
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/mutation/fields/username.tsx
@@ -0,0 +1,33 @@
+import {
+ FormControl,
+ FormField,
+ FormItem,
+ Input,
+ FormLabel,
+ FormMessage
+} from '@marzneshin/components';
+import { FC } from 'react'
+import { useFormContext } from 'react-hook-form';
+import { useTranslation } from 'react-i18next';
+
+interface UsernameFieldProps { }
+
+export const UsernameField: FC<UsernameFieldProps> = () => {
+ const form = useFormContext()
+ const { t } = useTranslation()
+ return (
+ <FormField
+ control={form.control}
+ name="username"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t('username')}</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ );
+}
diff --git a/dashboard/src/features/users/dialogs/mutation/index.tsx b/dashboard/src/features/users/dialogs/mutation/index.tsx
new file mode 100644
index 0000000..ed03687
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/mutation/index.tsx
@@ -0,0 +1,147 @@
+import { FC, useCallback, useEffect, useState } from "react";
+import {
+ DialogTitle,
+ DialogContent,
+ Dialog,
+ DialogHeader,
+ Form,
+ Button,
+ Tabs,
+ TabsTrigger,
+ TabsContent
+} from '@marzneshin/components';
+import { useTranslation } from 'react-i18next';
+import {
+ UserMutationType,
+ useUsersCreationMutation,
+ useUsersUpdateMutation
+} from "@marzneshin/features/users";
+import { UserSchema } from "./schema";
+import {
+ UsernameField,
+ DataLimitField,
+ ExpireDateField,
+ DataLimitResetStrategyField,
+ NoteField,
+ ServicesField,
+} from "./fields";
+import { useMutationDialog } from "@marzneshin/hooks";
+import { TabsList } from "@radix-ui/react-tabs";
+import { OnHoldExpireDurationField } from "./fields/onhold-expire-duration";
+import { OnHoldTimeoutField } from "./fields/onhold-timeout";
+
+interface UsersMutationDialogProps {
+ entity: UserMutationType | null;
+ open: boolean;
+ onOpenChange: (state: boolean) => void;
+}
+
+export const UsersMutationDialog: FC<UsersMutationDialogProps> = ({
+ entity,
+ open,
+ onOpenChange,
+}) => {
+ const { t } = useTranslation();
+ const getDefaultValues = useCallback((): UserMutationType => ({
+ services: [],
+ data_limit: 0,
+ expire: null,
+ username: '',
+ data_limit_reset_strategy: 'no_reset',
+ status: 'active',
+ note: '',
+ on_hold_expire_duration: 0,
+ on_hold_timeout: null,
+ }), []);
+
+ const [services, setServices] = useState<number[]>(entity ? entity.services : [])
+
+ const { form, handleSubmit } = useMutationDialog({
+ entity,
+ onOpenChange,
+ createMutation: useUsersCreationMutation(),
+ updateMutation: useUsersUpdateMutation(),
+ schema: UserSchema,
+ getDefaultValue: getDefaultValues,
+ })
+
+ useEffect(() => {
+ form.setValue("services", services.map(Number).filter(item => typeof item === 'number'))
+ }, [services, form])
+
+
+ const [selectedTab, setSelectedTab] = useState<'determined' | 'onhold' | string>('determined');
+
+ useEffect(() => {
+ if (selectedTab === 'onhold') {
+ form.setValue("status", "on_hold")
+ form.setValue("expire", null);
+ form.clearErrors("expire");
+ } else {
+ form.setValue("status", "active")
+ form.setValue("on_hold_expire_duration", 0);
+ form.setValue("on_hold_timeout", null);
+ form.clearErrors("on_hold_expire_duration");
+ form.clearErrors("on_hold_timeout");
+ }
+ }, [selectedTab, form]);
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange} defaultOpen={true}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle className="text-primary">
+ {entity
+ ? t('page.users.dialogs.edition.title')
+ : t('page.users.dialogs.creation.title')}
+ </DialogTitle>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={handleSubmit}>
+ <div className="flex-col grid-cols-2 gap-2 sm:flex md:grid">
+ <div>
+ <UsernameField />
+ <div className="flex gap-2 items-center w-full sm:flex-row md:flex-col">
+ <DataLimitField />
+ {form.watch().data_limit !== 0 && <DataLimitResetStrategyField />}
+ </div>
+ <Tabs defaultValue="determined" onValueChange={setSelectedTab} className="mt-2 w-full">
+ <TabsList className="flex flex-row items-center p-1 w-full rounded-md bg-accent">
+ <TabsTrigger
+ className="w-full"
+ value="determined">
+ {t('page.users.determined_expire')}
+ </TabsTrigger>
+ <TabsTrigger
+ className="w-full"
+ value="onhold">
+ {t('page.users.onhold_expire')}
+ </TabsTrigger>
+ </TabsList>
+ <TabsContent value="determined">
+ <ExpireDateField />
+ </TabsContent>
+ <TabsContent value="onhold">
+ <OnHoldExpireDurationField />
+ <OnHoldTimeoutField />
+ </TabsContent>
+ </Tabs>
+ <NoteField />
+ </div>
+ <div>
+ <ServicesField services={services} setServices={setServices} />
+ </div>
+ </div>
+ <Button
+ className="mt-3 w-full font-semibold"
+ type="submit"
+ disabled={form.formState.isSubmitting}
+ >
+ {t('submit')}
+ </Button>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+};
diff --git a/dashboard/src/features/users/dialogs/mutation/schema.ts b/dashboard/src/features/users/dialogs/mutation/schema.ts
new file mode 100644
index 0000000..6658eca
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/mutation/schema.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod';
+
+export const UserSchema = z.object({
+ username: z.string().min(1, { message: 'Username is required' }),
+ note: z.string().nullable(),
+ data_limit: z
+ .string()
+ .min(0, 'The minimum number is 0')
+ .or(z.number())
+ .nullable()
+ .transform((str) => {
+ if (str) return Number((parseFloat(String(str)) * 1073741824).toFixed(5));
+ return 0;
+ }),
+ expire: z.string().nullable(),
+ data_limit_reset_strategy: z.string().nullable(),
+ status: z.string(),
+ on_hold_expire_duration: z.number().nullable(),
+ on_hold_timeout: z.string().nullable(),
+ services: z.array(z.number().or(z.string())).nonempty({ message: 'At least one service is required' })
+ .transform((v) => v.map(Number)),
+});
diff --git a/dashboard/src/features/users/dialogs/settings/index.tsx b/dashboard/src/features/users/dialogs/settings/index.tsx
new file mode 100644
index 0000000..ce7ac2c
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/settings/index.tsx
@@ -0,0 +1,55 @@
+import {
+ ScrollArea,
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@marzneshin/components"
+import { FC } from "react"
+import { useTranslation } from "react-i18next"
+import { UserServicesTable, UserType } from "@marzneshin/features/users"
+import { UserInfoTable, QRCodeSection } from "./user-info"
+
+interface UsersSettingsDialogProps {
+ onOpenChange: (state: boolean) => void
+ open: boolean
+ entity: UserType | null
+}
+
+export const UsersSettingsDialog: FC<UsersSettingsDialogProps> = ({ onOpenChange, open, entity }) => {
+
+ const { t } = useTranslation();
+
+ if (entity) {
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="sm:min-w-full md:min-w-[700px]" >
+ <SheetHeader>
+ <SheetTitle>
+ {t('settings')}
+ </SheetTitle>
+ </SheetHeader>
+ <ScrollArea className="flex flex-col gap-4 m-3 h-full">
+ <Tabs defaultValue="info" className="w-full h-full">
+ <TabsList className="w-full bg-accent">
+ <TabsTrigger className="w-full" value="info"> {t('user_info')} </TabsTrigger>
+ <TabsTrigger className="w-full" value="services"> {t('services')} </TabsTrigger>
+ </TabsList>
+ <TabsContent value="info" className="flex flex-col gap-4">
+ <UserInfoTable entity={entity} />
+ <QRCodeSection entity={entity} />
+ </TabsContent>
+ <TabsContent value="services">
+ <UserServicesTable user={entity} />
+ </TabsContent>
+ </Tabs>
+ </ScrollArea>
+ </SheetContent>
+ </Sheet>
+ )
+ }
+}
diff --git a/dashboard/src/features/users/dialogs/settings/user-info/index.ts b/dashboard/src/features/users/dialogs/settings/user-info/index.ts
new file mode 100644
index 0000000..d947f5c
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/settings/user-info/index.ts
@@ -0,0 +1,2 @@
+export * from './table'
+export * from './qrcode'
diff --git a/dashboard/src/features/users/dialogs/settings/user-info/qrcode.tsx b/dashboard/src/features/users/dialogs/settings/user-info/qrcode.tsx
new file mode 100644
index 0000000..4ac0416
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/settings/user-info/qrcode.tsx
@@ -0,0 +1,41 @@
+
+import { UserType } from '@marzneshin/features/users';
+import QRCode from "react-qr-code";
+import { QrCodeIcon } from "lucide-react";
+import { FC } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@marzneshin/components';
+import { useTranslation } from 'react-i18next';
+import { SubscriptionLinkButton } from './subcription-link-button';
+
+interface QRCodeProps {
+ entity: UserType
+}
+
+export const QRCodeSection: FC<QRCodeProps> = (
+ { entity }
+) => {
+ const subscribeQrLink = String(entity.subscription_url).startsWith('/')
+ ? window.location.origin + entity.subscription_url
+ : String(entity.subscription_url);
+ const { t } = useTranslation()
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex flex-row gap-4 justify-start">
+ <QrCodeIcon />
+ {t('subscription')}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className='flex flex-col justify-center items-center py-10 w-full h-full'>
+ <div className="flex flex-col gap-3 justify-center items-center w-1/2 h-1/2">
+ <QRCode
+ size={150}
+ className="w-full h-full"
+ value={subscribeQrLink}
+ />
+ <SubscriptionLinkButton subscriptionLink={subscribeQrLink} />
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/dashboard/src/features/users/dialogs/settings/user-info/subcription-link-button.tsx b/dashboard/src/features/users/dialogs/settings/user-info/subcription-link-button.tsx
new file mode 100644
index 0000000..8f5a077
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/settings/user-info/subcription-link-button.tsx
@@ -0,0 +1,23 @@
+import { useTranslation } from 'react-i18next';
+import { CopyToClipboardButton } from '@marzneshin/components';
+import { FC } from 'react';
+import { SquareCode } from 'lucide-react';
+
+interface SubscriptionLinkButtonProps {
+ subscriptionLink: string;
+}
+
+export const SubscriptionLinkButton: FC<SubscriptionLinkButtonProps> = ({ subscriptionLink }) => {
+ const { t } = useTranslation();
+
+ return (
+ <CopyToClipboardButton
+ text={subscriptionLink}
+ successMessage={t('page.users.settings.subscription_link.copied')}
+ copyLabel={t('page.users.settings.subscription_link.copy')}
+ errorLabel={t('page.users.settings.subscription_link.error')}
+ copyIcon={SquareCode}
+ className="w-full"
+ />
+ );
+};
diff --git a/dashboard/src/features/users/dialogs/settings/user-info/table.tsx b/dashboard/src/features/users/dialogs/settings/user-info/table.tsx
new file mode 100644
index 0000000..c425b24
--- /dev/null
+++ b/dashboard/src/features/users/dialogs/settings/user-info/table.tsx
@@ -0,0 +1,70 @@
+import { Card, CardContent, CardHeader, CardTitle, Table, TableBody, TableCell, TableRow } from "@marzneshin/components";
+import { FC } from 'react';
+import { UserType, UsersStatus, UsersStatusBadge } from '@marzneshin/features/users';
+import { CircularProgress } from "@nextui-org/progress";
+import { useTranslation } from "react-i18next";
+import { format, isDate, isValid } from "date-fns";
+
+interface UserInfoTableProps {
+ entity: UserType;
+}
+
+const TableRowWithCell: FC<{ label: string; value: string | number | JSX.Element }> = ({ label, value }) => (
+ <TableRow>
+ <TableCell>{label}</TableCell>
+ <TableCell>{value}</TableCell>
+ </TableRow>
+);
+
+const DateTableRow: FC<{ label: string; date: Date | string }> = ({ label, date }) => {
+ let formattedDate = '';
+ if (date && isValid(date)) {
+ formattedDate = format(date, "PPP");
+ }
+ return (
+ <TableRowWithCell label={label} value={formattedDate} />
+ );
+};
+
+const CircularProgressBarRow: FC<{ label: string; value: number; limit: number }> = ({ label, value, limit }) => (
+ <TableRow>
+ <TableCell>{label}</TableCell>
+ <TableCell><CircularProgress size="sm" value={value / limit * 100} /></TableCell>
+ </TableRow>
+);
+
+export const UserInfoTable: FC<UserInfoTableProps> = ({ entity }) => {
+ const { t } = useTranslation();
+
+ const expireDate = entity.expire ? (isDate(entity.expire) ? entity.expire : new Date(entity.expire)) : null;
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>{t('user_info')}</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableBody>
+ <TableRowWithCell label={t('username')} value={entity.username} />
+ <TableRowWithCell label={t('status')} value={<UsersStatusBadge status={UsersStatus[entity.status]} />} />
+ {(expireDate && entity.on_hold_expire_duration === 0) ? (
+ <DateTableRow label={t('page.users.expire_date')} date={expireDate} />
+ ) : (
+ <>
+ <TableRowWithCell label={t('page.users.on_hold_expire_duration')} value={entity.on_hold_expire_duration} />
+ {entity.on_hold_timeout &&
+ <DateTableRow label={t('page.users.expire_date')} date={entity.on_hold_timeout} />
+ }
+ </>
+ )}
+ {entity.data_limit &&
+ <CircularProgressBarRow label={t('page.users.used_traffic')} value={entity.used_traffic} limit={entity.data_limit} />
+ }
+ <DateTableRow label={t('page.users.online_at')} date={entity.online_at} />
+ <TableRowWithCell label={t('note')} value={entity.note} />
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ );
+};
diff --git a/dashboard/src/features/users/index.ts b/dashboard/src/features/users/index.ts
index c56bbc5..c2ca7fa 100644
--- a/dashboard/src/features/users/index.ts
+++ b/dashboard/src/features/users/index.ts
@@ -1,2 +1,5 @@
export * from './services'
export * from './types'
+export * from './tables'
+export * from './components'
+export * from './dialogs'
diff --git a/dashboard/src/features/users/services/create-service.mutate.ts b/dashboard/src/features/users/services/create-user.mutate.ts
similarity index 76%
rename from dashboard/src/features/users/services/create-service.mutate.ts
rename to dashboard/src/features/users/services/create-user.mutate.ts
index 26b9ca0..bbe34f4 100644
--- a/dashboard/src/features/users/services/create-service.mutate.ts
+++ b/dashboard/src/features/users/services/create-user.mutate.ts
@@ -1,16 +1,16 @@
-import { UserCreateType, UsersQueryFetchKey } from "@marzneshin/features/users";
+import { UserMutationType, UsersQueryFetchKey } from "@marzneshin/features/users";
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 fetchCreateUser(user: UserCreateType): Promise<UserCreateType> {
+export async function fetchCreateUser(user: UserMutationType): Promise<UserMutationType> {
return fetch('/users', { method: 'post', body: user }).then((user) => {
return user;
});
}
-const handleError = (error: Error, value: UserCreateType) => {
+const handleError = (error: Error, value: UserMutationType) => {
toast.error(
i18n.t('events.create.error', { name: value.username }),
{
@@ -18,7 +18,7 @@ const handleError = (error: Error, value: UserCreateType) => {
})
}
-const handleSuccess = (value: UserCreateType) => {
+const handleSuccess = (value: UserMutationType) => {
toast.success(
i18n.t('events.create.success.title', { name: value.username }),
{
diff --git a/dashboard/src/features/users/services/delete-service.mutate.ts b/dashboard/src/features/users/services/delete-user.mutate.ts
similarity index 73%
rename from dashboard/src/features/users/services/delete-service.mutate.ts
rename to dashboard/src/features/users/services/delete-user.mutate.ts
index f405ff6..7ed0400 100644
--- a/dashboard/src/features/users/services/delete-service.mutate.ts
+++ b/dashboard/src/features/users/services/delete-user.mutate.ts
@@ -1,19 +1,19 @@
-import { UserType, UsersQueryFetchKey } from "@marzneshin/features/users";
+import { UserMutationType, UsersQueryFetchKey } from "@marzneshin/features/users";
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 fetchDeleteUser(user: UserType): Promise<UserType> {
+export async function fetchDeleteUser(user: UserMutationType): Promise<UserMutationType> {
return fetch(`/users/${user.username}`, { method: 'delete' }).then((user) => {
return user;
});
}
-const UsersDeleteFetchKey = "users-delete-fetch-key";
+const UsersDeleteFetchKey = "users";
-const handleError = (error: Error, value: UserType) => {
+const handleError = (error: Error, value: UserMutationType) => {
toast.error(
i18n.t('events.delete.error', { name: value.username }),
{
@@ -21,7 +21,7 @@ const handleError = (error: Error, value: UserType) => {
})
}
-const handleSuccess = (value: UserType) => {
+const handleSuccess = (value: UserMutationType) => {
toast.success(
i18n.t('events.delete.success.title', { name: value.username }),
{
diff --git a/dashboard/src/features/users/services/index.ts b/dashboard/src/features/users/services/index.ts
index 09bb9ea..9098541 100644
--- a/dashboard/src/features/users/services/index.ts
+++ b/dashboard/src/features/users/services/index.ts
@@ -1,4 +1,4 @@
export * from './users.query'
-export * from './update-service.mutate'
-export * from './create-service.mutate'
-export * from './delete-service.mutate'
+export * from './update-user.mutate'
+export * from './create-user.mutate'
+export * from './delete-user.mutate'
diff --git a/dashboard/src/features/users/services/update-service.mutate.ts b/dashboard/src/features/users/services/update-user.mutate.ts
similarity index 76%
rename from dashboard/src/features/users/services/update-service.mutate.ts
rename to dashboard/src/features/users/services/update-user.mutate.ts
index 711ee5d..aa18237 100644
--- a/dashboard/src/features/users/services/update-service.mutate.ts
+++ b/dashboard/src/features/users/services/update-user.mutate.ts
@@ -2,16 +2,18 @@ import { useMutation } from "@tanstack/react-query";
import { fetch, queryClient } from "@marzneshin/utils";
import { toast } from "sonner";
import i18n from "@marzneshin/features/i18n";
-import { UsersQueryFetchKey } from "./users.query";
-import { UserType } from "../types";
+import {
+ UsersQueryFetchKey,
+ UserMutationType
+} from "@marzneshin/features/users";
-export async function updateUser(user: UserType): Promise<UserType> {
+export async function updateUser(user: UserMutationType): Promise<UserMutationType> {
return fetch(`/users/${user.username}`, { method: 'put', body: user }).then((user) => {
return user;
});
}
-const handleError = (error: Error, value: UserType) => {
+const handleError = (error: Error, value: UserMutationType) => {
toast.error(
i18n.t('events.update.error', { name: value.username }),
{
@@ -19,7 +21,7 @@ const handleError = (error: Error, value: UserType) => {
})
}
-const handleSuccess = (value: UserType) => {
+const handleSuccess = (value: UserMutationType) => {
toast.success(
i18n.t('events.update.success.title', { name: value.username }),
{
diff --git a/dashboard/src/features/users/tables/index.ts b/dashboard/src/features/users/tables/index.ts
index e69de29..5846922 100644
--- a/dashboard/src/features/users/tables/index.ts
+++ b/dashboard/src/features/users/tables/index.ts
@@ -0,0 +1,2 @@
+export * from './users'
+export * from './services'
diff --git a/dashboard/src/features/users/tables/services/columns.tsx b/dashboard/src/features/users/tables/services/columns.tsx
new file mode 100644
index 0000000..93253a3
--- /dev/null
+++ b/dashboard/src/features/users/tables/services/columns.tsx
@@ -0,0 +1,46 @@
+import { ColumnDef } from "@tanstack/react-table"
+import { DataTableColumnHeader } from "@marzneshin/components/data-table/column-header"
+import i18n from "@marzneshin/features/i18n"
+import { Checkbox } from "@marzneshin/components"
+import { ServiceType } from "@marzneshin/features/services"
+
+export const columns: ColumnDef<ServiceType>[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => {
+ table.toggleAllPageRowsSelected(!!value)
+ }}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "name",
+ header: ({ column }) => <DataTableColumnHeader title={i18n.t('name')} column={column} />,
+ },
+ {
+ accessorKey: 'users',
+ header: ({ column }) => <DataTableColumnHeader title={i18n.t('Users')} column={column} />,
+ cell: ({ row }) => row.original.users.length
+ },
+ {
+ accessorKey: 'inbounds',
+ header: ({ column }) => <DataTableColumnHeader title={i18n.t('inbounds')} column={column} />,
+ cell: ({ row }) => row.original.inbounds.length
+ }
+];
diff --git a/dashboard/src/features/users/tables/services/index.tsx b/dashboard/src/features/users/tables/services/index.tsx
new file mode 100644
index 0000000..1e200b0
--- /dev/null
+++ b/dashboard/src/features/users/tables/services/index.tsx
@@ -0,0 +1,62 @@
+
+
+import { FC, useCallback, useEffect, useState } from "react";
+import {
+} from '@marzneshin/features/inbounds';
+import { Button, DataTable } from "@marzneshin/components";
+import { columns } from "./columns";
+import { UserType, useUsersUpdateMutation } from "@marzneshin/features/users";
+import { useTranslation } from "react-i18next";
+import { RowSelectionState } from "@tanstack/react-table";
+import {
+ ServiceType,
+ useServicesQuery,
+} from "@marzneshin/features/services";
+
+interface UserServicesTableProps {
+ user: UserType
+}
+
+export const UserServicesTable: FC<UserServicesTableProps> = ({ user }) => {
+ const { mutate: updateUser } = useUsersUpdateMutation();
+ const { data } = useServicesQuery();
+ const [selectedService, setSelectedService] = useState<{ [key: number]: boolean }>({})
+ const { t } = useTranslation()
+
+ useEffect(() => {
+ setSelectedService((prevSelected) => {
+ const updatedSelected: RowSelectionState = { ...prevSelected };
+ user.services.forEach((serviceId) => {
+ data.forEach((fetchedService: ServiceType, i) => {
+ if (fetchedService.id === serviceId) {
+ updatedSelected[i] = true;
+ }
+ });
+ });
+ return updatedSelected;
+ });
+ }, [data, user.services])
+
+ const handleApply = useCallback(() => {
+ const selectedServiceIds = Object.keys(selectedService)
+ .filter(key => selectedService[parseInt(key)])
+ .map(key => data[parseInt(key)].id);
+ updateUser({ ...user, services: selectedServiceIds });
+ }, [data, selectedService, user, updateUser]);
+
+ const disabled = (Object.keys(selectedService).length < 1) ? true : false
+
+ return (
+ <div className="flex flex-col gap-4">
+ <DataTable
+ columns={columns}
+ data={data}
+ filteredColumn='name'
+ selectedRow={selectedService}
+ setSelectedRow={setSelectedService}
+ />
+
+ <Button onClick={handleApply} disabled={disabled}>{t('apply')}</Button>
+ </div>
+ )
+}
diff --git a/dashboard/src/features/users/tables/users/columns.tsx b/dashboard/src/features/users/tables/users/columns.tsx
new file mode 100644
index 0000000..5d8af0b
--- /dev/null
+++ b/dashboard/src/features/users/tables/users/columns.tsx
@@ -0,0 +1,51 @@
+
+import { ColumnDef } from "@tanstack/react-table"
+import { UsersStatusBadge, UserType, UsersStatus } from "@marzneshin/features/users"
+import { DataTableColumnHeader } from "@marzneshin/components/data-table/column-header"
+import i18n from "@marzneshin/features/i18n"
+import { DataTableActionsCell } from "@marzneshin/components"
+import { CircularProgress } from "@nextui-org/progress"
+
+interface ColumnAction {
+ onDelete: (user: UserType) => void;
+ onOpen: (user: UserType) => void;
+ onEdit: (user: UserType) => void;
+}
+
+
+export const columns = (actions: ColumnAction): ColumnDef<UserType>[] => ([
+ {
+ accessorKey: "username",
+ header: ({ column }) => <DataTableColumnHeader title={i18n.t('username')} column={column} />,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeader title={i18n.t('status')} column={column} />,
+ cell: ({ row }) => <UsersStatusBadge status={UsersStatus[row.original.status]} />,
+ },
+ {
+ accessorKey: "services",
+ header: ({ column }) => <DataTableColumnHeader title={i18n.t('services')} column={column} />,
+ cell: ({ row }) => row.original.services.length
+ },
+ {
+ accessorKey: "used_traffic",
+ header: ({ column }) => <DataTableColumnHeader title={i18n.t('page.users.used_traffic')} column={column} />,
+ cell: ({ row }) => {
+ if (row.original.data_limit)
+ return (
+ <CircularProgress
+ value={row.original.used_traffic / row.original.data_limit * 100}
+ size='sm'
+ showValueLabel
+ />
+ )
+ else
+ return "No Traffic"
+ }
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => <DataTableActionsCell {...actions} row={row} />
+ },
+]);
diff --git a/dashboard/src/features/users/tables/users/index.tsx b/dashboard/src/features/users/tables/users/index.tsx
new file mode 100644
index 0000000..b19e7f3
--- /dev/null
+++ b/dashboard/src/features/users/tables/users/index.tsx
@@ -0,0 +1,23 @@
+
+import { FC } from "react";
+import {
+ useUsersQuery,
+ UsersMutationDialog,
+ UsersDeleteConfirmationDialog,
+ UsersSettingsDialog
+} from '@marzneshin/features/users';
+import { columns } from './columns'
+import { EntityTable } from "@marzneshin/components";
+
+export const UsersTable: FC = () => {
+ return (
+ <EntityTable
+ useQuery={useUsersQuery}
+ MutationDialog={UsersMutationDialog}
+ DeleteConfirmationDialog={UsersDeleteConfirmationDialog}
+ SettingsDialog={UsersSettingsDialog}
+ columns={columns}
+ filteredColumn="username"
+ />
+ )
+}
diff --git a/dashboard/src/features/users/types/index.ts b/dashboard/src/features/users/types/index.ts
index 1f88b01..85c7ebb 100644
--- a/dashboard/src/features/users/types/index.ts
+++ b/dashboard/src/features/users/types/index.ts
@@ -18,7 +18,9 @@ export type Status =
| 'connected';
export interface UserType {
- expire: Date | number;
+ on_hold_timeout: Date | string | null;
+ on_hold_expire_duration: number;
+ expire: Date | string | null;
data_limit: number | null;
data_limit_reset_strategy: DataLimitResetStrategy;
lifetime_used_traffic: number;
@@ -32,12 +34,14 @@ export interface UserType {
online_at: string;
}
-export type UserCreateType = Pick<
+export type UserMutationType = Pick<
UserType,
| 'services'
| 'expire'
| 'data_limit'
| 'data_limit_reset_strategy'
+ | 'on_hold_timeout'
+ | 'on_hold_expire_duration'
| 'username'
| 'status'
| 'note'
diff --git a/dashboard/src/hooks/index.ts b/dashboard/src/hooks/index.ts
index c7bfc20..9e25e4d 100644
--- a/dashboard/src/hooks/index.ts
+++ b/dashboard/src/hooks/index.ts
@@ -1 +1,2 @@
export * from './use-screen-breakpoint'
+export * from './use-mutation-dialog'
diff --git a/dashboard/src/hooks/use-mutation-dialog/index.ts b/dashboard/src/hooks/use-mutation-dialog/index.ts
new file mode 100644
index 0000000..00037d2
--- /dev/null
+++ b/dashboard/src/hooks/use-mutation-dialog/index.ts
@@ -0,0 +1,71 @@
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { UseMutationResult } from "@tanstack/react-query";
+import { useEffect } from "react";
+import { FieldValues, SubmitHandler, useForm } from "react-hook-form";
+import { ZodSchema } from "zod";
+
+interface IMutationDialog<TData, TError = unknown, TVariables = unknown> {
+ entity: TData | null
+ onOpenChange: (s: boolean) => void
+ createMutation: UseMutationResult<TData, TError, TVariables>
+ updateMutation: UseMutationResult<TData, TError, TVariables>
+ schema: ZodSchema
+ getDefaultValue: () => FieldValues
+}
+
+/** useMutationDialog - Dialog mutation hook
+ *
+ * @param entity - Entity value for update, leave empty for creation
+ * @param createMutation - react-query mutation hook for entity creation
+ * @param updateMutation - react-query mutation hook for entity edition
+ * @param entity - Entity value for update, leave empty for creation
+ * @param schema - Entity schema validated by zodResolver
+ * @param getDefaultValue - Default value function return the default value for function
+ * - Must be wrapped in useCallback to avoid infinite useEffect loop
+ * @param onOpenChange - Dialog state callback to change the dialog openness state
+ *
+ * @returns react-hook-form and submit handler
+ *
+ * @template TData - Mutation Type of Schema
+ * @template TError = unknown
+ * @template TVariables = unknown
+ */
+
+export const useMutationDialog = <TData, TError = unknown, TVariables = unknown>({
+ entity,
+ onOpenChange,
+ createMutation,
+ updateMutation,
+ schema,
+ getDefaultValue,
+}: IMutationDialog<TData, TError, TVariables>) => {
+ const form = useForm({
+ defaultValues: entity ? (entity as FieldValues) : (getDefaultValue()),
+ resolver: zodResolver(schema)
+ });
+
+ const submit: SubmitHandler<FieldValues> = async (values) => {
+ if (entity) {
+ updateMutation.mutate(values as TVariables);
+ } else {
+ createMutation.mutate(values as TVariables);
+ }
+ onOpenChange(false);
+ };
+
+ useEffect(() => {
+ if (entity) {
+ form.reset(entity)
+ } else {
+ form.reset(getDefaultValue());
+ }
+ }, [entity, form, getDefaultValue]);
+
+ const handleSubmit = form.handleSubmit(submit as SubmitHandler<FieldValues>);
+
+ return {
+ form,
+ handleSubmit
+ };
+};
diff --git a/dashboard/src/routes/_dashboard/users.tsx b/dashboard/src/routes/_dashboard/users.tsx
index 90e7a81..9d37c83 100644
--- a/dashboard/src/routes/_dashboard/users.tsx
+++ b/dashboard/src/routes/_dashboard/users.tsx
@@ -1,10 +1,14 @@
-import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@marzneshin/components';
-import { createFileRoute } from '@tanstack/react-router'
+import { Alert, AlertDescription, AlertTitle, Card, CardContent, CardFooter, CardHeader, CardTitle } from '@marzneshin/components';
+import { useServicesQuery } from '@marzneshin/features/services';
+import { UsersTable } from '@marzneshin/features/users';
+import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
+import { Link, createFileRoute } from '@tanstack/react-router'
import { FC } from 'react';
import { useTranslation } from 'react-i18next';
export const UsersPage: FC = () => {
+ const { data } = useServicesQuery();
const { t } = useTranslation();
return (
<Card className="m-3">
@@ -14,8 +18,19 @@ export const UsersPage: FC = () => {
</CardTitle>
</CardHeader>
<CardContent>
+ <UsersTable />
</CardContent>
<CardFooter>
+ {(data && data.length === 0) && (
+ <Alert>
+ <ExclamationTriangleIcon className="mr-2" />
+ <AlertTitle className="font-semibold text-primary">{t('page.users.services-alert.title')}</AlertTitle>
+ <AlertDescription>
+ {t('page.users.services-alert.desc')}
+ <Link className="m-1 font-semibold text-secondary-foreground" to="/services">{t('page.nodes.certificate-alert.click')}</Link>
+ </AlertDescription>
+ </Alert>
+ )}
</CardFooter>
</Card >
)
diff --git a/dashboard/src/types/status.ts b/dashboard/src/types/status.ts
index 55ab09e..4d320f0 100644
--- a/dashboard/src/types/status.ts
+++ b/dashboard/src/types/status.ts
@@ -1,6 +1,8 @@
+import { BadgeVariantKeys } from "@marzneshin/components";
import { LucideIcon } from "lucide-react";
export interface StatusType {
label: string;
icon: LucideIcon | null;
+ variant?: BadgeVariantKeys | undefined
}