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 = """\ ✏️ #Modified @@ -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 = ({ + 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 ( + + + + {t(copied ? successMessage : (healthy ? copyLabel : errorLabel))} + + + ); +}; 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 { useQuery: () => { data: T[] }; MutationDialog: FC>; DeleteConfirmationDialog: FC>; - SettingsDialog: FC>; + SettingsDialog: FC>; 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, - VariantProps {} + VariantProps { } 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 = ({ 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 ( @@ -72,7 +60,7 @@ export const MutationDialog: FC = ({ - + = ({ 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 ( @@ -88,7 +74,7 @@ export const MutationDialog: FC = ({ entity, open, onOpenCh - + = ({ 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 { +export async function fetchCreateService(service: ServiceMutationType): Promise { 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 { + +export async function updateService(node: ServiceMutationType): Promise { 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[] = [ accessorKey: "username", header: ({ column }) => , }, - // { - // accessorKey: 'status', - // header: ({ column }) => , - // cell: ({ row }) => , - // }, + { + accessorKey: 'status', + header: ({ column }) => , + cell: ({ row }) => , + }, { accessorKey: "used_traffic", header: ({ column }) => , @@ -44,6 +48,6 @@ export const columns: ColumnDef[] = [ { accessorKey: 'expire', header: ({ 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 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 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 { - copied: T - copy: T - noCopy: T - healthyState: boolean - copiedState: boolean -} - -function copyAt( - { copied, copy, noCopy, healthyState, copiedState }: ICopyAt -): 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(false); - const [Icon, setIcon] = useState(ClipboardCopy); - const [title, setTitle] = useState('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 ( - - setCopied([0, true])}> - - {t(title)} - - - ) -} + + ); +}; 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 = ({ name, label }) => { + const { t } = useTranslation(); + const form = useFormContext(); + const [selectedDate, setSelectedDate] = useState(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 ( + ( + + {t(label)} + + + + + {field.value ? ( + format(field.value, "PPP") + ) : ( + Pick a date + )} + + + + + + { + setSelectedDate(date); + }} + disabled={(date) => + date < new Date() + } + initialFocus + /> + + + + + )} + /> + ); +}; 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 }) => ( + + + {amount} + +) + +export const ServiceCard: FC = ( + { service } +) => { + if (service.users && service.inbounds) { + return ( + + {service.name} + + + + + + + ) + } +}; 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 = ({ status }) => { + const { label, icon: Icon } = status; + return ( + + {Icon && } {label} + + ); +}; + +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 = ({ onOpenChange, open, entity }) => { + const deleteMutation = useUsersDeletionMutation(); + if (entity !== null) { + return ( + 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 = () => { + const { t } = useTranslation() + const form = useFormContext() + return ( + ( + + {t('Data Limit Reset Strategy')} + + + + + + + + None + Daily + Weekly + Monthly + Yearly + + + + + )} + /> + ); +} 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 = () => { + const { t } = useTranslation() + const form = useFormContext() + return ( + ( + + {t('page.users.data_limit')} + + + + + + )} + /> + ); +} 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 ; +}; 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 = () => { + const { t } = useTranslation() + const form = useFormContext() + return ( + ( + + {t('note')} + + + + + + )} + /> + ); +} 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 = () => { + const form = useFormContext() + const [duration, setDuration] = useState(0) + useEffect(() => { + form.setValue('on_hold_expire_duration', duration * 86400) + }, [form, duration]) + const { t } = useTranslation() + + return ( + ( + + {t('page.users.on_hold_expire_duration')} + + setDuration(Number(e.target.value))} + /> + + + + )} + /> + ); +} 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 ; +}; 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 = ({ + services, + setServices +}) => { + const form = useFormContext(); + const { data } = useServicesQuery(); + const { t } = useTranslation(); + + const handleToggle = (selectedServiceIds: string[]) => { + setServices(selectedServiceIds.map(Number)); + }; + + return ( + ( + + {t('services')} + + + + {data.map(service => ( + + + + ))} + + + + + + )} + /> + ); +}; 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 = () => { + const form = useFormContext() + const { t } = useTranslation() + return ( + ( + + {t('username')} + + + + + + )} + /> + ); +} 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 = ({ + 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(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 ( + + + + + {entity + ? t('page.users.dialogs.edition.title') + : t('page.users.dialogs.creation.title')} + + + + + + + + + + {form.watch().data_limit !== 0 && } + + + + + {t('page.users.determined_expire')} + + + {t('page.users.onhold_expire')} + + + + + + + + + + + + + + + + + + {t('submit')} + + + + + + ); +}; 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 = ({ onOpenChange, open, entity }) => { + + const { t } = useTranslation(); + + if (entity) { + return ( + + + + + {t('settings')} + + + + + + {t('user_info')} + {t('services')} + + + + + + + + + + + + + ) + } +} 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 = ( + { entity } +) => { + const subscribeQrLink = String(entity.subscription_url).startsWith('/') + ? window.location.origin + entity.subscription_url + : String(entity.subscription_url); + const { t } = useTranslation() + return ( + + + + + {t('subscription')} + + + + + + + + + + ); +} 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 = ({ subscriptionLink }) => { + const { t } = useTranslation(); + + return ( + + ); +}; 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 }) => ( + + {label} + {value} + +); + +const DateTableRow: FC<{ label: string; date: Date | string }> = ({ label, date }) => { + let formattedDate = ''; + if (date && isValid(date)) { + formattedDate = format(date, "PPP"); + } + return ( + + ); +}; + +const CircularProgressBarRow: FC<{ label: string; value: number; limit: number }> = ({ label, value, limit }) => ( + + {label} + + +); + +export const UserInfoTable: FC = ({ entity }) => { + const { t } = useTranslation(); + + const expireDate = entity.expire ? (isDate(entity.expire) ? entity.expire : new Date(entity.expire)) : null; + return ( + + + {t('user_info')} + + + + + + } /> + {(expireDate && entity.on_hold_expire_duration === 0) ? ( + + ) : ( + <> + + {entity.on_hold_timeout && + + } + > + )} + {entity.data_limit && + + } + + + + + + + ); +}; 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 { +export async function fetchCreateUser(user: UserMutationType): Promise { 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 { +export async function fetchDeleteUser(user: UserMutationType): Promise { 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 { +export async function updateUser(user: UserMutationType): Promise { 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[] = [ + { + id: "select", + header: ({ table }) => ( + { + table.toggleAllPageRowsSelected(!!value) + }} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: ({ column }) => , + }, + { + accessorKey: 'users', + header: ({ column }) => , + cell: ({ row }) => row.original.users.length + }, + { + accessorKey: 'inbounds', + header: ({ 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 = ({ 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 ( + + + + {t('apply')} + + ) +} 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[] => ([ + { + accessorKey: "username", + header: ({ column }) => , + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => , + }, + { + accessorKey: "services", + header: ({ column }) => , + cell: ({ row }) => row.original.services.length + }, + { + accessorKey: "used_traffic", + header: ({ column }) => , + cell: ({ row }) => { + if (row.original.data_limit) + return ( + + ) + else + return "No Traffic" + } + }, + { + id: "actions", + cell: ({ 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 ( + + ) +} 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 { + entity: TData | null + onOpenChange: (s: boolean) => void + createMutation: UseMutationResult + updateMutation: UseMutationResult + 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 = ({ + entity, + onOpenChange, + createMutation, + updateMutation, + schema, + getDefaultValue, +}: IMutationDialog) => { + const form = useForm({ + defaultValues: entity ? (entity as FieldValues) : (getDefaultValue()), + resolver: zodResolver(schema) + }); + + const submit: SubmitHandler = 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); + + 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 ( @@ -14,8 +18,19 @@ export const UsersPage: FC = () => { + + {(data && data.length === 0) && ( + + + {t('page.users.services-alert.title')} + + {t('page.users.services-alert.desc')} + {t('page.nodes.certificate-alert.click')} + + + )} ) 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 }