diff --git a/CHANGELOG/CHANGELOG-5.0.0.md b/CHANGELOG/CHANGELOG-5.0.0.md new file mode 100644 index 00000000000..81702f81dfa --- /dev/null +++ b/CHANGELOG/CHANGELOG-5.0.0.md @@ -0,0 +1,130 @@ +Welcome to the v5.0.0 release of Sealos!🎉🎉! + + + +## Changelog +### New Features +* e19143794992d526fcd0775f455b1157f0081a4e: feat(applaunchpad): support file browser for pv (#4674) (@0fatal) +* 18464891fca36fd623da4f17faff2a035343dfa6: feat(desktop):add inviting others into workspace by link (#4712) (@xudaotutou) +* b81ec5fe785a3a233490151a20ec7111e6621612: feat(docs): update private pricing on website (#4846) (@zjy365) +* a1442f9d29d12ee22a68fbfbc4f32aacfecf2110: feat: add a function to retrieve the user's backup size. (#4780) (@nowinkeyy) +* 6db3f2b4161e672f3643041085bc2998243f4270: feat: add cluster type license (#4752) (@lingdie) +* 835e22457b23e9be7700a11734d41c694231818a: feat: add providers workorder app (#4718) (@zjy365) +* 708e977d11b50680dda2b481b05cc17748a5ab51: feat: app cost svc (#4843) (@bxy4543) +* d4040594a3cd148bf832651d435cf68d501db689: feat: cloud host supports GPU (#4728) (@zjy365) +* 4b2d3c0e72851ff5573d0c85f32450aa526df7e3: feat: desktop support controlling the size of the evoked window (#4700) (@zjy365) +* a1982f0868bc4262625ca7b5dbe79fcb051f538d: feat: impl cronjob show envs (#4833) (@zijiren233) +* 469e42c20ffcfd032b40a00f865e0e688defb6f9: feat: implement desktop 5.0 UI design (#4783) (@zjy365) +* f3ab3fae684612df0c9015aa5c0cde9d51150c6e: feat: improve animation smoothness for expanding and collapsing sections (#4847) (@zijiren233) +* b109ffe2f10db323938629dce20cd4a4fe0c0488: feat: optimize resource monitoring logic, performance and fix nil pointer issue (#4827) (@zijiren233) +* 4cba736bcba50803d0083be9bac97b54779fb0f2: feat: support configurable favicon (#4864) (@zjy365) +* 34e74b454b851c9698fe35e1169de29bbb543491: feat: support workorder migrate (#4782) (@zjy365) +* 3859de964955dda9a350ddbc5cda7f9f48c2cc6a: feat: upgrade cost center config. (#4756) (@lingdie) +* 02e12d646d47fdfb3304cadbc412b302ba2c6b80: feat: yearly/monthly billing for cloudserver (#4781) (@zjy365) +* f0e17bc07ea8b512cd0895022e3176425b607b3c: feat:add cloudserver app (#4698) (@zjy365) +* e8950db28214fc9f690b91b3fd8a4fc9c9246d96: feat:docs add recharge event (#4784) (@zjy365) +* 4ad5ea1aef290be85260dc64660b961e19933b32: feat:license app adds system node information (#4845) (@zjy365) +### Bug fixes +* add7316542a417b8ab0b1452c76873be60b38b50: fix(costcenter): remove bank account regex (#4713) (@xudaotutou) +* 9c178471c7e72b5beb2a9736a20b45fb4fda0fe4: fix(costcenter): update amount by the query (#4737) (@xudaotutou) +* 950e6cb44eb03f6e097a501a80b160eefe9017fc: fix: Delete unnecessary logs (#4710) (@ghostloda) +* 74e80c5763bfea005969792b820f4b4366d78714: fix: add nodes after upgrading, the version installed on the newly added nodes is incorrect (#4857) (@yangxggo) +* 0ca8f84aadc152a0fa926fbb761a38dbb61e1433: fix: applaunchpad unhandledRejection promise (#4771) (@zjy365) +* 7c509b14bfefb9eaf54f2827190c226686437c1f: fix: db backup label encodeToHex (#4792) (@zjy365) +* c4af244fa180b58a9b3068b94ebf7d9d92c42dbf: fix: db resource quota i18n (#4865) (@zijiren233) +* 2505b97f4102667c1fd65d583b6cd452ac8516ce: fix: default app (#4859) (@zijiren233) +* e119d8faef5c4bd9aa936e1a53ea1e1f6121a62d: fix: desktop notifications forbidden error (#4789) (@zjy365) +* 9ef2a3ed311bb7696c2836d78984c2776350b675: fix: ensure license issuance fields are of number type (#4873) (@zjy365) +* 8382b4b594e7ae157a77f1903c19aae1bf0de5cf: fix: scaledown processor checker error (#4815) (@ghostloda) +* 9d0437a95d5d27a55535ed911edfbde2507bb302: fix: sealos 5 cannot upgrade k8s from v1.27.x to v1.28.y (#4842) (@yangxggo) +* 134a975b96e7ef7d0ec316b27884585b73586c1e: fix: skip renew certificate (#4880) (@ghostloda) +* b5a1ef63b7b16f180d648fa578cf790bdb410416: fix: upgrade from k8s v1.26.x to v1.27.y, kubelet restart failed (#4850) (@yangxggo) +* 18be6fe4d903f3982900f07c80bea40853c9a109: fix:add check containerd (#4748) (@ghostloda) +* 5968bae3f03db7bf520294c497ca20264b0253d2: fix:cloudserver bandwidth price display (#4734) (@zjy365) +* 7b5fdf221c342f7e65611a5cd706dca78fa53bbb: fix:db podAntiAffinity Preferred (#4701) (@zjy365) +* 733beccad1ff3c7da3b35f92467823db9516d166: fix:fix transfer (#4804) (@xudaotutou) +* 02a7510c6d28faed82904c61eeae3588cf2b023f: fix:fix transfer billing (#4837) (@xudaotutou) +* bca847623a2c821c184d42fcf165c6fc5b8059e6: fix:launchpad checkNetworkPorts (#4724) (@zjy365) +* c723346d6ef16d9cc32feec91c46e4043c14eb1f: fix:launchpad config yaml boolean (#4715) (@zjy365) +* 0707cb5074ca096db4ce2954dd8a521e4524f8d1: fix:template frontend leaves zombie git processes (#4799) (@zjy365) +### Documentation updates +* 29fb771de5298272254b6e04e593cc2e6f4e4e2d: doc(Frontend): Rename READMD.md to README.md (#4755) (@luoling8192) +* 1c6f9500aced8c487e1e4d9ce5e81f385cd109ba: doc: add a doc for deploying Object Storage (#4777) (@nowinkeyy) +* c6e037670683209260d5e0ebb966354c7f08cedf: doc: move objectstorage install doc (#4835) (@nowinkeyy) +### Other work +* b4187c53b61f384ca21d36e4b4b3d1de7688b35a: Add clear mongo log (#4757) (@wallyxjh) +* 0041eedb17805bea35ccd3d4cda725194dc4b778: Add default install desktop apps. (#4838) (@zzjin) +* f04556bed1fe47332d9ad8286f308c4e9bb5db63: Change object storage monitor service from prometheus to vm (#4727) (@nowinkeyy) +* aec5facb360ef39d66029f4677a6751a5335e0b8: Database exceptions monitor (#4830) (@wallyxjh) +* 39926854fa70117cd634873ae0313a97611ff62f: Delete clear mongo log (#4806) (@wallyxjh) +* 1ad3c7185c5a88f9ddf3d7fdad96e141ffd24c62: Feat/ add vms/email debt notification (#4763) (@bxy4543) +* 8cb7b734000b94dc59e0b9a9722c1fbc466562e5: Feat: Cloud Virtual Machine Billing (#4699) (@bxy4543) +* 694ddb97645f30166e57aec31d9ff9ce469ab7c4: Feat: impl cronjob envs (#4821) (@zijiren233) +* afb2584ddcebf4d4c1655209704d1647152866e1: Fix desktop config (#4733) (@lingdie) +* 4d02f7661760c2a5fc140697bd95867bf14ea35e: Fix desktop password salt (#4762) (@lingdie) +* 2138f4656ed70f2d9c4a14ccdc8c7ce1a9ab4c0d: Fix doc link 404. (#4743) (@zzjin) +* 49e6422e89b7a11a47631e62c8291a5df870b0d9: Fix kubepanel typo&link. (#4729) (@zzjin) +* 13bd347c7e33097b304a2f159f08e79a6d517d0b: Fix/costcente svc (#4736) (@bxy4543) +* 18402ae8df78325f7fef7797b71f5ec6a8477885: Fix: remove deprecated rlcp options (#4803) (@zijiren233) +* 182fa028da5c04892f89e077ed95f5d650f8ef27: Fix: switch user register (#4813) (@zijiren233) +* 3b044f328db58acdd0381c0aec7809027e2d2025: Launchpad monitor (#4689) (@wallyxjh) +* 701612bdd0c5bbfe64b605513f4f2de1e6df1f16: Replace 404 doc to official link. (#4800) (@zzjin) +* 16c8524f7b05201aea5d3edcbd10bc41be3827ab: Stop clusters (#4644) (@wallyxjh) +* 372632d16ee8f06110399b00c4a7a7e54e31fcce: Update README.md (#4745) (@fanux) +* a7c1d9df0e86ea64fb54860f661b4c52a6ffec16: Update app menu types. (#4735) (@zzjin) +* 17c2b43ac55b1170a236e043d3ed007e0bb8aa14: Update appcr (#4742) (@lingdie) +* 6758e182d7dfafbb9779dffb072ea3056e1d0c79: Update cockroach version (#4844) (@wallyxjh) +* 469e9c9796df59f332ec006db3bd0c4effc5e68c: Update doc fireboom homepage link. (#4778) (@zzjin) +* 2d62a13d0e882a328c4dc0a1a9dde1f597f5f3bd: Update i18n for applaunchpad (#4851) (@yangchuansheng) +* 78cefc7715b6c0952f079cc6bbc1bd32e96adbcb: Update install.sh (#4791) (@lingdie) +* 7d5f5a154251ef5e5a6d0fda321c6c41e5ca7880: Update install.sh (#4812) (@lingdie) +* 28698d2d81ec0f48265c8d163f5a52f7fb47f012: Update sealos mongo role (#4823) (@wallyxjh) +* 85474f252f28e0392944f16c446b827c85f403d3: Update terminal frontend ,support customize keepalived time. (#4810) (@zzjin) +* c0bb1e72dd2564edef48e3b38d7c84719c76d6c4: add a key for a bucket (#4714) (@nowinkeyy) +* d11c5fd0cd464c37c3fe95e6ae5adc6a5065b0f0: add email debt notification (#4820) (@bxy4543) +* 89ada630ae6aa71fd569bebd2863e3f22da40911: add quotaEnabled and defaultQuota env (#4822) (@nowinkeyy) +* 6f5c5905b32ec33b36fed1b67ffc16ac9a9b227c: add set-cert doc. (#4772) (@nowinkeyy) +* 1cec0811ca614c3c1d6eb20205bda1f1896a6883: change cert duration. (#4768) (@lingdie) +* 7362d807e9c1612f97f1b9151a6701090431e338: change mongo 4.0 to 4.4 (#4708) (@lingdie) +* 47ba1b87d3adec8bbd48b1d1f557a36d1d65172b: docs: Update sealos version in self-hosting docs (#4683) (@yangchuansheng) +* ffd72f2c0f2b0dec391bcb13db5ebc2ee755b00d: docs: add README for license-system-frontend (#4868) (@zjy365) +* 123980df36875ae9a5b6b4fe7b8db02d89fcd515: docs: add blog "How To Deploy and Configure Meilisearch using Docker" (#4854) (@yangchuansheng) +* 24697342c516803b99f3c506cc2676ab887eafc5: docs: update header for landing page (#4875) (@yangchuansheng) +* 63ecc33d3c2cf70d9cdcd042837e9efaf95bb133: docs: update landing page (#4858) (@yangchuansheng) +* 85318ad38d687fd09298075b00353238918a0d7d: docs:remove HomeUserBy component (#4879) (@zjy365) +* 467a675d00994ba8dd7222fe3400ad88a4fd532c: feat(frontend/cloudserver): Add a quantity option when creating the v… (#4852) (@HUAHUAI23) +* daff4ac6f63a3f04938ef813c2d387b848b5aef7: feat. change desktop to use config file. (#4709) (@lingdie) +* c56ce7aebd2dd7eab3fdcb45764b513fdbd6ebbb: fix billing record query with app type (#4731) (@bxy4543) +* 786985e3cced44b14d8c1faa1018d208db671883: fix control-plane component status (#4749) (@ghostloda) +* dd1cfc9221c260fa2e4f0f9d344775f9aaeeb7bc: fix cost center frontend manifests (#4761) (@lingdie) +* 1d0ad5c5213e4915f196f7a9348a33c1e9cb65d2: fix object storage monitor (#4691) (@bxy4543) +* f6b1e76230978671d5ff52be4eb6d1cc232ccedc: fix objectstorage frontend deploy.yaml format error (#4725) (@nowinkeyy) +* a4b7f8a6b89dea9ed23a49952523a5a869c480a1: fix pull cert-manager image in install.sh (#4775) (@lingdie) +* 26739d9a6cee1afa95bae74e63c21a4f1b57ec5c: fix set account create region id (#4739) (@bxy4543) +* 9a4f321c1d19d9d0e58dd1569455f1b41316c091: i18n: update i18n for App Launchpad (#4754) (@yangchuansheng) +* 4960ec3d0fe0450c66bd5d464735468eb2d75672: i18n: update i18n for DBProvider (#4787) (@yangchuansheng) +* 4c3fac74d54699dac47d1e029547b34fce601c00: monitor svc multi nodePort (#4726) (@bxy4543) +* 4b80930b9090afc7384ef6d6b875743a186c4d97: none (#4732) (@nowinkeyy) +* 651a27cea9affa9e527e3075318b8af065026134: opt: costcenter get quota default use current namespace (#4828) (@zijiren233) +* 9bb4c44653a379e2e36e21a5e3f9b700d13c974d: optimize query object storage metric (#4686) (@nowinkeyy) +* a05b86025cc091977f2b7ab53853d42b641cdb05: refactor(costcenter): refactor invoice (#4694) (@xudaotutou) +* 2176fbb8b4eb6d24b1f7582aa34703af1f0e4583: refactor: adjusted license billing based on cluster size (#4853) (@zjy365) +* ef4409df911bb1565242b4e9dbc2b705888e6235: refactor: improve the prompt when deleting an app (#4786) (@zjy365) +* 216d68e62195bc3066290c3a1e2fe3b1fad217a8: refactor: rename Signin directory to Login and fix file name case issues (#4870) (@zjy365) +* 02327d53e0b9a13d603db249bd50637217f41e39: release: v5.0.0 release, update sealos version in install.sh. (#4882) (@lingdie) +* 2e7eb9f07c225839dc4b02acadb03e3f6183f54d: revert: remove recharge event (#4788) (@zjy365) +* 2fe621674f37a307d8af515ac7d316b3f6650065: style: launchpad displays monitoring values (#4688) (@zjy365) +* 0e1693c32ac098a0b78d793ab334a98246f03d10: styles: Updated UI design for dbprovider frontend (#4747) (@zjy365) +* a58a5b159dcfe74381ce5e85abff9f95149a8eca: styles: launchpad AppStatusTag & workorder appendixs (#4740) (@zjy365) +* f164e9dd48e8a8f6b4ac229dac96e7036854c29a: temporary hiding suspends kb package import (#4753) (@bxy4543) +* 0992fc04dfb4279c198d6e743719a11334c9b5e5: update cvm interface (#4798) (@bxy4543) +* 7974ba511348f0e1a1acb472e694c460744a9cb4: 为kubepanel network 添加service (#4705) (@bearslyricattack) +* 4535a7fcaceeeb9de1b61018e013abf3674db0a0: 🤖 add release changelog using rebot. (#4681) (@sealos-release-robot) + +**Full Changelog**: https://github.com/labring/sealos/compare/v5.0.0-beta5...v5.0.0 + +See [the CHANGELOG](https://github.com/labring/sealos/blob/main/CHANGELOG/CHANGELOG.md) for more details. + +Your patronage towards Sealos is greatly appreciated 🎉🎉. + +If you encounter any problems during its usage, please create an issue in the [GitHub repository](https://github.com/labring/sealos), we're committed to resolving your problem as soon as possible. diff --git a/CHANGELOG/CHANGELOG.md b/CHANGELOG/CHANGELOG.md index 2a288f8d08b..f4390057e1b 100644 --- a/CHANGELOG/CHANGELOG.md +++ b/CHANGELOG/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - [CHANGELOG-5.0.0-beta1.md](./CHANGELOG-5.0.0-beta1.md) - [CHANGELOG-5.0.0-alpha2.md](./CHANGELOG-5.0.0-alpha2.md) - [CHANGELOG-5.0.0-alpha1.md](./CHANGELOG-5.0.0-alpha1.md) +- [CHANGELOG-5.0.0.md](./CHANGELOG-5.0.0.md) - [CHANGELOG-4.4.0-beta3.md](./CHANGELOG-4.4.0-beta3.md) - [CHANGELOG-4.4.0-beta2.md](./CHANGELOG-4.4.0-beta2.md) - [CHANGELOG-4.4.0-beta1.md](./CHANGELOG-4.4.0-beta1.md) diff --git a/controllers/account/Makefile b/controllers/account/Makefile index 6f6ceb3c0e9..06035dfcf40 100644 --- a/controllers/account/Makefile +++ b/controllers/account/Makefile @@ -63,7 +63,9 @@ test: manifests generate fmt vet envtest ## Run tests. .PHONY: build build: ## Build manager binary. - CGO_ENABLED=0 GOOS=linux go build $(shell [ -n "${CRYPTOKEY}" ] && echo "-ldflags '-X github.com/labring/sealos/controllers/pkg/crypto.encryptionKey=${CRYPTOKEY} -X github.com/labring/sealos/controllers/pkg/database.cryptoKey=${CRYPTOKEY}'") -o bin/manager main.go + LD_FLAGS="-s -w"; \ + [ -n "$(CRYPTOKEY)" ] && LD_FLAGS+=" -X github.com/labring/sealos/controllers/pkg/crypto.encryptionKey=${CRYPTOKEY} -X github.com/labring/sealos/controllers/pkg/database.cryptoKey=${CRYPTOKEY}"; \ + CGO_ENABLED=0 GOOS=linux go build -ldflags "$${LD_FLAGS}" -trimpath -o bin/manager main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. diff --git a/controllers/app/Makefile b/controllers/app/Makefile index 9f69081f38c..91c2ede6e51 100644 --- a/controllers/app/Makefile +++ b/controllers/app/Makefile @@ -70,7 +70,7 @@ test: manifests generate fmt vet envtest ## Run tests. .PHONY: build build: ## Build manager binary. - CGO_ENABLED=0 GOOS=linux go build -o bin/manager cmd/main.go + CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -trimpath -o bin/manager cmd/main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. diff --git a/controllers/db/adminer/Makefile b/controllers/db/adminer/Makefile index 61fc5f08479..92bd69e2cd7 100644 --- a/controllers/db/adminer/Makefile +++ b/controllers/db/adminer/Makefile @@ -63,7 +63,7 @@ test: manifests generate fmt vet envtest ## Run tests. .PHONY: build build: ## Build manager binary. - CGO_ENABLED=0 go build -o bin/manager main.go + CGO_ENABLED=0 go build -ldflags "-s -w" -trimpath -o bin/manager main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. diff --git a/controllers/job/heartbeat/Makefile b/controllers/job/heartbeat/Makefile index 27000b99280..84e977e363e 100644 --- a/controllers/job/heartbeat/Makefile +++ b/controllers/job/heartbeat/Makefile @@ -48,7 +48,7 @@ vet: ## Run go vet against code. .PHONY: build build: fmt vet ## Build manager binary. - CGO_ENABLED=0 GOOS=linux go build -o bin/heartbeat-${GOARCH} cmd/main.go && chmod +x bin/heartbeat-${GOARCH} && cp bin/heartbeat-${GOARCH} bin/manager + CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -trimpath -o bin/heartbeat-${GOARCH} cmd/main.go && chmod +x bin/heartbeat-${GOARCH} && cp bin/heartbeat-${GOARCH} bin/manager .PHONY: run diff --git a/controllers/job/init/Makefile b/controllers/job/init/Makefile index 0a70765c430..49d5c8ad124 100644 --- a/controllers/job/init/Makefile +++ b/controllers/job/init/Makefile @@ -50,7 +50,9 @@ vet: ## Run go vet against code. CONTROLLER_PKG=github.com/labring/sealos/controllers/pkg CONTROLLER_LICENSE=github.com/labring/sealos/controllers/license/internal/controller build: fmt vet ## Build manager binary. - CGO_ENABLED=0 GOOS=linux go build $(shell [ -n "${CRYPTOKEY}" ] && echo "-ldflags '-X github.com/labring/sealos/controllers/pkg/crypto.encryptionKey=${CRYPTOKEY} -X github.com/labring/sealos/controllers/pkg/database.cryptoKey=${CRYPTOKEY}'") -o bin/preset-${GOARCH} cmd/preset/main.go && chmod +x bin/preset-${GOARCH} && cp bin/preset-${GOARCH} bin/manager + LD_FLAGS="-s -w"; \ + [ -n "$(CRYPTOKEY)" ] && LD_FLAGS+=" -X github.com/labring/sealos/controllers/pkg/crypto.encryptionKey=${CRYPTOKEY} -X github.com/labring/sealos/controllers/pkg/database.cryptoKey=${CRYPTOKEY}"; \ + CGO_ENABLED=0 GOOS=linux go build -ldflags "$${LD_FLAGS}" -trimpath -o bin/preset-${GOARCH} cmd/preset/main.go && chmod +x bin/preset-${GOARCH} && cp bin/preset-${GOARCH} bin/manager .PHONY: run diff --git a/controllers/license/Makefile b/controllers/license/Makefile index 95c4ce484fc..c71b79652e6 100644 --- a/controllers/license/Makefile +++ b/controllers/license/Makefile @@ -68,10 +68,10 @@ test: manifests generate fmt vet envtest ## Run tests. CONTROLLER_PKG=github.com/labring/sealos/controllers/pkg CONTROLLER_LICENSE=github.com/labring/sealos/controllers/license/internal build: manifests generate fmt vet ## Build manager binary. - LD_FLAGS=""; \ - [ -n "$(CRYPTOKEY)" ] && LD_FLAGS+="-X ${CONTROLLER_PKG}/crypto.encryptionKey=${CRYPTOKEY} -X ${CONTROLLER_PKG}/database.cryptoKey=${CRYPTOKEY}"; \ + LD_FLAGS="-s -w"; \ + [ -n "$(CRYPTOKEY)" ] && LD_FLAGS+=" -X ${CONTROLLER_PKG}/crypto.encryptionKey=${CRYPTOKEY} -X ${CONTROLLER_PKG}/database.cryptoKey=${CRYPTOKEY}"; \ [ -n "$(LICENSE_KEY)" ] && LD_FLAGS+=" -X ${CONTROLLER_LICENSE}/util/key.EncryptionKey=${LICENSE_KEY}"; \ - CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags "$${LD_FLAGS}" -o bin/manager cmd/manager/main.go + CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags "$${LD_FLAGS}" -trimpath -o bin/manager cmd/manager/main.go .PHONY: run diff --git a/controllers/node/Makefile b/controllers/node/Makefile index 8b7a684ea88..48cd9990fb5 100644 --- a/controllers/node/Makefile +++ b/controllers/node/Makefile @@ -63,7 +63,7 @@ test: manifests generate fmt vet envtest ## Run tests. .PHONY: build build: ## Build manager binary. - CGO_ENABLED=0 GOOS=linux go build -o bin/manager main.go + CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -trimpath -o bin/manager main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. diff --git a/controllers/objectstorage/Makefile b/controllers/objectstorage/Makefile index 34e0ee2cc67..ec50253fed7 100644 --- a/controllers/objectstorage/Makefile +++ b/controllers/objectstorage/Makefile @@ -63,7 +63,9 @@ test: manifests generate fmt vet envtest ## Run tests. .PHONY: build build: ## Build manager binary. - CGO_ENABLED=0 GOOS=linux go build $(shell [ -n "${CRYPTOKEY}" ] && echo "-ldflags '-X github.com/labring/sealos/controllers/pkg/crypto.encryptionKey=${CRYPTOKEY} -X github.com/labring/sealos/controllers/pkg/database.cryptoKey=${CRYPTOKEY}'") -o bin/manager main.go + LD_FLAGS="-s -w"; \ + [ -n "$(CRYPTOKEY)" ] && LD_FLAGS+=" -X github.com/labring/sealos/controllers/pkg/crypto.encryptionKey=${CRYPTOKEY} -X github.com/labring/sealos/controllers/pkg/database.cryptoKey=${CRYPTOKEY}"; \ + CGO_ENABLED=0 GOOS=linux go build -ldflags "$${LD_FLAGS}" -trimpath -o bin/manager main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. diff --git a/controllers/resources/Makefile b/controllers/resources/Makefile index 23032dc2865..867f334d4f6 100644 --- a/controllers/resources/Makefile +++ b/controllers/resources/Makefile @@ -63,7 +63,9 @@ test: manifests generate fmt vet envtest ## Run tests. .PHONY: build build: ## Build manager binary. - CGO_ENABLED=0 GOOS=linux go build $(shell [ -n "${CRYPTOKEY}" ] && echo "-ldflags '-X github.com/labring/sealos/controllers/pkg/crypto.encryptionKey=${CRYPTOKEY} -X github.com/labring/sealos/controllers/pkg/database.cryptoKey=${CRYPTOKEY}'") -o bin/manager main.go + LD_FLAGS="-s -w"; \ + [ -n "$(CRYPTOKEY)" ] && LD_FLAGS+=" -X github.com/labring/sealos/controllers/pkg/crypto.encryptionKey=${CRYPTOKEY} -X github.com/labring/sealos/controllers/pkg/database.cryptoKey=${CRYPTOKEY}"; \ + CGO_ENABLED=0 GOOS=linux go build -ldflags "$${LD_FLAGS}" -trimpath -o bin/manager main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. diff --git a/controllers/terminal/Makefile b/controllers/terminal/Makefile index 92442516e15..1fe5b0a7c45 100644 --- a/controllers/terminal/Makefile +++ b/controllers/terminal/Makefile @@ -65,7 +65,7 @@ test: manifests generate fmt vet envtest ## Run tests. .PHONY: build build: ## Build manager binary. - CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -o bin/manager main.go + CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags "-s -w" -trimpath -o bin/manager main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. diff --git a/controllers/user/Makefile b/controllers/user/Makefile index dfb4ca10285..3fada975794 100644 --- a/controllers/user/Makefile +++ b/controllers/user/Makefile @@ -65,7 +65,7 @@ test: manifests generate fmt vet envtest ## Run tests. .PHONY: build build: ## Build manager binary. - CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -o bin/manager main.go + CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags "-s -w" -trimpath -o bin/manager main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. diff --git a/frontend/desktop/.prettierrc.js b/frontend/desktop/.prettierrc.js index 8bc336a3674..b070d785ebd 100644 --- a/frontend/desktop/.prettierrc.js +++ b/frontend/desktop/.prettierrc.js @@ -1,20 +1,28 @@ module.exports = { - printWidth: 100, - tabWidth: 2, - useTabs: false, - semi: true, - singleQuote: true, - quoteProps: 'as-needed', - jsxSingleQuote: false, - trailingComma: 'none', - bracketSpacing: true, - jsxBracketSameLine: false, - arrowParens: 'always', - rangeStart: 0, - rangeEnd: Infinity, - requirePragma: false, - insertPragma: false, - proseWrap: 'preserve', - htmlWhitespaceSensitivity: 'css', - endOfLine: 'lf' + printWidth: 100, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: true, + quoteProps: 'as-needed', + jsxSingleQuote: false, + trailingComma: 'none', + bracketSpacing: true, + jsxBracketSameLine: false, + arrowParens: 'always', + rangeStart: 0, + rangeEnd: Infinity, + requirePragma: false, + insertPragma: false, + proseWrap: 'preserve', + htmlWhitespaceSensitivity: 'css', + endOfLine: 'lf', + overrides: [ + { + files: 'config.yaml.local', + options: { + parser: 'yaml' + } + } + ] }; diff --git a/frontend/desktop/data/config.yaml b/frontend/desktop/data/config.yaml index 3f1a2551c17..00175487ca3 100644 --- a/frontend/desktop/data/config.yaml +++ b/frontend/desktop/data/config.yaml @@ -26,7 +26,7 @@ desktop: githubStarEnabled: false workorderEnabled: false accountSettingEnabled: true - docsUrl: "" + docsUrl: "https://sealos.run/docs/Intro/" aiAssistantEnabled: false auth: proxyAddress: "" diff --git a/frontend/desktop/package.json b/frontend/desktop/package.json index 2ce017578b0..cf49f853ce6 100644 --- a/frontend/desktop/package.json +++ b/frontend/desktop/package.json @@ -36,6 +36,7 @@ "axios": "^1.5.1", "clsx": "^1.2.1", "cors": "^2.8.5", + "croner": "^8.0.2", "dayjs": "^1.11.10", "decimal.js": "^10.4.3", "eslint": "8.38.0", @@ -54,6 +55,7 @@ "next": "13.3.0", "next-i18next": "^13.3.0", "next-pwa": "^5.6.0", + "nodemailer": "^6.9.13", "nprogress": "^0.2.0", "prisma": "^5.10.2", "qrcode.react": "^3.1.0", @@ -81,6 +83,7 @@ "@types/lodash": "^4.14.199", "@types/minio": "^7.1.1", "@types/node": "18.15.11", + "@types/nodemailer": "^6.4.15", "@types/nprogress": "^0.2.1", "@types/react": "18.2.37", "@types/react-dom": "18.0.11", diff --git a/frontend/desktop/prisma/global/migrations/20240604123813_add_invite_reward_table/migration.sql b/frontend/desktop/prisma/global/migrations/20240604123813_add_invite_reward_table/migration.sql new file mode 100644 index 00000000000..ed43541b0b5 --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240604123813_add_invite_reward_table/migration.sql @@ -0,0 +1,14 @@ +-- AlterEnum +ALTER TYPE "ProviderType" ADD VALUE 'OAUTH2'; + +-- CreateTable +CREATE TABLE "InviteReward" ( + "payment_id" STRING NOT NULL, + "userUid" UUID NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "payment_amount" INT8 NOT NULL, + "reward_amount" INT8 NOT NULL, + "inviteFrom" UUID NOT NULL, + + CONSTRAINT "InviteReward_pkey" PRIMARY KEY ("payment_id") +); diff --git a/frontend/desktop/prisma/global/migrations/20240604123814_add_transfer_table/migration.sql b/frontend/desktop/prisma/global/migrations/20240604123814_add_transfer_table/migration.sql new file mode 100644 index 00000000000..3de68d4c967 --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240604123814_add_transfer_table/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE "Transfer" ( + uid uuid default gen_random_uuid () not null primary key, + "fromUserUid" uuid not null, + "toUserUid" uuid not null, + amount bigint not null, + remark text not null, + created_at timestamp + with + time zone default current_timestamp() not null +); + +COMMENT ON TABLE "Transfer" IS 'Calculates sum of squares of the independent variable.'; \ No newline at end of file diff --git a/frontend/desktop/prisma/global/migrations/20240604125758_add_transaction_table/migration.sql b/frontend/desktop/prisma/global/migrations/20240604125758_add_transaction_table/migration.sql new file mode 100644 index 00000000000..ba69474d49c --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240604125758_add_transaction_table/migration.sql @@ -0,0 +1,70 @@ +-- CreateEnum +CREATE TYPE "TransactionStatus" AS ENUM ('READY', 'RUNNING', 'FINISH', 'COMMITED', 'ERROR'); + +-- CreateEnum +CREATE TYPE "TransactionType" AS ENUM ('MERGE_USER', 'DELETE_USER'); + +-- CreateTable +CREATE TABLE "CommitTransactionSet" ( + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "precommitTransactionUid" STRING NOT NULL +); + +-- CreateTable +CREATE TABLE "PrecommitTransaction" ( + "uid" UUID NOT NULL DEFAULT gen_random_uuid(), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + "transactionType" "TransactionType" NOT NULL, + "infoUid" STRING NOT NULL, + "status" "TransactionStatus" NOT NULL, + + CONSTRAINT "PrecommitTransaction_pkey" PRIMARY KEY ("uid") +); + +-- CreateTable +CREATE TABLE "TransactionDetail" ( + "uid" UUID NOT NULL DEFAULT gen_random_uuid(), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + "status" "TransactionStatus" NOT NULL, + "regionUid" STRING NOT NULL, + "transactionUid" STRING NOT NULL, + + CONSTRAINT "TransactionDetail_pkey" PRIMARY KEY ("uid") +); + +-- CreateTable +CREATE TABLE "MergeUserTransactionInfo" ( + "uid" UUID NOT NULL DEFAULT gen_random_uuid(), + "mergeUserUid" STRING NOT NULL, + "userUid" STRING NOT NULL, + + CONSTRAINT "MergeUserTransactionInfo_pkey" PRIMARY KEY ("uid") +); + +-- CreateTable +CREATE TABLE "DeleteUserTransactionInfo" ( + "uid" UUID NOT NULL DEFAULT gen_random_uuid(), + "userUid" STRING NOT NULL, + + CONSTRAINT "DeleteUserTransactionInfo_pkey" PRIMARY KEY ("uid") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CommitTransactionSet_precommitTransactionUid_key" ON "CommitTransactionSet"("precommitTransactionUid"); + +-- CreateIndex +CREATE UNIQUE INDEX "PrecommitTransaction_infoUid_transactionType_key" ON "PrecommitTransaction"("infoUid", "transactionType"); + +-- CreateIndex +CREATE INDEX "TransactionDetail_regionUid_idx" ON "TransactionDetail"("regionUid"); + +-- CreateIndex +CREATE UNIQUE INDEX "TransactionDetail_transactionUid_regionUid_key" ON "TransactionDetail"("transactionUid", "regionUid"); + +-- CreateIndex +CREATE UNIQUE INDEX "MergeUserTransactionInfo_mergeUserUid_key" ON "MergeUserTransactionInfo"("mergeUserUid"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeleteUserTransactionInfo_userUid_key" ON "DeleteUserTransactionInfo"("userUid"); diff --git a/frontend/desktop/prisma/global/migrations/20240605130547_add_index/migration.sql b/frontend/desktop/prisma/global/migrations/20240605130547_add_index/migration.sql new file mode 100644 index 00000000000..b76aa4fbd77 --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240605130547_add_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "MergeUserTransactionInfo_userUid_idx" ON "MergeUserTransactionInfo"("userUid"); diff --git a/frontend/desktop/prisma/global/migrations/20240611073728_add_delete_user_log/migration.sql b/frontend/desktop/prisma/global/migrations/20240611073728_add_delete_user_log/migration.sql new file mode 100644 index 00000000000..aeef8a52abd --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240611073728_add_delete_user_log/migration.sql @@ -0,0 +1,7 @@ +-- CreateTable +CREATE TABLE "DeleteUserLog" ( + "userUid" UUID NOT NULL DEFAULT gen_random_uuid(), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DeleteUserLog_pkey" PRIMARY KEY ("userUid") +); diff --git a/frontend/desktop/prisma/global/migrations/20240612075113_add_audit_log/migration.sql b/frontend/desktop/prisma/global/migrations/20240612075113_add_audit_log/migration.sql new file mode 100644 index 00000000000..8c74f29d44e --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240612075113_add_audit_log/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "AuditAction" AS ENUM ('UPDATE', 'DELETE', 'CREATE'); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "uid" UUID NOT NULL DEFAULT gen_random_uuid(), + "entityUid" STRING NOT NULL, + "entityName" STRING NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "action" "AuditAction" NOT NULL, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("uid") +); + +-- CreateTable +CREATE TABLE "AuditLogDetail" ( + "auditLogUid" STRING NOT NULL, + "key" STRING NOT NULL, + "preValue" STRING NOT NULL, + "newValue" STRING NOT NULL, + + CONSTRAINT "AuditLogDetail_pkey" PRIMARY KEY ("auditLogUid") +); diff --git a/frontend/desktop/prisma/global/migrations/20240614075113_add_account_transaction/migration.sql b/frontend/desktop/prisma/global/migrations/20240614075113_add_account_transaction/migration.sql new file mode 100644 index 00000000000..250822deb55 --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240614075113_add_account_transaction/migration.sql @@ -0,0 +1,12 @@ +create table "AccountTransaction" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid (), + "type" TEXT NOT NULL, + "deduction_balance" INT8 NOT NULL, + "balance" INT8 NOT NULL, + "message" TEXT, + "created_at" TIMESTAMPTZ (3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ (3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "billing_id" UUID NOT NULL, + "userUid" UUID NOT NULL, + CONSTRAINT "AccountTransaction_pkey" PRIMARY KEY ("id") +); \ No newline at end of file diff --git a/frontend/desktop/prisma/global/migrations/20240618115428_update_provider_type/migration.sql b/frontend/desktop/prisma/global/migrations/20240618115428_update_provider_type/migration.sql new file mode 100644 index 00000000000..35dff6e5441 --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240618115428_update_provider_type/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `useruid` on the `AccountTransaction` table. All the data in the column will be lost. + - Added the required column `userUid` to the `AccountTransaction` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterEnum +ALTER TYPE "ProviderType" ADD VALUE 'EMAIL'; diff --git a/frontend/desktop/prisma/global/migrations/20240620070546_add_error_transaction_table/migration.sql b/frontend/desktop/prisma/global/migrations/20240620070546_add_error_transaction_table/migration.sql new file mode 100644 index 00000000000..85fed8c4f9c --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240620070546_add_error_transaction_table/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "ErrorPreCommitTransaction" ( + "uid" UUID NOT NULL DEFAULT gen_random_uuid(), + "transactionUid" STRING NOT NULL, + "reason" STRING, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ErrorPreCommitTransaction_pkey" PRIMARY KEY ("uid") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ErrorPreCommitTransaction_transactionUid_key" ON "ErrorPreCommitTransaction"("transactionUid"); diff --git a/frontend/desktop/prisma/global/migrations/20240620075123_update_uid_type/migration.sql b/frontend/desktop/prisma/global/migrations/20240620075123_update_uid_type/migration.sql new file mode 100644 index 00000000000..48487346385 --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240620075123_update_uid_type/migration.sql @@ -0,0 +1,20 @@ +/* + Warnings: + + - Changed the type of `precommitTransactionUid` on the `CommitTransactionSet` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Changed the type of `transactionUid` on the `ErrorPreCommitTransaction` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- AlterTable +ALTER TABLE "CommitTransactionSet" DROP COLUMN "precommitTransactionUid"; +ALTER TABLE "CommitTransactionSet" ADD COLUMN "precommitTransactionUid" UUID NOT NULL; + +-- AlterTable +ALTER TABLE "ErrorPreCommitTransaction" DROP COLUMN "transactionUid"; +ALTER TABLE "ErrorPreCommitTransaction" ADD COLUMN "transactionUid" UUID NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "CommitTransactionSet_precommitTransactionUid_key" ON "CommitTransactionSet"("precommitTransactionUid"); + +-- CreateIndex +CREATE UNIQUE INDEX "ErrorPreCommitTransaction_transactionUid_key" ON "ErrorPreCommitTransaction"("transactionUid"); diff --git a/frontend/desktop/prisma/global/migrations/20240710112344_update_eventlog/migration.sql b/frontend/desktop/prisma/global/migrations/20240710112344_update_eventlog/migration.sql new file mode 100644 index 00000000000..472e95a57ac --- /dev/null +++ b/frontend/desktop/prisma/global/migrations/20240710112344_update_eventlog/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "EventLog" ( + "uid" UUID NOT NULL DEFAULT gen_random_uuid(), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "mainId" STRING NOT NULL, + "eventName" STRING NOT NULL, + "data" STRING NOT NULL, + + CONSTRAINT "EventLog_pkey" PRIMARY KEY ("uid") +); diff --git a/frontend/desktop/prisma/global/schema.prisma b/frontend/desktop/prisma/global/schema.prisma index 6cb383e6109..e96b0fe4f59 100644 --- a/frontend/desktop/prisma/global/schema.prisma +++ b/frontend/desktop/prisma/global/schema.prisma @@ -1,6 +1,7 @@ generator globalClient { - provider = "prisma-client-js" - output = "./generated/client" + provider = "prisma-client-js" + output = "./generated/client" + binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } datasource db { @@ -24,11 +25,12 @@ model OauthProvider { } model Region { - uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid displayName String location String domain String description String? + txDetail TransactionDetail[] } model Account { @@ -42,6 +44,18 @@ model Account { deduction_balance BigInt? } +model AccountTransaction { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + type String + userUid String @db.Uuid + deduction_balance BigInt + balance BigInt + message String? + created_at DateTime @default(now()) @db.Timestamptz(3) + updated_at DateTime @default(now()) @db.Timestamptz(3) + billing_id String @db.Uuid +} + model ErrorPaymentCreate { userUid String @db.Uuid regionUid String @db.Uuid @@ -75,14 +89,27 @@ model Payment { } model User { - uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - createdAt DateTime @default(now()) @db.Timestamptz(3) - updatedAt DateTime @updatedAt @db.Timestamptz(3) - avatarUri String - nickname String - id String @unique - name String @unique - oauthProvider OauthProvider[] + uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + avatarUri String + nickname String + id String @unique + name String @unique + oauthProvider OauthProvider[] + oldMergeUserTransactionInfo MergeUserTransactionInfo[] @relation("oldUser") + newMergeUserTransactionInfo MergeUserTransactionInfo[] @relation("newUser") + DeleteUserTransactionInfo DeleteUserTransactionInfo? + deleteUserLog DeleteUserLog? +} + +model Transfer { + uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + fromUserUid String @db.Uuid + toUserUid String @db.Uuid + amount BigInt + remark String + created_at DateTime @default(now()) @db.Timestamptz(6) } model ErrorAccountCreate { @@ -101,6 +128,105 @@ model ErrorAccountCreate { message String } +model CommitTransactionSet { + createdAt DateTime @default(now()) @db.Timestamptz(3) + precommitTransactionUid String @unique @db.Uuid + precommitTransaction PrecommitTransaction @relation(fields: [precommitTransactionUid], references: [uid]) +} + +model PrecommitTransaction { + uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + transactionType TransactionType + infoUid String + status TransactionStatus + transactionDetail TransactionDetail[] + commitTransactionSet CommitTransactionSet? + errorPreCommitTransaction ErrorPreCommitTransaction? + + @@unique([infoUid, transactionType]) +} + +model ErrorPreCommitTransaction { + uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + transactionUid String @unique @db.Uuid + reason String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + precommitTransaction PrecommitTransaction @relation(fields: [transactionUid], references: [uid]) +} + +model TransactionDetail { + uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + status TransactionStatus + regionUid String + transactionUid String + region Region @relation(fields: [regionUid], references: [uid]) + precommitTransaction PrecommitTransaction @relation(fields: [transactionUid], references: [uid]) + + @@unique([transactionUid, regionUid]) + @@index([regionUid]) +} + +model MergeUserTransactionInfo { + uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + mergeUserUid String @unique + userUid String + mergeUser User? @relation("oldUser", fields: [mergeUserUid], references: [uid]) + user User? @relation("newUser", fields: [userUid], references: [uid]) + + @@index([userUid]) +} + +model DeleteUserTransactionInfo { + uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + userUid String @unique + user User? @relation(fields: [userUid], references: [uid]) +} + +model DeleteUserLog { + userUid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(3) + user User @relation(fields: [userUid], references: [uid]) +} + +model AuditLog { + uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + entityUid String + entityName String + createdAt DateTime @default(now()) @db.Timestamptz(3) + action AuditAction + auditLogDetail AuditLogDetail[] +} + +model AuditLogDetail { + auditLogUid String @id + key String + preValue String + newValue String + auditLog AuditLog @relation(fields: [auditLogUid], references: [uid]) +} + +model EventLog { + uid String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(3) + mainId String + eventName String + // json + data String +} + +model InviteReward { + payment_id String @id + userUid String @db.Uuid + created_at DateTime @default(now()) @db.Timestamptz(3) + payment_amount BigInt + reward_amount BigInt + inviteFrom String @db.Uuid +} + enum ProviderType { PHONE GITHUB @@ -108,4 +234,24 @@ enum ProviderType { GOOGLE PASSWORD OAUTH2 + EMAIL +} + +enum TransactionStatus { + READY + RUNNING + FINISH + COMMITED + ERROR +} + +enum TransactionType { + MERGE_USER + DELETE_USER +} + +enum AuditAction { + UPDATE + DELETE + CREATE } diff --git a/frontend/desktop/prisma/region/schema.prisma b/frontend/desktop/prisma/region/schema.prisma index ca03b335563..ab04e0cf91a 100644 --- a/frontend/desktop/prisma/region/schema.prisma +++ b/frontend/desktop/prisma/region/schema.prisma @@ -1,6 +1,7 @@ generator regionClient { provider = "prisma-client-js" output = "./generated/client" + binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } datasource db { diff --git a/frontend/desktop/public/locales/en/common.json b/frontend/desktop/public/locales/en/common.json index 2c7480b0321..5c133543c0d 100644 --- a/frontend/desktop/public/locales/en/common.json +++ b/frontend/desktop/public/locales/en/common.json @@ -21,7 +21,7 @@ "password tips": "Password must be 8 characters or more", "username tips": "Username must be 3-16 characters, including letters, numbers", "Password": "Password", - "change": "change", + "Change": "Change", "changePassword": "Change Password", "currentPassword": "Current Password", "newPassword": "New Password", @@ -152,5 +152,52 @@ "Toggle App Bar": "Toggle App Bar", "Work Order": "Work Order", "Under active development": "Under active development 🚧", - "Sealos Copilot": "Sealos Copilot" -} \ No newline at end of file + "Sealos Copilot": "Sealos Copilot", + "Avatar": "Avatar", + "Nickname": "Nickname", + "Language": "Language", + "Phone": "Phone", + "Github": "Github", + "Wechat": "Wechat", + "Bound": "Bound", + "Bind": "Bind", + "Unbound": "Unbound", + "Unbind": "Unbind", + "Change Binding": "Change Binding", + "verifyCode": "verification", + "Already Sent Code": "Already sent code", + "changePhone": "Change Phone", + "Delete account": "Delete account", + "Delete Account Tips": "Permanently delete this account and all its content.", + "Delete Account Button": "Delete", + "Delete Account Title": "Logout prompt", + "deleteMyAccount": "DeleteMyAccount", + "INSUFFICIENT_BALANCE_tips": "There is currently an outstanding balance in your account. In order to successfully complete the account cancellation process, please settle the outstanding balance first.", + "Remain App Tips": "There are still undeleted application resources in your account. To help you smoothly complete the account cancellation process, please manually delete all application resources to avoid data loss", + "Remain Template Tips": "There are still undeleted template resources in your account. To help you smoothly complete the account cancellation process, please manually delete all template resources to avoid data loss", + "Remain ObjectStorage Tips": "There are still undeleted object storage resources in your account. To help you smoothly complete the account cancellation process, please manually delete all object storage resources to avoid data loss", + "Remain Database Tips": "There are still undeleted database resources in your account. To help you smoothly complete the account cancellation process, please manually delete all database resources to avoid data loss", + "Remain Workspace Tips": "There are still undeleted associated resources in your account. To help you successfully complete the account cancellation process, please clean up or transfer your workspace to avoid data loss.", + "Remain Other Region Resource Tips": "There are still associated resources that have not been deleted in your account. To help you successfully complete the account cancellation process, please clean up all region resources to ensure that nothing is missing.", + "Delete Account Caution": "Once a resource is deleted, it cannot be recovered. \nTherefore, before performing the above operations, please be sure to back up your data.", + "Enter": "Enter", + "Google": "Google", + "Merge Account Title": "Account has been bound", + "Merge": "merge", + "phoneChangeSuccess": "Mobile phone number modified successfully", + "Bind Success": "Binding successful", + "Old Phone": "old mobile number", + "Next": "Next", + "New Phone": "New mobile number", + "Unbind Success": "Unbinding successfully", + "Invalid Email": "Invalid email", + "Email": "Email", + "Old Email": "Old email", + "New Email": "New email", + "emailChangeSuccess": "Email modified successfully", + "changeEmail": "Modify email", + "IrreversibleActionTips": "This action is irreversible, please proceed with caution.", + "DeleteAccountTitle": "Sealos will permanently delete this account.", + "MergeAccountTips1": "The account you are trying to bind has been used by another user. Due to conflicting binding methods, the accounts cannot be merged.", + "DeleteAccountTips2": "The account you are trying to bind has been used by another user. You can choose to merge the accounts to unify the management of your information and settings. Would you like to merge the accounts now?" +} diff --git a/frontend/desktop/public/locales/en/error.json b/frontend/desktop/public/locales/en/error.json new file mode 100644 index 00000000000..46e13a6e113 --- /dev/null +++ b/frontend/desktop/public/locales/en/error.json @@ -0,0 +1,24 @@ +{ + "NOT_SUPPORT": "Not supported", + "OAUTH_PROVIDER_NOT_FOUND": "Current OAuth provider not found", + "EXIST_SAME_OAUTH_PROVIDER": "Same OAuth provider already exists", + "USER_NOT_FOUND": "User not found", + "RESULT_SUCCESS": "Success response", + "INTERNAL_SERVER_ERROR": "Internal server error", + "INSUFFICENT_BALANCE": "Insufficient balance", + "ACCOUNT_NOT_FOUND": "Account not found", + "USER_CR_NOT_FOUND": "User CR not found", +"OAUTHPROVIDER_NOT_FOUND": "OAuth provider not found", +"PRIVATE_WORKSPACE_NOT_FOUND": "Private workspace not found", +"GET_RESOURCE_ERROR": "Get resource error", +"REMAIN_OTHER_REGION_RESOURCE": "Remaining resources in other region", +"REMAIN_WORKSACE_OWNER": "Remaining workspace owner in current region", +"REMAIN_CVM": "Remaining cloud virtual machine in current region", +"REMAIN_APP": "Remaining applications in current region", +"REMAIN_TEMPLATE": "Remaining templates in current region", +"REMAIN_OBJECT_STORAGE": "Remaining object storage in current region", +"REMAIN_DATABASE": "Remaining databases in current region", +"KUBECONFIG_NOT_FOUND": "Kubeconfig not found" + } + + \ No newline at end of file diff --git a/frontend/desktop/public/locales/zh/common.json b/frontend/desktop/public/locales/zh/common.json index c463ef4d60f..167eef87c26 100644 --- a/frontend/desktop/public/locales/zh/common.json +++ b/frontend/desktop/public/locales/zh/common.json @@ -14,7 +14,7 @@ "From": "来自", "Balance": "余额", "verify code tips": "6位验证码", - "phone number tips": "手机号码", + "phone number tips": "手机号", "password tips": "密码为8位以上字符", "username tips": "用户名为3-16位的英文或数字的字符", "currentPasswordRequired": "当前密码不能为空", @@ -25,9 +25,9 @@ "passwordMismatch": "密码不一致", "passwordChangeSuccess": "密码修改成功", "Verify password": "确认密码", - "change": "修改", + "Change": "变更", "Invalid username or password": "用户名或密码错误", - "Invalid phone number": "无效的手机号码", + "Invalid phone number": "无效的手机号", "Invalid verification code": "无效的验证码", "Get code failed": "获取验证码失败", "Read and agree": "请阅读并同意下方协议", @@ -145,5 +145,52 @@ "Toggle App Bar": "切换应用栏", "Work Order": "工单", "Under active development": "正在积极开发中 🚧", - "Sealos Copilot": "Sealos 小助理" -} \ No newline at end of file + "Sealos Copilot": "Sealos 小助理", + "Avatar": "头像", + "Nickname": "昵称", + "Language": "语言", + "Phone": "手机号", + "Github": "Github", + "Wechat": "微信", + "Bound": "已绑定", + "Bind": "绑定", + "Unbound": "未绑定", + "Unbind": "解绑", + "Change Binding": "改绑", + "verifyCode": "验证码", + "Already Sent Code": "已经发送验证码", + "changePhone": "更改手机号", + "Delete account": "注销账号", + "Delete Account Tips": "将永久删除此账户及其所有内容", + "Delete Account Button": "注销", + "Delete Account Title": "注销提示", + "deleteMyAccount": "删除我的账号", + "INSUFFICIENT_BALANCE_tips": "您的账户目前存在未结清的款项,为了顺利完成账户注销流程,请先结清欠款。", + "Remain App Tips": "您的账户中仍有未删除的应用资源,为了帮助您顺利完成账户注销流程,请您手动删除所有应用资源,以避免数据丢失", + "Remain Template Tips": "您的账户中仍有未删除的模板资源,为了帮助您顺利完成账户注销流程,请您手动删除所有模板资源,以避免数据丢失", + "Remain ObjectStorage Tips": "您的账户中仍有未删除的对象存储资源,为了帮助您顺利完成账户注销流程,请您手动删除所有对象存储资源,以避免数据丢失", + "Remain Database Tips": "您的账户中仍有未删除的数据库资源,为了帮助您顺利完成账户注销流程,请您手动删除所有数据库资源,以避免数据丢失", + "Remain Workspace Tips": "您的账户中仍有未删除的关联资源,为了帮助您顺利完成账户注销流程,请您清理或转移您的工作空间,以避免数据丢失", + "Remain Other Region Resource Tips": "您好,您的账户中仍有未删除的关联资源,为了帮助您顺利完成账户注销流程,请您清理所有可用区资源,确保无遗漏。", + "Delete Account Caution": "资源一旦删除,将不可恢复。因此,在执行以上操作前,请务必做好数据备份工作", + "Enter": "输入", + "Google": "Google", + "Merge Account Title": "账户已被绑定", + "Merge": "合并", + "phoneChangeSuccess": "手机号修改成功", + "Bind Success": "绑定成功", + "Old Phone": "旧手机号", + "Next": "下一步", + "New Phone": "新手机号", + "Unbind Success": "解绑成功", + "Invalid Email": "无效的电子邮箱", + "Email": "电子邮箱", + "Old Email": "旧电子邮箱", + "New Email": "新电子邮箱", + "emailChangeSuccess": "电子邮箱修改成功", + "changeEmail": "修改电子邮箱", + "IrreversibleActionTips": "此操作不可逆转,请谨慎操作", + "DeleteAccountTitle": "Sealos 将永久删除此账户。", + "MergeAccountTips1": "您尝试绑定的账号已被其他用户使用。由于存在冲突的其他绑定方式,无法合并账户。", + "DeleteAccountTips2": "您尝试绑定的账号已被其他用户使用。您可以选择合并账户,以统一管理您的信息和设置。是否现在合并账户?" +} diff --git a/frontend/desktop/public/locales/zh/error.json b/frontend/desktop/public/locales/zh/error.json new file mode 100644 index 00000000000..8a27e99f186 --- /dev/null +++ b/frontend/desktop/public/locales/zh/error.json @@ -0,0 +1,22 @@ +{ +"NOT_SUPPORT": "不支持", +"OAUTH_PROVIDER_NOT_FOUND": "当前绑定的登录方式不存在", +"EXIST_SAME_OAUTH_PROVIDER": "已存在相同的绑定的登录方式", +"USER_NOT_FOUND": "未找到用户", +"RESULT_SUCCESS": "响应成功", +"INTERNAL_SERVER_ERROR": "内部服务器错误", +"INSUFFICENT_BALANCE": "余额不足", +"ACCOUNT_NOT_FOUND": "未找到账户", +"USER_CR_NOT_FOUND": "未找到用户CR", +"OAUTHPROVIDER_NOT_FOUND": "未找到OAuth提供程序", +"PRIVATE_WORKSPACE_NOT_FOUND": "未找到私有工作空间", +"GET_RESOURCE_ERROR": "获取资源错误", +"REMAIN_OTHER_REGION_RESOURCE": "其他可用区残留资源", +"REMAIN_WORKSACE_OWNER": "当前可用区残留工作空间", +"REMAIN_CVM": "当前可用区残留云主机", +"REMAIN_APP": "当前可用区残留应用", +"REMAIN_TEMPLATE": "当前可用区残留模板", +"REMAIN_OBJECT_STORAGE": "当前可用区残留对象存储", +"REMAIN_DATABASE": "当前可用区残留数据库", +"KUBECONFIG_NOT_FOUND": "未找到Kubeconfig" +} \ No newline at end of file diff --git a/frontend/desktop/scripts/testDeleteAccountTrnasaction.ps1 b/frontend/desktop/scripts/testDeleteAccountTrnasaction.ps1 new file mode 100644 index 00000000000..cd28b89349e --- /dev/null +++ b/frontend/desktop/scripts/testDeleteAccountTrnasaction.ps1 @@ -0,0 +1,38 @@ +$desktopHostName = Read-Host "输入域名" +$start = Read-Host "输入开始" +$url = "https://${desktopHostName}/api/auth/password" +$tokenUrl = "https://${desktopHostName}/api/auth/regionToken" +$deleteAccountUrl = "https://${desktopHostName}/api/auth/delete" +Write-Host $url +$requests = 500 +$batchSize = 20 +$delay = 5 + +for ($i = $start; $i -lt $requests; $i += $batchSize) { + + foreach ($num in ($i..($i + $batchSize))) { + $body = @{ + "user" = "test${num}test" + "password" = "test${num}test" + } | ConvertTo-Json + # Start-Job -ScriptBlock { + $result = Invoke-WebRequest -Uri $url -Body $body -ContentType 'application/json' + $token = [URI]::EscapeDataString( ($result.Content | ConvertFrom-Json).data.token) + Write-Host 'password result' + Write-Host $token + $result2 = Invoke-WebRequest -Uri $tokenUrl -Headers @{ + 'Authorization' = $token + } + Write-Host 'regiontoken result' + Write-Host $result2.Content + $token = [URI]::EscapeDataString( ($result2.Content | ConvertFrom-Json).data.token) + $result3 = Invoke-WebRequest -Uri $deleteAccountUrl -Headers @{ + 'Authorization' = $token + } + Write-Host 'delete result' + Write-Host $result3.Content + # } + } + + # Start-Sleep -Seconds $delay +} diff --git a/frontend/desktop/src/api/auth.ts b/frontend/desktop/src/api/auth.ts index ef8659256d5..d496e7e2166 100644 --- a/frontend/desktop/src/api/auth.ts +++ b/frontend/desktop/src/api/auth.ts @@ -1,9 +1,15 @@ import request from '@/services/request'; -import { Session } from 'sealos-desktop-sdk'; -import { TUserExist } from '@/types/user'; +import { OauthProvider, TUserExist } from '@/types/user'; import { ApiResp, Region } from '@/types'; import { AxiosHeaders, AxiosHeaderValue, type AxiosInstance } from 'axios'; import useSessionStore from '@/stores/session'; +import { ProviderType } from 'prisma/global/generated/client'; +import { ValueOf } from '@/types/tools'; +import { SmsType } from '@/services/backend/db/verifyCode'; +import { USER_MERGE_STATUS } from '@/types/response/merge'; +import { BIND_STATUS } from '@/types/response/bind'; +import { UNBIND_STATUS } from '@/types/response/unbind'; +import { RESOURCE_STATUS } from '@/types/response/checkResource'; export const _getRegionToken = (request: AxiosInstance) => () => request.post>( @@ -49,6 +55,7 @@ export const _UserInfo = (request: AxiosInstance) => () => nickname: string; id: string; name: string; + oauthProvider: { providerId: string; providerType: Exclude }[]; }; }> >('/api/auth/info'); @@ -59,11 +66,97 @@ export const _regionList = (request: AxiosInstance) => () => regionList: Region[]; }> >('/api/auth/regionList'); +const _getSmsBindCodeRequest = + (request: AxiosInstance) => (smsType: SmsType) => (data: { id: string; cfToken?: string }) => + request.post(`/api/auth/${smsType}/bind/sms`, data); + +export const _verifySmsBindRequest = + (request: AxiosInstance) => (smsType: SmsType) => (data: { id: string; code: string }) => + request.post< + typeof data, + ApiResp<{ code: string | null | undefined }, ValueOf> + >(`/api/auth/${smsType}/bind/verify`, data); +export const _verifySmsUnbindRequest = + (request: AxiosInstance) => (smsType: SmsType) => (data: { id: string; code: string }) => + request.post(`/api/auth/${smsType}/unbind/verify`, data); +export const _getSmsUnbindCodeRequest = + (request: AxiosInstance) => (smsType: SmsType) => (data: { id: string; cfToken?: string }) => + request.post(`/api/auth/${smsType}/unbind/sms`, data); +export const _verifyOldSmsRequest = + (request: AxiosInstance) => (smsType: SmsType) => (data: { id: string; code: string }) => + request.post>( + `/api/auth/${smsType}/changeBinding/verifyOld`, + data + ); +export const _getOldSmsCodeRequest = + (request: AxiosInstance) => (smsType: SmsType) => (data: { id: string; cfToken?: string }) => + request.post(`/api/auth/${smsType}/changeBinding/oldSms`, data); +export const _verifyNewSmsRequest = + (request: AxiosInstance) => + (smsType: SmsType) => + (data: { id: string; code: string; uid: string }) => + request.post(`/api/auth/${smsType}/changeBinding/verifyNew`, data); +export const _getNewSmsCodeRequest = + (request: AxiosInstance) => + (smsType: SmsType) => + (data: { id: string; cfToken?: string; uid: string }) => + request.post(`/api/auth/${smsType}/changeBinding/newSms`, data); + +export const _oauthProviderSignIn = + (request: AxiosInstance) => + (provider: ProviderType) => + (data: { code: string; inviterId?: string }) => + request.post< + typeof data, + ApiResp<{ + token: string; + realUser: { + realUserUid: string; + }; + }> + >(`/api/auth/oauth/${provider.toLocaleLowerCase()}`, data); +export const _oauthProviderBind = + (request: AxiosInstance) => + (provider: ProviderType) => + (data: { code: string; inviterId?: string }) => + request.post< + typeof data, + ApiResp<{ code: string | null | undefined }, ValueOf> + >(`/api/auth/oauth/${provider.toLocaleLowerCase()}/bind`, data); +export const _oauthProviderUnbind = + (request: AxiosInstance) => + (provider: ProviderType) => + (data: { code: string; inviterId?: string }) => + request.post>>( + `/api/auth/oauth/${provider.toLocaleLowerCase()}/unbind`, + data + ); + +export const _mergeUser = + (request: AxiosInstance) => (data: { code: string; providerType: ProviderType }) => + request.post>('/api/auth/mergeUser', data); + +export const _deleteUser = (request: AxiosInstance) => () => + request>('/api/auth/delete'); + export const passwordExistRequest = _passwordExistRequest(request); export const passwordLoginRequest = _passwordLoginRequest(request, (token) => { - useSessionStore.setState({ token: token }); + useSessionStore.setState({ token }); }); export const passwordModifyRequest = _passwordModifyRequest(request); export const UserInfo = _UserInfo(request); - export const regionList = _regionList(request); + +export const getSmsBindCodeRequest = _getSmsBindCodeRequest(request); +export const verifySmsBindRequest = _verifySmsBindRequest(request); +export const getSmsUnbindCodeRequest = _getSmsUnbindCodeRequest(request); +export const verifySmsUnbindRequest = _verifySmsUnbindRequest(request); +export const getOldSmsCodeRequest = _getOldSmsCodeRequest(request); +export const verifyOldSmsRequest = _verifyOldSmsRequest(request); +export const getNewSmsCodeRequest = _getNewSmsCodeRequest(request); +export const verifyNewSmsRequest = _verifyNewSmsRequest(request); +export const bindRequest = _oauthProviderBind(request); +export const unBindRequest = _oauthProviderUnbind(request); +export const signInRequest = _oauthProviderSignIn(request); +export const mergeUserRequest = _mergeUser(request); +export const deleteUserRequest = _deleteUser(request); diff --git a/frontend/desktop/src/components/LangSelect/index.tsx b/frontend/desktop/src/components/LangSelect/index.tsx index c987ddee98d..e5f45b1da1f 100644 --- a/frontend/desktop/src/components/LangSelect/index.tsx +++ b/frontend/desktop/src/components/LangSelect/index.tsx @@ -1,70 +1,76 @@ import { setCookie } from '@/utils/cookieUtils'; -import { Box, Button, Stack, UseDisclosureReturn } from '@chakra-ui/react'; -import { I18n } from 'next-i18next'; +import { + Button, + Flex, + FlexProps, + Menu, + MenuButton, + MenuButtonProps, + MenuItem, + MenuList, + Text +} from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; import { EVENT_NAME } from 'sealos-desktop-sdk'; import { masterApp } from 'sealos-desktop-sdk/master'; +import { ExpanMoreIcon } from '../../../../packages/ui'; +import { ROLE_LIST } from '@/types/team'; +import { router } from 'next/client'; -const LANG_LIST = [ - { value: 'en', label: 'English' }, - { value: 'zh', label: '中文' } -]; -function LangSelect({ disclosure, i18n }: { disclosure: UseDisclosureReturn; i18n: I18n | null }) { - // const { i18n, ready } = useTranslation(); - - return disclosure.isOpen ? ( - <> - - + } + {...props} > - {LANG_LIST.map((item, index) => ( - + {lngVal} + ))} - - - ) : ( - <> + + ); } - -export default LangSelect; diff --git a/frontend/desktop/src/components/account/AccountCenter/AuthModifyList.tsx b/frontend/desktop/src/components/account/AccountCenter/AuthModifyList.tsx new file mode 100644 index 00000000000..93ab1f2532e --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/AuthModifyList.tsx @@ -0,0 +1,147 @@ +import { useConfigStore } from '@/stores/config'; +import useSessionStore, { OauthAction } from '@/stores/session'; +import { OauthProvider } from '@/types/user'; +import { Text, Image, Center } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import router from 'next/router'; +import { useMemo } from 'react'; +import { ConfigItem } from './ConfigItem'; +import { BINDING_STATE_MODIFY_BEHAVIOR, BindingModifyButton } from './BindingModifyButton'; + +export function AuthModifyList({ + isOnlyOne, + GITHUBIsBinding, + WECHATIsBinding, + GOOGLEIsBinding, + avatarUrl +}: { + isOnlyOne: boolean; + avatarUrl: string; + GOOGLEIsBinding: boolean; + GITHUBIsBinding: boolean; + WECHATIsBinding: boolean; +}) { + const { authConfig: conf, layoutConfig } = useConfigStore(); + const { setProvider, generateState } = useSessionStore(); + const { t } = useTranslation(); + const authActionList: { + title: string; + actionCb: (actino: OauthAction) => void; + isBinding: boolean; + }[] = useMemo(() => { + if (!conf) return []; + const actionCbGen = + ({ + url, + provider, + clientId + }: { + url: string; + provider: OauthProvider; + clientId: string; + }) => + (action: T) => { + const state = generateState(action); + setProvider(provider); + if (conf.proxyAddress) { + const target = new URL(conf.proxyAddress); + const callback = new URL(conf.callbackURL); + target.searchParams.append( + 'oauthProxyState', + encodeURIComponent(callback.toString()) + '_' + state + ); + target.searchParams.append('oauthProxyClientID', clientId); + target.searchParams.append('oauthProxyProvider', provider); + router.replace(target.toString()); + } else { + const target = new URL(url); + target.searchParams.append('state', state); + router.replace(target); + } + }; + const result = []; + if (conf.idp.github.enabled) + result.push({ + title: t('Github'), + isBinding: GITHUBIsBinding, + actionCb(action: Exclude) { + const githubConf = conf.idp.github; + return actionCbGen({ + provider: 'GITHUB', + clientId: githubConf.clientID, + url: `https://github.com/login/oauth/authorize?client_id=${githubConf?.clientID}&redirect_uri=${conf?.callbackURL}&scope=user:email%20read:user` + })(action); + } + }); + + if (conf.idp.wechat.enabled) + result.push({ + title: t('Wechat'), + isBinding: WECHATIsBinding, + actionCb(action: Exclude) { + const wechatConf = conf.idp.wechat; + return actionCbGen({ + provider: 'WECHAT', + clientId: wechatConf.clientID, + url: `https://open.weixin.qq.com/connect/qrconnect?appid=${wechatConf?.clientID}&redirect_uri=${conf?.callbackURL}&response_type=code&scope=snsapi_login&#wechat_redirect` + })(action); + } + }); + if (conf.idp.google.enabled) + result.push({ + title: t('Google'), + isBinding: GOOGLEIsBinding, + actionCb(action: Exclude) { + const googleConf = conf.idp.google; + const scope = encodeURIComponent( + `https://www.googleapis.com/auth/userinfo.profile openid` + ); + return actionCbGen({ + provider: 'GOOGLE', + clientId: googleConf.clientID, + url: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleConf.clientID}&redirect_uri=${conf.callbackURL}&response_type=code&scope=${scope}&include_granted_scopes=true` + })(action); + } + }); + return result; + }, [conf, t, GITHUBIsBinding, WECHATIsBinding, GOOGLEIsBinding]); + return authActionList.map((auth) => { + return ( + {auth.title}} + RightElement={ + <> + {auth.isBinding ? ( +
+ user avator +
+ ) : ( + {t('Unbound')} + )} + {(!auth.isBinding || !isOnlyOne) && ( + { + e.preventDefault(); + auth.isBinding ? auth.actionCb('UNBIND') : auth.actionCb('BIND'); + }} + modifyBehavior={ + auth.isBinding + ? BINDING_STATE_MODIFY_BEHAVIOR.UNBINDING + : BINDING_STATE_MODIFY_BEHAVIOR.BINDING + } + /> + )} + + } + >
+ ); + }); +} diff --git a/frontend/desktop/src/components/account/AccountCenter/BindingModifyButton.tsx b/frontend/desktop/src/components/account/AccountCenter/BindingModifyButton.tsx new file mode 100644 index 00000000000..775724a60ef --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/BindingModifyButton.tsx @@ -0,0 +1,24 @@ +import { ButtonProps, Button, forwardRef } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +export enum BINDING_STATE_MODIFY_BEHAVIOR { + BINDING, + UNBINDING, + CHANGE_BINDING +} +export const BindingModifyButton = forwardRef< + ButtonProps & { modifyBehavior: BINDING_STATE_MODIFY_BEHAVIOR }, + 'button' +>(function ChangeButton({ modifyBehavior, ...props }, ref) { + const { t } = useTranslation(); + return ( + + ); +}); diff --git a/frontend/desktop/src/components/account/AccountCenter/ConfigItem.tsx b/frontend/desktop/src/components/account/AccountCenter/ConfigItem.tsx new file mode 100644 index 00000000000..9ee7ed093e0 --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/ConfigItem.tsx @@ -0,0 +1,24 @@ +import { StackProps, HStack, Flex, Box, forwardRef } from '@chakra-ui/react'; +import { ReactNode } from 'react'; + +export const ConfigItem = forwardRef< + StackProps & { LeftElement: ReactNode; RightElement: ReactNode }, + 'div' +>(function ConfigItem({ LeftElement, RightElement, ...props }, ref) { + return ( + + + {LeftElement} + + + {RightElement} + + + ); +}); diff --git a/frontend/desktop/src/components/account/AccountCenter/DeleteAccountModal.tsx b/frontend/desktop/src/components/account/AccountCenter/DeleteAccountModal.tsx new file mode 100644 index 00000000000..4b2d652984b --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/DeleteAccountModal.tsx @@ -0,0 +1,250 @@ +import { useCustomToast } from '@/hooks/useCustomToast'; +import useSessionStore from '@/stores/session'; +import { ValueOf } from '@/types'; +import { + ButtonProps, + useDisclosure, + Text, + Button, + Modal, + ModalOverlay, + ModalContent, + ModalCloseButton, + ModalHeader, + Spinner, + ModalBody, + VStack, + HStack, + FormControl, + Divider +} from '@chakra-ui/react'; +import { InfoCircleIcon, WarnTriangeIcon } from '@sealos/ui'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'next-i18next'; +import { SettingInput } from './SettingInput'; +import { SettingInputGroup } from './SettingInputGroup'; +import { useRouter } from 'next/router'; +import { RESOURCE_STATUS } from '@/types/response/checkResource'; +import { deleteUserRequest } from '@/api/auth'; +enum PageStatus { + IDLE, + REMAIN_OTHER_REGION_RESOURCE, + REMAIN_WORKSPACE, + REMAIN_APP, + REMAIN_DATABASE, + REMAIN_OBJECT_STORAGE, + REMAIN_TEMPLATE, + INSUFFICIENT_BALANCE +} +export default function DeleteAccount({ ...props }: ButtonProps) { + const { onOpen, isOpen, onClose } = useDisclosure(); + const t = useTranslation().t; + const errorT = useTranslation('error').t; + const queryClient = useQueryClient(); + const { toast } = useCustomToast({ status: 'error' }); + const { delSession, setToken, session } = useSessionStore(); + const [pagestatus, setPagestatus] = useState(PageStatus.IDLE); + const [nickname, setnickname] = useState(''); + const verifyWords = t('deleteMyAccount'); + const router = useRouter(); + const [verifyValue, setVerifyValue] = useState(''); + const mutation = useMutation({ + mutationFn: deleteUserRequest, + onSuccess() { + delSession(); + queryClient.clear(); + setToken(''); + router.replace('/signin'); + setToken(''); + }, + onError(error: { message: ValueOf }) { + const message = error?.message; + if (message === RESOURCE_STATUS.REMAIN_APP) { + return setPagestatus(PageStatus.REMAIN_APP); + } else if (message === RESOURCE_STATUS.REMAIN_DATABASE) { + return setPagestatus(PageStatus.REMAIN_DATABASE); + } else if (message === RESOURCE_STATUS.REMAIN_OBJECT_STORAGE) { + return setPagestatus(PageStatus.REMAIN_OBJECT_STORAGE); + } else if (message === RESOURCE_STATUS.REMAIN_TEMPLATE) { + return setPagestatus(PageStatus.REMAIN_TEMPLATE); + } else if (message === RESOURCE_STATUS.REMAIN_OTHER_REGION_RESOURCE) { + return setPagestatus(PageStatus.REMAIN_OTHER_REGION_RESOURCE); + } else if (message === RESOURCE_STATUS.INSUFFICENT_BALANCE) { + return setPagestatus(PageStatus.INSUFFICIENT_BALANCE); + } else if (message === RESOURCE_STATUS.REMAIN_WORKSACE_OWNER) { + return setPagestatus(PageStatus.REMAIN_WORKSPACE); + } else { + setPagestatus(PageStatus.IDLE); + toast({ title: errorT(message) }); + } + } + }); + const deleteModalOnClose = () => { + setPagestatus(PageStatus.IDLE); + onClose(); + }; + return ( + <> + { + + } + + + + + + + {t('Delete Account Title')} + + {mutation.isLoading ? ( + + ) : ( + + {pagestatus === PageStatus.IDLE ? ( + + {t('DeleteAccountTitle')} + + + {t('IrreversibleActionTips')} + + + + + {t('Enter')} + {session?.user.name} + {t('Confirm')} + + + { + setnickname(e.target.value); + }} + /> + + + + + {t('Enter')} + {verifyWords} + {t('Confirm')} + + + { + setVerifyValue(e.target.value); + }} + /> + + + + + + + + ) : ( + + {pagestatus === PageStatus.INSUFFICIENT_BALANCE ? ( + {t('INSUFFICIENT_BALANCE_tips')} + ) : pagestatus === PageStatus.REMAIN_APP ? ( + {t('Remain App Tips')} + ) : pagestatus === PageStatus.REMAIN_TEMPLATE ? ( + {t('Remain Template Tips')} + ) : pagestatus === PageStatus.REMAIN_OBJECT_STORAGE ? ( + {t('Remain ObjectStorage Tips')} + ) : pagestatus === PageStatus.REMAIN_DATABASE ? ( + {t('Remain Database Tips')} + ) : pagestatus === PageStatus.REMAIN_WORKSPACE ? ( + {t('Remain Workspace Tips')} + ) : pagestatus === PageStatus.REMAIN_OTHER_REGION_RESOURCE ? ( + {t('Remain Other Region Resource Tips')} + ) : null} + + + {(pagestatus === PageStatus.REMAIN_APP || + pagestatus === PageStatus.REMAIN_DATABASE || + pagestatus === PageStatus.REMAIN_OBJECT_STORAGE || + pagestatus === PageStatus.REMAIN_TEMPLATE || + pagestatus === PageStatus.REMAIN_WORKSPACE || + pagestatus === PageStatus.REMAIN_OTHER_REGION_RESOURCE) && ( + {t('Delete Account Caution')} + )} + + + + + + + )} + + )} + + + + ); +} diff --git a/frontend/desktop/src/components/account/AccountCenter/PasswordModify.tsx b/frontend/desktop/src/components/account/AccountCenter/PasswordModify.tsx new file mode 100644 index 00000000000..c5ee714f6e9 --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/PasswordModify.tsx @@ -0,0 +1,125 @@ +import { + Button, + FlexProps, + FormControl, + FormLabel, + HStack, + Spinner, + VStack +} from '@chakra-ui/react'; +import { useMutation } from '@tanstack/react-query'; +import { useCustomToast } from '@/hooks/useCustomToast'; +import { ApiResp } from '@/types'; +import { useTranslation } from 'next-i18next'; +import { useForm } from 'react-hook-form'; +import { strongPassword } from '@/utils/crypto'; +import { passwordModifyRequest } from '@/api/auth'; +import { SettingInput } from './SettingInput'; +import { SettingInputGroup } from './SettingInputGroup'; + +export default function PasswordModify( + props: FlexProps & { + onClose: () => void; + } +) { + const { t } = useTranslation(); + const { toast } = useCustomToast({ status: 'error' }); + const { register, handleSubmit, reset, formState } = useForm<{ + oldPassword: string; + newPassword: string; + againPassword: string; + }>(); + const mutation = useMutation(passwordModifyRequest, { + onSuccess(data) { + if (data.code === 200) { + toast({ + status: 'success', + title: t('passwordChangeSuccess') + }); + reset(); + props.onClose?.(); + } + }, + onError(error) { + toast({ title: (error as ApiResp).message }); + } + }); + + return mutation.isLoading ? ( + + ) : ( + <> +
{ + mutation.mutate({ oldPassword, newPassword }); + }, + (errors) => { + if (errors.oldPassword) return toast({ title: t('currentPasswordRequired') }); + if (errors.newPassword) return toast({ title: t('password tips') }); + if (errors.againPassword) return toast({ title: t('passwordMismatch') }); + } + )} + > + + + + {t('currentPassword')} + + + + + + + + {t('newPassword')} + + + + + + + + {t('confirmNewPassword')} + + + + + + + +
+ + ); +} diff --git a/frontend/desktop/src/components/account/AccountCenter/SettingInput.tsx b/frontend/desktop/src/components/account/AccountCenter/SettingInput.tsx new file mode 100644 index 00000000000..e7f6f6b6c89 --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/SettingInput.tsx @@ -0,0 +1,20 @@ +import { InputProps, Flex, Input, forwardRef, InputGroup, InputGroupProps } from '@chakra-ui/react'; + +export const SettingInput = forwardRef(function SettingInput( + { children, ...props }, + ref +) { + return ( + + ); +}); diff --git a/frontend/desktop/src/components/account/AccountCenter/SettingInputGroup.tsx b/frontend/desktop/src/components/account/AccountCenter/SettingInputGroup.tsx new file mode 100644 index 00000000000..9b3d6563a2f --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/SettingInputGroup.tsx @@ -0,0 +1,22 @@ +import { InputProps, Flex, Input, forwardRef, InputGroup, InputGroupProps } from '@chakra-ui/react'; + +export const SettingInputGroup = forwardRef(function SettingInputGroup( + props, + ref +) { + return ( + + ); +}); diff --git a/frontend/desktop/src/components/account/AccountCenter/SettingInputRightElement.tsx b/frontend/desktop/src/components/account/AccountCenter/SettingInputRightElement.tsx new file mode 100644 index 00000000000..149e1ec2cb0 --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/SettingInputRightElement.tsx @@ -0,0 +1,25 @@ +import { + InputProps, + Flex, + Input, + forwardRef, + InputGroup, + InputGroupProps, + InputRightElement, + InputRightElementProps +} from '@chakra-ui/react'; + +export const SettingInputRightElement = forwardRef( + function SettingInputRightElement({ ...props }, ref) { + return ( + + ); + } +); diff --git a/frontend/desktop/src/components/account/AccountCenter/SmsModify/SmsBind.tsx b/frontend/desktop/src/components/account/AccountCenter/SmsModify/SmsBind.tsx new file mode 100644 index 00000000000..5c5e20919f7 --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/SmsModify/SmsBind.tsx @@ -0,0 +1,204 @@ +import { + Button, + FormControl, + FormLabel, + HStack, + Spinner, + VStack, + Text, + Link +} from '@chakra-ui/react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCustomToast } from '@/hooks/useCustomToast'; +import { useTranslation } from 'next-i18next'; +import { useForm } from 'react-hook-form'; +import { getSmsBindCodeRequest, verifySmsBindRequest } from '@/api/auth'; +import { SettingInput } from '../SettingInput'; +import { MouseEventHandler } from 'react'; + +import { SettingInputGroup } from '../SettingInputGroup'; +import { SettingInputRightElement } from '../SettingInputRightElement'; +import { useTimer } from '@/hooks/useTimer'; +import { ApiResp } from '@/types'; +import useCallbackStore, { MergeUserStatus } from '@/stores/callback'; +import { ProviderType } from 'prisma/global/generated/client'; +import { SmsType } from '@/services/backend/db/verifyCode'; +import { smsIdValid } from './utils'; +import { MERGE_USER_READY } from '@/types/response/utils'; + +const smsBindGen = (smsType: SmsType) => + function SmsBindCore({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + const { toast } = useCustomToast({ status: 'error' }); + + const { register, handleSubmit, trigger, getValues, reset, formState } = useForm<{ + id: string; + verifyCode: string; + }>(); + const { seconds, startTimer, isRunning } = useTimer({ + duration: 60, + step: 1 + }); + const { setMergeUserData, setMergeUserStatus } = useCallbackStore(); + const getCodeMutation = useMutation({ + mutationFn({ id, smsType }: { id: string; smsType: SmsType }) { + return getSmsBindCodeRequest(smsType)({ id }); + }, + onSuccess(data) { + startTimer(); + toast({ + status: 'success', + title: t('Already Sent Code') + }); + }, + onError(err) { + getCodeMutation.reset(); + toast({ + status: 'error', + title: t('Get code failed') + }); + } + }); + const remainTime = 60 - seconds; + const getCode: MouseEventHandler = async (e) => { + e.preventDefault(); + if (isRunning) { + toast({ + status: 'error', + title: t('Already Sent Code') + }); + return; + } + if (!(await trigger('id'))) { + toast({ + status: 'error', + title: smsType === 'phone' ? t('Invalid phone number') : t('Invalid Email') + }); + return; + } + const id = getValues('id'); + getCodeMutation.mutate({ + id, + smsType + }); + }; + const queryClient = useQueryClient(); + const mutation = useMutation({ + async mutationFn({ smsType, ...data }: { id: string; code: string; smsType: SmsType }) { + return verifySmsBindRequest(smsType)(data); + }, + async onSuccess(data) { + const status = data.message || ''; + if (data.code === 200) { + toast({ + status: 'success', + title: t('Bind Success') + }); + reset(); + await queryClient.invalidateQueries(); + onClose?.(); + } else if (Object.values(MERGE_USER_READY).includes(status as MERGE_USER_READY)) { + if (status === MERGE_USER_READY.MERGE_USER_CONTINUE) { + const code = data.data?.code; + if (!code) return; + setMergeUserStatus(MergeUserStatus.CANMERGE); + setMergeUserData({ + code, + providerType: ProviderType.PHONE + }); + } else { + setMergeUserStatus(MergeUserStatus.CONFLICT); + } + onClose?.(); + } + }, + onError(error) { + toast({ title: (error as ApiResp).message, status: 'error' }); + } + }); + + return mutation.isLoading ? ( + + ) : ( + <> +
{ + mutation.mutate({ + id: data.id, + code: data.verifyCode, + smsType + }); + }, + (errors) => { + if (errors.id) { + if (smsType === 'email') return toast({ title: t('Invalid Email') }); + else return toast({ title: t('Invalid phone number') }); + } + if (errors.verifyCode) return toast({ title: t('verify code tips') }); + } + )} + > + + + + {smsType === 'phone' ? t('Phone') : t('Email')} + + + + { + + {t('Get Code')} + + } + + + + + + + {t('verifyCode')} + + v.length === 6 + })} + autoComplete="one-time-code" + flex={'1'} + type="text" + > + + {isRunning && {remainTime} s} + + + + + + +
+ + ); + }; +export const PhoneBind = smsBindGen('phone'); +export const EmailBind = smsBindGen('email'); diff --git a/frontend/desktop/src/components/account/AccountCenter/SmsModify/SmsChange.tsx b/frontend/desktop/src/components/account/AccountCenter/SmsModify/SmsChange.tsx new file mode 100644 index 00000000000..9b626bdfe7d --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/SmsModify/SmsChange.tsx @@ -0,0 +1,389 @@ +import { + Button, + FlexProps, + FormControl, + FormLabel, + HStack, + Spinner, + VStack, + Text, + Link +} from '@chakra-ui/react'; +import { useMutation } from '@tanstack/react-query'; +import { useCustomToast } from '@/hooks/useCustomToast'; +import { ApiResp } from '@/types'; +import { useTranslation } from 'next-i18next'; +import { useForm } from 'react-hook-form'; +import { SettingInput } from '../SettingInput'; +import { MouseEventHandler, useState } from 'react'; + +import { SettingInputGroup } from '../SettingInputGroup'; +import { SettingInputRightElement } from '../SettingInputRightElement'; +import { useTimer } from '@/hooks/useTimer'; +import { + getNewSmsCodeRequest, + getOldSmsCodeRequest, + verifyNewSmsRequest, + verifyOldSmsRequest +} from '@/api/auth'; +import useCallbackStore, { MergeUserStatus } from '@/stores/callback'; +import { ProviderType } from 'prisma/global/generated/client'; +import { SmsType } from '@/services/backend/db/verifyCode'; +import { smsIdValid } from './utils'; +import { MERGE_USER_READY } from '@/types/response/utils'; +enum PageState { + VERIFY_OLD, + VERIFY_NEW +} +function OldSms({ smsType, onSuccess }: { smsType: SmsType; onSuccess?: (uid: string) => void }) { + const { t } = useTranslation(); + const { toast } = useCustomToast({ status: 'error' }); + const { register, handleSubmit, trigger, getValues, reset, formState } = useForm<{ + id: string; + verifyCode: string; + }>(); + const { seconds, startTimer, isRunning } = useTimer({ + duration: 60, + step: 1 + }); + + const getCodeMutation = useMutation({ + mutationFn({ smsType, id }: { id: string; smsType: SmsType }) { + return getOldSmsCodeRequest(smsType)({ id }); + }, + onSuccess(data) { + startTimer(); + toast({ + status: 'success', + title: t('Already Sent Code') + }); + }, + onError(err) { + getCodeMutation.reset(); + toast({ + status: 'error', + title: t('Get code failed') + }); + } + }); + const remainTime = 60 - seconds; + const getCode: MouseEventHandler = async (e) => { + e.preventDefault(); + if (isRunning) { + toast({ + status: 'error', + title: t('Already Sent Code') + }); + return; + } + if (!(await trigger('id'))) { + toast({ + status: 'error', + title: t('Get code failed') + }); + return; + } + const id = getValues('id'); + getCodeMutation.mutate({ id, smsType }); + }; + const mutation = useMutation({ + mutationFn({ smsType, ...data }: { smsType: SmsType; id: string; code: string }) { + return verifyOldSmsRequest(smsType)(data); + }, + onSuccess(data) { + const status = data.message || ''; + if (data.code === 200) { + reset(); + onSuccess?.(data?.data?.uid || ''); + } + }, + onError(error) { + toast({ title: (error as ApiResp).message }); + } + }); + + return mutation.isLoading ? ( + + ) : ( +
{ + mutation.mutate({ + id: data.id, + code: data.verifyCode, + smsType + }); + }, + (errors) => { + if (errors.id) { + if (smsType === 'phone') return toast({ title: t('Invalid phone number') }); + else return toast({ title: t('Invalid Email') }); + } + if (errors.verifyCode) return toast({ title: t('verify code tips') }); + } + )} + > + + + + + {smsType === 'phone' ? t('Old Phone') : t('Old Email')} + + + + + { + + {t('Get Code')} + + } + + + + + + + {t('verifyCode')} + + v.length === 6 + })} + autoComplete="one-time-code" + flex={'1'} + type="text" + > + + {isRunning && {remainTime} s} + + + + + + +
+ ); +} +function NewSms({ + smsType = 'phone', + uid, + onSuccess: onClose, + onError +}: { + uid: string; + smsType?: SmsType; + onSuccess?: () => void; + onError?: () => void; +}) { + const { t } = useTranslation(); + const { toast } = useCustomToast({ status: 'error' }); + const { setMergeUserData, setMergeUserStatus } = useCallbackStore(); + const { register, handleSubmit, trigger, getValues, reset, formState } = useForm<{ + id: string; + verifyCode: string; + }>(); + const { seconds, startTimer, isRunning } = useTimer({ + duration: 60, + step: 1 + }); + + const getCodeMutation = useMutation({ + mutationFn({ id, smsType }: { id: string; smsType: SmsType }) { + return getNewSmsCodeRequest(smsType)({ id, uid }); + }, + onSuccess(data) { + startTimer(); + toast({ + status: 'success', + title: t('Already Sent Code') + }); + }, + onError(err) { + getCodeMutation.reset(); + toast({ + status: 'error', + title: t('Get code failed') + }); + } + }); + const remainTime = 60 - seconds; + const getCode: MouseEventHandler = async (e) => { + e.preventDefault(); + if (isRunning) { + toast({ + status: 'error', + title: t('Already Sent Code') + }); + return; + } + if (!(await trigger('id'))) { + toast({ + status: 'error', + title: smsType === 'phone' ? t('Invalid phone number') : t('Invalid Email') + }); + return; + } + const id = getValues('id'); + getCodeMutation.mutate({ + id, + smsType + }); + }; + const mutation = useMutation(verifyNewSmsRequest(smsType), { + onSuccess(data) { + const status = data.message || ''; + if (data.code === 200) { + toast({ + status: 'success', + title: smsType === 'phone' ? t('phoneChangeSuccess') : t('emailChangeSuccess') + }); + reset(); + onClose?.(); + } else if (Object.values(MERGE_USER_READY).includes(status as MERGE_USER_READY)) { + // onSuccess?.() + if (status === MERGE_USER_READY.MERGE_USER_CONTINUE) { + setMergeUserStatus(MergeUserStatus.CANMERGE); + setMergeUserData({ + code: data.data.code, + providerType: ProviderType.PHONE + }); + } else { + setMergeUserStatus(MergeUserStatus.CONFLICT); + } + } + }, + onError(error) { + toast({ title: (error as ApiResp).message }); + } + }); + + return mutation.isLoading ? ( + + ) : ( +
{ + mutation.mutate({ + id: data.id, + code: data.verifyCode, + uid + }); + }, + (errors) => { + if (errors.id) { + if (smsType === 'phone') return toast({ title: t('Invalid phone number') }); + else return toast({ title: t('Invalid Email') }); + } + if (errors.verifyCode) return toast({ title: t('verify code tips') }); + } + )} + > + + + + + {smsType === 'phone' ? t('New Phone') : t('New Email')} + + + + + { + + {t('Get Code')} + + } + + + + + + + {t('verifyCode')} + + v.length === 6 + })} + autoComplete="one-time-code" + flex={'1'} + type="text" + > + + {isRunning && {remainTime} s} + + + + + + +
+ ); +} +const smsChangeGen = (smsType: SmsType) => + function SmsChangeCore({ onClose }: { onClose: () => void }) { + const [pageState, setPageState] = useState(PageState.VERIFY_OLD); + const [codeUid, setCodeUid] = useState(''); + return pageState === PageState.VERIFY_OLD ? ( + { + setCodeUid(uid); + setPageState(PageState.VERIFY_NEW); + }} + /> + ) : ( + { + setPageState(PageState.VERIFY_OLD); + onClose(); + }} + /> + ); + }; + +export const PhoneChange = smsChangeGen('phone'); +export const EmailChange = smsChangeGen('email'); diff --git a/frontend/desktop/src/components/account/AccountCenter/SmsModify/SmsUnbind.tsx b/frontend/desktop/src/components/account/AccountCenter/SmsModify/SmsUnbind.tsx new file mode 100644 index 00000000000..94bffad0228 --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/SmsModify/SmsUnbind.tsx @@ -0,0 +1,187 @@ +import { + Button, + FormControl, + FormLabel, + HStack, + Spinner, + VStack, + Text, + Link +} from '@chakra-ui/react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCustomToast } from '@/hooks/useCustomToast'; +import { useTranslation } from 'next-i18next'; +import { useForm } from 'react-hook-form'; +import { getSmsUnbindCodeRequest, verifySmsUnbindRequest } from '@/api/auth'; +import { SettingInput } from '../SettingInput'; +import { MouseEventHandler } from 'react'; + +import { SettingInputGroup } from '../SettingInputGroup'; +import { SettingInputRightElement } from '../SettingInputRightElement'; +import { useTimer } from '@/hooks/useTimer'; +import { ApiResp } from '@/types'; +import { SmsType } from '@/services/backend/db/verifyCode'; +import { smsIdValid } from './utils'; + +const smsUnBindGen = (smsType: SmsType) => + function UnBindCore({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + const { toast } = useCustomToast({ status: 'error' }); + + const { register, handleSubmit, trigger, getValues, reset, formState } = useForm<{ + id: string; + verifyCode: string; + }>(); + const { seconds, startTimer, isRunning } = useTimer({ + duration: 60, + step: 1 + }); + + const getCodeMutation = useMutation({ + mutationFn({ id, smsType }: { id: string; smsType: SmsType }) { + return getSmsUnbindCodeRequest(smsType)({ id }); + }, + onSuccess(data) { + startTimer(); + toast({ + status: 'success', + title: t('Already Sent Code') + }); + }, + onError(err) { + getCodeMutation.reset(); + toast({ + status: 'error', + title: t('Get code failed') + }); + } + }); + const remainTime = 60 - seconds; + const getCode: MouseEventHandler = async (e) => { + e.preventDefault(); + if (isRunning) { + toast({ + status: 'error', + title: t('Already Sent Code') + }); + return; + } + if (!(await trigger('id'))) { + toast({ + status: 'error', + title: t('Get code failed') + }); + return; + } + const id = getValues('id'); + getCodeMutation.mutate({ + id, + smsType + }); + }; + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn({ smsType, ...data }: { id: string; smsType: SmsType; code: string }) { + return verifySmsUnbindRequest(smsType)(data); + }, + async onSuccess(data) { + if (data.code === 200) { + toast({ + status: 'success', + title: t('Unbind Success') + }); + reset(); + await queryClient.invalidateQueries(); + onClose?.(); + } + }, + onError(error) { + toast({ title: (error as ApiResp).message }); + } + }); + + return mutation.isLoading ? ( + + ) : ( + <> +
{ + mutation.mutate({ + id: data.id, + code: data.verifyCode, + smsType + }); + }, + (errors) => { + if (errors.id) { + if (smsType === 'email') return toast({ title: t('Invalid Email') }); + else return toast({ title: t('Invalid phone number') }); + } + if (errors.verifyCode) return toast({ title: t('verify code tips') }); + } + )} + > + + + + {smsType === 'phone' ? t('Phone') : t('Email')} + + + + { + + {t('Get Code')} + + } + + + + + + + {t('verifyCode')} + + v.length === 6 + })} + autoComplete="one-time-code" + flex={'1'} + type="text" + > + + {isRunning && {remainTime} s} + + + + + + +
+ + ); + }; +export const PhoneUnBind = smsUnBindGen('phone'); +export const EmailUnBind = smsUnBindGen('email'); diff --git a/frontend/desktop/src/components/account/AccountCenter/SmsModify/utils.ts b/frontend/desktop/src/components/account/AccountCenter/SmsModify/utils.ts new file mode 100644 index 00000000000..44433a9d3d0 --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/SmsModify/utils.ts @@ -0,0 +1,20 @@ +import { SmsType } from '@/services/backend/db/verifyCode'; + +export const smsIdValid = (smsType: SmsType) => { + if (smsType === 'phone') + return { + pattern: { + value: /^1[3-9]\d{9}$/, + message: 'Invalid Phone Number' + }, + required: 'Phone number can not be blank' + }; + else + return { + pattern: { + value: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, + message: 'Invalid Email Address' + }, + required: 'Email Address can not be blank' + }; +}; diff --git a/frontend/desktop/src/components/account/AccountCenter/index.tsx b/frontend/desktop/src/components/account/AccountCenter/index.tsx new file mode 100644 index 00000000000..66183b34e9a --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/index.tsx @@ -0,0 +1,347 @@ +import { + Flex, + Text, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalOverlay, + useDisclosure, + IconButton, + IconButtonProps, + ModalHeader, + Spinner, + Image, + HStack, + VStack, + Center +} from '@chakra-ui/react'; +import { type FC, useMemo, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import useSessionStore from '@/stores/session'; +import { useTranslation } from 'next-i18next'; +import { SettingIcon, LeftArrowIcon, GithubIcon, GoogleIcon, WechatIcon } from '@sealos/ui'; +import LangSelectList from '@/components/LangSelect'; +import { UserInfo } from '@/api/auth'; +import PasswordModify from '@/components/account/AccountCenter/PasswordModify'; +import { PhoneBind, EmailBind } from './SmsModify/SmsBind'; +import { PhoneUnBind, EmailUnBind } from './SmsModify/SmsUnbind'; +import { useConfigStore } from '@/stores/config'; +import { PhoneChange, EmailChange } from './SmsModify/SmsChange'; +import { BindingModifyButton, BINDING_STATE_MODIFY_BEHAVIOR } from './BindingModifyButton'; +import { ConfigItem } from './ConfigItem'; +import { AuthModifyList } from './AuthModifyList'; +import { useRouter } from 'next/router'; +import DeleteAccount from './DeleteAccountModal'; +import { ValueOf } from '@/types'; +enum _PageState { + INDEX = 0 + // WECHAT_BIND, + // WECHAT_UNBIND, + // GITHUB_UNBIND, + // GITHUB_BIND +} +enum PasswordState { + PASSWORD = 10 +} +enum PhoneState { + PHONE_BIND = 20, + PHONE_UNBIND, + PHONE_CHANGE_BIND +} +enum EmailState { + EMAIL_BIND = 30, + EMAIL_UNBIND, + EMAIL_CHANGE_BIND +} + +const PageState = Object.assign( + Object.assign({}, _PageState, EmailState, PhoneState), + PasswordState +); + +export default function Index(props: Omit) { + const { session } = useSessionStore((s) => s); + const { t } = useTranslation(); + const logo = '/images/default-user.svg'; + const { isOpen, onOpen, onClose } = useDisclosure(); + const [pageState, setPageState] = useState>(PageState.INDEX); + const resetPageState = () => { + setPageState(PageState.INDEX); + infoData.refetch(); + }; + const modalTitle = useMemo(() => { + if (pageState === PageState.INDEX) return t('Account Settings'); + else if (pageState === PageState.PASSWORD) return t('changePassword'); + else if (Object.values(PhoneState).includes(pageState as PhoneState)) + return t('changePhone'); // bind or unbind + else if (Object.values(EmailState).includes(pageState as EmailState)) return t('changeEmail'); + else return ''; + }, [t, pageState]); + const queryClient = useQueryClient(); + const infoData = useQuery({ + queryFn: UserInfo, + queryKey: [session?.token, 'UserInfo'], + select(d) { + return d.data?.info; + } + }); + + const providerState = useMemo(() => { + const providerList = ['PHONE', 'PASSWORD', 'GITHUB', 'WECHAT', 'GOOGLE', 'EMAIL'] as const; + const state = (infoData.data?.oauthProvider || []).reduce( + (pre, cur) => { + const { providerType, providerId } = cur; + // @ts-ignore + if (!providerList.includes(providerType)) return pre; + // @ts-ignore + pre[providerType].isBinding = true; + if (providerType === 'PHONE' || providerType === 'EMAIL') pre[providerType].id = providerId; + pre.total += 1; + return pre; + }, + { + total: 0, + PHONE: { + isBinding: false, + id: '' + }, + EMAIL: { + isBinding: false, + id: '' + }, + GITHUB: { + isBinding: false + }, + WECHAT: { + isBinding: false + }, + GOOGLE: { + isBinding: false + }, + PASSWORD: { + isBinding: false + }, + AVATAR_URL: '' + } + ); + state.AVATAR_URL = infoData.data?.avatarUri || ''; + return state; + }, [infoData.data?.oauthProvider]); + + return ( + <> + { + e.preventDefault(); + return onOpen(); + }} + {...props} + variant={'white-bg-icon'} + icon={} + /> + + + + + + + {pageState !== PageState.INDEX && ( + { + setPageState(PageState.INDEX); + }} + /> + )} + + {modalTitle} + + + + {infoData.isSuccess && infoData.data ? ( + + {pageState === PageState.INDEX ? ( + + {/* */} + + {t('Avatar')} + +
+ user avator +
+
+
+ + {t('Nickname')} + {infoData.data.nickname} + + + {'ID'} + {infoData.data.id} + + {providerState.PASSWORD.isBinding && ( + {t('Password')}} + RightElement={ + <> + ********* + { + setPageState(PageState.PASSWORD); + }} + /> + + } + /> + )} + {t('Phone')}} + RightElement={ + <> + + {providerState.PHONE.isBinding + ? providerState.PHONE.id.replace(/(\d{3})\d+(\d{4})/, '$1****$2') + : t('Unbound')} + + + { + providerState.PHONE.isBinding + ? setPageState(PageState.PHONE_CHANGE_BIND) + : setPageState(PageState.PHONE_BIND); + }} + /> + {providerState.PHONE.isBinding && providerState.total > 1 && ( + { + setPageState(PageState.PHONE_UNBIND); + }} + /> + )} + + + } + /> + {t('Email')}} + RightElement={ + <> + + {providerState.EMAIL.isBinding + ? providerState.EMAIL.id.replace(/(\d{3})\d+(\d{4})/, '$1****$2') + : t('Unbound')} + + + { + providerState.EMAIL.isBinding + ? setPageState(PageState.EMAIL_CHANGE_BIND) + : setPageState(PageState.EMAIL_BIND); + }} + /> + {providerState.EMAIL.isBinding && providerState.total > 1 && ( + { + setPageState(PageState.EMAIL_UNBIND); + }} + /> + )} + + + } + /> + + {t('Delete account')}} + RightElement={ + <> + {t('Delete Account Tips')} + + + } + /> +
+ ) : ( + + {pageState === PageState.PASSWORD ? ( + + ) : pageState === PageState.PHONE_BIND ? ( + + ) : pageState === PageState.PHONE_UNBIND ? ( + + ) : pageState === PageState.PHONE_CHANGE_BIND ? ( + + ) : pageState === PageState.EMAIL_BIND ? ( + + ) : pageState === PageState.EMAIL_UNBIND ? ( + + ) : pageState === PageState.EMAIL_CHANGE_BIND ? ( + + ) : null} + + )} +
+ ) : ( + + )} +
+
+ + ); +} diff --git a/frontend/desktop/src/components/account/AccountCenter/mergeUser/NeedToMergeModal.tsx b/frontend/desktop/src/components/account/AccountCenter/mergeUser/NeedToMergeModal.tsx new file mode 100644 index 00000000000..1d25913c988 --- /dev/null +++ b/frontend/desktop/src/components/account/AccountCenter/mergeUser/NeedToMergeModal.tsx @@ -0,0 +1,145 @@ +import { useCustomToast } from '@/hooks/useCustomToast'; +import { + Text, + Button, + Modal, + ModalOverlay, + ModalContent, + ModalCloseButton, + ModalHeader, + Spinner, + ModalBody, + BoxProps, + VStack, + HStack +} from '@chakra-ui/react'; +import { WarnTriangeIcon } from '@sealos/ui'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { useTranslation } from 'next-i18next'; +import { mergeUserRequest } from '@/api/auth'; +import useCallbackStore, { MergeUserStatus } from '@/stores/callback'; +import { useEffect, useState } from 'react'; +import { USER_MERGE_STATUS } from '@/types/response/merge'; +import { ValueOf } from '@/types'; + +function NeedToMerge({ ...props }: BoxProps & {}) { + const { mergeUserStatus, mergeUserData, setMergeUserStatus, setMergeUserData } = + useCallbackStore(); + const [isOpen, setIsOpen] = useState(false); + + const onClose = () => { + setMergeUserStatus(MergeUserStatus.IDLE); + }; + + const { t } = useTranslation(); + const errorT = useTranslation('error').t; + const queryClient = useQueryClient(); + const { toast } = useCustomToast({ status: 'error' }); + const mutation = useMutation({ + mutationFn: mergeUserRequest, + onSuccess() { + queryClient.clear(); + }, + onError(err: { message: ValueOf }) { + toast({ + status: 'error', + title: errorT(err.message) + }); + }, + onSettled() { + setMergeUserData(); + setMergeUserStatus(MergeUserStatus.IDLE); + } + }); + useEffect(() => { + setIsOpen(!![MergeUserStatus.CONFLICT, MergeUserStatus.CANMERGE].includes(mergeUserStatus)); + }, [mergeUserStatus]); + return ( + + + + + + + {t('Merge Account Title')} + + {mutation.isLoading ? ( + + ) : ( + + + + {mergeUserStatus === MergeUserStatus.CONFLICT + ? t('MergeAccountTips1') + : t('DeleteAccountTips2')} + + {mergeUserStatus === MergeUserStatus.CONFLICT ? ( + + + + ) : ( + + + + + )} + + + )} + + + ); +} +export default NeedToMerge; diff --git a/frontend/desktop/src/components/account/PasswordModify.tsx b/frontend/desktop/src/components/account/PasswordModify.tsx deleted file mode 100644 index f9efe451de5..00000000000 --- a/frontend/desktop/src/components/account/PasswordModify.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { - Button, - Flex, - FlexProps, - Image, - Input, - InputGroup, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, - Spinner, - Stack, - useDisclosure -} from '@chakra-ui/react'; -import { useMutation } from '@tanstack/react-query'; -import { useCustomToast } from '@/hooks/useCustomToast'; -import { ApiResp } from '@/types'; -import { useTranslation } from 'react-i18next'; -import { useForm } from 'react-hook-form'; -import { strongPassword } from '@/utils/crypto'; -import { passwordModifyRequest } from '@/api/auth'; -export default function PasswordModify(props: FlexProps) { - const { onOpen, isOpen, onClose } = useDisclosure(); - const { t } = useTranslation(); - const { toast } = useCustomToast({ status: 'error' }); - const mutation = useMutation(passwordModifyRequest, { - onSuccess(data) { - if (data.code === 200) { - toast({ - status: 'success', - title: t('passwordChangeSuccess') - }); - onClose(); - } - }, - onError(error) { - toast({ title: (error as ApiResp).message }); - } - }); - const { register, handleSubmit, reset } = useForm<{ - oldPassword: string; - newPassword: string; - againPassword: string; - }>(); - return ( - <> - { - reset(); - onOpen(); - }} - > - {t('change')} - - - - - - - {t('changePassword')} - - {mutation.isLoading ? ( - - ) : ( - <> - -
{ - mutation.mutate({ oldPassword, newPassword }); - }, - (errors) => { - if (errors.oldPassword) return toast({ title: t('currentPasswordRequired') }); - if (errors.newPassword) return toast({ title: t('password tips') }); - if (errors.againPassword) return toast({ title: t('passwordMismatch') }); - } - )} - > - - - - - - - - - - - - -
-
- - )} -
-
- - ); -} diff --git a/frontend/desktop/src/components/account/index.tsx b/frontend/desktop/src/components/account/index.tsx index 85d7e11b75c..b3381c4ea2b 100644 --- a/frontend/desktop/src/components/account/index.tsx +++ b/frontend/desktop/src/components/account/index.tsx @@ -4,14 +4,7 @@ import { useConfigStore } from '@/stores/config'; import useSessionStore from '@/stores/session'; import download from '@/utils/downloadFIle'; import { Box, Center, Flex, IconButton, Image, Text, useDisclosure } from '@chakra-ui/react'; -import { - CopyIcon, - DocsIcon, - DownloadIcon, - LogoutIcon, - NotificationIcon, - SettingIcon -} from '@sealos/ui'; +import { CopyIcon, DocsIcon, DownloadIcon, LogoutIcon, NotificationIcon } from '@sealos/ui'; import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'next-i18next'; import { useRouter } from 'next/router'; @@ -20,10 +13,10 @@ import LangSelectSimple from '../LangSelect/simple'; import { blurBackgroundStyles } from '../desktop_content'; import RegionToggle from '../region/RegionToggle'; import WorkspaceToggle from '../team/WorkspaceToggle'; -import PasswordModify from './PasswordModify'; import GithubComponent from './github'; import { ArrowIcon } from '../icons'; import useAppStore from '@/stores/app'; +import AccountCenter from './AccountCenter'; const baseItemStyle = { w: '52px', @@ -39,8 +32,6 @@ const baseItemStyle = { export default function Account() { const { layoutConfig } = useConfigStore(); const [showId, setShowId] = useState(true); - const passwordEnabled = useConfigStore().authConfig?.idp?.password?.enabled; - const router = useRouter(); const { copyData } = useCopyData(); const { t } = useTranslation(); @@ -159,13 +150,7 @@ export default function Account() { px={'16px'} > {t('Account Settings')} - kubeconfig && copyData(kubeconfig)} - icon={} - aria-label={'setting'} - /> + )} {layoutConfig?.common.workorderEnabled && ( @@ -225,20 +210,6 @@ export default function Account() { /> - {/* {passwordEnabled && ( - - {t('changePassword')} - - - )} */} import('../AppDock'), { ssr: false }); const FloatButton = dynamic(() => import('@/components/floating_button'), { ssr: false }); @@ -240,6 +241,8 @@ export default function Desktop(props: any) { ); })} + {/* modal */} + ); } diff --git a/frontend/desktop/src/components/signin/auth/AuthList.tsx b/frontend/desktop/src/components/signin/auth/AuthList.tsx index 49b721dfabc..6f9b7edb746 100644 --- a/frontend/desktop/src/components/signin/auth/AuthList.tsx +++ b/frontend/desktop/src/components/signin/auth/AuthList.tsx @@ -32,8 +32,10 @@ const AuthList = () => { setProvider(provider); const target = new URL(conf.proxyAddress); const callback = new URL(conf.callbackURL); - callback.searchParams.append('state', state); - target.searchParams.append('oauthProxyState', callback.toString()); + target.searchParams.append( + 'oauthProxyState', + encodeURIComponent(callback.toString()) + '_' + state + ); target.searchParams.append('oauthProxyClientID', id); target.searchParams.append('oauthProxyProvider', provider); router.replace(target.toString()); @@ -47,13 +49,13 @@ const AuthList = () => { const githubConf = conf?.idp.github; if (conf?.proxyAddress) oauthProxyLogin({ - provider: 'github', + provider: 'GITHUB', state, id: githubConf?.clientID as string }); else oauthLogin({ - provider: 'github', + provider: 'GITHUB', url: `https://github.com/login/oauth/authorize?client_id=${githubConf?.clientID}&redirect_uri=${conf?.callbackURL}&scope=user:email%20read:user&state=${state}` }); }, @@ -67,13 +69,13 @@ const AuthList = () => { const state = generateState(); if (conf.proxyAddress) oauthProxyLogin({ - provider: 'wechat', + provider: 'WECHAT', state, id: conf.idp.wechat?.clientID }); else oauthLogin({ - provider: 'wechat', + provider: 'WECHAT', url: `https://open.weixin.qq.com/connect/qrconnect?appid=${wechatConf?.clientID}&redirect_uri=${conf?.callbackURL}&response_type=code&state=${state}&scope=snsapi_login&#wechat_redirect` }); }, @@ -91,17 +93,13 @@ const AuthList = () => { if (conf.proxyAddress) oauthProxyLogin({ state, - provider: 'google', - id: googleConf?.clientID as string + provider: 'GOOGLE', + id: googleConf.clientID }); else oauthLogin({ - provider: 'google', - url: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${ - googleConf?.clientID as string - }&redirect_uri=${ - conf?.callbackURL - }&response_type=code&state=${state}&scope=${scope}&include_granted_scopes=true` + provider: 'GOOGLE', + url: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${googleConf.clientID}&redirect_uri=${conf.callbackURL}&response_type=code&state=${state}&scope=${scope}&include_granted_scopes=true` }); }, need: conf.idp.google.enabled as boolean @@ -118,20 +116,20 @@ const AuthList = () => { const oauth2Conf = conf?.idp.oauth2; if (conf.proxyAddress) oauthProxyLogin({ - provider: 'oauth2', + provider: 'OAUTH2', state, id: oauth2Conf.clientID as string }); else oauthLogin({ - provider: 'oauth2', + provider: 'OAUTH2', url: `${oauth2Conf?.authURL}?client_id=${oauth2Conf.clientID}&redirect_uri=${oauth2Conf.callbackURL}&response_type=code&state=${state}` }); }, need: conf.idp.oauth2?.enabled as boolean } ]; - }, [conf, generateState, logo, router, setProvider]); + }, [conf, logo, router]); return ( diff --git a/frontend/desktop/src/components/signin/auth/useSms.tsx b/frontend/desktop/src/components/signin/auth/useSms.tsx index a1ab7d6e2f7..6f9401695f7 100644 --- a/frontend/desktop/src/components/signin/auth/useSms.tsx +++ b/frontend/desktop/src/components/signin/auth/useSms.tsx @@ -53,7 +53,7 @@ export default function useSms({ const result1 = await request.post>( '/api/auth/phone/verify', { - phoneNumbers: data.phoneNumber, + id: data.phoneNumber, code: data.verifyCode, inviterId: getInviterId() } @@ -116,7 +116,7 @@ export default function useSms({ try { const cfToken = getCfToken?.(); const res = await request.post>('/api/auth/phone/sms', { - phoneNumbers: getValues('phoneNumber'), + id: getValues('phoneNumber'), cfToken }); if (res.code !== 200 || res.message !== 'successfully') { diff --git a/frontend/desktop/src/components/team/CreateTeam.tsx b/frontend/desktop/src/components/team/CreateTeam.tsx index b9eed46db76..7898fc3db92 100644 --- a/frontend/desktop/src/components/team/CreateTeam.tsx +++ b/frontend/desktop/src/components/team/CreateTeam.tsx @@ -20,7 +20,7 @@ import useSessionStore from '@/stores/session'; import { createRequest } from '@/api/namespace'; import { useCustomToast } from '@/hooks/useCustomToast'; import { ApiResp } from '@/types'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; import { AddIcon, GroupAddIcon } from '@sealos/ui'; export default function CreateTeam({ textButton = false }: { textButton?: boolean }) { const { onOpen, isOpen, onClose } = useDisclosure(); diff --git a/frontend/desktop/src/components/team/DissolveTeam.tsx b/frontend/desktop/src/components/team/DissolveTeam.tsx index 1461d09d2f5..84d05a883e8 100644 --- a/frontend/desktop/src/components/team/DissolveTeam.tsx +++ b/frontend/desktop/src/components/team/DissolveTeam.tsx @@ -19,7 +19,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { deleteTeamRequest } from '@/api/namespace'; import useSessionStore from '@/stores/session'; import { ApiResp } from '@/types'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; import { DeleteIcon } from '@sealos/ui'; export default function DissolveTeam({ nsid, diff --git a/frontend/desktop/src/components/team/InviteMember.tsx b/frontend/desktop/src/components/team/InviteMember.tsx index 059adf638dd..0e39fa40cce 100644 --- a/frontend/desktop/src/components/team/InviteMember.tsx +++ b/frontend/desktop/src/components/team/InviteMember.tsx @@ -26,7 +26,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { getInviteCodeRequest, inviteMemberRequest } from '@/api/namespace'; import { vaildManage } from '@/utils/tools'; import { ApiResp } from '@/types'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; import { GroupAddIcon } from '@sealos/ui'; import { useCopyData } from '@/hooks/useCopyData'; diff --git a/frontend/desktop/src/components/team/ModifyRole.tsx b/frontend/desktop/src/components/team/ModifyRole.tsx index f217a52c8b1..6f40affe524 100644 --- a/frontend/desktop/src/components/team/ModifyRole.tsx +++ b/frontend/desktop/src/components/team/ModifyRole.tsx @@ -21,7 +21,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { modifyRoleRequest } from '@/api/namespace'; import { useCustomToast } from '@/hooks/useCustomToast'; import { ApiResp } from '@/types'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; import { ExpanMoreIcon } from '@sealos/ui'; export default function ModifyRole({ ns_uid, diff --git a/frontend/desktop/src/components/team/NsListItem.tsx b/frontend/desktop/src/components/team/NsListItem.tsx index 4c8408259bd..7989b41fb00 100644 --- a/frontend/desktop/src/components/team/NsListItem.tsx +++ b/frontend/desktop/src/components/team/NsListItem.tsx @@ -1,6 +1,6 @@ import { Box, Flex, FlexProps, HStack, Text, VStack } from '@chakra-ui/react'; import { useQueryClient } from '@tanstack/react-query'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; const NsListItem = ({ isSelected, diff --git a/frontend/desktop/src/components/team/ReciveMessage.tsx b/frontend/desktop/src/components/team/ReciveMessage.tsx index 06b0f1826cf..9c0a1f4b804 100644 --- a/frontend/desktop/src/components/team/ReciveMessage.tsx +++ b/frontend/desktop/src/components/team/ReciveMessage.tsx @@ -16,7 +16,7 @@ import { import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ROLE_LIST, teamMessageDto } from '@/types/team'; import { reciveAction, verifyInviteRequest } from '@/api/namespace'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; export default function ReciveMessage({ message, CloseTipHandler, diff --git a/frontend/desktop/src/components/team/RemoveMember.tsx b/frontend/desktop/src/components/team/RemoveMember.tsx index 1fcaeae1dea..0d82cda3da1 100644 --- a/frontend/desktop/src/components/team/RemoveMember.tsx +++ b/frontend/desktop/src/components/team/RemoveMember.tsx @@ -19,7 +19,7 @@ import { CancelIcon, DeleteIcon } from '@sealos/ui'; import { removeMemberRequest } from '@/api/namespace'; import { useCustomToast } from '@/hooks/useCustomToast'; import { ApiResp } from '@/types'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; export default function RemoveMember({ ns_uid, diff --git a/frontend/desktop/src/components/team/TeamCenter.tsx b/frontend/desktop/src/components/team/TeamCenter.tsx index 875a5968bad..87ff4975cbd 100644 --- a/frontend/desktop/src/components/team/TeamCenter.tsx +++ b/frontend/desktop/src/components/team/TeamCenter.tsx @@ -31,9 +31,8 @@ import { InvitedStatus, NSType, UserRole, teamMessageDto } from '@/types/team'; import { TeamUserDto } from '@/types/user'; import ReciveMessage from './ReciveMessage'; import { nsListRequest, reciveMessageRequest, teamDetailsRequest } from '@/api/namespace'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; import { CopyIcon, ListIcon, SettingIcon, StorageIcon } from '@sealos/ui'; -import { GetUserDefaultNameSpace } from '@/services/backend/kubernetes/user'; import NsListItem from '@/components/team/NsListItem'; export default function TeamCenter(props: StackProps) { diff --git a/frontend/desktop/src/components/team/WorkspaceToggle.tsx b/frontend/desktop/src/components/team/WorkspaceToggle.tsx index 13c579a7f87..be64906f934 100644 --- a/frontend/desktop/src/components/team/WorkspaceToggle.tsx +++ b/frontend/desktop/src/components/team/WorkspaceToggle.tsx @@ -27,7 +27,6 @@ export default function WorkspaceToggle() { async onSuccess(data) { if (data.code === 200 && !!data.data && session) { const payload = jwtDecode(data.data.token); - console.log(payload, session); await sessionConfig({ ...data.data, kubeconfig: switchKubeconfigNamespace(session.kubeconfig, payload.workspaceId) diff --git a/frontend/desktop/src/components/team/userTable.tsx b/frontend/desktop/src/components/team/userTable.tsx index 2c5165252bb..09d12ef0781 100644 --- a/frontend/desktop/src/components/team/userTable.tsx +++ b/frontend/desktop/src/components/team/userTable.tsx @@ -18,7 +18,7 @@ import { vaildManage } from '@/utils/tools'; import RemoveMember from './RemoveMember'; import Abdication from './Abdication'; import ModifyRole from './ModifyRole'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; import { useConfigStore } from '@/stores/config'; export default function UserTable({ diff --git a/frontend/desktop/src/hooks/useTimer.ts b/frontend/desktop/src/hooks/useTimer.ts new file mode 100644 index 00000000000..32a2a47d29f --- /dev/null +++ b/frontend/desktop/src/hooks/useTimer.ts @@ -0,0 +1,32 @@ +import { useState, useEffect } from 'react'; + +export const useTimer = ({ duration, step }: { duration: number; step: number }) => { + const [seconds, setSeconds] = useState(0); + const [start, setStart] = useState(seconds > 0); + useEffect(() => { + if (!start) return; + const interval = setInterval(() => { + // autoClear + if (seconds >= duration || !start) { + setSeconds(0); + setStart(false); + clearInterval(interval); + } + setSeconds((prevSeconds) => prevSeconds + step); + }, step * 1000); + + return () => clearInterval(interval); + }, [duration, seconds, start, step]); + + const resetTimer = () => { + setSeconds(0); + setStart(false); + }; + const startTimer = () => { + setStart(true); + }; + const stopTimer = () => { + setStart(true); + }; + return { seconds, resetTimer, startTimer, isRunning: start, stopTimer }; +}; diff --git a/frontend/desktop/src/pages/api/account/updateGuide.ts b/frontend/desktop/src/pages/api/account/updateGuide.ts index c3ef0606d69..8716e1afd02 100644 --- a/frontend/desktop/src/pages/api/account/updateGuide.ts +++ b/frontend/desktop/src/pages/api/account/updateGuide.ts @@ -30,7 +30,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) jsonRes(res, { data: reuslt?.body }); } catch (error) { - console.log(error, 'get user account err'); jsonRes(res, { code: 500, data: error }); } } diff --git a/frontend/desktop/src/pages/api/auth/delete/checkResource.ts b/frontend/desktop/src/pages/api/auth/delete/checkResource.ts new file mode 100644 index 00000000000..84be09e030c --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/delete/checkResource.ts @@ -0,0 +1,16 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { filterAccessToken, filterAuthenticationToken } from '@/services/backend/middleware/access'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { resourceGuard } from '@/services/backend/middleware/checkResource'; +import { jsonRes } from '@/services/backend/response'; +import { RESOURCE_STATUS } from '@/types/response/checkResource'; +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + await filterAuthenticationToken(req, res, async ({ userUid }) => { + await resourceGuard(userUid)(res, () => { + jsonRes(res, { + code: 200, + message: RESOURCE_STATUS.RESULT_SUCCESS + }); + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/delete/index.ts b/frontend/desktop/src/pages/api/auth/delete/index.ts new file mode 100644 index 00000000000..fc2d3053d0d --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/delete/index.ts @@ -0,0 +1,20 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { + otherRegionResourceGuard, + resourceGuard +} from '@/services/backend/middleware/checkResource'; +import { accountBalanceGuard } from '@/services/backend/middleware/amount'; +import { deleteUserSvc } from '@/services/backend/svc/deleteUser'; +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + await filterAccessToken(req, res, async ({ userUid, userId, userCrName }) => { + await accountBalanceGuard(userUid)(res, async () => { + await resourceGuard(userUid)(res, async () => { + await otherRegionResourceGuard(userUid, userId)(res, async () => { + await deleteUserSvc(userUid)(res); + }); + }); + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/email/bind/sms.ts b/frontend/desktop/src/pages/api/auth/email/bind/sms.ts new file mode 100644 index 00000000000..fac8154e629 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/email/bind/sms.ts @@ -0,0 +1,19 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { enableEmailSms } from '@/services/enable'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { filterCf, filterEmailParams, sendEmailCodeGuard } from '@/services/backend/middleware/sms'; +import { sendEmailCodeSvc } from '@/services/backend/svc/sms'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableEmailSms()) { + throw new Error('SMS is not enabled'); + } + await filterCf(req, res, async () => { + await filterAccessToken(req, res, () => + filterEmailParams(req, res, ({ email }) => + sendEmailCodeGuard(email)(res, () => sendEmailCodeSvc(email)(res)) + ) + ); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/email/bind/verify.ts b/frontend/desktop/src/pages/api/auth/email/bind/verify.ts new file mode 100644 index 00000000000..9c1f568d09c --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/email/bind/verify.ts @@ -0,0 +1,23 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableSms } from '@/services/enable'; +import { verifyEmailCodeGuard, filterEmailVerifyParams } from '@/services/backend/middleware/sms'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { bindEmailSvc, bindPhoneSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { bindEmailGuard, bindPhoneGuard } from '@/services/backend/middleware/oauth'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken( + req, + res, + async ({ userUid }) => + await filterEmailVerifyParams(req, res, async ({ email, code }) => { + await verifyEmailCodeGuard(email, code)(res, () => + bindEmailGuard(email, userUid)(res, () => bindEmailSvc(email, userUid)(res)) + ); + }) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/email/changeBinding/newSms.ts b/frontend/desktop/src/pages/api/auth/email/changeBinding/newSms.ts new file mode 100644 index 00000000000..faf1647b9e6 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/email/changeBinding/newSms.ts @@ -0,0 +1,24 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableSms } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { + verifyCodeUidGuard, + filterEmailParams, + sendEmailCodeGuard, + filterCodeUid, + sendNewEmailCodeGuard +} from '@/services/backend/middleware/sms'; +import { sendEmailCodeSvc } from '@/services/backend/svc/sms'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken(req, res, ({ userUid }) => + filterEmailParams(req, res, ({ email }) => + filterCodeUid(req, res, ({ uid }) => + sendNewEmailCodeGuard(uid, email)(res, () => sendEmailCodeSvc(email)(res)) + ) + ) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/email/changeBinding/oldSms.ts b/frontend/desktop/src/pages/api/auth/email/changeBinding/oldSms.ts new file mode 100644 index 00000000000..08958e261ee --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/email/changeBinding/oldSms.ts @@ -0,0 +1,20 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/services/backend/response'; +import { enableSms } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { sendEmailCodeGuard, filterEmailParams } from '@/services/backend/middleware/sms'; +import { sendEmailCodeSvc } from '@/services/backend/svc/sms'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { unbindEmailGuard } from '@/services/backend/middleware/oauth'; +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken(req, res, ({ userUid }) => + filterEmailParams(req, res, ({ email }) => + unbindEmailGuard(email, userUid)(res, () => + sendEmailCodeGuard(email)(res, () => sendEmailCodeSvc(email)(res)) + ) + ) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/email/changeBinding/verifyNew.ts b/frontend/desktop/src/pages/api/auth/email/changeBinding/verifyNew.ts new file mode 100644 index 00000000000..b9e6c7d7356 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/email/changeBinding/verifyNew.ts @@ -0,0 +1,33 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableSms } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { + verifyCodeUidGuard, + verifyEmailCodeGuard, + filterEmailVerifyParams, + filterCodeUid +} from '@/services/backend/middleware/sms'; +import { changeEmailBindingSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { bindEmailGuard, unbindEmailGuard } from '@/services/backend/middleware/oauth'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken(req, res, ({ userUid }) => + filterEmailVerifyParams(req, res, ({ email, code }) => + filterCodeUid(req, res, ({ uid }) => + verifyCodeUidGuard(uid)(res, ({ smsInfo: oldEmailInfo }) => + verifyEmailCodeGuard(email, code)(res, ({ smsInfo: newEmailInfo }) => + unbindEmailGuard(oldEmailInfo.id, userUid)(res, () => + bindEmailGuard(newEmailInfo.id, userUid)(res, () => + changeEmailBindingSvc(oldEmailInfo.id, newEmailInfo.id, userUid)(res) + ) + ) + ) + ) + ) + ) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/email/changeBinding/verifyOld.ts b/frontend/desktop/src/pages/api/auth/email/changeBinding/verifyOld.ts new file mode 100644 index 00000000000..cb3adc68db8 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/email/changeBinding/verifyOld.ts @@ -0,0 +1,31 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/services/backend/response'; +import { enableSms } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { filterEmailVerifyParams, verifyEmailCodeGuard } from '@/services/backend/middleware/sms'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { unbindEmailGuard } from '@/services/backend/middleware/oauth'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken( + req, + res, + async ({ userUid }) => + await filterEmailVerifyParams(req, res, async ({ email, code }) => { + await unbindEmailGuard(email, userUid)(res, async () => { + await verifyEmailCodeGuard(email, code)(res, async ({ smsInfo }) => { + return jsonRes(res, { + code: 200, + message: 'Successfully', + data: { + uid: smsInfo.uid + } + }); + }); + }); + }) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/email/unbind/sms.ts b/frontend/desktop/src/pages/api/auth/email/unbind/sms.ts new file mode 100644 index 00000000000..7c04a5f4bcd --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/email/unbind/sms.ts @@ -0,0 +1,22 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { sendEmailCodeGuard, filterEmailParams, filterCf } from '@/services/backend/middleware/sms'; +import { enableSms } from '@/services/enable'; +import { sendEmailCodeSvc } from '@/services/backend/svc/sms'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { unbindEmailGuard } from '@/services/backend/middleware/oauth'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken(req, res, ({ userUid }) => + filterCf(req, res, async () => + filterEmailParams(req, res, ({ email }) => + unbindEmailGuard(email, userUid)(res, () => + sendEmailCodeGuard(email)(res, () => sendEmailCodeSvc(email)(res)) + ) + ) + ) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/email/unbind/verify.ts b/frontend/desktop/src/pages/api/auth/email/unbind/verify.ts new file mode 100644 index 00000000000..aef6f20822a --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/email/unbind/verify.ts @@ -0,0 +1,19 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableSms } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { verifyEmailCodeGuard, filterEmailVerifyParams } from '@/services/backend/middleware/sms'; +import { unbindEmailSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken(req, res, ({ userUid }) => + filterEmailVerifyParams(req, res, async ({ email, code }) => + verifyEmailCodeGuard(email, code)(res, async ({ smsInfo }) => + unbindEmailSvc(smsInfo.id, userUid)(res) + ) + ) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/info.ts b/frontend/desktop/src/pages/api/auth/info.ts index d4d5f46534e..85ebadca940 100644 --- a/frontend/desktop/src/pages/api/auth/info.ts +++ b/frontend/desktop/src/pages/api/auth/info.ts @@ -1,6 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { jsonRes } from '@/services/backend/response'; import { globalPrisma, prisma } from '@/services/backend/db/init'; +import { ProviderType } from 'prisma/global/generated/client'; import { verifyAccessToken } from '@/services/backend/auth'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -20,6 +21,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) globalPrisma.user.findUnique({ where: { uid: regionUser.userUid + }, + include: { + oauthProvider: { + select: { + providerType: true, + providerId: true + } + } } }) ]); @@ -28,14 +37,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) code: 404, message: 'Not found' }); - else - return jsonRes(res, { - code: 200, - message: 'Successfully', - data: { - info: globalData - } - }); + const info = { + ...globalData, + oauthProvider: globalData.oauthProvider.map((o) => ({ + providerType: o.providerType, + providerId: ( + [ProviderType.PHONE, ProviderType.PASSWORD, ProviderType.EMAIL] as ProviderType[] + ).includes(o.providerType) + ? o.providerId + : '' + })) + }; + return jsonRes(res, { + code: 200, + message: 'Successfully', + data: { + info + } + }); } catch (err) { console.log(err); return jsonRes(res, { diff --git a/frontend/desktop/src/pages/api/auth/mergeUser.ts b/frontend/desktop/src/pages/api/auth/mergeUser.ts new file mode 100644 index 00000000000..9366ce71879 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/mergeUser.ts @@ -0,0 +1,22 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { accountBalanceGuard } from '@/services/backend/middleware/amount'; +import { filterMergeUser, mergeUserGuard } from '@/services/backend/middleware/mergeUser'; +import { mergeUserSvc } from '@/services/backend/svc/mergeUser'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + await filterAccessToken(req, res, async ({ userUid, userId, userCrName }) => { + await filterMergeUser(req, res, async ({ providerId, providerType }) => { + await mergeUserGuard( + userUid, + providerType, + providerId + )(res, async ({ mergeUserUid }) => { + await accountBalanceGuard(mergeUserUid)(res, async () => { + await mergeUserSvc(userUid, mergeUserUid)(res); + }); + }); + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/namespace/abdicate.ts b/frontend/desktop/src/pages/api/auth/namespace/abdicate.ts index 5d1d50c226e..568a8639663 100644 --- a/frontend/desktop/src/pages/api/auth/namespace/abdicate.ts +++ b/frontend/desktop/src/pages/api/auth/namespace/abdicate.ts @@ -1,5 +1,5 @@ import { jsonRes } from '@/services/backend/response'; -import { modifyTeamRole } from '@/services/backend/team'; +import { modifyWorkspaceRole } from '@/services/backend/team'; import { UserRole } from '@/types/team'; import { NextApiRequest, NextApiResponse } from 'next'; import { prisma } from '@/services/backend/db/init'; @@ -43,7 +43,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!target || target.status !== JoinStatus.IN_WORKSPACE) return jsonRes(res, { code: 404, message: 'The targetUser is not in namespace' }); // modify K8S - await modifyTeamRole({ + await modifyWorkspaceRole({ action: 'Change', pre_k8s_username: payload.userCrUid, k8s_username: target.userCr.crName, diff --git a/frontend/desktop/src/pages/api/auth/namespace/create.ts b/frontend/desktop/src/pages/api/auth/namespace/create.ts index 65ae25c3c1d..be19019f99f 100644 --- a/frontend/desktop/src/pages/api/auth/namespace/create.ts +++ b/frontend/desktop/src/pages/api/auth/namespace/create.ts @@ -1,7 +1,7 @@ import { getTeamKubeconfig } from '@/services/backend/kubernetes/admin'; import { GetUserDefaultNameSpace } from '@/services/backend/kubernetes/user'; import { jsonRes } from '@/services/backend/response'; -import { bindingRole, modifyTeamRole } from '@/services/backend/team'; +import { bindingRole, modifyWorkspaceRole } from '@/services/backend/team'; import { getTeamLimit } from '@/services/enable'; import { NSType, NamespaceDto, UserRole } from '@/types/team'; import { NextApiRequest, NextApiResponse } from 'next'; @@ -19,7 +19,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!teamName) return jsonRes(res, { code: 400, message: 'teamName is required' }); const currentNamespaces = await prisma.userWorkspace.findMany({ where: { - userCrUid: payload.userCrUid + userCrUid: payload.userCrUid, + status: 'IN_WORKSPACE' }, include: { workspace: { @@ -65,7 +66,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) direct: true }); if (!utnResult) throw new Error(`fail to binding namesapce: ${workspace.id}`); - await modifyTeamRole({ + await modifyWorkspaceRole({ role: UserRole.Owner, action: 'Create', workspaceId, diff --git a/frontend/desktop/src/pages/api/auth/namespace/delete.ts b/frontend/desktop/src/pages/api/auth/namespace/delete.ts index 9e24b7514e7..4547d14fe42 100644 --- a/frontend/desktop/src/pages/api/auth/namespace/delete.ts +++ b/frontend/desktop/src/pages/api/auth/namespace/delete.ts @@ -4,7 +4,7 @@ import { applyDeleteRequest } from '@/services/backend/team'; import { NextApiRequest, NextApiResponse } from 'next'; import { prisma } from '@/services/backend/db/init'; import { validate } from 'uuid'; -import { JoinStatus, Role } from 'prisma/region/generated/client'; +import { Role } from 'prisma/region/generated/client'; import { verifyAccessToken } from '@/services/backend/auth'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -40,13 +40,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!res1) throw new Error('fail to update user '); const res2 = await applyDeleteRequest(creator); if (!res2) throw new Error('fail to delete namespace '); - const results = await prisma.userWorkspace.updateMany({ + const results = await prisma.userWorkspace.deleteMany({ where: { workspaceUid: ns_uid, isPrivate: false - }, - data: { - status: JoinStatus.NOT_IN_WORKSPACE } }); diff --git a/frontend/desktop/src/pages/api/auth/namespace/details.ts b/frontend/desktop/src/pages/api/auth/namespace/details.ts index 8750bece674..ca3ecaec173 100644 --- a/frontend/desktop/src/pages/api/auth/namespace/details.ts +++ b/frontend/desktop/src/pages/api/auth/namespace/details.ts @@ -47,19 +47,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) teamName: workspace.displayName, nstype: NSType.Team }; - const users = queryResult.map((x) => { - const user = userResult.find((user) => user.uid === x.userCr.userUid)!; - return { - uid: x.userCr.userUid, - crUid: x.userCrUid, - k8s_username: x.userCr.crName, - avatarUrl: user.avatarUri, - nickname: user.nickname, - createdTime: x.userCr.createdAt.toString(), - joinTime: x.joinAt || undefined, - role: roleToUserRole(x.role), - status: joinStatusToNStatus(x.status) - }; + const users = queryResult.flatMap((x) => { + const user = userResult.find((user) => user.uid === x.userCr.userUid); + // when the user is deleting + if (!user) return []; + return [ + { + uid: x.userCr.userUid, + crUid: x.userCrUid, + k8s_username: x.userCr.crName, + avatarUrl: user.avatarUri, + nickname: user.nickname, + createdTime: x.userCr.createdAt.toString(), + joinTime: x.joinAt || undefined, + role: roleToUserRole(x.role), + status: joinStatusToNStatus(x.status) + } + ]; }); jsonRes(res, { code: 200, diff --git a/frontend/desktop/src/pages/api/auth/namespace/modifyRole.ts b/frontend/desktop/src/pages/api/auth/namespace/modifyRole.ts index 00aa8bca42a..4b800d6babf 100644 --- a/frontend/desktop/src/pages/api/auth/namespace/modifyRole.ts +++ b/frontend/desktop/src/pages/api/auth/namespace/modifyRole.ts @@ -1,6 +1,6 @@ import { verifyAccessToken } from '@/services/backend/auth'; import { jsonRes } from '@/services/backend/response'; -import { modifyBinding, modifyTeamRole } from '@/services/backend/team'; +import { modifyBinding, modifyWorkspaceRole } from '@/services/backend/team'; import { UserRole } from '@/types/team'; import { isUserRole, roleToUserRole, vaildManage } from '@/utils/tools'; import { NextApiRequest, NextApiResponse } from 'next'; @@ -52,7 +52,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (roleToUserRole(targetUser.role) === tRole) return jsonRes(res, { code: 200, message: 'Successfully' }); - await modifyTeamRole({ + await modifyWorkspaceRole({ k8s_username: targetUser.userCr.crName, role: tRole, action: 'Modify', @@ -71,6 +71,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } catch (e) { console.log(e); - jsonRes(res, { code: 500, message: 'fail to remove team member' }); + jsonRes(res, { code: 500, message: 'fail to modify member role' }); } } diff --git a/frontend/desktop/src/pages/api/auth/namespace/removeUser.ts b/frontend/desktop/src/pages/api/auth/namespace/removeUser.ts index bfac7a05c42..d2145af7fdb 100644 --- a/frontend/desktop/src/pages/api/auth/namespace/removeUser.ts +++ b/frontend/desktop/src/pages/api/auth/namespace/removeUser.ts @@ -1,5 +1,5 @@ import { jsonRes } from '@/services/backend/response'; -import { modifyTeamRole, unbindingRole } from '@/services/backend/team'; +import { modifyWorkspaceRole, unbindingRole } from '@/services/backend/team'; import { NextApiRequest, NextApiResponse } from 'next'; import { roleToUserRole, vaildManage } from '@/utils/tools'; import { validate } from 'uuid'; @@ -58,7 +58,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } else if (JoinStatus.IN_WORKSPACE === tItem.status) { // modify role - await modifyTeamRole({ + await modifyWorkspaceRole({ k8s_username: tItem.userCr.crName, role: roleToUserRole(tItem.role), action: 'Deprive', diff --git a/frontend/desktop/src/pages/api/auth/namespace/verifyInvite.ts b/frontend/desktop/src/pages/api/auth/namespace/verifyInvite.ts index 62604309b71..efab03ddc78 100644 --- a/frontend/desktop/src/pages/api/auth/namespace/verifyInvite.ts +++ b/frontend/desktop/src/pages/api/auth/namespace/verifyInvite.ts @@ -1,6 +1,6 @@ import { reciveAction } from '@/api/namespace'; import { jsonRes } from '@/services/backend/response'; -import { acceptInvite, modifyTeamRole, unbindingRole } from '@/services/backend/team'; +import { acceptInvite, modifyWorkspaceRole, unbindingRole } from '@/services/backend/team'; import { NextApiRequest, NextApiResponse } from 'next'; import { prisma } from '@/services/backend/db/init'; @@ -38,7 +38,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); if (!queryStatus) return jsonRes(res, { code: 404, message: "You're not invited" }); if (action === reciveAction.Accepte) { - await modifyTeamRole({ + await modifyWorkspaceRole({ k8s_username: queryStatus.userCr.crName, role: roleToUserRole(queryStatus.role), workspaceId: queryStatus.workspace.id, diff --git a/frontend/desktop/src/pages/api/auth/namespace/verifyInviteCode.ts b/frontend/desktop/src/pages/api/auth/namespace/verifyInviteCode.ts index d74bb5ba453..4088660a128 100644 --- a/frontend/desktop/src/pages/api/auth/namespace/verifyInviteCode.ts +++ b/frontend/desktop/src/pages/api/auth/namespace/verifyInviteCode.ts @@ -1,6 +1,6 @@ import { reciveAction } from '@/api/namespace'; import { jsonRes } from '@/services/backend/response'; -import { modifyTeamRole } from '@/services/backend/team'; +import { modifyWorkspaceRole } from '@/services/backend/team'; import { NextApiRequest, NextApiResponse } from 'next'; import { prisma } from '@/services/backend/db/init'; import { UserRoleToRole } from '@/utils/tools'; @@ -45,7 +45,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!inviterStatus) return jsonRes(res, { code: 404, message: 'the inviter or the namespace is not found' }); - await modifyTeamRole({ + await modifyWorkspaceRole({ k8s_username: payload.userCrName, role: linkResults.role, workspaceId: inviterStatus.workspace.id, diff --git a/frontend/desktop/src/pages/api/auth/oauth/github/bind.ts b/frontend/desktop/src/pages/api/auth/oauth/github/bind.ts new file mode 100644 index 00000000000..f5c8873abc2 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/oauth/github/bind.ts @@ -0,0 +1,30 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableGithub } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { + OauthCodeFilter, + bindGithubGuard, + githubOAuthEnvFilter, + githubOAuthGuard +} from '@/services/backend/middleware/oauth'; +import { bindGithubSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableGithub()) { + throw new Error('github clinet is not defined'); + } + await filterAccessToken(req, res, async ({ userUid }) => { + await OauthCodeFilter(req, res, async ({ code }) => { + await githubOAuthEnvFilter()(async ({ clientID, clientSecret }) => { + await githubOAuthGuard( + clientID, + clientSecret, + code + )(res, ({ id }) => + bindGithubGuard(id, userUid)(res, () => bindGithubSvc(id, userUid)(res)) + ); + }); + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/oauth/github/index.ts b/frontend/desktop/src/pages/api/auth/oauth/github/index.ts index 1ad2b7ad256..2433eed91cc 100644 --- a/frontend/desktop/src/pages/api/auth/oauth/github/index.ts +++ b/frontend/desktop/src/pages/api/auth/oauth/github/index.ts @@ -1,75 +1,32 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { jsonRes } from '@/services/backend/response'; -import { TgithubToken, TgithubUser } from '@/types/user'; import { enableGithub } from '@/services/enable'; -import { getGlobalToken } from '@/services/backend/globalAuth'; import { persistImage } from '@/services/backend/persistImage'; -import { isNumber } from 'lodash'; import { ProviderType } from 'prisma/global/generated/client'; +import { + OauthCodeFilter, + githubOAuthEnvFilter, + githubOAuthGuard +} from '@/services/backend/middleware/oauth'; +import { getGlobalTokenByGithubSvc } from '@/services/backend/svc/access'; +import { ErrorHandler } from '@/services/backend/middleware/error'; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const clientId = global.AppConfig?.desktop.auth.idp.github?.clientID!; - const clientSecret = global.AppConfig?.desktop.auth.idp.github?.clientSecret!; - try { - if (!enableGithub()) { - throw new Error('github clinet is not defined'); - } - const { code, inviterId } = req.body; - const url = ` https://github.com/login/oauth/access_token?client_id=${clientId}&client_secret=${clientSecret}&code=${code}`; - const __data = (await ( - await fetch(url, { method: 'POST', headers: { Accept: 'application/json' } }) - ).json()) as TgithubToken; - const access_token = __data.access_token; - if (!access_token) { - return jsonRes(res, { - message: 'Failed to authenticate with GitHub', - code: 500, - data: 'access_token is null' - }); - } - const userUrl = `https://api.github.com/user`; - const response = await fetch(userUrl, { - headers: { - Authorization: `Bearer ${access_token}` - } - }); - if (!response.ok) - return jsonRes(res, { - code: 401, - message: 'Unauthorized' - }); - const result = (await response.json()) as TgithubUser; - - const name = result.login; - const id = result.id; - if (!isNumber(id)) throw Error(); - const persistUrl = await persistImage( - result.avatar_url, - 'avatar/' + ProviderType.GITHUB + '/' + result.id - ); - const avatar_url = persistUrl || ''; - const data = await getGlobalToken({ - provider: ProviderType.GITHUB, - id: id + '', - avatar_url, - name, - inviterId - }); - if (!data) - return jsonRes(res, { - code: 401, - message: 'Unauthorized' +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableGithub()) { + throw new Error('github clinet is not defined'); + } + await OauthCodeFilter(req, res, async ({ code, inviterId }) => { + await githubOAuthEnvFilter()(async ({ clientID, clientSecret }) => { + await githubOAuthGuard( + clientID, + clientSecret, + code + )(res, async ({ id, name, avatar_url }) => { + const persistUrl = await persistImage( + avatar_url, + 'avatar/' + ProviderType.GITHUB + '/' + id + ); + await getGlobalTokenByGithubSvc(persistUrl || '', id, name, inviterId)(res); }); - return jsonRes(res, { - data, - code: 200, - message: 'Successfully' }); - } catch (err) { - console.log(err); - return jsonRes(res, { - message: 'Failed to authenticate with GitHub', - code: 500 - }); - } -} + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/oauth/github/unbind.ts b/frontend/desktop/src/pages/api/auth/oauth/github/unbind.ts new file mode 100644 index 00000000000..4754f47ad49 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/oauth/github/unbind.ts @@ -0,0 +1,30 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableGithub } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { + OauthCodeFilter, + githubOAuthEnvFilter, + githubOAuthGuard, + unbindGithubGuard +} from '@/services/backend/middleware/oauth'; +import { unbindGithubSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableGithub()) { + throw new Error('github clinet is not defined'); + } + await filterAccessToken(req, res, async ({ userUid }) => { + await OauthCodeFilter(req, res, async ({ code }) => { + await githubOAuthEnvFilter()(async ({ clientID, clientSecret }) => { + await githubOAuthGuard( + clientID, + clientSecret, + code + )(res, ({ id }) => + unbindGithubGuard(id, userUid)(res, () => unbindGithubSvc(id, userUid)(res)) + ); + }); + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/oauth/google/bind.ts b/frontend/desktop/src/pages/api/auth/oauth/google/bind.ts new file mode 100644 index 00000000000..31e7db02b56 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/oauth/google/bind.ts @@ -0,0 +1,31 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableGithub, enableGoogle } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { + OauthCodeFilter, + bindGoogleGuard, + googleOAuthEnvFilter, + googleOAuthGuard +} from '@/services/backend/middleware/oauth'; +import { bindGoogleSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableGoogle()) { + throw new Error('google svc is not defined'); + } + await filterAccessToken(req, res, async ({ userUid }) => { + await OauthCodeFilter(req, res, async ({ code }) => { + await googleOAuthEnvFilter()(async ({ clientID, clientSecret, callbackURL }) => { + await googleOAuthGuard( + clientID, + clientSecret, + code, + callbackURL + )(res, ({ name, id, avatar_url }) => + bindGoogleGuard(id, userUid)(res, () => bindGoogleSvc(id, userUid)(res)) + ); + }); + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/oauth/google/index.ts b/frontend/desktop/src/pages/api/auth/oauth/google/index.ts index 88d9a7accc4..517b0546ebf 100644 --- a/frontend/desktop/src/pages/api/auth/oauth/google/index.ts +++ b/frontend/desktop/src/pages/api/auth/oauth/google/index.ts @@ -1,80 +1,32 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { jsonRes } from '@/services/backend/response'; -import * as jwt from 'jsonwebtoken'; import { enableGoogle } from '@/services/enable'; -import { getBase64FromRemote } from '@/utils/tools'; -import { getGlobalToken } from '@/services/backend/globalAuth'; import { persistImage } from '@/services/backend/persistImage'; import { ProviderType } from 'prisma/global/generated/client'; +import { + googleOAuthEnvFilter, + googleOAuthGuard, + OauthCodeFilter +} from '@/services/backend/middleware/oauth'; +import { getGlobalTokenByGoogleSvc } from '@/services/backend/svc/access'; +import { ErrorHandler } from '@/services/backend/middleware/error'; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const clientId = global.AppConfig?.desktop.auth.idp.google?.clientID!; - const clientSecret = global.AppConfig?.desktop.auth.idp.google?.clientSecret!; - const callbackUrl = global.AppConfig?.desktop.auth.callbackURL || ''; - try { - if (!enableGoogle()) { - throw new Error('google clinet is not defined'); - } - const { code, inviterId } = req.body; - if (!code) - return jsonRes(res, { - code: 400, - message: 'The code is required' - }); - const url = `https://oauth2.googleapis.com/token?client_id=${clientId}&client_secret=${clientSecret}&code=${code}&redirect_uri=${callbackUrl}&grant_type=authorization_code`; - const response = await fetch(url, { method: 'POST', headers: { Accept: 'application/json' } }); - if (!response.ok) - return jsonRes(res, { - code: 401, - message: 'Unauthorized' - }); - const __data = (await response.json()) as { - access_token: string; - scope: string; - token_type: string; - id_token: string; - }; - const userInfo = jwt.decode(__data.id_token) as { - iss: string; - azp: string; - aud: string; - sub: string; - at_hash: string; - name: string; - picture: string; - given_name: string; - family_name: string; - locale: string; - iat: number; - exp: number; - }; - const name = userInfo.name; - const id = userInfo.sub; - const avatar_url = - (await persistImage(userInfo.picture, 'avatar/' + ProviderType.WECHAT + '/' + id)) || ''; - if (!id) throw new Error('fail to get google openid'); - const data = await getGlobalToken({ - provider: ProviderType.GOOGLE, - id: name, - avatar_url, - name, - inviterId - }); - if (!data) - return jsonRes(res, { - code: 401, - message: 'Unauthorized' - }); - return jsonRes(res, { - data, - code: 200, - message: 'Successfully' - }); - } catch (err) { - console.log(err); - return jsonRes(res, { - message: 'Failed to authenticate with Google', - code: 500 - }); - } -} +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableGoogle()) throw new Error('google clinet is not defined'); + await OauthCodeFilter( + req, + res, + async ({ code, inviterId }) => + await googleOAuthEnvFilter()(async ({ clientID, clientSecret, callbackURL }) => { + await googleOAuthGuard( + clientID, + clientSecret, + code, + callbackURL + )(res, async ({ id, name, avatar_url }) => { + const presistAvatarUrl = + (await persistImage(avatar_url, 'avatar/' + ProviderType.GOOGLE + '/' + id)) || ''; + await getGlobalTokenByGoogleSvc(presistAvatarUrl, id, name, inviterId)(res); + }); + }) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/oauth/google/unbind.ts b/frontend/desktop/src/pages/api/auth/oauth/google/unbind.ts new file mode 100644 index 00000000000..478107dd627 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/oauth/google/unbind.ts @@ -0,0 +1,31 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableGithub, enableGoogle } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { + OauthCodeFilter, + googleOAuthEnvFilter, + googleOAuthGuard, + unbindGoogleGuard +} from '@/services/backend/middleware/oauth'; +import { unbindGoogleSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableGoogle()) { + throw new Error('google svc is not defined'); + } + await filterAccessToken(req, res, async ({ userUid }) => { + await OauthCodeFilter(req, res, async ({ code }) => { + await googleOAuthEnvFilter()(async ({ clientID, clientSecret, callbackURL }) => { + await googleOAuthGuard( + clientID, + clientSecret, + code, + callbackURL + )(res, ({ name, id, avatar_url }) => + unbindGoogleGuard(id, userUid)(res, () => unbindGoogleSvc(id, userUid)(res)) + ); + }); + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/oauth/oauth2/index.ts b/frontend/desktop/src/pages/api/auth/oauth/oauth2/index.ts index 4a20fba3b0e..6628bd75398 100644 --- a/frontend/desktop/src/pages/api/auth/oauth/oauth2/index.ts +++ b/frontend/desktop/src/pages/api/auth/oauth/oauth2/index.ts @@ -1,4 +1,5 @@ import { getGlobalToken } from '@/services/backend/globalAuth'; +import { ErrorHandler } from '@/services/backend/middleware/error'; import { jsonRes } from '@/services/backend/response'; import { enableOAuth2 } from '@/services/enable'; import { OAuth2Type, OAuth2UserInfoType } from '@/types/user'; @@ -8,83 +9,74 @@ import { ProviderType } from 'prisma/global/generated/client'; const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12); //OAuth2 Support client_secret_post method to obtain token -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { const clientId = global.AppConfig?.desktop.auth.idp.oauth2?.clientID!; const clientSecret = global.AppConfig?.desktop.auth.idp.oauth2?.clientSecret!; const tokenUrl = global.AppConfig?.desktop.auth.idp.oauth2?.tokenURL; const userInfoUrl = global.AppConfig?.desktop.auth.idp.oauth2?.userInfoURL; const redirectUrl = global.AppConfig?.desktop.auth.callbackURL; - try { - if (!enableOAuth2() || !redirectUrl) { - throw new Error('District related env'); - } - - const { code, inviterId } = req.body; - const url = `${tokenUrl}`; - const oauth2Data = (await ( - await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - code, - client_id: clientId, - client_secret: clientSecret, - grant_type: 'authorization_code', - redirect_uri: redirectUrl - }) + if (!enableOAuth2() || !redirectUrl) { + throw new Error('District related env'); + } + const { code, inviterId } = req.body; + const url = `${tokenUrl}`; + const oauth2Data = (await ( + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + grant_type: 'authorization_code', + redirect_uri: redirectUrl }) - ).json()) as OAuth2Type; - const access_token = oauth2Data.access_token; + }) + ).json()) as OAuth2Type; + const access_token = oauth2Data.access_token; - if (!access_token) { - return jsonRes(res, { - message: 'Failed to authenticate', - code: 500, - data: 'access_token is null' - }); - } - - const userUrl = `${userInfoUrl}?access_token=${access_token}`; - const response = await fetch(userUrl, { - headers: { - Authorization: `Bearer ${access_token}` - } + if (!access_token) { + return jsonRes(res, { + message: 'Failed to authenticate', + code: 500, + data: 'access_token is null' }); - if (!response.ok) - return jsonRes(res, { - code: 401, - message: 'Unauthorized' - }); - const result = (await response.json()) as OAuth2UserInfoType; - - const id = result.sub; - const name = result?.nickname || result?.name || nanoid(8); - const avatar_url = result?.picture || ''; + } - const data = await getGlobalToken({ - provider: ProviderType.OAUTH2, - id: id + '', - avatar_url, - name, - inviterId - }); - if (!data) - return jsonRes(res, { - code: 401, - message: 'Unauthorized' - }); + const userUrl = `${userInfoUrl}?access_token=${access_token}`; + const response = await fetch(userUrl, { + headers: { + Authorization: `Bearer ${access_token}` + } + }); + if (!response.ok) return jsonRes(res, { - data, - code: 200, - message: 'Successfully' + code: 401, + message: 'Unauthorized' }); - } catch (err) { - console.log(err); + const result = (await response.json()) as OAuth2UserInfoType; + + const id = result.sub; + const name = result?.nickname || result?.name || nanoid(8); + const avatar_url = result?.picture || ''; + + const data = await getGlobalToken({ + provider: ProviderType.OAUTH2, + providerId: id + '', + avatar_url, + name, + inviterId + }); + if (!data) return jsonRes(res, { - message: 'Failed to authenticate with GitHub', - code: 500 + code: 401, + message: 'Unauthorized' }); - } -} + return jsonRes(res, { + data, + code: 200, + message: 'Successfully' + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/oauth/wechat/bind.ts b/frontend/desktop/src/pages/api/auth/oauth/wechat/bind.ts new file mode 100644 index 00000000000..1dfcbd8c7d3 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/oauth/wechat/bind.ts @@ -0,0 +1,30 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableWechat } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { + bindWechatGuard, + OauthCodeFilter, + wechatOAuthEnvFilter, + wechatOAuthGuard +} from '@/services/backend/middleware/oauth'; +import { bindWechatSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableWechat()) { + throw new Error('github clinet is not defined'); + } + await filterAccessToken(req, res, async ({ userUid }) => { + await OauthCodeFilter(req, res, async ({ code }) => { + await wechatOAuthEnvFilter()(async ({ clientID, clientSecret }) => { + await wechatOAuthGuard( + clientID, + clientSecret, + code + )(res, ({ name, id, avatar_url }) => + bindWechatGuard(id, userUid)(res, () => bindWechatSvc(id, userUid)(res)) + ); + }); + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/oauth/wechat/index.ts b/frontend/desktop/src/pages/api/auth/oauth/wechat/index.ts index fee46d8f077..c0a5aca8124 100644 --- a/frontend/desktop/src/pages/api/auth/oauth/wechat/index.ts +++ b/frontend/desktop/src/pages/api/auth/oauth/wechat/index.ts @@ -1,64 +1,31 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { jsonRes } from '@/services/backend/response'; -import { TWechatToken, TWechatUser } from '@/types/user'; import { enableWechat } from '@/services/enable'; -import { getGlobalToken } from '@/services/backend/globalAuth'; import { persistImage } from '@/services/backend/persistImage'; import { ProviderType } from 'prisma/global/generated/client'; +import { + OauthCodeFilter, + wechatOAuthEnvFilter, + wechatOAuthGuard +} from '@/services/backend/middleware/oauth'; +import { getGlobalTokenByWechatSvc } from '@/services/backend/svc/access'; +import { ErrorHandler } from '@/services/backend/middleware/error'; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const APP_ID = global.AppConfig?.desktop.auth.idp.wechat?.clientID!; - const APP_SECRET = global.AppConfig?.desktop.auth.idp.wechat?.clientSecret!; - try { - if (!enableWechat()) { - throw new Error('wechat clinet is not defined'); - } - const { code, inviterId } = req.body; - const url = `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${APP_ID}&secret=${APP_SECRET}&code=${code}&grant_type=authorization_code`; - const { access_token, openid } = (await ( - await fetch(url, { headers: { Accept: 'application/json' } }) - ).json()) as TWechatToken; - - if (!access_token || !openid) { - return jsonRes(res, { - message: 'Failed to authenticate with wechat', - code: 400, - data: 'access_token is null' - }); - } - - const userUrl = `https://api.weixin.qq.com/sns/userinfo?access_token=${access_token}&openid=${openid}&lang=zh_CN`; - const response = await fetch(userUrl); - if (!response.ok) - return jsonRes(res, { - code: 401, - message: 'Unauthorized' - }); - const { nickname: name, unionid: id, headimgurl } = (await response.json()) as TWechatUser; - const avatar_url = - (await persistImage(headimgurl, 'avatar/' + ProviderType.WECHAT + '/' + id)) || ''; - const data = await getGlobalToken({ - provider: ProviderType.WECHAT, - id, - avatar_url, - name, - inviterId - }); - if (!data) - return jsonRes(res, { - code: 401, - message: 'Unauthorized' +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableWechat()) { + throw new Error('wechat clinet is not defined'); + } + await OauthCodeFilter(req, res, async ({ code, inviterId }) => { + await wechatOAuthEnvFilter()(async ({ clientID, clientSecret }) => { + await wechatOAuthGuard( + clientID, + clientSecret, + code + )(res, async ({ id, name, avatar_url }) => { + const persistUrl = + (await persistImage(avatar_url, 'avatar/' + ProviderType.WECHAT + '/' + id)) || ''; + await getGlobalTokenByWechatSvc(persistUrl, id, name, inviterId)(res); }); - return jsonRes(res, { - data, - code: 200, - message: 'Successfully' }); - } catch (err) { - console.log(err); - jsonRes(res, { - message: 'Failed to authenticate with wechat', - code: 500 - }); - } -} + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/oauth/wechat/unbind.ts b/frontend/desktop/src/pages/api/auth/oauth/wechat/unbind.ts new file mode 100644 index 00000000000..d1dc96b8559 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/oauth/wechat/unbind.ts @@ -0,0 +1,30 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableWechat } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { + OauthCodeFilter, + unbindWechatGuard, + wechatOAuthEnvFilter, + wechatOAuthGuard +} from '@/services/backend/middleware/oauth'; +import { unbindWechatSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableWechat()) { + throw new Error('github clinet is not defined'); + } + await filterAccessToken(req, res, async ({ userUid }) => { + await OauthCodeFilter(req, res, async ({ code }) => { + await wechatOAuthEnvFilter()(async ({ clientID, clientSecret }) => { + await wechatOAuthGuard( + clientID, + clientSecret, + code + )(res, ({ id }) => + unbindWechatGuard(id, userUid)(res, () => unbindWechatSvc(id, userUid)(res)) + ); + }); + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/password/exist.ts b/frontend/desktop/src/pages/api/auth/password/exist.ts index 476913e5780..7a6218bba65 100644 --- a/frontend/desktop/src/pages/api/auth/password/exist.ts +++ b/frontend/desktop/src/pages/api/auth/password/exist.ts @@ -20,8 +20,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }); } - // const userRes = await findUser({realUserUid: payload.realUserUid}) - // const passwordProvider = userRes?.oauthProvider.find(val=>val.providerType === ProviderType.PASSWORD) const isExist = await globalPrisma.oauthProvider.findUnique({ where: { providerId_providerType: { diff --git a/frontend/desktop/src/pages/api/auth/password/index.ts b/frontend/desktop/src/pages/api/auth/password/index.ts index 9eac9ea76cf..1301cc57332 100644 --- a/frontend/desktop/src/pages/api/auth/password/index.ts +++ b/frontend/desktop/src/pages/api/auth/password/index.ts @@ -19,7 +19,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } const data = await getGlobalToken({ provider: ProviderType.PASSWORD, - id: name, + providerId: name, avatar_url: '', password, name, diff --git a/frontend/desktop/src/pages/api/auth/phone/bind/sms.ts b/frontend/desktop/src/pages/api/auth/phone/bind/sms.ts new file mode 100644 index 00000000000..04b4dfe2f33 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/phone/bind/sms.ts @@ -0,0 +1,20 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { sendPhoneCodeGuard, filterPhoneParams, filterCf } from '@/services/backend/middleware/sms'; +import { enableSms } from '@/services/enable'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { sendPhoneCodeSvc } from '@/services/backend/svc/sms'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterCf(req, res, async () => { + console.log('filiterAccess'); + await filterAccessToken(req, res, () => + filterPhoneParams(req, res, ({ phoneNumbers: phone }) => + sendPhoneCodeGuard(phone)(res, () => sendPhoneCodeSvc(phone)(res)) + ) + ); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/phone/bind/verify.ts b/frontend/desktop/src/pages/api/auth/phone/bind/verify.ts new file mode 100644 index 00000000000..56c208cc893 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/phone/bind/verify.ts @@ -0,0 +1,24 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/services/backend/response'; +import { enableSms } from '@/services/enable'; +import { filterPhoneVerifyParams, verifyPhoneCodeGuard } from '@/services/backend/middleware/sms'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { bindPhoneSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { bindPhoneGuard } from '@/services/backend/middleware/oauth'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken( + req, + res, + async ({ userUid }) => + await filterPhoneVerifyParams(req, res, async ({ phoneNumbers, code }) => { + await verifyPhoneCodeGuard(phoneNumbers, code)(res, () => + bindPhoneGuard(phoneNumbers, userUid)(res, () => bindPhoneSvc(phoneNumbers, userUid)(res)) + ); + }) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/phone/changeBinding/newSms.ts b/frontend/desktop/src/pages/api/auth/phone/changeBinding/newSms.ts new file mode 100644 index 00000000000..5b8e7b4525f --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/phone/changeBinding/newSms.ts @@ -0,0 +1,22 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableSms } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { + filterPhoneParams, + filterCodeUid, + sendNewPhoneCodeGuard +} from '@/services/backend/middleware/sms'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { sendPhoneCodeSvc } from '@/services/backend/svc/sms'; +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken(req, res, ({ userUid }) => + filterPhoneParams(req, res, ({ phoneNumbers }) => + filterCodeUid(req, res, ({ uid }) => + sendNewPhoneCodeGuard(uid, phoneNumbers)(res, () => sendPhoneCodeSvc(phoneNumbers)(res)) + ) + ) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/phone/changeBinding/oldSms.ts b/frontend/desktop/src/pages/api/auth/phone/changeBinding/oldSms.ts new file mode 100644 index 00000000000..30bd0065e35 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/phone/changeBinding/oldSms.ts @@ -0,0 +1,22 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableSms } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { sendPhoneCodeGuard, filterPhoneParams } from '@/services/backend/middleware/sms'; +import { sendPhoneCodeSvc } from '@/services/backend/svc/sms'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { unbindPhoneGuard } from '@/services/backend/middleware/oauth'; +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken( + req, + res, + async ({ userUid }) => + await filterPhoneParams(req, res, async ({ phoneNumbers }) => { + await unbindPhoneGuard(phoneNumbers, userUid)(res, () => + sendPhoneCodeGuard(phoneNumbers)(res, async () => sendPhoneCodeSvc(phoneNumbers)(res)) + ); + }) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/phone/changeBinding/verifyNew.ts b/frontend/desktop/src/pages/api/auth/phone/changeBinding/verifyNew.ts new file mode 100644 index 00000000000..822acaa4428 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/phone/changeBinding/verifyNew.ts @@ -0,0 +1,44 @@ +import next, { NextApiRequest, NextApiResponse } from 'next'; +import { enableSms } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { + verifyCodeUidGuard, + filterCodeUid, + filterPhoneVerifyParams, + verifyPhoneCodeGuard +} from '@/services/backend/middleware/sms'; +import { changePhoneBindingSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { bindPhoneGuard, unbindPhoneGuard } from '@/services/backend/middleware/oauth'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken( + req, + res, + async ({ userUid }) => + await filterPhoneVerifyParams( + req, + res, + async ({ phoneNumbers, code }) => + await filterCodeUid( + req, + res, + async ({ uid }) => + await verifyCodeUidGuard(uid)(res, async ({ smsInfo: oldPhoneInfo }) => { + await verifyPhoneCodeGuard(phoneNumbers, code)( + res, + async ({ smsInfo: newPhoneInfo }) => + unbindPhoneGuard(oldPhoneInfo.id, userUid)(res, () => + bindPhoneGuard(newPhoneInfo.id, userUid)(res, () => + changePhoneBindingSvc(oldPhoneInfo.id, newPhoneInfo.id, userUid)(res) + ) + ) + ); + }) + ) + ) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/phone/changeBinding/verifyOld.ts b/frontend/desktop/src/pages/api/auth/phone/changeBinding/verifyOld.ts new file mode 100644 index 00000000000..0cde43b58b1 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/phone/changeBinding/verifyOld.ts @@ -0,0 +1,31 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/services/backend/response'; +import { enableSms } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { filterPhoneVerifyParams, verifyPhoneCodeGuard } from '@/services/backend/middleware/sms'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { unbindPhoneGuard } from '@/services/backend/middleware/oauth'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken( + req, + res, + async ({ userUid }) => + await filterPhoneVerifyParams(req, res, async ({ phoneNumbers, code }) => { + await unbindPhoneGuard(phoneNumbers, userUid)(res, async () => { + await verifyPhoneCodeGuard(phoneNumbers, code)(res, async ({ smsInfo: phoneInfo }) => { + return jsonRes(res, { + code: 200, + message: 'Successfully', + data: { + uid: phoneInfo.uid + } + }); + }); + }); + }) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/phone/sms.ts b/frontend/desktop/src/pages/api/auth/phone/sms.ts index bdd427930fa..c1456aebfad 100644 --- a/frontend/desktop/src/pages/api/auth/phone/sms.ts +++ b/frontend/desktop/src/pages/api/auth/phone/sms.ts @@ -1,112 +1,16 @@ import { NextApiRequest, NextApiResponse } from 'next'; -// import twilio from 'twilio'; -//@ts-ignore -import Dysmsapi, * as dysmsapi from '@alicloud/dysmsapi20170525'; -//@ts-ignore -import * as OpenApi from '@alicloud/openapi-client'; -//@ts-ignore -import * as Util from '@alicloud/tea-util'; -import { jsonRes } from '@/services/backend/response'; -import { addOrUpdateCode, checkSendable } from '@/services/backend/db/verifyCode'; import { enableSms } from '@/services/enable'; -import { retrySerially } from '@/utils/tools'; +import { filterCf, filterPhoneParams, sendPhoneCodeGuard } from '@/services/backend/middleware/sms'; +import { sendPhoneCodeSvc } from '@/services/backend/svc/sms'; +import { ErrorHandler } from '@/services/backend/middleware/error'; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const accessKeyId = global.AppConfig?.desktop.auth.idp.sms?.ali?.accessKeyID!; - const accessKeySecret = global.AppConfig?.desktop.auth.idp.sms?.ali?.accessKeySecret!; - const templateCode = global.AppConfig?.desktop.auth.idp.sms?.ali?.templateCode!; - const signName = global.AppConfig?.desktop.auth.idp.sms?.ali?.signName!; - const cfSiteKey = global.AppConfig?.common.cfSiteKey!; - const cfVerifyEndpoint = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; - try { - if (!enableSms()) { - throw new Error('SMS is not enabled'); - } - const { phoneNumbers, cfToken } = req.body as { phoneNumbers?: string; cfToken?: string }; - - if (cfSiteKey) { - if (!cfToken) - return jsonRes(res, { - message: 'cfToken is invalid', - code: 400 - }); - const verifyRes = await fetch(cfVerifyEndpoint, { - method: 'POST', - body: `secret=${encodeURIComponent(cfSiteKey)}&response=${encodeURIComponent(cfToken)}`, - headers: { - 'content-type': 'application/x-www-form-urlencoded' - } - }); - const data = await verifyRes.json(); - if (!data.success) - return jsonRes(res, { - message: 'cfToken is invalid', - code: 400 - }); - } - if (!phoneNumbers) - return jsonRes(res, { - message: 'phoneNumbers is invalid', - code: 400 - }); - if (!(await checkSendable({ phone: phoneNumbers }))) { - return jsonRes(res, { - message: 'code already sent', - code: 400 - }); - } - // randomly generate six bit check code - const code = Math.floor(Math.random() * 900000 + 100000).toString(); - const sendSmsRequest = new dysmsapi.SendSmsRequest({ - phoneNumbers, - signName, - templateCode, - templateParam: `{"code":${code}}` - }); - const config = new OpenApi.Config({ - accessKeyId, - accessKeySecret - }); - - const client = new Dysmsapi(config); - const runtime = new Util.RuntimeOptions({}); - const result = await retrySerially(async () => { - try { - const _result = await client.sendSmsWithOptions(sendSmsRequest, runtime); - - if (!_result) { - throw new Error('sms result is null'); - } - if (_result.statusCode !== 200) { - throw new Error(`sms result status code is ${_result.statusCode} - ${_result.body} - ${phoneNumbers}, - ${new Date()} - `); - } - if (_result.body.code !== 'OK') { - throw new Error(` - ${_result.body.message} - ${phoneNumbers}, - ${new Date()}`); - } - return _result; - } catch (error) { - return Promise.reject(error); - } - }, 3); - - // update cache - await addOrUpdateCode({ phone: phoneNumbers, code }); - return jsonRes(res, { - message: 'successfully', - code: 200 - }); - } catch (error) { - console.log(error); - jsonRes(res, { - message: 'Failed to send code', - code: 500 - }); +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); } -} + await filterCf(req, res, async () => { + await filterPhoneParams(req, res, ({ phoneNumbers: phone }) => + sendPhoneCodeGuard(phone)(res, () => sendPhoneCodeSvc(phone)(res)) + ); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/phone/unbind/sms.ts b/frontend/desktop/src/pages/api/auth/phone/unbind/sms.ts new file mode 100644 index 00000000000..b5aa6c8c757 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/phone/unbind/sms.ts @@ -0,0 +1,29 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { sendPhoneCodeGuard, filterPhoneParams, filterCf } from '@/services/backend/middleware/sms'; +import { enableSms } from '@/services/enable'; +import { sendPhoneCodeSvc } from '@/services/backend/svc/sms'; +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { unbindPhoneGuard } from '@/services/backend/middleware/oauth'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken(req, res, async ({ userUid }) => { + await filterCf(req, res, async () => { + await filterPhoneParams( + req, + res, + async ({ phoneNumbers }) => + await unbindPhoneGuard(phoneNumbers, userUid)( + res, + async () => + await sendPhoneCodeGuard(phoneNumbers)(res, async () => { + await sendPhoneCodeSvc(phoneNumbers)(res); + }) + ) + ); + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/phone/unbind/verify.ts b/frontend/desktop/src/pages/api/auth/phone/unbind/verify.ts new file mode 100644 index 00000000000..10edab96ce8 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/phone/unbind/verify.ts @@ -0,0 +1,22 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { enableSms } from '@/services/enable'; +import { filterAccessToken } from '@/services/backend/middleware/access'; +import { filterPhoneVerifyParams, verifyPhoneCodeGuard } from '@/services/backend/middleware/sms'; +import { unbindPhoneSvc } from '@/services/backend/svc/bindProvider'; +import { ErrorHandler } from '@/services/backend/middleware/error'; + +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); + } + await filterAccessToken( + req, + res, + async ({ userUid }) => + await filterPhoneVerifyParams(req, res, async ({ phoneNumbers, code }) => { + await verifyPhoneCodeGuard(phoneNumbers, code)(res, async ({ smsInfo: phoneInfo }) => { + await unbindPhoneSvc(phoneInfo.id, userUid)(res); + }); + }) + ); +}); diff --git a/frontend/desktop/src/pages/api/auth/phone/verify.ts b/frontend/desktop/src/pages/api/auth/phone/verify.ts index ced07287003..3f867367e89 100644 --- a/frontend/desktop/src/pages/api/auth/phone/verify.ts +++ b/frontend/desktop/src/pages/api/auth/phone/verify.ts @@ -1,39 +1,16 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { jsonRes } from '@/services/backend/response'; -import { checkCode } from '@/services/backend/db/verifyCode'; import { enableSms } from '@/services/enable'; -import { getGlobalToken } from '@/services/backend/globalAuth'; -import { ProviderType } from 'prisma/global/generated/client'; +import { filterPhoneVerifyParams, verifyPhoneCodeGuard } from '@/services/backend/middleware/sms'; +import { getGlobalTokenByPhoneSvc } from '@/services/backend/svc/access'; +import { ErrorHandler } from '@/services/backend/middleware/error'; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - try { - if (!enableSms()) { - throw new Error('sms client is not defined'); - } - const { phoneNumbers, code, inviterId } = req.body; - if (!(await checkCode({ phone: phoneNumbers, code }))) { - return jsonRes(res, { - message: 'SMS code is wrong', - code: 400 - }); - } - const data = await getGlobalToken({ - provider: ProviderType.PHONE, - id: phoneNumbers, - name: phoneNumbers, - avatar_url: '', - inviterId - }); - return jsonRes(res, { - data, - code: 200, - message: 'Successfully' - }); - } catch (error) { - console.log(error); - jsonRes(res, { - message: 'Failed to authenticate with phone', - code: 400 - }); +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!enableSms()) { + throw new Error('SMS is not enabled'); } -} + await filterPhoneVerifyParams(req, res, async ({ phoneNumbers, code, inviterId }) => { + await verifyPhoneCodeGuard(phoneNumbers, code)(res, async ({ smsInfo: phoneInfo }) => { + await getGlobalTokenByPhoneSvc(phoneInfo.id, inviterId)(res); + }); + }); +}); diff --git a/frontend/desktop/src/pages/api/auth/publicWechat/getWechatResult.ts b/frontend/desktop/src/pages/api/auth/publicWechat/getWechatResult.ts index d4722b0a22e..fecf0f3a535 100644 --- a/frontend/desktop/src/pages/api/auth/publicWechat/getWechatResult.ts +++ b/frontend/desktop/src/pages/api/auth/publicWechat/getWechatResult.ts @@ -40,7 +40,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const _data = await getGlobalToken({ provider: ProviderType.WECHAT, - id: userInfo.openid, + providerId: userInfo.openid, avatar_url, name: userInfo?.nickname }); diff --git a/frontend/desktop/src/pages/api/auth/regionToken.ts b/frontend/desktop/src/pages/api/auth/regionToken.ts index af5901664e4..45b8278b23d 100644 --- a/frontend/desktop/src/pages/api/auth/regionToken.ts +++ b/frontend/desktop/src/pages/api/auth/regionToken.ts @@ -1,27 +1,15 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { jsonRes } from '@/services/backend/response'; import { getRegionToken } from '@/services/backend/regionAuth'; -import { verifyAuthenticationToken } from '@/services/backend/auth'; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - try { - const realUser = await verifyAuthenticationToken(req.headers); - - if (!realUser) - return jsonRes(res, { - code: 401, - message: 'invalid token' - }); - const regionData = await getRegionToken(realUser); +import { ErrorHandler } from '@/services/backend/middleware/error'; +import { filterAuthenticationToken } from '@/services/backend/middleware/access'; +export default ErrorHandler(async function handler(req: NextApiRequest, res: NextApiResponse) { + await filterAuthenticationToken(req, res, async ({ userId, userUid }) => { + const regionData = await getRegionToken({ userId, userUid }); return jsonRes(res, { code: 200, message: 'Successfully', data: regionData }); - } catch (err) { - console.log(err); - return jsonRes(res, { - message: 'Failed to authenticate with globalToken', - code: 500 - }); - } -} + }); +}, 'Failed to authenticate with globalToken'); diff --git a/frontend/desktop/src/pages/api/dev/migrate.ts b/frontend/desktop/src/pages/api/dev/migrate.ts index 8a0d06dfb74..28589664a46 100644 --- a/frontend/desktop/src/pages/api/dev/migrate.ts +++ b/frontend/desktop/src/pages/api/dev/migrate.ts @@ -200,7 +200,6 @@ async function pullUserData() { } // make sure provider sucessfully!!! - console.log('provider', providerInsertData); if (providerInsertData.length > 0) await globalPrisma.oauthProvider .createMany({ diff --git a/frontend/desktop/src/pages/api/platform/getAppConfig.ts b/frontend/desktop/src/pages/api/platform/getAppConfig.ts index b659d81e8f9..1c74133ba50 100644 --- a/frontend/desktop/src/pages/api/platform/getAppConfig.ts +++ b/frontend/desktop/src/pages/api/platform/getAppConfig.ts @@ -12,6 +12,12 @@ import { getCloudConfig } from '@/pages/api/platform/getCloudConfig'; import { getAuthClientConfig } from '@/pages/api/platform/getAuthConfig'; import { getLayoutConfig } from '@/pages/api/platform/getLayoutConfig'; import { getCommonClientConfig } from './getCommonConfig'; +import { Cron } from 'croner'; +import { + commitTransactionjob, + finishTransactionJob, + runTransactionjob +} from '@/services/backend/cronjob'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const config = await getAppConfig(); @@ -44,6 +50,33 @@ export async function getAppConfig(): Promise { const commonConf = await getCommonClientConfig(); const layoutConf = await getLayoutConfig(); const conf = genResConfig(cloudConf, authConf, commonConf, layoutConf); + if (!global.commitCroner) { + // console.log('init commit croner'); + global.commitCroner = new Cron('* * * * * *', commitTransactionjob, { + name: 'commitTransactionJob', + catch: (err) => { + // console.log(err); + } + }); + } + if (!global.runCroner) { + // console.log('init run croner'); + global.runCroner = new Cron('* * * * * *', runTransactionjob, { + name: 'runTransactionJob', + catch: (err) => { + // console.log(err); + } + }); + } + if (!global.finishCroner) { + // console.log('init finish croner'); + global.finishCroner = new Cron('* * * * * *', finishTransactionJob, { + name: 'finishTransactionJob', + catch: (err) => { + // console.log(err); + } + }); + } return conf; } catch (error) { console.log('-getAppConfig-', error); diff --git a/frontend/desktop/src/pages/api/platform/getAuthConfig.ts b/frontend/desktop/src/pages/api/platform/getAuthConfig.ts index f41b7a4b572..be538cfa7c8 100644 --- a/frontend/desktop/src/pages/api/platform/getAuthConfig.ts +++ b/frontend/desktop/src/pages/api/platform/getAuthConfig.ts @@ -60,7 +60,7 @@ function genResAuthClientConfig(conf: AuthConfigType) { export async function getAuthClientConfig(): Promise { try { - if (!global.AppConfig) { + if (process.env.NODE_ENV === 'development' || !global.AppConfig) { const filename = process.env.NODE_ENV === 'development' ? 'data/config.yaml.local' : '/app/data/config.yaml'; global.AppConfig = yaml.load(readFileSync(filename, 'utf-8')) as AppConfigType; diff --git a/frontend/desktop/src/pages/callback.tsx b/frontend/desktop/src/pages/callback.tsx index 827f204794c..06a24d0f6fa 100644 --- a/frontend/desktop/src/pages/callback.tsx +++ b/frontend/desktop/src/pages/callback.tsx @@ -1,89 +1,114 @@ import type { NextPage } from 'next'; import { useRouter } from 'next/router'; -import { useEffect } from 'react'; -import request from '@/services/request'; +import { useCallback, useEffect } from 'react'; import useSessionStore from '@/stores/session'; -import { ApiResp } from '@/types'; +import { ApiResp, AppClientConfigType } from '@/types'; import { Flex, Spinner } from '@chakra-ui/react'; import { uploadConvertData } from '@/api/platform'; -import { jwtDecode } from 'jwt-decode'; import { isString } from 'lodash'; -import { getRegionToken, UserInfo } from '@/api/auth'; -import { AccessTokenPayload } from '@/types/token'; +import { bindRequest, getRegionToken, signInRequest, unBindRequest } from '@/api/auth'; import { getInviterId, sessionConfig } from '@/utils/sessionConfig'; - -const Callback: NextPage = () => { +import useCallbackStore, { MergeUserStatus } from '@/stores/callback'; +import { ProviderType } from 'prisma/global/generated/client'; +import axios from 'axios'; +import request from '@/services/request'; +import { BIND_STATUS } from '@/types/response/bind'; +import { MERGE_USER_READY } from '@/types/response/utils'; +export default function Callback() { const router = useRouter(); - const setSession = useSessionStore((s) => s.setSession); const setProvider = useSessionStore((s) => s.setProvider); const setToken = useSessionStore((s) => s.setToken); const provider = useSessionStore((s) => s.provider); const compareState = useSessionStore((s) => s.compareState); + const { setMergeUserData, setMergeUserStatus } = useCallbackStore(); useEffect(() => { if (!router.isReady) return; let isProxy: boolean = false; (async () => { try { - if (!provider || !['github', 'wechat', 'google', 'oauth2'].includes(provider)) + if (!provider || !['GITHUB', 'WECHAT', 'GOOGLE', 'OAUTH2'].includes(provider)) throw new Error('provider error'); const { code, state } = router.query; if (!isString(code) || !isString(state)) throw new Error('failed to get code and state'); - console.log(encodeURIComponent(state), code, state); - if (!compareState(state)) throw new Error('invalid state'); - // proxy oauth2.0 - const _url = state; - await new Promise((resolve, reject) => { - resolve(new URL(_url)); - }) - .then(async (url) => { - const result = (await ( - await fetch(`/api/auth/canProxy?domain=${url.host}`) - ).json()) as ApiResp<{ containDomain: boolean }>; - isProxy = true; - if (result.data?.containDomain) { - url.searchParams.append('code', code); - console.log(url); - await router.replace(url.toString()); - } + const compareResult = compareState(state); + if (!compareResult.isSuccess) throw new Error('invalid state'); + if (compareResult.action === 'PROXY') { + // proxy oauth2.0, PROXY_URL_[ACTION]_STATE + const [_url, ...ret] = compareResult.statePayload; + await new Promise((resolve, reject) => { + resolve(new URL(decodeURIComponent(_url))); }) - .catch(() => { - Promise.resolve(); - }); - if (isProxy) { - // prevent once token - setProvider(); - isProxy = false; - return; - } - - const data = await request.post< - any, - ApiResp<{ - token: string; - realUser: { - realUserUid: string; - }; - }> - >('/api/auth/oauth/' + provider, { code, inviterId: getInviterId() }); - setProvider(); - if (data.code === 200 && data.data?.token) { - const token = data.data?.token; - setToken(token); - const regionTokenRes = await getRegionToken(); - if (regionTokenRes?.data) { - await sessionConfig(regionTokenRes.data); - uploadConvertData([3]).then( - (res) => { - console.log(res); - }, - (err) => { - console.log(err); + .then(async (url) => { + const result = (await request(`/api/auth/canProxy?domain=${url.host}`)) as ApiResp<{ + containDomain: boolean; + }>; + isProxy = true; + if (result.data?.containDomain) { + url.searchParams.append('code', code); + url.searchParams.append('state', ret.join('_')); + await router.replace(url.toString()); } - ); - await router.replace('/'); + }) + .catch(() => { + Promise.resolve(); + }); + if (isProxy) { + // prevent once token + setProvider(); + isProxy = false; + return; } } else { - throw new Error(); + const { statePayload, action } = compareResult; + // return + if (action === 'LOGIN') { + const data = await signInRequest(provider)({ code, inviterId: getInviterId()! }); + setProvider(); + if (data.code === 200 && data.data?.token) { + const token = data.data?.token; + setToken(token); + const regionTokenRes = await getRegionToken(); + if (regionTokenRes?.data) { + await sessionConfig(regionTokenRes.data); + uploadConvertData([3]).then( + (res) => { + console.log(res); + }, + (err) => { + console.log(err); + } + ); + await router.replace('/'); + } + } else { + throw new Error(); + } + } else if (action === 'BIND') { + const response = await bindRequest(provider)({ code }); + if (response.message === BIND_STATUS.RESULT_SUCCESS) { + setProvider(); + await router.replace('/'); + } else if (response.message === MERGE_USER_READY.MERGE_USER_CONTINUE) { + const code = response.data?.code; + if (!code) return; + setMergeUserData({ + providerType: provider as ProviderType, + code + }); + setMergeUserStatus(MergeUserStatus.CANMERGE); + setProvider(); + await router.replace('/'); + } else if (response.message === MERGE_USER_READY.MERGE_USER_PROVIDER_CONFLICT) { + setMergeUserData(); + setMergeUserStatus(MergeUserStatus.CONFLICT); + setProvider(); + await router.replace('/'); + } + } else if (action === 'UNBIND') { + await unBindRequest(provider)({ code }); + setProvider(); + await router.replace('/'); + } } } catch (error) { console.error(error); @@ -96,5 +121,4 @@ const Callback: NextPage = () => { ); -}; -export default Callback; +} diff --git a/frontend/desktop/src/pages/index.tsx b/frontend/desktop/src/pages/index.tsx index decb872afd3..3f78594ff12 100644 --- a/frontend/desktop/src/pages/index.tsx +++ b/frontend/desktop/src/pages/index.tsx @@ -13,7 +13,7 @@ import Script from 'next/script'; import { createContext, useEffect, useState } from 'react'; import useCallbackStore from '@/stores/callback'; import FloatButton from '@/components/floating_button'; -import 'react-contexify/dist/ReactContexify.css'; +// import 'react-contexify/dist/ReactContexify.css'; const destination = '/signin'; interface IMoreAppsContext { @@ -126,7 +126,12 @@ export async function getServerSideProps({ req, res, locales }: any) { return { props: { - ...(await serverSideTranslations(local, ['common', 'cloudProviders'], null, locales || [])), + ...(await serverSideTranslations( + local, + ['common', 'cloudProviders', 'error'], + null, + locales || [] + )), sealos_cloud_domain } }; diff --git a/frontend/desktop/src/pages/proxyOAuth.tsx b/frontend/desktop/src/pages/proxyOAuth.tsx index 80e6b746ffe..7fa6fd6901b 100644 --- a/frontend/desktop/src/pages/proxyOAuth.tsx +++ b/frontend/desktop/src/pages/proxyOAuth.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router'; import { useEffect } from 'react'; import request from '@/services/request'; import useSessionStore from '@/stores/session'; -import { ApiResp, AuthConfigType } from '@/types'; +import { ApiResp, AppClientConfigType, AuthConfigType } from '@/types'; import { Flex, Spinner } from '@chakra-ui/react'; import { isString } from 'lodash'; import { useQuery } from '@tanstack/react-query'; @@ -10,65 +10,65 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { compareFirstLanguages } from '@/utils/tools'; import { OauthProvider } from '@/types/user'; import { useConfigStore } from '@/stores/config'; +import { getAppConfig } from './api/platform/getAppConfig'; -export default function Callback() { +export default function Callback({ appConfig }: { appConfig: AppClientConfigType }) { const router = useRouter(); const setProvider = useSessionStore((s) => s.setProvider); const generateState = useSessionStore((s) => s.generateState); - const { data: res } = useQuery(['getPlatformEnv'], () => - request>('/api/platform/getLoginConfig') - ); - const conf = res?.data; - const callback_url = conf?.callbackURL; - const oauthLogin = async ({ url, provider }: { url: string; provider?: OauthProvider }) => { - setProvider(provider); - window.location.href = url; - }; + const callback_url = appConfig.desktop.auth.callbackURL; useEffect(() => { - // if (router.isReady) return - console.log('hellow proxy'); + if (!router.isReady) return; + const oauthLogin = async ({ url, provider }: { url: string; provider?: OauthProvider }) => { + setProvider(provider); + window.location.href = url; + }; const oauthProxyState = router.query.oauthProxyState; const oauthProvider = router.query.oauthProxyProvider; + let isProxying = false; // nextjs auto decode let oauthClientId = router.query.oauthProxyClientID; if (!isString(oauthProxyState) || !isString(oauthProvider) || !isString(oauthClientId)) return; - console.log(oauthProvider, oauthProxyState, oauthClientId, callback_url); (async () => { try { - const _url = new URL(oauthProxyState); + if (isProxying) return; + isProxying = true; + const [originUrl, ...res] = oauthProxyState.split('_'); + const _url = new URL(decodeURIComponent(originUrl)); const result = (await ( await fetch(`/api/auth/canProxy?domain=${encodeURIComponent(_url.host)}`, {}) ).json()) as ApiResp<{ containDomain: boolean }>; - console.log(result); if (!result.data?.containDomain) return; - const state = generateState(oauthProxyState); + const state = encodeURIComponent(generateState('PROXY', oauthProxyState)); - if (oauthProvider === 'github') { + if (oauthProvider === 'GITHUB') { await oauthLogin({ - provider: 'github', + provider: 'GITHUB', url: `https://github.com/login/oauth/authorize?client_id=${oauthClientId}&redirect_uri=${callback_url}&scope=user:email%20read:user&state=${state}` }); - } else if (oauthProvider === 'wechat') { + } else if (oauthProvider === 'WECHAT') { await oauthLogin({ - provider: 'wechat', + provider: 'WECHAT', url: `https://open.weixin.qq.com/connect/qrconnect?appid=${oauthClientId}&redirect_uri=${callback_url}&response_type=code&state=${state}&scope=snsapi_login&#wechat_redirect` }); - } else if (oauthProvider === 'google') { + } else if (oauthProvider === 'GOOGLE') { const scope = encodeURIComponent( `https://www.googleapis.com/auth/userinfo.profile openid` ); oauthLogin({ - provider: 'google', + provider: 'GOOGLE', url: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${oauthClientId}&redirect_uri=${callback_url}&response_type=code&state=${state}&scope=${scope}&include_granted_scopes=true` }); } } catch (err) { console.error(err); + } finally { + isProxying = false; } })(); return () => { - // abortController.abort() + isProxying = true; }; }, [router, callback_url]); return ( @@ -83,10 +83,12 @@ export async function getServerSideProps({ req, res, locales }: any) { req?.cookies?.NEXT_LOCALE || compareFirstLanguages(req?.headers?.['accept-language'] || 'zh'); res.setHeader('Set-Cookie', `NEXT_LOCALE=${local}; Max-Age=2592000; Secure; SameSite=None`); const sealos_cloud_domain = useConfigStore.getState().cloudConfig?.domain; + const appConfig = await getAppConfig(); return { props: { ...(await serverSideTranslations(local, undefined, null, locales || [])), - sealos_cloud_domain + sealos_cloud_domain, + appConfig } }; } diff --git a/frontend/desktop/src/services/backend/auth.ts b/frontend/desktop/src/services/backend/auth.ts index 145630ecf42..0d8f114ff8d 100644 --- a/frontend/desktop/src/services/backend/auth.ts +++ b/frontend/desktop/src/services/backend/auth.ts @@ -1,7 +1,7 @@ import { IncomingHttpHeaders } from 'http'; import { sign, verify } from 'jsonwebtoken'; import { JWTPayload } from '@/types'; -import { AuthenticationTokenPayload, AccessTokenPayload } from '@/types/token'; +import { AuthenticationTokenPayload, AccessTokenPayload, CronJobTokenPayload } from '@/types/token'; const regionUID = () => global.AppConfig?.cloud.regionUID || '123456789'; const grobalJwtSecret = () => global.AppConfig?.desktop.auth.jwt.global || '123456789'; @@ -17,7 +17,6 @@ const verifyToken = async (header: IncomingHttpHeaders) => { const payload = await verifyJWT(token); return payload; } catch (err) { - console.error(err); return null; } }; @@ -41,7 +40,6 @@ export const verifyAuthenticationToken = async (header: IncomingHttpHeaders) => const payload = await verifyJWT(token, grobalJwtSecret()); return payload; } catch (err) { - console.error(err); return null; } }; @@ -50,10 +48,9 @@ export const verifyJWT = (token?: string, secret? if (!token) return resolve(null); verify(token, secret || regionalJwtSecret(), (err, payload) => { if (err) { - console.log(err); + // console.log(err); resolve(null); } else if (!payload) { - console.log('payload is null'); resolve(null); } else { resolve(payload as T); @@ -66,3 +63,6 @@ export const generateAppToken = (props: AccessTokenPayload) => sign(props, internalJwtSecret(), { expiresIn: '7d' }); export const generateAuthenticationToken = (props: AuthenticationTokenPayload) => sign(props, grobalJwtSecret(), { expiresIn: '60000' }); + +export const generateCronJobToken = (props: CronJobTokenPayload) => + sign(props, internalJwtSecret(), { expiresIn: '60000' }); diff --git a/frontend/desktop/src/services/backend/cronjob/deleteUserCr.ts b/frontend/desktop/src/services/backend/cronjob/deleteUserCr.ts new file mode 100644 index 00000000000..880cd60f831 --- /dev/null +++ b/frontend/desktop/src/services/backend/cronjob/deleteUserCr.ts @@ -0,0 +1,113 @@ +import { setUserDelete } from '../kubernetes/admin'; +import { globalPrisma, prisma } from '../db/init'; +import { TransactionStatus, TransactionType } from 'prisma/global/generated/client'; +import { CronJobStatus } from '@/services/backend/cronjob/index'; +import { DeleteUserEvent } from '@/types/db/event'; +import { getRegionUid } from '@/services/enable'; + +export class DeleteUserCrJob implements CronJobStatus { + private userUid = ''; + transactionType = TransactionType.DELETE_USER; + UNIT_TIMEOUT = 3000; + COMMIT_TIMEOUT = 30000; + constructor(private transactionUid: string, private infoUid: string) {} + async init() { + const infoUid = this.infoUid; + const info = await globalPrisma.deleteUserTransactionInfo.findUnique({ + where: { + uid: infoUid + } + }); + if (!info) throw new Error('the transaction info not found'); + + this.userUid = info.userUid; + } + async unit() { + await this.init(); + const userUid = this.userUid; + const userCr = await prisma.userCr.findUnique({ + where: { userUid } + }); + if (!userCr) { + await globalPrisma.eventLog.create({ + data: { + eventName: DeleteUserEvent['_DELETE_USERCR'], + mainId: userUid, + data: JSON.stringify({ + userUid, + regionUid: getRegionUid(), + message: `Because the userCR is not found, deleting user success` + }) + } + }); + return; + // throw new Error('the userCR not found'); + } + const deleteResult = await setUserDelete(userCr.crName); + + if (!deleteResult) { + throw new Error(`delete User not Success`); + } + await globalPrisma.eventLog.create({ + data: { + eventName: DeleteUserEvent['_DELETE_USERCR'], + mainId: userUid, + data: JSON.stringify({ + userUid, + regionUid: getRegionUid(), + message: `delete user success` + }) + } + }); + await prisma.userWorkspace.deleteMany({ + where: { + userCrUid: userCr.uid + } + }); + } + canCommit() { + return true; + } + async commit() { + await this.init(); + const userUid = this.userUid; + if (!userUid) throw Error('uid not found'); + await globalPrisma.$transaction([ + globalPrisma.commitTransactionSet.create({ + data: { + precommitTransactionUid: this.transactionUid + } + }), + globalPrisma.deleteUserLog.create({ + data: { + userUid + } + }), + globalPrisma.eventLog.create({ + data: { + eventName: DeleteUserEvent['_COMMIT'], + mainId: userUid, + data: JSON.stringify({ + userUid, + message: `delete user success` + }) + } + }), + globalPrisma.precommitTransaction.findUniqueOrThrow({ + where: { + uid: this.transactionUid, + status: TransactionStatus.FINISH + } + }), + globalPrisma.precommitTransaction.update({ + where: { + uid: this.transactionUid, + status: TransactionStatus.FINISH + }, + data: { + status: TransactionStatus.COMMITED + } + }) + ]); + } +} diff --git a/frontend/desktop/src/services/backend/cronjob/index.ts b/frontend/desktop/src/services/backend/cronjob/index.ts new file mode 100644 index 00000000000..b8e99a2cceb --- /dev/null +++ b/frontend/desktop/src/services/backend/cronjob/index.ts @@ -0,0 +1,239 @@ +import { globalPrisma } from '../db/init'; +import { + TransactionStatus, + TransactionType, + Prisma as GlobalPrisma +} from 'prisma/global/generated/client'; +import { getRegionUid } from '@/services/enable'; +import dayjs from 'dayjs'; +import { DeleteUserCrJob } from '@/services/backend/cronjob/deleteUserCr'; +import { Prisma } from '@prisma/client/extension'; +import { MergeUserCrJob } from '@/services/backend/cronjob/mergeUserCr'; + +export type CronJobStatus = { + unit: (infoUid: string, transactionUid: string) => Promise; + canCommit: () => boolean; + transactionType: TransactionType; + COMMIT_TIMEOUT: number; + commit: () => Promise; +}; +const TIMEOUT = 5000; +const getJob = ( + transaction: Prisma.Result< + typeof globalPrisma.precommitTransaction, + GlobalPrisma.PrecommitTransactionDefaultArgs, + 'findFirst' + > +) => { + if (!transaction) return null; + if (transaction.transactionType === TransactionType.DELETE_USER) { + return new DeleteUserCrJob(transaction.uid, transaction.infoUid); + } else if (transaction.transactionType === TransactionType.MERGE_USER) { + return new MergeUserCrJob(transaction.uid, transaction.infoUid); + } else { + return null; + } +}; +// ready=>running +// handle one Task per tick +export const runTransactionjob = async () => { + // console.log('run transactionjob', new Date()); + const regionUid = getRegionUid(); + let isTimeoutTransactionDetail = false; + // find task + let transactionDetail = await globalPrisma.transactionDetail.findFirst({ + where: { + regionUid, + status: TransactionStatus.READY + }, + include: { + precommitTransaction: true + }, + orderBy: { + updatedAt: 'asc' + } + }); + if (!transactionDetail) { + transactionDetail = await globalPrisma.transactionDetail.findFirst({ + where: { + regionUid, + // death lock + status: TransactionStatus.RUNNING, + updatedAt: { + lte: dayjs().subtract(TIMEOUT, 'ms').toDate() + } + }, + orderBy: { + updatedAt: 'asc' + }, + include: { + precommitTransaction: true + } + }); + isTimeoutTransactionDetail = true; + } + // not found task + if (!transactionDetail) { + return; + } + const transaction = transactionDetail.precommitTransaction; + + const job = getJob(transaction); + if (!job) return; + + // startingRunning + if ( + transaction.status === TransactionStatus.RUNNING || + transaction.status === TransactionStatus.READY + ) { + if (isTimeoutTransactionDetail) { + await globalPrisma.$transaction([ + // make sure it is not running + globalPrisma.transactionDetail.findUniqueOrThrow({ + where: { + uid: transactionDetail.uid, + updatedAt: { + lte: dayjs().subtract(TIMEOUT, 'ms').toDate() + } + } + }), + globalPrisma.transactionDetail.update({ + where: { + uid: transactionDetail.uid, + status: TransactionStatus.RUNNING + }, + data: { + status: TransactionStatus.RUNNING + } + }) + ]); + } else { + await globalPrisma.$transaction([ + globalPrisma.transactionDetail.findUniqueOrThrow({ + where: { + uid: transactionDetail.uid, + status: TransactionStatus.READY + } + }), + globalPrisma.transactionDetail.update({ + where: { + uid: transactionDetail.uid, + status: TransactionStatus.READY + }, + data: { + status: TransactionStatus.RUNNING + } + }) + ]); + } + } else { + return; + } + // try { + await job.unit(); + await globalPrisma.transactionDetail.update({ + where: { + uid: transactionDetail.uid, + status: TransactionStatus.RUNNING + }, + data: { + status: TransactionStatus.FINISH + } + }); +}; + +// running => finish or error +export const finishTransactionJob = async () => { + // console.log('finish transactionjob', new Date()); + const regionList = await globalPrisma.region.findMany({}); + const transactionList = await globalPrisma.precommitTransaction.findMany({ + where: { + status: { + in: [TransactionStatus.RUNNING, TransactionStatus.READY] + } + }, + include: { + transactionDetail: { + select: { + status: true, + regionUid: true + } + } + } + }); + const needFinishTransactionList = transactionList + .filter((tx) => { + const finishList = tx.transactionDetail.filter((d) => d.status === TransactionStatus.FINISH); + return regionList.every(({ uid }) => finishList.findIndex((f) => f.regionUid === uid) >= 0); + }) + .map((tx) => tx.uid); + if (!needFinishTransactionList) return; + + await globalPrisma.precommitTransaction.updateMany({ + where: { + status: { + in: [TransactionStatus.RUNNING, TransactionStatus.READY] + }, + uid: { + in: needFinishTransactionList + } + }, + data: { + status: TransactionStatus.FINISH + } + }); +}; + +// finish => commited +export const commitTransactionjob = async () => { + // console.log('commit transactionjob', new Date()); + const unCommitedTransaction = await globalPrisma.precommitTransaction.findFirst({ + where: { + status: TransactionStatus.FINISH, + commitTransactionSet: null + }, + orderBy: { + updatedAt: 'asc' + } + }); + if (!unCommitedTransaction) return; + const job = getJob(unCommitedTransaction); + if (!job) return; + // if timeout, mark error + const currentTime = new Date().getTime(); + // the record will be updated when status is updated + if (currentTime - unCommitedTransaction.updatedAt.getTime() > job.COMMIT_TIMEOUT) { + await globalPrisma.$transaction([ + globalPrisma.commitTransactionSet.create({ + data: { + precommitTransactionUid: unCommitedTransaction.uid + } + }), + globalPrisma.precommitTransaction.findUniqueOrThrow({ + where: { + uid: unCommitedTransaction.uid, + status: TransactionStatus.FINISH + } + }), + globalPrisma.precommitTransaction.update({ + where: { + uid: unCommitedTransaction.uid, + status: TransactionStatus.FINISH + }, + data: { + status: TransactionStatus.ERROR + } + }), + globalPrisma.errorPreCommitTransaction.create({ + data: { + transactionUid: unCommitedTransaction.uid, + reason: 'commit timeout' + } + }) + ]); + return; + } + const canCommit = job.canCommit(); + if (!canCommit) return; + else await job.commit(); +}; diff --git a/frontend/desktop/src/services/backend/cronjob/mergeUserCr.ts b/frontend/desktop/src/services/backend/cronjob/mergeUserCr.ts new file mode 100644 index 00000000000..82832f8643b --- /dev/null +++ b/frontend/desktop/src/services/backend/cronjob/mergeUserCr.ts @@ -0,0 +1,254 @@ +import { globalPrisma, prisma } from '../db/init'; +import { TransactionStatus, TransactionType } from 'prisma/global/generated/client'; +import { JoinStatus } from 'prisma/region/generated/client'; +import { getBillingUrl, getCvmUrl, getRegionUid, getWorkorderUrl } from '@/services/enable'; +import { CronJobStatus } from '@/services/backend/cronjob/index'; +import { getUserKubeconfigNotPatch } from '@/services/backend/kubernetes/admin'; +import { mergeUserModifyBinding, mergeUserWorkspaceRole } from '@/services/backend/team'; +import axios from 'axios'; +import { generateCronJobToken } from '../auth'; +import { MergeUserEvent } from '@/types/db/event'; + +/** + * | | user is exist | user is not exist | + * |mergeUser is exist | merge | update mereUser to user | + * |mergeUser is not exist| return ok | return ok | + */ +export class MergeUserCrJob implements CronJobStatus { + private mergeUserUid = ''; + private userUid: string = ''; + UNIT_TIMEOUT = 3000; + COMMIT_TIMEOUT = 60000; + transactionType = TransactionType.MERGE_USER; + constructor(private transactionUid: string, private infoUid: string) {} + async init() { + const info = await globalPrisma.mergeUserTransactionInfo.findUnique({ + where: { + uid: this.infoUid + } + }); + if (!info) throw new Error('the transaction info not found'); + const { mergeUserUid, userUid } = info; + this.mergeUserUid = mergeUserUid; + this.userUid = userUid; + } + async unit() { + await this.init(); + const mergeUserUid = this.mergeUserUid; + const userUid = this.userUid; + const mergeUserCr = await prisma.userCr.findUnique({ + where: { userUid: mergeUserUid } + }); + + if (!mergeUserCr) { + // the mergeUser is not exist in the current region + await globalPrisma.eventLog.create({ + data: { + eventName: MergeUserEvent['_MERGE_WORKSPACE'], + mainId: userUid, + data: JSON.stringify({ + mergeUserUid, + userUid, + regionUid: getRegionUid(), + message: `Because the mergeUserCR is not found, merge workspace success` + }) + } + }); + return; + // throw new Error('the mergeUserCR is not found'); + } + const userCr = await prisma.userCr.findUnique({ + where: { userUid } + }); + if (!userCr) { + // the user is not exist in the current region + // throw new Error('the userCR is not found'); + await prisma.userCr.update({ + where: { + userUid: mergeUserUid + }, + data: { + userUid + } + }); + await globalPrisma.eventLog.create({ + data: { + eventName: MergeUserEvent['_MERGE_WORKSPACE'], + mainId: userUid, + data: JSON.stringify({ + mergeUserUid, + userUid, + regionUid: getRegionUid(), + message: `Because the userCR is not found, merge workspace success` + }) + } + }); + } else { + const [userWorkspaceList, mergeUserWorkspaceList] = await prisma.$transaction([ + prisma.userWorkspace.findMany({ + where: { + userCrUid: userCr.uid, + status: JoinStatus.IN_WORKSPACE + } + }), + prisma.userWorkspace.findMany({ + where: { + userCrUid: mergeUserCr.uid, + status: JoinStatus.IN_WORKSPACE + }, + include: { + workspace: { + select: { + id: true + } + } + } + }) + ]); + // modify role + await Promise.all( + mergeUserWorkspaceList.map(async ({ role: mergeUserRole, workspaceUid, workspace }) => { + try { + const userWorkspace = userWorkspaceList.find((r) => r.workspaceUid === workspaceUid); + await globalPrisma.eventLog.create({ + data: { + eventName: MergeUserEvent['_MERGE_WORKSPACE'], + mainId: userUid, + data: JSON.stringify({ + mergeUserCrName: mergeUserCr.crName, + userCrName: userCr.crName, + workspaceId: workspace.id, + userUid, + mergeUserUid, + mergeUserRole, + regionUid: getRegionUid(), + userRole: userWorkspace?.role, + message: `merge workspace` + }) + } + }); + // modify k8s resource, the handle is idempotent + await mergeUserWorkspaceRole({ + mergeUserRole, + userRole: userWorkspace?.role, + workspaceId: workspace.id, + mergeUserCrName: mergeUserCr.crName, + userCrName: userCr.crName + }); + // modify db resource + await mergeUserModifyBinding({ + mergeUserCrUid: mergeUserCr.uid, + mergeUserRole, + userCrUid: userCr.uid, + workspaceUid, + userRole: userWorkspace?.role + }); + } catch (err: any) { + console.error(err); + } + }) + ); + } + } + canCommit() { + const baseBillingUrl = getBillingUrl(); + const baseWorkorderUrl = getWorkorderUrl(); + const baseCvmUrl = getCvmUrl(); + return !!baseBillingUrl && !!baseCvmUrl && !!baseWorkorderUrl; + } + async commit() { + await this.init(); + const mergeUserUid = this.mergeUserUid; + const userUid = this.userUid; + if (!mergeUserUid || !userUid) throw Error('uid not found'); + const baseBillingUrl = getBillingUrl(); + const baseWorkorderUrl = getWorkorderUrl(); + const baseCvmUrl = getCvmUrl(); + const billingUrl = baseBillingUrl + '/account/v1alpha1/transfer'; + const workorderUrl = baseWorkorderUrl + '/api/v1/migrate'; + const cvmUrl = baseCvmUrl + '/action/sealos-account-merge'; + // transfer + const [user, mergeUser] = await globalPrisma.$transaction([ + globalPrisma.user.findUniqueOrThrow({ where: { uid: userUid } }), + globalPrisma.user.findUniqueOrThrow({ where: { uid: mergeUserUid } }) + ]); + let finalUserCr = await prisma.userCr.findUnique({ + where: { + userUid: mergeUser.uid + } + }); + if (!finalUserCr) { + finalUserCr = await prisma.userCr.findUnique({ + where: { + userUid + } + }); + if (!finalUserCr) { + throw Error('the userCr is not exist'); + } + } + const kubeConfig = await getUserKubeconfigNotPatch(finalUserCr.crName); + if (!kubeConfig) throw Error('the kubeconfig for ' + finalUserCr.crName + ' is not found'); + const [transferResult, workorderResult, cvmResult] = await Promise.all([ + axios.post(billingUrl, { + kubeConfig, + owner: finalUserCr.crName, + userid: mergeUser.id, + toUser: user.id, + transferAll: true + }), + axios.post(workorderUrl, { + token: generateCronJobToken({ + userUid: user.id, + mergeUserUid: mergeUser.id + }) + }), + axios.post(cvmUrl, { + token: generateCronJobToken({ + userUid: user.uid, + mergeUserUid: mergeUser.uid + }) + }) + ]); + const transferMergeSuccess = transferResult && transferResult.status === 200; + const workorderMergeSuccess = + workorderResult && workorderResult.status === 200 && workorderResult.data.code === 200; + const cvmMergeSuccess = cvmResult && cvmResult.status === 200 && cvmResult.data.data === 'ok'; + if (!transferMergeSuccess || !workorderMergeSuccess || !cvmMergeSuccess) { + throw new Error('commit Error'); + } + await globalPrisma.$transaction([ + globalPrisma.commitTransactionSet.create({ + data: { + precommitTransactionUid: this.transactionUid + } + }), + globalPrisma.deleteUserLog.create({ + data: { + userUid: mergeUserUid + } + }), + globalPrisma.eventLog.create({ + data: { + eventName: MergeUserEvent['_COMMIT'], + mainId: this.userUid, + data: JSON.stringify({ + userUid, + mergeUserUid, + regionUid: getRegionUid(), + message: `from ${mergeUser.id} to ${user.id}, + merge workorder, cloud vm and balance success` + }) + } + }), + globalPrisma.precommitTransaction.update({ + where: { + uid: this.transactionUid + }, + data: { + status: TransactionStatus.COMMITED + } + }) + ]); + } +} diff --git a/frontend/desktop/src/services/backend/db/emailVerifyCode.ts b/frontend/desktop/src/services/backend/db/emailVerifyCode.ts new file mode 100644 index 00000000000..45f242ef8df --- /dev/null +++ b/frontend/desktop/src/services/backend/db/emailVerifyCode.ts @@ -0,0 +1,75 @@ +import { v4 } from 'uuid'; +import { connectToDatabase } from './mongodb'; + +export type TEmailVerification_Codes = { + email: string; + code: string; + uid: string; + createdAt: Date; +}; + +async function connectToCollection() { + const client = await connectToDatabase(); + const collection = client.db().collection('email_verification_codes'); + await collection.createIndex({ createdAt: 1 }, { expireAfterSeconds: 60 * 5 }); + await collection.createIndex({ uid: 1 }, { unique: true }); + return collection; +} + +// addOrUpdateCode +export async function addOrUpdateCode({ email, code }: { email: string; code: string }) { + const codes = await connectToCollection(); + const result = await codes.updateOne( + { + email + }, + { + $set: { + code, + createdAt: new Date(), + uid: v4() + } + }, + { + upsert: true + } + ); + return result; +} +// checkCode +export async function checkSendable({ email }: { email: string }) { + const codes = await connectToCollection(); + const result = await codes.findOne({ + email, + createdAt: { + $gt: new Date(new Date().getTime() - 60 * 1000) + } + }); + return !result; +} +// checkCode +export async function checkCode({ email, code }: { email: string; code: string }) { + const codes = await connectToCollection(); + const result = await codes.findOne({ + email, + code, + createdAt: { + $gt: new Date(new Date().getTime() - 5 * 60 * 1000) + } + }); + return result; +} +export async function getInfoByUid({ uid }: { uid: string }) { + const codes = await connectToCollection(); + const result = await codes.findOne({ + uid + }); + return result; +} +export async function deleteByUid({ uid }: { uid: string }) { + const codes = await connectToCollection(); + const result = await codes.deleteOne({ + uid + }); + return result; +} diff --git a/frontend/desktop/src/services/backend/db/mergeUserCode.ts b/frontend/desktop/src/services/backend/db/mergeUserCode.ts new file mode 100644 index 00000000000..cc3c7a67cda --- /dev/null +++ b/frontend/desktop/src/services/backend/db/mergeUserCode.ts @@ -0,0 +1,75 @@ +import { v4 } from 'uuid'; +import { connectToDatabase } from './mongodb'; +import { ProviderType } from 'prisma/global/generated/client'; + +export type TMergeUserCode = { + code: string; + providerId: string; + providerType: ProviderType; + uid: string; + createdAt: Date; +}; + +async function connectToCollection() { + const client = await connectToDatabase(); + const collection = client.db().collection('mergeUserCode'); + await collection.createIndex({ createdAt: 1 }, { expireAfterSeconds: 60 * 1 }); + await collection.createIndex({ uid: 1 }, { unique: true }); + await collection.createIndex({ code: 1 }, { unique: true }); + await collection.createIndex({ providerId: 1, providerType: 1 }, { unique: true }); + return collection; +} + +// addOrUpdateCode +export async function addOrUpdateCode({ + providerId, + providerType, + code +}: { + providerId: string; + providerType: ProviderType; + code: string; +}) { + const codes = await connectToCollection(); + const result = await codes.updateOne( + { + providerId, + providerType + }, + { + $set: { + code, + createdAt: new Date(), + uid: v4() + } + }, + { + upsert: true + } + ); + return result; +} +export async function checkCode({ + providerType, + code +}: { + providerType: ProviderType; + code: string; +}) { + const codes = await connectToCollection(); + const result = await codes.findOne({ + providerType, + code, + createdAt: { + $gt: new Date(new Date().getTime() - 1 * 60 * 1000) + } + }); + return result; +} +export async function deleteByUid({ uid }: { uid: string }) { + const codes = await connectToCollection(); + const result = await codes.deleteOne({ + uid + }); + return result; +} diff --git a/frontend/desktop/src/services/backend/db/verifyCode.ts b/frontend/desktop/src/services/backend/db/verifyCode.ts index 6dad9c4b3ae..f7bea40c6bf 100644 --- a/frontend/desktop/src/services/backend/db/verifyCode.ts +++ b/frontend/desktop/src/services/backend/db/verifyCode.ts @@ -1,29 +1,44 @@ +import { v4 } from 'uuid'; import { connectToDatabase } from './mongodb'; - -type TVerification_Codes = { - phone: string; +export type SmsType = 'phone' | 'email'; +export type TVerification_Codes = { + id: string; + smsType: SmsType; code: string; + uid: string; createdAt: Date; }; -async function connectToUserCollection() { +async function connectToCollection() { const client = await connectToDatabase(); - const collection = client.db().collection('verification_codes'); + const collection = client.db().collection('sms_verification_codes'); await collection.createIndex({ createdAt: 1 }, { expireAfterSeconds: 60 * 5 }); + await collection.createIndex({ uid: 1 }, { unique: true }); + await collection.createIndex({ id: 1, smsType: 1 }, { unique: true }); return collection; } // addOrUpdateCode -export async function addOrUpdateCode({ phone, code }: { phone: string; code: string }) { - const codes = await connectToUserCollection(); +export async function addOrUpdateCode({ + id, + smsType, + code +}: { + id: string; + code: string; + smsType: SmsType; +}) { + const codes = await connectToCollection(); const result = await codes.updateOne( { - phone + id, + smsType }, { $set: { code, - createdAt: new Date() + createdAt: new Date(), + uid: v4() } }, { @@ -33,27 +48,49 @@ export async function addOrUpdateCode({ phone, code }: { phone: string; code: st return result; } // checkCode -export async function checkSendable({ phone }: { phone: string }) { - const codes = await connectToUserCollection(); +export async function checkSendable({ id, smsType }: { id: string; smsType: SmsType }) { + const codes = await connectToCollection(); const result = await codes.findOne({ - phone, + id, + smsType, createdAt: { - // 在区间范围内找到就是已经发送过了,不能再发了 $gt: new Date(new Date().getTime() - 60 * 1000) } }); return !result; } // checkCode -export async function checkCode({ phone, code }: { phone: string; code: string }) { - const codes = await connectToUserCollection(); +export async function checkCode({ + id, + smsType, + code +}: { + id: string; + code: string; + smsType: SmsType; +}) { + const codes = await connectToCollection(); const result = await codes.findOne({ - phone, + id, + smsType, code, createdAt: { - // 5分钟内有效 $gt: new Date(new Date().getTime() - 5 * 60 * 1000) } }); - return !!result; + return result; +} +export async function getInfoByUid({ uid }: { uid: string }) { + const codes = await connectToCollection(); + const result = await codes.findOne({ + uid + }); + return result; +} +export async function deleteByUid({ uid }: { uid: string }) { + const codes = await connectToCollection(); + const result = await codes.deleteOne({ + uid + }); + return result; } diff --git a/frontend/desktop/src/services/backend/globalAuth.ts b/frontend/desktop/src/services/backend/globalAuth.ts index 1b7cddf082b..98100e580bf 100644 --- a/frontend/desktop/src/services/backend/globalAuth.ts +++ b/frontend/desktop/src/services/backend/globalAuth.ts @@ -38,7 +38,7 @@ export const inviteHandler = ({ const secretKey = conf.invite?.lafSecretKey || ''; const baseUrl = conf.invite?.lafBaseURL || ''; - if (inviteEnabled || !baseUrl || inviterId === inviteeId) return; + if (!inviteEnabled || !baseUrl || inviterId === inviteeId) return; const payload = { inviterId, @@ -118,6 +118,7 @@ async function signUp({ user }; } catch (e) { + console.log(e); return null; } } @@ -180,14 +181,14 @@ export async function findUser({ userUid }: { userUid: string }) { } export const getGlobalToken = async ({ provider, - id, + providerId, name, avatar_url, password, inviterId }: { provider: ProviderType; - id: string; + providerId: string; name: string; avatar_url: string; password?: string; @@ -199,7 +200,7 @@ export const getGlobalToken = async ({ where: { providerId_providerType: { providerType: provider, - providerId: id + providerId } } }); @@ -210,7 +211,7 @@ export const getGlobalToken = async ({ if (!_user) { if (!enableSignUp()) throw new Error('Failed to signUp user'); const result = await signUpByPassword({ - id, + id: providerId, name, avatar_url, password @@ -225,7 +226,7 @@ export const getGlobalToken = async ({ } } else { const result = await signInByPassword({ - id, + id: providerId, password }); // password is wrong @@ -237,7 +238,7 @@ export const getGlobalToken = async ({ if (!enableSignUp()) throw new Error('Failed to signUp user'); const result = await signUp({ provider, - id, + id: providerId, name, avatar_url }); @@ -252,7 +253,7 @@ export const getGlobalToken = async ({ } else { const result = await signIn({ provider, - id + id: providerId }); result && (user = result.user); } diff --git a/frontend/desktop/src/services/backend/kubernetes/admin.ts b/frontend/desktop/src/services/backend/kubernetes/admin.ts index a76b5e63034..bef250d9faf 100644 --- a/frontend/desktop/src/services/backend/kubernetes/admin.ts +++ b/frontend/desktop/src/services/backend/kubernetes/admin.ts @@ -212,7 +212,33 @@ async function removeUserTeam(kc: k8s.KubeConfig, k8s_username: string) { ); return k8s_username; } - +async function removeUser(kc: k8s.KubeConfig, k8s_username: string) { + const group = 'user.sealos.io'; + const version = 'v1'; + const plural = 'users'; + const updateTime = k8sFormatTime(new Date()); + const client = kc.makeApiClient(k8s.CustomObjectsApi); + const patchs = [ + { op: 'add', path: '/metadata/labels/user.sealos.io~1status', value: 'Deleted' }, + { op: 'replace', path: '/metadata/labels/updateTime', value: updateTime } + ]; + await client.patchClusterCustomObject( + group, + version, + plural, + k8s_username, + patchs, + undefined, + undefined, + undefined, + { + headers: { + 'Content-Type': 'application/json-patch+json' + } + } + ); + return k8s_username; +} export const getUserCr = async (kc: KubeConfig, name: string) => { const resourceKind = 'User'; const group = 'user.sealos.io'; @@ -295,3 +321,30 @@ export const setUserTeamDelete = async (k8s_username: string) => { }); return body; }; +export const setUserDelete = async (k8s_username: string) => { + const kc = K8sApiDefault(); + const group = 'user.sealos.io'; + const version = 'v1'; + const plural = 'users'; + try { + const userCr = await getUserCr(kc, k8s_username); + // @ts-ignore + if (userCr.metadata?.labels?.['user.sealos.io/status'] === 'Deleted') return true; + } catch (e) { + return true; + } + await removeUser(kc, k8s_username); + + const body = await watchCustomClusterObject({ + kc, + group, + version, + plural, + fn(_, cur) { + return cur?.metadata?.labels?.['user.sealos.io/status'] === 'Deleted'; + }, + name: k8s_username, + interval: 100 + }); + return !!body; +}; diff --git a/frontend/desktop/src/services/backend/middleware/access.ts b/frontend/desktop/src/services/backend/middleware/access.ts new file mode 100644 index 00000000000..4c8ed8ad120 --- /dev/null +++ b/frontend/desktop/src/services/backend/middleware/access.ts @@ -0,0 +1,32 @@ +import { jsonRes } from '../response'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { verifyAccessToken, verifyAuthenticationToken } from '../auth'; +import { AccessTokenPayload, AuthenticationTokenPayload } from '@/types/token'; + +export const filterAccessToken = async ( + req: NextApiRequest, + res: NextApiResponse, + next: (data: AccessTokenPayload) => void +) => { + const userData = await verifyAccessToken(req.headers); + if (!userData) + return jsonRes(res, { + code: 401, + message: 'invalid token' + }); + else await Promise.resolve(next(userData)); +}; + +export const filterAuthenticationToken = async ( + req: NextApiRequest, + res: NextApiResponse, + next: (data: AuthenticationTokenPayload) => Promise +) => { + const userData = await verifyAuthenticationToken(req.headers); + if (!userData) + return jsonRes(res, { + code: 401, + message: 'invalid token' + }); + else await next(userData); +}; diff --git a/frontend/desktop/src/services/backend/middleware/amount.ts b/frontend/desktop/src/services/backend/middleware/amount.ts new file mode 100644 index 00000000000..0edc2c0757d --- /dev/null +++ b/frontend/desktop/src/services/backend/middleware/amount.ts @@ -0,0 +1,25 @@ +import { jsonRes } from '../response'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { globalPrisma } from '../db/init'; +import { RESOURCE_STATUS } from '@/types/response/checkResource'; + +export const accountBalanceGuard = + (userUid: string) => async (res: NextApiResponse, next?: () => void) => { + const account = await globalPrisma.account.findUnique({ + where: { + userUid + } + }); + if (!account) + return jsonRes(res, { + code: 404, + message: RESOURCE_STATUS.ACCOUNT_NOT_FOUND + }); + const balance = Number(account.balance || 0) - Number(account.deduction_balance || 0); + if (balance < 0) + return jsonRes(res, { + code: 409, + message: RESOURCE_STATUS.INSUFFICENT_BALANCE + }); + await Promise.resolve(next?.()); + }; diff --git a/frontend/desktop/src/services/backend/middleware/checkResource.ts b/frontend/desktop/src/services/backend/middleware/checkResource.ts new file mode 100644 index 00000000000..1f63487e4f0 --- /dev/null +++ b/frontend/desktop/src/services/backend/middleware/checkResource.ts @@ -0,0 +1,170 @@ +import { NextApiResponse } from 'next'; +import { globalPrisma, prisma } from '../db/init'; +import { getUserKubeconfigNotPatch, K8sApiDefault } from '../kubernetes/admin'; +import { jsonRes } from '../response'; +import { generateAuthenticationToken } from '../auth'; +import { JoinStatus } from 'prisma/region/generated/client'; +import { RESOURCE_STATUS } from '@/types/response/checkResource'; +export const resourceGuard = + (userUid: string) => async (res: NextApiResponse, next?: () => void) => { + const userCr = await prisma.userCr.findUnique({ + where: { + userUid + }, + include: { + userWorkspace: { + include: { + workspace: true + } + } + } + }); + if (!userCr) + return jsonRes(res, { + message: RESOURCE_STATUS.USER_CR_NOT_FOUND, + code: 404 + }); + const userWorkspaces = userCr.userWorkspace; + const privateUserWorkspace = userWorkspaces.find((w) => w.isPrivate); + if (!privateUserWorkspace) + return jsonRes(res, { + message: RESOURCE_STATUS.PRIVATE_WORKSPACE_NOT_FOUND, + code: 404 + }); + const OwnerWorspaces = userWorkspaces.filter( + (w) => w.role === 'OWNER' && w.status === JoinStatus.IN_WORKSPACE + ); + if (OwnerWorspaces.length > 1) + return jsonRes(res, { + message: RESOURCE_STATUS.REMAIN_WORKSACE_OWNER, + code: 409 + }); + + // const cvmUrl = `https://cloudserver.${global.AppConfig.cloud.domain}` + const baseTemplateUrl = global.AppConfig.common.templateUrl; + const baseObjectStorageUrl = global.AppConfig.common.objectstorageUrl; + const baseApplaunchPadUrl = global.AppConfig.common.applaunchpadUrl; + const baseDbproviderUrl = global.AppConfig.common.dbproviderUrl; + + const kc = await getUserKubeconfigNotPatch(userCr.crName); + if (!kc) + return jsonRes(res, { + message: RESOURCE_STATUS.KUBECONFIG_NOT_FOUND, + code: 404 + }); + const authorization = encodeURI(kc); + const genReq = (url: string) => + fetch(url, { + headers: { + authorization + } + }); + const fetchFilter = async (resource: Response) => { + const result: { isOk: boolean; data: any } = { + isOk: false, + data: undefined + }; + if (!resource.ok) { + await jsonRes(res, { + code: 500, + message: RESOURCE_STATUS.GET_RESOURCE_ERROR + }); + } else { + result.isOk = true; + result.data = await resource.clone().json(); + } + return result; + }; + if (baseTemplateUrl) { + const templateUrl = baseTemplateUrl + '/api/instance/list'; + const result = await fetchFilter(await genReq(templateUrl)); + if (!result.isOk) return; + if (result.data?.data?.items?.length !== 0) + return jsonRes(res, { + code: 409, + message: RESOURCE_STATUS.REMAIN_TEMPLATE + }); + } + if (baseObjectStorageUrl) { + const objectStorageUrl = baseObjectStorageUrl + '/api/bucket/list'; + const result = await fetchFilter(await genReq(objectStorageUrl)); + if (!result.isOk) return; + if (result.data?.data?.list.length !== 0) + return jsonRes(res, { + code: 409, + message: RESOURCE_STATUS.REMAIN_OBJECT_STORAGE + }); + } + if (baseApplaunchPadUrl) { + const applaunchPadUrl = baseApplaunchPadUrl + '/api/getApps'; + const result = await fetchFilter(await genReq(applaunchPadUrl)); + if (!result.isOk) return; + if (result.data?.data?.length !== 0) + return jsonRes(res, { + code: 409, + message: RESOURCE_STATUS.REMAIN_APP + }); + } + if (baseDbproviderUrl) { + const dbproviderUrl = baseDbproviderUrl + '/api/getDBList'; + const result = await fetchFilter(await genReq(dbproviderUrl)); + if (!result.isOk) return; + if (result.data?.data?.length !== 0) + return jsonRes(res, { + code: 409, + message: RESOURCE_STATUS.REMAIN_DATABASE + }); + } + await Promise.resolve(next?.()); + }; + +export const otherRegionResourceGuard = + (userUid: string, userId: string) => async (res: NextApiResponse, next?: () => void) => { + const regionList = await globalPrisma.region.findMany(); + const regionTarget = regionList.filter( + (region) => region.uid !== global.AppConfig.cloud.regionUID + ); + const otherCheckResp = await Promise.all( + regionTarget.map((region) => + fetch( + process.env.NODE_ENV === 'development' + ? `http://127.0.0.1:3000/api/auth/delete/checkResource` + : `https://${region.domain}/api/auth/delete/checkResource`, + { + headers: { + authorization: encodeURI( + generateAuthenticationToken({ + userUid: userUid, + userId: userId + }) + ) + } + } + ) + ) + ); + if (otherCheckResp.some((resp) => !resp.ok)) + return jsonRes(res, { + code: 500, + message: RESOURCE_STATUS.GET_RESOURCE_ERROR + }); + const otherData = await Promise.all(otherCheckResp.map((resp) => resp.clone().json())); + for (let i = 0; i < otherData.length; i++) { + const resp = otherData[i]; + if (resp?.message === RESOURCE_STATUS.INTERNAL_SERVER_ERROR) { + return jsonRes(res, { + code: 500, + message: RESOURCE_STATUS.GET_RESOURCE_ERROR + }); + } + if ( + resp?.message !== RESOURCE_STATUS.RESULT_SUCCESS && + resp?.message !== RESOURCE_STATUS.USER_CR_NOT_FOUND + ) + return jsonRes(res, { + code: 409, + message: RESOURCE_STATUS.REMAIN_OTHER_REGION_RESOURCE + }); + } + await Promise.resolve(next?.()); + }; diff --git a/frontend/desktop/src/services/backend/middleware/error.ts b/frontend/desktop/src/services/backend/middleware/error.ts new file mode 100644 index 00000000000..e16f318b9d6 --- /dev/null +++ b/frontend/desktop/src/services/backend/middleware/error.ts @@ -0,0 +1,17 @@ +import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '../response'; +import { RESPONSE_MESSAGE } from '@/types/response/utils'; + +export const ErrorHandler = + (handler: NextApiHandler, message: string = RESPONSE_MESSAGE.INTERNAL_SERVER_ERROR) => + async (req: NextApiRequest, res: NextApiResponse) => { + try { + await handler(req, res); + } catch (error) { + console.log(error); + jsonRes(res, { + message, + code: 500 + }); + } + }; diff --git a/frontend/desktop/src/services/backend/middleware/mergeUser.ts b/frontend/desktop/src/services/backend/middleware/mergeUser.ts new file mode 100644 index 00000000000..5dc96d5c0dc --- /dev/null +++ b/frontend/desktop/src/services/backend/middleware/mergeUser.ts @@ -0,0 +1,99 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/services/backend/response'; +import { ProviderType } from 'prisma/global/generated/client'; +import { globalPrisma } from '@/services/backend/db/init'; +import { checkCode } from '../db/mergeUserCode'; +import { USER_MERGE_STATUS } from '@/types/response/merge'; + +export const filterMergeUser = async ( + req: NextApiRequest, + res: NextApiResponse, + next: (data: { providerType: ProviderType; providerId: string }) => Promise +) => { + const { code, providerType } = req.body as { + providerType?: ProviderType; + code?: string; + }; + if (!code) + return jsonRes(res, { + code: 400, + message: 'invalid code' + }); + if ( + providerType === undefined || + providerType === null || + !Object.values(ProviderType).includes(providerType) + ) + return jsonRes(res, { + code: 400, + message: 'invalid providerType' + }); + const result = await checkCode({ providerType, code }); + if (!result) + return jsonRes(res, { + code: 409, + message: 'invalid code' + }); + const providerId = result.providerId; + await next({ + providerType, + providerId + }); +}; + +export const mergeUserGuard = + (userUid: string, providerType: ProviderType, providerId: string) => + async (res: NextApiResponse, next: (data: { mergeUserUid: string }) => Promise) => { + const mergeUserOauthprovider = await globalPrisma.oauthProvider.findUnique({ + where: { + providerId_providerType: { + providerType, + providerId + } + } + }); + if (!mergeUserOauthprovider) + return jsonRes(res, { + code: 404, + message: USER_MERGE_STATUS.OAUTH_PROVIDER_NOT_FOUND + }); + + const mergeUserUid = mergeUserOauthprovider.userUid; + const mergeUser = await globalPrisma.user.findUnique({ + where: { + uid: mergeUserUid + }, + include: { + oauthProvider: true + } + }); + + if (!mergeUser) + return jsonRes(res, { + code: 404, + message: USER_MERGE_STATUS.USER_NOT_FOUND + }); + const user = await globalPrisma.user.findUnique({ + where: { + uid: userUid + }, + include: { + oauthProvider: true + } + }); + if (!user) + return jsonRes(res, { + code: 404, + message: USER_MERGE_STATUS.USER_NOT_FOUND + }); + const curTypeList = user.oauthProvider.map((oauthProvider) => oauthProvider.providerType); + const canMerge = mergeUser.oauthProvider.every((o) => !curTypeList.includes(o.providerType)); + if (!canMerge) + return jsonRes(res, { + code: 409, + message: USER_MERGE_STATUS.EXIST_SAME_OAUTH_PROVIDER + }); + await next({ + mergeUserUid + }); + }; diff --git a/frontend/desktop/src/services/backend/middleware/oauth.ts b/frontend/desktop/src/services/backend/middleware/oauth.ts new file mode 100644 index 00000000000..f375ec212c1 --- /dev/null +++ b/frontend/desktop/src/services/backend/middleware/oauth.ts @@ -0,0 +1,284 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '../response'; +import { isNumber } from 'lodash'; +import { TgithubToken, TgithubUser, TWechatToken, TWechatUser } from '@/types/user'; +import * as jwt from 'jsonwebtoken'; +import { userCanMerge } from '@/utils/tools'; +import { ProviderType } from 'prisma/global/generated/client'; +import { globalPrisma } from '../db/init'; +import { addOrUpdateCode } from '../db/mergeUserCode'; +import { v4 } from 'uuid'; +import { BIND_STATUS } from '@/types/response/bind'; +import { UNBIND_STATUS } from '@/types/response/unbind'; + +export const OauthCodeFilter = async ( + req: NextApiRequest, + res: NextApiResponse, + next: (data: { code: string; inviterId?: string }) => void +) => { + const { code } = req.body as { code?: string }; + if (!code) + return jsonRes(res, { + message: 'code is invalid', + code: 400 + }); + const { inviterId } = req.body as { inviterId?: string }; + await Promise.resolve( + next?.({ + code, + inviterId + }) + ); +}; + +export const OAuthEnvFilter = + (clientId?: string, clientSecret?: string) => + async (next?: (d: { clientID: string; clientSecret: string }) => void) => { + if (!clientId) throw Error('clientID NOT FOUND'); + if (!clientSecret) throw Error('clientSecret NOT FOUND'); + await Promise.resolve(next?.({ clientID: clientId, clientSecret })); + }; + +// the env will be changeds +export const wechatOAuthEnvFilter = () => + OAuthEnvFilter( + global.AppConfig?.desktop.auth.idp.wechat?.clientID, + global.AppConfig?.desktop.auth.idp.wechat?.clientSecret + ); +export const githubOAuthEnvFilter = () => + OAuthEnvFilter( + global.AppConfig?.desktop.auth.idp.github?.clientID, + global.AppConfig?.desktop.auth.idp.github?.clientSecret + ); +export const googleOAuthEnvFilter = () => { + return (next?: (d: { clientID: string; clientSecret: string; callbackURL: string }) => void) => + OAuthEnvFilter( + global.AppConfig?.desktop.auth.idp.google?.clientID, + global.AppConfig?.desktop.auth.idp.google?.clientSecret + )(async (originData) => { + const callbackURL = global.AppConfig?.desktop.auth.callbackURL; + if (!callbackURL) throw Error('callbackURL NOT FOUND'); + await Promise.resolve( + next?.({ + ...originData, + callbackURL + }) + ); + }); +}; + +export const googleOAuthGuard = + (clientId: string, clientSecret: string, code: string, callbackUrl: string) => + async ( + res: NextApiResponse, + next: (data: { id: string; name: string; avatar_url: string }) => void + ) => { + const url = `https://oauth2.googleapis.com/token?client_id=${clientId}&client_secret=${clientSecret}&code=${code}&redirect_uri=${callbackUrl}&grant_type=authorization_code`; + const response = await fetch(url, { method: 'POST', headers: { Accept: 'application/json' } }); + if (!response.ok) + return jsonRes(res, { + code: 401, + message: 'Unauthorized' + }); + const __data = (await response.json()) as { + access_token: string; + scope: string; + token_type: string; + id_token: string; + }; + const userInfo = jwt.decode(__data.id_token) as { + iss: string; + azp: string; + aud: string; + sub: string; + at_hash: string; + name: string; + picture: string; + given_name: string; + family_name: string; + locale: string; + iat: number; + exp: number; + }; + const name = userInfo.name; + const id = userInfo.sub; + const avatar_url = userInfo.picture; + if (!id) throw Error('get userInfo error'); + // @ts-ignore + await Promise.resolve( + next?.({ + id, + name, + avatar_url + }) + ); + }; +export const githubOAuthGuard = + (clientId: string, clientSecret: string, code: string) => + async ( + res: NextApiResponse, + next: (data: { id: string; name: string; avatar_url: string }) => void + ) => { + const url = ` https://github.com/login/oauth/access_token?client_id=${clientId}&client_secret=${clientSecret}&code=${code}`; + const __data = (await ( + await fetch(url, { method: 'POST', headers: { Accept: 'application/json' } }) + ).json()) as TgithubToken; + const access_token = __data.access_token; + if (!access_token) { + return jsonRes(res, { + message: 'Failed to authenticate with GitHub', + code: 500, + data: 'access_token is null' + }); + } + const userUrl = `https://api.github.com/user`; + const response = await fetch(userUrl, { + headers: { + Authorization: `Bearer ${access_token}` + } + }); + if (!response.ok) + return jsonRes(res, { + code: 401, + message: 'Unauthorized' + }); + const result = (await response.json()) as TgithubUser; + const id = result.id; + if (!isNumber(id)) throw Error(); + // @ts-ignore + await Promise.resolve( + next?.({ + id: id + '', + name: result.login, + avatar_url: result.avatar_url + }) + ); + }; + +export const wechatOAuthGuard = + (clientId: string, clientSecret: string, code: string) => + async ( + res: NextApiResponse, + next: (data: { id: string; name: string; avatar_url: string }) => void + ) => { + const url = `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${clientId}&secret=${clientSecret}&code=${code}&grant_type=authorization_code`; + const { access_token, openid } = (await ( + await fetch(url, { headers: { Accept: 'application/json' } }) + ).json()) as TWechatToken; + + if (!access_token || !openid) { + return jsonRes(res, { + message: 'Failed to authenticate with wechat', + code: 401, + data: 'access_token is null' + }); + } + const userUrl = `https://api.weixin.qq.com/sns/userinfo?access_token=${access_token}&openid=${openid}&lang=zh_CN`; + const response = await fetch(userUrl); + if (!response.ok) + return jsonRes(res, { + code: 401, + message: 'Unauthorized' + }); + const result = (await response.json()) as TWechatUser; + const id = result.unionid; + if (!id) throw Error('get wechat unionid error'); + const name = result.nickname; + const avatar_url = result.headimgurl; + return await Promise.resolve( + next?.({ + id, + name, + avatar_url + }) + ); + }; + +export const bindGuard = + (providerType: ProviderType) => + (providerId: string, userUid: string) => + async (res: NextApiResponse, next?: () => void) => { + const oauthProvider = await globalPrisma.oauthProvider.findUnique({ + where: { + providerId_providerType: { + providerType, + providerId + } + } + }); + // already exist user + if (oauthProvider) { + if (providerType === 'EMAIL') + return jsonRes(res, { + code: 409, + message: BIND_STATUS.MERGE_USER_PROVIDER_CONFLICT + }); + const mergeUserUid = oauthProvider.userUid; + const [mergeUserOauthProviders, curUserOauthProviders] = await globalPrisma.$transaction([ + globalPrisma.oauthProvider.findMany({ + where: { + userUid: mergeUserUid + } + }), + globalPrisma.oauthProvider.findMany({ + where: { + userUid + } + }) + ]); + const canMerge = userCanMerge(mergeUserOauthProviders, curUserOauthProviders); + if (!canMerge) { + return jsonRes(res, { + code: 409, + message: BIND_STATUS.MERGE_USER_PROVIDER_CONFLICT + }); + } else { + const code = v4(); + await addOrUpdateCode({ + providerId, + providerType, + code + }); + return jsonRes(res, { + code: 203, + message: BIND_STATUS.MERGE_USER_CONTINUE, + data: { + code + } + }); + } + } else { + await Promise.resolve(next?.()); + } + }; + +export const unbindGuard = + (providerType: ProviderType) => + (providerId: string, userUid: string) => + async (res: NextApiResponse, next?: () => void) => { + const oauthProvider = await globalPrisma.oauthProvider.findUnique({ + where: { + providerId_providerType: { + providerType, + providerId + }, + userUid + } + }); + if (!oauthProvider) + return jsonRes(res, { + code: 409, + message: UNBIND_STATUS.PROVIDER_NOT_FOUND + }); + await Promise.resolve(next?.()); + }; +export const unbindPhoneGuard = unbindGuard(ProviderType.PHONE); +export const bindPhoneGuard = bindGuard(ProviderType.PHONE); +export const bindEmailGuard = bindGuard(ProviderType.EMAIL); +export const unbindEmailGuard = unbindGuard(ProviderType.EMAIL); +export const unbindGithubGuard = unbindGuard(ProviderType.GITHUB); +export const bindGithubGuard = bindGuard(ProviderType.GITHUB); +export const unbindGoogleGuard = unbindGuard(ProviderType.GOOGLE); +export const bindGoogleGuard = bindGuard(ProviderType.GOOGLE); +export const unbindWechatGuard = unbindGuard(ProviderType.WECHAT); +export const bindWechatGuard = bindGuard(ProviderType.WECHAT); diff --git a/frontend/desktop/src/services/backend/middleware/sms.ts b/frontend/desktop/src/services/backend/middleware/sms.ts new file mode 100644 index 00000000000..8ee09ddf9d3 --- /dev/null +++ b/frontend/desktop/src/services/backend/middleware/sms.ts @@ -0,0 +1,194 @@ +import { jsonRes } from '../response'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { + SmsType, + TVerification_Codes, + checkCode, + checkSendable, + deleteByUid, + getInfoByUid +} from '../db/verifyCode'; +import { isEmail } from '@/utils/crypto'; +import { EMAIL_STATUS } from '@/types/response/email'; + +export const filterPhoneParams = async ( + req: NextApiRequest, + res: NextApiResponse, + next: (data: { phoneNumbers: string }) => void +) => { + const { id: phoneNumbers } = req.body as { id?: string }; + if (!phoneNumbers) + return jsonRes(res, { + message: 'phoneNumbers is invalid', + code: 400 + }); + await Promise.resolve(next({ phoneNumbers })); +}; +export const filterEmailParams = async ( + req: NextApiRequest, + res: NextApiResponse, + next: (data: { email: string }) => void +) => { + const { id: email } = req.body as { id?: string }; + if (!email || !isEmail(email)) + return jsonRes(res, { + message: EMAIL_STATUS.INVALID_PARAMS, + code: 400 + }); + await Promise.resolve(next({ email })); +}; +export const filterPhoneVerifyParams = ( + req: NextApiRequest, + res: NextApiResponse, + next: (data: { phoneNumbers: string; code: string; inviterId?: string }) => void +) => + filterPhoneParams(req, res, async (data) => { + const { code, inviterId } = req.body as { + code?: string; + inviterId?: string; + }; + if (!code) + return jsonRes(res, { + message: 'code is invalid', + code: 400 + }); + + await Promise.resolve( + next({ + ...data, + code, + inviterId + }) + ); + }); +export const filterEmailVerifyParams = ( + req: NextApiRequest, + res: NextApiResponse, + next: (data: { email: string; code: string; inviterId?: string }) => void +) => + filterEmailParams(req, res, async (data) => { + const { code, inviterId } = req.body as { + code?: string; + inviterId?: string; + }; + if (!code) + return jsonRes(res, { + message: EMAIL_STATUS.INVALID_PARAMS, + code: 400 + }); + await Promise.resolve( + next({ + ...data, + code, + inviterId + }) + ); + }); + +export const filterCodeUid = async ( + req: NextApiRequest, + res: NextApiResponse, + next: (data: { uid: string }) => void +) => { + const { uid } = req.body as { uid?: string }; + if (!uid) + return jsonRes(res, { + message: 'uid is invalid', + code: 400 + }); + return await Promise.resolve( + next({ + uid + }) + ); +}; + +export const filterCf = async (req: NextApiRequest, res: NextApiResponse, next: () => void) => { + const { cfToken } = req.body as { cfToken?: string }; + const verifyEndpoint = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + const secret = process.env.CF_SECRET_KEY; + if (secret) { + if (!cfToken) + return jsonRes(res, { + message: 'cfToken is invalid', + code: 400 + }); + const verifyRes = await fetch(verifyEndpoint, { + method: 'POST', + body: `secret=${encodeURIComponent(secret)}&response=${encodeURIComponent(cfToken)}`, + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }); + const data = await verifyRes.json(); + if (!data.success) + return jsonRes(res, { + message: 'cfToken is invalid', + code: 400 + }); + } + return await Promise.resolve(next()); +}; + +// once code +export const verifyCodeUidGuard = + (uid: string) => + async (res: NextApiResponse, next: (d: { smsInfo: TVerification_Codes }) => void) => { + const oldSmsInfo = await getInfoByUid({ uid }); + if (!oldSmsInfo) + return jsonRes(res, { + message: 'uid is expired', + code: 409 + }); + await Promise.resolve(next({ smsInfo: oldSmsInfo })); + // once code + await deleteByUid({ uid: oldSmsInfo.uid }); + }; + +export const verifySmsCodeGuard = + (smsType: SmsType) => + (id: string, code: string) => + async (res: NextApiResponse, next: (d: { smsInfo: TVerification_Codes }) => void) => { + const smsInfo = await checkCode({ id, smsType, code }); + if (!smsInfo) { + return jsonRes(res, { + message: 'SMS code is wrong', + code: 409 + }); + } + return await Promise.resolve(next({ smsInfo })); + }; + +export const verifyPhoneCodeGuard = verifySmsCodeGuard('phone'); +export const verifyEmailCodeGuard = verifySmsCodeGuard('email'); + +// need to get queryParam from after filter +export const sendSmsCodeGuard = + (smsType: SmsType) => (id: string) => async (res: NextApiResponse, next?: () => void) => { + if (!(await checkSendable({ smsType, id }))) { + return jsonRes(res, { + message: 'code already sent', + code: 409 + }); + } + await Promise.resolve(next?.()); + }; +export const sendNewSmsCodeGuard = + (smsType: SmsType) => + (codeUid: string, smsId: string) => + (res: NextApiResponse, next: (d: { smsInfo: TVerification_Codes }) => void) => + sendSmsCodeGuard(smsType)(smsId)(res, async () => { + const oldSmsInfo = await getInfoByUid({ uid: codeUid }); + if (!oldSmsInfo) + return jsonRes(res, { + message: 'uid is expired', + code: 409 + }); + await Promise.resolve(next({ smsInfo: oldSmsInfo })); + }); + +export const sendPhoneCodeGuard = sendSmsCodeGuard('phone'); +export const sendEmailCodeGuard = sendSmsCodeGuard('email'); + +export const sendNewPhoneCodeGuard = sendNewSmsCodeGuard('phone'); +export const sendNewEmailCodeGuard = sendNewSmsCodeGuard('email'); diff --git a/frontend/desktop/src/services/backend/sms.ts b/frontend/desktop/src/services/backend/sms.ts new file mode 100644 index 00000000000..e208168097c --- /dev/null +++ b/frontend/desktop/src/services/backend/sms.ts @@ -0,0 +1,150 @@ +//@ts-ignore +import { getAppConfig } from '@/pages/api/platform/getAppConfig'; +import { retrySerially } from '@/utils/tools'; +import Dysmsapi, * as dysmsapi from '@alicloud/dysmsapi20170525'; +//@ts-ignore +import * as OpenApi from '@alicloud/openapi-client'; +//@ts-ignore +import * as Util from '@alicloud/tea-util'; +import nodemailer from 'nodemailer'; +const getTransporter = () => { + if (!global.nodemailer) { + const emailConfig = global.AppConfig.desktop.auth.idp.sms?.email; + if (!emailConfig) throw Error('email transporter config error'); + const transporter = nodemailer.createTransport({ + pool: true, + host: emailConfig.host, + + port: emailConfig.port, + secure: true, // use TLS + auth: { + user: emailConfig.user, + pass: emailConfig.password + } + }); + global.nodemailer = transporter; + } + return global.nodemailer; +}; +export const smsReq = async (phoneNumbers: string) => { + const aliConfig = global.AppConfig.desktop.auth.idp.sms?.ali; + if (!aliConfig) throw Error('config error'); + const { signName, templateCode, accessKeyID: accessKeyId, accessKeySecret } = aliConfig; + // for dev + if (process.env.NODE_ENV === 'development' && !process.env.DEV_SMS_ENABLED) { + const code = '123456'; + return code; + } + const code = Math.floor(Math.random() * 900000 + 100000).toString(); + const sendSmsRequest = new dysmsapi.SendSmsRequest({ + phoneNumbers, + signName, + templateCode, + templateParam: `{"code":${code}}` + }); + const config = new OpenApi.Config({ + accessKeyId, + accessKeySecret + }); + + const client = new Dysmsapi(config); + const runtime = new Util.RuntimeOptions({}); + await retrySerially(async () => { + try { + const _result = await client.sendSmsWithOptions(sendSmsRequest, runtime); + + if (!_result) { + throw new Error('sms result is null'); + } + if (_result.statusCode !== 200) { + throw new Error(`sms result status code is ${_result.statusCode} + ${_result.body} + ${phoneNumbers}, + ${new Date()} + `); + } + if (_result.body.code !== 'OK') { + throw new Error(` + ${_result.body.message} + ${phoneNumbers}, + ${new Date()}`); + } + return _result; + } catch (error) { + return Promise.reject(error); + } + }, 3); + return code; +}; +export const emailSmsReq = async (email: string) => { + const emailConfig = global.AppConfig.desktop.auth.idp.sms?.email; + if (!emailConfig) throw Error('config error'); + + const code = Math.floor(Math.random() * 900000 + 100000).toString(); + const transporter = getTransporter(); + + await retrySerially( + () => + transporter.sendMail({ + from: emailConfig.user, + to: email, + subject: '【sealos】验证码', + html: ` + + + + + 【sealos】验证码 + + + +
+

尊敬的用户,您正在进行邮箱绑定操作。请输入以下验证码完成验证。

+

您的验证码是:

+

${code}

+
+ + ` + }), + 3 + ); + return code; +}; diff --git a/frontend/desktop/src/services/backend/svc/access.ts b/frontend/desktop/src/services/backend/svc/access.ts new file mode 100644 index 00000000000..fb9c9cdc92a --- /dev/null +++ b/frontend/desktop/src/services/backend/svc/access.ts @@ -0,0 +1,57 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '../response'; +import { ProviderType } from 'prisma/global/generated/client'; +import { getGlobalToken } from '../globalAuth'; + +export const getGlobalTokenSvc = + ( + avatar_url: string, + providerId: string, + name: string, + providerType: ProviderType, + password?: string, + inviterId?: string + ) => + async (res: NextApiResponse, next?: () => void) => { + const data = await getGlobalToken({ + provider: providerType, + providerId, + avatar_url, + name, + inviterId, + password + }); + if (!data) + return jsonRes(res, { + code: 401, + message: 'Unauthorized' + }); + return jsonRes(res, { + data, + code: 200, + message: 'Successfully' + }); + }; + +export const getGlobalTokenByGithubSvc = ( + avatar_url: string, + providerId: string, + name: string, + inviterId?: string +) => getGlobalTokenSvc(avatar_url, providerId, name, ProviderType.GITHUB, undefined, inviterId); +export const getGlobalTokenByWechatSvc = ( + avatar_url: string, + providerId: string, + name: string, + inviterId?: string +) => getGlobalTokenSvc(avatar_url, providerId, name, ProviderType.WECHAT, undefined, inviterId); +export const getGlobalTokenByPhoneSvc = (phone: string, inviterId?: string) => + getGlobalTokenSvc('', phone, phone, ProviderType.PHONE, undefined, inviterId); +export const getGlobalTokenByPasswordSvc = (name: string, password: string, inviterId?: string) => + getGlobalTokenSvc('', name, name, ProviderType.PASSWORD, password, inviterId); +export const getGlobalTokenByGoogleSvc = ( + avatar_url: string, + providerId: string, + name: string, + inviterId?: string +) => getGlobalTokenSvc(avatar_url, providerId, name, ProviderType.GOOGLE, undefined, inviterId); diff --git a/frontend/desktop/src/services/backend/svc/bindProvider.ts b/frontend/desktop/src/services/backend/svc/bindProvider.ts new file mode 100644 index 00000000000..409b810b495 --- /dev/null +++ b/frontend/desktop/src/services/backend/svc/bindProvider.ts @@ -0,0 +1,243 @@ +import { globalPrisma } from '../db/init'; +import { NextApiResponse } from 'next'; +import { ProviderType } from 'prisma/global/generated/client'; +import { jsonRes } from '../response'; +import { findUser } from '../globalAuth'; +import { BIND_STATUS } from '@/types/response/bind'; +import { CHANGE_BIND_STATUS } from '@/types/response/changeBind'; +import { UNBIND_STATUS } from '@/types/response/unbind'; +import { PROVIDER_STATUS } from '@/types/response/utils'; +async function addOauthProvider({ + providerType, + providerId, + userUid +}: { + providerType: ProviderType; + providerId: string; + userUid: string; +}) { + try { + const oauthProvider = await globalPrisma.oauthProvider.create({ + data: { + providerId, + providerType, + user: { + connect: { + uid: userUid + } + } + } + }); + if (!oauthProvider) return null; + return { + oauthProvider + }; + } catch (e) { + return null; + } +} + +async function removeOauthProvider({ + provider, + id, + userUid +}: { + provider: ProviderType; + id: string; + userUid: string; +}) { + try { + const oauthProvider = await globalPrisma.oauthProvider.delete({ + where: { + providerId_providerType: { + providerId: id, + providerType: provider + }, + userUid + } + }); + if (!oauthProvider) return null; + return { + oauthProvider + }; + } catch (e) { + return null; + } +} + +async function updateOauthProvider({ + provider, + newId, + oldId, + userUid +}: { + provider: ProviderType; + oldId: string; + newId: string; + userUid: string; +}) { + try { + const oauthProvider = await globalPrisma.oauthProvider.update({ + where: { + providerId_providerType: { + providerId: oldId, + providerType: provider + }, + userUid + }, + data: { + providerId: newId + } + }); + if (!oauthProvider) return null; + return { + oauthProvider + }; + } catch (e) { + return null; + } +} + +export const changeBindProviderSvc = + (oldProviderId: string, newProviderId: string, providerType: ProviderType, userUid: string) => + async (res: NextApiResponse, next?: () => void) => { + if (providerType === ProviderType.PASSWORD) + return jsonRes(res, { + code: 409, + message: CHANGE_BIND_STATUS.NOT_SUPPORT + }); + + const user = await globalPrisma.user.findUnique({ where: { uid: userUid } }); + if (!user) + return jsonRes(res, { + code: 409, + message: CHANGE_BIND_STATUS.USER_NOT_FOUND + }); + if (newProviderId === oldProviderId) + return jsonRes(res, { + code: 200, + message: CHANGE_BIND_STATUS.RESULT_SUCCESS + }); + const result = await updateOauthProvider({ + provider: providerType, + oldId: oldProviderId, + newId: newProviderId, + userUid + }); + if (!result) throw Error(CHANGE_BIND_STATUS.DB_ERROR); + else { + jsonRes(res, { + code: 200, + message: CHANGE_BIND_STATUS.RESULT_SUCCESS + }); + await Promise.resolve(next?.()); + } + }; + +const bindProviderSvc = + (providerId: string, providerType: ProviderType, userUid: string) => + async (res: NextApiResponse, next?: () => void) => { + if (providerType === ProviderType.PASSWORD) + return jsonRes(res, { + code: 409, + message: BIND_STATUS.NOT_SUPPORT + }); + const user = await globalPrisma.user.findUnique({ where: { uid: userUid } }); + if (!user) + return jsonRes(res, { + code: 409, + message: BIND_STATUS.USER_NOT_FOUND + }); + const result = await addOauthProvider({ + providerType, + providerId, + userUid + }); + if (!result) throw Error(BIND_STATUS.DB_ERROR); + + return jsonRes(res, { + code: 200, + message: BIND_STATUS.RESULT_SUCCESS + }); + }; +const unbindProviderSvc = + (providerId: string, providerType: ProviderType, userUid: string) => + async (res: NextApiResponse, next?: () => void) => { + if (providerType === ProviderType.PASSWORD) + return jsonRes(res, { + code: 409, + message: UNBIND_STATUS.NOT_SUPPORT + }); + const user = await findUser({ userUid }); + if (!user) + return jsonRes(res, { + code: 409, + message: UNBIND_STATUS.USER_NOT_FOUND + }); + if ( + !user.oauthProvider.find( + (o) => o.providerType === providerType && o.providerId === providerId + ) + ) + return jsonRes(res, { + code: 409, + message: PROVIDER_STATUS.PROVIDER_NOT_FOUND + }); + if (providerType !== ProviderType.EMAIL) { + // the number of provider must more than 1 + let minCount = 1; + if (user.oauthProvider.find((o) => o.providerType === ProviderType.EMAIL)) { + minCount++; + } + if (user.oauthProvider.length <= minCount) + return jsonRes(res, { + code: 409, + message: UNBIND_STATUS.RESOURCE_CONFLICT + }); + } + + const result = await removeOauthProvider({ + provider: providerType, + id: providerId, + userUid + }); + if (!result) throw new Error(UNBIND_STATUS.DB_ERROR); + return jsonRes(res, { + code: 200, + message: UNBIND_STATUS.RESULT_SUCCESS + }); + }; + +export const bindPhoneSvc = (phoneNumbers: string, userUid: string) => + bindProviderSvc(phoneNumbers, ProviderType.PHONE, userUid); +export const bindGithubSvc = (id: string, userUid: string) => + bindProviderSvc(id, ProviderType.GITHUB, userUid); +export const bindWechatSvc = (id: string, userUid: string) => + bindProviderSvc(id, ProviderType.WECHAT, userUid); +export const bindGoogleSvc = (id: string, userUid: string) => + bindProviderSvc(id, ProviderType.GOOGLE, userUid); +export const bindEmailSvc = (email: string, userUid: string) => + bindProviderSvc(email, ProviderType.EMAIL, userUid); + +export const unbindPhoneSvc = (phoneNumbers: string, userUid: string) => + unbindProviderSvc(phoneNumbers, ProviderType.PHONE, userUid); +export const unbindGithubSvc = (id: string, userUid: string) => + unbindProviderSvc(id, ProviderType.GITHUB, userUid); +export const unbindWechatSvc = (id: string, userUid: string) => + unbindProviderSvc(id, ProviderType.WECHAT, userUid); +export const unbindGoogleSvc = (id: string, userUid: string) => + unbindProviderSvc(id, ProviderType.GOOGLE, userUid); +export const unbindEmailSvc = (email: string, userUid: string) => + unbindProviderSvc(email, ProviderType.EMAIL, userUid); + +export const changePhoneBindingSvc = ( + oldProviderId: string, + newProviderId: string, + userUid: string +) => changeBindProviderSvc(oldProviderId, newProviderId, ProviderType.PHONE, userUid); + +export const changeEmailBindingSvc = ( + oldProviderId: string, + newProviderId: string, + userUid: string +) => changeBindProviderSvc(oldProviderId, newProviderId, ProviderType.EMAIL, userUid); diff --git a/frontend/desktop/src/services/backend/svc/deleteUser.ts b/frontend/desktop/src/services/backend/svc/deleteUser.ts new file mode 100644 index 00000000000..22f18379a75 --- /dev/null +++ b/frontend/desktop/src/services/backend/svc/deleteUser.ts @@ -0,0 +1,92 @@ +import { NextApiResponse } from 'next'; +import { jsonRes } from '../response'; +import { globalPrisma, prisma } from '../db/init'; +import { v4 } from 'uuid'; +import { TransactionType, TransactionStatus, AuditAction } from 'prisma/global/generated/client'; +import { RESOURCE_STATUS } from '@/types/response/checkResource'; +import { DeleteUserEvent } from '@/types/db/event'; + +export const deleteUserSvc = (userUid: string) => async (res: NextApiResponse) => { + const user = await globalPrisma.user.findUnique({ + where: { + uid: userUid + }, + include: { + oauthProvider: true + } + }); + if (!user) + return jsonRes(res, { + message: RESOURCE_STATUS.USER_NOT_FOUND, + code: 404 + }); + const oauthProviderList = user.oauthProvider; + if (oauthProviderList.length === 0) + return jsonRes(res, { + message: RESOURCE_STATUS.OAUTHPROVIDER_NOT_FOUND, + code: 404 + }); + const txUid = v4(); + const infoUid = v4(); + const regionResults = await globalPrisma.region.findMany(); + if (!regionResults) throw Error('region list is null'); + const regionList = regionResults.map((r) => r.uid); + // add task ( catch by outer ) + await globalPrisma.$transaction(async (tx) => { + for await (const oauthProvider of oauthProviderList) { + await tx.oauthProvider.delete({ + where: { + uid: oauthProvider.uid + } + }); + const eventName = DeleteUserEvent['_DELETE_OAUTH_PROVIDER']; + const _data = { + userUid, + providerType: oauthProvider.providerType, + providerId: oauthProvider.providerId, + message: `${oauthProvider.providerType}:${oauthProvider.providerId}, delete` + }; + await tx.eventLog.create({ + data: { + eventName, + mainId: userUid, + data: JSON.stringify(_data) + } + }); + } + await tx.eventLog.create({ + data: { + eventName: DeleteUserEvent['_PUB_TRANSACTION'], + mainId: userUid, + data: JSON.stringify({ + message: `${userUid} publish delete user transaction` + }) + } + }); + await tx.precommitTransaction.create({ + data: { + uid: txUid, + status: TransactionStatus.READY, + infoUid, + transactionType: TransactionType.DELETE_USER + } + }); + await tx.deleteUserTransactionInfo.create({ + data: { + uid: infoUid, + userUid + } + }); + await tx.transactionDetail.createMany({ + data: regionList.map((regionUid) => ({ + status: TransactionStatus.READY, + transactionUid: txUid, + regionUid + })) + }); + }); + return jsonRes(res, { + message: RESOURCE_STATUS.RESULT_SUCCESS, + code: 200 + }); +}; diff --git a/frontend/desktop/src/services/backend/svc/mergeUser.ts b/frontend/desktop/src/services/backend/svc/mergeUser.ts new file mode 100644 index 00000000000..2fc90ca4975 --- /dev/null +++ b/frontend/desktop/src/services/backend/svc/mergeUser.ts @@ -0,0 +1,105 @@ +import { NextApiResponse } from 'next'; +import { jsonRes } from '../response'; +import { globalPrisma } from '../db/init'; +import { v4 } from 'uuid'; +import { TransactionType, TransactionStatus, AuditAction } from 'prisma/global/generated/client'; +import { USER_MERGE_STATUS } from '@/types/response/merge'; +import { MergeUserEvent } from '@/types/db/event'; + +export const mergeUserSvc = + (userUid: string, mergeUserUid: string) => async (res: NextApiResponse) => { + const user = await globalPrisma.user.findUnique({ + where: { + uid: userUid + }, + include: { + oauthProvider: true + } + }); + if (!user) + return jsonRes(res, { + message: USER_MERGE_STATUS.USER_NOT_FOUND, + code: 404 + }); + const txUid = v4(); + const infoUid = v4(); + const regionResults = await globalPrisma.region.findMany(); + if (!regionResults) throw Error('region list is null'); + const regionList = regionResults.map((r) => r.uid); + const oauthProviderList = await globalPrisma.oauthProvider.findMany({ + where: { + userUid: mergeUserUid + } + }); + // add task ( catch by outer ) + await globalPrisma.$transaction(async (tx) => { + // optimistic + for await (const oauthProvider of oauthProviderList) { + await tx.oauthProvider.findUniqueOrThrow({ + where: { + uid: oauthProvider.uid, + userUid: mergeUserUid + } + }); + await tx.oauthProvider.update({ + where: { + uid: oauthProvider.uid, + userUid: mergeUserUid + }, + data: { + userUid + } + }); + const eventName = MergeUserEvent['_MERGE_OAUTH_PROVIDER']; + const _data = { + mergeUserUid, + userUid, + providerType: oauthProvider.providerType, + providerId: oauthProvider.providerId, + message: `${oauthProvider.providerType}: ${oauthProvider.providerId}, update` + }; + await tx.eventLog.create({ + data: { + eventName, + mainId: userUid, + data: JSON.stringify(_data) + } + }); + } + await tx.precommitTransaction.create({ + data: { + uid: txUid, + status: TransactionStatus.READY, + infoUid, + transactionType: TransactionType.MERGE_USER + } + }); + await tx.eventLog.create({ + data: { + eventName: MergeUserEvent['_PUB_TRANSACTION'], + mainId: userUid, + data: JSON.stringify({ + message: `${userUid} publish merge user transaction` + }) + } + }); + await tx.mergeUserTransactionInfo.create({ + data: { + uid: infoUid, + mergeUserUid, + userUid + } + }); + await tx.transactionDetail.createMany({ + data: regionList.map((regionUid) => ({ + status: TransactionStatus.READY, + transactionUid: txUid, + regionUid + })) + }); + }); + return jsonRes(res, { + message: USER_MERGE_STATUS.RESULT_SUCCESS, + code: 200 + }); + }; diff --git a/frontend/desktop/src/services/backend/svc/sms.ts b/frontend/desktop/src/services/backend/svc/sms.ts new file mode 100644 index 00000000000..4f9dd65c75e --- /dev/null +++ b/frontend/desktop/src/services/backend/svc/sms.ts @@ -0,0 +1,23 @@ +import { NextApiResponse } from 'next'; +import { jsonRes } from '../response'; +import { addOrUpdateCode, SmsType } from '../db/verifyCode'; +import { emailSmsReq, smsReq } from '../sms'; + +export const sendSmsCodeResp = + (smsType: SmsType, id: string, code: string) => + async (res: NextApiResponse, next?: () => void) => { + await addOrUpdateCode({ id, smsType, code }); + return jsonRes(res, { + message: 'successfully', + code: 200 + }); + }; +export const sendPhoneCodeSvc = (phone: string) => async (res: NextApiResponse) => { + console.log('svc!'); + const code = await smsReq(phone); + return sendSmsCodeResp('phone', phone, code)(res); +}; +export const sendEmailCodeSvc = (email: string) => async (res: NextApiResponse) => { + const code = await emailSmsReq(email); + return sendSmsCodeResp('email', email, code)(res); +}; diff --git a/frontend/desktop/src/services/backend/team.ts b/frontend/desktop/src/services/backend/team.ts index 1da03e0d9a5..a1717e86de3 100644 --- a/frontend/desktop/src/services/backend/team.ts +++ b/frontend/desktop/src/services/backend/team.ts @@ -1,22 +1,32 @@ import { - RoleAction, + deleteRequestCrd, generateRequestCrd, ROLE_LIST, - UserRole, - watchEventType, - deleteRequestCrd + RoleAction, + UserRole } from '@/types/team'; -import { KubeConfig } from '@kubernetes/client-node'; +import { KubeConfig, V1Status } from '@kubernetes/client-node'; import { K8sApiDefault } from './kubernetes/admin'; -import { ApplyYaml } from './kubernetes/user'; +import { ApplyYaml, GetCRD } from './kubernetes/user'; import { prisma } from '@/services/backend/db/init'; import { JoinStatus, Role } from 'prisma/region/generated/client'; -import { UserRoleToRole } from '@/utils/tools'; +import { roleToUserRole, UserRoleToRole, vaildManage } from '@/utils/tools'; +import { createHash } from 'node:crypto'; const _applyRoleRequest = - (kc: KubeConfig, nsid: string) => - (action: 'Grant' | 'Deprive' | 'Update', types: watchEventType[]) => - (k8s_username: string, role: UserRole) => { + (kc: KubeConfig, nsid: string, idempotent: boolean = false) => + (action: 'Grant' | 'Deprive' | 'Update') => + async (k8s_username: string, role: UserRole) => { + const hash = createHash('sha256'); + const props = { + user: k8s_username, + namespace: nsid, + action, + role: ROLE_LIST[role] + }; + let name = ''; + hash.update(JSON.stringify(props) + new Date().getTime()); + name = hash.digest('hex'); const createCR = () => ApplyYaml( kc, @@ -24,7 +34,8 @@ const _applyRoleRequest = user: k8s_username, namespace: nsid, action, - role: ROLE_LIST[role] + role: ROLE_LIST[role], + name }) ); return new Promise((resolve, reject) => { @@ -43,6 +54,7 @@ const _applyRoleRequest = wrap(); }); }; + export const applyDeleteRequest = (user: string) => { const kc = new KubeConfig(); kc.loadFromDefault(); @@ -70,7 +82,7 @@ export const applyDeleteRequest = (user: string) => { }); }; -type ModifyTeamBaseParam = { +type ModifyWorkspaceBaseParam = { k8s_username: string; // 目标 // ns_uid: string; // 目标 workspaceId: string; @@ -78,61 +90,100 @@ type ModifyTeamBaseParam = { action: RoleAction; }; // 只修改,不包含邀请的业务逻辑,应该是确保数据库已经有记录,只修改记录,并且修改k8s的rolebinding -export const modifyTeamRole = async ({ +export const modifyWorkspaceRole = async ({ k8s_username, // ns_uid, workspaceId, ...props -}: - | (ModifyTeamBaseParam & { - action: 'Grant' | 'Create'; - }) - | (ModifyTeamBaseParam & { - action: 'Deprive' | 'Modify'; - pre_role: UserRole; - }) - | (ModifyTeamBaseParam & { - action: 'Change'; - pre_k8s_username: string; - })) => { +}: ModifyWorkspaceBaseParam & + ( + | { + action: 'Grant' | 'Create'; + } + | { + action: 'Deprive' | 'Modify'; + pre_role: UserRole; + } + | { + action: 'Change'; + pre_k8s_username: string; + } + | { + action: 'Merge'; + pre_role: UserRole; + } + )) => { const kc = K8sApiDefault(); const applyRoleRequest = _applyRoleRequest(kc, workspaceId); - const grantApply = applyRoleRequest('Grant', [watchEventType.ADDED]); - const depriveApply = applyRoleRequest('Deprive', [watchEventType.DELETED]); - const updateApply = applyRoleRequest('Update', []); - // let result: TeamRolebinding | null = null; + const grantApply = applyRoleRequest('Grant'); + const depriveApply = applyRoleRequest('Deprive'); + const updateApply = applyRoleRequest('Update'); if (props.action === 'Grant') { - // result = await grantApply(k8s_username, props.role); - // console.log(result) } else if (props.action === 'Deprive') { - // 保证存在 if (props.pre_role !== props.role) return null; - // result = await depriveApply(k8s_username, props.role); } else if (props.action === 'Change') { - // 移交自己的权限 + // abdicate role if (props.role === UserRole.Owner && props.pre_k8s_username) { - // 先把自己的权限去掉 - // result = + // remove owner await updateApply(props.pre_k8s_username, UserRole.Developer); - // 再补充新的权限上来 - // result = + // add owner await updateApply(k8s_username, UserRole.Owner); } } else if (props.action === 'Create') { - // 创建新的团队 + // create new workspace if (props.role === UserRole.Owner) { await grantApply(k8s_username, UserRole.Owner); } } else if (props.action === 'Modify') { - // 修改他人权限 - // 相同权限,不管 + // modify other role + // same role if (props.pre_role === props.role) return null; - updateApply(k8s_username, props.role); - } // if (!result) return null; - // return result; + await updateApply(k8s_username, props.role); + } +}; +// 4 conditions +/** + * | same role | user role >= user merge role | merge user role > user role | target user out of workspace | + * | deprive merge user | deprive merge user | deprive merge user & update to merge user role | deprive merge user & grant merge user role | + * + * */ +export const mergeUserWorkspaceRole = async ({ + workspaceId, + mergeUserCrName, + userCrName, + userRole, + mergeUserRole +}: { + workspaceId: string; + mergeUserCrName: string; + userCrName: string; + userRole?: Role; + mergeUserRole: Role; +}) => { + const kc = K8sApiDefault(); + const applyRoleRequest = _applyRoleRequest(kc, workspaceId, true); + const grantApply = applyRoleRequest('Grant'); + const depriveApply = applyRoleRequest('Deprive'); + const updateApply = applyRoleRequest('Update'); + // handle pre user + await depriveApply(mergeUserCrName, roleToUserRole(mergeUserRole)); + // handle new user + const targetUserExist = !(userRole === undefined || userRole === null); + if (targetUserExist) { + const targetUserRoleisHigher = vaildManage(roleToUserRole(userRole))( + roleToUserRole(mergeUserRole), + true + ); + if (!targetUserRoleisHigher) { + await updateApply(userCrName, roleToUserRole(mergeUserRole)); + } + } else { + await grantApply(userCrName, roleToUserRole(mergeUserRole)); + } + // handle new user }; // ================================== // 以下是邀请的业务逻辑,只负责改数据库 @@ -230,3 +281,82 @@ export const acceptInvite = async ({ } }); }; + +export const mergeUserModifyBinding = async ({ + mergeUserCrUid, + workspaceUid, + userCrUid, + userRole, + mergeUserRole +}: { + mergeUserCrUid: string; + workspaceUid: string; + userCrUid: string; + userRole?: Role; + mergeUserRole: Role; +}) => { + let role; + if (undefined === userRole || userRole === null) { + const role = mergeUserRole; + // user is not in workspace + await prisma.$transaction([ + prisma.userWorkspace.create({ + data: { + role, + userCrUid: userCrUid, + workspaceUid, + status: JoinStatus.IN_WORKSPACE, + isPrivate: false + } + }), + prisma.userWorkspace.delete({ + where: { + workspaceUid_userCrUid: { + userCrUid: mergeUserCrUid, + workspaceUid + } + } + }) + ]); + } else { + const userRoleisHigher = vaildManage(roleToUserRole(userRole))( + roleToUserRole(mergeUserRole), + true + ); + if (userRoleisHigher) { + role = userRole; + } else { + role = mergeUserRole; + } + await prisma.$transaction([ + prisma.userWorkspace.findUniqueOrThrow({ + where: { + workspaceUid_userCrUid: { + userCrUid: userCrUid, + workspaceUid + }, + role: userRole + } + }), + prisma.userWorkspace.update({ + where: { + workspaceUid_userCrUid: { + userCrUid: userCrUid, + workspaceUid + } + }, + data: { + role + } + }), + prisma.userWorkspace.delete({ + where: { + workspaceUid_userCrUid: { + userCrUid: mergeUserCrUid, + workspaceUid + } + } + }) + ]); + } +}; diff --git a/frontend/desktop/src/services/enable.ts b/frontend/desktop/src/services/enable.ts index ee720c14258..754605dbe39 100644 --- a/frontend/desktop/src/services/enable.ts +++ b/frontend/desktop/src/services/enable.ts @@ -1,15 +1,16 @@ // for service -import process from 'process'; - export const enablePassword = () => global.AppConfig.desktop.auth.idp.password?.enabled || false; export const enableGithub = () => global.AppConfig.desktop.auth.idp.github?.enabled || false; export const enableSms = () => global.AppConfig.desktop.auth.idp.sms?.ali?.enabled || false; +export const enableEmailSms = () => global.AppConfig.desktop.auth.idp.sms?.email?.enabled || false; export const enableWechat = () => global.AppConfig.desktop.auth.idp.wechat?.enabled || false; export const enableGoogle = () => global.AppConfig.desktop.auth.idp.google?.enabled || false; export const enableSignUp = () => global.AppConfig.desktop.auth.signUpEnabled || false; export const enableApi = () => global.AppConfig.common.apiEnabled || false; export const enableOAuth2 = () => global.AppConfig.desktop.auth.idp.oauth2?.enabled || false; - +export const getBillingUrl = () => global.AppConfig.desktop.auth.billingUrl || ''; +export const getWorkorderUrl = () => global.AppConfig.desktop.auth.workorderUrl || ''; +export const getCvmUrl = () => global.AppConfig.desktop.auth.cloudVitrualMachineUrl || ''; export const getTeamLimit = () => global.AppConfig.desktop.teamManagement?.maxTeamCount || 50; export const getTeamInviteLimit = () => global.AppConfig.desktop.teamManagement?.maxTeamMemberCount || 50; diff --git a/frontend/desktop/src/stores/callback.ts b/frontend/desktop/src/stores/callback.ts index 6e1451f2db7..2decf0363fc 100644 --- a/frontend/desktop/src/stores/callback.ts +++ b/frontend/desktop/src/stores/callback.ts @@ -1,9 +1,22 @@ +import { ProviderType } from 'prisma/global/generated/client'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; - +export enum MergeUserStatus { + IDLE, + CONFLICT, + CANMERGE +} +type MergeUserData = { + code: string; + providerType: ProviderType; +}; type CallbackState = { workspaceInviteCode?: string; + mergeUserData?: MergeUserData; + mergeUserStatus: MergeUserStatus; + setMergeUserData: (d?: MergeUserData) => void; + setMergeUserStatus: (s: MergeUserStatus) => void; setWorkspaceInviteCode: (id?: string) => void; }; @@ -11,6 +24,18 @@ const useCallbackStore = create()( persist( immer((set, get) => ({ workspaceInviteCode: undefined, + mergeUserData: undefined, + mergeUserStatus: MergeUserStatus.IDLE, + setMergeUserData(mergeUserData) { + set({ + mergeUserData + }); + }, + setMergeUserStatus(mergeUserStatus) { + set({ + mergeUserStatus + }); + }, setWorkspaceInviteCode: (id?: string) => { set((state) => ({ workspaceInviteCode: id })); } diff --git a/frontend/desktop/src/stores/session.ts b/frontend/desktop/src/stores/session.ts index 4d1d40a41d7..72eeb15355e 100644 --- a/frontend/desktop/src/stores/session.ts +++ b/frontend/desktop/src/stores/session.ts @@ -2,8 +2,12 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { Session, sessionKey } from '@/types'; -import { OauthProvider, Provider } from '@/types/user'; - +import { OauthProvider } from '@/types/user'; +type StatePayload = { + rad: string; + action: OauthAction; +}; +export type OauthAction = 'LOGIN' | 'BIND' | 'UNBIND' | 'PROXY'; type SessionState = { session?: Session; token: string; @@ -14,13 +18,16 @@ type SessionState = { delSession: () => void; isUserLogin: () => boolean; /* - when proxy oauth2.0 ,the domainState need to be used - */ - generateState: (domainState?: string) => string; - compareState: (state: string) => boolean; + when proxy oauth2.0 ,the domainState need to be used + */ + generateState: (action?: OauthAction, domainState?: string) => string; + compareState: (state: string) => { + isSuccess: boolean; + action: string; + statePayload: string[]; + }; setProvider: (provider?: OauthProvider) => void; setToken: (token: string) => void; - setState: (state: string) => void; lastWorkSpaceId: string; setWorkSpaceId: (id: string) => void; }; @@ -45,21 +52,29 @@ const useSessionStore = create()( set({ session: undefined }); }, isUserLogin: () => !!get().session?.user, - generateState: (domainState) => { - let state = new Date().getTime().toString(); - console.log(domainState); - if (!!domainState) state = domainState; + // [LOGIN/UNBIND/BIND]_STATE + // PROXY_DOMAINSTATE, DOMAINSTATE = URL_[LOGIN/UNBIND/BIND]_STATE + generateState: (action = 'LOGIN', domainState) => { + let state = action as string; + if (domainState && action === 'PROXY') { + state = state + '_' + domainState; + } else { + state = state + '_' + new Date().getTime().toString(); + } set({ oauth_state: state }); return state; }, - setState: (state) => { - set({ oauth_state: state }); - }, compareState: (state: string) => { - let result = state === get().oauth_state; - console.log(result, get().oauth_state, state, 'compareState'); + // fix wechat + let isSuccess = decodeURIComponent(state) === decodeURIComponent(get().oauth_state); + console.log(state, get().oauth_state); + const [action, ...statePayload] = state.split('_'); set({ oauth_state: undefined }); - return result; + return { + isSuccess, + action, + statePayload + }; }, setProvider: (provider?: OauthProvider) => { set({ provider }); diff --git a/frontend/desktop/src/types/api.ts b/frontend/desktop/src/types/api.ts index f53ba5bf39e..9c86ffa2826 100644 --- a/frontend/desktop/src/types/api.ts +++ b/frontend/desktop/src/types/api.ts @@ -1,5 +1,5 @@ -export type ApiResp = { +export type ApiResp = { code?: number; - message?: string; + message?: U; data?: T; }; diff --git a/frontend/desktop/src/types/crd.ts b/frontend/desktop/src/types/crd.ts index 0b81a3308ae..680172f158d 100644 --- a/frontend/desktop/src/types/crd.ts +++ b/frontend/desktop/src/types/crd.ts @@ -1,4 +1,3 @@ -import { KubeConfig } from '@kubernetes/client-node'; import { APPTYPE, displayType } from './app'; import { LicenseFrontendKey } from '@/constants/account'; diff --git a/frontend/desktop/src/types/db/event.ts b/frontend/desktop/src/types/db/event.ts new file mode 100644 index 00000000000..d2a39fa002c --- /dev/null +++ b/frontend/desktop/src/types/db/event.ts @@ -0,0 +1,13 @@ +export enum MergeUserEvent { + '_MERGE_OAUTH_PROVIDER' = '_MERGE_OAUTH_PROVIDER', + '_PUB_TRANSACTION' = '_PUB_TRANSACTION', + '_MERGE_WORKSPACE' = '_MERGE_WORKSPACE', + '_COMMIT' = '_COMMIT' +} +// export type EventName = 'DELETE_USER'; +export enum DeleteUserEvent { + '_DELETE_OAUTH_PROVIDER' = '_DELETE_OAUTH_PROVIDER', + '_PUB_TRANSACTION' = '_PUB_TRANSACTION', + '_DELETE_USERCR' = '_DELETE_USERCR', + '_COMMIT' = '_COMMIT' +} diff --git a/frontend/desktop/src/types/index.ts b/frontend/desktop/src/types/index.ts index dc461c73158..deff17a71ee 100644 --- a/frontend/desktop/src/types/index.ts +++ b/frontend/desktop/src/types/index.ts @@ -1,5 +1,8 @@ import { type MongoClient } from 'mongodb'; import { type AppConfigType } from './system'; +import { Cron } from 'croner'; +import { Transporter } from 'nodemailer'; +import SMTPPool from 'nodemailer/lib/smtp-pool'; export * from './api'; export * from './session'; export * from './app'; @@ -7,13 +10,17 @@ export * from './crd'; export * from './payment'; export * from './system'; export * from './login'; -export * from './valuation'; +export * from './tools'; export * from './license'; export * from './region'; declare global { var mongodb: MongoClient | null; var AppConfig: AppConfigType; + var commitCroner: Cron | undefined; + var finishCroner: Cron | undefined; + var runCroner: Cron | undefined; var WechatAccessToken: string | undefined; var WechatExpiresIn: number | undefined; + var nodemailer: Transporter | undefined; } diff --git a/frontend/desktop/src/types/oauth.ts b/frontend/desktop/src/types/oauth.ts deleted file mode 100644 index 4f00987b145..00000000000 --- a/frontend/desktop/src/types/oauth.ts +++ /dev/null @@ -1,43 +0,0 @@ -export type OAuthToken = { - accessToken: string; - accessTokenExpiresAt?: Date; - clientId: string; - refreshToken?: string; - refreshTokenExpiresAt?: Date; - userId: string; - scope?: string[]; - grants: Grant[]; -}; - -export enum Grant { - authorization_code = 'authorization_code', - - client_credentials = 'client_credentials', - - refresh_token = 'refresh_token', - - password = 'password' -} - -export type OAuthClient = { - clientId: string; - clientSecret: string; - redirectUris: string[]; - grants: Grant[]; -}; -export type OAuthAuthorizationCode = { - authorizationCode: string; - expiresAt: Date; - redirectUri: string; - scope?: string[]; - clientId: string; - userId: string; -}; -export type OAuthUser = { - // email: { type: String, default: '' }, - // firstname: { type: String }, - // lastname: { type: String }, - // password: { type: String }, - // username: { type: String } - // realUserId -}; diff --git a/frontend/desktop/src/types/response/bind.ts b/frontend/desktop/src/types/response/bind.ts new file mode 100644 index 00000000000..656857a1d83 --- /dev/null +++ b/frontend/desktop/src/types/response/bind.ts @@ -0,0 +1,13 @@ +import { MERGE_USER_READY, PROVIDER_STATUS, RESPONSE_MESSAGE } from './utils'; + +enum _BIND_STATUS { + USER_NOT_FOUND = 'USER_NOT_FOUND', + NOT_SUPPORT = 'NOT_SUPPORT', + RESOURCE_CONFLICT = 'RESOURCE_CONFLICT', + DB_ERROR = 'DB_ERROR' +} +export const BIND_STATUS = Object.assign( + Object.assign({}, MERGE_USER_READY, _BIND_STATUS, RESPONSE_MESSAGE), + PROVIDER_STATUS +); +export type BIND_STATUS = typeof BIND_STATUS; diff --git a/frontend/desktop/src/types/response/changeBind.ts b/frontend/desktop/src/types/response/changeBind.ts new file mode 100644 index 00000000000..624e42296dd --- /dev/null +++ b/frontend/desktop/src/types/response/changeBind.ts @@ -0,0 +1,16 @@ +import { MERGE_USER_READY, PROVIDER_STATUS, RESPONSE_MESSAGE } from './utils'; + +enum _CHANGE_BIND_STATUS { + USER_NOT_FOUND = 'USER_NOT_FOUND', + NOT_SUPPORT = 'NOT_SUPPORT', + // OLD_PROVIDER_NOT_EXIST = 'PROVIDER_NOT_EXIST', + // NEW_PROVIDER_USED_CONFLICT = 'NEW_PROVIDER_USED_CONFLICT', + // NEW_PROVIDER_USED_MERGE = 'NEW_PROVIDER_USED_MERGE', + RESOURCE_CONFLICT = 'RESOURCE_CONFLICT', + DB_ERROR = 'DB_ERROR' +} +export const CHANGE_BIND_STATUS = Object.assign( + Object.assign({}, _CHANGE_BIND_STATUS, MERGE_USER_READY, RESPONSE_MESSAGE), + PROVIDER_STATUS +); +export type CHANGE_BIND_STATUS = typeof CHANGE_BIND_STATUS; diff --git a/frontend/desktop/src/types/response/checkResource.ts b/frontend/desktop/src/types/response/checkResource.ts new file mode 100644 index 00000000000..e1c4d1ca96f --- /dev/null +++ b/frontend/desktop/src/types/response/checkResource.ts @@ -0,0 +1,25 @@ +import { _ACCOUNT_STATUS, RESPONSE_MESSAGE } from './utils'; + +enum _RESOURCE_STATUS { + USER_CR_NOT_FOUND = 'USER_CR_NOT_FOUND', + USER_NOT_FOUND = 'USER_NOT_FOUND', + OAUTHPROVIDER_NOT_FOUND = 'OAUTHPROVIDER_NOT_FOUND', + PRIVATE_WORKSPACE_NOT_FOUND = 'PRIVATE_WORKSPACE_NOT_FOUND', + GET_RESOURCE_ERROR = 'GET_RESOURCE_ERROR', + REMAIN_OTHER_REGION_RESOURCE = 'REMAIN_OTHER_REGION_RESOURCE', + REMAIN_WORKSACE_OWNER = 'REMAIN_WORKSACE_OWNER', + REMAIN_CVM = 'REMAIN_CVM', + REMAIN_APP = 'REMAIN_APP', + REMAIN_TEMPLATE = 'REMAIN_TEMPLATE', + REMAIN_OBJECT_STORAGE = 'REMAIN_OBJECT_STORAGE', + REMAIN_DATABASE = 'REMAIN_DATABASE', + KUBECONFIG_NOT_FOUND = 'KUBECONFIG_NOT_FOUND' +} + +export const RESOURCE_STATUS = Object.assign( + {}, + _RESOURCE_STATUS, + RESPONSE_MESSAGE, + _ACCOUNT_STATUS +); +export type RESOURCE_STATUS = typeof RESOURCE_STATUS; diff --git a/frontend/desktop/src/types/response/email.ts b/frontend/desktop/src/types/response/email.ts new file mode 100644 index 00000000000..8f860dd4b01 --- /dev/null +++ b/frontend/desktop/src/types/response/email.ts @@ -0,0 +1,11 @@ +import { RESPONSE_MESSAGE } from './utils'; + +enum _EMAIL_STATUS { + USER_NOT_FOUND = 'USER_NOT_FOUND', + INVALID_PARAMS = 'INVALID_PARAMS', + NOT_SUPPORT = 'NOT_SUPPORT', + RESOURCE_CONFLICT = 'RESOURCE_CONFLICT', + DB_ERROR = 'DB_ERROR' +} +export const EMAIL_STATUS = Object.assign({}, _EMAIL_STATUS, RESPONSE_MESSAGE); +export type EMAIL_STATUS = typeof EMAIL_STATUS; diff --git a/frontend/desktop/src/types/response/merge.ts b/frontend/desktop/src/types/response/merge.ts new file mode 100644 index 00000000000..fb9c25153ba --- /dev/null +++ b/frontend/desktop/src/types/response/merge.ts @@ -0,0 +1,15 @@ +import { _ACCOUNT_STATUS, RESPONSE_MESSAGE } from './utils'; + +enum _USER_MERGE_STATUS { + NOT_SUPPORT = 'NOT_SUPPORT', + OAUTH_PROVIDER_NOT_FOUND = 'OAUTH_PROVIDER_NOT_FOUND', + EXIST_SAME_OAUTH_PROVIDER = 'EXIST_SAME_OAUTH_PROVIDER', + USER_NOT_FOUND = 'USER_NOT_FOUND' +} +export const USER_MERGE_STATUS = Object.assign( + {}, + _USER_MERGE_STATUS, + RESPONSE_MESSAGE, + _ACCOUNT_STATUS +); +export type USER_MERGE_STATUS = typeof USER_MERGE_STATUS; diff --git a/frontend/desktop/src/types/response/unbind.ts b/frontend/desktop/src/types/response/unbind.ts new file mode 100644 index 00000000000..a9a8ebdb796 --- /dev/null +++ b/frontend/desktop/src/types/response/unbind.ts @@ -0,0 +1,10 @@ +import { PROVIDER_STATUS, RESPONSE_MESSAGE } from './utils'; + +enum _UNBIND_STATUS { + USER_NOT_FOUND = 'USER_NOT_FOUND', + NOT_SUPPORT = 'NOT_SUPPORT', + RESOURCE_CONFLICT = 'RESOURCE_CONFLICT', + DB_ERROR = 'DB_ERROR' +} +export const UNBIND_STATUS = Object.assign({}, _UNBIND_STATUS, RESPONSE_MESSAGE, PROVIDER_STATUS); +export type UNBIND_STATUS = typeof UNBIND_STATUS; diff --git a/frontend/desktop/src/types/response/utils.ts b/frontend/desktop/src/types/response/utils.ts new file mode 100644 index 00000000000..a6e333a38c9 --- /dev/null +++ b/frontend/desktop/src/types/response/utils.ts @@ -0,0 +1,17 @@ +export enum RESPONSE_MESSAGE { + RESULT_SUCCESS = 'RESULT_SUCCESS', + INTERNAL_SERVER_ERROR = 'INTERNAL SERVER ERROR' +} + +export enum _ACCOUNT_STATUS { + INSUFFICENT_BALANCE = 'INSUFFICENT_BALANCE', + ACCOUNT_NOT_FOUND = 'ACCOUNT_NOT_FOUND' +} +export enum MERGE_USER_READY { + MERGE_USER_CONTINUE = 'USER_MERGE', + MERGE_USER_PROVIDER_CONFLICT = 'PROVIDER_CONFLICT' +} +export enum PROVIDER_STATUS { + PROVIDER_NOT_FOUND = 'PROVIDER_NOT_FOUND', + PROVIDER_EXIST = 'PROVIDER_EXIST' +} diff --git a/frontend/desktop/src/types/system.ts b/frontend/desktop/src/types/system.ts index e4e8f99c45b..383c8359cd4 100644 --- a/frontend/desktop/src/types/system.ts +++ b/frontend/desktop/src/types/system.ts @@ -12,8 +12,17 @@ export type CommonConfigType = { apiEnabled: boolean; rechargeEnabled: boolean; cfSiteKey?: string; + templateUrl?: string; + objectstorageUrl: string; + applaunchpadUrl: string; + dbproviderUrl: string; }; -export type CommonClientConfigType = DeepRequired>; +export type CommonClientConfigType = DeepRequired< + Omit< + CommonConfigType, + 'apiEnabled' | 'objectstorageUrl' | 'applaunchpadUrl' | 'dbproviderUrl' | 'templateUrl' + > +>; export type DatabaseConfigType = { mongodbURI: string; globalCockroachdbURI: string; @@ -66,6 +75,8 @@ export type AuthConfigType = { baiduToken?: string; jwt: JwtConfigType; billingUrl?: string; + workorderUrl?: string; + cloudVitrualMachineUrl: string; invite?: { enabled: boolean; lafSecretKey: string; @@ -101,6 +112,13 @@ export type AuthConfigType = { accessKeyID: string; accessKeySecret?: string; }; + email?: { + enabled: boolean; + host: string; + port: number; + user: string; + password: string; + }; }; oauth2?: { enabled: boolean; @@ -126,9 +144,12 @@ export type AuthClientConfigType = DeepRequired< 'idp.wechat.clientSecret', 'idp.google.clientSecret', 'idp.sms.ali', + 'idp.sms.email', 'idp.oauth2.clientSecret', 'jwt', - 'billingUrl' + 'billingUrl', + 'workorderUrl', + 'cloudVitrualMachineUrl' ] > >; diff --git a/frontend/desktop/src/types/team.ts b/frontend/desktop/src/types/team.ts index feebe6a18de..621938df581 100644 --- a/frontend/desktop/src/types/team.ts +++ b/frontend/desktop/src/types/team.ts @@ -1,3 +1,5 @@ +import { K8sApiDefault } from '@/services/backend/kubernetes/admin'; +import * as k8s from '@kubernetes/client-node'; import { UUID, createHash } from 'crypto'; import * as yaml from 'js-yaml'; export type RoleAction = 'Grant' | 'Deprive' | 'Change' | 'Create' | 'Modify'; @@ -30,21 +32,14 @@ type CRD = { role: RoleType; }; }; -export enum watchEventType { - ADDED = 'ADDED', - MODIFIED = 'MODIFIED', - DELETED = 'DELETED', - BOOKMARK = 'BOOKMARK' -} -export const generateRequestCrd = (props: CRD['spec'] & { namespace: string }) => { - const hash = createHash('sha256'); - hash.update(JSON.stringify(props) + new Date().getTime()); - const name = hash.digest('hex'); +export const generateRequestCrd = ({ + ...props +}: CRD['spec'] & { namespace: string; name: string }) => { const requestCrd: CRD = { apiVersion: 'user.sealos.io/v1', kind: 'Operationrequest', metadata: { - name, + name: props.name, namespace: props.namespace }, spec: { @@ -53,7 +48,6 @@ export const generateRequestCrd = (props: CRD['spec'] & { namespace: string }) = role: props.role } }; - try { const result = yaml.dump(requestCrd); return result; diff --git a/frontend/desktop/src/types/token.ts b/frontend/desktop/src/types/token.ts index 69e41d0a2b4..37cfe813e92 100644 --- a/frontend/desktop/src/types/token.ts +++ b/frontend/desktop/src/types/token.ts @@ -9,3 +9,8 @@ export type AccessTokenPayload = { workspaceUid: string; workspaceId: string; } & AuthenticationTokenPayload; + +export type CronJobTokenPayload = { + mergeUserUid: string; + userUid: string; +}; diff --git a/frontend/desktop/src/types/tools.ts b/frontend/desktop/src/types/tools.ts index 731908114cb..29a110ea3e8 100644 --- a/frontend/desktop/src/types/tools.ts +++ b/frontend/desktop/src/types/tools.ts @@ -29,3 +29,16 @@ export type OmitPathArr = Paths extends [infer Hea ? OmitPathArr, Rest> : never : Obj; +export type ValueOf = T[keyof T]; +export type AsyncResult< + TData extends unknown, + StatusUnion extends unknown, + SuccessStatus extends StatusUnion +> = [TData] extends [never] + ? { status: StatusUnion } + : + | { + status: SuccessStatus; + data: TData; + } + | { status: Exclude }; diff --git a/frontend/desktop/src/types/user.ts b/frontend/desktop/src/types/user.ts index 229cbc5d9f1..4fb14d82b02 100644 --- a/frontend/desktop/src/types/user.ts +++ b/frontend/desktop/src/types/user.ts @@ -1,5 +1,6 @@ +import { _ACCOUNT_STATUS, RESPONSE_MESSAGE } from './response/utils'; import { InvitedStatus, UserRole } from './team'; - +import { ProviderType } from 'prisma/global/generated/client'; export type TgithubToken = { access_token: string; expires_in: number; @@ -63,19 +64,32 @@ export type TgithubUser = { created_at: string; updated_at: string; }; +export type TgoogleUser = { + iss: string; + azp: string; + aud: string; + sub: string; + at_hash: string; + name: string; + picture: string; + given_name: string; + family_name: string; + locale: string; + iat: number; + exp: number; +}; // if default, uid export const PROVIDERS = [ - 'github', - 'wechat', - 'phone', - 'uid', - 'password_user', - 'google', - 'wechat_open', - 'oauth2' + 'GITHUB', + 'WECHAT', + 'PHONE', + 'PASSWORD', + 'GOOGLE', + 'WECHAT_OPEN', + 'OAUTH2', + 'EMAIL' ] as const; -export type Provider = (typeof PROVIDERS)[number]; -export type OauthProvider = Exclude; +export type OauthProvider = Exclude; export type TUserExist = { user: string; exist: boolean }; export type K8s_user = { diff --git a/frontend/desktop/src/types/valuation.ts b/frontend/desktop/src/types/valuation.ts deleted file mode 100644 index da4244ceb53..00000000000 --- a/frontend/desktop/src/types/valuation.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type ValuationStandard = { - name: string; - unit: string; - price: string; -}; -export type ValuationBillingRecord = { - price: number; - resourceType: string; -}; -export type ValuationData = { - apiVersion: 'account.sealos.io/v1'; - kind: 'PriceQuery'; - metadata: any; - spec: {}; - status: { - billingRecords: ValuationBillingRecord[]; - }; -}; diff --git a/frontend/desktop/src/utils/crypto.ts b/frontend/desktop/src/utils/crypto.ts index d7596eed45c..2aa2fc65c8b 100644 --- a/frontend/desktop/src/utils/crypto.ts +++ b/frontend/desktop/src/utils/crypto.ts @@ -11,3 +11,5 @@ export const verifyPassword = (password: string, hash: string): boolean => hashPassword(password) === hash; export const strongPassword = (password: string): boolean => /^(?=.*\S).{8,}$/.test(password); export const strongUsername = (username: string): boolean => /^[a-zA-Z0-9_-]{3,16}$/.test(username); +export const isEmail = (email: string) => + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email); diff --git a/frontend/desktop/src/utils/format.ts b/frontend/desktop/src/utils/format.ts index 5f24f135ef4..aa33bfcf4d3 100644 --- a/frontend/desktop/src/utils/format.ts +++ b/frontend/desktop/src/utils/format.ts @@ -52,3 +52,23 @@ export const getRemainingTime = (expirationTime: number) => { const formattedTime = `${hours}小时${minutes}分钟`; return formattedTime; }; + +export function maskEmail(email: string): string { + const atIndex = email.indexOf('@'); + if (atIndex === -1) { + return email; + } + + const username = email.substring(0, atIndex); + const domain = email.substring(atIndex); + + if (username.length <= 3) { + return username + '****' + domain; + } + + const maskedUsername = + username.substring(0, 3) + + '*'.repeat(username.length - 4) + + username.substring(username.length - 1); + return maskedUsername + domain; +} diff --git a/frontend/desktop/src/utils/tools.ts b/frontend/desktop/src/utils/tools.ts index 529d9bcf2b3..0ae4f9c55f9 100644 --- a/frontend/desktop/src/utils/tools.ts +++ b/frontend/desktop/src/utils/tools.ts @@ -1,6 +1,10 @@ import { InvitedStatus, UserNsStatus, UserRole } from '@/types/team'; import dayjs from 'dayjs'; import { JoinStatus, Role } from 'prisma/region/generated/client'; +import { Prisma as GlobalPrisma } from 'prisma/global/generated/client'; +import { OauthProvider } from '@/types/user'; +import { Prisma } from '@prisma/client/extension'; +import { globalPrisma } from '@/services/backend/db/init'; export const validateNumber = (num: number) => typeof num === 'number' && isFinite(num) && num > 0; @@ -15,6 +19,7 @@ export function appWaitSeconds(ms: number) { }, ms); }); } + export async function getBase64FromRemote(url: string) { try { const res = await fetch(url); @@ -99,6 +104,7 @@ export const joinStatusToNStatus = (js: JoinStatus): InvitedStatus => { export const NStatusToJoinStatus = (ns: InvitedStatus): JoinStatus => { return [JoinStatus.INVITED, JoinStatus.IN_WORKSPACE, JoinStatus.NOT_IN_WORKSPACE][ns]; }; + export function compareFirstLanguages(acceptLanguageHeader: string) { const indexOfZh = acceptLanguageHeader.indexOf('zh'); const indexOfEn = acceptLanguageHeader.indexOf('en'); @@ -106,3 +112,19 @@ export function compareFirstLanguages(acceptLanguageHeader: string) { if (indexOfEn === -1 || indexOfZh < indexOfEn) return 'zh'; return 'en'; } + +type TOauthProviders = Prisma.Result< + typeof globalPrisma.oauthProvider, + GlobalPrisma.OauthProviderDefaultArgs, + 'findMany' +>; + +export function userCanMerge( + mergeUserOauthProviders: TOauthProviders, + userOauthProviders: TOauthProviders +) { + const curTypeList = userOauthProviders.map((o) => o.providerType); + const mergeTypeSet = new Set(mergeUserOauthProviders.map((o) => o.providerType)); + const canMerge = curTypeList.every((t) => !mergeTypeSet.has(t)); + return canMerge; +} diff --git a/frontend/desktop/tsconfig.json b/frontend/desktop/tsconfig.json index e4ff73c013e..ac5ba948a69 100644 --- a/frontend/desktop/tsconfig.json +++ b/frontend/desktop/tsconfig.json @@ -7,6 +7,7 @@ "@/*": ["src/*"], "prisma/*": ["prisma/*"] }, + "isolatedModules": true, "types": ["@types/jest"] }, "include": [ diff --git a/frontend/packages/ui/src/components/icons/line/WarnTriange.tsx b/frontend/packages/ui/src/components/icons/line/WarnTriange.tsx new file mode 100644 index 00000000000..18e811ed36b --- /dev/null +++ b/frontend/packages/ui/src/components/icons/line/WarnTriange.tsx @@ -0,0 +1,24 @@ +import { createIcon } from '@chakra-ui/react'; + +const WarnTriangeIcon = createIcon({ + displayName: 'WarnTriangeIcon', + viewBox: '0 0 25 24', + path: ( + + + + + + + + + + + ) +}); + +export default WarnTriangeIcon; diff --git a/frontend/packages/ui/src/components/index.ts b/frontend/packages/ui/src/components/index.ts index 986c1693e24..db02c0a1cc6 100644 --- a/frontend/packages/ui/src/components/index.ts +++ b/frontend/packages/ui/src/components/index.ts @@ -50,7 +50,7 @@ import useMessage from './Message/index'; import EditTabs from './EditTabs'; import YamlCode from './YamlCode'; import ProviderIcon from './icons/ProviderIcon'; - +import WarnTriangeIcon from './icons/line/WarnTriange'; export { SealosMenu } from './Menu'; export { Tabs } from './Tabs'; export { MyRangeSlider } from './RangeSlider'; @@ -115,5 +115,6 @@ export { SortPolygonUpIcon, SortPolygonDownIcon, ProviderIcon, - WebHostIcon + WebHostIcon, + WarnTriangeIcon }; diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index ac1830d4dcb..123703a8e2c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: cors: specifier: ^2.8.5 version: 2.8.5 + croner: + specifier: ^8.0.2 + version: 8.0.2 dayjs: specifier: ^1.11.10 version: 1.11.10 @@ -156,6 +159,9 @@ importers: next-pwa: specifier: ^5.6.0 version: 5.6.0(@babel/core@7.23.5)(next@13.3.0)(webpack@5.89.0) + nodemailer: + specifier: ^6.9.13 + version: 6.9.13 nprogress: specifier: ^0.2.0 version: 0.2.0 @@ -232,6 +238,9 @@ importers: '@types/node': specifier: 18.15.11 version: 18.15.11 + '@types/nodemailer': + specifier: ^6.4.15 + version: 6.4.15 '@types/nprogress': specifier: ^0.2.1 version: 0.2.3 @@ -2248,6 +2257,9 @@ importers: axios: specifier: ^1.5.1 version: 1.6.2 + croner: + specifier: ^8.1.0 + version: 8.1.0 date-fns: specifier: ^2.30.0 version: 2.30.0 @@ -9848,6 +9860,12 @@ packages: resolution: {integrity: sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==} dev: true + /@types/nodemailer@6.4.15: + resolution: {integrity: sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==} + dependencies: + '@types/node': 20.10.0 + dev: true + /@types/nprogress@0.2.3: resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==} dev: true @@ -11734,6 +11752,16 @@ packages: luxon: 3.4.4 dev: false + /croner@8.0.2: + resolution: {integrity: sha512-HgSdlSUX8mIgDTTiQpWUP4qY4IFRMsduPCYdca34Pelt8MVdxdaDOzreFtCscA6R+cRZd7UbD1CD3uyx6J3X1A==} + engines: {node: '>=18.0'} + dev: false + + /croner@8.1.0: + resolution: {integrity: sha512-sz990XOUPR8dG/r5BRKMBd15MYDDUu8oeSaxFD5DqvNgHSZw8Psd1s689/IGET7ezxRMiNlCIyGeY1Gvxp/MLg==} + engines: {node: '>=18.0'} + dev: false + /cronstrue@2.44.0: resolution: {integrity: sha512-71aQD16uXrqjDUYHsFYY4/SSmEepzQZqTqWsU9x2kDMCYKyIp/5e0QW/cp2lBNO9PJB1xOpIbBJuQEa5yKx98A==} hasBin: true @@ -17880,6 +17908,11 @@ packages: hasBin: true dev: false + /nodemailer@6.9.13: + resolution: {integrity: sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==} + engines: {node: '>=6.0.0'} + dev: false + /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} diff --git a/frontend/providers/applaunchpad/src/constants/theme.ts b/frontend/providers/applaunchpad/src/constants/theme.ts index 3b6677eccbc..482ab43103e 100644 --- a/frontend/providers/applaunchpad/src/constants/theme.ts +++ b/frontend/providers/applaunchpad/src/constants/theme.ts @@ -1,6 +1,22 @@ import { extendTheme } from '@chakra-ui/react'; import { theme as sealosTheme } from '@sealos/ui'; +import { switchAnatomy } from '@chakra-ui/anatomy'; +import { createMultiStyleConfigHelpers } from '@chakra-ui/react'; + +const { definePartsStyle, defineMultiStyleConfig } = createMultiStyleConfigHelpers( + switchAnatomy.keys +); + +const baseStyle = definePartsStyle({ + container: {}, + track: { + bg: 'gray.200' + } +}); + +export const Switch = defineMultiStyleConfig({ baseStyle }); + export const theme = extendTheme(sealosTheme, { styles: { global: { @@ -12,5 +28,8 @@ export const theme = extendTheme(sealosTheme, { minWidth: '1024px' } } + }, + components: { + Switch: Switch } }); diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx index 64275e48f05..843871bc4b7 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx @@ -818,7 +818,6 @@ const Form = ({ { updateNetworks(i, { diff --git a/frontend/providers/workorder/.env.template b/frontend/providers/workorder/.env.template index ec888eb5b11..f7500dd584e 100644 --- a/frontend/providers/workorder/.env.template +++ b/frontend/providers/workorder/.env.template @@ -1,6 +1,6 @@ NEXT_PUBLIC_MOCK_USER= -SEALOS_DOMAIN="cloud.sealos.io" +SEALOS_DOMAIN="cloud.sealos.io" # mongodb MONGODB_URI= # minio @@ -12,4 +12,10 @@ MINIO_PORT= # admin ADMIN_FEISHU_URL= JWT_SECRET_DESKTOP_TO_APP= -JWT_SECRET_SELF= \ No newline at end of file +JWT_SECRET_SELF= +ADMIN_FEISHU_CALLBACK_URL= +ADMIN_API_TOKEN= +# fastgpt +FASTGPT_API_URL= +FASTGPT_API_KEY= +FASTGPT_API_LIMIT= \ No newline at end of file diff --git a/frontend/providers/workorder/deploy/manifests/deploy.yaml.tmpl b/frontend/providers/workorder/deploy/manifests/deploy.yaml.tmpl index 541b20fd3bc..0aafe3a03de 100644 --- a/frontend/providers/workorder/deploy/manifests/deploy.yaml.tmpl +++ b/frontend/providers/workorder/deploy/manifests/deploy.yaml.tmpl @@ -64,6 +64,15 @@ spec: # do not modify this image, it is used for CI/CD image: ghcr.io/labring/sealos-workorder-frontend:latest imagePullPolicy: Always + readinessProbe: + httpGet: + path: /api/cronjob/init + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 3 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 3 volumeMounts: - name: workorder-frontend-volume mountPath: /config.yaml diff --git a/frontend/providers/workorder/package.json b/frontend/providers/workorder/package.json index 42fd27987c0..03f83a955be 100644 --- a/frontend/providers/workorder/package.json +++ b/frontend/providers/workorder/package.json @@ -23,6 +23,7 @@ "@types/multer": "^1.4.10", "ansi_up": "^5.2.1", "axios": "^1.5.1", + "croner": "^8.1.0", "date-fns": "^2.30.0", "dayjs": "^1.11.10", "echarts": "^5.4.3", diff --git a/frontend/providers/workorder/public/locales/en/common.json b/frontend/providers/workorder/public/locales/en/common.json index 023d727635d..52212d169b2 100644 --- a/frontend/providers/workorder/public/locales/en/common.json +++ b/frontend/providers/workorder/public/locales/en/common.json @@ -61,5 +61,6 @@ "api is error": "api is error", "region": "region", "userId": "userId", - "other": "other" + "other": "other", + "fastgpt": "FastGPT" } \ No newline at end of file diff --git a/frontend/providers/workorder/public/locales/zh/common.json b/frontend/providers/workorder/public/locales/zh/common.json index e3f58201863..8090d32602b 100644 --- a/frontend/providers/workorder/public/locales/zh/common.json +++ b/frontend/providers/workorder/public/locales/zh/common.json @@ -61,5 +61,6 @@ "api is error": "api is error", "region": "可用区", "userId": "用户ID", - "other": "其他" + "other": "其他", + "fastgpt": "FastGPT" } \ No newline at end of file diff --git a/frontend/providers/workorder/src/components/Markdown/index.module.scss b/frontend/providers/workorder/src/components/Markdown/index.module.scss index 684e63fde79..53ae87fc114 100644 --- a/frontend/providers/workorder/src/components/Markdown/index.module.scss +++ b/frontend/providers/workorder/src/components/Markdown/index.module.scss @@ -10,7 +10,6 @@ .animation { height: 20px; - &::after { display: inline-block; content: ''; @@ -21,6 +20,7 @@ animation: blink 0.6s infinite; } } + @keyframes blink { from, to { diff --git a/frontend/providers/workorder/src/pages/api/cronjob/init.ts b/frontend/providers/workorder/src/pages/api/cronjob/init.ts new file mode 100644 index 00000000000..54493c36b4d --- /dev/null +++ b/frontend/providers/workorder/src/pages/api/cronjob/init.ts @@ -0,0 +1,66 @@ +import { jsonRes } from '@/services/backend/response'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { Cron } from 'croner'; + +const adminToken = process.env.ADMIN_API_TOKEN; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const { action } = req.query; + const baseurl = `http://${process.env.HOSTNAME || 'localhost'}:${process.env.PORT || 3000}`; + + if (!global.cronJobWorkOrders) { + global.cronJobWorkOrders = new Cron( + process.env.NODE_ENV === 'production' ? '0,30 10-17 * * 1-5' : '* * * * *', + async () => { + const result = await ( + await fetch(`${baseurl}/api/workorder/check`, { + method: 'GET', + headers: { + Authorization: `Bearer ${adminToken}`, + 'Content-Type': 'application/json' + } + }) + ).json(); + const now = new Date(); + console.log( + `Cron Job Run at: ${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` + ); + }, + { + timezone: 'Asia/Shanghai' + } + ); + } + + switch (action) { + case 'pause': + global.cronJobWorkOrders.pause(); + jsonRes(res, { + data: 'Cron job paused' + }); + break; + case 'resume': + global.cronJobWorkOrders.resume(); + jsonRes(res, { + data: 'Cron job resumed' + }); + break; + case 'stop': + global.cronJobWorkOrders.stop(); + jsonRes(res, { + data: 'Cron job stopped' + }); + break; + default: + jsonRes(res, { + data: 'Cron job is running' + }); + } + } catch (error) { + jsonRes(res, { + code: 500, + error: error + }); + } +} diff --git a/frontend/providers/workorder/src/pages/api/workorder/check.ts b/frontend/providers/workorder/src/pages/api/workorder/check.ts new file mode 100644 index 00000000000..71ec45664ad --- /dev/null +++ b/frontend/providers/workorder/src/pages/api/workorder/check.ts @@ -0,0 +1,183 @@ +import { verifyDesktopToken } from '@/services/backend/auth'; +import { jsonRes } from '@/services/backend/response'; +import { getUserById } from '@/services/db/user'; +import { fetchProcessingOrders, updateOrder } from '@/services/db/workorder'; +import { WorkOrderDB, WorkOrderStatus } from '@/types/workorder'; +import { NextApiRequest, NextApiResponse } from 'next'; + +const feishuUrl = process.env.ADMIN_FEISHU_URL; +const feishuCallBackUrl = process.env.ADMIN_FEISHU_CALLBACK_URL; +const MINUTES_IN_A_WEEK = 7 * 24 * 60; + +const getFeishuForm = ({ + recentUnresponded, + overdueAutoCloseIn7Days +}: { + recentUnresponded: WorkOrderDB[]; + overdueAutoCloseIn7Days: WorkOrderDB[]; +}) => { + const content1 = recentUnresponded + .map((item) => `- [${item.orderId}](${feishuCallBackUrl}?orderId=${item.orderId})`) + .join('\n'); + + const content2 = overdueAutoCloseIn7Days + .map((item) => `- [${item.orderId}](${feishuCallBackUrl}?orderId=${item.orderId})`) + .join('\n'); + + const form = { + msg_type: 'interactive', + card: { + config: {}, + i18n_elements: { + zh_cn: [ + { + tag: 'markdown', + content: '**收到用户消息,超过30分钟的工单**', + text_align: 'left', + text_size: 'normal', + icon: { + tag: 'standard_icon', + token: 'vote_colorful', + color: 'grey' + } + }, + { + tag: 'markdown', + content: content1, + text_align: 'left', + text_size: 'normal' + }, + { + tag: 'hr' + }, + { + tag: 'markdown', + content: '**已自动关闭的工单**', + text_align: 'left', + text_size: 'normal', + icon: { + tag: 'standard_icon', + token: 'todo_colorful', + color: 'grey' + } + }, + { + tag: 'markdown', + content: content2, + text_align: 'left', + text_size: 'normal' + } + ] + }, + i18n_header: { + zh_cn: { + title: { + tag: 'plain_text', + content: '工单消息提醒' + }, + subtitle: { + tag: 'plain_text', + content: '' + }, + template: 'indigo', + ud_icon: { + tag: 'standard_icon', + token: 'myai_colorful' + } + } + } + } + }; + + return form; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const adminToken = req.headers['authorization']?.split(' ')[1]; + if (!adminToken) { + return jsonRes(res, { + code: 401, + message: "'token is invaild'" + }); + } + const payload = await verifyDesktopToken(adminToken); + if (!payload?.userId) { + return jsonRes(res, { + code: 401, + message: "'token is invaild'" + }); + } + const user = await getUserById(payload?.userId); + if (!user?.isAdmin) { + return jsonRes(res, { + code: 403, + message: 'Access denied' + }); + } + + const orders = await fetchProcessingOrders(); + const recentUnresponded: WorkOrderDB[] = []; + const overdueAutoCloseIn7Days: WorkOrderDB[] = []; + const currentTime = new Date(); + orders.forEach((order) => { + if (!order.dialogs || order.dialogs.length === 0) return; + let lastDialog = order.dialogs[order.dialogs.length - 1]; + if (lastDialog.userId === 'robot') return; + + const lastDialogTime = new Date(lastDialog.time); + const timeDiff = Math.ceil((currentTime.getTime() - lastDialogTime.getTime()) / 1000 / 60); + if (!lastDialog.isAdmin && timeDiff > 30) { + recentUnresponded.push(order); + } + if (lastDialog.isAdmin && timeDiff > MINUTES_IN_A_WEEK) { + console.log(order); + overdueAutoCloseIn7Days.push(order); + } + }); + + if (recentUnresponded.length === 0 && overdueAutoCloseIn7Days.length === 0) { + return jsonRes(res, { + code: 204, + message: 'No content to send' + }); + } + + if (overdueAutoCloseIn7Days.length > 0) { + try { + for (const order of overdueAutoCloseIn7Days) { + await updateOrder({ + orderId: order.orderId, + userId: payload.userId, + updates: { + status: WorkOrderStatus.Completed + } + }); + } + } catch (error) {} + } + + const form = getFeishuForm({ overdueAutoCloseIn7Days, recentUnresponded }); + + if (!feishuUrl) { + return jsonRes(res, { + code: 500, + message: 'Missing Feishu API' + }); + } + const data = await fetch(feishuUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(form) + }); + const result = await data.json(); + jsonRes(res, { + data: result + }); + } catch (error) { + console.log(error); + jsonRes(res, { code: 500, error: error }); + } +} diff --git a/frontend/providers/workorder/src/pages/workorder/detail/components/AppMainInfo.tsx b/frontend/providers/workorder/src/pages/workorder/detail/components/AppMainInfo.tsx index ec604a0f5d4..5516700fa9a 100644 --- a/frontend/providers/workorder/src/pages/workorder/detail/components/AppMainInfo.tsx +++ b/frontend/providers/workorder/src/pages/workorder/detail/components/AppMainInfo.tsx @@ -97,6 +97,7 @@ const AppMainInfo = ({ isManuallyHandled: boolean; }) => { const textareaMinH = '50px'; + const [isLoading, setIsloading] = useState(false); const { session } = useSessionStore(); const { t } = useTranslation(); const [text, setText] = useState(''); @@ -282,6 +283,7 @@ const AppMainInfo = ({ const handleTransferToHuman = async () => { try { + setIsloading(true); await FeishuNotification({ type: app.type, description: app.description, @@ -298,6 +300,7 @@ const AppMainInfo = ({ status: 'error' }); } + setIsloading(false); refetchWorkOrder(); }; @@ -409,7 +412,7 @@ const AppMainInfo = ({ {isURL(item.content) ? ( img scrollToBottom()}> ) : ( - + )} @@ -418,6 +421,7 @@ const AppMainInfo = ({ !session?.user.isAdmin && !app?.manualHandling?.isManuallyHandled && (