Compare commits
444 Commits
@nhost/rea
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac3f12c878 | ||
|
|
65cabb089f | ||
|
|
2905beb0a1 | ||
|
|
83fee54460 | ||
|
|
82898b6dae | ||
|
|
500f76a38d | ||
|
|
5e1e80aa8b | ||
|
|
6d0a126907 | ||
|
|
1b7dcf2121 | ||
|
|
2b9205b6cf | ||
|
|
bdc4d4a88c | ||
|
|
45759c4d4c | ||
|
|
5f9886577a | ||
|
|
fa65496327 | ||
|
|
03777680c1 | ||
|
|
72c81207ff | ||
|
|
5ca2a394e8 | ||
|
|
e63b8da58a | ||
|
|
bf8543cd34 | ||
|
|
8a557bbd02 | ||
|
|
327e30b859 | ||
|
|
bbfaf9732b | ||
|
|
c064a53256 | ||
|
|
ebda86f1f0 | ||
|
|
8948be9d3d | ||
|
|
54e9b141f1 | ||
|
|
dba71483df | ||
|
|
77ef68232a | ||
|
|
8fbc7f9f95 | ||
|
|
ca9f0f6ae9 | ||
|
|
e819903f1b | ||
|
|
f780b17581 | ||
|
|
032c0bd217 | ||
|
|
5d278709cb | ||
|
|
3a012e089a | ||
|
|
7aed620e12 | ||
|
|
d9fd1a54a5 | ||
|
|
a19b85c8ac | ||
|
|
4e1aaca0ee | ||
|
|
34ef37cdce | ||
|
|
5d6b655cb1 | ||
|
|
074a0fa111 | ||
|
|
403d839fca | ||
|
|
4e3098240b | ||
|
|
dd0a5cf3c1 | ||
|
|
5187fd3a4b | ||
|
|
d8dfd6bf80 | ||
|
|
6ea6ad61db | ||
|
|
fd0b904ed4 | ||
|
|
8989e314a6 | ||
|
|
5b5a1219c5 | ||
|
|
07fda9bbb3 | ||
|
|
2fa828fef1 | ||
|
|
d5ec69ac37 | ||
|
|
4a7ede11e9 | ||
|
|
482ae4c4f1 | ||
|
|
08fe4cd65f | ||
|
|
5781721bca | ||
|
|
39de0063bf | ||
|
|
202b647234 | ||
|
|
51c163a268 | ||
|
|
6e802c9938 | ||
|
|
9a46104e37 | ||
|
|
655b317c39 | ||
|
|
d3ad7c9d4a | ||
|
|
09fc852c3a | ||
|
|
ece08d3efd | ||
|
|
3493442c2d | ||
|
|
632a79b9e4 | ||
|
|
4a4d85757a | ||
|
|
88a01004b7 | ||
|
|
73230eb35a | ||
|
|
27e1c90624 | ||
|
|
1cc53d550a | ||
|
|
22d3f71e02 | ||
|
|
010b816866 | ||
|
|
4a6e62e673 | ||
|
|
5cf9dd9bc2 | ||
|
|
27e74c10d7 | ||
|
|
bd807a5ee1 | ||
|
|
4093e03a13 | ||
|
|
29076d0304 | ||
|
|
ab83fa6b5e | ||
|
|
b20761e976 | ||
|
|
a445e5b786 | ||
|
|
90df6d81d8 | ||
|
|
aa85084675 | ||
|
|
07ad470c0c | ||
|
|
fa6b58a9c5 | ||
|
|
acf55376ba | ||
|
|
b0a9798b04 | ||
|
|
3952e87f01 | ||
|
|
b95ccf873d | ||
|
|
8d7f84b8da | ||
|
|
bd1b69bd75 | ||
|
|
84d5436634 | ||
|
|
2325766c1d | ||
|
|
2c355eaae4 | ||
|
|
9e26ed767e | ||
|
|
abdb6c56f4 | ||
|
|
3b75bfce27 | ||
|
|
f498190758 | ||
|
|
b4158fa513 | ||
|
|
3d1a177632 | ||
|
|
0675a213b5 | ||
|
|
a8ff383490 | ||
|
|
960d815f68 | ||
|
|
edf2b4e93f | ||
|
|
fe240542a4 | ||
|
|
c7752c0657 | ||
|
|
d1e2b1c75a | ||
|
|
bcdab66bf8 | ||
|
|
7636f40030 | ||
|
|
e643bd3620 | ||
|
|
311c7756d7 | ||
|
|
f967a2e596 | ||
|
|
4c4b253a71 | ||
|
|
0f5f8c0d90 | ||
|
|
37a7fc05d5 | ||
|
|
bf93d87b36 | ||
|
|
efb3dc7294 | ||
|
|
42bd7807b2 | ||
|
|
eea59bd202 | ||
|
|
7248eb733f | ||
|
|
fceb6a4a89 | ||
|
|
b10eca09a8 | ||
|
|
4799b65e96 | ||
|
|
067eb9d6a9 | ||
|
|
219d5ecdcf | ||
|
|
9073182d51 | ||
|
|
bdb5783e79 | ||
|
|
ece717d6e0 | ||
|
|
b135ef695c | ||
|
|
82b3353110 | ||
|
|
3f165a85e3 | ||
|
|
aa4018909f | ||
|
|
98397e3ccd | ||
|
|
911e7112c9 | ||
|
|
e62402ecfc | ||
|
|
9190dd726d | ||
|
|
ae093283d0 | ||
|
|
875327fbea | ||
|
|
3d5c34f4ce | ||
|
|
58c2a20532 | ||
|
|
6c90cb5024 | ||
|
|
7e37570587 | ||
|
|
87d225a840 | ||
|
|
7b0de27c80 | ||
|
|
564fc76195 | ||
|
|
2ed4f40c12 | ||
|
|
d67a023e21 | ||
|
|
c99d117d1c | ||
|
|
a497a6ba0a | ||
|
|
160cd08cc7 | ||
|
|
120151c40c | ||
|
|
9dc16f29b3 | ||
|
|
964fc5644a | ||
|
|
2f907fc68f | ||
|
|
fe6cadc2cd | ||
|
|
338c8e5a80 | ||
|
|
e6f3a1a39d | ||
|
|
a168faeb69 | ||
|
|
b1628c59b5 | ||
|
|
32a2f5db9a | ||
|
|
818a48f74d | ||
|
|
bed377d05f | ||
|
|
709a616cfa | ||
|
|
860e2d877c | ||
|
|
5c6b2f88b9 | ||
|
|
f151a0e872 | ||
|
|
4a84bbb410 | ||
|
|
fa3a50e323 | ||
|
|
398152358c | ||
|
|
34ae9046f3 | ||
|
|
a478689587 | ||
|
|
9dbc0607dc | ||
|
|
7455efdd53 | ||
|
|
d0aff6141f | ||
|
|
aed0c4f82a | ||
|
|
74d4276c1a | ||
|
|
1e98130aa1 | ||
|
|
52e9b510da | ||
|
|
ece197eb6b | ||
|
|
d14e112bff | ||
|
|
83884f04a5 | ||
|
|
977de21e86 | ||
|
|
462a60a8f8 | ||
|
|
9aa4371ef4 | ||
|
|
f0feddd83f | ||
|
|
0748cab125 | ||
|
|
27885491ee | ||
|
|
a36bdbf907 | ||
|
|
d3e8bb94ae | ||
|
|
645595ee43 | ||
|
|
4d82bc5609 | ||
|
|
fdf1e555d8 | ||
|
|
90c694cbba | ||
|
|
3262fa7b37 | ||
|
|
ab43fe567f | ||
|
|
b4c10f9f8a | ||
|
|
f4c6e7cfab | ||
|
|
72d1e94cb3 | ||
|
|
82d221a48d | ||
|
|
3fe46771b9 | ||
|
|
a1c487aa21 | ||
|
|
cf455608e2 | ||
|
|
5dac12dd41 | ||
|
|
2389b46e0d | ||
|
|
6fe2d22d0e | ||
|
|
0b439149e4 | ||
|
|
a9d7da8af7 | ||
|
|
3ecc21a45e | ||
|
|
aa19e85cdc | ||
|
|
26c650227d | ||
|
|
face99ccde | ||
|
|
49bcc525ad | ||
|
|
533563c893 | ||
|
|
cfe527307e | ||
|
|
1e36c6706d | ||
|
|
6e40b114fc | ||
|
|
77acf1385d | ||
|
|
cec7edd2d5 | ||
|
|
9dbbdb3121 | ||
|
|
79d2602648 | ||
|
|
b0363a4f4c | ||
|
|
17045b2018 | ||
|
|
c49cc11862 | ||
|
|
c83fe7d776 | ||
|
|
235b4c7405 | ||
|
|
c2c0fbd33a | ||
|
|
300e3f49e0 | ||
|
|
a95a77886b | ||
|
|
1f3f683202 | ||
|
|
4c67fd23c4 | ||
|
|
93d8d71e34 | ||
|
|
47bda15ff2 | ||
|
|
4563488b5d | ||
|
|
8fd35f3fea | ||
|
|
9c61c69a7b | ||
|
|
030ad4621e | ||
|
|
ee0b9b8edc | ||
|
|
c6fa8da6df | ||
|
|
dd9dedc226 | ||
|
|
5638a91240 | ||
|
|
cdefbdebee | ||
|
|
923abd3655 | ||
|
|
ef28540f9a | ||
|
|
d54e4cdd4e | ||
|
|
4a00963602 | ||
|
|
7ea9b890c8 | ||
|
|
f866120a65 | ||
|
|
472559276c | ||
|
|
2cdb13b3ef | ||
|
|
a41124c5e0 | ||
|
|
6ecffa81ae | ||
|
|
ea7b102c07 | ||
|
|
e9daf92830 | ||
|
|
9e4ad76e7f | ||
|
|
0fd65db563 | ||
|
|
146fbb84b9 | ||
|
|
b51c18fedb | ||
|
|
a5305e6b56 | ||
|
|
aa88ef2e5c | ||
|
|
ee6b3c9ac8 | ||
|
|
79fd86acc5 | ||
|
|
c2cbeddcb8 | ||
|
|
62b2de59d4 | ||
|
|
2a760593db | ||
|
|
9288873ce8 | ||
|
|
47014be8e3 | ||
|
|
49719f7a84 | ||
|
|
b3b64a3b74 | ||
|
|
3a56c12df4 | ||
|
|
5b15a4f235 | ||
|
|
83303017c3 | ||
|
|
e0739a5883 | ||
|
|
0a5a841cc8 | ||
|
|
3309835f06 | ||
|
|
32b221f944 | ||
|
|
e8a99badb8 | ||
|
|
1ea6e01963 | ||
|
|
958dec5dfe | ||
|
|
09257fbfb2 | ||
|
|
61e3497a13 | ||
|
|
a7b4e5606d | ||
|
|
34d77c9db1 | ||
|
|
4f1efd28a6 | ||
|
|
07a45fde0e | ||
|
|
9d0380eef3 | ||
|
|
ce3ec36b0a | ||
|
|
b62a9d19b5 | ||
|
|
c1472079c5 | ||
|
|
dd36971798 | ||
|
|
6199c1c555 | ||
|
|
f41fdc12af | ||
|
|
fc419ffa4d | ||
|
|
b7c102e876 | ||
|
|
873fc36e61 | ||
|
|
29743f0b71 | ||
|
|
d904ca2bbf | ||
|
|
80b22724de | ||
|
|
80e49f4459 | ||
|
|
b3d5ead508 | ||
|
|
77dcb8c964 | ||
|
|
3488da9dfd | ||
|
|
0e68a1fdfd | ||
|
|
8797b2bd17 | ||
|
|
5ef0b31573 | ||
|
|
86e5e0fb50 | ||
|
|
c2d589dd29 | ||
|
|
4b807d8134 | ||
|
|
ccdabb707f | ||
|
|
364bc87fd3 | ||
|
|
cc02902cbb | ||
|
|
0e838b9406 | ||
|
|
37ebf7d8e2 | ||
|
|
e23af24bdd | ||
|
|
90eb53cf19 | ||
|
|
7e516d7630 | ||
|
|
0861e41e70 | ||
|
|
057e7e2572 | ||
|
|
5a4e237a29 | ||
|
|
c7501c70ae | ||
|
|
6a45c1abad | ||
|
|
660d339e14 | ||
|
|
3dca08595d | ||
|
|
7c501c4e4f | ||
|
|
b9316bb668 | ||
|
|
5e1d5b737c | ||
|
|
bd4d0c2708 | ||
|
|
1d04ad6306 | ||
|
|
a4fa5f6f59 | ||
|
|
7e973d568a | ||
|
|
d81c52209b | ||
|
|
72744b3082 | ||
|
|
ff4efe2712 | ||
|
|
2982b90469 | ||
|
|
428a5df038 | ||
|
|
f79bf784b5 | ||
|
|
3b7449ac08 | ||
|
|
37bbfdb7ae | ||
|
|
eb570d2d09 | ||
|
|
c8c2a10b2d | ||
|
|
92c79eb2fb | ||
|
|
e70b45498d | ||
|
|
2e1ecfa731 | ||
|
|
8d323a7762 | ||
|
|
8aa0ff936a | ||
|
|
c6806d60c7 | ||
|
|
a13eb25ebc | ||
|
|
228d8a0686 | ||
|
|
0de1bc7ce3 | ||
|
|
6a94cad04b | ||
|
|
8643d25cc8 | ||
|
|
e820f11dda | ||
|
|
3555ab2b71 | ||
|
|
6e41d58131 | ||
|
|
6cf3beae1c | ||
|
|
022b76e784 | ||
|
|
2fbe88f806 | ||
|
|
9457bc32ca | ||
|
|
3de2639ae9 | ||
|
|
c43e549224 | ||
|
|
fc6fe5007b | ||
|
|
829febf33b | ||
|
|
ae99ba14b9 | ||
|
|
a158dc3a17 | ||
|
|
8420550990 | ||
|
|
156667cdbd | ||
|
|
7d388a8c91 | ||
|
|
d32a2fceae | ||
|
|
d690eb86bb | ||
|
|
d91271cce1 | ||
|
|
1e74a2da85 | ||
|
|
bc8837b961 | ||
|
|
78fdad8404 | ||
|
|
2e8a72d445 | ||
|
|
ce1ae32772 | ||
|
|
b51455d324 | ||
|
|
28a305d9be | ||
|
|
e23bf4500d | ||
|
|
d0457fe5c3 | ||
|
|
766d1e1c5a | ||
|
|
44d460cd01 | ||
|
|
adf934c871 | ||
|
|
0a963486e2 | ||
|
|
227d1704f2 | ||
|
|
2baef92988 | ||
|
|
2a10da128d | ||
|
|
62a51c9fc7 | ||
|
|
58977b173b | ||
|
|
b5e5dcf6de | ||
|
|
157e1b74b8 | ||
|
|
b3a475c60f | ||
|
|
3d62871db1 | ||
|
|
4f0368b95f | ||
|
|
0385093111 | ||
|
|
463cb50c27 | ||
|
|
a50174a0a1 | ||
|
|
21cbe7487e | ||
|
|
6e4b34126e | ||
|
|
fd3a1a44ef | ||
|
|
66e6021dc0 | ||
|
|
57fdba70e0 | ||
|
|
676c11f814 | ||
|
|
d8442a290b | ||
|
|
0db333353b | ||
|
|
7ea8120723 | ||
|
|
64a8f41d03 | ||
|
|
8e12ded94b | ||
|
|
564ce1ac2d | ||
|
|
b024817eb5 | ||
|
|
24f98630fd | ||
|
|
c1b024cf53 | ||
|
|
dbacbf140b | ||
|
|
eda9e57583 | ||
|
|
0a9af5075c | ||
|
|
f92d9d1fd2 | ||
|
|
15168539d8 | ||
|
|
0d74217a4c | ||
|
|
9721527324 | ||
|
|
fd4d024bfc | ||
|
|
c994c8f05b | ||
|
|
4c00a796eb | ||
|
|
2d3a77af76 | ||
|
|
ef05d69889 | ||
|
|
9b1d0f7a5b | ||
|
|
07abea4c16 | ||
|
|
8733961026 | ||
|
|
dfa8776b2b | ||
|
|
1b9f15cb67 | ||
|
|
b683615269 | ||
|
|
3dc97f17ae | ||
|
|
6d2963ffa7 | ||
|
|
d1ec8c0781 | ||
|
|
8b205e9c08 | ||
|
|
e2792cd453 | ||
|
|
a60ca2f6f5 | ||
|
|
14a2ead79f | ||
|
|
b625a6b4d4 | ||
|
|
fd12aa0a8d | ||
|
|
8871267b91 | ||
|
|
e3001ba4a5 | ||
|
|
1133b76a7e |
@@ -6,5 +6,5 @@
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
"ignore": ["@nhost-examples/sveltekit"]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ runs:
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 8.6.0
|
||||
version: 8.6.2
|
||||
run_install: false
|
||||
- name: Get pnpm cache directory
|
||||
id: pnpm-cache-dir
|
||||
@@ -26,10 +26,10 @@ runs:
|
||||
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-node-
|
||||
- name: Use Node.js 16
|
||||
- name: Use Node.js v18
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
- shell: bash
|
||||
name: Install packages
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
3
.github/workflows/changesets.yaml
vendored
3
.github/workflows/changesets.yaml
vendored
@@ -10,6 +10,7 @@ on:
|
||||
- '**.md'
|
||||
- '!.changeset/**'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
@@ -99,7 +100,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push to Docker Hub
|
||||
uses: docker/build-push-action@v4
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
with:
|
||||
context: .
|
||||
file: ./dashboard/Dockerfile
|
||||
|
||||
3
.github/workflows/ci.yaml
vendored
3
.github/workflows/ci.yaml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
BUILD: 'all'
|
||||
- name: Check if the pnpm lockfile changed
|
||||
id: changed-lockfile
|
||||
uses: tj-actions/changed-files@v36
|
||||
uses: tj-actions/changed-files@v37
|
||||
with:
|
||||
files: pnpm-lock.yaml
|
||||
# * Determine a pnpm filter argument for packages that have been modified.
|
||||
@@ -146,6 +146,7 @@ jobs:
|
||||
run: echo "NHOST_TEST_DASHBOARD_URL=https://${{ steps.fetch-dashboard-preview-url.outputs.preview_url }}" >> $GITHUB_ENV
|
||||
# * Run the `ci` script of the current package of the matrix. Dependencies build is cached by Turborepo
|
||||
- name: Run e2e tests
|
||||
timeout-minutes: 15
|
||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e
|
||||
- id: file-name
|
||||
if: ${{ failure() }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -19,10 +19,8 @@ logs/
|
||||
coverage/
|
||||
dist/
|
||||
umd/
|
||||
lib/
|
||||
node_modules/
|
||||
tmp/
|
||||
.docz/
|
||||
.pnpm-store
|
||||
.turbo
|
||||
.env
|
||||
@@ -32,7 +30,6 @@ out/
|
||||
# Custom
|
||||
*.min.js
|
||||
*.map
|
||||
todo.md
|
||||
|
||||
# Config files that are not part of the repository root anymore. Should be removed in the future.
|
||||
/.eslintignore
|
||||
@@ -62,3 +59,7 @@ todo.md
|
||||
|
||||
# Nhost CLI data
|
||||
.nhost
|
||||
|
||||
# Nix
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
103
DEVELOPERS.md
103
DEVELOPERS.md
@@ -1,16 +1,37 @@
|
||||
# Developer guide
|
||||
# Developer Guide
|
||||
|
||||
## Requirements
|
||||
|
||||
- This repository works with **Node 16**
|
||||
### Node.js v18
|
||||
|
||||
- We use [pnpm](https://pnpm.io/) as a package manager to speed up development and builds, and as a basis for our monorepo. You need to make sure it's installed on your machine. There are [several ways to install it](https://pnpm.io/installation), but the easiest way is with `npm`:
|
||||
_⚠️ Node.js v16 is also supported for the time being but support will be dropped in the near future_.
|
||||
|
||||
### [pnpm](https://pnpm.io/) package manager
|
||||
|
||||
The easiest way to install `pnpm` if it's not installed on your machine yet is to use `npm`:
|
||||
|
||||
```sh
|
||||
$ npm install -g pnpm
|
||||
```
|
||||
|
||||
- Our tests and examples use the Nhost CLI, to run the backend services locally. You can follow the installation instructions in [our documentation](https://docs.nhost.io/get-started/cli-workflow/install-cli).
|
||||
### [Nhost CLI](https://docs.nhost.io/cli)
|
||||
|
||||
- The CLI is primarily used for running the E2E tests
|
||||
- Please refer to the [installation guide](https://docs.nhost.io/get-started/cli-workflow/install-cli) if you have not installed it yet
|
||||
|
||||
## File Structure
|
||||
|
||||
The repository is organized as a monorepo, with the following structure (only relevant folders are shown):
|
||||
|
||||
```
|
||||
assets/ # Assets used in the README
|
||||
config/ # Configuration files for the monorepo
|
||||
dashboard/ # Dashboard
|
||||
docs/ # Documentation website
|
||||
examples/ # Example projects
|
||||
packages/ # Core packages
|
||||
integrations/ # These are packages that rely on the core packages
|
||||
```
|
||||
|
||||
## Get started
|
||||
|
||||
@@ -31,25 +52,25 @@ $ pnpm install
|
||||
|
||||
### Development
|
||||
|
||||
Although package references are correctly updated on the fly for TypeScript, example projects won't
|
||||
see the changes because they are depending on the build output. To fix this, you can run packages
|
||||
in development mode.
|
||||
Although package references are correctly updated on the fly for TypeScript, example projects and the dashboard won't see the changes because they are depending on the build output. To fix this, you can run packages in development mode.
|
||||
|
||||
Running packages in development mode is as simple as:
|
||||
Running packages in development mode from the root folder is as simple as:
|
||||
|
||||
```sh
|
||||
$ pnpm dev
|
||||
```
|
||||
|
||||
Our packages are linked together using [PNPM's workspace](https://pnpm.io/workspaces) feature. Vite automatically detects changes in the dependencies and rebuilds everything, so that the changes are immediately reflected in the other packages.
|
||||
Our packages are linked together using [PNPM's workspace](https://pnpm.io/workspaces) feature. Next.js and Vite automatically detect changes in the dependencies and rebuild everything, so the changes will be reflected in the examples and the dashboard.
|
||||
|
||||
### Use examples
|
||||
**Note:** It's possible that Next.js or Vite throw an error when you run `pnpm dev`. Restarting the process should fix it.
|
||||
|
||||
### Use Examples
|
||||
|
||||
Examples are a great way to test your changes in practice. Make sure you've `pnpm dev` running in your terminal and then run an example.
|
||||
|
||||
Let's follow the instructions to run [react-apollo example](https://github.com/nhost/nhost/blob/main/examples/react-apollo/README.md).
|
||||
|
||||
## Run the documentation website locally
|
||||
## Edit Documentation
|
||||
|
||||
The easier way to contribute to our documentation is to go to the `docs` folder and follow the [instructions to start local development](https://github.com/nhost/nhost/blob/main/docs/README.md):
|
||||
|
||||
@@ -60,9 +81,9 @@ $ pnpm install
|
||||
$ pnpm start
|
||||
```
|
||||
|
||||
## Run test suites
|
||||
## Run Test Suites
|
||||
|
||||
### Unit tests
|
||||
### Unit Tests
|
||||
|
||||
You can run the unit tests with the following command from the repository root:
|
||||
|
||||
@@ -70,7 +91,7 @@ You can run the unit tests with the following command from the repository root:
|
||||
$ pnpm test
|
||||
```
|
||||
|
||||
### End-to-end tests
|
||||
### E2E Tests
|
||||
|
||||
Each package that defines end-to-end tests embeds their own Nhost configuration, that will be automatically when running the tests. As a result, you must make sure you are not running the Nhost CLI before running the tests.
|
||||
|
||||
@@ -83,24 +104,60 @@ $ pnpm e2e
|
||||
## Changesets
|
||||
|
||||
If you've made changes to the packages, you must describe those changes so that they can be reflected in the next release.
|
||||
We use [changesets](https://github.com/changesets/changesets) to support our versioning and release workflows. When you submit a pull request, a bot checks if some changesets are present, and if not, it directs you to add them.
|
||||
We use [changesets](https://github.com/changesets/changesets) to support our versioning and release workflows. When you submit a pull request, a bot checks if changesets are present, and if not, it asks you to add them.
|
||||
|
||||
The most comprehensive way to add a changeset is to run the following command in the repository root:
|
||||
To create a changeset, run the following command from the repository root:
|
||||
|
||||
```sh
|
||||
$ pnpm changeset
|
||||
```
|
||||
|
||||
This will create a file in the `.changeset` directory. You can edit it to give more details about the change you just made.
|
||||
This command will guide you through the process of creating a changeset. It will create a file in the `.changeset` directory.
|
||||
|
||||
You can take a look at the changeset documentation: [How to add a changeset](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).
|
||||
|
||||
## Committing changes
|
||||
### Selecting the Version
|
||||
|
||||
You'll notice that `git commit` takes a few seconds to run. We set a commit hook that scans the changes in the code, automatically generates documentation from the inline [TSDoc](https://tsdoc.org/) annotations, and adds these generated documentation files to the commit. They automatically update the [reference documentation](https://docs.nhost.io/reference).
|
||||
When you create a changeset, you will be asked to select the version of the package that you are bumping. The versioning scheme is as follows:
|
||||
|
||||
- **major**
|
||||
- For breaking changes (e.g: changing the function signature, etc.)
|
||||
- Should be avoided as much as possible as it will require users to update their code. Instead, consider supporting both the old and the new API simultaneously for a while.
|
||||
- For example: `v1.5.8` -> `v2.0.0`
|
||||
- **minor**
|
||||
- For new features (e.g: adding a new page to the dashboard, etc.)
|
||||
- For example: `v1.5.8` -> `v1.6.0`
|
||||
- **patch**
|
||||
- For bug fixes (e.g: fixing a typo, etc.)
|
||||
- For example: `v1.5.8` -> `v1.5.9`
|
||||
|
||||
<!-- ## Good practices
|
||||
- lint
|
||||
- prettier
|
||||
- documentation -->
|
||||
### Writing Good Changesets
|
||||
|
||||
A concise summary that describes the changes should be added to each PR. This summary will be used as the changeset description.
|
||||
|
||||
The following structure is used for describing changes:
|
||||
|
||||
- **The type of the change**:
|
||||
|
||||
- fix
|
||||
- feat
|
||||
- chore
|
||||
- docs
|
||||
|
||||
- **The scope of the change** (_broader scopes (e.g: dashboard, hasura-storage-js, etc.) are not recommended as GitHub Releases already contain which project is being bumped_):
|
||||
|
||||
- projects
|
||||
- deployments
|
||||
- deps
|
||||
- etc.
|
||||
|
||||
- **A short summary of the changes that were made**
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `fix(deployments): use correct timestamp for deployment details`
|
||||
- `chore(deps): bump @types/react to v18.2.8`
|
||||
- `feat(secrets): enable secrets`
|
||||
- etc.
|
||||
|
||||
You can always take a look at examples of changesets in the [GitHub Releases section](https://github.com/nhost/nhost/releases).
|
||||
|
||||
@@ -34,7 +34,7 @@ Nhost consists of open source software:
|
||||
- Authentication: [Hasura Auth](https://github.com/nhost/hasura-auth/)
|
||||
- Storage: [Hasura Storage](https://github.com/nhost/hasura-storage)
|
||||
- Serverless Functions: Node.js (JavaScript and TypeScript)
|
||||
- [Nhost CLI](https://docs.nhost.io/reference/cli) for local development
|
||||
- [Nhost CLI](https://docs.nhost.io/cli) for local development
|
||||
|
||||
## Architecture of Nhost
|
||||
|
||||
@@ -97,7 +97,7 @@ Nhost is frontend agnostic, which means Nhost works with all frontend frameworks
|
||||
|
||||
# Resources
|
||||
|
||||
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/reference/cli)
|
||||
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/cli)
|
||||
|
||||
## Nhost Clients
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import baseLibConfig from './vite.lib.config'
|
||||
export default defineConfig({
|
||||
...baseLibConfig,
|
||||
optimizeDeps: {
|
||||
include: ['react/jsx-runtime']
|
||||
include: ['react/jsx-runtime'],
|
||||
exclude: ['react-hook-form']
|
||||
},
|
||||
plugins: [react({ jsxRuntime: 'classic' }), ...baseLibConfig.plugins]
|
||||
})
|
||||
|
||||
@@ -2,13 +2,14 @@ import '@fontsource/inter';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/700.css';
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import { NhostClient, NhostProvider } from '@nhost/nextjs';
|
||||
import { NhostApolloProvider } from '@nhost/react-apollo';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Buffer } from 'buffer';
|
||||
import { initialize, mswDecorator } from 'msw-storybook-addon';
|
||||
import { RouterContext } from 'next/dist/shared/lib/router-context';
|
||||
import { createTheme } from '../src/components/ui/v2/createTheme';
|
||||
import '../src/styles/globals.css';
|
||||
import createTheme from '../src/theme/createTheme';
|
||||
|
||||
global.Buffer = Buffer;
|
||||
|
||||
@@ -56,5 +57,10 @@ export const decorators = [
|
||||
<Story />
|
||||
</NhostApolloProvider>
|
||||
),
|
||||
(Story) => (
|
||||
<NhostProvider nhost={new NhostClient({ subdomain: 'local' })}>
|
||||
<Story />
|
||||
</NhostProvider>
|
||||
),
|
||||
mswDecorator,
|
||||
];
|
||||
|
||||
@@ -1,5 +1,242 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.20.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5e1e80aa8: fix(dashboard): show correct locales in user details
|
||||
- @nhost/react-apollo@5.0.35
|
||||
- @nhost/nextjs@1.13.37
|
||||
|
||||
## 0.20.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.34
|
||||
- @nhost/nextjs@1.13.36
|
||||
|
||||
## 0.20.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4a7ede11e: fix: distinguish files that were not uploaded
|
||||
- 202b64723: feat(nhost-run): add support for one-click-install run services
|
||||
- 074a0fa11: feat(dashboard): add settings toggle to enable/disable antivirus
|
||||
- @nhost/react-apollo@5.0.33
|
||||
- @nhost/nextjs@1.13.35
|
||||
|
||||
## 0.20.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b20761e97: feat(services): add pricing info and confirmation dialog
|
||||
- 90df6d81d: fix(services): handle null values when editing a service
|
||||
- aa8508467: fix: query service logs correctly
|
||||
feat: enable multiline support for environment value input
|
||||
|
||||
## 0.20.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8d7f84b8d: fix: make announcement adapt to theme
|
||||
|
||||
## 0.20.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3b75bfce2: fix: make announcement close properly
|
||||
- f49819075: fix: show correct values when dedicated resources are disabled
|
||||
|
||||
## 0.20.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e643bd362: fix(services): fix errors when config is null
|
||||
- bcdab66bf: feat: add annoucement for nhost run
|
||||
- f967a2e59: added note about storage not being able to be downsized
|
||||
- 311c7756d: chore(services): consistent naming for compute
|
||||
|
||||
## 0.20.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9073182d5: chore(dashboard): bump `turbo` to 1.10.11
|
||||
- ece717d6e: feat(logs): show services in the logs page
|
||||
- 82b335311: feat(metrics): change grafana link to point to the dashboards
|
||||
- b135ef695: fix(services): set command as optional and set min replicas to 0
|
||||
|
||||
## 0.20.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3d5c34f4c: fix(auth): fix users pagination limit
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c99d117d1: feat(services): add support for custom services
|
||||
|
||||
## 0.19.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- face99ccd: chore(deps): bump turbo version
|
||||
- cfe527307: style: tweak pull config warning in dark mode
|
||||
- a9d7da8af: chore(deps): update dependency @types/pluralize to ^0.0.30
|
||||
- 9aa4371ef: chore: add hasura-auth version 0.21.2
|
||||
- d14e112bf: chore(deps): update dependency prettier-plugin-tailwindcss to ^0.4.0
|
||||
- d3e8bb94a: chore(deps): update dependency vite-plugin-dts to v3
|
||||
|
||||
## 0.19.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.32
|
||||
- @nhost/nextjs@1.13.34
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9c61c69a7: chore(dashboard):add postgres 14.6-20230705-1 to the version selector
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 47bda15ff: feat(settings): add warning to pull config
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ee0b9b8ed: chore(dashboard):add hasura v2.28.2 and v2.29.0 to the version selector
|
||||
|
||||
## 0.17.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.31
|
||||
- @nhost/nextjs@1.13.33
|
||||
|
||||
## 0.17.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f866120a6: fix(users): use the password length from the config
|
||||
|
||||
## 0.17.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.30
|
||||
- @nhost/nextjs@1.13.32
|
||||
|
||||
## 0.17.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ea7b102c0: fix(pat): highlight expired tokens
|
||||
|
||||
## 0.17.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b3b64a3b7: chore(deps): bump `@types/react` to `v18.2.14` and `@types/react-dom` to `v18.2.6`
|
||||
- 32b221f94: chore(deps): bump `graphiql` to `v3`
|
||||
- 3a56c12df: chore(deps): bump `turbo` to `v1.10.6`
|
||||
- Updated dependencies [b3b64a3b7]
|
||||
- @nhost/react-apollo@5.0.29
|
||||
- @nhost/nextjs@1.13.31
|
||||
|
||||
## 0.17.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f41fdc12a: chore(deps): bump `turbo` to `1.10.5`
|
||||
- 6199c1c55: fix(projects): don't redirect to 404 page
|
||||
- Updated dependencies [07a45fde0]
|
||||
- @nhost/react-apollo@5.0.28
|
||||
- @nhost/nextjs@1.13.30
|
||||
|
||||
## 0.17.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 80b22724d: chore(deps): bump `@types/react` to `v18.2.13`, `@types/react-dom` to `v18.2.6` and `@storybook/testing-library` to `v0.2.0`
|
||||
|
||||
## 0.17.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cc02902cb: chore(docs): update environment variable documentation
|
||||
|
||||
## 0.17.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 660d339e1: fix(storybook): don't break storybook
|
||||
- 660d339e1: fix(tests): prevent warnings during tests
|
||||
- @nhost/react-apollo@5.0.27
|
||||
- @nhost/nextjs@1.13.29
|
||||
|
||||
## 0.17.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bd4d0c270: chore(dashboard):add postgres 14.6-20230613-1 to the version selector
|
||||
|
||||
## 0.17.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c8c2a10b2: fix(database): don't break the password reset flow
|
||||
- e70b45498: chore(deps): bump `@types/react` to `v18.2.12` and `@types/react-dom` to `v18.2.5`
|
||||
|
||||
## 0.17.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 842055099: chore(deps): bump `turbo` to `v1.10.3` and `pnpm` to `v8.6.2`
|
||||
- fd12aa0a8: chore(projects): remove the postgres password input from the project creation screen
|
||||
- 022b76e78: chore(deps): bump `@types/react` to `v18.2.11`
|
||||
- 3555ab2b7: chore(deps): bump `vitest` monorepo to `v0.32.0`
|
||||
- c43e54922: feat(backups): add download button to backups
|
||||
|
||||
## 0.17.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d0457fe5c: feat(settings): improve the dashboard and config parity
|
||||
- @nhost/react-apollo@5.0.26
|
||||
- @nhost/nextjs@1.13.28
|
||||
|
||||
## 0.17.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4f0368b95: fix(account): don't break account settings page
|
||||
|
||||
## 0.17.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 64a8f41d0: chore(resources): lower the maximum allowed resources per service
|
||||
|
||||
## 0.17.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.25
|
||||
- @nhost/nextjs@1.13.27
|
||||
|
||||
## 0.17.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9b1d0f7a5: fix(deployments): use correct timestamp for deployment details
|
||||
- 6d2963ffa: chore(deps): bump `@types/react` to `v18.2.8`
|
||||
- 8871267b9: chore(deps): downgrade `pnpm` to `v8.5.1` because of no Turborepo support
|
||||
|
||||
## 0.17.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -3,7 +3,7 @@ RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn global add turbo@1.10.1
|
||||
RUN yarn global add turbo@1.10.11
|
||||
COPY . .
|
||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||
|
||||
@@ -29,7 +29,7 @@ ENV NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL __NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL_
|
||||
ENV NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL __NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__
|
||||
ENV NEXT_PUBLIC_NHOST_HASURA_API_URL __NEXT_PUBLIC_NHOST_HASURA_API_URL__
|
||||
|
||||
RUN yarn global add pnpm@8.6.0
|
||||
RUN yarn global add pnpm@8.6.2
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-*.yaml .
|
||||
|
||||
@@ -3,10 +3,26 @@
|
||||
This is the Nhost Dashboard, a web application that allows you to manage your Nhost projects.
|
||||
To get started, you need to have an Nhost project. If you don't have one, you can [create a project here](https://app.nhost.io).
|
||||
|
||||
First, install the dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Then, build the packages that are used by the Nhost Dashboard:
|
||||
|
||||
```bash
|
||||
pnpm -w build
|
||||
```
|
||||
|
||||
Finally, run the development server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) to see the result in your browser.
|
||||
|
||||
## Environment
|
||||
|
||||
### Setup Environment Variables
|
||||
@@ -54,6 +70,12 @@ Components are documented using [Storybook](https://storybook.js.org/). To run S
|
||||
pnpm storybook
|
||||
```
|
||||
|
||||
By default, Storybook will run on port `6006`. You can change this by passing the `--port` flag:
|
||||
|
||||
```bash
|
||||
pnpm storybook --port 6007
|
||||
```
|
||||
|
||||
### General Environment Variables
|
||||
|
||||
| Name | Description |
|
||||
@@ -110,15 +132,19 @@ pnpm storybook
|
||||
| `@typescript-eslint/naming-convention` | Enforces a consistent naming convention. |
|
||||
| `no-restricted-imports` | Enforces absolute imports and consistent import paths for components from `src/components/ui` folder. |
|
||||
|
||||
### End-to-End Tests
|
||||
### Unit Tests
|
||||
|
||||
End-to-end tests are written using [Playwright](https://playwright.dev/). To run the tests, run the following command:
|
||||
Unit tests are written using [Vitest](https://vitest.dev/). To run the tests, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm e2e
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Most of the tests require access to the Nhost test user. To run these tests, you need to set the following environment variables in `.env.test`:
|
||||
### End-to-End Tests
|
||||
|
||||
Most of the end-to-end tests require access to an Nhost test user and a live project. You can register a user and create a test project on the [Nhost Dashboard](https://app.nhost.io/).
|
||||
|
||||
Next, you need to create a project. Create a `.env.test` file with the following variables:
|
||||
|
||||
```
|
||||
NHOST_TEST_DASHBOARD_URL=<test_dashboard_url>
|
||||
@@ -128,3 +154,20 @@ NHOST_TEST_WORKSPACE_NAME=<test_workspace_name>
|
||||
NHOST_TEST_PROJECT_NAME=<test_project_name>
|
||||
NHOST_TEST_PROJECT_ADMIN_SECRET=<test_project_admin_secret>
|
||||
```
|
||||
|
||||
**Required Variables**:
|
||||
|
||||
- `NHOST_TEST_DASHBOARD_URL`: The URL to run the tests against (e.g: http://localhost:3000 or https://staging.app.nhost.io)
|
||||
- `NHOST_TEST_USER_EMAIL`: Email address of the test user that owns the test project
|
||||
- `NHOST_TEST_USER_PASSWORD`: Password of the test user that owns the test project
|
||||
- `NHOST_TEST_WORKSPACE_NAME`: Name of the workspace that contains the test project
|
||||
- `NHOST_TEST_PROJECT_NAME`: Name of the test project
|
||||
- `NHOST_TEST_PROJECT_ADMIN_SECRET`: Admin secret of the test project
|
||||
|
||||
Make sure to copy the workspace and project information from the [Nhost Dashboard](https://app.nhost.io/).
|
||||
|
||||
End-to-end tests are written using [Playwright](https://playwright.dev/). To run the tests, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm e2e
|
||||
```
|
||||
|
||||
@@ -9,7 +9,7 @@ import { openProject } from '@/e2e/utils';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
async function globalTeardown() {
|
||||
const browser = await chromium.launch();
|
||||
const browser = await chromium.launch({ slowMo: 1000 });
|
||||
|
||||
const context = await browser.newContext({
|
||||
baseURL: TEST_DASHBOARD_URL,
|
||||
@@ -46,18 +46,23 @@ async function globalTeardown() {
|
||||
await hasuraPage.locator('a', { hasText: /data/i }).click();
|
||||
await hasuraPage.getByRole('link', { name: /sql/i }).click();
|
||||
|
||||
await hasuraPage.locator('#raw_sql > textarea').fill(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
FOR tablename IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
// Set the value of the Ace code editor using JavaScript evaluation in the browser context
|
||||
await hasuraPage.evaluate(() => {
|
||||
const editor = ace.edit('raw_sql');
|
||||
|
||||
editor.setValue(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
FOR tablename IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
});
|
||||
|
||||
await hasuraPage.getByRole('button', { name: /run!/i }).click();
|
||||
await hasuraPage.getByText(/sql executed!/i).waitFor();
|
||||
|
||||
5
dashboard/hypertune.graphql
Normal file
5
dashboard/hypertune.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
query InitQuery {
|
||||
root {
|
||||
enableServices
|
||||
}
|
||||
}
|
||||
5
dashboard/hypertune.json
Normal file
5
dashboard/hypertune.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projectId": 2596,
|
||||
"token": "U2FsdGVkX19+V8BJnVR0xLEC+42OW5qZl/A0i6beAaRmJoIhFh5Yf6eIKBzLbV9h",
|
||||
"outputDirectoryPath": "src/hypertune"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.17.3",
|
||||
"version": "0.20.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -11,11 +11,11 @@
|
||||
"lint": "next lint --max-warnings 0",
|
||||
"test": "vitest",
|
||||
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
|
||||
"nhost:dev": "nhost up",
|
||||
"format": "prettier --write \"src/**/*.{js,ts,tsx,jsx,json,md}\" --plugin-search-dir=.",
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook",
|
||||
"e2e": "npx playwright@1.34.0 install --with-deps && playwright test"
|
||||
"install-browsers": "pnpm dlx playwright@1.31.0 install --with-deps",
|
||||
"e2e": "pnpm install-browsers && pnpm dlx playwright@1.31.0 test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.10",
|
||||
@@ -26,7 +26,7 @@
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@fontsource/inter": "^5.0.0",
|
||||
"@fontsource/roboto-mono": "^5.0.0",
|
||||
"@graphiql/react": "^0.17.0",
|
||||
"@graphiql/react": "^0.18.0",
|
||||
"@graphiql/toolkit": "^0.8.2",
|
||||
"@headlessui/react": "^1.6.5",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
@@ -49,11 +49,12 @@
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^2.29.3",
|
||||
"generate-password": "^1.7.0",
|
||||
"graphiql": "^2.4.0",
|
||||
"graphiql": "^3.0.0",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^6.0.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-ws": "^5.11.2",
|
||||
"hypertune": "^1.4.4",
|
||||
"just-kebab-case": "^4.1.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"next": "^12.3.1",
|
||||
@@ -65,12 +66,14 @@
|
||||
"react-error-boundary": "^4.0.0",
|
||||
"react-hook-form": "^7.42.1",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-intersection-observer": "^9.5.2",
|
||||
"react-is": "18.2.0",
|
||||
"react-loading-skeleton": "^2.2.0",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
"react-syntax-highlighter": "^15.4.5",
|
||||
"react-table": "^7.8.0",
|
||||
"sharp": "^0.32.0",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.5",
|
||||
"stripe": "^10.17.0",
|
||||
"tailwind-merge": "^1.8.0",
|
||||
@@ -87,7 +90,7 @@
|
||||
"@graphql-codegen/typescript-operations": "^3.0.0",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.1",
|
||||
"@next/bundle-analyzer": "^12.3.1",
|
||||
"@playwright/test": "^1.34.0",
|
||||
"@playwright/test": "1.31.0",
|
||||
"@storybook/addon-actions": "^6.5.14",
|
||||
"@storybook/addon-essentials": "^6.5.14",
|
||||
"@storybook/addon-interactions": "^6.5.14",
|
||||
@@ -96,23 +99,27 @@
|
||||
"@storybook/builder-webpack5": "^6.5.14",
|
||||
"@storybook/manager-webpack5": "^6.5.14",
|
||||
"@storybook/react": "^6.5.14",
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@testing-library/dom": "^9.0.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/ace": "^0.0.48",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^16.11.7",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/react": "18.2.7",
|
||||
"@types/react-dom": "18.2.4",
|
||||
"@types/pluralize": "^0.0.30",
|
||||
"@types/react": "^18.2.14",
|
||||
"@types/react-dom": "^18.2.6",
|
||||
"@types/react-table": "^7.7.12",
|
||||
"@types/shell-quote": "^1.7.1",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/validator": "^13.7.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||
"@typescript-eslint/parser": "^5.43.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitest/coverage-c8": "^0.31.0",
|
||||
"@vitest/coverage-v8": "^0.32.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"babel-loader": "^8.3.0",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
@@ -136,7 +143,7 @@
|
||||
"postcss": "^8.4.19",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.0",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.4.0",
|
||||
"react-date-fns-hooks": "^0.9.4",
|
||||
"require-from-string": "^2.0.2",
|
||||
"snake-case": "^3.0.4",
|
||||
@@ -146,7 +153,7 @@
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||
"vite": "^4.0.2",
|
||||
"vite-tsconfig-paths": "^4.0.3",
|
||||
"vitest": "^0.31.0"
|
||||
"vitest": "^0.32.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
1
dashboard/public/assets/brands/bitbucket.svg
Normal file
1
dashboard/public/assets/brands/bitbucket.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#2684FF" fill-rule="evenodd" d="M3.41 4.393a.563.563 0 0 1 .434-.195l16.416.003a.562.562 0 0 1 .563.652l-2.388 14.66a.562.562 0 0 1-.563.472H6.417a.765.765 0 0 1-.748-.639L3.281 4.851a.562.562 0 0 1 .13-.458Zm6.832 10.282h3.656l.886-5.173H9.252l.99 5.173Z" clip-rule="evenodd"/><path fill="url(#a)" d="M20.063 9.502h-5.279l-.886 5.173h-3.656l-4.317 5.124a.762.762 0 0 0 .492.186h11.458a.562.562 0 0 0 .563-.473l1.625-10.01Z"/><defs><linearGradient id="a" x1="16.692" x2="10.594" y1="7.717" y2="16.375" gradientUnits="userSpaceOnUse"><stop offset=".18" stop-color="#0052CC"/><stop offset="1" stop-color="#2684FF"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 730 B |
1
dashboard/public/assets/brands/gitlab.svg
Normal file
1
dashboard/public/assets/brands/gitlab.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#E24329" fill-rule="evenodd" d="m12 19.996 3.223-9.917H8.777L12 19.995Z" clip-rule="evenodd"/><path fill="#FC6D26" fill-rule="evenodd" d="m12 19.996-3.223-9.917H4.261L12 19.996Z" clip-rule="evenodd"/><path fill="#FCA326" fill-rule="evenodd" d="m4.262 10.079-.98 3.013a.667.667 0 0 0 .243.746L12 19.996l-7.738-9.917Z" clip-rule="evenodd"/><path fill="#E24329" fill-rule="evenodd" d="M4.261 10.079h4.517L6.837 4.106a.333.333 0 0 0-.635 0l-1.94 5.973Z" clip-rule="evenodd"/><path fill="#FC6D26" fill-rule="evenodd" d="m12 19.996 3.222-9.917h4.516L12 19.996Z" clip-rule="evenodd"/><path fill="#FCA326" fill-rule="evenodd" d="m19.738 10.079.98 3.013a.667.667 0 0 1-.243.746L12 19.996l7.738-9.917Z" clip-rule="evenodd"/><path fill="#E24329" fill-rule="evenodd" d="M19.739 10.079h-4.517l1.941-5.973a.334.334 0 0 1 .635 0l1.94 5.973Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 941 B |
1
dashboard/public/assets/brands/strava.svg
Normal file
1
dashboard/public/assets/brands/strava.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#FE7203" d="M4.125 5.25c0-.621.504-1.125 1.125-1.125h13.5c.621 0 1.125.504 1.125 1.125v13.5c0 .621-.504 1.125-1.125 1.125H5.25a1.125 1.125 0 0 1-1.125-1.125V5.25Z"/><path fill="url(#a)" d="M4.125 5.25c0-.621.504-1.125 1.125-1.125h13.5c.621 0 1.125.504 1.125 1.125v13.5c0 .621-.504 1.125-1.125 1.125H5.25a1.125 1.125 0 0 1-1.125-1.125V5.25Z"/><path fill="#fff" fill-rule="evenodd" d="m10.917 12.787 2.461 4.43 2.363-4.43h-1.477l-.886 1.674-.984-1.674h-1.477Z" clip-rule="evenodd" opacity=".6"/><path fill="#fff" fill-rule="evenodd" d="m11.213 6.586 3.051 6.201H8.063l3.15-6.201Zm0 3.74 1.18 2.461h-2.46l1.28-2.46Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="12" x2="12" y1="4.125" y2="19.875" gradientUnits="userSpaceOnUse"><stop stop-color="#FB2F01" stop-opacity="0"/><stop offset="1" stop-color="#FB2F01"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 935 B |
@@ -1,25 +1 @@
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H64C68.4183 0 72 3.58172 72 8V64C72 68.4183 68.4183 72 64 72H8C3.58172 72 0 68.4183 0 64V8Z" fill="#1EB4D4"/>
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H64C68.4183 0 72 3.58172 72 8V64C72 68.4183 68.4183 72 64 72H8C3.58172 72 0 68.4183 0 64V8Z" fill="url(#paint0_linear_1_85)" fill-opacity="0.2"/>
|
||||
<g filter="url(#filter0_d_1_85)">
|
||||
<circle cx="36" cy="39" r="16" fill="#35BCD8"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.3994 17.2212C52.831 21.3475 52.831 26.737 51.7362 29.3896C51.1889 30.6948 51.1467 32.2106 51.5678 33.558C52.1152 35.2001 52.4099 36.9264 52.4099 38.7791C52.4099 48.0001 44.9573 55.3685 35.7783 55.2001C27.0625 55.0738 19.6941 47.6633 19.4836 38.9896C19.4415 37.0948 19.7362 35.2001 20.3257 33.5159C20.7889 32.1685 20.7889 30.6948 20.2415 29.3896C19.1889 26.7791 19.1467 21.3896 20.5783 17.1791C20.9152 16.4212 22.0941 16.6738 22.0941 17.4738V17.7685C22.3467 21.7685 23.8625 24.1685 26.052 24.9685C26.3889 25.137 26.8099 25.0948 27.1467 24.8843C29.7152 23.3264 32.7046 22.358 35.9467 22.358C39.1889 22.358 42.2204 23.2843 44.7467 24.8843C45.1257 25.137 45.6731 25.137 46.0099 24.9685C48.1573 23.9159 49.5889 21.7685 49.8415 17.8106V17.5159C49.8836 16.7159 51.0204 16.4633 51.3994 17.2212ZM36.1994 26.2738C29.1257 26.1054 23.3152 31.9159 23.4836 39.0317C23.5678 45.7264 29.0836 51.158 35.7362 51.3264C42.852 51.4106 48.6204 45.6422 48.4941 38.5685C48.3678 31.8738 42.8941 26.358 36.1994 26.2738ZM34.9362 32.8844L37.8836 37.4318L40.7468 41.9792C40.9152 42.2318 40.9994 42.5265 40.9994 42.8213C40.9994 43.3686 40.7047 43.8739 40.2415 44.1686C39.6941 44.5055 38.9783 44.5055 38.431 44.1265C38.2204 44.0002 38.052 43.8739 37.9678 43.6634L36.3257 41.3055C36.1994 41.0528 35.9047 41.0528 35.6941 41.2634L33.3783 43.9581C33.0836 44.2528 32.7047 44.4634 32.2415 44.4634C31.8626 44.4634 31.4415 44.3371 31.1468 44.0844C30.5152 43.495 30.4731 42.4844 31.0626 41.8528L34.1783 38.4423C34.3047 38.2318 34.3889 37.9371 34.2204 37.6844L32.2415 34.5686C32.0731 34.316 31.9889 34.0213 31.9889 33.7265C31.9889 33.1792 32.2836 32.6739 32.7468 32.3792C33.5047 31.916 34.4731 32.1265 34.9362 32.8844Z" fill="white"/>
|
||||
<defs>
|
||||
<filter id="filter0_d_1_85" x="17" y="23" width="38" height="41" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect1_dropShadow_1_85"/>
|
||||
<feOffset dy="6"/>
|
||||
<feGaussianBlur stdDeviation="3.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.231373 0 0 0 0 0.278431 0 0 0 0.25 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_85"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_85" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_1_85" x1="0" y1="0" x2="72" y2="72" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" fill="none"><path fill="#1EB4D4" d="M0 8a8 8 0 0 1 8-8h56a8 8 0 0 1 8 8v56a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8V8Z"/><path fill="url(#a)" fill-opacity=".2" d="M0 8a8 8 0 0 1 8-8h56a8 8 0 0 1 8 8v56a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8V8Z"/><g filter="url(#b)"><circle cx="36" cy="39" r="16" fill="#35BCD8"/></g><path fill="#fff" fill-rule="evenodd" d="M51.4 17.221c1.431 4.127 1.431 9.516.336 12.169-.547 1.305-.59 2.82-.168 4.168.547 1.642.842 3.368.842 5.221 0 9.221-7.453 16.59-16.632 16.421-8.715-.126-16.084-7.537-16.294-16.21-.043-1.895.252-3.79.842-5.474.463-1.347.463-2.821-.085-4.126-1.052-2.61-1.094-8 .337-12.21.337-.759 1.516-.506 1.516.294v.294c.253 4 1.768 6.4 3.958 7.2.337.169.758.127 1.095-.084 2.568-1.558 5.558-2.526 8.8-2.526 3.242 0 6.273.926 8.8 2.526.379.253.926.253 1.263.084 2.147-1.052 3.579-3.2 3.832-7.157v-.295c.042-.8 1.178-1.053 1.557-.295Zm-15.2 9.053c-7.074-.169-12.885 5.642-12.716 12.758.084 6.694 5.6 12.126 12.252 12.294 7.116.085 12.884-5.684 12.758-12.758-.126-6.694-5.6-12.21-12.295-12.294Zm-1.264 6.61 2.948 4.548 2.863 4.547c.168.253.252.547.252.842 0 .548-.294 1.053-.758 1.348a1.662 1.662 0 0 1-1.81-.042c-.21-.127-.379-.253-.463-.464l-1.642-2.357c-.127-.253-.421-.253-.632-.043l-2.316 2.695c-.294.295-.673.505-1.136.505-.38 0-.8-.126-1.095-.379a1.59 1.59 0 0 1-.084-2.231l3.115-3.41c.127-.211.21-.506.042-.759l-1.978-3.115a1.518 1.518 0 0 1-.253-.843c0-.547.295-1.052.758-1.347.758-.463 1.726-.252 2.19.505Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="0" x2="72" y1="0" y2="72" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><filter id="b" width="38" height="41" x="17" y="23" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feMorphology in="SourceAlpha" radius="4" result="effect1_dropShadow_1_85"/><feOffset dy="6"/><feGaussianBlur stdDeviation="3.5"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0.231373 0 0 0 0 0.278431 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_1_85"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_1_85" result="shape"/></filter></defs></svg>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { forwardRef, type ForwardedRef } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import AnnouncementContainer, {
|
||||
type AnnouncementContainerProps,
|
||||
} from './AnnouncementContainer';
|
||||
|
||||
export interface AnnouncementProps extends AnnouncementContainerProps {
|
||||
/**
|
||||
* Function called when the announcement is closed.
|
||||
*/
|
||||
onClose?: VoidFunction;
|
||||
/**
|
||||
* The href to use for the announcement link.
|
||||
*/
|
||||
href: string;
|
||||
}
|
||||
|
||||
function Announcement(
|
||||
{ children, slotProps, onClose, href, ...props }: AnnouncementProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
return (
|
||||
<AnnouncementContainer
|
||||
{...props}
|
||||
ref={ref}
|
||||
className="grid grid-flow-col justify-between gap-4"
|
||||
slotProps={{
|
||||
root: {
|
||||
...(slotProps?.root || {}),
|
||||
className: twMerge('w-full py-1.5', slotProps?.root?.className),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<span />
|
||||
|
||||
<div className="flex items-center self-center truncate">
|
||||
<a href={href}>
|
||||
<Text className="cursor-pointer truncate hover:underline">
|
||||
{children}
|
||||
</Text>
|
||||
</a>
|
||||
<ArrowRightIcon className="ml-1 h-4 w-4 text-white" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={onClose}
|
||||
aria-label="Close announcement"
|
||||
size="small"
|
||||
className="rounded-sm p-1"
|
||||
>
|
||||
<XIcon className="opacity-65 h-4 w-4" />
|
||||
</Button>
|
||||
</AnnouncementContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(Announcement);
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
createElement,
|
||||
forwardRef,
|
||||
type DetailedHTMLProps,
|
||||
type ElementType,
|
||||
type ForwardedRef,
|
||||
type HTMLProps,
|
||||
type PropsWithoutRef,
|
||||
} from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface AnnouncementContainerProps
|
||||
extends PropsWithoutRef<
|
||||
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* Custom component to render as.
|
||||
*/
|
||||
component?: ElementType<any>;
|
||||
/**
|
||||
* Props passed to component slots.
|
||||
*/
|
||||
slotProps?: {
|
||||
/**
|
||||
* Props passed to the root component.
|
||||
*/
|
||||
root?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
|
||||
/**
|
||||
* Props passed to the content component.
|
||||
*/
|
||||
content?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
|
||||
};
|
||||
}
|
||||
|
||||
function AnnouncementContainer(
|
||||
{
|
||||
component = 'div',
|
||||
className,
|
||||
children,
|
||||
slotProps,
|
||||
...props
|
||||
}: AnnouncementContainerProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
return createElement(
|
||||
component,
|
||||
{
|
||||
...props,
|
||||
...(slotProps?.root || {}),
|
||||
ref,
|
||||
className: twMerge('w-full overflow-hidden', slotProps?.root?.className),
|
||||
},
|
||||
<div
|
||||
{...(slotProps?.content || {})}
|
||||
className={twMerge(
|
||||
'mx-auto max-w-7xl px-5',
|
||||
className,
|
||||
slotProps?.content?.className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(AnnouncementContainer);
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import {
|
||||
createContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import Announcement from './Announcement';
|
||||
|
||||
interface AnnouncementType {
|
||||
id: string;
|
||||
content: ReactNode;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface AnnouncementContextProps {
|
||||
/**
|
||||
* The announcement to show.
|
||||
*/
|
||||
announcement?: AnnouncementType;
|
||||
/**
|
||||
* Whether or not to show the announcement.
|
||||
*/
|
||||
showAnnouncement?: boolean;
|
||||
/**
|
||||
* Function to close the announcement.
|
||||
*/
|
||||
handleClose?: () => void;
|
||||
/**
|
||||
* Whether or not the announcement is in view.
|
||||
*/
|
||||
inView?: boolean;
|
||||
}
|
||||
|
||||
// Note: You can define the active announcement here.
|
||||
const announcement: AnnouncementType = {
|
||||
id: 'nhost-run',
|
||||
href: 'https://discord.com/invite/9V7Qb2U',
|
||||
content:
|
||||
'Now you can bring custom and third-party OSS services to run alongside your Nhost projects',
|
||||
};
|
||||
|
||||
export const AnnouncementContext = createContext<AnnouncementContextProps>({});
|
||||
|
||||
export default function AnnouncementProvider({ children }: PropsWithChildren) {
|
||||
const { ref, inView } = useInView();
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!announcement ||
|
||||
window.localStorage.getItem(announcement.id) === '1'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowAnnouncement(true);
|
||||
}, []);
|
||||
|
||||
function handleClose() {
|
||||
setShowAnnouncement(false);
|
||||
window.localStorage.setItem(announcement?.id, '1');
|
||||
}
|
||||
|
||||
const announcementValue = useMemo(
|
||||
() => ({ showAnnouncement, announcement, handleClose, inView }),
|
||||
[inView, showAnnouncement],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnnouncementContext.Provider value={announcementValue}>
|
||||
{announcement && showAnnouncement && (
|
||||
<>
|
||||
<Announcement
|
||||
ref={ref}
|
||||
href={announcement.href}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{announcement.content}
|
||||
</Announcement>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</AnnouncementContext.Provider>
|
||||
);
|
||||
}
|
||||
3
dashboard/src/components/common/Announcement/index.ts
Normal file
3
dashboard/src/components/common/Announcement/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './Announcement';
|
||||
export * from './AnnouncementProvider';
|
||||
export { default as useAnnouncement } from './useAnnouncement';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useContext } from 'react';
|
||||
import { AnnouncementContext } from './AnnouncementProvider';
|
||||
|
||||
export default function useAnnouncement() {
|
||||
const context = useContext(AnnouncementContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useAnnouncement must be used within an AnnouncementProvider',
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export type PaginationProps = DetailedHTMLProps<
|
||||
* Total number of pages.
|
||||
*/
|
||||
totalNrOfPages: number;
|
||||
|
||||
/**
|
||||
* Number of total elements per page.
|
||||
*/
|
||||
@@ -23,6 +24,10 @@ export type PaginationProps = DetailedHTMLProps<
|
||||
* Total number of elements.
|
||||
*/
|
||||
totalNrOfElements: number;
|
||||
/**
|
||||
* Label of the elements displayed ex: pages, users...
|
||||
*/
|
||||
itemsLabel: string;
|
||||
/**
|
||||
* Current page number.
|
||||
*/
|
||||
@@ -64,6 +69,7 @@ export default function Pagination({
|
||||
elementsPerPage,
|
||||
onPageChange,
|
||||
totalNrOfElements,
|
||||
itemsLabel,
|
||||
...props
|
||||
}: PaginationProps) {
|
||||
return (
|
||||
@@ -132,7 +138,7 @@ export default function Pagination({
|
||||
{totalNrOfElements < currentPageNumber * elementsPerPage
|
||||
? totalNrOfElements
|
||||
: currentPageNumber * elementsPerPage}{' '}
|
||||
of {totalNrOfElements} users
|
||||
of {totalNrOfElements} {itemsLabel}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,6 +178,22 @@ export default function DataGridBody<T extends object>({
|
||||
}
|
||||
}
|
||||
|
||||
const getBackgroundCellColor = (
|
||||
row: Row<T>,
|
||||
column: DataBrowserGridColumn<T>,
|
||||
) => {
|
||||
// Grey out files not uploaded
|
||||
if (!row.values.isUploaded) {
|
||||
return 'grey.200';
|
||||
}
|
||||
|
||||
if (column.isDisabled) {
|
||||
return 'grey.100';
|
||||
}
|
||||
|
||||
return 'background.paper';
|
||||
};
|
||||
|
||||
return (
|
||||
<div {...getTableBodyProps()} ref={bodyRef} {...props}>
|
||||
{rows.length === 0 && !loading && (
|
||||
@@ -260,9 +276,7 @@ export default function DataGridBody<T extends object>({
|
||||
})}
|
||||
cell={cell}
|
||||
sx={{
|
||||
backgroundColor: column.isDisabled
|
||||
? 'grey.100'
|
||||
: 'background.paper',
|
||||
backgroundColor: getBackgroundCellColor(row, column),
|
||||
color: isCellDisabled ? 'text.secondary' : 'text.primary',
|
||||
}}
|
||||
className={twMerge(
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
sx={{ backgroundColor: 'background.paper' }}
|
||||
{...props}
|
||||
>
|
||||
<div className="grid grid-flow-col items-center gap-3">
|
||||
<div className="grid grid-flow-col items-center gap-3 ">
|
||||
<NavLink href="/" className="w-12">
|
||||
<Logo className="mx-auto cursor-pointer" />
|
||||
</NavLink>
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function SettingsContainer({
|
||||
<Box
|
||||
{...root}
|
||||
className={twMerge(
|
||||
'grid grid-flow-row gap-4 rounded-lg border-1 py-4',
|
||||
'grid grid-flow-row gap-4 overflow-hidden rounded-lg border-1 py-4',
|
||||
root?.className || rootClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,11 @@ import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import type { SettingsSidebarProps } from '@/components/layout/SettingsSidebar';
|
||||
import { SettingsSidebar } from '@/components/layout/SettingsSidebar';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface SettingsLayoutProps extends ProjectLayoutProps {
|
||||
@@ -22,6 +26,10 @@ export default function SettingsLayout({
|
||||
sidebarProps: { className: sidebarClassName, ...sidebarProps } = {},
|
||||
...props
|
||||
}: SettingsLayoutProps) {
|
||||
const theme = useTheme();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const hasGitRepo = !!currentProject?.githubRepository;
|
||||
|
||||
return (
|
||||
<ProjectLayout
|
||||
mainContainerProps={{
|
||||
@@ -37,9 +45,46 @@ export default function SettingsLayout({
|
||||
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex w-full flex-auto flex-col overflow-x-hidden"
|
||||
className="flex flex-col flex-auto w-full overflow-scroll overflow-x-hidden"
|
||||
>
|
||||
<RetryableErrorBoundary>{children}</RetryableErrorBoundary>
|
||||
<RetryableErrorBoundary>
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row gap-2 place-content-center"
|
||||
>
|
||||
<Text color="warning" className="text-sm ">
|
||||
As you have a connected repository, make sure to synchronize
|
||||
your changes with{' '}
|
||||
<code
|
||||
className={twMerge(
|
||||
'rounded-md px-2 py-px',
|
||||
theme.palette.mode === 'dark'
|
||||
? 'bg-brown text-copper'
|
||||
: 'bg-slate-200 text-slate-700',
|
||||
)}
|
||||
>
|
||||
nhost config pull
|
||||
</code>{' '}
|
||||
or they may be reverted with the next push.
|
||||
<br />
|
||||
If there are multiple projects linked to the same repository and
|
||||
you only want these changes to apply to a subset of them, please
|
||||
check out{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://docs.nhost.io/cli/overlays"
|
||||
>
|
||||
docs.nhost.io/cli/overlays
|
||||
</a>{' '}
|
||||
for guidance.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{children}
|
||||
</RetryableErrorBoundary>
|
||||
</Box>
|
||||
</ProjectLayout>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { List } from '@/components/ui/v2/List';
|
||||
import type { ListItemButtonProps } from '@/components/ui/v2/ListItem';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { isK8SPostgresEnabledInCurrentEnvironment } from '@/utils/helpers';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -135,15 +134,13 @@ export default function SettingsSidebar({
|
||||
>
|
||||
Compute Resources
|
||||
</SettingsNavLink>
|
||||
{isK8SPostgresEnabledInCurrentEnvironment && (
|
||||
<SettingsNavLink
|
||||
href="/database"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Database
|
||||
</SettingsNavLink>
|
||||
)}
|
||||
<SettingsNavLink
|
||||
href="/database"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Database
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/hasura"
|
||||
exact={false}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { styled } from '@mui/material';
|
||||
|
||||
export interface AlertProps extends BoxProps {
|
||||
/**
|
||||
@@ -11,19 +11,25 @@ export interface AlertProps extends BoxProps {
|
||||
severity?: 'info' | 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
borderRadius: 4,
|
||||
padding: theme.spacing(1.5, 2),
|
||||
textAlign: 'center',
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
lineHeight: theme.typography.pxToRem(22),
|
||||
'@media (prefers-reduced-motion: no-preference)': {
|
||||
transition: theme.transitions.create('background-color'),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function Alert({
|
||||
severity = 'info',
|
||||
children,
|
||||
className,
|
||||
sx,
|
||||
...props
|
||||
}: AlertProps) {
|
||||
return (
|
||||
<Box
|
||||
className={twMerge(
|
||||
'rounded-sm+ bg-opacity-20 p-4 text-center text-sm+ motion-safe:transition-colors',
|
||||
className,
|
||||
)}
|
||||
<StyledBox
|
||||
sx={[
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
severity === 'error' && {
|
||||
@@ -43,6 +49,6 @@ export default function Alert({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</StyledBox>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Chip } from '@/components/ui/v2/Chip';
|
||||
import type { FormControlProps } from '@/components/ui/v2/FormControl';
|
||||
import { CheckIcon } from '@/components/ui/v2/icons/CheckIcon';
|
||||
import { ChevronDownIcon } from '@/components/ui/v2/icons/ChevronDownIcon';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import type { InputProps } from '@/components/ui/v2/Input';
|
||||
@@ -134,6 +135,13 @@ const StyledAutocomplete = styled(MaterialAutocomplete)(({ theme }) => ({
|
||||
MaterialAutocompleteProps<AutocompleteOption, boolean, boolean, boolean>
|
||||
>;
|
||||
|
||||
const StyledOptionBase = styled(OptionBase)(({ theme }) => ({
|
||||
display: 'grid !important',
|
||||
gridAutoFlow: 'column',
|
||||
justifyContent: 'space-between !important',
|
||||
gap: theme.spacing(0.5),
|
||||
}));
|
||||
|
||||
export const AutocompletePopper = styled(PopperUnstyled)(({ theme }) => ({
|
||||
zIndex: theme.zIndex.modal + 1,
|
||||
boxShadow: 'none',
|
||||
@@ -326,6 +334,7 @@ function Autocomplete(
|
||||
<StyledTag
|
||||
deleteIcon={<XIcon />}
|
||||
size="small"
|
||||
sx={{ fontSize: (theme) => theme.typography.pxToRem(12) }}
|
||||
label={
|
||||
typeof option !== 'object' ? option.toString() : option.value
|
||||
}
|
||||
@@ -349,17 +358,32 @@ function Autocomplete(
|
||||
optionProps,
|
||||
option: string | number | AutocompleteOption<string>,
|
||||
) => {
|
||||
const selected = optionProps['aria-selected'];
|
||||
|
||||
if (typeof option !== 'object') {
|
||||
return <OptionBase {...optionProps}>{option.toString()}</OptionBase>;
|
||||
return (
|
||||
<StyledOptionBase {...optionProps} key={option.toString()}>
|
||||
{option.toString()}
|
||||
{selected && props.multiple && (
|
||||
<CheckIcon sx={{ width: 16, height: 16 }} />
|
||||
)}
|
||||
</StyledOptionBase>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<OptionBase
|
||||
<StyledOptionBase
|
||||
{...optionProps}
|
||||
key={option.dropdownLabel || option.label}
|
||||
>
|
||||
{option.dropdownLabel || option.label}
|
||||
</OptionBase>
|
||||
<>
|
||||
<span>{option.dropdownLabel || option.label}</span>
|
||||
|
||||
{selected && props.multiple && (
|
||||
<CheckIcon key="asd" sx={{ width: 16, height: 16 }} />
|
||||
)}
|
||||
</>
|
||||
</StyledOptionBase>
|
||||
);
|
||||
}}
|
||||
filterOptions={
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { styled } from '@mui/material';
|
||||
import { textClasses } from '@/components/ui/v2/Text';
|
||||
import { getTypographyUtilityClass, styled } from '@mui/material';
|
||||
import type { ListItemTextProps as MaterialListItemTextProps } from '@mui/material/ListItemText';
|
||||
import MaterialListItemText, {
|
||||
listItemTextClasses,
|
||||
listItemTextClasses as materialListItemTextClasses,
|
||||
} from '@mui/material/ListItemText';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface ListItemTextProps extends MaterialListItemTextProps {}
|
||||
|
||||
const listItemTextClasses = {
|
||||
...materialListItemTextClasses,
|
||||
warning: getTypographyUtilityClass('colorWarning'),
|
||||
};
|
||||
|
||||
const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
|
||||
color: theme.palette.text.primary,
|
||||
display: 'grid',
|
||||
@@ -16,6 +23,9 @@ const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
|
||||
[`&.${listItemTextClasses.root}`]: {
|
||||
margin: 0,
|
||||
},
|
||||
[`&.${listItemTextClasses.warning}`]: {
|
||||
color: theme.palette.warning.dark,
|
||||
},
|
||||
[`& > .${listItemTextClasses.primary}`]: {
|
||||
fontWeight: 500,
|
||||
textOverflow: 'ellipsis',
|
||||
@@ -29,8 +39,23 @@ const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function ListItemText({ children, ...props }: ListItemTextProps) {
|
||||
return <StyledListItemText {...props}>{children}</StyledListItemText>;
|
||||
function ListItemText({
|
||||
children,
|
||||
color = 'primary',
|
||||
className,
|
||||
...props
|
||||
}: ListItemTextProps) {
|
||||
return (
|
||||
<StyledListItemText
|
||||
className={clsx(
|
||||
color === 'warning' && textClasses.colorWarning,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StyledListItemText>
|
||||
);
|
||||
}
|
||||
|
||||
ListItemText.displayName = 'NhostListItemText';
|
||||
|
||||
@@ -18,7 +18,7 @@ export type TextProps<
|
||||
*
|
||||
* @default 'primary'
|
||||
*/
|
||||
color?: 'primary' | 'secondary' | 'disabled' | 'error';
|
||||
color?: 'primary' | 'secondary' | 'disabled' | 'error' | 'warning';
|
||||
/**
|
||||
* The component used for the root node.
|
||||
*/
|
||||
@@ -31,6 +31,7 @@ const textClasses = {
|
||||
colorSecondary: getTypographyUtilityClass('colorSecondary'),
|
||||
colorDisabled: getTypographyUtilityClass('colorDisabled'),
|
||||
colorError: getTypographyUtilityClass('colorError'),
|
||||
colorWarning: getTypographyUtilityClass('colorWarning'),
|
||||
};
|
||||
|
||||
const StyledTypography = styled(MaterialTypography)<TextProps>(({ theme }) => ({
|
||||
@@ -50,6 +51,9 @@ const StyledTypography = styled(MaterialTypography)<TextProps>(({ theme }) => ({
|
||||
[`&.${textClasses.colorError}`]: {
|
||||
color: theme.palette.error.main,
|
||||
},
|
||||
[`&.${textClasses.colorWarning}`]: {
|
||||
color: theme.palette.warning.dark,
|
||||
},
|
||||
}));
|
||||
|
||||
function Text<
|
||||
@@ -70,6 +74,7 @@ function Text<
|
||||
color === 'secondary' && textClasses.colorSecondary,
|
||||
color === 'disabled' && textClasses.colorDisabled,
|
||||
color === 'error' && textClasses.colorError,
|
||||
color === 'warning' && textClasses.colorWarning,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
40
dashboard/src/components/ui/v2/icons/CubeIcon/CubeIcon.tsx
Normal file
40
dashboard/src/components/ui/v2/icons/CubeIcon/CubeIcon.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
|
||||
function CubeIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M14 11.0826V4.91742C14 4.8287 13.9764 4.74158 13.9316 4.665C13.8868 4.58841 13.8225 4.52513 13.7451 4.48163L8.24513 1.38788C8.17029 1.34578 8.08587 1.32367 8 1.32367C7.91413 1.32367 7.82971 1.34578 7.75487 1.38788L2.25487 4.48163C2.17754 4.52513 2.11318 4.58841 2.0684 4.665C2.02361 4.74158 2 4.8287 2 4.91742V11.0826C2 11.1713 2.02361 11.2584 2.0684 11.335C2.11318 11.4116 2.17754 11.4749 2.25487 11.5184L7.75487 14.6121C7.82971 14.6542 7.91413 14.6763 8 14.6763C8.08587 14.6763 8.17029 14.6542 8.24513 14.6121L13.7451 11.5184C13.8225 11.4749 13.8868 11.4116 13.9316 11.335C13.9764 11.2584 14 11.1713 14 11.0826Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.9311 4.66414L8.0594 8.00001L2.06934 4.66357"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.05916 8L8.00049 14.6763"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
CubeIcon.displayName = 'NhostCubeIcon';
|
||||
|
||||
export default CubeIcon;
|
||||
1
dashboard/src/components/ui/v2/icons/CubeIcon/index.ts
Normal file
1
dashboard/src/components/ui/v2/icons/CubeIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CubeIcon } from './CubeIcon';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
|
||||
function ServicesIcon(props: IconProps) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Services"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.89295 4.15125H9.21701C9.28097 4.15125 9.33291 4.09959 9.33326 4.03565V2.8556C9.33291 2.79163 9.28097 2.73999 9.21701 2.73999H7.89295C7.82909 2.73999 7.77734 2.79174 7.77734 2.8556V4.03562C7.77734 4.09948 7.82911 4.15125 7.89295 4.15125ZM5.53406 5.84862H4.21001C4.14594 5.84826 4.09411 5.79643 4.09375 5.73236V4.55298C4.09411 4.48902 4.14606 4.43738 4.21001 4.43738H5.53406C5.5979 4.43738 5.64967 4.48912 5.64967 4.55298V5.73236C5.64967 5.79631 5.59801 5.84826 5.53406 5.84862ZM14.6307 6.48419C15.4316 6.48419 15.8114 6.77094 15.8521 6.80325L16 6.92016L15.9386 7.09971C15.8408 7.34738 15.69 7.57067 15.4968 7.75398C15.2062 8.04139 14.6791 8.38436 13.8221 8.38436H13.6839C13.337 9.26145 12.8707 10.2484 12.0879 11.1345C11.6196 11.6644 11.0689 12.1152 10.457 12.4696C9.71438 12.8901 8.90665 13.1835 8.06725 13.3376C7.4634 13.45 6.85036 13.5056 6.23616 13.5036C4.87658 13.5036 3.67717 13.2453 2.93893 12.7932C2.28012 12.3908 1.77374 11.7333 1.43337 10.8407C1.13576 10.0277 0.989105 9.1673 1.00063 8.30169C1.00204 8.04363 1.21146 7.83507 1.46954 7.83472H11.3503C11.471 7.8302 12.0678 7.77917 12.4399 7.57185C12.1318 7.08484 12.0446 6.51519 12.188 5.9087C12.2639 5.59123 12.3932 5.28898 12.5703 5.01479L12.7118 4.81068L12.9268 4.93471L12.9269 4.93473C12.9668 4.9583 13.8447 5.47632 13.9996 6.53843C14.2082 6.50325 14.4192 6.48511 14.6307 6.48419ZM3.7092 7.54529H2.38514C2.32128 7.54529 2.26953 7.49353 2.26953 7.42967V6.25029V6.24964C2.26953 6.1858 2.32128 6.13403 2.38514 6.13403H3.7092H3.70985C3.77369 6.13439 3.82516 6.18643 3.8248 6.25029V7.42969C3.8248 7.49353 3.77306 7.54529 3.7092 7.54529ZM4.21003 7.54529H5.53409C5.59794 7.54529 5.64969 7.49353 5.64969 7.42969V6.25029C5.65005 6.18643 5.59858 6.13439 5.53472 6.13403H5.53407H4.21001C4.14579 6.13403 4.09375 6.18607 4.09375 6.25029V7.42967C4.09413 7.49363 4.14606 7.54529 4.21003 7.54529ZM7.38597 7.54529H6.06191C5.99808 7.54529 5.94631 7.49353 5.94629 7.42967V6.25029V6.24964C5.94629 6.1858 5.99803 6.13403 6.06189 6.13403H7.38595H7.3866C7.45046 6.13439 7.50193 6.18643 7.50157 6.25029V7.42969C7.50157 7.49353 7.44983 7.54529 7.38597 7.54529ZM7.89295 7.54529H9.21701C9.28097 7.54529 9.33291 7.49365 9.33326 7.42969V6.25029C9.33326 6.18607 9.28122 6.13403 9.21701 6.13403H7.89295C7.82909 6.13403 7.77734 6.1858 7.77734 6.24964V6.25029V7.42967C7.77734 7.49353 7.82911 7.54529 7.89295 7.54529ZM6.06189 5.84862H7.38595C7.4499 5.84826 7.50156 5.79631 7.50156 5.73236V4.55298C7.50156 4.48912 7.44979 4.43738 7.38595 4.43738H6.06189C5.99804 4.43738 5.94629 4.48915 5.94629 4.55298V5.73236C5.94629 5.79631 5.99795 5.84826 6.06189 5.84862ZM9.21701 5.84862H7.89295C7.82901 5.84826 7.77734 5.79631 7.77734 5.73236V4.55298C7.77734 4.48915 7.82909 4.43738 7.89295 4.43738H9.21701C9.28097 4.43738 9.33291 4.48902 9.33326 4.55298V5.73236C9.33291 5.79643 9.28108 5.84826 9.21701 5.84862ZM11.0637 7.54529H9.73963C9.67579 7.54529 9.62402 7.49353 9.62402 7.42967V6.25029V6.24964C9.62402 6.1858 9.67579 6.13403 9.73963 6.13403H11.0637C11.1279 6.13403 11.1799 6.18607 11.1799 6.25029V7.42969C11.1796 7.49365 11.1277 7.54529 11.0637 7.54529Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
ServicesIcon.displayName = 'NhostServicesIcon';
|
||||
|
||||
export default ServicesIcon;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ServicesIcon } from './ServicesIcon';
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IconProps } from '@/components/ui/v2/icons';
|
||||
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
function WarningIcon(props: IconProps, ref: ForwardedRef<SVGSVGElement>) {
|
||||
return (
|
||||
<SvgIcon
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
aria-label="Warning"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8 5.5V9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.135 2.49904L1.63648 11.9986C1.5485 12.1506 1.5021 12.3231 1.50195 12.4987C1.50181 12.6743 1.54792 12.8469 1.63565 12.999C1.72338 13.1512 1.84964 13.2776 2.00172 13.3654C2.15379 13.4533 2.32633 13.4995 2.50196 13.4995H13.499C13.6746 13.4995 13.8472 13.4533 13.9992 13.3654C14.1513 13.2776 14.2776 13.1512 14.3653 12.999C14.453 12.8469 14.4991 12.6743 14.499 12.4987C14.4988 12.3231 14.4524 12.1506 14.3645 11.9986L8.86594 2.49904C8.7781 2.34728 8.6519 2.22129 8.49999 2.1337C8.34809 2.04611 8.17582 2 8.00047 2C7.82512 2 7.65285 2.04611 7.50095 2.1337C7.34904 2.22129 7.22284 2.34728 7.135 2.49904V2.49904Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 12C8.41421 12 8.75 11.6642 8.75 11.25C8.75 10.8358 8.41421 10.5 8 10.5C7.58579 10.5 7.25 10.8358 7.25 11.25C7.25 11.6642 7.58579 12 8 12Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
WarningIcon.displayName = 'NhostWarningIcon';
|
||||
|
||||
export default forwardRef(WarningIcon);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as WarningIcon } from './WarningIcon';
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"k8s-postgres": {
|
||||
"enabled": ["dev", "staging", "production"]
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { DotsVerticalIcon } from '@/components/ui/v2/icons/DotsVerticalIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { WarningIcon } from '@/components/ui/v2/icons/WarningIcon';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { CreatePATForm } from '@/features/account/settings/components/CreatePATForm';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
@@ -133,69 +135,91 @@ export default function PATSettings() {
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
{availablePersonalAccessTokens.length > 0 && (
|
||||
<List>
|
||||
{availablePersonalAccessTokens.map((pat, index) => (
|
||||
<Fragment key={pat.id}>
|
||||
<ListItem.Root
|
||||
className="grid grid-cols-3 gap-2 px-4 pr-12"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={maintenanceActive}
|
||||
aria-label={`More options for ${pat.name}`}
|
||||
{availablePersonalAccessTokens.map((pat, index) => {
|
||||
const tokenHasExpired = new Date(pat.expiresAt) < new Date();
|
||||
|
||||
return (
|
||||
<Fragment key={pat.id}>
|
||||
<ListItem.Root
|
||||
className="grid grid-cols-3 gap-2 px-4 pr-12"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={maintenanceActive}
|
||||
aria-label={`More options for ${pat.name}`}
|
||||
>
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item onClick={() => handleConfirmDelete(pat)}>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text className="truncate">{pat.name}</ListItem.Text>
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleConfirmDelete(pat)}
|
||||
>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text
|
||||
className="truncate"
|
||||
color={tokenHasExpired ? 'warning' : 'primary'}
|
||||
>
|
||||
<span className="mr-2">{pat.name}</span>
|
||||
{tokenHasExpired && (
|
||||
<Tooltip title="This personal access token is expired.">
|
||||
<WarningIcon className="h-4 w-4" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</ListItem.Text>
|
||||
|
||||
<Text className="truncate">
|
||||
{new Date(pat.expiresAt).toLocaleDateString()}
|
||||
</Text>
|
||||
<Text
|
||||
className="truncate"
|
||||
color={tokenHasExpired ? 'warning' : 'primary'}
|
||||
>
|
||||
{new Date(pat.expiresAt).toLocaleDateString()}
|
||||
</Text>
|
||||
|
||||
<Text className="truncate">
|
||||
{new Date(pat.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</ListItem.Root>
|
||||
<Text
|
||||
className="truncate"
|
||||
color={tokenHasExpired ? 'warning' : 'primary'}
|
||||
>
|
||||
{new Date(pat.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</ListItem.Root>
|
||||
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availablePersonalAccessTokens.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availablePersonalAccessTokens.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
query GetPersonalAccessTokens {
|
||||
personalAccessTokens: authRefreshTokens(
|
||||
where: { type: { _eq: "pat" } }
|
||||
where: { type: { _eq: pat } }
|
||||
order_by: { expiresAt: asc }
|
||||
) {
|
||||
id
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function AppleProviderSettings() {
|
||||
const { register, formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (values: AppleProviderFormValues) => {
|
||||
async function handleSubmit(formValues: AppleProviderFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -107,7 +107,7 @@ export default function AppleProviderSettings() {
|
||||
method: {
|
||||
oauth: {
|
||||
apple: {
|
||||
...values,
|
||||
...formValues,
|
||||
scope: [],
|
||||
},
|
||||
},
|
||||
@@ -130,15 +130,15 @@ export default function AppleProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Apple"
|
||||
description="Allow users to sign in with Apple."
|
||||
@@ -214,7 +214,7 @@ export default function AppleProviderSettings() {
|
||||
/>
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="apple-redirectUrl"
|
||||
defaultValue={`${generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
|
||||
@@ -29,6 +29,7 @@ export type AuthServiceVersionFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
const AVAILABLE_AUTH_VERSIONS = [
|
||||
'0.21.2',
|
||||
'0.20.1',
|
||||
'0.20.0',
|
||||
'0.19.3',
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function AzureADProviderSettings() {
|
||||
const { register, formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (values: AzureADProviderFormValues) => {
|
||||
async function handleSubmit(formValues: AzureADProviderFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -98,7 +98,7 @@ export default function AzureADProviderSettings() {
|
||||
auth: {
|
||||
method: {
|
||||
oauth: {
|
||||
azuread: values,
|
||||
azuread: formValues,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -119,15 +119,15 @@ export default function AzureADProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Azure AD"
|
||||
description="Allow users to sign in with Azure AD."
|
||||
@@ -160,7 +160,7 @@ export default function AzureADProviderSettings() {
|
||||
/>
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="azuerad-redirectUrl"
|
||||
defaultValue={`${generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||
import type { BaseProviderSettingsFormValues } from '@/features/authentication/settings/components/BaseProviderSettings';
|
||||
import {
|
||||
BaseProviderSettings,
|
||||
baseProviderValidationSchema,
|
||||
} from '@/features/authentication/settings/components/BaseProviderSettings';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import {
|
||||
GetSignInMethodsDocument,
|
||||
useGetSignInMethodsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export default function BitbucketProviderSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetSignInMethodsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetSignInMethodsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { clientId, clientSecret, enabled } =
|
||||
data?.config?.auth?.method?.oauth?.bitbucket || {};
|
||||
|
||||
const form = useForm<BaseProviderSettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
clientId: clientId || '',
|
||||
clientSecret: clientSecret || '',
|
||||
enabled: enabled || false,
|
||||
},
|
||||
resolver: yupResolver(baseProviderValidationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading settings for Bitbucket..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
async function handleSubmit(formValues: BaseProviderSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
auth: {
|
||||
method: {
|
||||
oauth: {
|
||||
bitbucket: formValues,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Bitbucket settings are being updated...`,
|
||||
success: `Bitbucket settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's Bitbucket settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Bitbucket"
|
||||
description="Allow users to sign in with Bitbucket."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
icon="/assets/brands/bitbucket.svg"
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
|
||||
!authEnabled && 'hidden',
|
||||
)}
|
||||
>
|
||||
<BaseProviderSettings providerName="bitbucket" />
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="bitbucket-redirectUrl"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
label="Redirect URL"
|
||||
defaultValue={`${generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
'auth',
|
||||
)}/signin/provider/bitbucket/callback`}
|
||||
disabled
|
||||
endAdornment={
|
||||
<InputAdornment position="end" className="absolute right-2">
|
||||
<IconButton
|
||||
sx={{ minWidth: 0, padding: 0 }}
|
||||
color="secondary"
|
||||
variant="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(
|
||||
`${generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
'auth',
|
||||
)}/signin/provider/bitbucket/callback`,
|
||||
'Redirect URL',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as BitbucketProviderSettings } from './BitbucketProviderSettings';
|
||||
@@ -68,9 +68,7 @@ export default function DiscordProviderSettings() {
|
||||
const { formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (
|
||||
values: BaseProviderSettingsFormValues,
|
||||
) => {
|
||||
async function handleSubmit(formValues: BaseProviderSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
@@ -79,7 +77,7 @@ export default function DiscordProviderSettings() {
|
||||
method: {
|
||||
oauth: {
|
||||
discord: {
|
||||
...values,
|
||||
...formValues,
|
||||
scope: [],
|
||||
},
|
||||
},
|
||||
@@ -102,15 +100,15 @@ export default function DiscordProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Discord"
|
||||
description="Allow users to sign in with Discord."
|
||||
@@ -133,7 +131,7 @@ export default function DiscordProviderSettings() {
|
||||
<BaseProviderSettings providerName="discord" />
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="discord-redirectUrl"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ControlledCheckbox } from '@/components/form/ControlledCheckbox';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
@@ -20,6 +21,11 @@ import * as Yup from 'yup';
|
||||
const validationSchema = Yup.object({
|
||||
emailVerificationRequired: Yup.boolean(),
|
||||
hibpEnabled: Yup.boolean(),
|
||||
passwordMinLength: Yup.number()
|
||||
.label('Minimum password length')
|
||||
.min(3)
|
||||
.typeError('Minimum password length must be a number')
|
||||
.required(),
|
||||
});
|
||||
|
||||
export type EmailAndPasswordFormValues = Yup.InferType<typeof validationSchema>;
|
||||
@@ -36,7 +42,7 @@ export default function EmailAndPasswordSettings() {
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { hibpEnabled, emailVerificationRequired } =
|
||||
const { hibpEnabled, emailVerificationRequired, passwordMinLength } =
|
||||
data?.config?.auth?.method?.emailPassword || {};
|
||||
|
||||
const form = useForm<EmailAndPasswordFormValues>({
|
||||
@@ -44,6 +50,7 @@ export default function EmailAndPasswordSettings() {
|
||||
defaultValues: {
|
||||
hibpEnabled: hibpEnabled || false,
|
||||
emailVerificationRequired: emailVerificationRequired || false,
|
||||
passwordMinLength: passwordMinLength || 9,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
@@ -62,18 +69,16 @@ export default function EmailAndPasswordSettings() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState } = form;
|
||||
const { formState, register } = form;
|
||||
|
||||
const handleEmailAndPasswordSettingsChange = async (
|
||||
values: EmailAndPasswordFormValues,
|
||||
) => {
|
||||
async function handleSubmit(formValues: EmailAndPasswordFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
auth: {
|
||||
method: {
|
||||
emailPassword: values,
|
||||
emailPassword: formValues,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -93,15 +98,15 @@ export default function EmailAndPasswordSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleEmailAndPasswordSettingsChange}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Email and Password"
|
||||
description="Allow users to sign in with email and password."
|
||||
@@ -118,6 +123,19 @@ export default function EmailAndPasswordSettings() {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
{...register('passwordMinLength')}
|
||||
id="passwordMinLength"
|
||||
name="passwordMinLength"
|
||||
type="number"
|
||||
label="Minimum required password length"
|
||||
fullWidth
|
||||
className="lg:max-w-[50%]"
|
||||
error={Boolean(formState.errors.passwordMinLength?.message)}
|
||||
helperText={formState.errors.passwordMinLength?.message}
|
||||
slotProps={{ inputRoot: { min: 3 } }}
|
||||
/>
|
||||
|
||||
<ControlledCheckbox
|
||||
name="emailVerificationRequired"
|
||||
id="emailVerificationRequired"
|
||||
|
||||
@@ -68,9 +68,7 @@ export default function FacebookProviderSettings() {
|
||||
const { formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (
|
||||
values: BaseProviderSettingsFormValues,
|
||||
) => {
|
||||
async function handleSubmit(formValues: BaseProviderSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -79,7 +77,7 @@ export default function FacebookProviderSettings() {
|
||||
method: {
|
||||
oauth: {
|
||||
facebook: {
|
||||
...values,
|
||||
...formValues,
|
||||
scope: [],
|
||||
},
|
||||
},
|
||||
@@ -102,15 +100,15 @@ export default function FacebookProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Facebook"
|
||||
description="Allow users to sign in with Facebook."
|
||||
@@ -133,7 +131,7 @@ export default function FacebookProviderSettings() {
|
||||
<BaseProviderSettings providerName="facebook" />
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="facebook-redirectUrl"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
|
||||
@@ -70,9 +70,7 @@ export default function GitHubProviderSettings() {
|
||||
const { formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (
|
||||
values: BaseProviderSettingsFormValues,
|
||||
) => {
|
||||
async function handleSubmit(formValues: BaseProviderSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -81,7 +79,7 @@ export default function GitHubProviderSettings() {
|
||||
method: {
|
||||
oauth: {
|
||||
github: {
|
||||
...values,
|
||||
...formValues,
|
||||
scope: [],
|
||||
},
|
||||
},
|
||||
@@ -104,15 +102,15 @@ export default function GitHubProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="GitHub"
|
||||
description="Allow users to sign in with GitHub."
|
||||
@@ -139,7 +137,7 @@ export default function GitHubProviderSettings() {
|
||||
<BaseProviderSettings providerName="github" />
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="github-redirectUrl"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||
import type { BaseProviderSettingsFormValues } from '@/features/authentication/settings/components/BaseProviderSettings';
|
||||
import {
|
||||
BaseProviderSettings,
|
||||
baseProviderValidationSchema,
|
||||
} from '@/features/authentication/settings/components/BaseProviderSettings';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import {
|
||||
GetSignInMethodsDocument,
|
||||
useGetSignInMethodsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export default function GitLabProviderSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetSignInMethodsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetSignInMethodsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { clientId, clientSecret, enabled } =
|
||||
data?.config?.auth?.method?.oauth?.gitlab || {};
|
||||
|
||||
const form = useForm<BaseProviderSettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
clientId: clientId || '',
|
||||
clientSecret: clientSecret || '',
|
||||
enabled: enabled || false,
|
||||
},
|
||||
resolver: yupResolver(baseProviderValidationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading settings for GitLab..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
async function handleSubmit(values: BaseProviderSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
auth: {
|
||||
method: {
|
||||
oauth: {
|
||||
gitlab: {
|
||||
...values,
|
||||
scope: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `GitLab settings are being updated...`,
|
||||
success: `GitLab settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's GitLab settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="GitLab"
|
||||
description="Allow users to sign in with GitLab."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
icon="/assets/brands/gitlab.svg"
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
|
||||
!authEnabled && 'hidden',
|
||||
)}
|
||||
>
|
||||
<BaseProviderSettings providerName="gitlab" />
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="gitlab-redirectUrl"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
label="Redirect URL"
|
||||
defaultValue={`${generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
'auth',
|
||||
)}/signin/provider/gitlab/callback`}
|
||||
disabled
|
||||
endAdornment={
|
||||
<InputAdornment position="end" className="absolute right-2">
|
||||
<IconButton
|
||||
sx={{ minWidth: 0, padding: 0 }}
|
||||
color="secondary"
|
||||
variant="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(
|
||||
`${generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
'auth',
|
||||
)}/signin/provider/gitlab/callback`,
|
||||
'Redirect URL',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as GitLabProviderSettings } from './GitLabProviderSettings';
|
||||
@@ -68,9 +68,7 @@ export default function GoogleProviderSettings() {
|
||||
const { formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (
|
||||
values: BaseProviderSettingsFormValues,
|
||||
) => {
|
||||
async function handleSubmit(formValues: BaseProviderSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -79,7 +77,7 @@ export default function GoogleProviderSettings() {
|
||||
method: {
|
||||
oauth: {
|
||||
google: {
|
||||
...values,
|
||||
...formValues,
|
||||
scope: [],
|
||||
},
|
||||
},
|
||||
@@ -102,15 +100,15 @@ export default function GoogleProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Google"
|
||||
description="Allow users to sign in with Google."
|
||||
@@ -133,7 +131,7 @@ export default function GoogleProviderSettings() {
|
||||
<BaseProviderSettings providerName="google" />
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="google-redirectUrl"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
|
||||
@@ -68,9 +68,7 @@ export default function LinkedInProviderSettings() {
|
||||
const { formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (
|
||||
values: BaseProviderSettingsFormValues,
|
||||
) => {
|
||||
async function handleSubmit(formValues: BaseProviderSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -79,7 +77,7 @@ export default function LinkedInProviderSettings() {
|
||||
method: {
|
||||
oauth: {
|
||||
linkedin: {
|
||||
...values,
|
||||
...formValues,
|
||||
scope: [],
|
||||
},
|
||||
},
|
||||
@@ -102,15 +100,15 @@ export default function LinkedInProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="LinkedIn"
|
||||
description="Allow users to sign in with LinkedIn."
|
||||
@@ -133,7 +131,7 @@ export default function LinkedInProviderSettings() {
|
||||
<BaseProviderSettings providerName="linkedin" />
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="linkedin-redirectUrl"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
accessTokenExpiresIn: Yup.number()
|
||||
.label('Access token expiration')
|
||||
.typeError('Access token expiration must be a number')
|
||||
.required(),
|
||||
refreshTokenExpiresIn: Yup.number()
|
||||
.label('Refresh token expiration')
|
||||
.typeError('Refresh token expiration must be a number')
|
||||
.required(),
|
||||
});
|
||||
|
||||
export type SessionFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function SessionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken } = data?.config?.auth?.session || {};
|
||||
|
||||
const form = useForm<SessionFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
accessTokenExpiresIn: accessToken?.expiresIn || 900,
|
||||
refreshTokenExpiresIn: refreshToken?.expiresIn || 43200,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading session settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { register, formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const handleSessionSettingsChange = async (formValues: SessionFormValues) => {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
auth: {
|
||||
session: {
|
||||
accessToken: { expiresIn: formValues.accessTokenExpiresIn },
|
||||
refreshToken: { expiresIn: formValues.refreshTokenExpiresIn },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Session settings are being updated...`,
|
||||
success: `Session settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's session settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSessionSettingsChange}>
|
||||
<SettingsContainer
|
||||
title="Session"
|
||||
description="Change the expiration time of the access and refresh tokens."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-cols-5 grid-rows-2 gap-y-6"
|
||||
>
|
||||
<Input
|
||||
{...register('accessTokenExpiresIn')}
|
||||
id="accessTokenExpiresIn"
|
||||
type="number"
|
||||
label="Access Token Expires In (Seconds)"
|
||||
fullWidth
|
||||
className="col-span-5 lg:col-span-2"
|
||||
error={Boolean(formState.errors.accessTokenExpiresIn?.message)}
|
||||
helperText={formState.errors.accessTokenExpiresIn?.message}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('refreshTokenExpiresIn')}
|
||||
id="refreshTokenExpiresIn"
|
||||
type="number"
|
||||
label="Refresh Token Expires In (Seconds)"
|
||||
fullWidth
|
||||
className="col-span-5 row-start-2 lg:col-span-2"
|
||||
error={Boolean(formState.errors.refreshTokenExpiresIn?.message)}
|
||||
helperText={formState.errors.refreshTokenExpiresIn?.message}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './SessionSettings';
|
||||
export { default as SessionSettings } from './SessionSettings';
|
||||
@@ -68,9 +68,7 @@ export default function SpotifyProviderSettings() {
|
||||
const { formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (
|
||||
values: BaseProviderSettingsFormValues,
|
||||
) => {
|
||||
async function handleSubmit(formValues: BaseProviderSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -79,7 +77,7 @@ export default function SpotifyProviderSettings() {
|
||||
method: {
|
||||
oauth: {
|
||||
spotify: {
|
||||
...values,
|
||||
...formValues,
|
||||
scope: [],
|
||||
},
|
||||
},
|
||||
@@ -102,15 +100,15 @@ export default function SpotifyProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Spotify"
|
||||
description="Allow users to sign in with Spotify."
|
||||
@@ -133,7 +131,7 @@ export default function SpotifyProviderSettings() {
|
||||
<BaseProviderSettings providerName="spotify" />
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="spotify-redirectUrl"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||
import type { BaseProviderSettingsFormValues } from '@/features/authentication/settings/components/BaseProviderSettings';
|
||||
import {
|
||||
BaseProviderSettings,
|
||||
baseProviderValidationSchema,
|
||||
} from '@/features/authentication/settings/components/BaseProviderSettings';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import {
|
||||
GetSignInMethodsDocument,
|
||||
useGetSignInMethodsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export default function StravaProviderSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetSignInMethodsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetSignInMethodsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { clientId, clientSecret, enabled } =
|
||||
data?.config?.auth?.method?.oauth?.strava || {};
|
||||
|
||||
const form = useForm<BaseProviderSettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
clientId: clientId || '',
|
||||
clientSecret: clientSecret || '',
|
||||
enabled: enabled || false,
|
||||
},
|
||||
resolver: yupResolver(baseProviderValidationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading settings for Strava..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
async function handleSubmit(values: BaseProviderSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
auth: {
|
||||
method: {
|
||||
oauth: {
|
||||
strava: {
|
||||
...values,
|
||||
scope: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Strava settings are being updated...`,
|
||||
success: `Strava settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's Strava settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Strava"
|
||||
description="Allow users to sign in with Strava."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
icon="/assets/brands/strava.svg"
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
|
||||
!authEnabled && 'hidden',
|
||||
)}
|
||||
>
|
||||
<BaseProviderSettings providerName="strava" />
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="strava-redirectUrl"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
label="Redirect URL"
|
||||
defaultValue={`${generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
'auth',
|
||||
)}/signin/provider/strava/callback`}
|
||||
disabled
|
||||
endAdornment={
|
||||
<InputAdornment position="end" className="absolute right-2">
|
||||
<IconButton
|
||||
sx={{ minWidth: 0, padding: 0 }}
|
||||
color="secondary"
|
||||
variant="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(
|
||||
`${generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
'auth',
|
||||
)}/signin/provider/strava/callback`,
|
||||
'Redirect URL',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as StravaProviderSettings } from './StravaProviderSettings';
|
||||
@@ -70,9 +70,7 @@ export default function TwitchProviderSettings() {
|
||||
const { formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (
|
||||
values: BaseProviderSettingsFormValues,
|
||||
) => {
|
||||
async function handleSubmit(formValues: BaseProviderSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -81,7 +79,7 @@ export default function TwitchProviderSettings() {
|
||||
method: {
|
||||
oauth: {
|
||||
twitch: {
|
||||
...values,
|
||||
...formValues,
|
||||
scope: [],
|
||||
},
|
||||
},
|
||||
@@ -104,15 +102,15 @@ export default function TwitchProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Twitch"
|
||||
description="Allow users to sign in with Twitch."
|
||||
@@ -139,7 +137,7 @@ export default function TwitchProviderSettings() {
|
||||
<BaseProviderSettings providerName="twitch" />
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="twitch-redirectUrl"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function TwitterProviderSettings() {
|
||||
const { register, formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (values: TwitterProviderFormValues) => {
|
||||
async function handleSubmit(formValues: TwitterProviderFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -90,7 +90,7 @@ export default function TwitterProviderSettings() {
|
||||
auth: {
|
||||
method: {
|
||||
oauth: {
|
||||
twitter: values,
|
||||
twitter: formValues,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -111,15 +111,15 @@ export default function TwitterProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Twitter"
|
||||
description="Allow users to sign in with Twitter."
|
||||
@@ -164,7 +164,7 @@ export default function TwitterProviderSettings() {
|
||||
/>
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="twitter-redirectUrl"
|
||||
defaultValue={`${generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
|
||||
@@ -66,7 +66,10 @@ export default function WebAuthnSettings() {
|
||||
config: {
|
||||
auth: {
|
||||
method: {
|
||||
webauthn: values,
|
||||
webauthn: {...values,
|
||||
relyingParty: {
|
||||
name: currentProject.name,
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -68,9 +68,7 @@ export default function WindowsLiveProviderSettings() {
|
||||
const { formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (
|
||||
values: BaseProviderSettingsFormValues,
|
||||
) => {
|
||||
async function handleSubmit(formValues: BaseProviderSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -79,7 +77,7 @@ export default function WindowsLiveProviderSettings() {
|
||||
method: {
|
||||
oauth: {
|
||||
windowslive: {
|
||||
...values,
|
||||
...formValues,
|
||||
scope: [],
|
||||
},
|
||||
},
|
||||
@@ -102,15 +100,15 @@ export default function WindowsLiveProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Windows Live"
|
||||
description="Allow users to sign in with Windows Live."
|
||||
@@ -132,7 +130,7 @@ export default function WindowsLiveProviderSettings() {
|
||||
<BaseProviderSettings providerName="windowslive" />
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="windowslive-redirectUrl"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function WorkOsProviderSettings() {
|
||||
const { register, formState, watch } = form;
|
||||
const authEnabled = watch('enabled');
|
||||
|
||||
const handleProviderUpdate = async (values: WorkOsProviderFormValues) => {
|
||||
async function handleSubmit(formValues: WorkOsProviderFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -105,7 +105,7 @@ export default function WorkOsProviderSettings() {
|
||||
auth: {
|
||||
method: {
|
||||
oauth: {
|
||||
workos: values,
|
||||
workos: formValues,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -126,15 +126,15 @@ export default function WorkOsProviderSettings() {
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleProviderUpdate}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="WorkOS"
|
||||
description="Allow users to sign in with WorkOS."
|
||||
@@ -181,7 +181,7 @@ export default function WorkOsProviderSettings() {
|
||||
/>
|
||||
<Input
|
||||
name="redirectUrl"
|
||||
id="redirectUrl"
|
||||
id="workos-redirectUrl"
|
||||
defaultValue={`${generateAppServiceUrl(
|
||||
currentProject.subdomain,
|
||||
currentProject.region,
|
||||
|
||||
@@ -16,6 +16,14 @@ query GetAuthenticationSettings($appId: uuid!) {
|
||||
signUp {
|
||||
enabled
|
||||
}
|
||||
session {
|
||||
accessToken {
|
||||
expiresIn
|
||||
}
|
||||
refreshToken {
|
||||
expiresIn
|
||||
}
|
||||
}
|
||||
user {
|
||||
email {
|
||||
allowed
|
||||
@@ -30,6 +38,10 @@ query GetAuthenticationSettings($appId: uuid!) {
|
||||
default
|
||||
rating
|
||||
}
|
||||
locale {
|
||||
allowed
|
||||
default
|
||||
}
|
||||
}
|
||||
version
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { copy } from '@/utils/copy';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import {
|
||||
RemoteAppGetUsersDocument,
|
||||
useGetProjectLocalesQuery,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
@@ -146,6 +147,14 @@ export default function EditUserForm({
|
||||
dataRoles?.config?.auth?.user?.roles?.allowed,
|
||||
);
|
||||
|
||||
const { data } = useGetProjectLocalesQuery({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
},
|
||||
});
|
||||
|
||||
const allowedLocales = data?.config?.auth?.user?.locale?.allowed || [];
|
||||
|
||||
/**
|
||||
* This will change the `disabled` field in the user to its opposite.
|
||||
* If the user is disabled, it will be enabled and vice versa.
|
||||
@@ -374,12 +383,11 @@ export default function EditUserForm({
|
||||
error={!!errors.locale}
|
||||
helperText={errors?.locale?.message}
|
||||
>
|
||||
<Option key="en" value="en">
|
||||
en
|
||||
</Option>
|
||||
<Option key="fr" value="fr">
|
||||
fr
|
||||
</Option>
|
||||
{allowedLocales.map((locale) => (
|
||||
<Option key={locale} value={locale}>
|
||||
{locale}
|
||||
</Option>
|
||||
))}
|
||||
</ControlledSelect>
|
||||
</Box>
|
||||
<Box
|
||||
|
||||
@@ -3,12 +3,16 @@ import { Form } from '@/components/form/Form';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import { useUpdateRemoteAppUserMutation } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetSignInMethodsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { useState } from 'react';
|
||||
@@ -27,19 +31,6 @@ export interface EditUserPasswordFormProps extends DialogFormProps {
|
||||
user: RemoteAppGetUsersQuery['users'][0];
|
||||
}
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
password: Yup.string()
|
||||
.label('Users Password')
|
||||
.min(8, 'Password must be at least 8 characters long.')
|
||||
.required('This field is required.'),
|
||||
cpassword: Yup.string()
|
||||
.required('Confirm Password is required')
|
||||
.min(8, 'Password must be at least 8 characters long.')
|
||||
.oneOf([Yup.ref('password')], 'Passwords do not match'),
|
||||
});
|
||||
|
||||
export type EditUserPasswordFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function EditUserPasswordForm({
|
||||
onCancel,
|
||||
user,
|
||||
@@ -49,26 +40,52 @@ export default function EditUserPasswordForm({
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
const { closeDialog } = useDialog();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { data } = useGetSignInMethodsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
skip: !currentProject?.id,
|
||||
});
|
||||
|
||||
const passwordMinLength =
|
||||
data?.config?.auth?.method?.emailPassword?.passwordMinLength || 1;
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
password: Yup.string()
|
||||
.label('Password')
|
||||
.min(
|
||||
passwordMinLength,
|
||||
`Password must be at least ${passwordMinLength} characters long.`,
|
||||
)
|
||||
.required('This field is required.'),
|
||||
cpassword: Yup.string()
|
||||
.label('Password Confirmation')
|
||||
.min(
|
||||
passwordMinLength,
|
||||
`Password must be at least ${passwordMinLength} characters long.`,
|
||||
)
|
||||
.oneOf([Yup.ref('password')], 'Passwords do not match')
|
||||
.required('This field is required.'),
|
||||
});
|
||||
|
||||
const [editUserPasswordFormError, setEditUserPasswordFormError] =
|
||||
useState<Error | null>(null);
|
||||
|
||||
const form = useForm<EditUserPasswordFormValues>({
|
||||
const form = useForm<Yup.InferType<typeof validationSchema>>({
|
||||
defaultValues: {},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const handleSubmit = async ({ password }: EditUserPasswordFormValues) => {
|
||||
const handleSubmit = async ({
|
||||
password,
|
||||
}: Yup.InferType<typeof validationSchema>) => {
|
||||
setEditUserPasswordFormError(null);
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const updateUserPasswordPromise = updateUser({
|
||||
variables: {
|
||||
id: user.id,
|
||||
user: {
|
||||
passwordHash,
|
||||
},
|
||||
user: { passwordHash },
|
||||
},
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -34,7 +35,7 @@ const defaultParameters = {
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers: [tableQuery, hasuraMetadataQuery],
|
||||
handlers: [tokenQuery, tableQuery, hasuraMetadataQuery],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariab
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import '@testing-library/jest-dom';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { test, vi } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, test, vi } from 'vitest';
|
||||
import ColumnAutocomplete from './ColumnAutocomplete';
|
||||
|
||||
const server = setupServer(
|
||||
|
||||
@@ -187,11 +187,11 @@ export default async function fetchTable({
|
||||
const queryError = responseData as QueryError;
|
||||
const schemaNotFound =
|
||||
POSTGRESQL_ERROR_CODES.SCHEMA_NOT_FOUND ===
|
||||
queryError.internal.error.status_code;
|
||||
queryError.internal?.error?.status_code;
|
||||
|
||||
const tableNotFound =
|
||||
POSTGRESQL_ERROR_CODES.TABLE_NOT_FOUND ===
|
||||
queryError.internal.error.status_code;
|
||||
queryError.internal?.error?.status_code;
|
||||
|
||||
if (schemaNotFound || tableNotFound) {
|
||||
return {
|
||||
@@ -203,7 +203,7 @@ export default async function fetchTable({
|
||||
}
|
||||
|
||||
if (
|
||||
queryError.internal.error.status_code ===
|
||||
queryError.internal?.error?.status_code ===
|
||||
POSTGRESQL_ERROR_CODES.COLUMNS_NOT_FOUND
|
||||
) {
|
||||
return {
|
||||
@@ -214,7 +214,7 @@ export default async function fetchTable({
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(queryError.internal.error.message);
|
||||
throw new Error(queryError.internal?.error?.message);
|
||||
}
|
||||
|
||||
if ('error' in responseData) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import type { InputProps } from '@/components/ui/v2/Input';
|
||||
@@ -91,28 +92,33 @@ export default function DatabaseConnectionInfo() {
|
||||
disabled
|
||||
value={inputValue}
|
||||
className={className}
|
||||
slotProps={{ inputRoot: { className: '!pr-8 truncate' } }}
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
endAdornment={
|
||||
name !== 'postgresPassword' && (
|
||||
<InputAdornment position="end" className="absolute right-2">
|
||||
<Button
|
||||
sx={{ minWidth: 0, padding: 0 }}
|
||||
color="secondary"
|
||||
variant="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(inputValue as string, `${label}`);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
)
|
||||
<InputAdornment position="end" className="absolute right-2">
|
||||
<Button
|
||||
sx={{ minWidth: 0, padding: 0 }}
|
||||
color="secondary"
|
||||
variant="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy(inputValue as string, `${label}`);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
<Alert severity="info" className="col-span-6 text-left">
|
||||
To connect to the Postgres database directly, generate a new password,
|
||||
securely save it, and then modify your connection string with the newly
|
||||
created password.
|
||||
</Alert>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export type DatabaseServiceVersionFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
const AVAILABLE_POSTGRES_VERSIONS = [
|
||||
'14.6-20230705-1',
|
||||
'14.6-20230613-1',
|
||||
'14.6-20230525',
|
||||
'14.6-20230406-2',
|
||||
'14.6-20230406-1',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
@@ -6,30 +7,27 @@ import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||
import { generateRandomDatabasePassword } from '@/features/database/common/utils/generateRandomDatabasePassword';
|
||||
import type { ResetDatabasePasswordFormValues } from '@/features/database/settings/utils/resetDatabasePasswordValidationSchema';
|
||||
import { resetDatabasePasswordValidationSchema } from '@/features/database/settings/utils/resetDatabasePasswordValidationSchema';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
useResetPostgresPasswordMutation,
|
||||
useUpdateApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useResetDatabasePasswordMutation } from '@/generated/graphql';
|
||||
import { useLeaveConfirm } from '@/hooks/useLeaveConfirm';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { alpha } from '@mui/system';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface ResetDatabasePasswordFormValues {
|
||||
/**
|
||||
* The new password to set for the database.
|
||||
*/
|
||||
databasePassword: string;
|
||||
}
|
||||
|
||||
export default function ResetDatabasePasswordSettings() {
|
||||
const [updateApplication] = useUpdateApplicationMutation();
|
||||
const [resetPassword, { loading: resetPasswordLoading }] =
|
||||
useResetDatabasePasswordMutation();
|
||||
const { maintenanceActive } = useUI();
|
||||
const user = useUserData();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { openAlertDialog } = useDialog();
|
||||
|
||||
const form = useForm<ResetDatabasePasswordFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
@@ -46,41 +44,36 @@ export default function ResetDatabasePasswordSettings() {
|
||||
setValue,
|
||||
getValues,
|
||||
register,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
formState: { errors, dirtyFields, isSubmitting },
|
||||
} = form;
|
||||
|
||||
const [resetPostgresPasswordMutation] = useResetPostgresPasswordMutation();
|
||||
const user = useUserData();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
const handleGenerateRandomPassword = () => {
|
||||
useLeaveConfirm({ isDirty });
|
||||
|
||||
function handleGenerateRandomPassword() {
|
||||
const newRandomDatabasePassword = generateRandomDatabasePassword();
|
||||
triggerToast('New random database password generated.');
|
||||
triggerToast(
|
||||
'Random database password was generated and copied to clipboard. Submit the form to save it.',
|
||||
);
|
||||
copy(newRandomDatabasePassword);
|
||||
setValue('databasePassword', newRandomDatabasePassword, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const handleChangeDatabasePassword = async (
|
||||
values: ResetDatabasePasswordFormValues,
|
||||
) => {
|
||||
async function handleChangeDatabasePassword(
|
||||
formValues: ResetDatabasePasswordFormValues,
|
||||
) {
|
||||
try {
|
||||
await resetPostgresPasswordMutation({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
newPassword: values.databasePassword,
|
||||
},
|
||||
});
|
||||
await updateApplication({
|
||||
await resetPassword({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
app: {
|
||||
postgresPassword: values.databasePassword,
|
||||
},
|
||||
newPassword: formValues.databasePassword,
|
||||
},
|
||||
});
|
||||
|
||||
form.reset(values);
|
||||
form.reset(formValues);
|
||||
|
||||
triggerToast(
|
||||
`The database password for ${currentProject.name} has been updated successfully.`,
|
||||
@@ -93,24 +86,45 @@ export default function ResetDatabasePasswordSettings() {
|
||||
`An error occurred while trying to update the database password: ${currentProject.name} (${user.email}): ${e.message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleSubmit(formValues: ResetDatabasePasswordFormValues) {
|
||||
openAlertDialog({
|
||||
title: 'Confirm Change',
|
||||
payload: 'Are you sure you want to change the database password?',
|
||||
props: {
|
||||
primaryButtonColor: 'error',
|
||||
primaryButtonText: 'Confirm',
|
||||
onPrimaryAction: () => handleChangeDatabasePassword(formValues),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleChangeDatabasePassword}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Reset Password"
|
||||
description="This password is used for accessing your database."
|
||||
submitButtonText="Reset"
|
||||
description="This password will be used for accessing your database."
|
||||
submitButtonText="Save"
|
||||
slotProps={{
|
||||
root: {
|
||||
sx: { borderColor: (theme) => theme.palette.error.main },
|
||||
sx: {
|
||||
borderColor: (theme) =>
|
||||
isDirty
|
||||
? theme.palette.error.main
|
||||
: alpha(theme.palette.error.main, 0.5),
|
||||
'@media (prefers-reduced-motion: no-preference)': {
|
||||
transition: (theme) =>
|
||||
theme.transitions.create('border-color'),
|
||||
},
|
||||
},
|
||||
},
|
||||
submitButton: {
|
||||
variant: 'contained',
|
||||
color: 'error',
|
||||
variant: isDirty ? 'contained' : 'outlined',
|
||||
color: isDirty ? 'error' : 'secondary',
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: isSubmitting,
|
||||
loading: isSubmitting || resetPasswordLoading,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row pb-4"
|
||||
@@ -126,6 +140,7 @@ export default function ResetDatabasePasswordSettings() {
|
||||
hideEmptyHelperText
|
||||
slotProps={{
|
||||
input: { className: 'lg:w-1/2' },
|
||||
inputRoot: { className: '!pr-8' },
|
||||
helperText: { component: 'div' },
|
||||
}}
|
||||
helperText={
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from './ResetDatabasePasswordSettings';
|
||||
export { default as ResetDatabasePasswordSettings } from './ResetDatabasePasswordSettings';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation ResetDatabasePassword($appId: String!, $newPassword: String!) {
|
||||
resetPostgresPassword(appID: $appId, newPassword: $newPassword)
|
||||
}
|
||||
@@ -16,4 +16,8 @@ export const resetDatabasePasswordValidationSchema = yup.object().shape({
|
||||
.minUppercase(1),
|
||||
});
|
||||
|
||||
export type ResetDatabasePasswordFormValues = yup.InferType<
|
||||
typeof resetDatabasePasswordValidationSchema
|
||||
>;
|
||||
|
||||
export default resetDatabasePasswordValidationSchema;
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
defaultLocalBackendSlugs,
|
||||
defaultRemoteBackendSlugs,
|
||||
generateAppServiceUrl,
|
||||
} from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getHasuraConsoleServiceUrl } from '@/utils/env';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface HasuraConnectionInfoProps {
|
||||
close?: () => void;
|
||||
}
|
||||
|
||||
export default function HasuraConnectionInfo({
|
||||
close,
|
||||
}: HasuraConnectionInfoProps) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isPlatform = useIsPlatform();
|
||||
const projectAdminSecret = currentProject?.config?.hasura.adminSecret;
|
||||
|
||||
if (!currentProject?.subdomain || !projectAdminSecret) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
const hasuraUrl =
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev' || !isPlatform
|
||||
? `${getHasuraConsoleServiceUrl()}`
|
||||
: generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
'hasura',
|
||||
defaultLocalBackendSlugs,
|
||||
{ ...defaultRemoteBackendSlugs, hasura: '/console' },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md px-6 py-4 text-left">
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<div className="mx-auto">
|
||||
<Image
|
||||
src="/assets/hasuramodal.svg"
|
||||
width={72}
|
||||
height={72}
|
||||
alt="Hasura"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text variant="h3" component="h1" className="text-center">
|
||||
Open Hasura
|
||||
</Text>
|
||||
|
||||
<Text className="text-center">
|
||||
Hasura is the dashboard you'll use to edit your schema and
|
||||
permissions as well as browse data. Copy the admin secret to your
|
||||
clipboard and enter it in the next screen.
|
||||
</Text>
|
||||
|
||||
<Box className="mt-6 border-y-1">
|
||||
<div className="grid w-full grid-cols-1 place-content-between items-center py-2 sm:grid-cols-3">
|
||||
<Text className="col-span-1 text-center font-medium sm:justify-start sm:text-left">
|
||||
Admin Secret
|
||||
</Text>
|
||||
|
||||
<div className="col-span-1 grid grid-flow-col items-center justify-center gap-2 sm:col-span-2 sm:justify-end">
|
||||
<Text className="font-medium" variant="subtitle2">
|
||||
{Array(projectAdminSecret.length).fill('•').join('')}
|
||||
</Text>
|
||||
|
||||
<IconButton
|
||||
onClick={() => copy(projectAdminSecret, 'Hasura admin secret')}
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="min-w-0 p-1"
|
||||
aria-label="Copy admin secret"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div className="mt-6 grid grid-flow-row gap-2">
|
||||
<Button
|
||||
href={hasuraUrl}
|
||||
// Both `target` and `rel` are available when `href` is set. This is
|
||||
// a limitation of MUI.
|
||||
// @ts-ignore
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
endIcon={<ArrowSquareOutIcon className="h-4 w-4" />}
|
||||
>
|
||||
Open Hasura
|
||||
</Button>
|
||||
|
||||
{close && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className="text-sm+ font-normal"
|
||||
onClick={close}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as HasuraConnectionInfo } from './HasuraConnectionInfo';
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
useGetHasuraSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
export type HasuraAllowListFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function HasuraAllowListSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetHasuraSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetHasuraSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const { enableAllowList } = data?.config?.hasura.settings || {};
|
||||
|
||||
const form = useForm<HasuraAllowListFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled: enableAllowList,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading allow list settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleSubmit(formValues: HasuraAllowListFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
hasura: {
|
||||
settings: {
|
||||
enableAllowList: formValues.enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Allow list settings are being updated...`,
|
||||
success: `Allow list settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update allow list settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Allow List"
|
||||
description="Safely allow a limited number of GraphQL queries, mutations and subscriptions for your project."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !form.formState.isDirty || maintenanceActive,
|
||||
loading: form.formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
switchId="enabled"
|
||||
docsTitle="enabling or disabling Allow Lists"
|
||||
docsLink="https://hasura.io/learn/graphql/hasura-advanced/security/3-allow-list/"
|
||||
showSwitch
|
||||
className="hidden"
|
||||
/>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './HasuraAllowListSettings';
|
||||
export { default as HasuraAllowListSettings } from './HasuraAllowListSettings';
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
useGetHasuraSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
export type HasuraConsoleFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function HasuraConsoleSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetHasuraSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetHasuraSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const { enableConsole } = data?.config?.hasura.settings || {};
|
||||
|
||||
const form = useForm<HasuraConsoleFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled: enableConsole,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Hasura Console settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleSubmit(formValues: HasuraConsoleFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
hasura: {
|
||||
settings: {
|
||||
enableConsole: formValues.enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Hasura Console settings are being updated...`,
|
||||
success: `Hasura Console settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update Hasura Console settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Hasura Console"
|
||||
description="Enable or disable the Hasura Console. This will enable or disable the Hasura Console on the dashboard as well."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !form.formState.isDirty || maintenanceActive,
|
||||
loading: form.formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
switchId="enabled"
|
||||
docsTitle="enabling or disabling the Hasura Console"
|
||||
docsLink="https://hasura.io/docs/latest/deployment/graphql-engine-flags/reference/#enable-console"
|
||||
showSwitch
|
||||
className="hidden"
|
||||
/>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './HasuraConsoleSettings';
|
||||
export { default as HasuraConsoleSettings } from './HasuraConsoleSettings';
|
||||
@@ -0,0 +1,93 @@
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import { render, screen, waitFor } from '@/tests/testUtils';
|
||||
import { graphql } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { beforeAll, expect, test } from 'vitest';
|
||||
import HasuraCorsDomainSettings from './HasuraCorsDomainSettings';
|
||||
|
||||
const server = setupServer(
|
||||
tokenQuery,
|
||||
graphql.query('GetHasuraSettings', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
config: {
|
||||
id: 'HasuraSettings',
|
||||
__typename: 'HasuraSettings',
|
||||
hasura: {
|
||||
version: 'v2.25.1-ce',
|
||||
settings: {
|
||||
corsDomain: ['*'],
|
||||
enableAllowList: false,
|
||||
enableRemoteSchemaPermissions: false,
|
||||
enableConsole: false,
|
||||
devMode: false,
|
||||
enabledAPIs: [],
|
||||
},
|
||||
logs: [],
|
||||
events: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('should not enable switch by default when CORS domain is set to *', async () => {
|
||||
render(<HasuraCorsDomainSettings />);
|
||||
|
||||
expect(await screen.findByText(/configure cors/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('checkbox')).not.toBeChecked();
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should enable switch by default when CORS domain is set to one or more domains', async () => {
|
||||
server.use(
|
||||
graphql.query('GetHasuraSettings', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
config: {
|
||||
id: 'HasuraSettings',
|
||||
__typename: 'HasuraSettings',
|
||||
hasura: {
|
||||
version: 'v2.25.1-ce',
|
||||
settings: {
|
||||
corsDomain: ['https://example.com', 'https://*.example.com'],
|
||||
enableAllowList: false,
|
||||
enableRemoteSchemaPermissions: false,
|
||||
enableConsole: false,
|
||||
devMode: false,
|
||||
enabledAPIs: [],
|
||||
},
|
||||
logs: [],
|
||||
events: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<HasuraCorsDomainSettings />);
|
||||
|
||||
expect(await screen.findByText(/configure cors/i)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox')).toBeChecked());
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toHaveValue(
|
||||
'https://example.com, https://*.example.com',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
useGetHasuraSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
corsDomain: Yup.string()
|
||||
.label('Allowed CORS domains')
|
||||
.when('enabled', {
|
||||
is: true,
|
||||
then: (schema) => schema.required(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type HasuraCorsDomainFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function HasuraCorsDomainSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetHasuraSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetHasuraSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const { corsDomain } = data?.config?.hasura.settings || {};
|
||||
|
||||
const form = useForm<HasuraCorsDomainFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
values: {
|
||||
enabled:
|
||||
corsDomain && corsDomain.length === 1
|
||||
? corsDomain[0] !== '*'
|
||||
: !!corsDomain?.length,
|
||||
corsDomain:
|
||||
corsDomain && corsDomain.length === 1 && corsDomain[0] !== '*'
|
||||
? corsDomain[0]
|
||||
: corsDomain?.join(', ') || '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState, watch } = form;
|
||||
const enabled = watch('enabled');
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading CORS domain settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleSubmit(formValues: HasuraCorsDomainFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
hasura: {
|
||||
settings: {
|
||||
corsDomain: formValues.enabled
|
||||
? formValues.corsDomain
|
||||
.split(',')
|
||||
.map((domain) => domain.trim())
|
||||
: ['*'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `CORS domain settings are being updated...`,
|
||||
success: `CORS domain settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's CORS domain settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Configure CORS"
|
||||
description="Allow requests from specific domains to access your GraphQL API. Disable this setting to allow requests from all domains."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
docsTitle="CORS configuration"
|
||||
docsLink="https://hasura.io/docs/latest/deployment/graphql-engine-flags/config-examples/#configure-cors"
|
||||
className={twMerge(
|
||||
'grid grid-cols-5 gap-4 px-4',
|
||||
!enabled && 'hidden',
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
{...register('corsDomain')}
|
||||
label="Allowed CORS domains"
|
||||
placeholder="https://example.com, https://*.example.com"
|
||||
id="corsDomain"
|
||||
fullWidth
|
||||
className="col-span-5 lg:col-span-2"
|
||||
error={Boolean(formState.errors.corsDomain)}
|
||||
aria-hidden={!enabled}
|
||||
helperText={formState.errors.corsDomain?.message}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './HasuraCorsDomainSettings';
|
||||
export { default as HasuraCorsDomainSettings } from './HasuraCorsDomainSettings';
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
useGetHasuraSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
export type HasuraDevModeFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function HasuraDevModeSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetHasuraSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetHasuraSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const { devMode } = data?.config?.hasura.settings || {};
|
||||
|
||||
const form = useForm<HasuraDevModeFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled: devMode,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading Dev Mode settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleSubmit(formValues: HasuraDevModeFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
hasura: {
|
||||
settings: {
|
||||
enableConsole: formValues.enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Dev Mode settings are being updated...`,
|
||||
success: `Dev Mode settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update Dev Mode settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Dev Mode"
|
||||
description="Enable or disable Dev Mode."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !form.formState.isDirty || maintenanceActive,
|
||||
loading: form.formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
switchId="enabled"
|
||||
docsTitle="enabling or disabling Dev Mode"
|
||||
docsLink="https://hasura.io/learn/graphql/hasura-advanced/debugging/1-dev-mode/"
|
||||
showSwitch
|
||||
className="hidden"
|
||||
/>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './HasuraDevModeSettings';
|
||||
export { default as HasuraDevModeSettings } from './HasuraDevModeSettings';
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
useGetHasuraSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
enabledAPIs: Yup.array(
|
||||
Yup.object({
|
||||
label: Yup.string().required(),
|
||||
value: Yup.string().required(),
|
||||
}),
|
||||
)
|
||||
.label('Enabled Hasura APIs')
|
||||
.required(),
|
||||
});
|
||||
|
||||
export type HasuraEnabledAPIFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
const AVAILABLE_HASURA_APIS = ['metadata', 'graphql', 'pgdump', 'config'];
|
||||
|
||||
export default function HasuraEnabledAPISettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetHasuraSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetHasuraSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { enabledAPIs } = data?.config?.hasura.settings || {};
|
||||
|
||||
const form = useForm<HasuraEnabledAPIFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabledAPIs: enabledAPIs.map((api) => ({
|
||||
label: api,
|
||||
value: api,
|
||||
})),
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading enabled APIs..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const availableAPIs = AVAILABLE_HASURA_APIS.map((api) => ({
|
||||
label: api,
|
||||
value: api,
|
||||
}));
|
||||
|
||||
async function handleSubmit(formValues: HasuraEnabledAPIFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
hasura: {
|
||||
settings: {
|
||||
enabledAPIs: formValues.enabledAPIs.map((api) => api.value),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Enabled APIs are being updated...`,
|
||||
success: `Enabled APIs have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update enabled APIs.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Enabled APIs"
|
||||
description="Enable or disable APIs for your Hasura instance."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row gap-y-2 gap-x-4 px-4 lg:grid-cols-6"
|
||||
>
|
||||
<ControlledAutocomplete
|
||||
id="enabledAPIs"
|
||||
name="enabledAPIs"
|
||||
fullWidth
|
||||
multiple
|
||||
className="lg:col-span-3"
|
||||
aria-label="Enabled APIs"
|
||||
options={availableAPIs}
|
||||
error={!!formState.errors?.enabledAPIs?.message}
|
||||
helperText={formState.errors?.enabledAPIs?.message}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './HasuraEnabledAPISettings';
|
||||
export { default as HasuraEnabledAPISettings } from './HasuraEnabledAPISettings';
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { HighlightedText } from '@/components/presentational/HighlightedText';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
useGetHasuraSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
logLevel: Yup.object({
|
||||
label: Yup.string().required(),
|
||||
value: Yup.string().required(),
|
||||
})
|
||||
.label('Log level')
|
||||
.required(),
|
||||
});
|
||||
|
||||
export type HasuraLogLevelFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
const AVAILABLE_HASURA_LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
|
||||
|
||||
export default function HasuraLogLevelSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetHasuraSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetHasuraSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const { level } = data?.config?.hasura.logs || {};
|
||||
|
||||
const form = useForm<HasuraLogLevelFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
logLevel: level
|
||||
? {
|
||||
label: level,
|
||||
value: level,
|
||||
}
|
||||
: { label: 'warn', value: 'warn' },
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading log level settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const availableLogLevels = AVAILABLE_HASURA_LOG_LEVELS.map((api) => ({
|
||||
label: api,
|
||||
value: api,
|
||||
}));
|
||||
|
||||
async function handleSubmit(formValues: HasuraLogLevelFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
hasura: {
|
||||
logs: {
|
||||
level: formValues.logLevel?.value || 'warn',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Log level is being updated...`,
|
||||
success: `Log level has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update log level.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Log Level"
|
||||
description={
|
||||
<>
|
||||
Setting a log-level will print all logs of priority greater than
|
||||
the set level. The log-level hierarchy is:{' '}
|
||||
<HighlightedText>
|
||||
debug → info → warn → error
|
||||
</HighlightedText>
|
||||
</>
|
||||
}
|
||||
docsLink="https://hasura.io/docs/latest/deployment/logging/#logging-levels"
|
||||
docsTitle="Log Levels"
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row gap-y-2 gap-x-4 px-4 lg:grid-cols-5"
|
||||
>
|
||||
<ControlledAutocomplete
|
||||
id="logLevel"
|
||||
name="logLevel"
|
||||
fullWidth
|
||||
className="lg:col-span-2"
|
||||
aria-label="Hasura Log Level"
|
||||
options={availableLogLevels}
|
||||
error={!!formState.errors?.logLevel?.message}
|
||||
helperText={formState.errors?.logLevel?.message}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './HasuraLogLevelSettings';
|
||||
export { default as HasuraLogLevelSettings } from './HasuraLogLevelSettings';
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
useGetHasuraSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
httpPoolSize: Yup.number()
|
||||
.label('HTTP Pool Size')
|
||||
.min(1)
|
||||
.max(100)
|
||||
.typeError('HTTP Pool Size must be a number')
|
||||
.required(),
|
||||
});
|
||||
|
||||
export type HasuraPoolSizeFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function HasuraPoolSizeSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetHasuraSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetHasuraSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const { httpPoolSize } = data?.config?.hasura.events || {};
|
||||
|
||||
const form = useForm<HasuraPoolSizeFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: { httpPoolSize: httpPoolSize || 100 },
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading pool size settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, register } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
async function handleSubmit(formValues: HasuraPoolSizeFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
hasura: {
|
||||
events: {
|
||||
httpPoolSize: formValues.httpPoolSize,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Pool size is being updated...`,
|
||||
success: `Pool size has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the pool size.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="HTTP Pool Size"
|
||||
description="Set the maximum number of concurrent HTTP workers for event delivery."
|
||||
docsLink="https://hasura.io/docs/latest/deployment/graphql-engine-flags/reference/#events-http-pool-size"
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row gap-y-2 gap-x-4 px-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('httpPoolSize')}
|
||||
id="httpPoolSize"
|
||||
name="httpPoolSize"
|
||||
type="number"
|
||||
label="HTTP Pool Size"
|
||||
fullWidth
|
||||
className="lg:col-span-2"
|
||||
error={Boolean(formState.errors.httpPoolSize?.message)}
|
||||
helperText={formState.errors.httpPoolSize?.message}
|
||||
slotProps={{ inputRoot: { min: 1, max: 100 } }}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HasuraPoolSizeSettings } from './HasuraPoolSizeSettings';
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import {
|
||||
GetHasuraSettingsDocument,
|
||||
useGetHasuraSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { getServerError } from '@/utils/getServerError';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
export type HasuraRemoteSchemaPermissionsFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function HasuraRemoteSchemaPermissionsSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetHasuraSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetHasuraSettingsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const { enableRemoteSchemaPermissions } = data?.config?.hasura.settings || {};
|
||||
|
||||
const form = useForm<HasuraRemoteSchemaPermissionsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled: enableRemoteSchemaPermissions,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading remote schema permission settings..."
|
||||
className="justify-center"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleSubmit(
|
||||
formValues: HasuraRemoteSchemaPermissionsFormValues,
|
||||
) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
config: {
|
||||
hasura: {
|
||||
settings: {
|
||||
enableConsole: formValues.enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Remote schema permission settings are being updated...`,
|
||||
success: `Remote schema permission settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update remote schema permission settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Remote Schema Permissions"
|
||||
description="Enable or disable remote schema permissions."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !form.formState.isDirty || maintenanceActive,
|
||||
loading: form.formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
switchId="enabled"
|
||||
docsTitle="enabling or disabling Remote Schema Permissions"
|
||||
docsLink="https://hasura.io/docs/latest/remote-schemas/auth/remote-schema-permissions/"
|
||||
showSwitch
|
||||
className="hidden"
|
||||
/>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './HasuraRemoteSchemaPermissionsSettings';
|
||||
export { default as HasuraRemoteSchemaPermissionsSettings } from './HasuraRemoteSchemaPermissionsSettings';
|
||||
@@ -31,6 +31,9 @@ export type HasuraServiceVersionFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
const AVAILABLE_HASURA_VERSIONS = [
|
||||
'v2.29.0-ce',
|
||||
'v2.28.2-ce',
|
||||
'v2.27.0-ce',
|
||||
'v2.25.1-ce',
|
||||
'v2.25.0-ce',
|
||||
'v2.24.1-ce',
|
||||
@@ -39,7 +42,8 @@ const AVAILABLE_HASURA_VERSIONS = [
|
||||
|
||||
export default function HasuraServiceVersionSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { currentProject, refetch: refetchWorkspaceAndProject } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetHasuraSettingsDocument],
|
||||
});
|
||||
@@ -82,9 +86,7 @@ export default function HasuraServiceVersionSettings() {
|
||||
|
||||
const { formState } = form;
|
||||
|
||||
const handleHasuraServiceVersionsChange = async (
|
||||
formValues: HasuraServiceVersionFormValues,
|
||||
) => {
|
||||
async function handleSubmit(formValues: HasuraServiceVersionFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject.id,
|
||||
@@ -110,14 +112,15 @@ export default function HasuraServiceVersionSettings() {
|
||||
);
|
||||
|
||||
form.reset(formValues);
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleHasuraServiceVersionsChange}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Hasura GraphQL Engine Version"
|
||||
description="The version of the Hasura GraphQL Engine to use."
|
||||
@@ -143,6 +146,7 @@ export default function HasuraServiceVersionSettings() {
|
||||
}}
|
||||
fullWidth
|
||||
className="lg:col-span-2"
|
||||
aria-label="Hasura Service Version"
|
||||
options={availableVersions}
|
||||
error={!!formState.errors?.version?.message}
|
||||
helperText={formState.errors?.version?.message}
|
||||
|
||||
@@ -4,6 +4,20 @@ query GetHasuraSettings($appId: uuid!) {
|
||||
__typename
|
||||
hasura {
|
||||
version
|
||||
settings {
|
||||
enableAllowList
|
||||
enableRemoteSchemaPermissions
|
||||
enableConsole
|
||||
devMode
|
||||
corsDomain
|
||||
enabledAPIs
|
||||
}
|
||||
logs {
|
||||
level
|
||||
}
|
||||
events {
|
||||
httpPoolSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user