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 ( + + + + ); +}; 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 ( - - - - ) -} + + ); +}; 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)} + + + + + + + + { + 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 ( +
+ +
+ + + +
+
+ ) + } +}; 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 && } + + ); +}; + +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')} + + + + )} + /> + ); +} 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')} + +