Compare commits

...

152 Commits

Author SHA1 Message Date
semantic-release-bot 047a69886e 🔖 chore(release): v1.0.0-beta.25 [skip ci]
# [1.0.0-beta.25](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.24...v1.0.0-beta.25) (2025-03-01)

###  Features

* **auth**: Add privacy policy link to the footer ([8e16ef1](https://github.com/perfect-panel/ppanel-web/commit/8e16ef1))

### 🐛 Bug Fixes

* **dashboard**: Display subscription creation date in user dashboard ([d0e6df0](https://github.com/perfect-panel/ppanel-web/commit/d0e6df0))
* **request**: Add error code 40005 to trigger logout ([71bf002](https://github.com/perfect-panel/ppanel-web/commit/71bf002))
* **subscribe**: Update payment return URL ([2b80496](https://github.com/perfect-panel/ppanel-web/commit/2b80496))
2025-03-01 12:59:06 +00:00
web@ppanel 71bf002370 🐛 fix(request): Add error code 40005 to trigger logout 2025-03-01 19:55:55 +07:00
web@ppanel d0e6df044e 🐛 fix(dashboard): Display subscription creation date in user dashboard 2025-03-01 19:55:31 +07:00
web@ppanel 8e16ef17ed feat(auth): Add privacy policy link to the footer 2025-03-01 19:40:45 +07:00
web@ppanel 2b80496637 🐛 fix(subscribe): Update payment return URL 2025-03-01 19:35:32 +07:00
semantic-release-bot 01a3aa02ec 🔖 chore(release): v1.0.0-beta.24 [skip ci]
# [1.0.0-beta.24](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.23...v1.0.0-beta.24) (2025-02-27)

### ♻ Code Refactoring

* **ui**: Optimize document display ([2ca2992](https://github.com/perfect-panel/ppanel-web/commit/2ca2992))
* Reduce code complexity and improve readability ([e11f18c](https://github.com/perfect-panel/ppanel-web/commit/e11f18c))

###  Features

* **loading**: Add loading components and integrate them in Providers ([d5847fa](https://github.com/perfect-panel/ppanel-web/commit/d5847fa))

### 🎫 Chores

* **merge**: Add advertising module and device settings ([0130e02](https://github.com/perfect-panel/ppanel-web/commit/0130e02))

### 🐛 Bug Fixes

* **locales**: Order recharge related fields ([35210fe](https://github.com/perfect-panel/ppanel-web/commit/35210fe))
2025-02-27 08:02:53 +00:00
web@ppanel 35210fe8cf 🐛 fix(locales): Order recharge related fields 2025-02-27 14:53:19 +07:00
web@ppanel d5847faeda feat(loading): Add loading components and integrate them in Providers 2025-02-27 14:44:30 +07:00
web@ppanel 0130e02ffd 🔧 chore(merge): Add advertising module and device settings 2025-02-27 13:46:51 +07:00
web@ppanel c2b858e3fe Merge pull request #11 from turbolnk-com/develop
♻️ refactor(ui): Optimize document display
2025-02-25 18:35:05 +07:00
turbolnk e11f18c034 ♻️ refactor: Reduce code complexity and improve readability 2025-02-25 19:31:32 +08:00
turbolnk 2ca299224d ♻️ refactor(ui): Optimize document display 2025-02-25 18:38:25 +08:00
semantic-release-bot cf1d66d07e 🔖 chore(release): v1.0.0-beta.23 [skip ci]
# [1.0.0-beta.23](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.22...v1.0.0-beta.23) (2025-02-24)

### 🐛 Bug Fixes

* **auth**: Update email verification logic to use domain suffix check ([62662bb](https://github.com/perfect-panel/ppanel-web/commit/62662bb))
2025-02-24 05:10:30 +00:00
web@ppanel 62662bb79c 🐛 fix(auth): Update email verification logic to use domain suffix check 2025-02-24 12:07:41 +07:00
semantic-release-bot c0fb34fa50 🔖 chore(release): v1.0.0-beta.22 [skip ci]
# [1.0.0-beta.22](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.21...v1.0.0-beta.22) (2025-02-23)

### 🐛 Bug Fixes

* **locales**: Removed language file import to clean up unnecessary language support ([68f6ab2](https://github.com/perfect-panel/ppanel-web/commit/68f6ab2))
2025-02-23 15:06:05 +00:00
web@ppanel 68f6ab2e6c 🐛 fix(locales): Removed language file import to clean up unnecessary language support 2025-02-23 22:03:15 +07:00
semantic-release-bot ca642c2001 🔖 chore(release): v1.0.0-beta.21 [skip ci]
# [1.0.0-beta.21](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.20...v1.0.0-beta.21) (2025-02-23)

###  Features

* **privacy-policy**: Add privacy policy related text and links ([baa68f0](https://github.com/perfect-panel/ppanel-web/commit/baa68f0))

### 🐛 Bug Fixes

* **locales**: Removed multilingual files to clean up unnecessary language support ([5b151cd](https://github.com/perfect-panel/ppanel-web/commit/5b151cd))
* **locales**: Update custom HTML description in language file, ([87381da](https://github.com/perfect-panel/ppanel-web/commit/87381da))
* **table**: Update privacy policy tab translation key and remove unnecessary requestType from OAuth callback ([14b3af5](https://github.com/perfect-panel/ppanel-web/commit/14b3af5))
2025-02-23 14:17:29 +00:00
web@ppanel 5b151cdbfc 🐛 fix(locales): Removed multilingual files to clean up unnecessary language support 2025-02-23 21:14:17 +07:00
web@ppanel 14b3af5f96 🐛 fix(table): Update privacy policy tab translation key and remove unnecessary requestType from OAuth callback 2025-02-23 20:59:48 +07:00
web@ppanel baa68f03e3 feat(privacy-policy): Add privacy policy related text and links 2025-02-23 20:54:56 +07:00
web@ppanel 87381da1e5 🐛 fix(locales): Update custom HTML description in language file, 2025-02-23 17:25:31 +07:00
semantic-release-bot bbd44f0107 🔖 chore(release): v1.0.0-beta.20 [skip ci]
# [1.0.0-beta.20](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.19...v1.0.0-beta.20) (2025-02-23)

### 🐛 Bug Fixes

* **auth**: Add error handling to form submission and reset Turnstile on failure ([715d011](https://github.com/perfect-panel/ppanel-web/commit/715d011))
* **subscribe**: Update subscription domain placeholder to include examples; improve site name retrieval in global store ([c65a44c](https://github.com/perfect-panel/ppanel-web/commit/c65a44c))
2025-02-23 06:31:01 +00:00
web@ppanel c65a44c2c7 🐛 fix(subscribe): Update subscription domain placeholder to include examples; improve site name retrieval in global store 2025-02-23 13:25:01 +07:00
web@ppanel 715d0113ca 🐛 fix(auth): Add error handling to form submission and reset Turnstile on failure 2025-02-23 13:12:25 +07:00
semantic-release-bot 0f15fb82cb 🔖 chore(release): v1.0.0-beta.19 [skip ci]
# [1.0.0-beta.19](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.18...v1.0.0-beta.19) (2025-02-23)

###  Features

* **form**: Make version field optional and set default value; update site domain placeholder for clarity ([42ba9e8](https://github.com/perfect-panel/ppanel-web/commit/42ba9e8))

### 🐛 Bug Fixes

* **auth**: Refactor forms to use Turnstile ref for reset functionality ([320a7dc](https://github.com/perfect-panel/ppanel-web/commit/320a7dc))
2025-02-23 05:58:05 +00:00
web@ppanel 320a7dc342 🐛 fix(auth): Refactor forms to use Turnstile ref for reset functionality 2025-02-23 12:55:21 +07:00
web@ppanel 42ba9e88ad feat(form): Make version field optional and set default value; update site domain placeholder for clarity 2025-02-23 12:46:29 +07:00
semantic-release-bot 4a002333d4 🔖 chore(release): v1.0.0-beta.18 [skip ci]
# [1.0.0-beta.18](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.17...v1.0.0-beta.18) (2025-02-22)

###  Features

* **platform**: Update platform naming and add keywords and custom HTML fields ([6384237](https://github.com/perfect-panel/ppanel-web/commit/6384237))
* **site**: Added localization support for custom HTML and keyword fields ([f9d7736](https://github.com/perfect-panel/ppanel-web/commit/f9d7736))

### 🐛 Bug Fixes

* **locales**: Update custom HTML description for clarity across multiple languages ([557c5cd](https://github.com/perfect-panel/ppanel-web/commit/557c5cd))
2025-02-22 16:38:46 +00:00
web@ppanel 6384237782 feat(platform): Update platform naming and add keywords and custom HTML fields 2025-02-22 23:35:30 +07:00
web@ppanel 557c5cdc29 🐛 fix(locales): Update custom HTML description for clarity across multiple languages 2025-02-22 12:51:57 +07:00
web@ppanel f9d7736e48 feat(site): Added localization support for custom HTML and keyword fields 2025-02-22 12:33:09 +07:00
semantic-release-bot 5b64389544 🔖 chore(release): v1.0.0-beta.17 [skip ci]
# [1.0.0-beta.17](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.16...v1.0.0-beta.17) (2025-02-21)

### 🐛 Bug Fixes

* **auth**: Refactor reset password form to simplify code input and update placeholder text ([23833b4](https://github.com/perfect-panel/ppanel-web/commit/23833b4))
2025-02-21 13:26:11 +00:00
web@ppanel 23833b47d1 🐛 fix(auth): Refactor reset password form to simplify code input and update placeholder text 2025-02-21 20:23:30 +07:00
semantic-release-bot c3eff0a0e7 🔖 chore(release): v1.0.0-beta.16 [skip ci]
# [1.0.0-beta.16](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.15...v1.0.0-beta.16) (2025-02-21)

### 🐛 Bug Fixes

* **auth**: Simplify email verification code input rendering ([6f7bc37](https://github.com/perfect-panel/ppanel-web/commit/6f7bc37))
2025-02-21 13:18:47 +00:00
web@ppanel 6f7bc37233 🐛 fix(auth): Simplify email verification code input rendering 2025-02-21 20:16:02 +07:00
semantic-release-bot b2e8fadaf3 🔖 chore(release): v1.0.0-beta.15 [skip ci]
# [1.0.0-beta.15](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.14...v1.0.0-beta.15) (2025-02-21)

### 🐛 Bug Fixes

* **profile**: Restore filter to ensure only valid OAuth accounts are shown ([315c8f9](https://github.com/perfect-panel/ppanel-web/commit/315c8f9))
2025-02-21 12:59:02 +00:00
web@ppanel 315c8f9afe 🐛 fix(profile): Restore filter to ensure only valid OAuth accounts are shown 2025-02-21 19:55:24 +07:00
semantic-release-bot 0fb0d8bc11 🔖 chore(release): v1.0.0-beta.14 [skip ci]
# [1.0.0-beta.14](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.13...v1.0.0-beta.14) (2025-02-21)

### 🐛 Bug Fixes

* **locales**: Fixed description in multilingual files, updated text related to email registration functionality ([c356bc2](https://github.com/perfect-panel/ppanel-web/commit/c356bc2))
2025-02-21 07:07:36 +00:00
web@ppanel c356bc26ff 🐛 fix(locales): Fixed description in multilingual files, updated text related to email registration functionality 2025-02-21 14:04:15 +07:00
semantic-release-bot 23c974a94d 🔖 chore(release): v1.0.0-beta.13 [skip ci]
# [1.0.0-beta.13](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.12...v1.0.0-beta.13) (2025-02-21)

### 🐛 Bug Fixes

* **api**: Fix type error in API request and add return URL parameter ([ee286dd](https://github.com/perfect-panel/ppanel-web/commit/ee286dd))
2025-02-21 06:03:00 +00:00
web@ppanel ee286dd629 🐛 fix(api): Fix type error in API request and add return URL parameter 2025-02-21 12:59:23 +07:00
semantic-release-bot 4418c47f4f 🔖 chore(release): v1.0.0-beta.12 [skip ci]
# [1.0.0-beta.12](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2025-02-18)

### 🐛 Bug Fixes

* More bugs ([2d88a3a](https://github.com/perfect-panel/ppanel-web/commit/2d88a3a))
2025-02-18 14:57:24 +00:00
web@ppanel 2d88a3a81d 🐛 fix: More bugs 2025-02-18 21:53:24 +07:00
semantic-release-bot 1f29506179 🔖 chore(release): v1.0.0-beta.11 [skip ci]
# [1.0.0-beta.11](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2025-02-16)

### 🐛 Bug Fixes

* **email**: Update platform configuration handling to use current ref for consistency ([c90175b](https://github.com/perfect-panel/ppanel-web/commit/c90175b))
2025-02-16 12:09:00 +00:00
web@ppanel c90175b6eb 🐛 fix(email): Update platform configuration handling to use current ref for consistency 2025-02-16 19:06:17 +07:00
semantic-release-bot 5cf573ab03 🔖 chore(release): v1.0.0-beta.10 [skip ci]
# [1.0.0-beta.10](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2025-02-16)

### 🐛 Bug Fixes

* **subscribe**: Update forms to include refetch functionality and improve toast messages ([fc55e95](https://github.com/perfect-panel/ppanel-web/commit/fc55e95))
2025-02-16 10:14:25 +00:00
web@ppanel fc55e9544d 🐛 fix(subscribe): Update forms to include refetch functionality and improve toast messages 2025-02-16 17:08:34 +07:00
semantic-release-bot 855d1b096f 🔖 chore(release): v1.0.0-beta.9 [skip ci]
# [1.0.0-beta.9](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2025-02-15)

### 🐛 Bug Fixes

* **register**: Adjust user email verification logic to handle domain suffix checks correctly ([686aa2d](https://github.com/perfect-panel/ppanel-web/commit/686aa2d))
2025-02-15 15:02:16 +00:00
web@ppanel 686aa2d0ba 🐛 fix(register): Adjust user email verification logic to handle domain suffix checks correctly 2025-02-15 21:58:25 +07:00
semantic-release-bot a593eac119 🔖 chore(release): v1.0.0-beta.8 [skip ci]
# [1.0.0-beta.8](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.7...v1.0.0-beta.8) (2025-02-15)

###  Features

* **accounts**: Update third-party account binding and unbinding ([1841552](https://github.com/perfect-panel/ppanel-web/commit/1841552))
* **auth-control**: Adding phone number labels to mobile verification configurations in multiple languages ([046740f](https://github.com/perfect-panel/ppanel-web/commit/046740f))
* **auth-control**: Update general ([3883646](https://github.com/perfect-panel/ppanel-web/commit/3883646))
* **auth**: Add type parameter to SendCode and update related API typings ([4198871](https://github.com/perfect-panel/ppanel-web/commit/4198871))
* **auth**: Refactor mobile authentication config to support whitelist functionality ([c761ec7](https://github.com/perfect-panel/ppanel-web/commit/c761ec7))
* **device**: Modify IMEI to device identifier support ([e3f9ef6](https://github.com/perfect-panel/ppanel-web/commit/e3f9ef6))
* **imei**: Add IMEI related internationalization support and menu items ([13c3337](https://github.com/perfect-panel/ppanel-web/commit/13c3337))
* **locales**: Add kick offline confirmation and success messages in multiple languages ([5db5343](https://github.com/perfect-panel/ppanel-web/commit/5db5343))
* **locales**: Update 'sms' to 'mobile' in authentication methods across multiple languages ([fea2171](https://github.com/perfect-panel/ppanel-web/commit/fea2171))
* **log**: Add message log retrieval functionality and update related typings ([1c0ecae](https://github.com/perfect-panel/ppanel-web/commit/1c0ecae))
* **profile**:  Update localization strings and enhance third-party account binding ([2d1effb](https://github.com/perfect-panel/ppanel-web/commit/2d1effb))
* **release**: Extend supported platforms for Docker images, closes [#9](https://github.com/perfect-panel/ppanel-web/issues/9) ([e3a31eb](https://github.com/perfect-panel/ppanel-web/commit/e3a31eb))
* **subscription**: Add delete user subscription functionality ([1fc3a10](https://github.com/perfect-panel/ppanel-web/commit/1fc3a10))
* **ui**: Update input components and enhance card minimum width for better layout ([8a02310](https://github.com/perfect-panel/ppanel-web/commit/8a02310))
* **user**: Add user Detail ([3a3d223](https://github.com/perfect-panel/ppanel-web/commit/3a3d223))
* **user**: Add User Detail ([fdaf11b](https://github.com/perfect-panel/ppanel-web/commit/fdaf11b))

### 🐛 Bug Fixes

* **auth-control**: Fix citation error for platform values ([c940f3c](https://github.com/perfect-panel/ppanel-web/commit/c940f3c))
* **auth-control**: Fix citation error for platform values ([28813d2](https://github.com/perfect-panel/ppanel-web/commit/28813d2))
* **auth-control**: Rename phone_variable to phone_number in mobile verification configuration ([e5455aa](https://github.com/perfect-panel/ppanel-web/commit/e5455aa))
* **auth**: Update authentication configuration and localization strings ([47f2c58](https://github.com/perfect-panel/ppanel-web/commit/47f2c58))
* **locales**: Update expiration time description from minutes to seconds in multiple languages ([5bac933](https://github.com/perfect-panel/ppanel-web/commit/5bac933))
* **notify**: Ensure user info is updated after notification settings submission ([9bc3a94](https://github.com/perfect-panel/ppanel-web/commit/9bc3a94))
* **notify**: Set default values for notification settings to false ([3652819](https://github.com/perfect-panel/ppanel-web/commit/3652819))
* **third-party-accounts**: Remove mobile display logic from third-party accounts component ([b4946f7](https://github.com/perfect-panel/ppanel-web/commit/b4946f7))
* **third-party-accounts**: Update redirect property name in binding response handling ([012e83a](https://github.com/perfect-panel/ppanel-web/commit/012e83a))
* **user**: Refactor user form validation and reset password fields ([6733fc2](https://github.com/perfect-panel/ppanel-web/commit/6733fc2))
* **user**: Update locales ([4e7d249](https://github.com/perfect-panel/ppanel-web/commit/4e7d249))
* **user**: Update notification and verify code settings ([574b043](https://github.com/perfect-panel/ppanel-web/commit/574b043))

### 💄 Styles

* **dashboard**: Enhance card components with full height and improved empty state handling ([7e1d551](https://github.com/perfect-panel/ppanel-web/commit/7e1d551))
2025-02-15 11:08:52 +00:00
web@ppanel 7e1d551d8f 💄 style(dashboard): Enhance card components with full height and improved empty state handling 2025-02-15 18:06:07 +07:00
web@ppanel 1fc3a10c67 feat(subscription): Add delete user subscription functionality 2025-02-14 23:58:10 +07:00
web@ppanel 9bc3a940ba 🐛 fix(notify): Ensure user info is updated after notification settings submission 2025-02-14 23:42:54 +07:00
web@ppanel 3652819ac9 🐛 fix(notify): Set default values for notification settings to false 2025-02-14 23:01:16 +07:00
web@ppanel c761ec7538 feat(auth): Refactor mobile authentication config to support whitelist functionality 2025-02-14 22:13:50 +07:00
web@ppanel 5bac933fbc 🐛 fix(locales): Update expiration time description from minutes to seconds in multiple languages 2025-02-14 15:53:26 +07:00
web@ppanel 8a02310752 feat(ui): Update input components and enhance card minimum width for better layout 2025-02-14 15:49:34 +07:00
web@ppanel 574b043be6 🐛 fix(user): Update notification and verify code settings 2025-02-14 15:39:25 +07:00
web@ppanel e3f9ef6ebe feat(device): Modify IMEI to device identifier support 2025-02-13 22:28:45 +07:00
web@ppanel 4e7d249902 🐛 fix(user): Update locales 2025-02-13 21:42:20 +07:00
web@ppanel 5db5343e4b feat(locales): Add kick offline confirmation and success messages in multiple languages 2025-02-13 21:37:05 +07:00
web@ppanel 1c0ecaecbd feat(log): Add message log retrieval functionality and update related typings 2025-02-13 21:25:21 +07:00
web@ppanel 13c33378aa feat(imei): Add IMEI related internationalization support and menu items 2025-02-12 16:34:23 +07:00
web@ppanel b4946f7a06 🐛 fix(third-party-accounts): Remove mobile display logic from third-party accounts component 2025-02-10 23:12:43 +07:00
web@ppanel 012e83a25c 🐛 fix(third-party-accounts): Update redirect property name in binding response handling 2025-02-10 22:51:31 +07:00
web@ppanel 1841552b1b feat(accounts): Update third-party account binding and unbinding 2025-02-10 21:39:28 +07:00
web@ppanel 4198871eef feat(auth): Add type parameter to SendCode and update related API typings 2025-02-10 14:39:31 +07:00
web@ppanel e5455aa1dc 🐛 fix(auth-control): Rename phone_variable to phone_number in mobile verification configuration 2025-02-10 13:05:10 +07:00
web@ppanel 046740f037 feat(auth-control): Adding phone number labels to mobile verification configurations in multiple languages 2025-02-10 13:03:48 +07:00
web@ppanel fea2171d54 feat(locales): Update 'sms' to 'mobile' in authentication methods across multiple languages 2025-02-09 22:24:00 +07:00
web@ppanel c940f3c209 🐛 fix(auth-control): Fix citation error for platform values 2025-02-09 22:06:29 +07:00
web@ppanel 28813d2d3a 🐛 fix(auth-control): Fix citation error for platform values 2025-02-09 21:12:26 +07:00
web@ppanel 2d1effb7ab feat(profile): Update localization strings and enhance third-party account binding 2025-02-09 21:05:56 +07:00
web@ppanel 3883646566 feat(auth-control): Update general 2025-02-09 20:14:27 +07:00
web@ppanel e3a31eb709 feat(release): Extend supported platforms for Docker images (#9) 2025-02-09 16:34:43 +07:00
web@ppanel 47f2c5853c 🐛 fix(auth): Update authentication configuration and localization strings 2025-02-08 23:58:03 +07:00
web@ppanel 3a3d2236e2 feat(user): Add user Detail 2025-02-08 14:47:23 +07:00
web@ppanel fdaf11b654 feat(user): Add User Detail 2025-02-02 14:40:29 +07:00
web@ppanel 6733fc211c 🐛 fix(user): Refactor user form validation and reset password fields 2025-01-29 17:16:25 +07:00
semantic-release-bot f60d40c06d 🔖 chore(release): v1.0.0-beta.7 [skip ci]
# [1.0.0-beta.7](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.6...v1.0.0-beta.7) (2025-01-28)

### ♻ Code Refactoring

* **sbscribe**: Rename and reorganize components for better structure and clarity ([5e5e4ed](https://github.com/perfect-panel/ppanel-web/commit/5e5e4ed))

###  Features

* **auth**: Add email and SMS code sending functionality with localization updates ([57eaa55](https://github.com/perfect-panel/ppanel-web/commit/57eaa55))
* **auth**: Add Oauth configuration for Telegram, Facebook, Google, Github, and Apple ([18ee600](https://github.com/perfect-panel/ppanel-web/commit/18ee600))
* **auth**: Add SMS and email configuration options to global store and update localization ([4acf7b1](https://github.com/perfect-panel/ppanel-web/commit/4acf7b1))
* **auth**: Enhance user registration with invite handling and logo display ([207bc24](https://github.com/perfect-panel/ppanel-web/commit/207bc24))
* **auth**: Redirect user after OAuth login and add logos icon collection ([aa6dda8](https://github.com/perfect-panel/ppanel-web/commit/aa6dda8))
* **config**: Add application selection and encryption settings to configuration form ([88b3504](https://github.com/perfect-panel/ppanel-web/commit/88b3504))
* **config**: Update encryption fields in configuration form and refactor OAuth callback parameters ([652e032](https://github.com/perfect-panel/ppanel-web/commit/652e032))
* **global**: Add SMS configuration options to global store ([39a9ce6](https://github.com/perfect-panel/ppanel-web/commit/39a9ce6))
* **locales**: Add area code and telephone fields to user forms in multiple languages ([9b8258c](https://github.com/perfect-panel/ppanel-web/commit/9b8258c))
* **locales**: Add description information of communication keys and encryption methods to enhance client configuration capabilities ([d1f5a9b](https://github.com/perfect-panel/ppanel-web/commit/d1f5a9b))
* **node**: Add tags ([f408fdf](https://github.com/perfect-panel/ppanel-web/commit/f408fdf))
* **node**: Move the node configuration to the server module ([7f0f5ce](https://github.com/perfect-panel/ppanel-web/commit/7f0f5ce))
* **oauth**: Add certification component for handling OAuth login callbacks and improve user authentication flow ([5ed04c0](https://github.com/perfect-panel/ppanel-web/commit/5ed04c0))
* **oauth**: Implement OAuth token retrieval and refactor login callback handling ([40a6f7c](https://github.com/perfect-panel/ppanel-web/commit/40a6f7c))
* **oauth**: Refactor platform parameter handling and improve logout redirection logic ([8346c85](https://github.com/perfect-panel/ppanel-web/commit/8346c85))
* **oauth**: Update OAuth login handling to use callback parameter and improve URL parameter retrieval ([9227411](https://github.com/perfect-panel/ppanel-web/commit/9227411))
* **sms**: Update locales ([938363b](https://github.com/perfect-panel/ppanel-web/commit/938363b))
* **subscribe**: Add 'sold' column to SubscribeTable and update inventory terminology ([19619fd](https://github.com/perfect-panel/ppanel-web/commit/19619fd))
* **subscribe**: Move subscription configuration and application to subscription module ([f90d4d2](https://github.com/perfect-panel/ppanel-web/commit/f90d4d2))
* **subscribe**: Update SubscribeTable component to use API.SubscribeItem type and ensure proper type casting ([f26f1c2](https://github.com/perfect-panel/ppanel-web/commit/f26f1c2))
* **tutorial**: Fetch the latest tutorial version from GitHub API for dynamic URL generation ([28f8c78](https://github.com/perfect-panel/ppanel-web/commit/28f8c78))
* **user**: Add telephone input with area code selection and update localization ([585b99c](https://github.com/perfect-panel/ppanel-web/commit/585b99c))
* Update Auth Control ([c59742a](https://github.com/perfect-panel/ppanel-web/commit/c59742a))

### 🐛 Bug Fixes

* **api**: Remove redundant requestType parameter in appleLoginCallback ([0aa5d5b](https://github.com/perfect-panel/ppanel-web/commit/0aa5d5b))
* **api**: Rename app-related functions and types to application for consistency ([9d8b814](https://github.com/perfect-panel/ppanel-web/commit/9d8b814))
* **api**: Update subscription_protocol to subscribe_type for consistency across services ([b6da51b](https://github.com/perfect-panel/ppanel-web/commit/b6da51b))
* **auth**: Change Textarea value to defaultValue for client_secret in Apple auth page ([69fc670](https://github.com/perfect-panel/ppanel-web/commit/69fc670))
* **auth**: Remove unused telephone code login function and update typings for telephone login requests ([7239685](https://github.com/perfect-panel/ppanel-web/commit/7239685))
* **auth**: Require minimum length for invite string when forced invite is enabled ([a604f28](https://github.com/perfect-panel/ppanel-web/commit/a604f28))
* **auth**: Update user authentication flow to include email and phone code verification ([5d078fd](https://github.com/perfect-panel/ppanel-web/commit/5d078fd))
* **dashboard**:  Improve URL encoding for subscription links and enhance success message handling ([4983c33](https://github.com/perfect-panel/ppanel-web/commit/4983c33))
* **dashboard**: Update icon imports for platform consistency and adjust icon size ([3e8912e](https://github.com/perfect-panel/ppanel-web/commit/3e8912e))
* **dashboard**: Update platform detection logic and improve layout responsiveness ([b0aa364](https://github.com/perfect-panel/ppanel-web/commit/b0aa364))
* **deps**: Remove outdated @iconify/react dependency and add iconify-json packages ([d6fbc38](https://github.com/perfect-panel/ppanel-web/commit/d6fbc38))
* **locales**: Add error message for incorrect user information ([52c1d1f](https://github.com/perfect-panel/ppanel-web/commit/52c1d1f))
* **locales**: Add error message for incorrect user information ([3d92902](https://github.com/perfect-panel/ppanel-web/commit/3d92902))
* **locales**: Add logout message to authentication localization files ([1d0d911](https://github.com/perfect-panel/ppanel-web/commit/1d0d911))
* **nav**: Comment out unused social login options to simplify navigation configuration ([cefcb31](https://github.com/perfect-panel/ppanel-web/commit/cefcb31))
* **node-config**: Add null checks for time slots and ensure proper handling of undefined values ([1cdb7e7](https://github.com/perfect-panel/ppanel-web/commit/1cdb7e7))
* **node**: Add country and city fields to the form schema and localization files ([8775fb6](https://github.com/perfect-panel/ppanel-web/commit/8775fb6))
* **oauth**: Refactor OAuth configuration types and update related API methods ([6227ba9](https://github.com/perfect-panel/ppanel-web/commit/6227ba9))
* **oauth**: Remove redundant checks when updating configuration to simplify logic ([9140b8a](https://github.com/perfect-panel/ppanel-web/commit/9140b8a))
* **payment**: Replace window.open with window.location.href for checkout links ([1d8c765](https://github.com/perfect-panel/ppanel-web/commit/1d8c765))
* **phone**: Update SMS expiration time field to use 'sms_expire_time' with default value of 300 ([18b07c7](https://github.com/perfect-panel/ppanel-web/commit/18b07c7))
* **redirect**: Simplify redirect URL logic by removing unnecessary condition for sessionStorage ([c53ac61](https://github.com/perfect-panel/ppanel-web/commit/c53ac61))
* **redirect**: Update redirect URL logic to ensure proper handling of OAuth and auth paths ([7954762](https://github.com/perfect-panel/ppanel-web/commit/7954762))
* **site**: Add image upload functionality for site logo configuration ([4ea6e4a](https://github.com/perfect-panel/ppanel-web/commit/4ea6e4a))
* **sort**: Refactor sorting logic in NodeTable and SubscribeTable components for improved clarity and performance ([331bbea](https://github.com/perfect-panel/ppanel-web/commit/331bbea))
* **subscription**: Add reset functionality for user subscription token ([39e89bf](https://github.com/perfect-panel/ppanel-web/commit/39e89bf))
* **type**: Fix ts type check error ([3cb0629](https://github.com/perfect-panel/ppanel-web/commit/3cb0629))
* **user-nav**: Update user avatar and label to display telephone if email is not available ([7b6bb7b](https://github.com/perfect-panel/ppanel-web/commit/7b6bb7b))
* **user**: Update user identifier field and localizations ([1b6befa](https://github.com/perfect-panel/ppanel-web/commit/1b6befa))

### 💄 Styles

* **dashboard**: Adjust grid layout and update image dimensions in application display ([f3204b7](https://github.com/perfect-panel/ppanel-web/commit/f3204b7))
* **globals**: Refactor delete confirmation button and update badge styles in node and subscribe tables ([30ae781](https://github.com/perfect-panel/ppanel-web/commit/30ae781))
* Update node secret UI and add telephone code field to authentication form ([770932e](https://github.com/perfect-panel/ppanel-web/commit/770932e))
2025-01-28 16:03:56 +00:00
web@ppanel 1b6befac87 🐛 fix(user): Update user identifier field and localizations 2025-01-28 23:00:49 +07:00
web@ppanel 3cb0629d64 🐛 fix(type): Fix ts type check error 2025-01-28 13:55:43 +07:00
web@ppanel 1d8c765c14 🐛 fix(payment): Replace window.open with window.location.href for checkout links 2025-01-28 13:45:55 +07:00
web@ppanel 39e89bfef5 🐛 fix(subscription): Add reset functionality for user subscription token 2025-01-28 13:45:25 +07:00
web@ppanel 52c1d1fc60 🐛 fix(locales): Add error message for incorrect user information 2025-01-28 13:44:31 +07:00
web@ppanel 3d92902f1b 🐛 fix(locales): Add error message for incorrect user information 2025-01-28 13:43:53 +07:00
web@ppanel 331bbea45f 🐛 fix(sort): Refactor sorting logic in NodeTable and SubscribeTable components for improved clarity and performance 2025-01-28 13:43:18 +07:00
web@ppanel a604f28fef 🐛 fix(auth): Require minimum length for invite string when forced invite is enabled 2025-01-23 16:10:44 +07:00
web@ppanel 69fc670ba3 🐛 fix(auth): Change Textarea value to defaultValue for client_secret in Apple auth page 2025-01-23 00:29:40 +07:00
web@ppanel c53ac61501 🐛 fix(redirect): Simplify redirect URL logic by removing unnecessary condition for sessionStorage 2025-01-22 22:42:02 +07:00
web@ppanel 795476209a 🐛 fix(redirect): Update redirect URL logic to ensure proper handling of OAuth and auth paths 2025-01-22 22:41:17 +07:00
web@ppanel 9227411d74 feat(oauth): Update OAuth login handling to use callback parameter and improve URL parameter retrieval 2025-01-22 22:16:04 +07:00
web@ppanel 0aa5d5b0a9 🐛 fix(api): Remove redundant requestType parameter in appleLoginCallback 2025-01-21 23:38:14 +07:00
web@ppanel 40a6f7ca81 feat(oauth): Implement OAuth token retrieval and refactor login callback handling 2025-01-21 23:32:12 +07:00
web@ppanel 8346c85109 feat(oauth): Refactor platform parameter handling and improve logout redirection logic 2025-01-21 22:39:23 +07:00
web@ppanel aa6dda894b feat(auth): Redirect user after OAuth login and add logos icon collection 2025-01-20 23:55:58 +07:00
web@ppanel 5ed04c0a59 feat(oauth): Add certification component for handling OAuth login callbacks and improve user authentication flow 2025-01-20 23:50:42 +07:00
web@ppanel 652e0323fd feat(config): Update encryption fields in configuration form and refactor OAuth callback parameters 2025-01-20 22:23:04 +07:00
web@ppanel d1f5a9b825 feat(locales): Add description information of communication keys and encryption methods to enhance client configuration capabilities 2025-01-20 20:19:57 +07:00
web@ppanel 88b3504735 feat(config): Add application selection and encryption settings to configuration form 2025-01-20 20:17:49 +07:00
web@ppanel 28f8c78963 feat(tutorial): Fetch the latest tutorial version from GitHub API for dynamic URL generation 2025-01-20 16:02:53 +07:00
web@ppanel 9140b8ad1e 🐛 fix(oauth): Remove redundant checks when updating configuration to simplify logic 2025-01-19 22:36:50 +07:00
web@ppanel 6227ba91d0 🐛 fix(oauth): Refactor OAuth configuration types and update related API methods 2025-01-19 22:34:12 +07:00
web@ppanel 18ee600836 feat(auth): Add Oauth configuration for Telegram, Facebook, Google, Github, and Apple 2025-01-19 20:43:35 +07:00
web@ppanel cefcb310d6 🐛 fix(nav): Comment out unused social login options to simplify navigation configuration 2025-01-18 21:53:44 +07:00
web@ppanel 4ea6e4a2a3 🐛 fix(site): Add image upload functionality for site logo configuration 2025-01-18 21:46:05 +07:00
web@ppanel 4983c33bb3 🐛 fix(dashboard): Improve URL encoding for subscription links and enhance success message handling 2025-01-18 21:40:01 +07:00
web@ppanel 3e8912e7bb 🐛 fix(dashboard): Update icon imports for platform consistency and adjust icon size 2025-01-18 19:40:36 +07:00
web@ppanel b6da51b618 🐛 fix(api): Update subscription_protocol to subscribe_type for consistency across services 2025-01-18 19:36:39 +07:00
web@ppanel 9d8b814212 🐛 fix(api): Rename app-related functions and types to application for consistency 2025-01-18 18:48:28 +07:00
web@ppanel 5e5e4edd2e ♻️ refactor(sbscribe): Rename and reorganize components for better structure and clarity 2025-01-18 17:39:57 +07:00
web@ppanel d6fbc38795 🐛 fix(deps): Remove outdated @iconify/react dependency and add iconify-json packages 2025-01-18 17:39:26 +07:00
web@ppanel b0aa3645f8 🐛 fix(dashboard): Update platform detection logic and improve layout responsiveness 2025-01-16 21:57:30 +07:00
web@ppanel 30ae781f05 💄 style(globals): Refactor delete confirmation button and update badge styles in node and subscribe tables 2025-01-16 15:29:59 +07:00
web@ppanel f3204b757f 💄 style(dashboard): Adjust grid layout and update image dimensions in application display 2025-01-16 14:50:27 +07:00
web@ppanel 1d0d911741 🐛 fix(locales): Add logout message to authentication localization files 2025-01-16 14:40:08 +07:00
web@ppanel 5d078fdaca 🐛 fix(auth): Update user authentication flow to include email and phone code verification 2025-01-16 14:26:33 +07:00
web@ppanel 8775fb620e 🐛 fix(node): Add country and city fields to the form schema and localization files 2025-01-16 14:15:40 +07:00
web@ppanel 770932e957 💄 style: Update node secret UI and add telephone code field to authentication form 2025-01-16 12:13:44 +07:00
web@ppanel 1cdb7e7872 🐛 fix(node-config): Add null checks for time slots and ensure proper handling of undefined values 2025-01-15 22:50:12 +07:00
web@ppanel 72396851ff 🐛 fix(auth): Remove unused telephone code login function and update typings for telephone login requests 2025-01-15 22:49:40 +07:00
web@ppanel 7b6bb7bc69 🐛 fix(user-nav): Update user avatar and label to display telephone if email is not available 2025-01-15 21:46:30 +07:00
web@ppanel 57eaa5532d feat(auth): Add email and SMS code sending functionality with localization updates 2025-01-15 21:41:54 +07:00
web@ppanel 4acf7b1837 feat(auth): Add SMS and email configuration options to global store and update localization 2025-01-15 15:49:11 +07:00
web@ppanel 9b8258ce5a feat(locales): Add area code and telephone fields to user forms in multiple languages 2025-01-14 19:26:38 +07:00
web@ppanel 585b99c2cc feat(user): Add telephone input with area code selection and update localization 2025-01-14 19:24:46 +07:00
web@ppanel 39a9ce60de feat(global): Add SMS configuration options to global store 2025-01-14 16:40:02 +07:00
web@ppanel 207bc247f4 feat(auth): Enhance user registration with invite handling and logo display 2025-01-14 16:33:09 +07:00
web@ppanel 938363b1eb feat(sms): Update locales 2025-01-14 16:15:25 +07:00
web@ppanel 7f0f5ce9db feat(node): Move the node configuration to the server module 2025-01-14 14:41:34 +07:00
web@ppanel f90d4d2ce6 feat(subscribe): Move subscription configuration and application to subscription module 2025-01-14 14:12:26 +07:00
web@ppanel 18b07c750d 🐛 fix(phone): Update SMS expiration time field to use 'sms_expire_time' with default value of 300 2025-01-13 22:06:14 +07:00
web@ppanel 19619fdbe0 feat(subscribe): Add 'sold' column to SubscribeTable and update inventory terminology 2025-01-13 21:54:49 +07:00
web@ppanel f408fdffae feat(node): Add tags 2025-01-13 21:37:56 +07:00
web@ppanel f26f1c24ae feat(subscribe): Update SubscribeTable component to use API.SubscribeItem type and ensure proper type casting 2025-01-13 21:15:35 +07:00
web@ppanel c59742a4f3 feat: Update Auth Control 2025-01-13 21:07:27 +07:00
semantic-release-bot dd23279e3e 🔖 chore(release): v1.0.0-beta.6 [skip ci]
# [1.0.0-beta.6](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.5...v1.0.0-beta.6) (2025-01-10)

### 🐛 Bug Fixes

* **auth**: Update UserCheckForm to use setInitialValues and modify onSubmit type ([c984c0d](https://github.com/perfect-panel/ppanel-web/commit/c984c0d))
2025-01-10 06:08:16 +00:00
web@ppanel c984c0d9c0 🐛 fix(auth): Update UserCheckForm to use setInitialValues and modify onSubmit type 2025-01-10 13:04:33 +07:00
semantic-release-bot f275f01f25 🔖 chore(release): v1.0.0-beta.5 [skip ci]
# [1.0.0-beta.5](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.4...v1.0.0-beta.5) (2025-01-09)

###  Features

* **locales**: Replace 'nodeGroupId' with 'groupId' in multiple language files for consistency ([a4e9d5d](https://github.com/perfect-panel/ppanel-web/commit/a4e9d5d))
* **locales**: Update 'deductBalance' to 'giftAmount' across multiple languages and fix newline in announcement.json ([70497af](https://github.com/perfect-panel/ppanel-web/commit/70497af))
* **stats**: Replace dynamic stat fetching with environment constants for user, server, and location counts ([46ae166](https://github.com/perfect-panel/ppanel-web/commit/46ae166))
* **subscribe**: Update suffix from 'MB' to 'Mbps' and enhance speed limit display logic ([3547bb1](https://github.com/perfect-panel/ppanel-web/commit/3547bb1))
* **user**: Add 'gift_amount' field and update related references in user services and components ([b13c77e](https://github.com/perfect-panel/ppanel-web/commit/b13c77e))

### 🎫 Chores

* **deps**: Update package dependencies across multiple projects for improved stability and performance ([b01a5bc](https://github.com/perfect-panel/ppanel-web/commit/b01a5bc))
* **ui**: Update package dependencies for improved stability and performance ([25da429](https://github.com/perfect-panel/ppanel-web/commit/25da429))

### 🐛 Bug Fixes

* **api**: Replace 'deduction' with 'gift_amount' and add 'commission' field in type definitions ([77edf1d](https://github.com/perfect-panel/ppanel-web/commit/77edf1d))
* **api**: Update API type definitions to replace 'deduction' with 'gift_amount' and make 'commission' field optional ([c2af060](https://github.com/perfect-panel/ppanel-web/commit/c2af060))
* **auth**: Refactor user authentication forms to remove global store dependency and improve type handling ([12026b0](https://github.com/perfect-panel/ppanel-web/commit/12026b0))
* **coupon**: Rename 'server' field to 'subscribe' in coupon form and update coupon update request type ([f8b6d82](https://github.com/perfect-panel/ppanel-web/commit/f8b6d82))
* **types**: Add 'gift_amount' field to API type definitions ([8f8a12a](https://github.com/perfect-panel/ppanel-web/commit/8f8a12a))
* **user**: Add the 'gift_amount' field to the user service's type definition ([6301409](https://github.com/perfect-panel/ppanel-web/commit/6301409))
2025-01-09 14:42:36 +00:00
web@ppanel c2af06052c 🐛 fix(api): Update API type definitions to replace 'deduction' with 'gift_amount' and make 'commission' field optional 2025-01-09 21:40:02 +07:00
web@ppanel 8f8a12a510 🐛 fix(types): Add 'gift_amount' field to API type definitions 2025-01-09 21:31:25 +07:00
web@ppanel 77edf1d6ec 🐛 fix(api): Replace 'deduction' with 'gift_amount' and add 'commission' field in type definitions 2025-01-09 21:25:21 +07:00
web@ppanel f8b6d82780 🐛 fix(coupon): Rename 'server' field to 'subscribe' in coupon form and update coupon update request type 2025-01-09 20:25:34 +07:00
web@ppanel b01a5bc83b 🔧 chore(deps): Update package dependencies across multiple projects for improved stability and performance 2025-01-09 17:28:34 +07:00
web@ppanel 25da429da6 🔧 chore(ui): Update package dependencies for improved stability and performance 2025-01-09 13:30:11 +07:00
web@ppanel 12026b02cd 🐛 fix(auth): Refactor user authentication forms to remove global store dependency and improve type handling 2025-01-09 13:19:18 +07:00
web@ppanel 6301409531 🐛 fix(user): Add the 'gift_amount' field to the user service's type definition 2025-01-08 23:42:34 +07:00
web@ppanel b13c77e9b7 feat(user): Add 'gift_amount' field and update related references in user services and components 2025-01-08 23:25:13 +07:00
web@ppanel a4e9d5da59 feat(locales): Replace 'nodeGroupId' with 'groupId' in multiple language files for consistency 2025-01-08 23:13:32 +07:00
web@ppanel 70497af0aa feat(locales): Update 'deductBalance' to 'giftAmount' across multiple languages and fix newline in announcement.json 2025-01-08 12:55:10 +07:00
web@ppanel 46ae1665be feat(stats): Replace dynamic stat fetching with environment constants for user, server, and location counts 2025-01-08 12:54:43 +07:00
web@ppanel 3547bb1596 feat(subscribe): Update suffix from 'MB' to 'Mbps' and enhance speed limit display logic 2025-01-08 11:14:16 +07:00
807 changed files with 25031 additions and 8671 deletions
+333 -37
View File
@@ -1,62 +1,358 @@
<a name="readme-top"></a> <a name="readme-top"></a>
# Changelog # Changelog
# [1.0.0-beta.4](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.3...v1.0.0-beta.4) (2025-01-07) # [1.0.0-beta.25](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.24...v1.0.0-beta.25) (2025-03-01)
### ♻ Code Refactoring
* **auth**: Refactor user authorization handling and improve error logging ([68bc18f](https://github.com/perfect-panel/ppanel-web/commit/68bc18f))
### ✨ Features ### ✨ Features
* **affiliate**: Add Affiliate component with commission display and invite link functionality ([4aea4e8](https://github.com/perfect-panel/ppanel-web/commit/4aea4e8)) * **auth**: Add privacy policy link to the footer ([8e16ef1](https://github.com/perfect-panel/ppanel-web/commit/8e16ef1))
* **affiliate**: Update affiliate component to display total commission and improve data fetching ([cc834ca](https://github.com/perfect-panel/ppanel-web/commit/cc834ca))
* **api**: Add new subscription properties and locale support for deduction ratios and reset cycles ([fec80f5](https://github.com/perfect-panel/ppanel-web/commit/fec80f5))
* **api**: Add Time Period Configuration ([837157c](https://github.com/perfect-panel/ppanel-web/commit/837157c))
* **favicon**: Update SVG favicon design for admin and user interfaces ([1d91738](https://github.com/perfect-panel/ppanel-web/commit/1d91738))
* **node**: Add serverKey ([25ce37e](https://github.com/perfect-panel/ppanel-web/commit/25ce37e))
* **relay**: Add relay mode configuration and update related schemas ([3cc9477](https://github.com/perfect-panel/ppanel-web/commit/3cc9477))
* **schema**: Add security field to hysteria2 and tuic schemas ([cd59d44](https://github.com/perfect-panel/ppanel-web/commit/cd59d44))
* **subscribe**: Add reset_time to API typings and update unsubscribe logic ([eeea165](https://github.com/perfect-panel/ppanel-web/commit/eeea165))
* **subscribe**: Add subscribe_discount type ([f99c604](https://github.com/perfect-panel/ppanel-web/commit/f99c604))
* **subscribe**: Add subscription credits ([5bc7905](https://github.com/perfect-panel/ppanel-web/commit/5bc7905))
* **subscribe**: Add unsubscribe functionality with confirmation messages and localized strings ([b2a2f42](https://github.com/perfect-panel/ppanel-web/commit/b2a2f42))
* **subscribe**: Improve error handling in subscription forms and update component props ([d28a10b](https://github.com/perfect-panel/ppanel-web/commit/d28a10b))
* **subscribe**: Improve layout and styling in subscription components ([5766376](https://github.com/perfect-panel/ppanel-web/commit/5766376))
* **subscription**: Add localized messages for existing subscriptions and deletion restrictions ([e8a72d5](https://github.com/perfect-panel/ppanel-web/commit/e8a72d5))
### 🎫 Chores
* Update changelog, enhance prepare script, and add openapi command ([a93db4e](https://github.com/perfect-panel/ppanel-web/commit/a93db4e))
### 🐛 Bug Fixes ### 🐛 Bug Fixes
* **dashboard**: Correct progress value calculations and update groupId accessor ([36c7667](https://github.com/perfect-panel/ppanel-web/commit/36c7667)) * **dashboard**: Display subscription creation date in user dashboard ([d0e6df0](https://github.com/perfect-panel/ppanel-web/commit/d0e6df0))
* **layout**: Remove unnecessary cookie initialization in Logout function ([3065c3a](https://github.com/perfect-panel/ppanel-web/commit/3065c3a)) * **request**: Add error code 40005 to trigger logout ([71bf002](https://github.com/perfect-panel/ppanel-web/commit/71bf002))
* **locales**: Update Hong Kong ([6d0d069](https://github.com/perfect-panel/ppanel-web/commit/6d0d069)) * **subscribe**: Update payment return URL ([2b80496](https://github.com/perfect-panel/ppanel-web/commit/2b80496))
* **subscribe**: Update value validation to check for number type in subscribe form ([6de29d5](https://github.com/perfect-panel/ppanel-web/commit/6de29d5))
# [1.0.0-beta.24](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.23...v1.0.0-beta.24) (2025-02-27)
### 💄 Styles ### ♻ Code Refactoring
* **locales**: Remove unused subscription labels from multiple locale files ([fb0c510](https://github.com/perfect-panel/ppanel-web/commit/fb0c510)) * **ui**: Optimize document display ([2ca2992](https://github.com/perfect-panel/ppanel-web/commit/2ca2992))
* **locales**: Update server.json to reorganize relay mode options and improve labels ([701cdee](https://github.com/perfect-panel/ppanel-web/commit/701cdee)) * Reduce code complexity and improve readability ([e11f18c](https://github.com/perfect-panel/ppanel-web/commit/e11f18c))
* **node**: Improve layout and spacing in NodeStatusCell component ([136287d](https://github.com/perfect-panel/ppanel-web/commit/136287d))
* **time-slot**: Add chart display ([c44ad47](https://github.com/perfect-panel/ppanel-web/commit/c44ad47))
### ✨ Features
* **loading**: Add loading components and integrate them in Providers ([d5847fa](https://github.com/perfect-panel/ppanel-web/commit/d5847fa))
### 🎫 Chores
* **merge**: Add advertising module and device settings ([0130e02](https://github.com/perfect-panel/ppanel-web/commit/0130e02))
### 🐛 Bug Fixes
* **locales**: Order recharge related fields ([35210fe](https://github.com/perfect-panel/ppanel-web/commit/35210fe))
<a name="readme-top"></a> <a name="readme-top"></a>
# Changelog # Changelog
# [1.0.0-beta.3](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.2...v1.0.0-beta.3) (2024-12-26) # [1.0.0-beta.23](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.22...v1.0.0-beta.23) (2025-02-24)
### 🐛 Bug Fixes
- **auth**: Update email verification logic to use domain suffix check ([62662bb](https://github.com/perfect-panel/ppanel-web/commit/62662bb))
# [1.0.0-beta.22](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.21...v1.0.0-beta.22) (2025-02-23)
### 🐛 Bug Fixes
- **locales**: Removed language file import to clean up unnecessary language support ([68f6ab2](https://github.com/perfect-panel/ppanel-web/commit/68f6ab2))
# [1.0.0-beta.21](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.20...v1.0.0-beta.21) (2025-02-23)
### ✨ Features
- **privacy-policy**: Add privacy policy related text and links ([baa68f0](https://github.com/perfect-panel/ppanel-web/commit/baa68f0))
### 🐛 Bug Fixes
- **locales**: Removed multilingual files to clean up unnecessary language support ([5b151cd](https://github.com/perfect-panel/ppanel-web/commit/5b151cd))
- **locales**: Update custom HTML description in language file, ([87381da](https://github.com/perfect-panel/ppanel-web/commit/87381da))
- **table**: Update privacy policy tab translation key and remove unnecessary requestType from OAuth callback ([14b3af5](https://github.com/perfect-panel/ppanel-web/commit/14b3af5))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.20](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.19...v1.0.0-beta.20) (2025-02-23)
### 🐛 Bug Fixes
- **auth**: Add error handling to form submission and reset Turnstile on failure ([715d011](https://github.com/perfect-panel/ppanel-web/commit/715d011))
- **subscribe**: Update subscription domain placeholder to include examples; improve site name retrieval in global store ([c65a44c](https://github.com/perfect-panel/ppanel-web/commit/c65a44c))
# [1.0.0-beta.19](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.18...v1.0.0-beta.19) (2025-02-23)
### ✨ Features
- **form**: Make version field optional and set default value; update site domain placeholder for clarity ([42ba9e8](https://github.com/perfect-panel/ppanel-web/commit/42ba9e8))
### 🐛 Bug Fixes
- **auth**: Refactor forms to use Turnstile ref for reset functionality ([320a7dc](https://github.com/perfect-panel/ppanel-web/commit/320a7dc))
# [1.0.0-beta.18](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.17...v1.0.0-beta.18) (2025-02-22)
### ✨ Features
- **platform**: Update platform naming and add keywords and custom HTML fields ([6384237](https://github.com/perfect-panel/ppanel-web/commit/6384237))
- **site**: Added localization support for custom HTML and keyword fields ([f9d7736](https://github.com/perfect-panel/ppanel-web/commit/f9d7736))
### 🐛 Bug Fixes
- **locales**: Update custom HTML description for clarity across multiple languages ([557c5cd](https://github.com/perfect-panel/ppanel-web/commit/557c5cd))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.17](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.16...v1.0.0-beta.17) (2025-02-21)
### 🐛 Bug Fixes
- **auth**: Refactor reset password form to simplify code input and update placeholder text ([23833b4](https://github.com/perfect-panel/ppanel-web/commit/23833b4))
# [1.0.0-beta.16](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.15...v1.0.0-beta.16) (2025-02-21)
### 🐛 Bug Fixes
- **auth**: Simplify email verification code input rendering ([6f7bc37](https://github.com/perfect-panel/ppanel-web/commit/6f7bc37))
# [1.0.0-beta.15](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.14...v1.0.0-beta.15) (2025-02-21)
### 🐛 Bug Fixes
- **profile**: Restore filter to ensure only valid OAuth accounts are shown ([315c8f9](https://github.com/perfect-panel/ppanel-web/commit/315c8f9))
# [1.0.0-beta.14](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.13...v1.0.0-beta.14) (2025-02-21)
### 🐛 Bug Fixes
- **locales**: Fixed description in multilingual files, updated text related to email registration functionality ([c356bc2](https://github.com/perfect-panel/ppanel-web/commit/c356bc2))
# [1.0.0-beta.13](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.12...v1.0.0-beta.13) (2025-02-21)
### 🐛 Bug Fixes
- **api**: Fix type error in API request and add return URL parameter ([ee286dd](https://github.com/perfect-panel/ppanel-web/commit/ee286dd))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.12](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.11...v1.0.0-beta.12) (2025-02-18)
### 🐛 Bug Fixes
- More bugs ([2d88a3a](https://github.com/perfect-panel/ppanel-web/commit/2d88a3a))
# [1.0.0-beta.11](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.10...v1.0.0-beta.11) (2025-02-16)
### 🐛 Bug Fixes
- **email**: Update platform configuration handling to use current ref for consistency ([c90175b](https://github.com/perfect-panel/ppanel-web/commit/c90175b))
# [1.0.0-beta.10](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.9...v1.0.0-beta.10) (2025-02-16)
### 🐛 Bug Fixes
- **subscribe**: Update forms to include refetch functionality and improve toast messages ([fc55e95](https://github.com/perfect-panel/ppanel-web/commit/fc55e95))
# [1.0.0-beta.9](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.8...v1.0.0-beta.9) (2025-02-15)
### 🐛 Bug Fixes
- **register**: Adjust user email verification logic to handle domain suffix checks correctly ([686aa2d](https://github.com/perfect-panel/ppanel-web/commit/686aa2d))
# [1.0.0-beta.8](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.7...v1.0.0-beta.8) (2025-02-15)
### ✨ Features
- **accounts**: Update third-party account binding and unbinding ([1841552](https://github.com/perfect-panel/ppanel-web/commit/1841552))
- **auth-control**: Adding phone number labels to mobile verification configurations in multiple languages ([046740f](https://github.com/perfect-panel/ppanel-web/commit/046740f))
- **auth-control**: Update general ([3883646](https://github.com/perfect-panel/ppanel-web/commit/3883646))
- **auth**: Add type parameter to SendCode and update related API typings ([4198871](https://github.com/perfect-panel/ppanel-web/commit/4198871))
- **auth**: Refactor mobile authentication config to support whitelist functionality ([c761ec7](https://github.com/perfect-panel/ppanel-web/commit/c761ec7))
- **device**: Modify IMEI to device identifier support ([e3f9ef6](https://github.com/perfect-panel/ppanel-web/commit/e3f9ef6))
- **imei**: Add IMEI related internationalization support and menu items ([13c3337](https://github.com/perfect-panel/ppanel-web/commit/13c3337))
- **locales**: Add kick offline confirmation and success messages in multiple languages ([5db5343](https://github.com/perfect-panel/ppanel-web/commit/5db5343))
- **locales**: Update 'sms' to 'mobile' in authentication methods across multiple languages ([fea2171](https://github.com/perfect-panel/ppanel-web/commit/fea2171))
- **log**: Add message log retrieval functionality and update related typings ([1c0ecae](https://github.com/perfect-panel/ppanel-web/commit/1c0ecae))
- **profile**: Update localization strings and enhance third-party account binding ([2d1effb](https://github.com/perfect-panel/ppanel-web/commit/2d1effb))
- **release**: Extend supported platforms for Docker images, closes [#9](https://github.com/perfect-panel/ppanel-web/issues/9) ([e3a31eb](https://github.com/perfect-panel/ppanel-web/commit/e3a31eb))
- **subscription**: Add delete user subscription functionality ([1fc3a10](https://github.com/perfect-panel/ppanel-web/commit/1fc3a10))
- **ui**: Update input components and enhance card minimum width for better layout ([8a02310](https://github.com/perfect-panel/ppanel-web/commit/8a02310))
- **user**: Add user Detail ([3a3d223](https://github.com/perfect-panel/ppanel-web/commit/3a3d223))
- **user**: Add User Detail ([fdaf11b](https://github.com/perfect-panel/ppanel-web/commit/fdaf11b))
### 🐛 Bug Fixes
- **auth-control**: Fix citation error for platform values ([c940f3c](https://github.com/perfect-panel/ppanel-web/commit/c940f3c))
- **auth-control**: Fix citation error for platform values ([28813d2](https://github.com/perfect-panel/ppanel-web/commit/28813d2))
- **auth-control**: Rename phone_variable to phone_number in mobile verification configuration ([e5455aa](https://github.com/perfect-panel/ppanel-web/commit/e5455aa))
- **auth**: Update authentication configuration and localization strings ([47f2c58](https://github.com/perfect-panel/ppanel-web/commit/47f2c58))
- **locales**: Update expiration time description from minutes to seconds in multiple languages ([5bac933](https://github.com/perfect-panel/ppanel-web/commit/5bac933))
- **notify**: Ensure user info is updated after notification settings submission ([9bc3a94](https://github.com/perfect-panel/ppanel-web/commit/9bc3a94))
- **notify**: Set default values for notification settings to false ([3652819](https://github.com/perfect-panel/ppanel-web/commit/3652819))
- **third-party-accounts**: Remove mobile display logic from third-party accounts component ([b4946f7](https://github.com/perfect-panel/ppanel-web/commit/b4946f7))
- **third-party-accounts**: Update redirect property name in binding response handling ([012e83a](https://github.com/perfect-panel/ppanel-web/commit/012e83a))
- **user**: Refactor user form validation and reset password fields ([6733fc2](https://github.com/perfect-panel/ppanel-web/commit/6733fc2))
- **user**: Update locales ([4e7d249](https://github.com/perfect-panel/ppanel-web/commit/4e7d249))
- **user**: Update notification and verify code settings ([574b043](https://github.com/perfect-panel/ppanel-web/commit/574b043))
### 💄 Styles
- **dashboard**: Enhance card components with full height and improved empty state handling ([7e1d551](https://github.com/perfect-panel/ppanel-web/commit/7e1d551))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.7](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.6...v1.0.0-beta.7) (2025-01-28)
### ♻ Code Refactoring ### ♻ Code Refactoring
- **config**: Simplify environment variable handling and improve build script ([cf54d0f](https://github.com/perfect-panel/ppanel-web/commit/cf54d0f)) - **sbscribe**: Rename and reorganize components for better structure and clarity ([5e5e4ed](https://github.com/perfect-panel/ppanel-web/commit/5e5e4ed))
### ✨ Features
- **auth**: Add email and SMS code sending functionality with localization updates ([57eaa55](https://github.com/perfect-panel/ppanel-web/commit/57eaa55))
- **auth**: Add Oauth configuration for Telegram, Facebook, Google, Github, and Apple ([18ee600](https://github.com/perfect-panel/ppanel-web/commit/18ee600))
- **auth**: Add SMS and email configuration options to global store and update localization ([4acf7b1](https://github.com/perfect-panel/ppanel-web/commit/4acf7b1))
- **auth**: Enhance user registration with invite handling and logo display ([207bc24](https://github.com/perfect-panel/ppanel-web/commit/207bc24))
- **auth**: Redirect user after OAuth login and add logos icon collection ([aa6dda8](https://github.com/perfect-panel/ppanel-web/commit/aa6dda8))
- **config**: Add application selection and encryption settings to configuration form ([88b3504](https://github.com/perfect-panel/ppanel-web/commit/88b3504))
- **config**: Update encryption fields in configuration form and refactor OAuth callback parameters ([652e032](https://github.com/perfect-panel/ppanel-web/commit/652e032))
- **global**: Add SMS configuration options to global store ([39a9ce6](https://github.com/perfect-panel/ppanel-web/commit/39a9ce6))
- **locales**: Add area code and telephone fields to user forms in multiple languages ([9b8258c](https://github.com/perfect-panel/ppanel-web/commit/9b8258c))
- **locales**: Add description information of communication keys and encryption methods to enhance client configuration capabilities ([d1f5a9b](https://github.com/perfect-panel/ppanel-web/commit/d1f5a9b))
- **node**: Add tags ([f408fdf](https://github.com/perfect-panel/ppanel-web/commit/f408fdf))
- **node**: Move the node configuration to the server module ([7f0f5ce](https://github.com/perfect-panel/ppanel-web/commit/7f0f5ce))
- **oauth**: Add certification component for handling OAuth login callbacks and improve user authentication flow ([5ed04c0](https://github.com/perfect-panel/ppanel-web/commit/5ed04c0))
- **oauth**: Implement OAuth token retrieval and refactor login callback handling ([40a6f7c](https://github.com/perfect-panel/ppanel-web/commit/40a6f7c))
- **oauth**: Refactor platform parameter handling and improve logout redirection logic ([8346c85](https://github.com/perfect-panel/ppanel-web/commit/8346c85))
- **oauth**: Update OAuth login handling to use callback parameter and improve URL parameter retrieval ([9227411](https://github.com/perfect-panel/ppanel-web/commit/9227411))
- **sms**: Update locales ([938363b](https://github.com/perfect-panel/ppanel-web/commit/938363b))
- **subscribe**: Add 'sold' column to SubscribeTable and update inventory terminology ([19619fd](https://github.com/perfect-panel/ppanel-web/commit/19619fd))
- **subscribe**: Move subscription configuration and application to subscription module ([f90d4d2](https://github.com/perfect-panel/ppanel-web/commit/f90d4d2))
- **subscribe**: Update SubscribeTable component to use API.SubscribeItem type and ensure proper type casting ([f26f1c2](https://github.com/perfect-panel/ppanel-web/commit/f26f1c2))
- **tutorial**: Fetch the latest tutorial version from GitHub API for dynamic URL generation ([28f8c78](https://github.com/perfect-panel/ppanel-web/commit/28f8c78))
- **user**: Add telephone input with area code selection and update localization ([585b99c](https://github.com/perfect-panel/ppanel-web/commit/585b99c))
- Update Auth Control ([c59742a](https://github.com/perfect-panel/ppanel-web/commit/c59742a))
### 🐛 Bug Fixes
- **api**: Remove redundant requestType parameter in appleLoginCallback ([0aa5d5b](https://github.com/perfect-panel/ppanel-web/commit/0aa5d5b))
- **api**: Rename app-related functions and types to application for consistency ([9d8b814](https://github.com/perfect-panel/ppanel-web/commit/9d8b814))
- **api**: Update subscription_protocol to subscribe_type for consistency across services ([b6da51b](https://github.com/perfect-panel/ppanel-web/commit/b6da51b))
- **auth**: Change Textarea value to defaultValue for client_secret in Apple auth page ([69fc670](https://github.com/perfect-panel/ppanel-web/commit/69fc670))
- **auth**: Remove unused telephone code login function and update typings for telephone login requests ([7239685](https://github.com/perfect-panel/ppanel-web/commit/7239685))
- **auth**: Require minimum length for invite string when forced invite is enabled ([a604f28](https://github.com/perfect-panel/ppanel-web/commit/a604f28))
- **auth**: Update user authentication flow to include email and phone code verification ([5d078fd](https://github.com/perfect-panel/ppanel-web/commit/5d078fd))
- **dashboard**: Improve URL encoding for subscription links and enhance success message handling ([4983c33](https://github.com/perfect-panel/ppanel-web/commit/4983c33))
- **dashboard**: Update icon imports for platform consistency and adjust icon size ([3e8912e](https://github.com/perfect-panel/ppanel-web/commit/3e8912e))
- **dashboard**: Update platform detection logic and improve layout responsiveness ([b0aa364](https://github.com/perfect-panel/ppanel-web/commit/b0aa364))
- **deps**: Remove outdated @iconify/react dependency and add iconify-json packages ([d6fbc38](https://github.com/perfect-panel/ppanel-web/commit/d6fbc38))
- **locales**: Add error message for incorrect user information ([52c1d1f](https://github.com/perfect-panel/ppanel-web/commit/52c1d1f))
- **locales**: Add error message for incorrect user information ([3d92902](https://github.com/perfect-panel/ppanel-web/commit/3d92902))
- **locales**: Add logout message to authentication localization files ([1d0d911](https://github.com/perfect-panel/ppanel-web/commit/1d0d911))
- **nav**: Comment out unused social login options to simplify navigation configuration ([cefcb31](https://github.com/perfect-panel/ppanel-web/commit/cefcb31))
- **node-config**: Add null checks for time slots and ensure proper handling of undefined values ([1cdb7e7](https://github.com/perfect-panel/ppanel-web/commit/1cdb7e7))
- **node**: Add country and city fields to the form schema and localization files ([8775fb6](https://github.com/perfect-panel/ppanel-web/commit/8775fb6))
- **oauth**: Refactor OAuth configuration types and update related API methods ([6227ba9](https://github.com/perfect-panel/ppanel-web/commit/6227ba9))
- **oauth**: Remove redundant checks when updating configuration to simplify logic ([9140b8a](https://github.com/perfect-panel/ppanel-web/commit/9140b8a))
- **payment**: Replace window.open with window.location.href for checkout links ([1d8c765](https://github.com/perfect-panel/ppanel-web/commit/1d8c765))
- **phone**: Update SMS expiration time field to use 'sms_expire_time' with default value of 300 ([18b07c7](https://github.com/perfect-panel/ppanel-web/commit/18b07c7))
- **redirect**: Simplify redirect URL logic by removing unnecessary condition for sessionStorage ([c53ac61](https://github.com/perfect-panel/ppanel-web/commit/c53ac61))
- **redirect**: Update redirect URL logic to ensure proper handling of OAuth and auth paths ([7954762](https://github.com/perfect-panel/ppanel-web/commit/7954762))
- **site**: Add image upload functionality for site logo configuration ([4ea6e4a](https://github.com/perfect-panel/ppanel-web/commit/4ea6e4a))
- **sort**: Refactor sorting logic in NodeTable and SubscribeTable components for improved clarity and performance ([331bbea](https://github.com/perfect-panel/ppanel-web/commit/331bbea))
- **subscription**: Add reset functionality for user subscription token ([39e89bf](https://github.com/perfect-panel/ppanel-web/commit/39e89bf))
- **type**: Fix ts type check error ([3cb0629](https://github.com/perfect-panel/ppanel-web/commit/3cb0629))
- **user-nav**: Update user avatar and label to display telephone if email is not available ([7b6bb7b](https://github.com/perfect-panel/ppanel-web/commit/7b6bb7b))
- **user**: Update user identifier field and localizations ([1b6befa](https://github.com/perfect-panel/ppanel-web/commit/1b6befa))
### 💄 Styles
- **dashboard**: Adjust grid layout and update image dimensions in application display ([f3204b7](https://github.com/perfect-panel/ppanel-web/commit/f3204b7))
- **globals**: Refactor delete confirmation button and update badge styles in node and subscribe tables ([30ae781](https://github.com/perfect-panel/ppanel-web/commit/30ae781))
- Update node secret UI and add telephone code field to authentication form ([770932e](https://github.com/perfect-panel/ppanel-web/commit/770932e))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.6](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.5...v1.0.0-beta.6) (2025-01-10)
### 🐛 Bug Fixes
- **auth**: Update UserCheckForm to use setInitialValues and modify onSubmit type ([c984c0d](https://github.com/perfect-panel/ppanel-web/commit/c984c0d))
# [1.0.0-beta.5](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.4...v1.0.0-beta.5) (2025-01-09)
### ✨ Features
- **locales**: Replace 'nodeGroupId' with 'groupId' in multiple language files for consistency ([a4e9d5d](https://github.com/perfect-panel/ppanel-web/commit/a4e9d5d))
- **locales**: Update 'deductBalance' to 'giftAmount' across multiple languages and fix newline in announcement.json ([70497af](https://github.com/perfect-panel/ppanel-web/commit/70497af))
- **stats**: Replace dynamic stat fetching with environment constants for user, server, and location counts ([46ae166](https://github.com/perfect-panel/ppanel-web/commit/46ae166))
- **subscribe**: Update suffix from 'MB' to 'Mbps' and enhance speed limit display logic ([3547bb1](https://github.com/perfect-panel/ppanel-web/commit/3547bb1))
- **user**: Add 'gift_amount' field and update related references in user services and components ([b13c77e](https://github.com/perfect-panel/ppanel-web/commit/b13c77e))
### 🎫 Chores
- **deps**: Update package dependencies across multiple projects for improved stability and performance ([b01a5bc](https://github.com/perfect-panel/ppanel-web/commit/b01a5bc))
- **ui**: Update package dependencies for improved stability and performance ([25da429](https://github.com/perfect-panel/ppanel-web/commit/25da429))
### 🐛 Bug Fixes
- **api**: Replace 'deduction' with 'gift_amount' and add 'commission' field in type definitions ([77edf1d](https://github.com/perfect-panel/ppanel-web/commit/77edf1d))
- **api**: Update API type definitions to replace 'deduction' with 'gift_amount' and make 'commission' field optional ([c2af060](https://github.com/perfect-panel/ppanel-web/commit/c2af060))
- **auth**: Refactor user authentication forms to remove global store dependency and improve type handling ([12026b0](https://github.com/perfect-panel/ppanel-web/commit/12026b0))
- **coupon**: Rename 'server' field to 'subscribe' in coupon form and update coupon update request type ([f8b6d82](https://github.com/perfect-panel/ppanel-web/commit/f8b6d82))
- **types**: Add 'gift_amount' field to API type definitions ([8f8a12a](https://github.com/perfect-panel/ppanel-web/commit/8f8a12a))
- **user**: Add the 'gift_amount' field to the user service's type definition ([6301409](https://github.com/perfect-panel/ppanel-web/commit/6301409))
<a name="readme-top"></a>
# Changelog
# [1.0.0-beta.4](https://github.com/perfect-panel/ppanel-web/compare/v1.0.0-beta.3...v1.0.0-beta.4) (2025-01-07)
### ♻ Code Refactoring
- **auth**: Refactor user authorization handling and improve error logging ([68bc18f](https://github.com/perfect-panel/ppanel-web/commit/68bc18f))
### ✨ Features
- **affiliate**: Add Affiliate component with commission display and invite link functionality ([4aea4e8](https://github.com/perfect-panel/ppanel-web/commit/4aea4e8))
- **affiliate**: Update affiliate component to display total commission and improve data fetching ([cc834ca](https://github.com/perfect-panel/ppanel-web/commit/cc834ca))
- **api**: Add new subscription properties and locale support for deduction ratios and reset cycles ([fec80f5](https://github.com/perfect-panel/ppanel-web/commit/fec80f5))
- **api**: Add Time Period Configuration ([837157c](https://github.com/perfect-panel/ppanel-web/commit/837157c))
- **favicon**: Update SVG favicon design for admin and user interfaces ([1d91738](https://github.com/perfect-panel/ppanel-web/commit/1d91738))
- **node**: Add serverKey ([25ce37e](https://github.com/perfect-panel/ppanel-web/commit/25ce37e))
- **relay**: Add relay mode configuration and update related schemas ([3cc9477](https://github.com/perfect-panel/ppanel-web/commit/3cc9477))
- **schema**: Add security field to hysteria2 and tuic schemas ([cd59d44](https://github.com/perfect-panel/ppanel-web/commit/cd59d44))
- **subscribe**: Add reset_time to API typings and update unsubscribe logic ([eeea165](https://github.com/perfect-panel/ppanel-web/commit/eeea165))
- **subscribe**: Add subscribe_discount type ([f99c604](https://github.com/perfect-panel/ppanel-web/commit/f99c604))
- **subscribe**: Add subscription credits ([5bc7905](https://github.com/perfect-panel/ppanel-web/commit/5bc7905))
- **subscribe**: Add unsubscribe functionality with confirmation messages and localized strings ([b2a2f42](https://github.com/perfect-panel/ppanel-web/commit/b2a2f42))
- **subscribe**: Improve error handling in subscription forms and update component props ([d28a10b](https://github.com/perfect-panel/ppanel-web/commit/d28a10b))
- **subscribe**: Improve layout and styling in subscription components ([5766376](https://github.com/perfect-panel/ppanel-web/commit/5766376))
- **subscription**: Add localized messages for existing subscriptions and deletion restrictions ([e8a72d5](https://github.com/perfect-panel/ppanel-web/commit/e8a72d5))
### 🎫 Chores
- Update changelog, enhance prepare script, and add openapi command ([a93db4e](https://github.com/perfect-panel/ppanel-web/commit/a93db4e))
### 🐛 Bug Fixes
- **dashboard**: Correct progress value calculations and update groupId accessor ([36c7667](https://github.com/perfect-panel/ppanel-web/commit/36c7667))
- **layout**: Remove unnecessary cookie initialization in Logout function ([3065c3a](https://github.com/perfect-panel/ppanel-web/commit/3065c3a))
- **locales**: Update Hong Kong ([6d0d069](https://github.com/perfect-panel/ppanel-web/commit/6d0d069))
- **subscribe**: Update value validation to check for number type in subscribe form ([6de29d5](https://github.com/perfect-panel/ppanel-web/commit/6de29d5))
### 💄 Styles
- **locales**: Remove unused subscription labels from multiple locale files ([fb0c510](https://github.com/perfect-panel/ppanel-web/commit/fb0c510))
- **locales**: Update server.json to reorganize relay mode options and improve labels ([701cdee](https://github.com/perfect-panel/ppanel-web/commit/701cdee))
- **node**: Improve layout and spacing in NodeStatusCell component ([136287d](https://github.com/perfect-panel/ppanel-web/commit/136287d))
- **time-slot**: Add chart display ([c44ad47](https://github.com/perfect-panel/ppanel-web/commit/c44ad47))
<a name="readme-top"></a> <a name="readme-top"></a>
@@ -1,27 +1,24 @@
'use client'; 'use client';
import { resetPassword, userLogin, userRegister } from '@/services/common/auth';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { ReactNode, useState, useTransition } from 'react';
import { toast } from 'sonner';
import { import {
NEXT_PUBLIC_DEFAULT_USER_EMAIL, NEXT_PUBLIC_DEFAULT_USER_EMAIL,
NEXT_PUBLIC_DEFAULT_USER_PASSWORD, NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
} from '@/config/constants'; } from '@/config/constants';
import useGlobalStore from '@/config/use-global';
import { checkUser, resetPassword, userLogin, userRegister } from '@/services/common/auth';
import { getRedirectUrl, setAuthorization } from '@/utils/common'; import { getRedirectUrl, setAuthorization } from '@/utils/common';
import { useTranslations } from 'next-intl'; import LoginForm from './login-form';
import { useRouter } from 'next/navigation'; import RegisterForm from './register-form';
import { ReactNode, useState, useTransition } from 'react'; import ResetForm from './reset-form';
import { toast } from 'sonner';
import UserCheckForm from './user-check-form';
import UserLoginForm from './user-login-form';
import UserRegisterForm from './user-register-form';
import UserResetForm from './user-reset-form';
export default function UserAuthForm() { export default function EmailAuthForm() {
const t = useTranslations('auth'); const t = useTranslations('auth');
const { common } = useGlobalStore();
const { register } = common;
const router = useRouter(); const router = useRouter();
const [type, setType] = useState<'login' | 'register' | 'reset'>(); const [type, setType] = useState<'login' | 'register' | 'reset'>('login');
const [loading, startTransition] = useTransition(); const [loading, startTransition] = useTransition();
const [initialValues, setInitialValues] = useState<{ const [initialValues, setInitialValues] = useState<{
email?: string; email?: string;
@@ -41,44 +38,35 @@ export default function UserAuthForm() {
startTransition(async () => { startTransition(async () => {
try { try {
switch (type) { switch (type) {
case 'login': case 'login': {
// eslint-disable-next-line no-case-declarations
const login = await userLogin(params); const login = await userLogin(params);
toast.success(t('login.success')); toast.success(t('login.success'));
onLogin(login.data.data?.token); onLogin(login.data.data?.token);
break; break;
case 'register': }
// eslint-disable-next-line no-case-declarations case 'register': {
const create = await userRegister(params); const create = await userRegister(params);
toast.success(t('register.success')); toast.success(t('register.success'));
onLogin(create.data.data?.token); onLogin(create.data.data?.token);
break; break;
}
case 'reset': case 'reset':
await resetPassword(params); await resetPassword(params);
toast.success(t('reset.success')); toast.success(t('reset.success'));
setType('login'); setType('login');
break; break;
default:
if (type === 'reset') break;
// eslint-disable-next-line no-case-declarations
const response = await checkUser(params);
setInitialValues({
...initialValues,
...params,
});
setType(response.data.data?.exist ? 'login' : 'register');
break;
} }
} catch (error) { } catch (error) {
/* empty */ /* empty */
} }
}); });
}; };
let UserForm: ReactNode = null; let UserForm: ReactNode = null;
switch (type) { switch (type) {
case 'login': case 'login':
UserForm = ( UserForm = (
<UserLoginForm <LoginForm
loading={loading} loading={loading}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
initialValues={initialValues} initialValues={initialValues}
@@ -89,7 +77,7 @@ export default function UserAuthForm() {
break; break;
case 'register': case 'register':
UserForm = ( UserForm = (
<UserRegisterForm <RegisterForm
loading={loading} loading={loading}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
initialValues={initialValues} initialValues={initialValues}
@@ -100,7 +88,7 @@ export default function UserAuthForm() {
break; break;
case 'reset': case 'reset':
UserForm = ( UserForm = (
<UserResetForm <ResetForm
loading={loading} loading={loading}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
initialValues={initialValues} initialValues={initialValues}
@@ -109,15 +97,6 @@ export default function UserAuthForm() {
/> />
); );
break; break;
default:
UserForm = (
<UserCheckForm
loading={loading}
onSubmit={handleFormSubmit}
initialValues={initialValues}
/>
);
break;
} }
return ( return (
@@ -128,34 +107,6 @@ export default function UserAuthForm() {
{t(`${type || 'check'}.description`)} {t(`${type || 'check'}.description`)}
</div> </div>
</div> </div>
{/* {!((type === 'register' && register.stop_register) || type === 'reset') && (
<>
<div className='mb-3 flex flex-wrap items-center justify-center gap-3 font-bold'>
<Button type='button' variant='outline'>
<Icon icon='uil:telegram' className='mr-2 size-5' />
Telegram
</Button>
<Button type='button' variant='outline'>
<Icon icon='uil:google' className='mr-2 size-5' />
Google
</Button>
<Button type='button' variant='outline'>
<Icon icon='uil:apple' className='mr-2 size-5' />
Apple
</Button>
</div>
<div
className={cn(
'my-14 flex h-0 items-center text-center',
'before:mr-4 before:block before:w-1/2 before:border-b-[1px]',
'after:ml-4 after:w-1/2 after:border-b-[1px]',
)}
>
<span className='text-muted-foreground w-[125px] text-sm'>{t('orWithEmail')}</span>
</div>
</>
)} */}
{UserForm} {UserForm}
</> </>
); );
@@ -1,16 +1,16 @@
import useGlobalStore from '@/config/use-global'; import useGlobalStore from '@/config/use-global';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react/dist/iconify.js';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/ui/components/form'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/ui/components/form';
import { Input } from '@workspace/ui/components/input'; import { Input } from '@workspace/ui/components/input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import CloudFlareTurnstile from './turnstile'; import CloudFlareTurnstile, { TurnstileRef } from '../turnstile';
export default function UserLoginForm({ export default function LoginForm({
loading, loading,
onSubmit, onSubmit,
initialValues, initialValues,
@@ -21,14 +21,14 @@ export default function UserLoginForm({
onSubmit: (data: any) => void; onSubmit: (data: any) => void;
initialValues: any; initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>; setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: (type?: 'register' | 'reset') => void; onSwitchForm: Dispatch<SetStateAction<'register' | 'reset' | 'login'>>;
}) { }) {
const t = useTranslations('auth.login'); const t = useTranslations('auth.login');
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify } = common; const { verify } = common;
const formSchema = z.object({ const formSchema = z.object({
email: z.string(), email: z.string().email(t('email')),
password: z.string(), password: z.string(),
cf_token: cf_token:
verify.enable_login_verify && verify.turnstile_site_key ? z.string() : z.string().optional(), verify.enable_login_verify && verify.turnstile_site_key ? z.string() : z.string().optional(),
@@ -38,17 +38,26 @@ export default function UserLoginForm({
defaultValues: initialValues, defaultValues: initialValues,
}); });
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (error) {
turnstile.current?.reset();
}
});
return ( return (
<> <>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'> <form onSubmit={handleSubmit} className='grid gap-6'>
<FormField <FormField
control={form.control} control={form.control}
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input disabled placeholder='Enter your email...' type='email' {...field} /> <Input placeholder='Enter your email...' type='email' {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -60,12 +69,7 @@ export default function UserLoginForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input placeholder='Enter your password...' type='password' {...field} />
disabled={loading}
placeholder='Enter your password...'
type='password'
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -78,7 +82,7 @@ export default function UserLoginForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<CloudFlareTurnstile id='login' {...field} /> <CloudFlareTurnstile id='login' {...field} ref={turnstile} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -100,10 +104,10 @@ export default function UserLoginForm({
className='p-0' className='p-0'
onClick={() => { onClick={() => {
setInitialValues(undefined); setInitialValues(undefined);
onSwitchForm(undefined); onSwitchForm('register');
}} }}
> >
{t('switchAccount')} {t('registerAccount')}
</Button> </Button>
</div> </div>
</> </>
@@ -1,19 +1,18 @@
import useGlobalStore from '@/config/use-global'; import useGlobalStore from '@/config/use-global';
import { sendEmailCode } from '@/services/common/common';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react/dist/iconify.js';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/ui/components/form'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/ui/components/form';
import { Input } from '@workspace/ui/components/input'; import { Input } from '@workspace/ui/components/input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { Markdown } from '@workspace/ui/custom-components/markdown'; import { Markdown } from '@workspace/ui/custom-components/markdown';
import { useCountDown } from 'ahooks';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction, useState } from 'react'; import { Dispatch, SetStateAction, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import CloudFlareTurnstile from './turnstile'; import SendCode from '../send-code';
import CloudFlareTurnstile, { TurnstileRef } from '../turnstile';
export default function UserRegisterForm({ export default function RegisterForm({
loading, loading,
onSubmit, onSubmit,
initialValues, initialValues,
@@ -24,34 +23,36 @@ export default function UserRegisterForm({
onSubmit: (data: any) => void; onSubmit: (data: any) => void;
initialValues: any; initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>; setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: (type?: 'register' | 'reset') => void; onSwitchForm: Dispatch<SetStateAction<'register' | 'reset' | 'login'>>;
}) { }) {
const t = useTranslations('auth.register'); const t = useTranslations('auth.register');
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify, register, invite } = common; const { verify, auth, invite } = common;
const [targetDate, setTargetDate] = useState<number>(); const handleCheckUser = async (email: string) => {
const [, { seconds }] = useCountDown({ try {
targetDate, if (!auth.email.enable_domain_suffix) return true;
onEnd: () => { const domain = email.split('@')[1];
setTargetDate(undefined); const isValid = auth.email?.domain_suffix_list.split('\n').includes(domain || '');
}, return isValid;
}); } catch (error) {
const handleSendCode = async () => { console.log('Error checking user:', error);
await sendEmailCode({ return false;
email: initialValues.email, }
type: 1,
});
setTargetDate(Date.now() + 60000);
}; };
const formSchema = z const formSchema = z
.object({ .object({
email: z.string(), email: z
.string()
.email(t('email'))
.refine(handleCheckUser, {
message: t('whitelist'),
}),
password: z.string(), password: z.string(),
repeat_password: z.string(), repeat_password: z.string(),
code: register.enable_email_verify ? z.string() : z.string().nullish(), code: auth.email.enable_verify ? z.string() : z.string().nullish(),
invite: invite.forced_invite ? z.string() : z.string().nullish(), invite: invite.forced_invite ? z.string().min(1) : z.string().nullish(),
cf_token: cf_token:
verify.enable_register_verify && verify.turnstile_site_key verify.enable_register_verify && verify.turnstile_site_key
? z.string() ? z.string()
@@ -66,28 +67,38 @@ export default function UserRegisterForm({
}); });
} }
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
...initialValues, ...initialValues,
invite: sessionStorage.getItem('invite'), invite: localStorage.getItem('invite') || '',
}, },
}); });
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (error) {
turnstile.current?.reset();
}
});
return ( return (
<> <>
{register.stop_register ? ( {auth.register.stop_register ? (
<Markdown>{t('message')}</Markdown> <Markdown>{t('message')}</Markdown>
) : ( ) : (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'> <form onSubmit={handleSubmit} className='grid gap-6'>
<FormField <FormField
control={form.control} control={form.control}
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input disabled placeholder='Enter your email...' type='email' {...field} /> <Input placeholder='Enter your email...' type='email' {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -99,12 +110,7 @@ export default function UserRegisterForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input placeholder='Enter your password...' type='password' {...field} />
disabled={loading}
placeholder='Enter your password...'
type='password'
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -127,7 +133,7 @@ export default function UserRegisterForm({
</FormItem> </FormItem>
)} )}
/> />
{register.enable_email_verify && ( {auth.email.enable_verify && (
<FormField <FormField
control={form.control} control={form.control}
name='code' name='code'
@@ -142,9 +148,13 @@ export default function UserRegisterForm({
{...field} {...field}
value={field.value as string} value={field.value as string}
/> />
<Button type='button' onClick={handleSendCode} disabled={seconds > 0}> <SendCode
{seconds > 0 ? `${seconds}s` : t('get')} type='email'
</Button> params={{
...form.getValues(),
type: 1,
}}
/>
</div> </div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -159,7 +169,7 @@ export default function UserRegisterForm({
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input
disabled={loading} disabled={loading || !!localStorage.getItem('invite')}
placeholder={t('invite')} placeholder={t('invite')}
{...field} {...field}
value={field.value || ''} value={field.value || ''}
@@ -176,7 +186,7 @@ export default function UserRegisterForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<CloudFlareTurnstile id='register' {...field} /> <CloudFlareTurnstile id='register' {...field} ref={turnstile} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -191,13 +201,13 @@ export default function UserRegisterForm({
</Form> </Form>
)} )}
<div className='mt-4 text-right text-sm'> <div className='mt-4 text-right text-sm'>
{t('existingAccount')} {t('existingAccount')}&nbsp;
<Button <Button
variant='link' variant='link'
className='p-0' className='p-0'
onClick={() => { onClick={() => {
setInitialValues(undefined); setInitialValues(undefined);
onSwitchForm(undefined); onSwitchForm('login');
}} }}
> >
{t('switchToLogin')} {t('switchToLogin')}
@@ -1,18 +1,17 @@
import useGlobalStore from '@/config/use-global'; import useGlobalStore from '@/config/use-global';
import { sendEmailCode } from '@/services/common/common';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/ui/components/form'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/ui/components/form';
import { Input } from '@workspace/ui/components/input'; import { Input } from '@workspace/ui/components/input';
import { useCountDown } from 'ahooks'; import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction, useState } from 'react'; import { Dispatch, SetStateAction, useRef } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import CloudFlareTurnstile from './turnstile'; import SendCode from '../send-code';
import CloudFlareTurnstile, { TurnstileRef } from '../turnstile';
export default function UserResetForm({ export default function ResetForm({
loading, loading,
onSubmit, onSubmit,
initialValues, initialValues,
@@ -23,32 +22,17 @@ export default function UserResetForm({
onSubmit: (data: any) => void; onSubmit: (data: any) => void;
initialValues: any; initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>; setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: (type?: 'register' | 'reset') => void; onSwitchForm: Dispatch<SetStateAction<'register' | 'reset' | 'login'>>;
}) { }) {
const t = useTranslations('auth.reset'); const t = useTranslations('auth.reset');
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify, register } = common; const { verify, auth } = common;
const [targetDate, setTargetDate] = useState<number>();
const [, { seconds }] = useCountDown({
targetDate,
onEnd: () => {
setTargetDate(undefined);
},
});
const handleSendCode = async () => {
await sendEmailCode({
email: initialValues.email,
type: 2,
});
setTargetDate(Date.now() + 60000); // 60秒倒计时
};
const formSchema = z.object({ const formSchema = z.object({
email: z.string(), email: z.string().email(t('email')),
password: z.string(), password: z.string(),
code: register.enable_email_verify ? z.string() : z.string().nullish(), code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
cf_token: cf_token:
verify.enable_register_verify && verify.turnstile_site_key verify.enable_register_verify && verify.turnstile_site_key
? z.string() ? z.string()
@@ -59,17 +43,53 @@ export default function UserResetForm({
defaultValues: initialValues, defaultValues: initialValues,
}); });
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (error) {
turnstile.current?.reset();
}
});
return ( return (
<> <>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'> <form onSubmit={handleSubmit} className='grid gap-6'>
<FormField <FormField
control={form.control} control={form.control}
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input disabled placeholder='Enter your email...' type='email' {...field} /> <Input placeholder='Enter your email...' type='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='code'
render={({ field }) => (
<FormItem>
<FormControl>
<div className='flex items-center gap-2'>
<Input
disabled={loading}
placeholder='Enter code...'
type='text'
{...field}
value={field.value as string}
/>
<SendCode
type='email'
params={{
...form.getValues(),
type: 2,
}}
/>
</div>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -81,43 +101,12 @@ export default function UserResetForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Input <Input placeholder='Enter your new password...' type='password' {...field} />
disabled={loading}
placeholder='Enter your password...'
type='password'
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{register.enable_email_verify && (
<FormField
control={form.control}
name='code'
render={({ field }) => (
<FormItem>
<FormControl>
<div className='flex items-center gap-2'>
<Input
disabled={loading}
placeholder='Enter code...'
type='text'
{...field}
value={field.value as string}
/>
<Button type='button' onClick={handleSendCode} disabled={seconds > 0}>
{seconds > 0 ? `${seconds}s` : t('get')}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{verify.enable_reset_password_verify && ( {verify.enable_reset_password_verify && (
<FormField <FormField
control={form.control} control={form.control}
@@ -125,7 +114,7 @@ export default function UserResetForm({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<CloudFlareTurnstile id='reset' {...field} /> <CloudFlareTurnstile id='reset' {...field} ref={turnstile} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -139,13 +128,13 @@ export default function UserResetForm({
</form> </form>
</Form> </Form>
<div className='mt-4 text-right text-sm'> <div className='mt-4 text-right text-sm'>
{t('existingAccount')} {t('existingAccount')}&nbsp;
<Button <Button
variant='link' variant='link'
className='p-0' className='p-0'
onClick={() => { onClick={() => {
setInitialValues(undefined); setInitialValues(undefined);
onSwitchForm(undefined); onSwitchForm('login');
}} }}
> >
{t('switchToLogin')} {t('switchToLogin')}
+2 -2
View File
@@ -8,7 +8,7 @@ import LoginLottie from '@workspace/ui/lotties/login.json';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image'; import Image from 'next/legacy/image';
import Link from 'next/link'; import Link from 'next/link';
import UserAuthForm from './user-auth-form'; import EmailAuthForm from './email/auth-form';
export default function Page() { export default function Page() {
const t = useTranslations('auth'); const t = useTranslations('auth');
@@ -45,7 +45,7 @@ export default function Page() {
<div className='lg:bg-background flex flex-col items-center rounded-2xl md:w-[600px] lg:flex-auto lg:p-10 lg:shadow'> <div className='lg:bg-background flex flex-col items-center rounded-2xl md:w-[600px] lg:flex-auto lg:p-10 lg:shadow'>
<div className='flex flex-col items-stretch justify-center md:w-[400px] lg:h-full'> <div className='flex flex-col items-stretch justify-center md:w-[400px] lg:h-full'>
<div className='flex flex-col justify-center pb-14 lg:flex-auto lg:pb-20'> <div className='flex flex-col justify-center pb-14 lg:flex-auto lg:pb-20'>
<UserAuthForm /> <EmailAuthForm />
</div> </div>
<div className='flex items-center justify-end'> <div className='flex items-center justify-end'>
{/* <div className='text-primary flex gap-5 text-sm font-semibold'> {/* <div className='text-primary flex gap-5 text-sm font-semibold'>
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { sendEmailCode, sendSmsCode } from '@/services/common/common';
import { Button } from '@workspace/ui/components/button';
import { useCountDown } from 'ahooks';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
interface SendCodeProps {
type: 'email' | 'phone';
params: {
email?: string;
type?: 1 | 2;
telephone_area_code?: string;
telephone?: string;
};
}
export default function SendCode({ type, params }: SendCodeProps) {
const t = useTranslations('auth');
const [targetDate, setTargetDate] = useState<number>();
const [, { seconds }] = useCountDown({
targetDate,
onEnd: () => {
setTargetDate(undefined);
},
});
const getEmailCode = async () => {
if (params.email && params.type) {
await sendEmailCode({
email: params.email,
type: params.type,
});
setTargetDate(Date.now() + 60000);
}
};
const getPhoneCode = async () => {
if (params.telephone && params.telephone_area_code && params.type) {
await sendSmsCode({
telephone: params.telephone,
telephone_area_code: params.telephone_area_code,
type: params.type,
});
setTargetDate(Date.now() + 60000);
}
};
const handleSendCode = async () => {
if (type === 'email') {
getEmailCode();
} else {
getPhoneCode();
}
};
const disabled =
seconds > 0 ||
(type === 'email' ? !params.email : !params.telephone || !params.telephone_area_code);
return (
<Button type='button' onClick={handleSendCode} disabled={disabled}>
{seconds > 0 ? `${seconds}s` : t('get')}
</Button>
);
}
+26 -12
View File
@@ -1,26 +1,38 @@
'use client'; 'use client';
import useGlobalStore from '@/config/use-global';
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useEffect } from 'react'; import { forwardRef, useEffect, useImperativeHandle } from 'react';
import Turnstile, { useTurnstile } from 'react-turnstile'; import Turnstile, { useTurnstile } from 'react-turnstile';
export default function CloudFlareTurnstile({ import useGlobalStore from '@/config/use-global';
id,
value, export type TurnstileRef = {
onChange, reset: () => void;
}: { };
id?: string;
value?: null | string; const CloudFlareTurnstile = forwardRef<
onChange: (value?: string) => void; TurnstileRef,
}) { {
id?: string;
value?: null | string;
onChange: (value?: string) => void;
}
>(function CloudFlareTurnstile({ id, value, onChange }, ref) {
const { common } = useGlobalStore(); const { common } = useGlobalStore();
const { verify } = common; const { verify } = common;
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const locale = useLocale(); const locale = useLocale();
const turnstile = useTurnstile(); const turnstile = useTurnstile();
useImperativeHandle(
ref,
() => ({
reset: () => turnstile.reset(),
}),
[turnstile],
);
useEffect(() => { useEffect(() => {
if (value === '') { if (value === '') {
turnstile.reset(); turnstile.reset();
@@ -51,4 +63,6 @@ export default function CloudFlareTurnstile({
/> />
) )
); );
} });
export default CloudFlareTurnstile;
-109
View File
@@ -1,109 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { Button } from '@workspace/ui/components/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/ui/components/form';
import { Input } from '@workspace/ui/components/input';
import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
export default function UserCheckForm({
loading,
onSubmit,
initialValues,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
}) {
const t = useTranslations('auth.check');
// const { common } = useGlobalStore();
// const { register } = common;
const formSchema = z.object({
email: z.string().email(t('email')),
// .refine(
// (email) => {
// if (!register.enable_email_domain_suffix) return true;
// const domain = email.split('@')[1] as string;
// return register.email_domain_suffix_list.split('\n').includes(domain);
// },
// {
// message: t('whitelist'),
// },
// ),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleEmailChange = (value: string) => {
form.setValue('email', value);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(async () => {
const isValid = await form.trigger('email');
if (isValid) {
setIsSubmitting(true);
form.handleSubmit(onSubmit)();
} else {
setIsSubmitting(false);
}
}, 500);
};
useEffect(() => {
if (initialValues && initialValues.email) {
handleEmailChange(initialValues.email);
}
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder='Enter your email...'
type='email'
{...field}
onChange={(e) => handleEmailChange(e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit' disabled={!isSubmitting}>
{isSubmitting ? (
<>
<Icon icon='mdi:loading' className='mr-2 size-5 animate-spin' />
{t('checking')}
</>
) : (
t('continue')
)}
</Button>
</form>
</Form>
);
}
+299
View File
@@ -0,0 +1,299 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { RadioGroup, RadioGroupItem } from '@workspace/ui/components/radio-group';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const formSchema = z.object({
title: z.string(),
type: z.enum(['image', 'video']),
content: z.string(),
description: z.string(),
target_url: z.string().url(),
start_time: z.number(),
end_time: z.number(),
});
interface AdsFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function AdsForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: AdsFormProps<T>) {
const t = useTranslations('ads');
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
} as any,
});
useEffect(() => {
form?.reset(initialValues);
}, [form, initialValues]);
const type = form.watch('type');
const startTime = form.watch('start_time');
const renderContentField = () => {
return (
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.content')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={
type === 'image'
? 'https://example.com/image.jpg'
: 'https://example.com/video.mp4'
}
value={field.value}
onValueChange={(value) => {
form.setValue('content', value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]'>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 px-6 pt-4'>
<FormField
control={form.control}
name='title'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.title')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('form.enterTitle')}
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.type')}</FormLabel>
<FormControl>
<RadioGroup
defaultValue={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
className='flex gap-4'
>
<FormItem className='flex items-center space-x-3 space-y-0'>
<FormControl>
<RadioGroupItem value='image' />
</FormControl>
<FormLabel className='font-normal'>{t('form.typeImage')}</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-3 space-y-0'>
<FormControl>
<RadioGroupItem value='video' />
</FormControl>
<FormLabel className='font-normal'>{t('form.typeVideo')}</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{renderContentField()}
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.description')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('form.enterDescription')}
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='target_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.targetUrl')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('form.enterTargetUrl')}
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='start_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.startTime')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
placeholder={t('form.enterStartTime')}
value={field.value ? new Date(field.value).toISOString().slice(0, 16) : ''}
min={Number(new Date().toISOString().slice(0, 16))}
onValueChange={(value) => {
const timestamp = value ? new Date(value).getTime() : 0;
form.setValue(field.name, timestamp);
const endTime = form.getValues('end_time');
if (endTime && timestamp > endTime) {
form.setValue('end_time', '');
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='end_time'
render={({ field }) => {
return (
<FormItem>
<FormLabel>{t('form.endTime')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
placeholder={t('form.enterEndTime')}
value={
field.value ? new Date(field.value).toISOString().slice(0, 16) : ''
}
min={Number(
startTime
? new Date(startTime).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16),
)}
disabled={!startTime}
onValueChange={(value) => {
const timestamp = value ? new Date(value).getTime() : 0;
if (!startTime || timestamp < startTime) return;
form.setValue(field.name, timestamp);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button
variant='outline'
disabled={loading}
onClick={() => {
setOpen(false);
}}
>
{t('form.cancel')}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('form.confirm')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+162
View File
@@ -0,0 +1,162 @@
'use client';
import { ProTable, ProTableActions } from '@/components/pro-table';
import { createAds, deleteAds, getAdsList, updateAds } from '@/services/admin/ads';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import { Switch } from '@workspace/ui/components/switch';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import AdsForm from './ads-form';
export default function Page() {
const t = useTranslations('ads');
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Ads, Record<string, unknown>>
action={ref}
header={{
toolbar: (
<AdsForm<API.CreateAdsRequest>
trigger={t('create')}
title={t('createAds')}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createAds({
...values,
status: 0,
});
toast.success(t('createSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>
),
}}
params={[
{
key: 'search',
},
{
key: 'status',
placeholder: t('status'),
options: [
{ label: t('enabled'), value: '1' },
{ label: t('disabled'), value: '0' },
],
},
]}
request={async (pagination, filters) => {
const { data } = await getAdsList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
columns={[
{
accessorKey: 'status',
header: t('status'),
cell: ({ row }) => {
return (
<Switch
defaultChecked={row.getValue('status') === 1}
onCheckedChange={async (checked) => {
await updateAds({
...row.original,
status: checked ? 1 : 0,
});
ref.current?.refresh();
}}
/>
);
},
},
{
accessorKey: 'title',
header: t('title'),
},
{
accessorKey: 'type',
header: t('type'),
cell: ({ row }) => {
const type = row.original.type;
return <Badge>{type}</Badge>;
},
},
{
accessorKey: 'target_url',
header: t('targetUrl'),
},
{
accessorKey: 'description',
header: t('form.description'),
},
{
accessorKey: 'period',
header: t('validityPeriod'),
cell: ({ row }) => {
const { start_time, end_time } = row.original;
return (
<>
{formatDate(start_time)} - {formatDate(end_time)}
</>
);
},
},
]}
actions={{
render: (row) => [
<AdsForm<API.UpdateAdsRequest>
key='edit'
trigger={t('edit')}
title={t('editAds')}
loading={loading}
initialValues={row}
onSubmit={async (values) => {
setLoading(true);
try {
await updateAds({ ...row, ...values });
toast.success(t('updateSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await deleteAds({ id: row.id });
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
],
}}
/>
);
}
@@ -1,5 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { import {
Form, Form,
@@ -20,6 +19,7 @@ import {
SheetTrigger, SheetTrigger,
} from '@workspace/ui/components/sheet'; } from '@workspace/ui/components/sheet';
import { MarkdownEditor } from '@workspace/ui/custom-components/editor'; import { MarkdownEditor } from '@workspace/ui/custom-components/editor';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -0,0 +1,148 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Textarea } from '@workspace/ui/components/textarea';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('apple');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'apple'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'apple',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('teamId')}</Label>
<p className='text-muted-foreground text-xs'>{t('teamIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='ABCDE1FGHI'
value={data?.config?.team_id}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
team_id: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('keyId')}</Label>
<p className='text-muted-foreground text-xs'>{t('keyIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='ABC1234567'
value={data?.config?.key_id}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
key_id: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientId')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='com.your.app.service'
value={data?.config?.client_id}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_id: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('clientSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-20'
placeholder={`-----BEGIN PRIVATE KEY-----\nMIGTAgEA...\n-----END PRIVATE KEY-----`}
defaultValue={data?.config?.client_secret}
onBlur={(e) => {
updateConfig('config', {
...data?.config,
client_secret: e.target.value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('redirectUri')}</Label>
<p className='text-muted-foreground text-xs'>{t('redirectUriDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='https://your-domain.com'
value={data?.config.redirect_url}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
redirect_url: value,
})
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -0,0 +1,141 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { DicesIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { uid } from 'radash';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('device');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'device'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'device',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('showAds')}</Label>
<p className='text-muted-foreground text-xs'>{t('showAdsDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.show_ads}
onCheckedChange={(checked) => {
updateConfig('config', {
...data?.config,
show_ads: checked,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('blockVirtualMachine')}</Label>
<p className='text-muted-foreground text-xs'>{t('blockVirtualMachineDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.only_real_device}
onCheckedChange={(checked) => {
updateConfig('config', {
...data?.config,
only_real_device: checked,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('enableSecurity')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableSecurityDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.enable_security}
onCheckedChange={(checked) => {
updateConfig('config', {
...data?.config,
enable_security: checked,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('communicationKey')}</Label>
<p className='text-muted-foreground text-xs'>{t('communicationKeyDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.security_secret}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
security_secret: value,
});
}}
suffix={
<div className='bg-muted flex h-9 items-center text-nowrap px-3'>
<DicesIcon
onClick={() => {
const id = uid(32).toLowerCase();
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
updateConfig('config', {
...data?.config,
security_secret: formatted,
});
}}
className='cursor-pointer'
/>
</div>
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -0,0 +1,322 @@
'use client';
import {
getAuthMethodConfig,
testEmailSend,
updateAuthMethodConfig,
} from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@workspace/ui/components/card';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Textarea } from '@workspace/ui/components/textarea';
import { HTMLEditor } from '@workspace/ui/custom-components/editor';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import { LogsTable } from '../log';
export default function Page() {
const t = useTranslations('email');
const ref = useRef<Partial<API.AuthMethodConfig>>({});
const [email, setEmail] = useState<string>();
const { data, refetch, isFetching } = useQuery({
queryKey: ['getAuthMethodConfig', 'email'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'email',
});
ref.current = data.data as API.AuthMethodConfig;
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateAuthMethodConfig({
...ref.current,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Tabs defaultValue='settings'>
<TabsList className='h-full flex-wrap'>
<TabsTrigger value='settings'>{t('settings')}</TabsTrigger>
<TabsTrigger value='template'>{t('template')}</TabsTrigger>
<TabsTrigger value='logs'>{t('logs')}</TabsTrigger>
</TabsList>
<TabsContent value='settings'>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('emailVerification')}</Label>
<p className='text-muted-foreground text-xs'>{t('emailVerificationDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.enable_verify}
onCheckedChange={(checked) =>
updateConfig('config', { ...data?.config, enable_verify: checked })
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('emailSuffixWhitelist')}</Label>
<p className='text-muted-foreground text-xs'>
{t('emailSuffixWhitelistDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.enable_domain_suffix}
onCheckedChange={(checked) =>
updateConfig('config', { ...data?.config, enable_domain_suffix: checked })
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('whitelistSuffixes')}</Label>
<p className='text-muted-foreground text-xs'>{t('whitelistSuffixesDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-32'
placeholder={t('whitelistSuffixesPlaceholder')}
defaultValue={data?.config?.domain_suffix_list}
onBlur={(e) =>
updateConfig('config', { ...data?.config, domain_suffix_list: e.target.value })
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpServerAddress')}</Label>
<p className='text-muted-foreground text-xs'>{t('smtpServerAddressDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.host}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.platform_config,
host: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpServerPort')}</Label>
<p className='text-muted-foreground text-xs'>{t('smtpServerPortDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.port}
type='number'
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
port: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpEncryptionMethod')}</Label>
<p className='text-muted-foreground text-xs'>
{t('smtpEncryptionMethodDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.config?.platform_config?.ssl}
onCheckedChange={(checked) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
ssl: checked,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpAccount')}</Label>
<p className='text-muted-foreground text-xs'>{t('smtpAccountDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.user}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
user: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('smtpPassword')}</Label>
<p className='text-muted-foreground text-xs'>{t('smtpPasswordDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.pass}
type='password'
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
pass: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('senderAddress')}</Label>
<p className='text-muted-foreground text-xs'>{t('senderAddressDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.config?.platform_config?.from}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...ref.current?.config?.platform_config,
from: value,
},
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('sendTestEmail')}</Label>
<p className='text-muted-foreground text-xs'>{t('sendTestEmailDescription')}</p>
</TableCell>
<TableCell className='flex items-center gap-2 text-right'>
<EnhancedInput
placeholder='test@example.com'
value={email}
type='email'
onValueChange={(value) => setEmail(value as string)}
/>
<Button
disabled={!email || isFetching}
onClick={async () => {
if (!email) return;
try {
await testEmailSend({ email });
toast.success(t('sendSuccess'));
} catch {
toast.error(t('sendFailure'));
}
}}
>
{t('sendTestEmail')}
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
<TabsContent value='template'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{['verify_email_template', 'expiration_email_template', 'maintenance_email_template'].map(
(templateKey) => (
<Card key={templateKey}>
<CardHeader>
<CardTitle>{t(`${templateKey}`)}</CardTitle>
<CardDescription>
{t(`${templateKey}Description`, { after: '{{', before: '}}' })}
</CardDescription>
</CardHeader>
<CardContent>
<HTMLEditor
placeholder={t('inputPlaceholder')}
value={data?.config?.[templateKey] as string}
onBlur={(value) =>
updateConfig('config', {
...data?.config,
[templateKey]: value,
})
}
/>
</CardContent>
</Card>
),
)}
</div>
</TabsContent>
<TabsContent value='logs'>
<LogsTable type='email' />
</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,92 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('facebook');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'facebook'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'facebook',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientId')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='1234567890123456'
value={data?.config?.client_id}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_id: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('clientSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='1234567890abcdef1234567890abcdef'
value={data?.config?.client_secret}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_secret: value,
});
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -0,0 +1,103 @@
'use client';
import { getInviteConfig, updateInviteConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function Invite() {
const t = useTranslations('auth-control.invite');
const { data, refetch } = useQuery({
queryKey: ['getInviteConfig'],
queryFn: async () => {
const { data } = await getInviteConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateInviteConfig({
...data,
[key]: value,
} as API.InviteConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Card>
<CardHeader>
<CardTitle>{t('inviteSettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enableForcedInvite')}</Label>
<p className='text-muted-foreground text-xs'>
{t('enableForcedInviteDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.forced_invite}
onCheckedChange={(checked) => {
updateConfig('forced_invite', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('inviteCommissionPercentage')}</Label>
<p className='text-muted-foreground text-xs'>
{t('inviteCommissionPercentageDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.referral_percentage}
type='number'
min={0}
max={100}
suffix='%'
onValueBlur={(value) => updateConfig('referral_percentage', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('commissionFirstTimeOnly')}</Label>
<p className='text-muted-foreground text-xs'>
{t('commissionFirstTimeOnlyDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.only_first_purchase}
onCheckedChange={(checked) => {
updateConfig('only_first_purchase', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,17 @@
'use client';
import { Invite } from './invite';
import { Register } from './register';
import { Verify } from './verify';
import { VerifyCode } from './verify-code';
export default function Page() {
return (
<div className='space-y-3'>
<Invite />
<Register />
<VerifyCode />
<Verify />
</div>
);
}
@@ -0,0 +1,201 @@
'use client';
import { getSubscribeList } from '@/services/admin/subscribe';
import { getRegisterConfig, updateRegisterConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Label } from '@workspace/ui/components/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Combobox } from '@workspace/ui/custom-components/combobox';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function Register() {
const t = useTranslations('auth-control.register');
const { data, refetch } = useQuery({
queryKey: ['getRegisterConfig'],
queryFn: async () => {
const { data } = await getRegisterConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
await updateRegisterConfig({
...data,
[key]: value,
} as API.RegisterConfig);
toast.success(t('saveSuccess'));
refetch();
}
const { data: subscribe } = useQuery({
queryKey: ['getSubscribeList', 'all'],
queryFn: async () => {
const { data } = await getSubscribeList({
page: 1,
size: 9999,
});
return data.data?.list as API.Subscribe[];
},
});
return (
<Card>
<CardHeader>
<CardTitle>{t('registerSettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('stopNewUserRegistration')}</Label>
<p className='text-muted-foreground text-xs'>
{t('stopNewUserRegistrationDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.stop_register}
onCheckedChange={(checked) => {
updateConfig('stop_register', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('ipRegistrationLimit')}</Label>
<p className='text-muted-foreground text-xs'>
{t('ipRegistrationLimitDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_ip_register_limit}
onCheckedChange={(checked) => {
updateConfig('enable_ip_register_limit', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('registrationLimitCount')}</Label>
<p className='text-muted-foreground text-xs'>
{t('registrationLimitCountDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
value={data?.ip_register_limit}
onValueBlur={(value) => updateConfig('ip_register_limit', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('penaltyTime')}</Label>
<p className='text-muted-foreground text-xs'>{t('penaltyTimeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
value={data?.ip_register_limit_duration}
onValueBlur={(value) => updateConfig('ip_register_limit_duration', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('trialRegistration')}</Label>
<p className='text-muted-foreground text-xs'>{t('trialRegistrationDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_trial}
onCheckedChange={(checked) => {
updateConfig('enable_trial', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('trialSubscribePlan')}</Label>
<p className='text-muted-foreground text-xs'>
{t('trialSubscribePlanDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('trialDuration')}
type='number'
min={0}
value={data?.trial_time}
onValueBlur={(value) => updateConfig('trial_time', value)}
prefix={
<Select
value={String(data?.trial_subscribe)}
onValueChange={(value) => updateConfig('trial_subscribe', Number(value))}
>
<SelectTrigger className='bg-secondary rounded-r-none'>
{data?.trial_subscribe ? (
<SelectValue placeholder='Select Subscribe' />
) : (
'Select Subscribe'
)}
</SelectTrigger>
<SelectContent>
{subscribe?.map((item) => (
<SelectItem key={item.id} value={String(item.id)}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
}
suffix={
<Combobox
className='bg-secondary rounded-l-none'
value={data?.trial_time_unit}
onChange={(value) => {
if (value) {
updateConfig('trial_time_unit', value);
}
}}
options={[
{ label: t('noLimit'), value: 'NoLimit' },
{ label: t('year'), value: 'Year' },
{ label: t('month'), value: 'Month' },
{ label: t('day'), value: 'Day' },
{ label: t('hour'), value: 'Hour' },
{ label: t('minute'), value: 'Minute' },
]}
/>
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,95 @@
'use client';
import { getVerifyCodeConfig, updateVerifyCodeConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Label } from '@workspace/ui/components/label';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function VerifyCode() {
const t = useTranslations('auth-control.verify-code');
const { data, refetch } = useQuery({
queryKey: ['getVerifyCodeConfig'],
queryFn: async () => {
const { data } = await getVerifyCodeConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateVerifyCodeConfig({
...data,
[key]: value,
} as API.VerifyCodeConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Card className='mb-6'>
<CardHeader>
<CardTitle>{t('verifyCodeSettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('expireTime')}</Label>
<p className='text-muted-foreground text-xs'>{t('expireTimeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
placeholder='300'
value={data?.verify_code_expire_time}
onValueBlur={(value) => updateConfig('verify_code_expire_time', Number(value))}
suffix={t('second')}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('interval')}</Label>
<p className='text-muted-foreground text-xs'>{t('intervalDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
placeholder='60'
value={data?.verify_code_interval}
onValueBlur={(value) => updateConfig('verify_code_interval', Number(value))}
suffix={t('second')}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('dailyLimit')}</Label>
<p className='text-muted-foreground text-xs'>{t('dailyLimitDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
placeholder='15'
value={data?.verify_code_limit}
onValueBlur={(value) => updateConfig('verify_code_limit', Number(value))}
suffix={t('times')}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,125 @@
'use client';
import { getVerifyConfig, updateVerifyConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export function Verify() {
const t = useTranslations('auth-control.verify');
const { data, refetch } = useQuery({
queryKey: ['getVerifyConfig'],
queryFn: async () => {
const { data } = await getVerifyConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateVerifyConfig({
...data,
[key]: value,
} as API.VerifyConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Card className='mb-6'>
<CardHeader>
<CardTitle>{t('verifySettings')}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>Turnstile Site Key</Label>
<p className='text-muted-foreground text-xs'>{t('turnstileSiteKeyDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.turnstile_site_key}
onValueBlur={(value) => updateConfig('turnstile_site_key', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>Turnstile Site Secret</Label>
<p className='text-muted-foreground text-xs'>{t('turnstileSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.turnstile_secret}
onValueBlur={(value) => updateConfig('turnstile_secret', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('registrationVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('registrationVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_register_verify}
onCheckedChange={(checked) => {
updateConfig('enable_register_verify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('loginVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('loginVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_login_verify}
onCheckedChange={(checked) => {
updateConfig('enable_login_verify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('resetPasswordVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('resetPasswordVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_reset_password_verify}
onCheckedChange={(checked) => {
updateConfig('enable_reset_password_verify', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,91 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('github');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'github'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'github',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientId')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='e.g., Iv1.1234567890abcdef'
value={data?.config?.client_id}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
client_id: value,
})
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('clientSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='e.g., 1234567890abcdef1234567890abcdef12345678'
value={data?.config?.client_secret}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_secret: value,
});
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -0,0 +1,92 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('google');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'google'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'google',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientId')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='123456789-abc123def456.apps.googleusercontent.com'
value={data?.config?.client_id}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_id: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('clientSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
value={data?.config?.client_secret}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
client_secret: value,
});
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -0,0 +1,29 @@
'use client';
import { AuthControl } from '@/config/navs';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
interface AuthControlLayoutProps {
children: React.ReactNode;
}
export default function AuthControlLayout({ children }: Readonly<AuthControlLayoutProps>) {
const pathname = usePathname();
const t = useTranslations('menu');
if (!pathname) return null;
return (
<Tabs value={pathname}>
<TabsList className='h-full flex-wrap'>
{AuthControl.map((item) => (
<TabsTrigger key={item.url} value={item.url} asChild>
<Link href={item.url}>{t(item.title)}</Link>
</TabsTrigger>
))}
</TabsList>
<TabsContent value={pathname}>{children}</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,108 @@
import { ProTable, ProTableActions } from '@/components/pro-table';
import { getMessageLogList } from '@/services/admin/log';
import { Badge } from '@workspace/ui/components/badge';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useRef } from 'react';
export function LogsTable({ type }: { type: 'email' | 'mobile' }) {
const t = useTranslations('auth-control.log');
const ref = useRef<ProTableActions>(null);
return (
<ProTable<
API.MessageLog,
{
platform?: string;
to?: string;
subject?: string;
content?: string;
status?: number;
}
>
action={ref}
header={{
title: t(`${type}Log`),
}}
columns={[
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'platform',
header: t('platform'),
},
{
accessorKey: 'to',
header: t('to'),
},
{
accessorKey: 'subject',
header: t('subject'),
},
{
accessorKey: 'content',
header: t('content'),
},
{
accessorKey: 'status',
header: t('status'),
cell: ({ row }) => {
const status = row.getValue('status');
const text = status === 1 ? t('sendSuccess') : t('sendFailed');
return <Badge variant={status === 1 ? 'default' : 'destructive'}>{text}</Badge>;
},
},
{
accessorKey: 'created_at',
header: t('createdAt'),
cell: ({ row }) => formatDate(row.getValue('created_at')),
},
{
accessorKey: 'updated_at',
header: t('updatedAt'),
cell: ({ row }) => formatDate(row.getValue('updated_at')),
},
]}
params={[
// {
// key: 'platform',
// placeholder: t('platform'),
// },
{
key: 'to',
placeholder: t('to'),
},
{
key: 'subject',
placeholder: t('subject'),
},
{
key: 'content',
placeholder: t('content'),
},
{
key: 'status',
placeholder: t('status'),
options: [
{ label: t('sendSuccess'), value: '1' },
{ label: t('sendFailed'), value: '0' },
],
},
]}
request={async (pagination, filter) => {
const { data } = await getMessageLogList({
...pagination,
...filter,
status: filter.status === undefined ? undefined : Number(filter.status),
type: type,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
return redirect('/dashboard/auth-control/general');
}
@@ -0,0 +1,395 @@
'use client';
import {
getAuthMethodConfig,
getSmsPlatform,
testSmsSend,
updateAuthMethodConfig,
} from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import { Label } from '@workspace/ui/components/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Textarea } from '@workspace/ui/components/textarea';
import { AreaCodeSelect } from '@workspace/ui/custom-components/area-code-select';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import TagInput from '@workspace/ui/custom-components/tag-input';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useState } from 'react';
import { toast } from 'sonner';
import { LogsTable } from '../log';
export default function Page() {
const t = useTranslations('phone');
const { data, refetch, isFetching } = useQuery({
queryKey: ['getAuthMethodConfig', 'mobile'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'mobile',
});
return data.data;
},
});
const { data: platforms } = useQuery({
queryKey: ['getSmsPlatform'],
queryFn: async () => {
const { data } = await getSmsPlatform();
return data.data?.list;
},
});
const selectedPlatform = platforms?.find(
(platform) => platform.platform === data?.config?.platform,
);
const { platform_url, platform_field_description: platformConfig } = selectedPlatform ?? {};
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('updateSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
const [params, setParams] = useState<API.TestSmsSendRequest>({
telephone: '',
area_code: '1',
});
return (
<Tabs defaultValue='settings' className='w-full'>
<TabsList>
<TabsTrigger value='settings'>{t('settings')}</TabsTrigger>
<TabsTrigger value='logs'>{t('logs')}</TabsTrigger>
</TabsList>
<TabsContent value='settings'>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableTip')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
disabled={isFetching}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('whitelistValidation')}</Label>
<p className='text-muted-foreground text-xs'>{t('whitelistValidationTip')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
defaultValue={data?.config?.enable_whitelist}
onCheckedChange={(checked) =>
updateConfig('config', { ...data?.config, enable_whitelist: checked })
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('whitelistAreaCode')}</Label>
<p className='text-muted-foreground text-xs'>{t('whitelistAreaCodeTip')}</p>
</TableCell>
<TableCell className='w-1/2 text-right'>
<TagInput
placeholder='1, 852, 886, 888'
value={data?.config?.whitelist || []}
onChange={(value) =>
updateConfig('config', { ...data?.config, whitelist: value })
}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('platform')}</Label>
<p className='text-muted-foreground text-xs'>{t('platformTip')}</p>
</TableCell>
<TableCell className='flex items-center gap-1 text-right'>
<Select
value={data?.config?.platform}
onValueChange={(value) =>
updateConfig('config', {
...data?.config,
platform: value,
})
}
disabled={isFetching}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{platforms?.map((item) => (
<SelectItem key={item.platform} value={item.platform}>
{item.platform}
</SelectItem>
))}
</SelectContent>
</Select>
{platform_url && (
<Button size='sm' asChild>
<Link href={platform_url} target='_blank'>
{t('applyPlatform')}
</Link>
</Button>
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('accessLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.access })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config.access ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
access: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', { key: platformConfig?.access })}
/>
</TableCell>
</TableRow>
{platformConfig?.endpoint && (
<TableRow>
<TableCell>
<Label>{t('endpointLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.endpoint })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config.endpoint ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
endpoint: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', { key: platformConfig?.endpoint })}
/>
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell>
<Label>{t('secretLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.secret })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config?.secret ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
secret: value,
},
})
}
disabled={isFetching}
type='password'
placeholder={t('platformConfigTip', { key: platformConfig?.secret })}
/>
</TableCell>
</TableRow>
{platformConfig?.template_code && (
<TableRow>
<TableCell>
<Label>{t('templateCodeLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.template_code })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config?.template_code ?? 'code'}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
template_code: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', { key: platformConfig?.template_code })}
/>
</TableCell>
</TableRow>
)}
{platformConfig?.sign_name && (
<TableRow>
<TableCell>
<Label>{t('signNameLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.sign_name })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config?.sign_name ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
sign_name: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', {
key: platformConfig?.sign_name,
})}
/>
</TableCell>
</TableRow>
)}
{platformConfig?.phone_number && (
<TableRow>
<TableCell>
<Label>{t('phoneNumberLabel')}</Label>
<p className='text-muted-foreground text-xs'>
{t('platformConfigTip', { key: platformConfig?.phone_number })}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
value={data?.config?.platform_config?.phone_number ?? ''}
onValueBlur={(value) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
phone_number: value,
},
})
}
disabled={isFetching}
placeholder={t('platformConfigTip', {
key: platformConfig?.phone_number,
})}
/>
</TableCell>
</TableRow>
)}
{platformConfig?.code_variable && (
<TableRow>
<TableCell>
<Label>{t('template')}</Label>
<p className='text-muted-foreground text-xs'>
{t('templateTip', { code: platformConfig?.code_variable })}
</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
defaultValue={data?.config?.platform_config?.template ?? ''}
onBlur={(e) =>
updateConfig('config', {
...data?.config,
platform_config: {
...data?.config?.platform_config,
template: e.target.value,
},
})
}
disabled={isFetching}
placeholder={t('placeholders.template', {
code: platformConfig?.code_variable,
})}
/>
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell>
<Label>{t('testSms')}</Label>
<p className='text-muted-foreground text-xs'>{t('testSmsTip')}</p>
</TableCell>
<TableCell className='flex items-center gap-2 text-right'>
<AreaCodeSelect
value={params.area_code}
onChange={(value) => {
if (value.phone) {
setParams((prev) => ({ ...prev, area_code: value.phone! }));
}
}}
/>
<EnhancedInput
placeholder={t('testSmsPhone')}
value={params.telephone}
onValueChange={(value) => {
setParams((prev) => ({ ...prev, telephone: value as string }));
}}
/>
<Button
disabled={!params.telephone || !params.area_code}
onClick={async () => {
if (isFetching || !params.telephone || !params.area_code) return;
try {
await testSmsSend(params);
toast.success(t('sendSuccess'));
} catch {
toast.error(t('sendFailed'));
}
}}
>
{t('testSms')}
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
<TabsContent value='logs'>
<LogsTable type='mobile' />
</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,92 @@
'use client';
import { getAuthMethodConfig, updateAuthMethodConfig } from '@/services/admin/authMethod';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Page() {
const t = useTranslations('telegram');
const { data, refetch } = useQuery({
queryKey: ['getAuthMethodConfig', 'telegram'],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: 'telegram',
});
return data.data;
},
});
async function updateConfig(key: keyof API.UpdateAuthMethodConfigRequest, value: unknown) {
try {
await updateAuthMethodConfig({
...data,
[key]: value,
} as API.UpdateAuthMethodConfigRequest);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
toast.error(t('saveFailed'));
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enable')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enabled}
onCheckedChange={(checked) => updateConfig('enabled', checked)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientId')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientIdDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='6123456789'
value={data?.config?.bot}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
bot: value,
});
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('clientSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('clientSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder='6123456789:AAHn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
value={data?.config?.bot_token}
onValueBlur={(value) => {
updateConfig('config', {
...data?.config,
bot_token: value,
});
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -2,7 +2,6 @@
import { getSubscribeList } from '@/services/admin/subscribe'; import { getSubscribeList } from '@/services/admin/subscribe';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { import {
@@ -26,6 +25,7 @@ import {
import { Combobox } from '@workspace/ui/custom-components/combobox'; import { Combobox } from '@workspace/ui/custom-components/combobox';
import { DatePicker } from '@workspace/ui/custom-components/date-picker'; import { DatePicker } from '@workspace/ui/custom-components/date-picker';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { unitConversion } from '@workspace/ui/utils'; import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -235,7 +235,7 @@ export default function CouponForm<T extends Record<string, any>>({
)} )}
<FormField <FormField
control={form.control} control={form.control}
name='server' name='subscribe'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.specifiedServer')}</FormLabel> <FormLabel>{t('form.specifiedServer')}</FormLabel>
+2 -2
View File
@@ -96,9 +96,9 @@ export default function Page() {
defaultChecked={row.getValue('enable')} defaultChecked={row.getValue('enable')}
onCheckedChange={async (checked) => { onCheckedChange={async (checked) => {
await updateCoupon({ await updateCoupon({
...row, ...row.original,
enable: checked, enable: checked,
} as any); } as API.UpdateCouponRequest);
ref.current?.refresh(); ref.current?.refresh();
}} }}
/> />
@@ -1,5 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { import {
Form, Form,
@@ -20,6 +19,7 @@ import {
SheetTrigger, SheetTrigger,
} from '@workspace/ui/components/sheet'; } from '@workspace/ui/components/sheet';
import { MarkdownEditor } from '@workspace/ui/custom-components/editor'; import { MarkdownEditor } from '@workspace/ui/custom-components/editor';
import { Icon } from '@workspace/ui/custom-components/icon';
import { TagInput } from '@workspace/ui/custom-components/tag-input'; import { TagInput } from '@workspace/ui/custom-components/tag-input';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
+14 -5
View File
@@ -17,7 +17,7 @@ import { cn } from '@workspace/ui/lib/utils';
import { formatDate } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { UserDetail } from '../user/user-detail'; import { UserDetail } from '../user/user-detail';
export default function Page() { export default function Page(props: any) {
const t = useTranslations('order'); const t = useTranslations('order');
const statusOptions = [ const statusOptions = [
@@ -115,7 +115,7 @@ export default function Page() {
<Separator className='my-4' /> <Separator className='my-4' />
<ul className='grid gap-3'> <ul className='grid gap-3'>
<li className='flex items-center justify-between'> <li className='flex items-center justify-between'>
<span className='text-muted-foreground'></span> <span className='text-muted-foreground'>{t('method')}</span>
<span>{t(`methods.${row.original.method}`)}</span> <span>{t(`methods.${row.original.method}`)}</span>
</li> </li>
</ul> </ul>
@@ -177,10 +177,19 @@ export default function Page() {
value: String(item.id), value: String(item.id),
})), })),
}, },
{ key: 'user_id', placeholder: `${t('user')}ID` }, ].concat(
]} props.userId
? []
: [
{
key: 'user_id',
placeholder: `${t('user')} ID`,
options: undefined,
},
],
)}
request={async (pagination, filter) => { request={async (pagination, filter) => {
const { data } = await getOrderList({ ...pagination, ...filter }); const { data } = await getOrderList({ ...pagination, ...filter, user_id: props.userId });
return { return {
list: data.data?.list || [], list: data.data?.list || [],
total: data.data?.total || 0, total: data.data?.total || 0,
@@ -91,6 +91,9 @@ const protocolConfigSchema = z.discriminatedUnion('protocol', [
const baseFormSchema = z.object({ const baseFormSchema = z.object({
name: z.string(), name: z.string(),
tags: z.array(z.string()).nullish().default([]),
country: z.string().nullish(),
city: z.string().nullish(),
server_addr: z.string(), server_addr: z.string(),
speed_limit: z.number().nullish(), speed_limit: z.number().nullish(),
traffic_ratio: z.number().default(1), traffic_ratio: z.number().default(1),
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { import {
Form, Form,
@@ -21,6 +20,7 @@ import {
SheetTrigger, SheetTrigger,
} from '@workspace/ui/components/sheet'; } from '@workspace/ui/components/sheet';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -14,8 +14,8 @@ import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/
import { ArrayInput } from '@workspace/ui/custom-components/dynamic-Inputs'; import { ArrayInput } from '@workspace/ui/custom-components/dynamic-Inputs';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { DicesIcon } from 'lucide-react'; import { DicesIcon } from 'lucide-react';
import { nanoid } from 'nanoid';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { uid } from 'radash';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Cell, Legend, Pie, PieChart } from 'recharts'; import { Cell, Legend, Pie, PieChart } from 'recharts';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -28,8 +28,53 @@ const COLORS = [
'hsl(var(--chart-5))', 'hsl(var(--chart-5))',
]; ];
export default function Node() { const MINUTES_IN_DAY = 1440; // 24 * 60
const t = useTranslations('system.node');
function getTimeRangeData(slots: API.TimePeriod[]) {
const timePoints = slots
.filter((slot) => slot.start_time && slot.end_time)
.flatMap((slot) => {
const [startH = 0, startM = 0] = slot.start_time.split(':').map(Number);
const [endH = 0, endM = 0] = slot?.end_time.split(':').map(Number);
const start = startH * 60 + startM;
let end = endH * 60 + endM;
if (end < start) end += MINUTES_IN_DAY;
return { start, end, multiplier: slot.multiplier };
})
.sort((a, b) => a.start - b.start);
const result = [];
let currentMinute = 0;
timePoints.forEach((point) => {
if (point.start > currentMinute) {
result.push({
name: `${Math.floor(currentMinute / 60)}:${String(currentMinute % 60).padStart(2, '0')} - ${Math.floor(point.start / 60)}:${String(point.start % 60).padStart(2, '0')}`,
value: point.start - currentMinute,
multiplier: 1,
});
}
result.push({
name: `${Math.floor(point.start / 60)}:${String(point.start % 60).padStart(2, '0')} - ${Math.floor((point.end / 60) % 24)}:${String(point.end % 60).padStart(2, '0')}`,
value: point.end - point.start,
multiplier: point.multiplier,
});
currentMinute = point.end % MINUTES_IN_DAY;
});
if (currentMinute < MINUTES_IN_DAY) {
result.push({
name: `${Math.floor(currentMinute / 60)}:${String(currentMinute % 60).padStart(2, '0')} - 24:00`,
value: MINUTES_IN_DAY - currentMinute,
multiplier: 1,
});
}
return result;
}
export default function NodeConfig() {
const t = useTranslations('server.config');
const { data, refetch } = useQuery({ const { data, refetch } = useQuery({
queryKey: ['getNodeConfig'], queryKey: ['getNodeConfig'],
@@ -68,14 +113,11 @@ export default function Node() {
}); });
const chartTimeSlots = useMemo(() => { const chartTimeSlots = useMemo(() => {
return timeSlots.map((slot) => ({ return getTimeRangeData(timeSlots);
name: `${slot.start_time} - ${slot.end_time}`,
value: slot.multiplier,
}));
}, [timeSlots]); }, [timeSlots]);
const chartConfig = useMemo(() => { const chartConfig = useMemo(() => {
return chartTimeSlots.reduce( return chartTimeSlots?.reduce(
(acc, item, index) => { (acc, item, index) => {
acc[item.name] = { acc[item.name] = {
label: item.name, label: item.name,
@@ -102,11 +144,16 @@ export default function Node() {
value={data?.node_secret} value={data?.node_secret}
onValueBlur={(value) => updateConfig('node_secret', value)} onValueBlur={(value) => updateConfig('node_secret', value)}
suffix={ suffix={
<DicesIcon <div className='bg-muted flex h-9 items-center text-nowrap px-3'>
onClick={() => { <DicesIcon
updateConfig('node_secret', nanoid()); onClick={() => {
}} const id = uid(32).toLowerCase();
/> const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
updateConfig('node_secret', formatted);
}}
className='cursor-pointer'
/>
</div>
} }
/> />
</TableCell> </TableCell>
@@ -136,6 +183,7 @@ export default function Node() {
<EnhancedInput <EnhancedInput
type='number' type='number'
min={0} min={0}
step={0.1}
value={data?.node_push_interval} value={data?.node_push_interval}
onValueBlur={(value) => updateConfig('node_push_interval', value)} onValueBlur={(value) => updateConfig('node_push_interval', value)}
placeholder={t('inputPlaceholder')} placeholder={t('inputPlaceholder')}
@@ -187,8 +235,8 @@ export default function Node() {
outerRadius='80%' outerRadius='80%'
fill='#8884d8' fill='#8884d8'
dataKey='value' dataKey='value'
label={({ name, percent, value }) => label={({ name, percent, multiplier }) =>
`${value?.toFixed(2)}x (${(percent * 100).toFixed(0)}%)` `${(multiplier || 0)?.toFixed(2)}x (${(percent * 100).toFixed(0)}%)`
} }
> >
{chartTimeSlots.map((entry, index) => ( {chartTimeSlots.map((entry, index) => (
@@ -214,7 +262,7 @@ export default function Node() {
<span className='text-muted-foreground text-[0.70rem] uppercase'> <span className='text-muted-foreground text-[0.70rem] uppercase'>
{t('multiplier')} {t('multiplier')}
</span> </span>
<span className='font-bold'>{data.value.toFixed(2)}x</span> <span className='font-bold'>{data.multiplier.toFixed(2)}x</span>
</div> </div>
</div> </div>
</div> </div>
@@ -236,7 +284,7 @@ export default function Node() {
type: 'time', type: 'time',
}, },
{ name: 'end_time', prefix: t('endTime'), type: 'time' }, { name: 'end_time', prefix: t('endTime'), type: 'time' },
{ name: 'multiplier', prefix: t('multiplier'), type: 'number' }, { name: 'multiplier', prefix: t('multiplier'), type: 'number', placeholder: '0' },
]} ]}
value={timeSlots} value={timeSlots}
onChange={setTimeSlots} onChange={setTimeSlots}
+63 -3
View File
@@ -2,7 +2,6 @@
import { getNodeGroupList } from '@/services/admin/server'; import { getNodeGroupList } from '@/services/admin/server';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'; import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
@@ -35,6 +34,8 @@ import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Combobox } from '@workspace/ui/custom-components/combobox'; import { Combobox } from '@workspace/ui/custom-components/combobox';
import { ArrayInput } from '@workspace/ui/custom-components/dynamic-Inputs'; import { ArrayInput } from '@workspace/ui/custom-components/dynamic-Inputs';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import TagInput from '@workspace/ui/custom-components/tag-input';
import { cn } from '@workspace/ui/lib/utils'; import { cn } from '@workspace/ui/lib/utils';
import { unitConversion } from '@workspace/ui/utils'; import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
@@ -63,6 +64,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
const form = useForm({ const form = useForm({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
tags: [],
traffic_ratio: 1, traffic_ratio: 1,
protocol: 'shadowsocks', protocol: 'shadowsocks',
...initialValues, ...initialValues,
@@ -139,7 +141,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
name='group_id' name='group_id'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.nodeGroupId')}</FormLabel> <FormLabel>{t('form.groupId')}</FormLabel>
<FormControl> <FormControl>
<Combobox<number, false> <Combobox<number, false>
placeholder={t('form.selectNodeGroup')} placeholder={t('form.selectNodeGroup')}
@@ -148,6 +150,64 @@ export default function NodeForm<T extends { [x: string]: any }>({
value: item.id, value: item.id,
label: item.name, label: item.name,
}))} }))}
onChange={(value) => {
form.setValue(field.name, value || 0);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-5 gap-2'>
<FormField
control={form.control}
name='tags'
render={({ field }) => (
<FormItem className='col-span-3'>
<FormLabel>{t('form.tags')}</FormLabel>
<FormControl>
<TagInput
placeholder={t('form.tagsPlaceholder')}
value={field.value || []}
onChange={(value) => form.setValue(field.name, value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='country'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.country')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='city'
render={({ field }) => (
<FormItem>
<FormLabel>{t('form.city')}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -190,7 +250,7 @@ export default function NodeForm<T extends { [x: string]: any }>({
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
}} }}
suffix='MB' suffix='Mbps'
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
+58 -30
View File
@@ -116,6 +116,24 @@ export default function NodeTable() {
); );
}, },
}, },
{
accessorKey: 'name',
header: t('name'),
},
{
accessorKey: 'server_addr',
header: t('serverAddr'),
cell: ({ row }) => {
return (
<div className='flex gap-1'>
<Badge variant='outline'>
{row.original.country} - {row.original.city}
</Badge>
<Badge variant='outline'>{row.getValue('server_addr')}</Badge>
</div>
);
},
},
{ {
accessorKey: 'status', accessorKey: 'status',
header: t('status'), header: t('status'),
@@ -123,19 +141,11 @@ export default function NodeTable() {
return <NodeStatusCell status={row.original?.status} />; return <NodeStatusCell status={row.original?.status} />;
}, },
}, },
{
accessorKey: 'name',
header: t('name'),
},
{
accessorKey: 'server_addr',
header: t('serverAddr'),
},
{ {
accessorKey: 'speed_limit', accessorKey: 'speed_limit',
header: t('speedLimit'), header: t('speedLimit'),
cell: ({ row }) => ( cell: ({ row }) => (
<Display type='traffic' value={row.getValue('speed_limit')} unlimited /> <Display type='trafficSpeed' value={row.getValue('speed_limit')} unlimited />
), ),
}, },
{ {
@@ -152,6 +162,24 @@ export default function NodeTable() {
return name ? <Badge variant='outline'>{name}</Badge> : '--'; return name ? <Badge variant='outline'>{name}</Badge> : '--';
}, },
}, },
{
accessorKey: 'tags',
header: t('tags'),
cell: ({ row }) => {
const tags = (row.getValue('tags') as string[]) || [];
return tags.length > 0 ? (
<div className='flex gap-1'>
{tags.map((tag) => (
<Badge key={tag} variant='outline'>
{tag}
</Badge>
))}
</div>
) : (
'--'
);
},
},
]} ]}
params={[ params={[
{ {
@@ -200,9 +228,24 @@ export default function NodeTable() {
} }
}} }}
/>, />,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await deleteNode({
id: row.id,
});
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
<Button <Button
key='copy' key='copy'
variant='secondary' variant='outline'
onClick={async () => { onClick={async () => {
setLoading(true); setLoading(true);
try { try {
@@ -223,21 +266,6 @@ export default function NodeTable() {
> >
{t('copy')} {t('copy')}
</Button>, </Button>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await deleteNode({
id: row.id,
});
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
], ],
batchRender(rows) { batchRender(rows) {
return [ return [
@@ -263,20 +291,20 @@ export default function NodeTable() {
const sourceIndex = items.findIndex((item) => String(item.id) === source); const sourceIndex = items.findIndex((item) => String(item.id) === source);
const targetIndex = items.findIndex((item) => String(item.id) === target); const targetIndex = items.findIndex((item) => String(item.id) === target);
const originalSortMap = new Map(items.map((item) => [item.id, item.sort || item.id])); const originalSorts = items.map((item) => item.sort);
const [movedItem] = items.splice(sourceIndex, 1); const [movedItem] = items.splice(sourceIndex, 1);
items.splice(targetIndex, 0, movedItem!); items.splice(targetIndex, 0, movedItem!);
const updatedItems = items.map((item, index) => { const updatedItems = items.map((item, index) => {
const originalSort = originalSortMap.get(item.id); const originalSort = originalSorts[index];
const newSort = originalSort !== undefined ? originalSort : item.sort; const newSort = originalSort !== undefined ? originalSort : item.sort;
return { ...item, sort: newSort }; return { ...item, sort: newSort };
}); });
const changedItems = updatedItems.filter( const changedItems = updatedItems.filter((item, index) => {
(item) => originalSortMap.get(item.id) !== item.sort, return item.sort !== items[index]?.sort;
); });
if (changedItems.length > 0) { if (changedItems.length > 0) {
nodeSort({ nodeSort({
+5
View File
@@ -2,6 +2,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/componen
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import GroupTable from './group-table'; import GroupTable from './group-table';
import NodeConfig from './node-config';
import NodeTable from './node-table'; import NodeTable from './node-table';
export default async function Page() { export default async function Page() {
@@ -12,6 +13,7 @@ export default async function Page() {
<TabsList> <TabsList>
<TabsTrigger value='node'>{t('tabs.node')}</TabsTrigger> <TabsTrigger value='node'>{t('tabs.node')}</TabsTrigger>
<TabsTrigger value='group'>{t('tabs.nodeGroup')}</TabsTrigger> <TabsTrigger value='group'>{t('tabs.nodeGroup')}</TabsTrigger>
<TabsTrigger value='config'>{t('tabs.nodeConfig')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='node'> <TabsContent value='node'>
<NodeTable /> <NodeTable />
@@ -19,6 +21,9 @@ export default async function Page() {
<TabsContent value='group'> <TabsContent value='group'>
<GroupTable /> <GroupTable />
</TabsContent> </TabsContent>
<TabsContent value='config'>
<NodeConfig />
</TabsContent>
</Tabs> </Tabs>
); );
} }
@@ -0,0 +1,282 @@
'use client';
import {
getApplication,
getApplicationConfig,
updateApplicationConfig,
} from '@/services/admin/system';
import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Textarea } from '@workspace/ui/components/textarea';
import { Combobox } from '@workspace/ui/custom-components/combobox';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { UploadImage } from '@workspace/ui/custom-components/upload-image';
import { DicesIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { uid } from 'radash';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const formSchema = z.object({
app_id: z.number().optional(),
encryption_key: z.string().optional(),
encryption_method: z.string().optional(),
startup_picture: z.string().optional(),
startup_picture_skip_time: z.number().optional(),
domains: z.array(z.string()).optional(),
});
type FormSchema = z.infer<typeof formSchema>;
export default function ConfigForm() {
const t = useTranslations('subscribe.app');
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
app_id: 0,
encryption_key: '',
encryption_method: '',
startup_picture: '',
startup_picture_skip_time: 0,
domains: [],
},
});
const { data, refetch } = useQuery({
queryKey: ['getApplicationConfig'],
queryFn: async () => {
const { data } = await getApplicationConfig();
return data.data;
},
});
const { data: applications } = useQuery({
queryKey: ['getApplication'],
queryFn: async () => {
const { data } = await getApplication();
return data.data?.applications || [];
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: FormSchema) {
setLoading(true);
try {
await updateApplicationConfig({
...values,
domains: values.domains?.filter((domain) => domain),
} as API.ApplicationConfig);
toast.success(t('updateSuccess'));
refetch();
setOpen(false);
} catch (error) {
/* empty */
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant='outline'>
<Icon icon='mdi:cog' className='mr-2' />
{t('config')}
</Button>
</SheetTrigger>
<SheetContent className='w-[520px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{t('configApp')}</SheetTitle>
</SheetHeader>
<ScrollArea className='h-[calc(100dvh-48px-36px-36px)]'>
<Form {...form}>
<form className='space-y-4 py-4'>
<FormField
control={form.control}
name='app_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('selectApp')}</FormLabel>
<FormDescription>{t('selectAppDescription')}</FormDescription>
<FormControl>
<Combobox
{...field}
options={
applications?.map((app) => ({
label: app.name,
value: app.id,
})) || []
}
value={field.value}
onChange={(value) => form.setValue(field.name, value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='encryption_key'
render={({ field }) => (
<FormItem>
<FormLabel>{t('communicationKey')}</FormLabel>
<FormDescription>{t('communicationKeyDescription')}</FormDescription>
<FormControl>
<EnhancedInput
value={field.value}
onValueChange={(value) => form.setValue(field.name, value as string)}
suffix={
<div className='bg-muted flex h-9 items-center text-nowrap px-3'>
<DicesIcon
onClick={() => {
const id = uid(32).toLowerCase();
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
form.setValue(field.name, formatted);
}}
className='cursor-pointer'
/>
</div>
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='encryption_method'
render={({ field }) => (
<FormItem>
<FormLabel>{t('encryption')}</FormLabel>
<FormDescription>{t('encryptionDescription')}</FormDescription>
<FormControl>
<Combobox
options={[
{ label: 'none', value: 'none' },
{ label: 'AES', value: 'aes' },
]}
value={field.value}
onChange={(value) => form.setValue(field.name, value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='startup_picture'
render={({ field }) => (
<FormItem>
<FormLabel>{t('startupPicture')}</FormLabel>
<FormDescription>{t('startupPictureDescription')}</FormDescription>
<FormControl>
<EnhancedInput
value={field.value}
onValueChange={(value) => form.setValue(field.name, value as string)}
suffix={
<UploadImage
className='bg-muted h-9 rounded-none border-none px-2'
onChange={(value) => form.setValue('startup_picture', value as string)}
/>
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='startup_picture_skip_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('startupPictureSkip')}</FormLabel>
<FormDescription>{t('startupPictureSkipDescription')}</FormDescription>
<FormControl>
<EnhancedInput
{...field}
type='number'
min={0}
suffix='S'
value={field.value}
onValueChange={(value) =>
form.setValue('startup_picture_skip_time', Number(value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='domains'
render={({ field }) => (
<FormItem className='px-1'>
<FormLabel>{t('backupDomains')}</FormLabel>
<FormDescription>{t('backupDomainsDescription')}</FormDescription>
<FormControl>
<Textarea
className='h-28'
placeholder='example.com'
value={field.value?.join('\n')}
onChange={(e) => {
form.setValue(field.name, e.target.value.split('\n'));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' onClick={() => setOpen(false)}>
{t('cancel')}
</Button>
<Button onClick={form.handleSubmit(onSubmit)} disabled={loading}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('confirm')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,291 @@
'use client';
import { getSubscribeType } from '@/services/admin/system';
import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { ArrayInput } from '@workspace/ui/custom-components/dynamic-Inputs';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { UploadImage } from '@workspace/ui/custom-components/upload-image';
import { useTranslations } from 'next-intl';
import { assign, shake } from 'radash';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const platforms = ['windows', 'macos', 'linux', 'android', 'ios', 'harmony'];
const defaultValues = {
subscribe_type: 'Clash',
name: '',
icon: '',
url: '',
};
const versionSchema = z.object({
url: z.string(),
version: z.string().optional(),
description: z.string().optional(),
is_default: z.boolean().optional(),
});
const formSchema = z.object({
icon: z.string(),
name: z.string(),
subscribe_type: z.string(),
platform: z.object({
windows: z.array(versionSchema).optional(),
macos: z.array(versionSchema).optional(),
linux: z.array(versionSchema).optional(),
android: z.array(versionSchema).optional(),
ios: z.array(versionSchema).optional(),
harmony: z.array(versionSchema).optional(),
}),
});
interface FormProps<T> {
trigger: React.ReactNode | string;
title: string;
initialValues?: Partial<T>;
onSubmit: (values: T) => Promise<boolean>;
loading?: boolean;
}
export default function SubscribeAppForm<
T extends API.CreateApplicationRequest | API.UpdateApplicationRequest,
>({ trigger, title, loading, initialValues, onSubmit }: FormProps<T>) {
const t = useTranslations('subscribe.app');
const [open, setOpen] = useState(false);
type FormSchema = z.infer<typeof formSchema>;
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: assign(
defaultValues,
shake(initialValues, (value) => value === null),
),
});
useEffect(() => {
form.reset(
assign(
defaultValues,
shake(initialValues, (value) => value === null),
),
);
}, [form, initialValues]);
const { data: subscribe_types } = useQuery<string[]>({
queryKey: ['getSubscribeType'],
queryFn: async () => {
const { data } = await getSubscribeType();
return data.data?.subscribe_types || [];
},
});
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
{typeof trigger === 'string' ? <Button>{trigger}</Button> : trigger}
</SheetTrigger>
<SheetContent className='w-[520px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className='h-[calc(100dvh-48px-36px-36px)]'>
<Form {...form}>
<form className='space-y-4 py-4'>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem className='col-span-2'>
<FormLabel>{t('appIcon')}</FormLabel>
<FormControl>
<EnhancedInput
required
suffix={
<UploadImage
className='bg-muted h-9 rounded-none border-none px-2'
onChange={(value) => {
form.setValue(field.name, value as string);
}}
/>
}
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value as string);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('appName')}</FormLabel>
<FormControl>
<EnhancedInput
required
type='text'
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value as string);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='subscribe_type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('subscriptionProtocol')}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t('subscriptionProtocol')} />
</SelectTrigger>
<SelectContent>
{subscribe_types?.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormItem>
<FormLabel>{t('platform')}</FormLabel>
<Tabs defaultValue={platforms[0]}>
<TabsList>
{platforms.map((platform) => (
<TabsTrigger key={platform} value={platform} className='uppercase'>
{platform}
</TabsTrigger>
))}
</TabsList>
{platforms.map((platform) => (
<TabsContent key={platform} value={platform}>
<FormField
control={form.control}
name={`platform.${platform as keyof FormSchema['platform']}`}
render={({ field }) => (
<FormItem>
<FormControl>
<ArrayInput
isReverse
className='grid grid-cols-3 gap-4'
fields={[
{
name: 'version',
type: 'text',
placeholder: t('version'),
defaultValue: '1.0.0',
},
{
name: 'description',
type: 'text',
placeholder: t('description'),
},
{
name: 'is_default',
type: 'boolean',
placeholder: t('defaultVersion'),
calculateValue: (value) => {
const newField = field.value?.map((item) => {
if (item.is_default) {
item.is_default = false;
}
return item;
});
form.setValue(field.name, newField);
return value;
},
},
{
name: 'url',
type: 'text',
placeholder: t('downloadLink'),
className: 'col-span-3',
},
]}
value={field.value}
onChange={(value) => {
form.setValue(
field.name,
value.filter((item) => item.url),
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
))}
</Tabs>
</FormItem>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button variant='outline' onClick={() => setOpen(false)}>
{t('cancel')}
</Button>
<Button
onClick={form.handleSubmit(async (values) => {
const success = await onSubmit(values as unknown as T);
if (success) setOpen(false);
})}
disabled={loading}
>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('confirm')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,143 @@
'use client';
import { ProTable, ProTableActions } from '@/components/pro-table';
import {
createApplication,
deleteApplication,
getApplication,
updateApplication,
} from '@/services/admin/system';
import { Button } from '@workspace/ui/components/button';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import ConfigForm from './config';
import SubscribeAppForm from './form';
export default function SubscribeApp() {
const t = useTranslations('subscribe.app');
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.ApplicationResponseInfo, Record<string, unknown>>
action={ref}
header={{
title: t('appList'),
toolbar: (
<div className='flex items-center gap-2'>
<ConfigForm />
<SubscribeAppForm<API.CreateApplicationRequest>
trigger={t('create')}
title={t('createApp')}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createApplication(values);
toast.success(t('createSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>
</div>
),
}}
request={async (_pagination, filters) => {
const { data } = await getApplication();
return {
list: data.data?.applications || [],
total: 0,
};
}}
columns={[
{
accessorKey: 'icon',
header: t('appIcon'),
cell: ({ row }) => (
<Image
src={row.getValue('icon')}
alt={row.getValue('name')}
className='h-8 w-8 rounded-md'
width={32}
height={32}
/>
),
},
{
accessorKey: 'name',
header: t('appName'),
},
{
accessorKey: 'subscribe_type',
header: t('subscriptionProtocol'),
cell: ({ row }) => row.getValue('subscribe_type'),
},
]}
actions={{
render: (row) => [
<SubscribeAppForm<API.UpdateApplicationRequest>
key='edit'
trigger={<Button>{t('edit')}</Button>}
title={t('editApp')}
loading={loading}
initialValues={{
...row,
}}
onSubmit={async (values) => {
setLoading(true);
try {
await updateApplication({
...values,
id: row.id,
});
toast.success(t('updateSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await deleteApplication({ id: row.id! });
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
],
batchRender: (rows) => [
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('batchDelete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await Promise.all(rows.map((row) => deleteApplication({ id: row.id! })));
toast.success(t('deleteSuccess'));
ref.current?.reset();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
],
}}
/>
);
}
@@ -1,5 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { import {
Form, Form,
@@ -19,6 +18,7 @@ import {
SheetTrigger, SheetTrigger,
} from '@workspace/ui/components/sheet'; } from '@workspace/ui/components/sheet';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -14,7 +14,7 @@ import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import GroupForm from './group-form'; import GroupForm from './form';
const GroupTable = () => { const GroupTable = () => {
const t = useTranslations('subscribe'); const t = useTranslations('subscribe');
+11 -1
View File
@@ -2,7 +2,9 @@ import { getTranslations } from 'next-intl/server';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import GroupTable from './group-table'; import SubscribeApp from './app/table';
import GroupTable from './group/table';
import SubscribeConfig from './subscribe-config';
import SubscribeTable from './subscribe-table'; import SubscribeTable from './subscribe-table';
export default async function Page() { export default async function Page() {
@@ -13,6 +15,8 @@ export default async function Page() {
<TabsList> <TabsList>
<TabsTrigger value='subscribe'>{t('tabs.subscribe')}</TabsTrigger> <TabsTrigger value='subscribe'>{t('tabs.subscribe')}</TabsTrigger>
<TabsTrigger value='group'>{t('tabs.subscribeGroup')}</TabsTrigger> <TabsTrigger value='group'>{t('tabs.subscribeGroup')}</TabsTrigger>
<TabsTrigger value='config'>{t('tabs.subscribeConfig')}</TabsTrigger>
<TabsTrigger value='app'>{t('tabs.subscribeApp')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='subscribe'> <TabsContent value='subscribe'>
<SubscribeTable /> <SubscribeTable />
@@ -20,6 +24,12 @@ export default async function Page() {
<TabsContent value='group'> <TabsContent value='group'>
<GroupTable /> <GroupTable />
</TabsContent> </TabsContent>
<TabsContent value='config'>
<SubscribeConfig />
</TabsContent>
<TabsContent value='app'>
<SubscribeApp />
</TabsContent>
</Tabs> </Tabs>
); );
} }
@@ -0,0 +1,106 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { getSubscribeConfig, updateSubscribeConfig } from '@/services/admin/system';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Textarea } from '@workspace/ui/components/textarea';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
export default function SubscribeConfig() {
const t = useTranslations('subscribe.config');
const { data, refetch } = useQuery({
queryKey: ['getSubscribeConfig'],
queryFn: async () => {
const { data } = await getSubscribeConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateSubscribeConfig({
...data,
[key]: value,
} as API.SubscribeConfig);
toast.success(t('updateSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('singleSubscriptionMode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('singleSubscriptionModeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.single_model}
onCheckedChange={(checked) => {
updateConfig('single_model', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('wildcardResolution')}</Label>
<p className='text-muted-foreground text-xs'>{t('wildcardResolutionDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.pan_domain}
onCheckedChange={(checked) => {
updateConfig('pan_domain', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('subscriptionPath')}</Label>
<p className='text-muted-foreground text-xs'>{t('subscriptionPathDescription')}</p>
</TableCell>
<TableCell className='flex items-center gap-2 text-right'>
<EnhancedInput
placeholder={t('subscriptionPathPlaceholder')}
value={data?.subscribe_path}
onValueBlur={(value) => updateConfig('subscribe_path', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('subscriptionDomain')}</Label>
<p className='text-muted-foreground text-xs'>{t('subscriptionDomainDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-52'
placeholder={`${t('subscriptionDomainPlaceholder')}\nexample.com\nwww.example.com`}
defaultValue={data?.subscribe_domain}
onBlur={(e) => {
updateConfig('subscribe_domain', e.target.value);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
@@ -3,7 +3,6 @@
import { getNodeGroupList, getNodeList } from '@/services/admin/server'; import { getNodeGroupList, getNodeList } from '@/services/admin/server';
import { getSubscribeGroupList } from '@/services/admin/subscribe'; import { getSubscribeGroupList } from '@/services/admin/subscribe';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
Accordion, Accordion,
@@ -38,6 +37,7 @@ import { Combobox } from '@workspace/ui/custom-components/combobox';
import { ArrayInput } from '@workspace/ui/custom-components/dynamic-Inputs'; import { ArrayInput } from '@workspace/ui/custom-components/dynamic-Inputs';
import { JSONEditor } from '@workspace/ui/custom-components/editor'; import { JSONEditor } from '@workspace/ui/custom-components/editor';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { evaluateWithPrecision, unitConversion } from '@workspace/ui/utils'; import { evaluateWithPrecision, unitConversion } from '@workspace/ui/utils';
import { CreditCard, Server, Settings } from 'lucide-react'; import { CreditCard, Server, Settings } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
@@ -56,7 +56,7 @@ interface SubscribeFormProps<T> {
} }
const defaultValues = { const defaultValues = {
inventory: -1, inventory: 0,
speed_limit: 0, speed_limit: 0,
device_limit: 0, device_limit: 0,
traffic: 0, traffic: 0,
@@ -105,7 +105,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
server_group: z.array(z.number()).optional().default([]), server_group: z.array(z.number()).optional().default([]),
server: z.array(z.number()).optional().default([]), server: z.array(z.number()).optional().default([]),
deduction_ratio: z.number().optional().default(0), deduction_ratio: z.number().optional().default(0),
purchase_with_discount: z.boolean().optional().default(false), allow_deduction: z.boolean().optional().default(false),
reset_cycle: z.number().optional().default(0), reset_cycle: z.number().optional().default(0),
renewal_reset: z.boolean().optional().default(false), renewal_reset: z.boolean().optional().default(false),
}); });
@@ -219,13 +219,13 @@ export default function SubscribeForm<T extends Record<string, any>>({
name='group_id' name='group_id'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.subscribeGroup')}</FormLabel> <FormLabel>{t('form.groupId')}</FormLabel>
<FormControl> <FormControl>
<Combobox<number, false> <Combobox<number, false>
placeholder={t('form.selectSubscribeGroup')} placeholder={t('form.selectSubscribeGroup')}
{...field} {...field}
onChange={(value) => { onChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value || 0);
}} }}
options={group?.map((item) => ({ options={group?.map((item) => ({
label: item.name, label: item.name,
@@ -277,7 +277,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
{...field} {...field}
formatInput={(value) => unitConversion('bitsToMb', value)} formatInput={(value) => unitConversion('bitsToMb', value)}
formatOutput={(value) => unitConversion('mbToBits', value)} formatOutput={(value) => unitConversion('mbToBits', value)}
suffix='MB' suffix='Mbps'
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
}} }}
@@ -319,10 +319,10 @@ export default function SubscribeForm<T extends Record<string, any>>({
<FormLabel>{t('form.inventory')}</FormLabel> <FormLabel>{t('form.inventory')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
placeholder={`-1 ${t('form.noLimit')}`} placeholder={t('form.noLimit')}
type='number' type='number'
{...field} value={field.value === -1 ? 0 : field.value}
min={-1} min={0}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
}} }}
@@ -639,7 +639,7 @@ export default function SubscribeForm<T extends Record<string, any>>({
/> />
<FormField <FormField
control={form.control} control={form.control}
name='purchase_with_discount' name='allow_deduction'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
@@ -36,7 +36,7 @@ export default function SubscribeTable() {
}); });
const ref = useRef<ProTableActions>(null); const ref = useRef<ProTableActions>(null);
return ( return (
<ProTable<API.Subscribe, { group_id: number; query: string }> <ProTable<API.SubscribeItem, { group_id: number; query: string }>
action={ref} action={ref}
header={{ header={{
toolbar: ( toolbar: (
@@ -183,10 +183,15 @@ export default function SubscribeTable() {
return name ? <Badge variant='outline'>{name}</Badge> : '--'; return name ? <Badge variant='outline'>{name}</Badge> : '--';
}, },
}, },
{
accessorKey: 'sold',
header: t('sold'),
cell: ({ row }) => <Badge variant='outline'>{row.getValue('sold')}</Badge>,
},
]} ]}
actions={{ actions={{
render: (row) => [ render: (row) => [
<SubscribeForm<API.Subscribe> <SubscribeForm<API.SubscribeItem>
key='edit' key='edit'
trigger={t('edit')} trigger={t('edit')}
title={t('editSubscribe')} title={t('editSubscribe')}
@@ -210,6 +215,21 @@ export default function SubscribeTable() {
} }
}} }}
/>, />,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await deleteSubscribe({
id: row.id!,
});
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
<Button <Button
key='copy' key='copy'
variant='secondary' variant='secondary'
@@ -221,7 +241,7 @@ export default function SubscribeTable() {
...params, ...params,
show: false, show: false,
sell: false, sell: false,
}); } as API.CreateSubscribeRequest);
toast.success(t('copySuccess')); toast.success(t('copySuccess'));
ref.current?.refresh(); ref.current?.refresh();
setLoading(false); setLoading(false);
@@ -234,21 +254,6 @@ export default function SubscribeTable() {
> >
{t('copy')} {t('copy')}
</Button>, </Button>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteWarning')}
onConfirm={async () => {
await deleteSubscribe({
id: row.id,
});
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
], ],
batchRender: (rows) => [ batchRender: (rows) => [
<ConfirmButton <ConfirmButton
@@ -258,7 +263,7 @@ export default function SubscribeTable() {
description={t('deleteWarning')} description={t('deleteWarning')}
onConfirm={async () => { onConfirm={async () => {
await batchDeleteSubscribe({ await batchDeleteSubscribe({
ids: rows.map((item) => item.id), ids: rows.map((item) => item.id) as number[],
}); });
toast.success(t('deleteSuccess')); toast.success(t('deleteSuccess'));
@@ -273,24 +278,24 @@ export default function SubscribeTable() {
const sourceIndex = items.findIndex((item) => String(item.id) === source); const sourceIndex = items.findIndex((item) => String(item.id) === source);
const targetIndex = items.findIndex((item) => String(item.id) === target); const targetIndex = items.findIndex((item) => String(item.id) === target);
const originalSortMap = new Map(items.map((item) => [item.id, item.sort || item.id])); const originalSorts = items.map((item) => item.sort);
const [movedItem] = items.splice(sourceIndex, 1); const [movedItem] = items.splice(sourceIndex, 1);
items.splice(targetIndex, 0, movedItem!); items.splice(targetIndex, 0, movedItem!);
const updatedItems = items.map((item, index) => { const updatedItems = items.map((item, index) => {
const originalSort = originalSortMap.get(item.id); const originalSort = originalSorts[index];
const newSort = originalSort !== undefined ? originalSort : item.sort; const newSort = originalSort !== undefined ? originalSort : item.sort;
return { ...item, sort: newSort }; return { ...item, sort: newSort };
}); });
const changedItems = updatedItems.filter( const changedItems = updatedItems.filter((item, index) => {
(item) => originalSortMap.get(item.id) !== item.sort, return item.sort !== items[index]?.sort;
); });
if (changedItems.length > 0) { if (changedItems.length > 0) {
subscribeSort({ subscribeSort({
sort: changedItems.map((item) => ({ id: item.id, sort: item.sort })), sort: changedItems.map((item) => ({ id: item.id, sort: item.sort })) as API.SortItem[],
}); });
} }
-150
View File
@@ -1,150 +0,0 @@
'use client';
import { getEmailSmtpConfig, testEmailSmtp, updateEmailSmtpConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { HTMLEditor } from '@workspace/ui/custom-components/editor';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
export default function Email() {
const t = useTranslations('system.email');
const { data, refetch, isFetching } = useQuery({
queryKey: ['getEmailSmtpConfig'],
queryFn: async () => {
const { data } = await getEmailSmtpConfig();
return data.data;
},
});
const [email, setEmail] = useState<string>();
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateEmailSmtpConfig({
...data,
[key]: value,
} as API.EmailSmtpConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<>
<Table>
<TableBody>
{[
{
key: 'email_smtp_host',
label: t('smtpServerAddress'),
description: t('smtpServerAddressDescription'),
},
{
key: 'email_smtp_port',
label: t('smtpServerPort'),
description: t('smtpServerPortDescription'),
type: 'number',
},
{
key: 'email_smtp_ssl',
label: t('smtpEncryptionMethod'),
description: t('smtpEncryptionMethodDescription'),
component: 'switch',
},
{
key: 'email_smtp_user',
label: t('smtpAccount'),
description: t('smtpAccountDescription'),
},
{
key: 'email_smtp_pass',
label: t('smtpPassword'),
description: t('smtpPasswordDescription'),
type: 'password',
},
{
key: 'email_smtp_from',
label: t('senderAddress'),
description: t('senderAddressDescription'),
},
].map(({ key, label, description, type = 'text', component = 'input' }) => (
<TableRow key={key}>
<TableCell>
<Label>{label}</Label>
<p className='text-muted-foreground text-xs'>{description}</p>
</TableCell>
<TableCell className='text-right'>
{component === 'input' ? (
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.[key]}
type={type}
onValueBlur={(value) => updateConfig(key, value)}
/>
) : (
<Switch
checked={data?.[key]}
onCheckedChange={(checked) => updateConfig(key, checked)}
/>
)}
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell>
<Label>{t('sendTestEmail')}</Label>
<p className='text-muted-foreground text-xs'>{t('sendTestEmailDescription')}</p>
</TableCell>
<TableCell className='flex items-center gap-2 text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={email}
onValueChange={(value) => setEmail(value as string)}
/>
<Button
disabled={!email}
onClick={async () => {
if (isFetching || !email) return;
try {
await testEmailSmtp({ email });
toast.success(t('sendSuccess'));
} catch {
toast.error(t('sendFailure'));
}
}}
>
{t('sendTestEmail')}
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<div className='grid grid-cols-1 gap-4 py-4 md:grid-cols-3'>
{['verify_email_template', 'expiration_email_template', 'maintenance_email_template'].map(
(templateKey) => (
<HTMLEditor
key={templateKey}
title={t(`${templateKey}`)}
description={t(`${templateKey}Description`, { after: '{{', before: '}}' })}
placeholder={t('inputPlaceholder')}
value={data?.[templateKey]}
onBlur={(value) => {
updateConfig(templateKey, value);
}}
/>
),
)}
</div>
</>
);
}
@@ -1,93 +0,0 @@
'use client';
import { getInviteConfig, updateInviteConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Invite() {
const t = useTranslations('system.invite');
const { data, refetch } = useQuery({
queryKey: ['getInviteConfig'],
queryFn: async () => {
const { data } = await getInviteConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateInviteConfig({
...data,
[key]: value,
} as API.InviteConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('enableForcedInvite')}</Label>
<p className='text-muted-foreground text-xs'>{t('enableForcedInviteDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.forced_invite}
onCheckedChange={(checked) => {
updateConfig('forced_invite', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('inviteCommissionPercentage')}</Label>
<p className='text-muted-foreground text-xs'>
{t('inviteCommissionPercentageDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
value={data?.referral_percentage}
type='number'
min={0}
max={100}
suffix='%'
onValueBlur={(value) => updateConfig('referral_percentage', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('commissionFirstTimeOnly')}</Label>
<p className='text-muted-foreground text-xs'>
{t('commissionFirstTimeOnlyDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.only_first_purchase}
onCheckedChange={(checked) => {
updateConfig('only_first_purchase', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
+5 -36
View File
@@ -1,16 +1,9 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import Currency from './currency'; import Currency from './currency';
import Email from './email'; import PrivacyPolicy from './privacy-policy';
import Invite from './invite';
import Node from './node';
import Register from './register';
import Site from './site'; import Site from './site';
import Subscription from './subscription';
import Telegram from './telegram';
import Tos from './tos'; import Tos from './tos';
import Verify from './verify';
export default async function Page() { export default async function Page() {
const t = await getTranslations('system'); const t = await getTranslations('system');
@@ -20,14 +13,8 @@ export default async function Page() {
<TabsList className='h-full flex-wrap'> <TabsList className='h-full flex-wrap'>
<TabsTrigger value='site'>{t('tabs.site')}</TabsTrigger> <TabsTrigger value='site'>{t('tabs.site')}</TabsTrigger>
<TabsTrigger value='currency'>{t('tabs.currency')}</TabsTrigger> <TabsTrigger value='currency'>{t('tabs.currency')}</TabsTrigger>
<TabsTrigger value='subscription'>{t('tabs.subscription')}</TabsTrigger>
<TabsTrigger value='register'>{t('tabs.register')}</TabsTrigger>
<TabsTrigger value='verify'>{t('tabs.verify')}</TabsTrigger>
<TabsTrigger value='email'>{t('tabs.email')}</TabsTrigger>
<TabsTrigger value='node'>{t('tabs.node')}</TabsTrigger>
<TabsTrigger value='invite'>{t('tabs.invite')}</TabsTrigger>
<TabsTrigger value='telegram'>{t('tabs.telegram')}</TabsTrigger>
<TabsTrigger value='tos'>{t('tabs.tos')}</TabsTrigger> <TabsTrigger value='tos'>{t('tabs.tos')}</TabsTrigger>
<TabsTrigger value='privacy-policy'>{t('privacy-policy.title')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='site'> <TabsContent value='site'>
<Site /> <Site />
@@ -35,30 +22,12 @@ export default async function Page() {
<TabsContent value='currency'> <TabsContent value='currency'>
<Currency /> <Currency />
</TabsContent> </TabsContent>
<TabsContent value='subscription'>
<Subscription />
</TabsContent>
<TabsContent value='register'>
<Register />
</TabsContent>
<TabsContent value='verify'>
<Verify />
</TabsContent>
<TabsContent value='email'>
<Email />
</TabsContent>
<TabsContent value='node'>
<Node />
</TabsContent>
<TabsContent value='invite'>
<Invite />
</TabsContent>
<TabsContent value='telegram'>
<Telegram />
</TabsContent>
<TabsContent value='tos'> <TabsContent value='tos'>
<Tos /> <Tos />
</TabsContent> </TabsContent>
<TabsContent value='privacy-policy'>
<PrivacyPolicy />
</TabsContent>
</Tabs> </Tabs>
); );
} }
@@ -0,0 +1,48 @@
'use client';
import { getPrivacyPolicyConfig, updatePrivacyPolicyConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { MarkdownEditor } from '@workspace/ui/custom-components/editor';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function PrivacyPolicy() {
const t = useTranslations('system.privacy-policy');
const { data, refetch, isFetched } = useQuery({
queryKey: ['getPrivacyPolicyConfig'],
queryFn: async () => {
const { data } = await getPrivacyPolicyConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updatePrivacyPolicyConfig({
...data,
[key]: value,
} as API.PrivacyPolicyConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
isFetched && (
<div className='h-[calc(100dvh-132px-env(safe-area-inset-top))] overflow-hidden'>
<MarkdownEditor
title={t('title')}
value={data?.privacy_policy}
onBlur={(value) => {
if (data?.privacy_policy !== value) {
updateConfig('privacy_policy', value);
}
}}
/>
</div>
)
);
}
@@ -1,163 +0,0 @@
'use client';
import { getRegisterConfig, updateRegisterConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Textarea } from '@workspace/ui/components/textarea';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Register() {
const t = useTranslations('system.register');
const { data, refetch } = useQuery({
queryKey: ['getRegisterConfig'],
queryFn: async () => {
const { data } = await getRegisterConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateRegisterConfig({
...data,
[key]: value,
} as API.RegisterConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('stopNewUserRegistration')}</Label>
<p className='text-muted-foreground text-xs'>
{t('stopNewUserRegistrationDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.stop_register}
onCheckedChange={(checked) => {
updateConfig('stop_register', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('emailVerification')}</Label>
<p className='text-muted-foreground text-xs'>{t('emailVerificationDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_email_verify}
onCheckedChange={(checked) => {
updateConfig('enable_email_verify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('emailSuffixWhitelist')}</Label>
<p className='text-muted-foreground text-xs'>{t('emailSuffixWhitelistDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_email_domain_suffix}
onCheckedChange={(checked) => {
updateConfig('enable_email_domain_suffix', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('whitelistSuffixes')}</Label>
<p className='text-muted-foreground text-xs'>{t('whitelistSuffixesDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-52'
placeholder={t('whitelistSuffixesPlaceholder')}
defaultValue={data?.email_domain_suffix_list}
onBlur={(e) => {
updateConfig('email_domain_suffix_list', e.target.value);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('ipRegistrationLimit')}</Label>
<p className='text-muted-foreground text-xs'>{t('ipRegistrationLimitDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_ip_register_limit}
onCheckedChange={(checked) => {
updateConfig('enable_ip_register_limit', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('registrationLimitCount')}</Label>
<p className='text-muted-foreground text-xs'>
{t('registrationLimitCountDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
value={data?.ip_register_limit}
onValueBlur={(value) => updateConfig('ip_register_limit', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('penaltyTime')}</Label>
<p className='text-muted-foreground text-xs'>{t('penaltyTimeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
type='number'
min={0}
value={data?.ip_register_limit_duration}
onValueBlur={(value) => updateConfig('ip_register_limit_duration', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('trialRegistration')}</Label>
<p className='text-muted-foreground text-xs'>{t('trialRegistrationDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_trial}
onCheckedChange={(checked) => {
updateConfig('enable_trial', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
+39 -1
View File
@@ -6,6 +6,7 @@ import { Label } from '@workspace/ui/components/label';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table'; import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Textarea } from '@workspace/ui/components/textarea'; import { Textarea } from '@workspace/ui/components/textarea';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { UploadImage } from '@workspace/ui/custom-components/upload-image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -47,6 +48,14 @@ export default function Site() {
placeholder={t('logoPlaceholder')} placeholder={t('logoPlaceholder')}
value={data?.site_logo} value={data?.site_logo}
onValueBlur={(value) => updateConfig('site_logo', value)} onValueBlur={(value) => updateConfig('site_logo', value)}
suffix={
<UploadImage
className='bg-muted h-9 rounded-none border-none px-2'
onChange={(value) => {
updateConfig('site_logo', value);
}}
/>
}
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -76,6 +85,35 @@ export default function Site() {
/> />
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow>
<TableCell>
<Label>{t('keywords')}</Label>
<p className='text-muted-foreground text-xs'>{t('keywordsDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('keywordsDescription')}
value={data?.keywords}
onValueBlur={(value) => updateConfig('keywords', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('customHtml')}</Label>
<p className='text-muted-foreground text-xs'>{t('customHtmlDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-52'
placeholder={t('customHtmlDescription')}
defaultValue={data?.custom_html}
onBlur={(e) => {
updateConfig('custom_html', e.target.value);
}}
/>
</TableCell>
</TableRow>
<TableRow> <TableRow>
<TableCell className='align-top'> <TableCell className='align-top'>
<Label>{t('siteDomain')}</Label> <Label>{t('siteDomain')}</Label>
@@ -84,7 +122,7 @@ export default function Site() {
<TableCell className='text-right'> <TableCell className='text-right'>
<Textarea <Textarea
className='h-52' className='h-52'
placeholder={t('siteDomainPlaceholder')} placeholder={`${t('siteDomainPlaceholder')}\nexample.com\nwww.example.com`}
defaultValue={data?.host} defaultValue={data?.host}
onBlur={(e) => { onBlur={(e) => {
updateConfig('host', e.target.value); updateConfig('host', e.target.value);
@@ -1,340 +0,0 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
createApplication,
deleteApplication,
getApplication,
getSubscribeConfig,
getSubscribeType,
updateApplication,
updateSubscribeConfig,
} from '@/services/admin/system';
import { Button } from '@workspace/ui/components/button';
import { Label } from '@workspace/ui/components/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@workspace/ui/components/select';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Textarea } from '@workspace/ui/components/textarea';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
function compareData(
originalData: API.ApplicationResponse,
modifiedData: API.ApplicationResponse,
): {
added: API.Application[];
deleted: API.Application[];
updated: API.Application[];
} {
const added: API.Application[] = [];
const deleted: API.Application[] = [];
const updated: API.Application[] = [];
const findById = (array: API.Application[], id: number): API.Application | undefined => {
return array.find((item) => item.id === id);
};
const isUpdated = (original: API.Application, modified: API.Application): boolean => {
return (
original.name !== modified.name ||
original.platform !== modified.platform ||
original.subscribe_type !== modified.subscribe_type ||
original.url !== modified.url ||
original.icon !== modified.icon
);
};
Object.values(originalData).forEach((platformData) => {
platformData.forEach((originalApp) => {
const modifiedApp = findById(
modifiedData[originalApp.platform as API.CreateApplicationRequest['platform']],
originalApp.id,
);
if (!modifiedApp) {
deleted.push(originalApp);
} else if (isUpdated(originalApp, modifiedApp)) {
updated.push(modifiedApp);
}
});
});
Object.values(modifiedData).forEach((platformData) => {
platformData.forEach((modifiedApp) => {
if (
!findById(
originalData[modifiedApp.platform as API.CreateApplicationRequest['platform']],
modifiedApp.id,
)
) {
added.push(modifiedApp);
}
});
});
return { added, deleted, updated };
}
export default function Subscription() {
const t = useTranslations('system.subscription');
const { data, refetch } = useQuery({
queryKey: ['getSubscribeConfig'],
queryFn: async () => {
const { data } = await getSubscribeConfig();
return data.data;
},
});
const { data: apps, refetch: appsRefetch } = useQuery({
queryKey: ['getApplication'],
queryFn: async () => {
const { data } = await getApplication();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateSubscribeConfig({
...data,
[key]: value,
} as API.SubscribeConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
const { data: subscribe_types } = useQuery<string[]>({
queryKey: ['getSubscribeType'],
queryFn: async () => {
const { data } = await getSubscribeType();
return data.data?.subscribe_types || [];
},
});
const [app, setApp] = useState<API.ApplicationResponse>();
const appTypes = Object.keys(apps || {}) as (keyof API.ApplicationResponse)[];
useEffect(() => {
if (!app) setApp(apps);
}, [app, apps]);
return (
<>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('singleSubscriptionMode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('singleSubscriptionModeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.single_model}
onCheckedChange={(checked) => {
updateConfig('single_model', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('wildcardResolution')}</Label>
<p className='text-muted-foreground text-xs'>{t('wildcardResolutionDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.pan_domain}
onCheckedChange={(checked) => {
updateConfig('pan_domain', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('subscriptionPath')}</Label>
<p className='text-muted-foreground text-xs'>{t('subscriptionPathDescription')}</p>
</TableCell>
<TableCell className='flex items-center gap-2 text-right'>
<EnhancedInput
placeholder={t('subscriptionPathPlaceholder')}
value={data?.subscribe_path}
onValueBlur={(value) => updateConfig('subscribe_path', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='align-top'>
<Label>{t('subscriptionDomain')}</Label>
<p className='text-muted-foreground text-xs'>{t('subscriptionDomainDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Textarea
className='h-52'
placeholder={t('subscriptionDomainPlaceholder')}
defaultValue={data?.subscribe_domain}
onBlur={(e) => {
updateConfig('subscribe_domain', e.target.value);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('app')}</Label>
<p className='text-muted-foreground text-xs'>{t('appDescription')}</p>
</TableCell>
<TableCell className='flex justify-end gap-2'>
<Button
size='sm'
variant='outline'
onClick={() => {
setApp(apps);
}}
>
{t('reset')}
</Button>
<Button
size='sm'
onClick={() => {
const { added, deleted, updated } = compareData(apps!, app!);
added.forEach(async (item) => {
await createApplication(item as API.CreateApplicationRequest);
});
deleted.forEach(async (item) => {
await deleteApplication({
id: item.id,
});
});
updated.forEach(async (item) => {
await updateApplication(item);
});
toast.success(t('saveSuccess'));
appsRefetch();
}}
>
{t('save')}
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<Tabs defaultValue='windows'>
<TabsList className='h-full flex-wrap'>
{appTypes.map((type) => {
return (
<TabsTrigger value={type} key={type} className='uppercase'>
{type}
</TabsTrigger>
);
})}
</TabsList>
{appTypes.map((type) => {
const list = (app?.[type] || []) as API.Application[];
const updatedList = (key: string, value: string, index: number) => {
const newList = list.map((item, i) => (i === index ? { ...item, [key]: value } : item));
setApp({
...app,
[type]: newList,
} as API.ApplicationResponse);
};
return (
<TabsContent value={type} key={type} className='mt-4 space-y-4'>
{list.map((item, index) => {
return (
<div className='flex flex-col items-center gap-2 lg:flex-row' key={index}>
<Select
value={item.subscribe_type}
onValueChange={(value) => {
updatedList('subscribe_type', value, index);
}}
>
<SelectTrigger>
<SelectValue placeholder={t('subscriptionProtocol')} />
</SelectTrigger>
<SelectContent>
{subscribe_types?.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
<EnhancedInput
placeholder={t('appName')}
value={item.name}
onValueChange={(value) => updatedList('name', value as string, index)}
/>
<EnhancedInput
placeholder={t('appIcon')}
value={item.icon}
onValueChange={(value) => updatedList('icon', value as string, index)}
/>
<EnhancedInput
placeholder={t('appDownloadURL')}
value={item.url}
onValueChange={(value) => updatedList('url', value as string, index)}
/>
<Button
variant='destructive'
size='sm'
onClick={() => {
setApp({
...app,
[type]: list.filter((l, i) => i !== index),
} as API.ApplicationResponse);
}}
>
{t('delete')}
</Button>
</div>
);
})}
<Button
className='w-full'
variant='outline'
onClick={() => {
setApp({
...app,
[type]: [
...list,
{
platform: type,
},
],
} as API.ApplicationResponse);
}}
>
{t('add')}
</Button>
</TabsContent>
);
})}
</Tabs>
</>
);
}
@@ -1,99 +0,0 @@
'use client';
import { getTelegramConfig, updateTelegramConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Telegram() {
const t = useTranslations('system.telegram');
const { data, refetch } = useQuery({
queryKey: ['getTelegramConfig'],
queryFn: async () => {
const { data } = await getTelegramConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateTelegramConfig({
...data,
[key]: value,
} as API.TelegramConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('webhookDomain')}</Label>
<p className='text-muted-foreground text-xs'>{t('webhookDomainDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholderWebhookDomain')}
value={data?.telegram_web_hook_domain}
onValueBlur={(value) => updateConfig('telegram_web_hook_domain', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('botToken')}</Label>
<p className='text-muted-foreground text-xs'>{t('botTokenDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholderBotToken')}
value={data?.telegram_bot_token}
onValueBlur={(value) => updateConfig('telegram_bot_token', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('enableBotNotifications')}</Label>
<p className='text-muted-foreground text-xs'>
{t('enableBotNotificationsDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.telegram_notify}
onCheckedChange={(checked) => {
updateConfig('telegram_notify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('groupURL')}</Label>
<p className='text-muted-foreground text-xs'>{t('groupURLDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholderGroupURL')}
value={data?.telegram_group_url}
onValueBlur={(value) => updateConfig('telegram_group_url', value)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
-115
View File
@@ -1,115 +0,0 @@
'use client';
import { getVerifyConfig, updateVerifyConfig } from '@/services/admin/system';
import { useQuery } from '@tanstack/react-query';
import { Label } from '@workspace/ui/components/label';
import { Switch } from '@workspace/ui/components/switch';
import { Table, TableBody, TableCell, TableRow } from '@workspace/ui/components/table';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
export default function Verify() {
const t = useTranslations('system.verify');
const { data, refetch } = useQuery({
queryKey: ['getVerifyConfig'],
queryFn: async () => {
const { data } = await getVerifyConfig();
return data.data;
},
});
async function updateConfig(key: string, value: unknown) {
if (data?.[key] === value) return;
try {
await updateVerifyConfig({
...data,
[key]: value,
} as API.VerifyConfig);
toast.success(t('saveSuccess'));
refetch();
} catch (error) {
/* empty */
}
}
return (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Label>{t('turnstileSiteKey')}</Label>
<p className='text-muted-foreground text-xs'>{t('turnstileSiteKeyDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
defaultValue={data?.turnstile_site_key}
onValueBlur={(value) => updateConfig('turnstile_site_key', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('turnstileSecret')}</Label>
<p className='text-muted-foreground text-xs'>{t('turnstileSecretDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<EnhancedInput
placeholder={t('inputPlaceholder')}
defaultValue={data?.turnstile_secret}
onValueBlur={(value) => updateConfig('turnstile_secret', value)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('registrationVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('registrationVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_register_verify}
onCheckedChange={(checked) => {
updateConfig('enable_register_verify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('loginVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>{t('loginVerificationCodeDescription')}</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_login_verify}
onCheckedChange={(checked) => {
updateConfig('enable_login_verify', checked);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Label>{t('resetPasswordVerificationCode')}</Label>
<p className='text-muted-foreground text-xs'>
{t('resetPasswordVerificationCodeDescription')}
</p>
</TableCell>
<TableCell className='text-right'>
<Switch
checked={data?.enable_reset_password_verify}
onCheckedChange={(checked) => {
updateConfig('enable_reset_password_verify', checked);
}}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
+1 -1
View File
@@ -7,7 +7,6 @@ import {
getTicketList, getTicketList,
updateTicketStatus, updateTicketStatus,
} from '@/services/admin/ticket'; } from '@/services/admin/ticket';
import { Icon } from '@iconify/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { import {
@@ -22,6 +21,7 @@ import { Input } from '@workspace/ui/components/input';
import { Label } from '@workspace/ui/components/label'; import { Label } from '@workspace/ui/components/label';
import { ScrollArea } from '@workspace/ui/components/scroll-area'; import { ScrollArea } from '@workspace/ui/components/scroll-area';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button'; import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { Icon } from '@workspace/ui/custom-components/icon';
import { cn } from '@workspace/ui/lib/utils'; import { cn } from '@workspace/ui/lib/utils';
import { formatDate } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
+1 -1
View File
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { getSystemLog, restartSystem } from '@/services/admin/tool'; import { getSystemLog, restartSystem } from '@/services/admin/tool';
import { Icon } from '@iconify/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
Accordion, Accordion,
@@ -30,6 +29,7 @@ import {
CardTitle, CardTitle,
} from '@workspace/ui/components/card'; } from '@workspace/ui/components/card';
import { ScrollArea } from '@workspace/ui/components/scroll-area'; import { ScrollArea } from '@workspace/ui/components/scroll-area';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
@@ -0,0 +1,33 @@
import UserOrderList from '@/app/dashboard/order/page';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { getTranslations } from 'next-intl/server';
import UserLoginHistory from './user-login-history';
import { UserProfileForm } from './user-profile';
import UserSubscription from './user-subscription';
export default async function Page({ params }: { params: Promise<{ id: number }> }) {
const t = await getTranslations('user');
const { id } = await params;
return (
<Tabs defaultValue='profile'>
<TabsList>
<TabsTrigger value='profile'>{t('userProfile')}</TabsTrigger>
<TabsTrigger value='subscriptions'>{t('userSubscriptions')}</TabsTrigger>
<TabsTrigger value='orders'>{t('userOrders')}</TabsTrigger>
<TabsTrigger value='logs'>{t('userLogs')}</TabsTrigger>
</TabsList>
<TabsContent value='profile'>
<UserProfileForm />
</TabsContent>
<TabsContent value='subscriptions'>
<UserSubscription userId={id} />
</TabsContent>
<TabsContent value='orders'>
<UserOrderList userId={id} />
</TabsContent>
<TabsContent value='logs'>
<UserLoginHistory />
</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,53 @@
'use client';
import { ProTable } from '@/components/pro-table';
import { getUserLoginLogs } from '@/services/admin/user';
import { Badge } from '@workspace/ui/components/badge';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
export default function UserLoginHistory() {
const t = useTranslations('user');
const { id } = useParams<{ id: string }>();
return (
<ProTable<API.UserLoginLog, Record<string, unknown>>
columns={[
{
accessorKey: 'success',
header: t('loginStatus'),
cell: ({ row }) => (
<Badge variant={row.getValue('success') ? 'default' : 'destructive'}>
{row.getValue('success') ? t('success') : t('failed')}
</Badge>
),
},
{
accessorKey: 'login_ip',
header: t('loginIp'),
},
{
accessorKey: 'user_agent',
header: t('userAgent'),
},
{
accessorKey: 'created_at',
header: t('loginTime'),
cell: ({ row }) => formatDate(row.getValue('created_at')),
},
]}
request={async (pagination, filter) => {
const { data } = await getUserLoginLogs({
user_id: Number(id),
...pagination,
...filter,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,140 @@
'use client';
import {
createUserAuthMethod,
deleteUserAuthMethod,
updateUserAuthMethod,
} from '@/services/admin/user';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
export function AuthMethodsForm({ user, refetch }: { user: API.User; refetch: () => void }) {
const t = useTranslations('user');
const [emailChanges, setEmailChanges] = useState<Record<string, string>>({});
const handleRemoveAuth = async (authType: string) => {
await deleteUserAuthMethod({
user_id: user.id,
auth_type: authType,
});
toast.success(t('deleteSuccess'));
};
const handleUpdateEmail = async (email: string) => {
await updateUserAuthMethod({
user_id: user.id,
auth_type: 'email',
auth_identifier: email,
});
toast.success(t('updateSuccess'));
refetch();
};
const handleCreateEmail = async (email: string) => {
await createUserAuthMethod({
user_id: user.id,
auth_type: 'email',
auth_identifier: email,
});
toast.success(t('createSuccess'));
refetch();
};
const handleEmailChange = (authType: string, value: string) => {
setEmailChanges((prev) => ({
...prev,
[authType]: value,
}));
};
const emailMethod = user.auth_methods.find((method) => method.auth_type === 'email');
const otherMethods = user.auth_methods.filter((method) => method.auth_type !== 'email');
const defaultEmailMethod = {
auth_type: 'email',
auth_identifier: '',
verified: false,
...emailMethod,
};
const isEmailExists = !!emailMethod;
const handleEmailAction = () => {
const email = emailChanges['email'];
if (isEmailExists) {
handleUpdateEmail(email as string);
} else {
handleCreateEmail(email as string);
}
};
return (
<Card>
<CardHeader>
<CardTitle>{t('authMethodsTitle')}</CardTitle>
</CardHeader>
<CardContent className='space-y-6'>
<div className='space-y-6'>
<Card className='border-none shadow-none'>
<CardContent className='space-y-3 p-0'>
<div className='flex items-center justify-between'>
<div className='font-medium uppercase'>email</div>
<Badge variant={defaultEmailMethod.verified ? 'default' : 'destructive'}>
{defaultEmailMethod.verified ? t('verified') : t('unverified')}
</Badge>
</div>
<div className='flex items-center gap-2'>
<div className='flex-1'>
<EnhancedInput
value={defaultEmailMethod.auth_identifier}
placeholder={t('pleaseEnterEmail')}
onValueChange={(value) => handleEmailChange('email', value as string)}
/>
</div>
<Button
onClick={handleEmailAction}
disabled={
!emailChanges['email'] ||
(isEmailExists && emailChanges['email'] === defaultEmailMethod.auth_identifier)
}
>
{isEmailExists ? t('update') : t('add')}
</Button>
</div>
</CardContent>
</Card>
{otherMethods.map((method) => (
<Card key={method.auth_type} className='border-none shadow-none'>
<CardContent className='space-y-3 p-0'>
<div className='flex items-center justify-between'>
<div className='font-medium uppercase'>{method.auth_type}</div>
<Badge variant={method.verified ? 'default' : 'destructive'}>
{method.verified ? t('verified') : t('unverified')}
</Badge>
</div>
<div className='flex items-center gap-4'>
<div className='flex-1'>
<div className='text-muted-foreground text-sm'>{method.auth_identifier}</div>
</div>
<Button
variant='destructive'
size='sm'
onClick={() => handleRemoveAuth(method.auth_type)}
>
{t('remove')}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,259 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { updateUserBasicInfo } from '@/services/admin/user';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@workspace/ui/components/button';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { Input } from '@workspace/ui/components/input';
import { Switch } from '@workspace/ui/components/switch';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { UploadImage } from '@workspace/ui/custom-components/upload-image';
import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import * as z from 'zod';
const basicInfoSchema = z.object({
avatar: z.string().optional(),
balance: z.number().optional(),
commission: z.number().optional(),
gift_amount: z.number().optional(),
refer_code: z.string().optional(),
referer_id: z.number().optional(),
is_admin: z.boolean().optional(),
password: z.string().optional(),
enable: z.boolean(),
});
type BasicInfoValues = z.infer<typeof basicInfoSchema>;
export function BasicInfoForm({ user, refetch }: { user: API.User; refetch: () => void }) {
const t = useTranslations('user');
const { common } = useGlobalStore();
const { currency } = common;
const form = useForm<BasicInfoValues>({
resolver: zodResolver(basicInfoSchema),
defaultValues: {
avatar: user.avatar,
balance: user.balance,
commission: user.commission,
gift_amount: user.gift_amount,
refer_code: user.refer_code,
referer_id: user.referer_id,
is_admin: user.is_admin,
enable: user.enable,
},
});
async function onSubmit(data: BasicInfoValues) {
await updateUserBasicInfo({
user_id: user.id,
telegram: user.telegram,
...data,
} as API.UpdateUserBasiceInfoRequest);
toast.success(t('updateSuccess'));
refetch();
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle>{t('basicInfoTitle')}</CardTitle>
<Button type='submit' size='sm'>
{t('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<FormField
control={form.control}
name='enable'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('accountEnable')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='is_admin'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('administrator')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<div className='grid grid-cols-3 gap-4'>
<FormField
control={form.control}
name='balance'
render={({ field }) => (
<FormItem>
<FormLabel>{t('balance')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
value={field.value}
prefix={currency?.currency_symbol ?? '$'}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='commission'
render={({ field }) => (
<FormItem>
<FormLabel>{t('commission')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
value={field.value}
prefix={currency?.currency_symbol ?? '$'}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='gift_amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('giftAmount')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
value={field.value}
prefix={currency?.currency_symbol ?? '$'}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='refer_code'
render={({ field }) => (
<FormItem>
<FormLabel>{t('referralCode')}</FormLabel>
<FormControl>
<EnhancedInput
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value as string);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='referer_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('referrerUserId')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='avatar'
render={({ field }) => (
<FormItem>
<FormLabel>{t('avatar')}</FormLabel>
<FormControl>
<EnhancedInput
value={field.value}
onValueChange={(value) => {
form.setValue(field.name, value as string);
}}
suffix={
<UploadImage
className='bg-muted h-9 rounded-none border-none px-2'
returnType='base64'
onChange={(value) => form.setValue('avatar', value as string)}
/>
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input type='password' placeholder={t('passwordPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</form>
</Form>
);
}
@@ -0,0 +1,38 @@
'use client';
import { getUserDetail } from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { AuthMethodsForm } from './auth-methods-form';
import { BasicInfoForm } from './basic-info-form';
import { NotifySettingsForm } from './notify-settings-form';
export function UserProfileForm() {
const { id } = useParams<{ id: string }>();
const { data: user, refetch } = useQuery({
queryKey: ['user', id],
queryFn: async () => {
const { data } = await getUserDetail({
id: Number(id),
});
return data.data;
},
});
if (!user) return null;
return (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<div className='md:col-span-2 xl:col-span-1'>
<BasicInfoForm user={user} refetch={refetch} />
</div>
<div>
<NotifySettingsForm user={user} refetch={refetch} />
</div>
<div>
<AuthMethodsForm user={user} refetch={refetch} />
</div>
</div>
);
}
@@ -0,0 +1,114 @@
'use client';
import { updateUserNotifySetting } from '@/services/admin/user';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@workspace/ui/components/button';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@workspace/ui/components/form';
import { Switch } from '@workspace/ui/components/switch';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import * as z from 'zod';
const notifySettingsSchema = z.object({
enable_balance_notify: z.boolean(),
enable_login_notify: z.boolean(),
enable_subscribe_notify: z.boolean(),
enable_trade_notify: z.boolean(),
});
type NotifySettingsValues = z.infer<typeof notifySettingsSchema>;
export function NotifySettingsForm({ user, refetch }: { user: API.User; refetch: () => void }) {
const t = useTranslations('user');
const form = useForm<NotifySettingsValues>({
resolver: zodResolver(notifySettingsSchema),
defaultValues: {
enable_balance_notify: user.enable_balance_notify,
enable_login_notify: user.enable_login_notify,
enable_subscribe_notify: user.enable_subscribe_notify,
enable_trade_notify: user.enable_trade_notify,
},
});
async function onSubmit(data: NotifySettingsValues) {
await updateUserNotifySetting({
...data,
user_id: user.id,
});
toast.success(t('updateSuccess'));
refetch();
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle>{t('notifySettingsTitle')}</CardTitle>
<Button type='submit' size='sm'>
{t('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid grid-cols-1 gap-4'>
<FormField
control={form.control}
name='enable_balance_notify'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('balanceNotifications')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_login_notify'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('loginNotifications')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_subscribe_notify'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('subscriptionNotifications')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_trade_notify'
render={({ field }) => (
<FormItem className='flex items-center justify-between space-x-2'>
<FormLabel>{t('tradeNotifications')}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</form>
</Form>
);
}
@@ -0,0 +1,166 @@
'use client';
import { Display } from '@/components/display';
import { ProTable, ProTableActions } from '@/components/pro-table';
import {
createUserSubscribe,
deleteUserSubscribe,
getUserSubscribe,
updateUserSubscribe,
} from '@/services/admin/user';
import { Button } from '@workspace/ui/components/button';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
import { SubscriptionDetail } from './subscription-detail';
import { SubscriptionForm } from './subscription-form';
export default function UserSubscription({ userId }: { userId: number }) {
const t = useTranslations('user');
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.UserSubscribe, Record<string, unknown>>
action={ref}
header={{
title: t('subscriptionList'),
toolbar: (
<SubscriptionForm
key='create'
trigger={t('add')}
title={t('createSubscription')}
loading={loading}
onSubmit={async (values) => {
setLoading(true);
await createUserSubscribe({
user_id: Number(userId),
...values,
});
toast.success(t('createSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
}}
/>
),
}}
columns={[
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'name',
header: t('subscriptionName'),
cell: ({ row }) => row.original.subscribe.name,
},
{
accessorKey: 'upload',
header: t('upload'),
cell: ({ row }) => <Display type='traffic' value={row.getValue('upload')} />,
},
{
accessorKey: 'download',
header: t('download'),
cell: ({ row }) => <Display type='traffic' value={row.getValue('download')} />,
},
{
accessorKey: 'traffic',
header: t('totalTraffic'),
cell: ({ row }) => <Display type='traffic' value={row.getValue('traffic')} unlimited />,
},
{
accessorKey: 'speed_limit',
header: t('speedLimit'),
cell: ({ row }) => {
const speed = row.original?.subscribe?.speed_limit;
return <Display type='trafficSpeed' value={speed} />;
},
},
{
accessorKey: 'device_limit',
header: t('deviceLimit'),
cell: ({ row }) => {
const limit = row.original?.subscribe?.device_limit;
return <Display type='number' value={limit} unlimited />;
},
},
{
accessorKey: 'reset_time',
header: t('resetTime'),
cell: ({ row }) => {
return <Display type='number' value={row.getValue('reset_time')} unlimited />;
},
},
{
accessorKey: 'expire_time',
header: t('expireTime'),
cell: ({ row }) =>
row.getValue('expire_time') ? formatDate(row.getValue('expire_time')) : t('permanent'),
},
{
accessorKey: 'created_at',
header: t('createdAt'),
cell: ({ row }) => formatDate(row.getValue('created_at')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribe({
user_id: userId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
actions={{
render: (row) => {
return [
<SubscriptionForm
key='edit'
trigger={t('edit')}
title={t('editSubscription')}
loading={loading}
initialData={row}
onSubmit={async (values) => {
setLoading(true);
await updateUserSubscribe({
user_id: Number(userId),
user_subscribe_id: row.id,
...values,
});
toast.success(t('updateSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
}}
/>,
<SubscriptionDetail
key='detail'
trigger={<Button variant='secondary'>{t('detail')}</Button>}
userId={userId}
subscriptionId={row.id}
/>,
<ConfirmButton
key='delete'
trigger={<Button variant='destructive'>{t('delete')}</Button>}
title={t('confirmDelete')}
description={t('deleteSubscriptionDescription')}
onConfirm={async () => {
await deleteUserSubscribe({ user_subscribe_id: row.id });
toast.success(t('deleteSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
];
},
}}
/>
);
}
@@ -0,0 +1,209 @@
'use client';
import { Display } from '@/components/display';
import { ProTable } from '@/components/pro-table';
import {
getUserSubscribeDevices,
getUserSubscribeLogs,
getUserSubscribeTrafficLogs,
kickOfflineByUserDevice,
} from '@/services/admin/user';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@workspace/ui/components/dialog';
import { Switch } from '@workspace/ui/components/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { ReactNode, useState } from 'react';
import { toast } from 'sonner';
export function SubscriptionDetail({
trigger,
userId,
subscriptionId,
}: {
trigger: ReactNode;
userId: number;
subscriptionId: number;
}) {
const t = useTranslations('user');
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className='max-w-5xl'>
<DialogHeader>
<DialogTitle>{t('subscriptionDetails')}</DialogTitle>
</DialogHeader>
<div className='mt-4'>
<Tabs defaultValue='logs'>
<TabsList className='w-full'>
<TabsTrigger value='logs' className='flex-1'>
{t('subscriptionLogs')}
</TabsTrigger>
<TabsTrigger value='traffic' className='flex-1'>
{t('trafficLogs')}
</TabsTrigger>
<TabsTrigger value='devices' className='flex-1'>
{t('onlineDevices')}
</TabsTrigger>
</TabsList>
<TabsContent value='logs'>
<ProTable<API.UserSubscribeLog, Record<string, unknown>>
columns={[
{
accessorKey: 'ip',
header: 'IP',
},
{
accessorKey: 'user_agent',
header: t('userAgent'),
},
{
accessorKey: 'token',
header: t('token'),
},
{
accessorKey: 'created_at',
header: t('time'),
cell: ({ row }) => formatDate(row.getValue('created_at')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribeLogs({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
</TabsContent>
<TabsContent value='traffic'>
<ProTable<API.TrafficLog, Record<string, unknown>>
columns={[
{
accessorKey: 'download',
header: t('download'),
cell: ({ row }) => <Display type='traffic' value={row.getValue('download')} />,
},
{
accessorKey: 'upload',
header: t('upload'),
cell: ({ row }) => <Display type='traffic' value={row.getValue('upload')} />,
},
{
accessorKey: 'timestamp',
header: t('time'),
cell: ({ row }) => formatDate(row.getValue('timestamp')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribeTrafficLogs({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
</TabsContent>
<TabsContent value='devices'>
<ProTable<API.UserDevice, Record<string, unknown>>
columns={[
{
accessorKey: 'enabled',
header: t('enable'),
cell: ({ row }) => (
<Switch
checked={row.getValue('enabled')}
onChange={(checked) => {
console.log('Switch:', checked);
}}
/>
),
},
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'identifier',
header: 'IMEI',
},
{
accessorKey: 'user_agent',
header: t('userAgent'),
},
{
accessorKey: 'ip',
header: 'IP',
},
{
accessorKey: 'online',
header: t('loginStatus'),
cell: ({ row }) => (
<Badge variant={row.getValue('online') ? 'default' : 'destructive'}>
{row.getValue('online') ? t('online') : t('offline')}
</Badge>
),
},
{
accessorKey: 'updated_at',
header: t('lastSeen'),
cell: ({ row }) => formatDate(row.getValue('updated_at')),
},
]}
request={async (pagination) => {
const { data } = await getUserSubscribeDevices({
user_id: userId,
subscribe_id: subscriptionId,
...pagination,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
actions={{
render: (row) => {
if (!row.identifier) return [];
return [
<ConfirmButton
key='offline'
trigger={<Button variant='destructive'>{t('confirmOffline')}</Button>}
title={t('confirmOffline')}
description={t('kickOfflineConfirm', { ip: row.ip })}
onConfirm={async () => {
await kickOfflineByUserDevice({ id: row.id });
toast.success(t('kickOfflineSuccess'));
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>,
];
},
}}
/>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,240 @@
'use client';
import { getSubscribeList } from '@/services/admin/subscribe';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Combobox } from '@workspace/ui/custom-components/combobox';
import { DatePicker } from '@workspace/ui/custom-components/date-picker';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { ReactNode, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
interface Props {
trigger: ReactNode;
title: string;
loading?: boolean;
initialData?: API.UserSubscribe;
onSubmit: (values: any) => Promise<boolean>;
}
const formSchema = z.object({
subscribe_id: z.number().optional(),
traffic: z.number().optional(),
speed_limit: z.number().optional(),
device_limit: z.number().optional(),
expired_at: z.number().nullish().optional(),
});
export function SubscriptionForm({ trigger, title, loading, initialData, onSubmit }: Props) {
const t = useTranslations('user');
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
subscribe_id: initialData?.subscribe_id || 0,
traffic: initialData?.traffic || 0,
upload: initialData?.upload || 0,
download: initialData?.download || 0,
expired_at: initialData?.expire_time || 0,
...(initialData && { id: initialData.id }),
},
});
const handleSubmit = async (values: any) => {
const success = await onSubmit(values);
if (success) {
setOpen(false);
form.reset();
}
};
const { data: subscribe } = useQuery({
queryKey: ['getSubscribeList', 'all'],
queryFn: async () => {
const { data } = await getSubscribeList({
page: 1,
size: 9999,
});
return data.data?.list as API.Subscribe[];
},
});
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent side='right'>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className='h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className='mt-4 space-y-4'>
<FormField
control={form.control}
name='subscribe_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('subscription')}</FormLabel>
<FormControl>
<Combobox<number, false>
placeholder='Select Subscription'
value={field.value}
onChange={(value) => {
form.setValue(field.name, value);
}}
options={subscribe?.map((item: API.Subscribe) => ({
value: item.id,
label: item.name,
}))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='traffic'
render={({ field }) => (
<FormItem>
<FormLabel>{t('trafficLimit')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('unlimited')}
type='number'
{...field}
formatInput={(value) => unitConversion('bytesToGb', value)}
formatOutput={(value) => unitConversion('gbToBytes', value)}
suffix='GB'
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='upload'
render={({ field }) => (
<FormItem>
<FormLabel>{t('uploadTraffic')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='0'
type='number'
{...field}
formatInput={(value) => unitConversion('bytesToGb', value)}
formatOutput={(value) => unitConversion('gbToBytes', value)}
suffix='GB'
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='download'
render={({ field }) => (
<FormItem>
<FormLabel>{t('downloadTraffic')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder='0'
type='number'
{...field}
formatInput={(value) => unitConversion('bytesToGb', value)}
formatOutput={(value) => unitConversion('gbToBytes', value)}
suffix='GB'
onValueChange={(value) => {
form.setValue(field.name, value as number);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='expired_at'
render={({ field }) => (
<FormItem>
<FormLabel>{t('expiredAt')}</FormLabel>
<FormControl>
<DatePicker
placeholder={t('permanent')}
value={field.value}
onChange={(value) => {
if (value === field.value) {
form.setValue(field.name, 0);
} else {
form.setValue(field.name, value!);
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
<Button
variant='outline'
disabled={loading}
onClick={() => {
setOpen(false);
}}
>
{t('cancel')}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
{t('confirm')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+38 -31
View File
@@ -1,12 +1,15 @@
'use client'; 'use client';
import { Display } from '@/components/display';
import { ProTable, ProTableActions } from '@/components/pro-table'; import { ProTable, ProTableActions } from '@/components/pro-table';
import { createUser, deleteUser, getUserList, updateUser } from '@/services/admin/user'; import { createUser, deleteUser, getUserList, updateUserBasicInfo } from '@/services/admin/user';
import { Badge } from '@workspace/ui/components/badge';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { Switch } from '@workspace/ui/components/switch'; import { Switch } from '@workspace/ui/components/switch';
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button'; import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
import { formatDate, unitConversion } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { UserDetail } from './user-detail'; import { UserDetail } from './user-detail';
@@ -55,10 +58,10 @@ export default function Page() {
<Switch <Switch
defaultChecked={row.getValue('enable')} defaultChecked={row.getValue('enable')}
onCheckedChange={async (checked) => { onCheckedChange={async (checked) => {
await updateUser({ await updateUserBasicInfo({
...row.original, ...row.original,
enable: checked, enable: checked,
} as unknown as API.UpdateUserRequest); } as unknown as API.UpdateUserBasiceInfoRequest);
toast.success(t('updateSuccess')); toast.success(t('updateSuccess'));
ref.current?.refresh(); ref.current?.refresh();
}} }}
@@ -71,13 +74,39 @@ export default function Page() {
header: 'ID', header: 'ID',
}, },
{ {
accessorKey: 'email', accessorKey: 'auth_methods',
header: t('userName'), header: t('userName'),
cell: ({ row }) => {
const method = row.original.auth_methods?.[0];
return (
<div>
<Badge className='mr-1 uppercase' title={method?.verified ? t('verified') : ''}>
{method?.auth_type}
</Badge>
{method?.auth_identifier}
</div>
);
},
}, },
{ {
accessorKey: 'balance', accessorKey: 'balance',
header: t('balance'), header: t('balance'),
cell: ({ row }) => unitConversion('centsToDollars', row.getValue('balance')), cell: ({ row }) => <Display type='currency' value={row.getValue('balance')} />,
},
{
accessorKey: 'gift_amount',
header: t('giftAmount'),
cell: ({ row }) => <Display type='currency' value={row.getValue('gift_amount')} />,
},
{
accessorKey: 'commission',
header: t('commission'),
cell: ({ row }) => <Display type='currency' value={row.getValue('commission')} />,
},
{
accessorKey: 'refer_code',
header: t('inviteCode'),
cell: ({ row }) => row.getValue('refer_code') || '--',
}, },
{ {
accessorKey: 'referer_id', accessorKey: 'referer_id',
@@ -100,31 +129,9 @@ export default function Page() {
actions={{ actions={{
render: (row) => { render: (row) => {
return [ return [
<UserForm<API.UpdateUserRequest> <Button key='detail' asChild>
key='edit' <Link href={`/dashboard/user/${row.id}`}>{t('edit')}</Link>
trigger={t('edit')} </Button>,
title={t('editUser')}
loading={loading}
initialValues={row as unknown as API.UpdateUserRequest}
onSubmit={async (values) => {
setLoading(true);
try {
await updateUser({
...row,
...values,
});
toast.success(t('updateSuccess'));
ref.current?.refresh();
setLoading(false);
return true;
} catch (error) {
setLoading(false);
return false;
}
}}
/>,
<ConfirmButton <ConfirmButton
key='edit' key='edit'
trigger={<Button variant='destructive'>{t('delete')}</Button>} trigger={<Button variant='destructive'>{t('delete')}</Button>}
+22 -8
View File
@@ -1,11 +1,13 @@
'use client'; 'use client';
import { Display } from '@/components/display';
import { getUserDetail } from '@/services/admin/user'; import { getUserDetail } from '@/services/admin/user';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@workspace/ui/components/hover-card'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@workspace/ui/components/hover-card';
import { formatDate, unitConversion } from '@workspace/ui/utils'; import { formatDate } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
export function UserDetail({ id }: { id: number }) { export function UserDetail({ id }: { id: number }) {
@@ -26,8 +28,10 @@ export function UserDetail({ id }: { id: number }) {
return ( return (
<HoverCard> <HoverCard>
<HoverCardTrigger asChild onMouseEnter={() => setShouldFetch(true)}> <HoverCardTrigger asChild onMouseEnter={() => setShouldFetch(true)}>
<Button variant='link' className='p-0'> <Button variant='link' className='p-0' asChild>
@{data?.email?.split('@')[0] || 'Loading...'} <Link href={`/dashboard/user/${id}`}>
{data?.auth_methods[0]?.auth_identifier || t('loading')}
</Link>
</Button> </Button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent> <HoverCardContent>
@@ -37,13 +41,23 @@ export function UserDetail({ id }: { id: number }) {
<span className='text-muted-foreground'>ID</span> <span className='text-muted-foreground'>ID</span>
<span>{data?.id}</span> <span>{data?.id}</span>
</li> </li>
<li className='flex items-center justify-between font-semibold'>
<span className='text-muted-foreground'>{t('email')}</span>
<span>{data?.email}</span>
</li>
<li className='flex items-center justify-between font-semibold'> <li className='flex items-center justify-between font-semibold'>
<span className='text-muted-foreground'>{t('balance')}</span> <span className='text-muted-foreground'>{t('balance')}</span>
<span>{unitConversion('centsToDollars', data?.balance)}</span> <span>
<Display type='currency' value={data?.balance} />
</span>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('giftAmount')}</span>
<span>
<Display type='currency' value={data?.gift_amount} />
</span>
</li>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('commission')}</span>
<span>
<Display type='currency' value={data?.commission} />
</span>
</li> </li>
<li className='flex items-center justify-between'> <li className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('createdAt')}</span> <span className='text-muted-foreground'>{t('createdAt')}</span>
+115 -18
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import useGlobalStore from '@/config/use-global';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Icon } from '@iconify/react';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { import {
Form, Form,
@@ -21,7 +21,9 @@ import {
SheetTrigger, SheetTrigger,
} from '@workspace/ui/components/sheet'; } from '@workspace/ui/components/sheet';
import { Switch } from '@workspace/ui/components/switch'; import { Switch } from '@workspace/ui/components/switch';
import { AreaCodeSelect } from '@workspace/ui/custom-components/area-code-select';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input'; import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { unitConversion } from '@workspace/ui/utils'; import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -43,17 +45,23 @@ export default function UserForm<T extends Record<string, any>>({
loading, loading,
trigger, trigger,
title, title,
}: UserFormProps<T>) { }: Readonly<UserFormProps<T>>) {
const t = useTranslations('user'); const t = useTranslations('user');
const { common } = useGlobalStore();
const { currency } = common;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email(t('form.invalidEmailFormat')), email: z.string().email(t('invalidEmailFormat')),
telephone_area_code: z.string().optional(),
telephone: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
referer_id: z.number().optional(), referer_id: z.number().optional(),
refer_code: z.string().optional(), refer_code: z.string().optional(),
is_admin: z.boolean().optional(), is_admin: z.boolean().optional(),
balance: z.number().optional(), balance: z.number().optional(),
gift_amount: z.number().optional(),
commission: z.number().optional(),
}); });
const form = useForm({ const form = useForm({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -96,10 +104,52 @@ export default function UserForm<T extends Record<string, any>>({
name='email' name='email'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.userEmail')}</FormLabel> <FormLabel>{t('userEmail')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
placeholder={t('form.userEmailPlaceholder')} placeholder={t('userEmailPlaceholder')}
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='telephone'
render={({ field }) => (
<FormItem>
<FormLabel>{t('telephone')}</FormLabel>
<FormControl>
<EnhancedInput
prefix={
<FormField
control={form.control}
name='telephone_area_code'
render={({ field }) => (
<FormItem>
<FormControl>
<AreaCodeSelect
className='w-32 rounded-none border-y-0 border-l-0'
simple
placeholder={t('areaCodePlaceholder')}
value={field.value}
onChange={(value) => {
form.setValue(field.name, value.phone);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
}
placeholder={t('telephonePlaceholder')}
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -115,10 +165,10 @@ export default function UserForm<T extends Record<string, any>>({
name='password' name='password'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.password')}</FormLabel> <FormLabel>{t('password')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
placeholder={t('form.passwordPlaceholder')} placeholder={t('passwordPlaceholder')}
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -134,10 +184,10 @@ export default function UserForm<T extends Record<string, any>>({
name='referer_id' name='referer_id'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.refererId')}</FormLabel> <FormLabel>{t('refererId')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
placeholder={t('form.refererIdPlaceholder')} placeholder={t('refererIdPlaceholder')}
{...field} {...field}
type='number' type='number'
onValueChange={(value) => { onValueChange={(value) => {
@@ -154,10 +204,10 @@ export default function UserForm<T extends Record<string, any>>({
name='refer_code' name='refer_code'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.inviteCode')}</FormLabel> <FormLabel>{t('inviteCode')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
placeholder={t('form.inviteCodePlaceholder')} placeholder={t('inviteCodePlaceholder')}
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
form.setValue(field.name, value); form.setValue(field.name, value);
@@ -173,11 +223,59 @@ export default function UserForm<T extends Record<string, any>>({
name='balance' name='balance'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.balance')}</FormLabel> <FormLabel>{t('balance')}</FormLabel>
<FormControl> <FormControl>
<EnhancedInput <EnhancedInput
prefix='¥' prefix={currency?.currency_symbol ?? '$'}
placeholder={t('form.balancePlaceholder')} placeholder={t('balancePlaceholder')}
type='number'
{...field}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
min={0}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='gift_amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('giftAmount')}</FormLabel>
<FormControl>
<EnhancedInput
prefix={currency?.currency_symbol ?? '$'}
placeholder={t('giftAmountPlaceholder')}
type='number'
{...field}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
min={0}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='commission'
render={({ field }) => (
<FormItem>
<FormLabel>{t('commission')}</FormLabel>
<FormControl>
<EnhancedInput
prefix={currency?.currency_symbol ?? '$'}
placeholder={t('commissionPlaceholder')}
type='number' type='number'
{...field} {...field}
formatInput={(value) => unitConversion('centsToDollars', value)} formatInput={(value) => unitConversion('centsToDollars', value)}
@@ -197,7 +295,7 @@ export default function UserForm<T extends Record<string, any>>({
name='is_admin' name='is_admin'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('form.manager')}</FormLabel> <FormLabel>{t('manager')}</FormLabel>
<FormControl> <FormControl>
<div className='pt-2'> <div className='pt-2'>
<Switch checked={!!field.value} onCheckedChange={field.onChange} /> <Switch checked={!!field.value} onCheckedChange={field.onChange} />
@@ -218,11 +316,10 @@ export default function UserForm<T extends Record<string, any>>({
setOpen(false); setOpen(false);
}} }}
> >
{t('form.cancel')} {t('cancel')}
</Button> </Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}> <Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '} {loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />} {t('confirm')}
{t('form.confirm')}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
+6 -1
View File
@@ -93,7 +93,12 @@ export default async function RootLayout({
user = await currentUser({ user = await currentUser({
skipErrorHandler: true, skipErrorHandler: true,
Authorization, Authorization,
}).then((res) => res.data.data); }).then((res) => {
if (res.data.data?.is_admin) {
return res.data.data;
}
return undefined;
});
} catch (error) { } catch (error) {
console.log('Error fetching current user:', error); console.log('Error fetching current user:', error);
} }
+34 -11
View File
@@ -3,8 +3,6 @@ import { Card, CardDescription, CardHeader, CardTitle } from '@workspace/ui/comp
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import Link from 'next/link'; import Link from 'next/link';
const BASE_URL = 'https://cdn.jsdelivr.net/gh/perfect-panel/ppanel-assets/billing/index.json';
interface BillingProps { interface BillingProps {
type: 'dashboard' | 'payment'; type: 'dashboard' | 'payment';
} }
@@ -17,23 +15,48 @@ interface ItemType {
href: string; href: string;
} }
async function getBillingURL() {
try {
const response = await fetch(
'https://api.github.com/repos/perfect-panel/ppanel-assets/commits',
);
const json = await response.json();
const version = json[0]?.sha;
const url = new URL('https://cdn.jsdelivr.net/gh/perfect-panel/ppanel-assets');
url.pathname += `@${version}/billing/index.json`;
return url.toString();
} catch (error) {
return 'https://cdn.jsdelivr.net/gh/perfect-panel/ppanel-assets/billing/index.json';
}
}
export default async function Billing({ type }: BillingProps) { export default async function Billing({ type }: BillingProps) {
const t = await getTranslations('common.billing'); const t = await getTranslations('common.billing');
let list: ItemType[] = []; let list: ItemType[] = [];
try { try {
const response = await fetch(BASE_URL, { cache: 'no-store' }); const url = await getBillingURL();
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const response = await fetch(url, {
const data = await response.json(); headers: {
const now = new Date().getTime(); Accept: 'application/json',
list = data[type].filter((item: { expiryDate: string }) => { },
const expiryDate = Date.parse(item.expiryDate);
return !isNaN(expiryDate) && expiryDate > now;
}); });
const data = await response.json();
const now = Date.now();
list = Array.isArray(data[type])
? data[type].filter((item: { expiryDate: string }) => {
const expiryDate = Date.parse(item.expiryDate);
return !isNaN(expiryDate) && expiryDate > now;
})
: [];
} catch (error) { } catch (error) {
console.log('Error fetching billing data:', error); console.log(error);
return null; return null;
} }
if (list && list.length === 0) return null;
if (!list?.length) return null;
return ( return (
<> <>
<h1 className='text mt-2 font-bold'> <h1 className='text mt-2 font-bold'>
@@ -59,7 +59,7 @@ export function RevenueStatisticsCard() {
return ( return (
<Tabs defaultValue='today'> <Tabs defaultValue='today'>
<Card> <Card className='h-full'>
<CardHeader className='flex !flex-row items-center justify-between'> <CardHeader className='flex !flex-row items-center justify-between'>
<CardTitle>{t('revenueTitle')}</CardTitle> <CardTitle>{t('revenueTitle')}</CardTitle>
<TabsList> <TabsList>
@@ -68,8 +68,8 @@ export function RevenueStatisticsCard() {
<TabsTrigger value='total'>{t('total')}</TabsTrigger> <TabsTrigger value='total'>{t('total')}</TabsTrigger>
</TabsList> </TabsList>
</CardHeader> </CardHeader>
<TabsContent value='today'> <TabsContent value='today' className='h-full'>
<CardContent> <CardContent className='h-80'>
{RevenueStatistics?.today.new_order_amount || {RevenueStatistics?.today.new_order_amount ||
RevenueStatistics?.today.renewal_order_amount ? ( RevenueStatistics?.today.renewal_order_amount ? (
<ChartContainer config={IncomeStatisticsConfig} className='mx-auto max-h-80'> <ChartContainer config={IncomeStatisticsConfig} className='mx-auto max-h-80'>
@@ -129,7 +129,9 @@ export function RevenueStatisticsCard() {
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
@@ -162,8 +164,8 @@ export function RevenueStatisticsCard() {
</CardFooter> </CardFooter>
</TabsContent> </TabsContent>
<TabsContent value='month'> <TabsContent value='month' className='h-full'>
<CardContent> <CardContent className='h-80'>
{RevenueStatistics?.monthly.list && RevenueStatistics?.monthly.list.length > 0 ? ( {RevenueStatistics?.monthly.list && RevenueStatistics?.monthly.list.length > 0 ? (
<ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'> <ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'>
<BarChart <BarChart
@@ -196,7 +198,9 @@ export function RevenueStatisticsCard() {
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
@@ -232,8 +236,8 @@ export function RevenueStatisticsCard() {
</CardFooter> </CardFooter>
</TabsContent> </TabsContent>
<TabsContent value='total'> <TabsContent value='total' className='h-full'>
<CardContent> <CardContent className='h-80'>
{RevenueStatistics?.all.list && RevenueStatistics?.all.list.length > 0 ? ( {RevenueStatistics?.all.list && RevenueStatistics?.all.list.length > 0 ? (
<ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'> <ChartContainer config={IncomeStatisticsConfig} className='max-h-80 w-full'>
<AreaChart <AreaChart
@@ -283,7 +287,9 @@ export function RevenueStatisticsCard() {
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { queryServerTotalData, queryTicketWaitReply } from '@/services/admin/console'; import { queryServerTotalData, queryTicketWaitReply } from '@/services/admin/console';
import { Icon } from '@iconify/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'; import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@workspace/ui/components/chart'; import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@workspace/ui/components/chart';
@@ -13,6 +12,7 @@ import {
SelectValue, SelectValue,
} from '@workspace/ui/components/select'; } from '@workspace/ui/components/select';
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Icon } from '@workspace/ui/custom-components/icon';
import { formatBytes } from '@workspace/ui/utils'; import { formatBytes } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
@@ -149,7 +149,7 @@ export default function Statistics() {
</TabsList> </TabsList>
</Tabs> </Tabs>
</CardHeader> </CardHeader>
<CardContent> <CardContent className='h-80'>
<div className='mb-6 flex items-center justify-between'> <div className='mb-6 flex items-center justify-between'>
<h4 className='font-semibold'> <h4 className='font-semibold'>
{dataType === 'nodes' ? t('nodeTraffic') : t('userTraffic')} {dataType === 'nodes' ? t('nodeTraffic') : t('userTraffic')}
@@ -230,7 +230,9 @@ export default function Statistics() {
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -61,7 +61,7 @@ export function UserStatisticsCard() {
return ( return (
<Tabs defaultValue='today'> <Tabs defaultValue='today'>
<Card> <Card className='h-full'>
<CardHeader className='flex !flex-row items-center justify-between'> <CardHeader className='flex !flex-row items-center justify-between'>
<CardTitle>{t('userTitle')}</CardTitle> <CardTitle>{t('userTitle')}</CardTitle>
<TabsList> <TabsList>
@@ -71,8 +71,8 @@ export function UserStatisticsCard() {
</TabsList> </TabsList>
</CardHeader> </CardHeader>
<TabsContent value='today'> <TabsContent value='today' className='h-full'>
<CardContent> <CardContent className='h-80'>
{UserStatistics?.today.register || {UserStatistics?.today.register ||
UserStatistics?.today.new_order_users || UserStatistics?.today.new_order_users ||
UserStatistics?.today.renewal_order_users ? ( UserStatistics?.today.renewal_order_users ? (
@@ -133,7 +133,9 @@ export function UserStatisticsCard() {
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
@@ -168,8 +170,8 @@ export function UserStatisticsCard() {
</CardFooter> </CardFooter>
</TabsContent> </TabsContent>
<TabsContent value='month'> <TabsContent value='month' className='h-full'>
<CardContent> <CardContent className='h-80'>
{UserStatistics?.monthly.list && UserStatistics?.monthly.list.length > 0 ? ( {UserStatistics?.monthly.list && UserStatistics?.monthly.list.length > 0 ? (
<ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'> <ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'>
<BarChart <BarChart
@@ -204,7 +206,9 @@ export function UserStatisticsCard() {
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
@@ -239,8 +243,8 @@ export function UserStatisticsCard() {
</CardFooter> </CardFooter>
</TabsContent> </TabsContent>
<TabsContent value='total'> <TabsContent value='total' className='h-full'>
<CardContent> <CardContent className='h-80'>
{UserStatistics?.all.list && UserStatistics?.all.list.length > 0 ? ( {UserStatistics?.all.list && UserStatistics?.all.list.length > 0 ? (
<ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'> <ChartContainer config={UserStatisticsConfig} className='max-h-80 w-full'>
<AreaChart <AreaChart
@@ -299,7 +303,9 @@ export function UserStatisticsCard() {
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
) : ( ) : (
<Empty /> <div className='flex h-full items-center justify-center'>
<Empty />
</div>
)} )}
</CardContent> </CardContent>
<CardFooter className='flex h-20 flex-row border-t p-4'> <CardFooter className='flex h-20 flex-row border-t p-4'>
+7 -3
View File
@@ -4,7 +4,7 @@ import useGlobalStore from '@/config/use-global';
import { formatBytes, unitConversion } from '@workspace/ui/utils'; import { formatBytes, unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
type DisplayType = 'currency' | 'traffic' | 'number'; type DisplayType = 'currency' | 'traffic' | 'number' | 'trafficSpeed';
interface DisplayProps<T> { interface DisplayProps<T> {
value?: T; value?: T;
@@ -26,12 +26,16 @@ export function Display<T extends number | undefined | null>({
return formattedValue; return formattedValue;
} }
if (['traffic', 'number'].includes(type) && unlimited && !value) { if (['traffic', 'trafficSpeed', 'number'].includes(type) && unlimited && !value) {
return t('unlimited'); return t('unlimited');
} }
if (type === 'traffic') { if (type === 'traffic') {
return value ? formatBytes(value as number) : '0'; return value ? formatBytes(value) : '0';
}
if (type === 'trafficSpeed') {
return value ? formatBytes(value).replace('B', 'b') + 'ps' : '0';
} }
if (type === 'number') { if (type === 'number') {
+30 -5
View File
@@ -2,7 +2,6 @@
import { locales } from '@/config/constants'; import { locales } from '@/config/constants';
import { setLocale } from '@/utils/common'; import { setLocale } from '@/utils/common';
import { Icon } from '@iconify/react';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -10,14 +9,40 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@workspace/ui/components/select'; } from '@workspace/ui/components/select';
import { Icon } from '@workspace/ui/custom-components/icon';
import { getCountry } from '@workspace/ui/utils'; import { getCountry } from '@workspace/ui/utils';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
const languages = {
'cs-CZ': 'Čeština',
'de-DE': 'Deutsch',
'en-US': 'English',
'es-ES': 'Español',
'es-MX': 'Español (México)',
'fa-IR': 'فارسی',
'fi-FI': 'Suomi',
'fr-FR': 'Français',
'hi-IN': 'हिन्दी',
'hu-HU': 'Magyar',
'ja-JP': '日本語',
'ko-KR': '한국어',
'no-NO': 'Norsk',
'pl-PL': 'Polski',
'pt-BR': 'Português (Brasil)',
'ro-RO': 'Română',
'ru-RU': 'Русский',
'th-TH': 'ไทย',
'tr-TR': 'Türkçe',
'uk-UA': 'Українська',
'vi-VN': 'Tiếng Việt',
'zh-CN': '简体中文',
'zh-HK': '繁體中文',
} as const;
export default function LanguageSwitch() { export default function LanguageSwitch() {
const locale = useLocale(); const locale = useLocale();
const country = getCountry(locale); const country = getCountry(locale);
const t = useTranslations('language');
const router = useRouter(); const router = useRouter();
const handleLanguageChange = (value: string) => { const handleLanguageChange = (value: string) => {
@@ -31,7 +56,7 @@ export default function LanguageSwitch() {
<SelectValue> <SelectValue>
<div className='flex items-center'> <div className='flex items-center'>
<Icon icon={`flagpack:${country?.alpha2.toLowerCase()}`} className='!size-5' /> <Icon icon={`flagpack:${country?.alpha2.toLowerCase()}`} className='!size-5' />
<span className='sr-only'>{t(`${locale}`)}</span> <span className='sr-only'>{languages[locale as keyof typeof languages]}</span>
</div> </div>
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
@@ -40,7 +65,7 @@ export default function LanguageSwitch() {
<SelectItem key={`${item?.lang}-${item?.alpha2}`} value={`${item?.lang}-${item?.alpha2}`}> <SelectItem key={`${item?.lang}-${item?.alpha2}`} value={`${item?.lang}-${item?.alpha2}`}>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Icon icon={`flagpack:${item?.alpha2.toLowerCase()}`} className='!size-5' /> <Icon icon={`flagpack:${item?.alpha2.toLowerCase()}`} className='!size-5' />
{t(`${item?.lang}-${item?.alpha2}`)} {languages[`${item?.lang}-${item?.alpha2}` as keyof typeof languages]}
</div> </div>
</SelectItem> </SelectItem>
))} ))}
+2
View File
@@ -2,6 +2,7 @@
import { ProTable as _ProTable, ProTableProps } from '@workspace/ui/custom-components/pro-table'; import { ProTable as _ProTable, ProTableProps } from '@workspace/ui/custom-components/pro-table';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Empty } from './empty';
export { type ProTableActions } from '@workspace/ui/custom-components/pro-table'; export { type ProTableActions } from '@workspace/ui/custom-components/pro-table';
export function ProTable< export function ProTable<
@@ -25,6 +26,7 @@ export function ProTable<
}, },
textRowsPerPage: t('rowsPerPage'), textRowsPerPage: t('rowsPerPage'),
}} }}
empty={<Empty />}
/> />
); );
} }
+6 -2
View File
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { navs } from '@/config/navs'; import { navs } from '@/config/navs';
import useGlobalStore from '@/config/use-global'; import useGlobalStore from '@/config/use-global';
import { Icon } from '@iconify/react';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -13,6 +12,7 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from '@workspace/ui/components/sidebar'; } from '@workspace/ui/components/sidebar';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image'; import Image from 'next/legacy/image';
import Link from 'next/link'; import Link from 'next/link';
@@ -61,7 +61,11 @@ export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>)
<SidebarMenuButton <SidebarMenuButton
asChild asChild
tooltip={t(item.title)} tooltip={t(item.title)}
isActive={item.url === pathname} isActive={
item.url === '/dashboard'
? pathname === item.url
: pathname.startsWith(item.url)
}
> >
<Link href={item.url}> <Link href={item.url}>
{item.icon && <Icon icon={item.icon} />} {item.icon && <Icon icon={item.icon} />}
+4 -2
View File
@@ -27,7 +27,7 @@ export function UserNav() {
<Avatar className='size-8'> <Avatar className='size-8'>
<AvatarImage alt={user?.avatar ?? ''} src={user?.avatar ?? ''} /> <AvatarImage alt={user?.avatar ?? ''} src={user?.avatar ?? ''} />
<AvatarFallback className='rounded-none bg-transparent'> <AvatarFallback className='rounded-none bg-transparent'>
{user?.email?.[0]?.toUpperCase()} {user?.auth_methods?.[0]?.auth_identifier.toUpperCase().charAt(0)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</Button> </Button>
@@ -35,7 +35,9 @@ export function UserNav() {
<DropdownMenuContent forceMount align='end' className='w-56'> <DropdownMenuContent forceMount align='end' className='w-56'>
<DropdownMenuLabel className='font-normal'> <DropdownMenuLabel className='font-normal'>
<div className='flex flex-col space-y-1'> <div className='flex flex-col space-y-1'>
<p className='text-sm font-medium leading-none'>{user?.email}</p> <p className='text-sm font-medium leading-none'>
{user?.auth_methods?.[0]?.auth_identifier}
</p>
{/* <p className='text-xs leading-none text-muted-foreground'>ID: {user?.id}</p> */} {/* <p className='text-xs leading-none text-muted-foreground'>ID: {user?.id}</p> */}
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
+80 -10
View File
@@ -1,3 +1,46 @@
export const AuthControl = [
{
title: 'General',
url: '/dashboard/auth-control/general',
},
{
title: 'Email',
url: '/dashboard/auth-control/email',
},
{
title: 'Phone Number',
url: '/dashboard/auth-control/phone',
},
{
title: 'Telegram',
url: '/dashboard/auth-control/telegram',
},
{
title: 'Apple',
url: '/dashboard/auth-control/apple',
},
{
title: 'Google',
url: '/dashboard/auth-control/google',
},
{
title: 'Facebook',
url: '/dashboard/auth-control/facebook',
},
// {
// title: 'Twitter',
// url: '/dashboard/auth-control/twitter',
// },
{
title: 'GitHub',
url: '/dashboard/auth-control/github',
},
{
title: 'Device',
url: '/dashboard/auth-control/device',
},
];
export const navs = [ export const navs = [
{ {
title: 'Dashboard', title: 'Dashboard',
@@ -12,6 +55,17 @@ export const navs = [
url: '/dashboard/system', url: '/dashboard/system',
icon: 'flat-color-icons:services', icon: 'flat-color-icons:services',
}, },
{
title: 'Auth Control',
url: '/dashboard/auth-control',
icon: 'flat-color-icons:lock-portrait',
items: AuthControl,
},
{
title: 'ADS Config',
url: '/dashboard/ads',
icon: 'flat-color-icons:electrical-sensor',
},
{ {
title: 'Payment Config', title: 'Payment Config',
url: '/dashboard/payment', url: '/dashboard/payment',
@@ -56,11 +110,17 @@ export const navs = [
title: 'User Management', title: 'User Management',
url: '/dashboard/user', url: '/dashboard/user',
icon: 'flat-color-icons:conference-call', icon: 'flat-color-icons:conference-call',
items: [
{
title: 'User Detail',
url: '/dashboard/user/:id',
},
],
}, },
{ {
title: 'Announcement Management', title: 'Announcement Management',
url: '/dashboard/announcement', url: '/dashboard/announcement',
icon: 'flat-color-icons:news', icon: 'flat-color-icons:advertising',
}, },
{ {
title: 'Ticket Management', title: 'Ticket Management',
@@ -82,16 +142,26 @@ export const navs = [
]; ];
export function findNavByUrl(url: string) { export function findNavByUrl(url: string) {
for (const nav of navs) { function matchDynamicRoute(pattern: string, path: string): boolean {
if (nav.url && nav.url === url) { const regexPattern = pattern.replace(/:[^/]+/g, '[^/]+').replace(/\//g, '\\/');
return [nav]; const regex = new RegExp(`^${regexPattern}$`);
} return regex.test(path);
if (nav.items) { }
const current = nav.items.find((item) => item.url === url);
if (current) { function findNav(items: any[], url: string, path: any[] = []): any[] {
return [nav, current]; for (const item of items) {
if (item.url === url || (item.url && matchDynamicRoute(item.url, url))) {
return [...path, item];
}
if (item.items) {
const result = findNav(item.items, url, [...path, item]);
if (result.length) {
return result;
}
} }
} }
return [];
} }
return [];
return findNav(navs, url);
} }
+27 -11
View File
@@ -14,22 +14,33 @@ export const useGlobalStore = create<GlobalStore>((set) => ({
site_name: '', site_name: '',
site_desc: '', site_desc: '',
site_logo: '', site_logo: '',
keywords: '',
custom_html: '',
}, },
verify: { verify: {
turnstile_site_key: '',
enable_login_verify: false, enable_login_verify: false,
enable_register_verify: false, enable_register_verify: false,
enable_reset_password_verify: false, enable_reset_password_verify: false,
turnstile_site_key: '',
}, },
register: { auth: {
stop_register: false, mobile: {
enable_email_verify: false, enable: false,
enable_email_domain_suffix: false, enable_whitelist: false,
email_domain_suffix_list: '', whitelist: [],
enable_trial: false, },
enable_ip_register_limit: false, email: {
ip_register_limit: 0, enable: false,
ip_register_limit_duration: 0, enable_verify: false,
enable_domain_suffix: false,
domain_suffix_list: '',
},
register: {
stop_register: false,
enable_ip_register_limit: false,
ip_register_limit: 0,
ip_register_limit_duration: 0,
},
}, },
invite: { invite: {
forced_invite: false, forced_invite: false,
@@ -39,7 +50,6 @@ export const useGlobalStore = create<GlobalStore>((set) => ({
currency: { currency: {
currency_unit: 'USD', currency_unit: 'USD',
currency_symbol: '$', currency_symbol: '$',
access_key: '',
}, },
subscribe: { subscribe: {
single_model: false, single_model: false,
@@ -47,6 +57,12 @@ export const useGlobalStore = create<GlobalStore>((set) => ({
subscribe_domain: '', subscribe_domain: '',
pan_domain: false, pan_domain: false,
}, },
verify_code: {
verify_code_expire_time: 5,
verify_code_limit: 15,
verify_code_interval: 60,
},
oauth_methods: [],
}, },
user: undefined, user: undefined,
setCommon: (common) => setCommon: (common) =>
+40
View File
@@ -0,0 +1,40 @@
{
"cancel": "Zrušit",
"confirm": "Potvrdit",
"confirmDelete": "Potvrdit smazání",
"create": "Vytvořit inzerát",
"createAds": "Vytvořit reklamu",
"createSuccess": "Úspěšně vytvořeno",
"delete": "Smazat",
"deleteSuccess": "Úspěšně smazáno",
"deleteWarning": "Opravdu chcete smazat tento inzerát?",
"disabled": "Deaktivováno",
"edit": "Upravit",
"editAds": "Upravit reklamu",
"enabled": "Aktivováno",
"form": {
"cancel": "Zrušit",
"confirm": "Potvrdit",
"content": "Obsah",
"description": "Popis",
"endTime": "Čas ukončení",
"enterContent": "Prosím zadejte obsah inzerátu",
"enterDescription": "Prosím zadejte popis inzerátu",
"enterEndTime": "Prosím vyberte čas ukončení",
"enterStartTime": "Prosím vyberte čas zahájení",
"enterTargetUrl": "Prosím zadejte cílovou URL",
"enterTitle": "Prosím zadejte název inzerátu",
"startTime": "Čas zahájení",
"targetUrl": "Cílová URL",
"title": "Název",
"type": "Typ inzerátu",
"typeImage": "Obrázek",
"typeVideo": "Video"
},
"status": "Stav",
"targetUrl": "Cílová URL",
"title": "Název",
"type": "Typ",
"updateSuccess": "Úspěšně aktualizováno",
"validityPeriod": "Doba platnosti"
}
+16
View File
@@ -0,0 +1,16 @@
{
"clientId": "ID služby",
"clientIdDescription": "Apple Service ID, který můžete získat z Apple Developer Portálu",
"clientSecret": "Soukromý klíč",
"clientSecretDescription": "Obsah soukromého klíče (.p8 soubor) používaný pro autentizaci s Apple",
"enable": "Povolit",
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého Apple ID",
"keyId": "ID klíče",
"keyIdDescription": "ID vašeho soukromého klíče z Apple Developer Portálu",
"redirectUri": "Přesměrovací URL",
"redirectUriDescription": "Prosím vyplňte adresu API přesměrované URL po úspěšném ověření pomocí Apple. Nepoužívejte / na konci.",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné",
"teamId": "ID týmu",
"teamIdDescription": "ID týmu Apple Developer"
}
@@ -0,0 +1,74 @@
{
"invite": {
"commissionFirstTimeOnly": "Provize pouze za první nákup",
"commissionFirstTimeOnlyDescription": "Pokud je povoleno, provize se generuje pouze při první platbě pozývatele; jednotlivé uživatele můžete konfigurovat v uživatelské správě",
"enableForcedInvite": "Povolit nucené pozvání",
"enableForcedInviteDescription": "Pokud je povoleno, mohou se registrovat pouze pozvaní uživatelé",
"inputPlaceholder": "Zadejte",
"inviteCommissionPercentage": "Procento provize za pozvání",
"inviteCommissionPercentageDescription": "Výchozí globální poměr rozdělení provize; jednotlivé poměry můžete konfigurovat v uživatelské správě",
"inviteSettings": "Nastavení pozvání",
"saveSuccess": "Uložení úspěšné"
},
"log": {
"content": "Obsah",
"createdAt": "Vytvořeno",
"emailLog": "Emailový záznam",
"mobileLog": "Mobilní záznam",
"platform": "Platforma",
"sendFailed": "Selhalo",
"sendSuccess": "Úspěch",
"status": "Stav",
"subject": "Předmět",
"to": "Příjemce",
"updatedAt": "Aktualizováno"
},
"register": {
"day": "Den",
"hour": "Hodina",
"ipRegistrationLimit": "Omezení registrace podle IP",
"ipRegistrationLimitDescription": "Pokud je povoleno, IP adresy splňující pravidla budou omezeny v registraci; mějte na paměti, že určení IP může způsobit problémy kvůli CDN nebo proxy serverům",
"minute": "Minuta",
"month": "Měsíc",
"noLimit": "Bez omezení",
"penaltyTime": "Doba trestu (minuty)",
"penaltyTimeDescription": "Uživatelé musí počkat, až doba trestu vyprší, než se znovu zaregistrují",
"registerSettings": "Nastavení registrace",
"registrationLimitCount": "Počet omezení registrace",
"registrationLimitCountDescription": "Povolit trest po dosažení registračního limitu",
"saveSuccess": "Uložení úspěšné",
"stopNewUserRegistration": "Zastavit registraci nových uživatelů",
"stopNewUserRegistrationDescription": "Pokud je povoleno, nikdo se nemůže registrovat",
"trialDuration": "Doba zkušební verze",
"trialRegistration": "Zkušební registrace",
"trialRegistrationDescription": "Povolit zkušební registraci; nejprve upravte zkušební balíček a dobu trvání",
"trialSubscribePlan": "Plán zkušebního předplatného",
"trialSubscribePlanDescription": "Vyberte plán zkušebního předplatného",
"year": "Rok"
},
"verify": {
"inputPlaceholder": "Zadejte",
"loginVerificationCode": "Ověřovací kód pro přihlášení",
"loginVerificationCodeDescription": "Ověření člověka během přihlášení",
"registrationVerificationCode": "Ověřovací kód pro registraci",
"registrationVerificationCodeDescription": "Ověření člověka během registrace",
"resetPasswordVerificationCode": "Ověřovací kód pro resetování hesla",
"resetPasswordVerificationCodeDescription": "Ověření člověka během resetování hesla",
"saveSuccess": "Uložení úspěšné",
"turnstileSecretDescription": "Tajný klíč turniketu poskytovaný Cloudflare",
"turnstileSiteKeyDescription": "Klíč pro web turniketu poskytovaný Cloudflare",
"verifySettings": "Nastavení ověření"
},
"verify-code": {
"dailyLimit": "Denní limit",
"dailyLimitDescription": "Maximální počet ověřovacích kódů, které lze odeslat za den",
"expireTime": "Čas vypršení",
"expireTimeDescription": "Doba platnosti ověřovacího kódu (vteřiny)",
"interval": "Interval odesílání",
"intervalDescription": "Minimální interval mezi odesíláním ověřovacích kódů (sekundy)",
"saveSuccess": "Uložení úspěšné",
"second": "sekundy",
"times": "krát",
"verifyCodeSettings": "Nastavení ověřovacího kódu"
}
}
+17 -5
View File
@@ -4,20 +4,28 @@
"continue": "Pokračovat", "continue": "Pokračovat",
"description": "Zadejte svůj e-mail pro pokračování v přihlášení nebo registraci.", "description": "Zadejte svůj e-mail pro pokračování v přihlášení nebo registraci.",
"email": "Zadejte platnou e-mailovou adresu.", "email": "Zadejte platnou e-mailovou adresu.",
"title": "Přihlášení/Registrace", "title": "Přihlášení/Registrace"
"whitelist": "Doména e-mailu není na povoleném seznamu."
}, },
"get": "Získat",
"login": { "login": {
"codeLogin": "Přihlášení pomocí ověřovacího kódu",
"description": "Zadejte prosím své přihlašovací údaje pro přihlášení.", "description": "Zadejte prosím své přihlašovací údaje pro přihlášení.",
"email": "Zadejte platnou e-mailovou adresu.",
"forgotPassword": "Zapomněli jste heslo?", "forgotPassword": "Zapomněli jste heslo?",
"passwordLogin": "Přihlášení pomocí hesla",
"registerAccount": "Registrace účtu",
"success": "Přihlášení bylo úspěšné!", "success": "Přihlášení bylo úspěšné!",
"switchAccount": "Registrace/Přepnout účet",
"title": "Přihlášení" "title": "Přihlášení"
}, },
"logout": "Odhlásit se", "logout": "Odhlásit se",
"methods": {
"email": "E-mail",
"mobile": "Telefon"
},
"orWithEmail": "nebo pomocí e-mailu", "orWithEmail": "nebo pomocí e-mailu",
"register": { "register": {
"description": "Vytvořte si nový účet, vyplňte své údaje pro registraci.", "description": "Vytvořte si nový účet, vyplňte své údaje pro registraci.",
"email": "Zadejte platnou e-mailovou adresu.",
"existingAccount": "Již máte účet?", "existingAccount": "Již máte účet?",
"get": "Získat", "get": "Získat",
"invite": "Pozvánkový kód", "invite": "Pozvánkový kód",
@@ -25,15 +33,19 @@
"passwordMismatch": "Hesla se neshodují", "passwordMismatch": "Hesla se neshodují",
"success": "Registrace úspěšná, automaticky přihlášeno!", "success": "Registrace úspěšná, automaticky přihlášeno!",
"switchToLogin": "Přihlásit se/Obnovit e-mail", "switchToLogin": "Přihlásit se/Obnovit e-mail",
"title": "Registrace" "title": "Registrace",
"whitelist": "Doména e-mailu není na povoleném seznamu."
}, },
"reset": { "reset": {
"description": "Zadejte svou e-mailovou adresu pro obnovení hesla.", "description": "Zadejte svou e-mailovou adresu pro obnovení hesla.",
"email": "Zadejte prosím platnou e-mailovou adresu.",
"existingAccount": "Máte již účet?", "existingAccount": "Máte již účet?",
"get": "Získat", "get": "Získat",
"success": "Obnovení hesla bylo úspěšné, automaticky přepnuto na přihlášení!", "success": "Obnovení hesla bylo úspěšné, automaticky přepnuto na přihlášení!",
"switchToLogin": "Přihlášení/Registrace", "switchToLogin": "Přihlášení/Registrace",
"title": "Obnovení hesla" "title": "Obnovení hesla"
}, },
"tos": "Podmínky služby" "tos": "Podmínky služby",
"verifyAccount": "Ověření účtu",
"verifyAccountDesc": "Prosím, ověřte svůj účet, abyste mohli pokračovat."
} }
+3
View File
@@ -26,6 +26,7 @@
"20004": "Tento uživatel byl deaktivován, pokud máte dotazy, kontaktujte zákaznický servis.", "20004": "Tento uživatel byl deaktivován, pokud máte dotazy, kontaktujte zákaznický servis.",
"20005": "Nedostatečný zůstatek, prosím dobijte a zkuste to znovu.", "20005": "Nedostatečný zůstatek, prosím dobijte a zkuste to znovu.",
"20006": "Funkce registrace není momentálně dostupná, zkuste to prosím později.", "20006": "Funkce registrace není momentálně dostupná, zkuste to prosím později.",
"20008": "Informace o uživateli jsou nesprávné, zkontrolujte je a zkuste to znovu.",
"30001": "Tento uzel již existuje, nepřidávejte ho znovu.", "30001": "Tento uzel již existuje, nepřidávejte ho znovu.",
"30002": "Příslušný uzel nebyl nalezen, zkontrolujte a zkuste to znovu.", "30002": "Příslušný uzel nebyl nalezen, zkontrolujte a zkuste to znovu.",
"30003": "Skupina již existuje, zkuste použít jiný název.", "30003": "Skupina již existuje, zkuste použít jiný název.",
@@ -44,9 +45,11 @@
"60002": "Toto předplatné nelze momentálně použít, zkuste to prosím později.", "60002": "Toto předplatné nelze momentálně použít, zkuste to prosím později.",
"60003": "Bylo zjištěno existující předplatné. Zrušte ho, než budete pokračovat.", "60003": "Bylo zjištěno existující předplatné. Zrušte ho, než budete pokračovat.",
"60004": "Nelze momentálně odstranit, protože předplatné má aktivní uživatele.", "60004": "Nelze momentálně odstranit, protože předplatné má aktivní uživatele.",
"60005": "Režim jednotného předplatného překročil limit uživatelů",
"70001": "Ověřovací kód je nesprávný, zadejte ho prosím znovu.", "70001": "Ověřovací kód je nesprávný, zadejte ho prosím znovu.",
"80001": "Úkol nebyl úspěšně přidán do fronty, zkuste to prosím později.", "80001": "Úkol nebyl úspěšně přidán do fronty, zkuste to prosím později.",
"90001": "Vypněte prosím režim DEBUG a zkuste to znovu.", "90001": "Vypněte prosím režim DEBUG a zkuste to znovu.",
"90015": "Tento účet dnes dosáhl limitu pro odesílání, zkuste to prosím znovu zítra",
"undefined": "Došlo k chybě systému, zkuste to prosím později." "undefined": "Došlo k chybě systému, zkuste to prosím později."
}, },
"table": { "table": {
+14
View File
@@ -0,0 +1,14 @@
{
"blockVirtualMachine": "Blokovat virtuální stroj",
"blockVirtualMachineDescription": "Pokud je povoleno, zařízení nebudou moci běžet na virtuálních strojích nebo emulátorech",
"communicationKey": "Komunikační klíč",
"communicationKeyDescription": "Komunikační klíč se používá pro bezpečnou komunikaci mezi zařízeními a servery",
"enable": "Povolit",
"enableDescription": "Po povolení jsou podporovány různé identifikátory zařízení, jako jsou IMEI/IDFA/IDFV/AndroidID/Mac adresa pro přihlášení a registraci",
"enableSecurity": "Povolit šifrování komunikace",
"enableSecurityDescription": "Pokud je povoleno, komunikace mezi zařízeními a servery bude šifrována",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné",
"showAds": "Zobrazit reklamy",
"showAdsDescription": "Pokud je povoleno, na zařízeních se budou zobrazovat reklamy"
}
+47
View File
@@ -0,0 +1,47 @@
{
"emailBasicConfigDescription": "Nakonfigurujte nastavení SMTP serveru a možnosti ověření e-mailu",
"emailLogsDescription": "Zobrazit historii odeslaných e-mailů a jejich stav",
"emailSuffixWhitelist": "Seznam povolených přípon e-mailů",
"emailSuffixWhitelistDescription": "Když je povoleno, mohou se registrovat pouze e-maily s příponami v seznamu",
"emailVerification": "Ověření e-mailu",
"emailVerificationDescription": "Když je povoleno, uživatelé budou muset ověřit svůj e-mail",
"enable": "Povolit",
"enableDescription": "Po aktivaci budou povoleny funkce registrace e-mailem, přihlášení, propojení a odpojení.",
"expiration_email_template": "Šablona oznámení o vypršení platnosti",
"expiration_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.",
"failed": "Neúspěch",
"inputPlaceholder": "Zadejte hodnotu...",
"logs": "Protokoly",
"maintenance_email_template": "Šablona oznámení o údržbě",
"maintenance_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.",
"recipient": "Příjemce",
"saveFailed": "Nepodařilo se uložit konfiguraci",
"saveSuccess": "Konfigurace byla úspěšně uložena.",
"sendFailure": "Nepodařilo se odeslat testovací e-mail, zkontrolujte prosím konfiguraci.",
"sendSuccess": "Testovací e-mail byl úspěšně odeslán.",
"sendTestEmail": "Odeslat testovací e-mail",
"sendTestEmailDescription": "Odeslat testovací e-mail pro ověření konfigurace.",
"senderAddress": "Adresa odesílatele",
"senderAddressDescription": "Výchozí e-mailová adresa používaná pro odesílání e-mailů.",
"sent": "Odesláno",
"sentAt": "Odesláno",
"settings": "Nastavení",
"smtpAccount": "SMTP účet",
"smtpAccountDescription": "E-mailový účet používaný pro ověřování.",
"smtpEncryptionMethod": "Metoda šifrování SMTP",
"smtpEncryptionMethodDescription": "Vyberte, zda povolit šifrování SSL/TLS.",
"smtpPassword": "SMTP heslo",
"smtpPasswordDescription": "Heslo pro účet SMTP.",
"smtpServerAddress": "Adresa SMTP serveru",
"smtpServerAddressDescription": "Zadejte adresu serveru používanou pro odesílání e-mailů.",
"smtpServerPort": "Port SMTP serveru",
"smtpServerPortDescription": "Zadejte port používaný k připojení k SMTP serveru.",
"status": "Stav",
"subject": "Předmět",
"template": "Šablona",
"verify_email_template": "Šablona ověřovacího e-mailu",
"verify_email_templateDescription": "Zástupné symboly {after}.variable{before} budou nahrazeny skutečnými daty. Ujistěte se, že tyto proměnné zachováte.",
"whitelistSuffixes": "Povolené přípony",
"whitelistSuffixesDescription": "Používá se pro ověření e-mailu během registrace; jeden na řádek",
"whitelistSuffixesPlaceholder": "Zadejte přípony e-mailů, každou na samostatný řádek"
}
+10
View File
@@ -0,0 +1,10 @@
{
"clientId": "ID klienta",
"clientIdDescription": "ID aplikace Facebook z konzole pro vývojáře Facebooku",
"clientSecret": "Klientský tajný klíč",
"clientSecretDescription": "Facebook App Secret z Facebook Developers Console",
"enable": "Povolit",
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu na Facebooku",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné"
}
+10
View File
@@ -0,0 +1,10 @@
{
"clientId": "ID klienta GitHub",
"clientIdDescription": "ID klienta z nastavení vaší GitHub OAuth aplikace",
"clientSecret": "GitHub klientský tajný klíč",
"clientSecretDescription": "Tajný klíč klienta z nastavení vaší GitHub OAuth aplikace",
"enable": "Povolit ověřování GitHub",
"enableDescription": "Povolit uživatelům přihlásit se pomocí jejich účtů na GitHubu",
"saveFailed": "Nepodařilo se uložit nastavení GitHubu",
"saveSuccess": "Nastavení GitHubu bylo úspěšně uloženo"
}
+10
View File
@@ -0,0 +1,10 @@
{
"clientId": "ID klienta",
"clientIdDescription": "ID klienta Google OAuth 2.0 z Google Cloud Console",
"clientSecret": "Klientský tajný klíč",
"clientSecretDescription": "Tajný klíč klienta Google OAuth 2.0 z Google Cloud Console",
"enable": "Povolit",
"enableDescription": "Po povolení se uživatelé mohou přihlásit pomocí svého účtu Google",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné"
}
-25
View File
@@ -1,25 +0,0 @@
{
"cs-CZ": "čeština",
"de-DE": "němčina",
"en-US": "angličtina",
"es-ES": "španělština",
"es-MX": "španělština (Mexiko)",
"fa-IR": "Perština",
"fi-FI": "finština",
"fr-FR": "Francouzština",
"hi-IN": "hindština",
"hu-HU": "maďarština",
"ja-JP": "Japonština",
"ko-KR": "Korejština",
"no-NO": "norština",
"pl-PL": "polština",
"pt-BR": "portugalština (Brazílie)",
"ro-RO": "rumunština",
"ru-RU": "ruština",
"th-TH": "thajština",
"tr-TR": "turečtina",
"uk-UA": "Ukrajinština",
"vi-VN": "Vietnamština",
"zh-CN": "čeština (CS)",
"zh-HK": "čínština (tradiční)"
}
+13
View File
@@ -1,18 +1,31 @@
{ {
"ADS Config": "Konfigurace ADS",
"Announcement Management": "Správa oznámení", "Announcement Management": "Správa oznámení",
"Apple": "Apple ID",
"Auth Control": "Řízení ověřování",
"Coupon Management": "Správa kupónů", "Coupon Management": "Správa kupónů",
"Dashboard": "Přístrojová deska", "Dashboard": "Přístrojová deska",
"Device": "Zařízení",
"Document Management": "Správa dokumentů", "Document Management": "Správa dokumentů",
"Email": "E-mail",
"Facebook": "Facebook",
"Finance": "Finance", "Finance": "Finance",
"General": "Obecné",
"GitHub": "GitHub",
"Google": "Google",
"Order Management": "Správa objednávek", "Order Management": "Správa objednávek",
"Payment Config": "Konfigurace platby", "Payment Config": "Konfigurace platby",
"Phone Number": "Telefonní číslo",
"Server": "Server", "Server": "Server",
"Server Management": "Správa serveru", "Server Management": "Správa serveru",
"Settings": "Nastavení", "Settings": "Nastavení",
"Subscribe Management": "Správa předplatného", "Subscribe Management": "Správa předplatného",
"System Config": "Systémová konfigurace", "System Config": "Systémová konfigurace",
"System Tool": "Systémový nástroj", "System Tool": "Systémový nástroj",
"Telegram": "Telegram",
"Ticket Management": "Správa lístků", "Ticket Management": "Správa lístků",
"Twitter": "Twitter",
"User": "Uživatel", "User": "Uživatel",
"User Detail": "Podrobnosti o uživateli",
"User Management": "Správa uživatelů" "User Management": "Správa uživatelů"
} }
+32
View File
@@ -0,0 +1,32 @@
{
"accessLabel": "Přístup",
"applyPlatform": "Použít platformu",
"enable": "Povolit",
"enableTip": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
"endpointLabel": "Koncový bod",
"logs": "Protokoly",
"phoneNumberLabel": "Telefonní číslo",
"placeholders": {
"template": "Váš ověřovací kód je {code}, platný po dobu 5 minut",
"templateCode": "Zadejte kód šablony"
},
"platform": "SMS platforma",
"platformConfigTip": "Vyplňte prosím poskytnutou konfiguraci {key}",
"platformTip": "Vyberte prosím platformu pro SMS",
"secretLabel": "Tajný klíč",
"sendFailed": "Odeslání se nezdařilo",
"sendSuccess": "Úspěšně odesláno",
"settings": "Nastavení",
"signNameLabel": "Název podpisu",
"template": "Šablona SMS",
"templateCodeLabel": "Kód šablony",
"templateTip": "Prosím vyplňte SMS šablonu, ponechte {code} uprostřed, jinak SMS funkce nebude fungovat",
"testSms": "Odeslat testovací SMS",
"testSmsPhone": "Zadejte telefonní číslo",
"testSmsTip": "Odeslat testovací SMS pro ověření vaší konfigurace",
"updateSuccess": "Aktualizace úspěšná",
"whitelistAreaCode": "Kód oblasti v bílém seznamu",
"whitelistAreaCodeTip": "Zadejte kódy oblastí v bílém seznamu, např. 1, 852, 886, 888",
"whitelistValidation": "Ověření bílého seznamu",
"whitelistValidationTip": "Pokud je povoleno, mohou SMS odesílat pouze kódy oblastí v bílém seznamu"
}
+27 -1
View File
@@ -1,4 +1,24 @@
{ {
"config": {
"addTimeSlot": "Přidat časový úsek",
"communicationKey": "Komunikační klíč",
"communicationKeyDescription": "Klíč pro komunikaci uzlů k zajištění bezpečnosti dat",
"delete": "Smazat",
"dynamicMultiplier": "Dynamický multiplikátor",
"dynamicMultiplierDescription": "Dynamický multiplikátor označuje zpracování posílaných dat s různými multiplikátory pro různé uzly během různých časových období.",
"endTime": "Konec",
"inputPlaceholder": "Zadejte",
"multiplier": "Multiplikátor",
"nodePullInterval": "Interval stahování uzlu",
"nodePullIntervalDescription": "Frekvence (v sekundách) pro stahování dat uzly z panelu",
"nodePushInterval": "Interval odesílání uzlu",
"nodePushIntervalDescription": "Frekvence odesílání dat uzly do panelu",
"reset": "Resetovat",
"save": "Uložit",
"saveSuccess": "Úspěšně uloženo",
"startTime": "Začátek",
"timeSlot": "Časový úsek"
},
"group": { "group": {
"actions": "Akce", "actions": "Akce",
"cancel": "Zrušit", "cancel": "Zrušit",
@@ -46,17 +66,19 @@
"form": { "form": {
"allowInsecure": "Povolit nezabezpečené", "allowInsecure": "Povolit nezabezpečené",
"cancel": "Zrušit", "cancel": "Zrušit",
"city": "Město",
"confirm": "Potvrdit", "confirm": "Potvrdit",
"country": "Země",
"edit": "Upravit", "edit": "Upravit",
"editSecurity": "Upravit nastavení zabezpečení", "editSecurity": "Upravit nastavení zabezpečení",
"enableTLS": "Povolit TLS", "enableTLS": "Povolit TLS",
"encryptionMethod": "Metoda šifrování", "encryptionMethod": "Metoda šifrování",
"flow": "Algoritmus řízení toku", "flow": "Algoritmus řízení toku",
"groupId": "Skupina uzlů",
"hopInterval": "Interval skoků", "hopInterval": "Interval skoků",
"hopPorts": "Porty skoků", "hopPorts": "Porty skoků",
"hopPortsPlaceholder": "Více portů oddělených čárkou", "hopPortsPlaceholder": "Více portů oddělených čárkou",
"name": "Název", "name": "Název",
"nodeGroupId": "ID skupiny uzlů",
"obfsPassword": "Heslo pro zmatení", "obfsPassword": "Heslo pro zmatení",
"obfsPasswordPlaceholder": "Nechte prázdné, pokud nechcete zmást", "obfsPasswordPlaceholder": "Nechte prázdné, pokud nechcete zmást",
"path": "Cesta", "path": "Cesta",
@@ -100,6 +122,8 @@
"serverName": "Název služby", "serverName": "Název služby",
"speedLimit": "Omezení rychlosti", "speedLimit": "Omezení rychlosti",
"speedLimitPlaceholder": "Bez omezení", "speedLimitPlaceholder": "Bez omezení",
"tags": "Štítky",
"tagsPlaceholder": "Použijte Enter nebo čárku (,) pro zadání více štítků",
"trafficRatio": "Poměr provozu", "trafficRatio": "Poměr provozu",
"transport": "Přenosový protokol", "transport": "Přenosový protokol",
"transportConfig": "Nastavení přenosového protokolu", "transportConfig": "Nastavení přenosového protokolu",
@@ -120,6 +144,7 @@
"serverAddr": "Adresa serveru", "serverAddr": "Adresa serveru",
"speedLimit": "Omezení rychlosti", "speedLimit": "Omezení rychlosti",
"status": "Stav", "status": "Stav",
"tags": "Štítky",
"trafficRatio": "Poměr provozu", "trafficRatio": "Poměr provozu",
"type": "Typ", "type": "Typ",
"updateSuccess": "Úspěšně aktualizováno", "updateSuccess": "Úspěšně aktualizováno",
@@ -127,6 +152,7 @@
}, },
"tabs": { "tabs": {
"node": "Uzlu", "node": "Uzlu",
"nodeConfig": "Konfigurace uzlu",
"nodeGroup": "Skupina uzlů" "nodeGroup": "Skupina uzlů"
} }
} }
+62 -5
View File
@@ -1,6 +1,60 @@
{ {
"actions": "akce", "actions": "akce",
"app": {
"appDownloadURL": "URL pro stažení aplikace",
"appIcon": "Ikona aplikace",
"appList": "Seznam aplikací",
"appName": "Název aplikace",
"backupDomains": "Seznam záložních domén",
"backupDomainsDescription": "Seznam záložních domén pro řešení domén, jedna doména na řádek",
"batchDelete": "Hromadné smazání",
"cancel": "Zrušit",
"communicationKey": "Komunikační klíč",
"communicationKeyDescription": "Klíč používaný pro komunikaci klienta",
"config": "Konfigurace",
"configApp": "Konfigurace aplikace",
"confirm": "Potvrdit",
"confirmDelete": "Potvrdit smazání",
"create": "Vytvořit",
"createApp": "Vytvořit aplikaci",
"createSuccess": "Úspěšně vytvořeno",
"defaultVersion": "Výchozí",
"delete": "Smazat",
"deleteWarning": "Tuto akci nelze vrátit zpět",
"describeDescription": "Používá se k popisu aplikace, zobrazuje se v seznamu aplikací",
"description": "Popis",
"downloadLink": "Odkaz ke stažení",
"edit": "Upravit",
"editApp": "Upravit aplikaci",
"encryption": "Metoda šifrování",
"encryptionDescription": "Vyberte metodu šifrování pro komunikaci klienta. Pokud je vybrána, klient použije tuto metodu ke komunikaci se serverem",
"nameDescription": "Název aplikace, zobrazuje se v seznamu aplikací",
"platform": "Platforma",
"selectApp": "Vybrat aplikaci",
"selectAppDescription": "Vyberte aplikaci k nastavení, všechna nastavení se použijí na vybranou aplikaci",
"startupPicture": "Úvodní obrázek",
"startupPictureDescription": "Úvodní obrázek, podporuje síťové a lokální obrázky. Pro síťové obrázky zadejte úplnou URL adresu obrázku",
"startupPicturePreview": "Náhled úvodního obrázku",
"startupPictureSkip": "Čas přeskočení úvodního obrázku",
"startupPictureSkipDescription": "Doba zobrazení úvodního obrázku v sekundách, zadejte 0 pro nezobrazení",
"subscriptionProtocol": "Protokol předplatného",
"updateSuccess": "Úspěšně aktualizováno",
"version": "Verze"
},
"cancel": "Zrušit", "cancel": "Zrušit",
"config": {
"singleSubscriptionMode": "Režim jednorázového předplatného",
"singleSubscriptionModeDescription": "Když je povoleno, všechna uživatelská předplatná budou převedena na zůstatek",
"subscriptionDomain": "Doména předplatného",
"subscriptionDomainDescription": "Používá se pro předplatné; nechte prázdné pro použití domény webu",
"subscriptionDomainPlaceholder": "Zadejte doménu předplatného, jednu na řádek",
"subscriptionPath": "Cesta předplatného",
"subscriptionPathDescription": "Používá se pro předplatné; po úpravě nezapomeňte restartovat systém pro optimální výkon",
"subscriptionPathPlaceholder": "Zadejte",
"updateSuccess": "Úspěšně aktualizováno",
"wildcardResolution": "Řešení zástupných znaků",
"wildcardResolutionDescription": "Používá se pro předplatné"
},
"confirm": "Potvrdit", "confirm": "Potvrdit",
"confirmDelete": "Opravdu chcete smazat?", "confirmDelete": "Opravdu chcete smazat?",
"copy": "Kopírovat", "copy": "Kopírovat",
@@ -35,14 +89,15 @@
"discountPercent": "Procento slevy", "discountPercent": "Procento slevy",
"discount_price": "Cena po slevě", "discount_price": "Cena po slevě",
"duration": "Doba trvání (měsíce)", "duration": "Doba trvání (měsíce)",
"inventory": "Sklad", "groupId": "Skupina předplatného",
"inventory": "Limit předplatného",
"monthlyReset": "Měsíční Reset", "monthlyReset": "Měsíční Reset",
"name": "Název", "name": "Název",
"noLimit": "Bez omezení", "noLimit": "Bez omezení",
"noReset": "Žádný Reset", "noReset": "Žádný Reset",
"pricing": "Cenotvorba", "pricing": "Cenotvorba",
"purchaseWithDiscount": "Povolit Odpočet", "purchaseWithDiscount": "Povolit Odpočet",
"purchaseWithDiscountDescription": "Povolit nebo zakázat funkci odpočtu. Když je povolena, systém zpracová odpočty na základě nakonfigurovaných pravidel a poměrů", "purchaseWithDiscountDescription": "Povolit nebo zakázat funkci odhlášení. Po aktivaci systém provede zpracová odpočtu podle nakonfigurovaných pravidel a poměrů a zbývající hodnota bude vrácena na zůstatek.",
"quota": "Limit nákupu", "quota": "Limit nákupu",
"renewalReset": "Reset při Obnovení", "renewalReset": "Reset při Obnovení",
"renewalResetDescription": "Resetovací cyklus při obnovení", "renewalResetDescription": "Resetovací cyklus při obnovení",
@@ -55,8 +110,7 @@
"server": "Server", "server": "Server",
"serverGroup": "Skupina serverů", "serverGroup": "Skupina serverů",
"servers": "Servery", "servers": "Servery",
"speedLimit": "Omezení rychlosti (Mbps)", "speedLimit": "Omezení rychlosti ",
"subscribeGroup": "Skupina předplatného",
"traffic": "Přenos dat", "traffic": "Přenos dat",
"unitPrice": "Jednotková cena", "unitPrice": "Jednotková cena",
"unitTime": "Jednotka času" "unitTime": "Jednotka času"
@@ -86,16 +140,19 @@
"updateSuccess": "Úspěšně aktualizováno", "updateSuccess": "Úspěšně aktualizováno",
"updatedAt": "Čas aktualizace" "updatedAt": "Čas aktualizace"
}, },
"inventory": "inventář", "inventory": "Limit předplatného",
"name": "název", "name": "název",
"quota": "Limit na nákup/čas", "quota": "Limit na nákup/čas",
"replacement": "Obnovení ceny/krát", "replacement": "Obnovení ceny/krát",
"sell": "Prodej", "sell": "Prodej",
"show": "Zobrazit na hlavní stránce", "show": "Zobrazit na hlavní stránce",
"sold": "Počet předplatných",
"subscribe": "Předplatit", "subscribe": "Předplatit",
"subscribeGroup": "Přihlásit se ke skupině", "subscribeGroup": "Přihlásit se ke skupině",
"tabs": { "tabs": {
"subscribe": "Předplatit", "subscribe": "Předplatit",
"subscribeApp": "Konfigurace aplikace",
"subscribeConfig": "Konfigurace předplatného",
"subscribeGroup": "Skupina předplatných" "subscribeGroup": "Skupina předplatných"
}, },
"traffic": "provoz", "traffic": "provoz",
+8 -134
View File
@@ -8,83 +8,15 @@
"currencyUnitDescription": "Pouze pro zobrazení, po změně se změní všechny měnové jednotky v systému", "currencyUnitDescription": "Pouze pro zobrazení, po změně se změní všechny měnové jednotky v systému",
"saveSuccess": "Úspěšně uloženo" "saveSuccess": "Úspěšně uloženo"
}, },
"email": { "privacy-policy": {
"expiration_email_template": "Šablona oznámení o vypršení platnosti", "saveSuccess": "Uložení bylo úspěšné",
"expiration_email_templateDescription": "Proměnné {after}.názevProměnné{before} v šabloně budou nahrazeny skutečnými daty, ujistěte se, že tyto proměnné zachováte.", "title": "Zásady ochrany osobních údajů"
"inputPlaceholder": "Zadejte obsah...",
"maintenance_email_template": "Šablona oznámení o údržbě",
"maintenance_email_templateDescription": "Proměnné {after}.názevProměnné{before} v šabloně budou nahrazeny skutečnými daty, ujistěte se, že tyto proměnné zachováte.",
"saveSuccess": "Konfigurace byla úspěšně uložena.",
"sendFailure": "Odeslání testovacího e-mailu selhalo, zkontrolujte konfiguraci.",
"sendSuccess": "Testovací e-mail byl úspěšně odeslán.",
"sendTestEmail": "Odeslat testovací e-mail",
"sendTestEmailDescription": "Odešlete testovací e-mail pro ověření správnosti konfigurace.",
"senderAddress": "Adresa odesílatele",
"senderAddressDescription": "Výchozí e-mailová adresa použitá k odesílání e-mailů.",
"smtpAccount": "Účet SMTP",
"smtpAccountDescription": "E-mailový účet pro ověření identity.",
"smtpEncryptionMethod": "Metoda šifrování SMTP",
"smtpEncryptionMethodDescription": "Vyberte, zda povolit šifrování SSL/TLS.",
"smtpPassword": "Heslo SMTP",
"smtpPasswordDescription": "Heslo k účtu SMTP.",
"smtpServerAddress": "Adresa serveru SMTP",
"smtpServerAddressDescription": "Zadejte adresu serveru pro odesílání e-mailů.",
"smtpServerPort": "Port serveru SMTP",
"smtpServerPortDescription": "Zadejte číslo portu pro připojení k serveru SMTP.",
"verify_email_template": "Šablona ověřovacího e-mailu",
"verify_email_templateDescription": "Proměnné {after}.názevProměnné{before} v šabloně budou nahrazeny skutečnými daty, ujistěte se, že tyto proměnné zachováte."
},
"invite": {
"commissionFirstTimeOnly": "Provize pouze při prvním nákupu",
"commissionFirstTimeOnlyDescription": "Po aktivaci se provize generuje pouze při první platbě pozvaného uživatele. Můžete konfigurovat jednotlivé uživatele ve správě uživatelů.",
"enableForcedInvite": "Povolit povinné pozvání",
"enableForcedInviteDescription": "Po aktivaci se mohou registrovat pouze pozvaní uživatelé.",
"inputPlaceholder": "Prosím zadejte",
"inviteCommissionPercentage": "Procento provize za pozvání",
"inviteCommissionPercentageDescription": "Výchozí globální procento rozdělení provize, můžete konfigurovat jednotlivé procento ve správě uživatelů.",
"saveSuccess": "Úspěšně uloženo"
},
"node": {
"addTimeSlot": "Přidat časový úsek",
"communicationKey": "Komunikační klíč",
"communicationKeyDescription": "Klíč pro komunikaci uzlu, aby se zajistilo, že data nebudou zachycena jinými osobami",
"delete": "Smazat",
"dynamicMultiplier": "Dynamický násobitel",
"dynamicMultiplierDescription": "Dynamický násobitel označuje zpracování posunutých dat s různými násobiteli pro různé uzly během různých časových období.",
"endTime": "Čas konce",
"inputPlaceholder": "Prosím zadejte",
"multiplier": "Násobitel",
"nodePullInterval": "Interval stahování uzlu",
"nodePullIntervalDescription": "Frekvence, s jakou uzel získává data z panelu (v sekundách)",
"nodePushInterval": "Interval odesílání uzlu",
"nodePushIntervalDescription": "Frekvence, s jakou uzel odesílá data do panelu",
"reset": "Resetovat",
"save": "Uložit",
"saveSuccess": "Úspěšně uloženo",
"startTime": "Čas začátku",
"timeSlot": "Časový úsek"
},
"register": {
"emailSuffixWhitelist": "Seznam povolených přípon e-mailů",
"emailSuffixWhitelistDescription": "Po aktivaci se mohou registrovat pouze e-maily s příponami uvedenými v seznamu",
"emailVerification": "Ověření e-mailu",
"emailVerificationDescription": "Po aktivaci budou uživatelé muset ověřit svůj e-mail",
"ipRegistrationLimit": "Omezení registrace podle IP",
"ipRegistrationLimitDescription": "Po aktivaci budou IP adresy, které splňují pravidla, omezeny v registraci. Upozorňujeme, že kvůli CDN nebo front-end proxy může dojít k problémům s určením IP adresy",
"penaltyTime": "Doba trestu (minuty)",
"penaltyTimeDescription": "Uživatelé musí počkat, až uplyne doba trestu, než se budou moci znovu registrovat",
"registrationLimitCount": "Počet omezení registrace",
"registrationLimitCountDescription": "Po dosažení limitu registrace se aktivuje trest",
"saveSuccess": "Úspěšně uloženo",
"stopNewUserRegistration": "Zastavit registraci nových uživatelů",
"stopNewUserRegistrationDescription": "Po aktivaci se nikdo nebude moci registrovat",
"trialRegistration": "Zkušební registrace",
"trialRegistrationDescription": "Aktivujte zkušební registraci, nejprve upravte zkušební balíček a dobu trvání",
"whitelistSuffixes": "Povolené přípony",
"whitelistSuffixesDescription": "Používá se pro ověření e-mailu při registraci, jedna přípona na řádek",
"whitelistSuffixesPlaceholder": "Zadejte přípony e-mailů, jednu na řádek"
}, },
"site": { "site": {
"customHtml": "Vlastní HTML",
"customHtmlDescription": "Vlastní HTML kód, který bude vložen na konec tagu body na stránce.",
"keywords": "Klíčová slova",
"keywordsDescription": "Používá se pro SEO účely",
"logo": "LOGO", "logo": "LOGO",
"logoDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit LOGO", "logoDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit LOGO",
"logoPlaceholder": "Zadejte URL adresu LOGA, nekončete '/'", "logoPlaceholder": "Zadejte URL adresu LOGA, nekončete '/'",
@@ -99,71 +31,13 @@
"siteNameDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit název webu", "siteNameDescription": "Používá se k zobrazení místa, kde je potřeba zobrazit název webu",
"siteNamePlaceholder": "Zadejte název webu" "siteNamePlaceholder": "Zadejte název webu"
}, },
"subscription": {
"add": "Přidat",
"app": "Aplikace",
"appDescription": "Nastavte adresu pro stažení aplikace a pravidla importu, nezapomeňte po úpravách uložit",
"appDownloadURL": "Adresa pro stažení aplikace",
"appIcon": "Ikona aplikace",
"appName": "Název aplikace",
"delete": "Smazat",
"reset": "Resetovat",
"save": "Uložit",
"saveSuccess": "Úspěšně uloženo",
"singleSubscriptionMode": "Režim jednorázového předplatného",
"singleSubscriptionModeDescription": "Po aktivaci se všechny uživatelské balíčky převedou na zůstatek",
"subscriptionDomain": "Doména předplatného",
"subscriptionDomainDescription": "Používá se pro předplatné, pokud je prázdné, použije se doména webu",
"subscriptionDomainPlaceholder": "Zadejte doménu předplatného, pro více domén použijte jeden řádek pro každou",
"subscriptionPath": "Cesta předplatného",
"subscriptionPathDescription": "Používá se pro předplatné, po úpravě je nutné restartovat systém pro zajištění nejlepšího výkonu",
"subscriptionPathPlaceholder": "Zadejte",
"subscriptionProtocol": "Protokol předplatného",
"wildcardResolution": "Řešení zástupných znaků",
"wildcardResolutionDescription": "Používá se pro předplatné"
},
"tabs": { "tabs": {
"currency": "Měna", "currency": "Měna",
"email": "E-mail",
"invite": "Pozvat",
"node": "Uzlový bod",
"register": "Registrace",
"site": "Stránka", "site": "Stránka",
"subscription": "Předplatné", "tos": "Podmínky služby"
"telegram": "Telegram",
"tos": "Podmínky služby",
"verify": "Ověřit"
},
"telegram": {
"botToken": "Token robota",
"botTokenDescription": "Zadejte token poskytnutý Botfatherem",
"enableBotNotifications": "Povolit oznámení robota",
"enableBotNotificationsDescription": "Po povolení bude robot posílat základní oznámení správcům a uživatelům propojeným s Telegramem",
"groupURL": "URL skupiny",
"groupURLDescription": "Po vyplnění se zobrazí na uživatelské straně nebo tam, kde je potřeba",
"inputPlaceholderBotToken": "0000000000:xxxxxxxxx_xxxxxxxxxxxxxxx",
"inputPlaceholderGroupURL": "https://t.me/xxxxxx",
"inputPlaceholderWebhookDomain": "https://example.com",
"saveSuccess": "Úspěšně uloženo",
"webhookDomain": "Doména webhooku",
"webhookDomainDescription": "Zadejte název domény serveru webhooku"
}, },
"tos": { "tos": {
"saveSuccess": "Uložení bylo úspěšné", "saveSuccess": "Uložení bylo úspěšné",
"title": "Podmínky služby" "title": "Podmínky služby"
},
"verify": {
"inputPlaceholder": "Prosím zadejte",
"loginVerificationCode": "Ověřovací kód pro přihlášení",
"loginVerificationCodeDescription": "Ověření člověka při přihlášení",
"registrationVerificationCode": "Ověřovací kód pro registraci",
"registrationVerificationCodeDescription": "Ověření člověka při registraci",
"resetPasswordVerificationCode": "Ověřovací kód pro resetování hesla",
"resetPasswordVerificationCodeDescription": "Ověření člověka při resetování hesla",
"saveSuccess": "Úspěšně uloženo",
"turnstileSecret": "Turnstile tajný klíč",
"turnstileSecretDescription": "Turnstile tajný klíč poskytovaný Cloudflare",
"turnstileSiteKey": "Turnstile klíč webu",
"turnstileSiteKeyDescription": "Turnstile klíč webu poskytovaný Cloudflare"
} }
} }
+10
View File
@@ -0,0 +1,10 @@
{
"clientId": "ID robota",
"clientIdDescription": "ID Telegram bota, které můžete získat od @BotFather",
"clientSecret": "Token robota",
"clientSecretDescription": "Telegram Bot Token, který můžete získat od @BotFather",
"enable": "Povolit",
"enableDescription": "Po povolení budou povoleny funkce registrace, přihlášení, připojení a odpojení mobilního telefonu",
"saveFailed": "Uložení se nezdařilo",
"saveSuccess": "Uložení bylo úspěšné"
}
+87 -17
View File
@@ -1,38 +1,108 @@
{ {
"accountEnable": "Povolení účtu",
"actions": "akce", "actions": "akce",
"add": "Přidat",
"administrator": "Administrátor",
"areaCode": "Směrový kód",
"areaCodePlaceholder": "Kód oblasti",
"auth": "Autentizace",
"authIdentifier": "Identifikátor ověření",
"authIdentifierPlaceholder": "Zadejte identifikátor ověření",
"authMethodsTitle": "Nastavení ověřování",
"authType": "Typ ověření",
"authUser": "Autentizovaný uživatel",
"avatar": "Avatar",
"balance": "Zůstatek", "balance": "Zůstatek",
"balanceNotifications": "Oznámení o změně zůstatku",
"balancePlaceholder": "Zůstatek",
"basicInfoTitle": "Základní informace",
"cancel": "zrušit", "cancel": "zrušit",
"commission": "Provize",
"commissionPlaceholder": "Zadejte provizi",
"confirm": "Potvrdit", "confirm": "Potvrdit",
"confirmDelete": "Opravdu chcete smazat?", "confirmDelete": "Opravdu chcete smazat?",
"confirmOffline": "Potvrdit offline",
"create": "Vytvořit", "create": "Vytvořit",
"createSubscription": "Vytvořit předplatné",
"createSuccess": "Vytvoření úspěšné", "createSuccess": "Vytvoření úspěšné",
"createUser": "Vytvořit uživatele", "createUser": "Vytvořit uživatele",
"createdAt": "Datum registrace", "createdAt": "Datum registrace",
"delete": "smazat", "delete": "smazat",
"deleteDescription": "Po odstranění nelze data obnovit, prosím, postupujte opatrně.", "deleteDescription": "Po odstranění nelze data obnovit, prosím, postupujte opatrně.",
"deleteSubscriptionDescription": "Opravdu chcete smazat toto předplatné?",
"deleteSuccess": "Úspěšně odstraněno", "deleteSuccess": "Úspěšně odstraněno",
"detail": "Detail",
"deviceLimit": "Limit zařízení",
"download": "Stáhnout",
"downloadTraffic": "Stáhnout provoz",
"edit": "Upravit", "edit": "Upravit",
"editSubscription": "Upravit předplatné",
"editUser": "Upravit uživatele", "editUser": "Upravit uživatele",
"email": "e-mail", "email": "e-mail",
"enable": "Povolit", "enable": "Povolit",
"form": { "expireTime": "Čas vypršení",
"balance": "Zůstatek", "expiredAt": "Platnost vypršela",
"balancePlaceholder": "Zůstatek", "failed": "Neúspěch",
"cancel": "Zrušit", "giftAmount": "Částka dárku",
"confirm": "Potvrdit", "giftAmountPlaceholder": "Zadejte částku dárku",
"invalidEmailFormat": "Neplatný formát e-mailu", "invalidEmailFormat": "Neplatný formát e-mailu",
"inviteCode": "Pozvánkový kód", "inviteCode": "Pozvánkový kód",
"inviteCodePlaceholder": "Zadejte pozvánkový kód (ponechte prázdné pro automatické generování)", "inviteCodePlaceholder": "Zadejte kód pozvánky (ponechte prázdné pro vygenerování)",
"manager": "Správce", "kickOfflineConfirm": "Opravdu chcete odpojit IP {ip}?",
"password": "Heslo", "kickOfflineSuccess": "Úspěšně oznámeno odpojení",
"passwordPlaceholder": "Zadejte nové heslo (může zůstat prázdné)", "lastSeen": "Naposledy viděno",
"refererId": "ID doporučitele", "loading": "Načítání...",
"refererIdPlaceholder": "Zadejte ID doporučitele", "loginIp": "Přihlašovací IP",
"userEmail": "E-mail uživatele", "loginNotifications": "Oznámení o přihlášení",
"userEmailPlaceholder": "Zadejte e-mail uživatele" "loginStatus": "Stav přihlášení",
}, "loginTime": "Čas přihlášení",
"manager": "Manažer",
"notifySettingsTitle": "Nastavení oznámení",
"onlineDevices": "Online zařízení",
"password": "Heslo",
"passwordPlaceholder": "Zadejte nové heslo (volitelné)",
"permanent": "Trvalý",
"pleaseEnterEmail": "Prosím, zadejte e-mail",
"referer": "Doporučitel", "referer": "Doporučitel",
"refererId": "ID doporučitele",
"refererIdPlaceholder": "Zadejte ID doporučitele",
"referralCode": "Doporučovací kód",
"referrerUserId": "Odesílatel (uživatelské ID)",
"remove": "Odstranit",
"resetTime": "Obnovit čas",
"save": "Uložit",
"searchIp": "Vyhledat IP adresu",
"selectAuthType": "Vyberte typ ověření",
"speedLimit": "Rychlostní limit",
"subscription": "Předplatné",
"subscriptionDetails": "Podrobnosti o předplatném",
"subscriptionList": "Seznam předplatných",
"subscriptionLogs": "Záznamy o předplatném",
"subscriptionName": "Název předplatného",
"subscriptionNotifications": "Oznámení o předplatném",
"success": "Úspěch",
"telephone": "Telefonní číslo",
"telephonePlaceholder": "Zadejte telefonní číslo",
"time": "Čas",
"token": "Token",
"totalTraffic": "Celkový provoz",
"tradeNotifications": "Obchodní oznámení",
"trafficLimit": "Limit provozu",
"trafficLogs": "Protokoly provozu",
"unlimited": "Neomezený",
"unverified": "Neověřeno",
"update": "Aktualizovat",
"updateSuccess": "Aktualizace byla úspěšná", "updateSuccess": "Aktualizace byla úspěšná",
"upload": "Nahrát",
"uploadTraffic": "Nahrát provoz",
"userAgent": "Uživatelský agent",
"userEmail": "Uživatelský e-mail",
"userEmailPlaceholder": "Zadejte e-mail uživatele",
"userList": "Seznam uživatelů", "userList": "Seznam uživatelů",
"userName": "Uživatelský e-mail" "userLogs": "Historie přihlášení",
"userName": "Identifikátor uživatele",
"userOrders": "Objednávky",
"userProfile": "Profil",
"userSubscriptions": "Předplatné",
"verified": "Ověřeno"
} }

Some files were not shown because too many files have changed in this diff Show More