Compare commits
417 Commits
@nhost/das
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa045eed15 | ||
|
|
61c0583b6d | ||
|
|
1343a6f252 | ||
|
|
0d73e87a83 | ||
|
|
1ee0d332bf | ||
|
|
130ce49c76 | ||
|
|
6be6d6475a | ||
|
|
177b146b93 | ||
|
|
3cb673000a | ||
|
|
09cf5d4b39 | ||
|
|
48c0061a0b | ||
|
|
0795d1c6a1 | ||
|
|
45a23dd1bf | ||
|
|
bb8e3454df | ||
|
|
6a290bb297 | ||
|
|
80baec7356 | ||
|
|
8e43297564 | ||
|
|
bb8eb9e387 | ||
|
|
5b0dc6cb19 | ||
|
|
826112afd0 | ||
|
|
97105c390d | ||
|
|
8e3707ff2c | ||
|
|
7453bf3b6a | ||
|
|
bd739383d2 | ||
|
|
f75e2e41db | ||
|
|
7328491be0 | ||
|
|
11b4d12f12 | ||
|
|
6c24d56b1d | ||
|
|
0a523f4b45 | ||
|
|
12301e6551 | ||
|
|
8440d0389e | ||
|
|
c166dad0f8 | ||
|
|
e31d39b3d2 | ||
|
|
090f9cef86 | ||
|
|
74e52cac2d | ||
|
|
f17823760a | ||
|
|
bb8803a1e3 | ||
|
|
b846291331 | ||
|
|
2b2fb94f00 | ||
|
|
551760c4f0 | ||
|
|
5ae5a8e77d | ||
|
|
56aae0c964 | ||
|
|
a0e093d77b | ||
|
|
5e82e1b3da | ||
|
|
e618b705e7 | ||
|
|
a232c9f0f6 | ||
|
|
bf4644ea10 | ||
|
|
0aca907ea4 | ||
|
|
394f4c4174 | ||
|
|
8fef08a150 | ||
|
|
1bd2c37301 | ||
|
|
5cdb70bd81 | ||
|
|
1a5f80e1b6 | ||
|
|
59e0cb00c5 | ||
|
|
406b0f2cb7 | ||
|
|
d329b6218f | ||
|
|
335b58670e | ||
|
|
efa2d89067 | ||
|
|
77ce4bd738 | ||
|
|
017adea700 | ||
|
|
378284faa8 | ||
|
|
e5e2d114b1 | ||
|
|
5e3dbdeb7d | ||
|
|
98b777491a | ||
|
|
71de870cb0 | ||
|
|
74d4deba28 | ||
|
|
cb248f0d30 | ||
|
|
09e4f1eb34 | ||
|
|
19818e2b59 | ||
|
|
6e1f03eaee | ||
|
|
b3eeec82ef | ||
|
|
34ff254696 | ||
|
|
867c807699 | ||
|
|
1c4806bf51 | ||
|
|
2fb82ec97d | ||
|
|
d0673d7825 | ||
|
|
106f23dcfa | ||
|
|
0c994a9651 | ||
|
|
83ef755822 | ||
|
|
b7703ffd70 | ||
|
|
4713cecfc2 | ||
|
|
340ea5b115 | ||
|
|
f79eebadbf | ||
|
|
ac174b5e51 | ||
|
|
dc9ddfc9ae | ||
|
|
3bdd9f570c | ||
|
|
94477be998 | ||
|
|
568577e8ca | ||
|
|
e93b06ab8f | ||
|
|
c75bf46ba1 | ||
|
|
63a1fd09b5 | ||
|
|
630d44ad6e | ||
|
|
d7db521974 | ||
|
|
90e4053f0a | ||
|
|
8e9d5d1b38 | ||
|
|
43c86fef14 | ||
|
|
6b97340cf4 | ||
|
|
1605756362 | ||
|
|
6437544384 | ||
|
|
776eca3fb5 | ||
|
|
b4dcd1996d | ||
|
|
7fb73dbb1b | ||
|
|
a66b11d245 | ||
|
|
912ed76c64 | ||
|
|
b47c0d1af7 | ||
|
|
b97ab2be2f | ||
|
|
f12cb666ff | ||
|
|
c3b2b1cd02 | ||
|
|
c0b71102d4 | ||
|
|
5f68ae95c4 | ||
|
|
2d1b7bb292 | ||
|
|
ee154d4eca | ||
|
|
58ef9bbe02 | ||
|
|
f3f35beefd | ||
|
|
d31330e6c0 | ||
|
|
c3dda79d95 | ||
|
|
7c1273725d | ||
|
|
70be0e1ab4 | ||
|
|
4f5870cfd7 | ||
|
|
623607476e | ||
|
|
1e232713d9 | ||
|
|
1ed647c4e9 | ||
|
|
b666a173b1 | ||
|
|
caba147b32 | ||
|
|
ca365fc8e7 | ||
|
|
d88cdedb26 | ||
|
|
1de08cecaf | ||
|
|
47bb997036 | ||
|
|
4e4d962f30 | ||
|
|
883fb82c77 | ||
|
|
c9f5634ac2 | ||
|
|
6ee9a589fb | ||
|
|
e2d733cf34 | ||
|
|
a0d7327c8d | ||
|
|
c7c8a20334 | ||
|
|
cca8de5805 | ||
|
|
8c065c42d6 | ||
|
|
210af3a3e8 | ||
|
|
fbb12a8079 | ||
|
|
77692ac40e | ||
|
|
2c2a42a8e8 | ||
|
|
a8466798a3 | ||
|
|
a45c0970bb | ||
|
|
9bf30a1ccc | ||
|
|
99d3d82c72 | ||
|
|
43acb3fb50 | ||
|
|
ba9ef13ba3 | ||
|
|
cea507a271 | ||
|
|
9130ab1230 | ||
|
|
27acdd6f56 | ||
|
|
dcdacd73ec | ||
|
|
9c9966a30f | ||
|
|
5a23e7a0a8 | ||
|
|
47500fac39 | ||
|
|
cbbf53c05b | ||
|
|
11bd011860 | ||
|
|
e3c0c47777 | ||
|
|
d825404b54 | ||
|
|
d46d77ee71 | ||
|
|
a292482705 | ||
|
|
8a4ca41172 | ||
|
|
fd3ce98600 | ||
|
|
04f36a0491 | ||
|
|
5e2ecb4d1e | ||
|
|
eca9e551e8 | ||
|
|
52ebbef762 | ||
|
|
82faa4ca0a | ||
|
|
d06a21764a | ||
|
|
8b54d290a5 | ||
|
|
4cfa6bbe1e | ||
|
|
614f213e26 | ||
|
|
4eebf51821 | ||
|
|
9a52298aa7 | ||
|
|
099eebe602 | ||
|
|
7cce8652e7 | ||
|
|
f2e2323801 | ||
|
|
4e16de6db2 | ||
|
|
798e591b1d | ||
|
|
b48bc034ca | ||
|
|
f57819230b | ||
|
|
3d8067ff7b | ||
|
|
0fa4b428a9 | ||
|
|
8c5864340e | ||
|
|
c131100af9 | ||
|
|
e363fef8cf | ||
|
|
d8072101c8 | ||
|
|
afbba531a1 | ||
|
|
4b6df8b9d6 | ||
|
|
a2af5a674d | ||
|
|
c33c1fd6b9 | ||
|
|
041d9b98e3 | ||
|
|
e4b4940397 | ||
|
|
be91f4ed2a | ||
|
|
ec6ba846cf | ||
|
|
a9e9fc4305 | ||
|
|
d8d8394b3b | ||
|
|
f051a121b2 | ||
|
|
c547b490e5 | ||
|
|
4f4449b855 | ||
|
|
6ed46ce2d4 | ||
|
|
bfb4c1a6cc | ||
|
|
776c8f9237 | ||
|
|
c0773d82e9 | ||
|
|
c46b1383f2 | ||
|
|
beed2eba21 | ||
|
|
70f9610041 | ||
|
|
e91de1088d | ||
|
|
ce1ee40dab | ||
|
|
bd7929f5ed | ||
|
|
2c8559a319 | ||
|
|
bd5ea5ee3a | ||
|
|
3538dbac39 | ||
|
|
03b5cda69a | ||
|
|
4329d04854 | ||
|
|
ca50c5ce0c | ||
|
|
a3271ed014 | ||
|
|
d4fc99a77c | ||
|
|
d90fcf3c24 | ||
|
|
ee70b226fc | ||
|
|
227ef968e6 | ||
|
|
430b37b2e1 | ||
|
|
124620c33e | ||
|
|
ce3ece1ad7 | ||
|
|
c81002622c | ||
|
|
35fa6bb043 | ||
|
|
a4469a5942 | ||
|
|
b8f11a13d7 | ||
|
|
1d1555593f | ||
|
|
001b3dccec | ||
|
|
6755dfb17b | ||
|
|
2ac90dfdec | ||
|
|
093f3906a4 | ||
|
|
6fb81a27ba | ||
|
|
9be41bf594 | ||
|
|
cbb1fc5bc8 | ||
|
|
99fcc36250 | ||
|
|
7e4a756cfe | ||
|
|
5bf61583e0 | ||
|
|
7eac17a1cb | ||
|
|
a41aeeb9ef | ||
|
|
e33df513ff | ||
|
|
323fd5cbe3 | ||
|
|
0ec3abf47c | ||
|
|
ffb3c426d3 | ||
|
|
889ee6589e | ||
|
|
ae19105302 | ||
|
|
730a482598 | ||
|
|
b00d261916 | ||
|
|
6e05ab4628 | ||
|
|
5223ee9353 | ||
|
|
c8c5ace7cc | ||
|
|
c6a4c28579 | ||
|
|
850a049ca2 | ||
|
|
eff3f0aefd | ||
|
|
2b1338f716 | ||
|
|
2b58c60747 | ||
|
|
1b2e3fbd1d | ||
|
|
6f4fdcf73f | ||
|
|
cb529dc60c | ||
|
|
68a449dbfc | ||
|
|
7d0c6d083a | ||
|
|
1353477da1 | ||
|
|
549c7cb7eb | ||
|
|
e131c12d5d | ||
|
|
8bb097c9a7 | ||
|
|
ea31e64a71 | ||
|
|
369b931689 | ||
|
|
e1ec5c1be2 | ||
|
|
9822a160d4 | ||
|
|
7c67a2c437 | ||
|
|
8e8884f4e1 | ||
|
|
9923be41ce | ||
|
|
3141ce5b68 | ||
|
|
9c22a616a7 | ||
|
|
6bc67e95a5 | ||
|
|
0f6074c16f | ||
|
|
c96d7ccdf2 | ||
|
|
fde7ac7c1c | ||
|
|
24ef6071cc | ||
|
|
bb993b6b03 | ||
|
|
89ca34be9a | ||
|
|
b66d095c95 | ||
|
|
0bad9ff4fa | ||
|
|
9a761f4fec | ||
|
|
253dd235ca | ||
|
|
991e8f2d15 | ||
|
|
e500e87022 | ||
|
|
bd6b55868a | ||
|
|
afb3fe490e | ||
|
|
c684d0307b | ||
|
|
eaebd2b028 | ||
|
|
f03ecd91a9 | ||
|
|
96f17c39b1 | ||
|
|
2d657b9c29 | ||
|
|
cb7c8c6398 | ||
|
|
4bf40995b5 | ||
|
|
ab5f704280 | ||
|
|
f65e4de955 | ||
|
|
7e0e4d05aa | ||
|
|
0954a44f84 | ||
|
|
700cbd9e47 | ||
|
|
3238543b08 | ||
|
|
3f8d68ffab | ||
|
|
f7e706724c | ||
|
|
f46d96bafc | ||
|
|
6b8acd35bd | ||
|
|
2832d7299f | ||
|
|
44c5b386c3 | ||
|
|
44ff6a059f | ||
|
|
5a91c477f0 | ||
|
|
66f73d06a8 | ||
|
|
35d52aab87 | ||
|
|
8261743bd3 | ||
|
|
ddd41aae99 | ||
|
|
34cf1d79a0 | ||
|
|
9d4542b3db | ||
|
|
bb5dbdf5a3 | ||
|
|
2801b03bf4 | ||
|
|
8298d458d5 | ||
|
|
6e9b941b89 | ||
|
|
5dd25941e5 | ||
|
|
832210d8ad | ||
|
|
a09dad060c | ||
|
|
76b63debf0 | ||
|
|
e88684ff2a | ||
|
|
095d6e918c | ||
|
|
6593e8d3eb | ||
|
|
9219838127 | ||
|
|
43b68a79eb | ||
|
|
ac845c6d92 | ||
|
|
cfcb97b8ee | ||
|
|
892ad66ba1 | ||
|
|
f4af81020b | ||
|
|
6999562b59 | ||
|
|
d167121093 | ||
|
|
822e251b11 | ||
|
|
328c6bb486 | ||
|
|
bef8198cbf | ||
|
|
179313d8a2 | ||
|
|
c3ce004f46 | ||
|
|
a1ffad77eb | ||
|
|
de4d59da99 | ||
|
|
7d577a68b7 | ||
|
|
982059e18e | ||
|
|
02c0586467 | ||
|
|
0753e6529c | ||
|
|
e87a14a3fe | ||
|
|
b45aa420d9 | ||
|
|
1d76de3f60 | ||
|
|
9e37ca4cbc | ||
|
|
af57ccce0f | ||
|
|
5f44aefcc6 | ||
|
|
168616df38 | ||
|
|
96f9278c8f | ||
|
|
9fe2ecd317 | ||
|
|
ada5309b49 | ||
|
|
08698f8246 | ||
|
|
0b56e31408 | ||
|
|
d8c45b452d | ||
|
|
c4e3e3f91f | ||
|
|
483fd6c7f4 | ||
|
|
ac37d7bcae | ||
|
|
9adf91ba87 | ||
|
|
d11f6eebb0 | ||
|
|
8a678fbc87 | ||
|
|
6411ec3ec3 | ||
|
|
5187fe76aa | ||
|
|
859f457e4a | ||
|
|
dc2b5b4429 | ||
|
|
b7645e7892 | ||
|
|
b1338246aa | ||
|
|
d04ccd600e | ||
|
|
d483ad5602 | ||
|
|
bcf3e6bc2c | ||
|
|
575ff4e9b5 | ||
|
|
2010638540 | ||
|
|
0346495a79 | ||
|
|
2babb0b6f3 | ||
|
|
1f293d0f0c | ||
|
|
af4c886437 | ||
|
|
c182b3ca4b | ||
|
|
d5344ed31f | ||
|
|
adeb2a6d90 | ||
|
|
921243e4d9 | ||
|
|
1c5178f5fb | ||
|
|
72ad9aa8ee | ||
|
|
1b45db8caf | ||
|
|
9ffb4d0295 | ||
|
|
e56340b792 | ||
|
|
814c6d997a | ||
|
|
7d7a352c33 | ||
|
|
53a704fc7d | ||
|
|
c23eddf33d | ||
|
|
d4147f4713 | ||
|
|
f375eaccf5 | ||
|
|
47f79ba9f3 | ||
|
|
2e010455cf | ||
|
|
7e63c822ec | ||
|
|
276b7d48c3 | ||
|
|
6925b0d510 | ||
|
|
6ff306c4e4 | ||
|
|
aa440fefe6 | ||
|
|
9fbafc6654 | ||
|
|
b086175045 | ||
|
|
36db12297b | ||
|
|
e5885d9bad | ||
|
|
15c13f3bbe | ||
|
|
8d47cafd86 | ||
|
|
408cb6d10c | ||
|
|
4d882703f2 | ||
|
|
437dacaa9e | ||
|
|
088584e79d | ||
|
|
ce4b655c55 | ||
|
|
dc57d31ec9 | ||
|
|
ea29fd6b73 | ||
|
|
d8e4073957 | ||
|
|
3f399a54a3 |
@@ -23,9 +23,7 @@ runs:
|
||||
- uses: actions/cache@v3
|
||||
id: pnpm-cache
|
||||
with:
|
||||
path: |
|
||||
${{ steps.pnpm-cache-dir.outputs.dir }}
|
||||
~/.cache/Cypress
|
||||
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
|
||||
|
||||
2
.github/workflows/changesets.yaml
vendored
2
.github/workflows/changesets.yaml
vendored
@@ -98,7 +98,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push to Docker Hub
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
timeout-minutes: 60
|
||||
with:
|
||||
context: .
|
||||
|
||||
101
.github/workflows/ci.yaml
vendored
101
.github/workflows/ci.yaml
vendored
@@ -19,6 +19,12 @@ env:
|
||||
NEXT_PUBLIC_ENV: dev
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337
|
||||
NHOST_TEST_DASHBOARD_URL: ${{ vars.NHOST_TEST_DASHBOARD_URL }}
|
||||
NHOST_TEST_WORKSPACE_NAME: ${{ vars.NHOST_TEST_WORKSPACE_NAME }}
|
||||
NHOST_TEST_PROJECT_NAME: ${{ vars.NHOST_TEST_PROJECT_NAME }}
|
||||
NHOST_TEST_USER_EMAIL: ${{ secrets.NHOST_TEST_USER_EMAIL }}
|
||||
NHOST_TEST_USER_PASSWORD: ${{ secrets.NHOST_TEST_USER_PASSWORD }}
|
||||
NHOST_TEST_PROJECT_ADMIN_SECRET: ${{ secrets.NHOST_TEST_PROJECT_ADMIN_SECRET }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -60,47 +66,6 @@ jobs:
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
|
||||
e2e:
|
||||
name: 'e2e (${{ matrix.package.path }})'
|
||||
needs: build
|
||||
if: ${{ needs.build.outputs.matrix != '[]' && needs.build.outputs.matrix != '' }}
|
||||
strategy:
|
||||
# * Don't cancel other matrices when one fails
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: ${{ fromJson(needs.build.outputs.matrix) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# * Install Node and dependencies. Package dependencies won't be downloaded again as they have been cached by the `build` job.
|
||||
- name: Install Node and dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
# * Install Nhost CLI if a `nhost/config.yaml` file is found
|
||||
- name: Install Nhost CLI
|
||||
if: hashFiles(format('{0}/nhost/config.yaml', matrix.package.path)) != ''
|
||||
uses: ./.github/actions/nhost-cli
|
||||
# * Run the `ci` script of the current package of the matrix. Dependencies build is cached by Turborepo
|
||||
- name: Run e2e test
|
||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e
|
||||
- id: file-name
|
||||
if: ${{ failure() }}
|
||||
name: Tranform package name into a valid file name
|
||||
run: |
|
||||
PACKAGE_FILE_NAME=$(echo "${{ matrix.package.name }}" | sed 's/@//g; s/\//-/g')
|
||||
echo "fileName=$PACKAGE_FILE_NAME" >> $GITHUB_OUTPUT
|
||||
# * Run this step only if the previous step failed, and some Cypress screenshots/videos exist
|
||||
- name: Upload Cypress videos and screenshots
|
||||
if: ${{ failure() && hashFiles(format('{0}/cypress/screenshots/**', matrix.package.path), format('{0}/cypress/videos/**', matrix.package.path)) != ''}}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: cypress-${{ steps.file-name.outputs.fileName }}
|
||||
path: |
|
||||
${{format('{0}/cypress/screenshots/**', matrix.package.path)}}
|
||||
${{format('{0}/cypress/videos/**', matrix.package.path)}}
|
||||
|
||||
unit:
|
||||
name: Unit tests
|
||||
needs: build
|
||||
@@ -141,3 +106,57 @@ jobs:
|
||||
# * Run every `lint` script in the workspace . Dependencies build is cached by Turborepo
|
||||
- name: Lint
|
||||
run: pnpm run lint:all
|
||||
|
||||
e2e:
|
||||
name: 'E2E (Package: ${{ matrix.package.path }})'
|
||||
needs: build
|
||||
if: ${{ needs.build.outputs.matrix != '[]' && needs.build.outputs.matrix != '' }}
|
||||
strategy:
|
||||
# * Don't cancel other matrices when one fails
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: ${{ fromJson(needs.build.outputs.matrix) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# * Install Node and dependencies. Package dependencies won't be downloaded again as they have been cached by the `build` job.
|
||||
- name: Install Node and dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
# * Install Nhost CLI if a `nhost/config.yaml` file is found
|
||||
- name: Install Nhost CLI
|
||||
if: hashFiles(format('{0}/nhost/config.yaml', matrix.package.path)) != ''
|
||||
uses: ./.github/actions/nhost-cli
|
||||
- name: Fetch Dashboard Preview URL
|
||||
id: fetch-dashboard-preview-url
|
||||
uses: zentered/vercel-preview-url@v1.1.9
|
||||
if: github.ref_name != 'main'
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
GITHUB_REF: ${{ github.ref_name }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
with:
|
||||
vercel_team_id: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||
vercel_project_id: ${{ secrets.DASHBOARD_STAGING_VERCEL_PROJECT_ID }}
|
||||
vercel_state: BUILDING,READY,INITIALIZING
|
||||
- name: Set Dashboard Preview URL
|
||||
if: steps.fetch-dashboard-preview-url.outputs.preview_url != ''
|
||||
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
|
||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e
|
||||
- id: file-name
|
||||
if: ${{ failure() }}
|
||||
name: Transform package name into a valid file name
|
||||
run: |
|
||||
PACKAGE_FILE_NAME=$(echo "${{ matrix.package.name }}" | sed 's/@//g; s/\//-/g')
|
||||
echo "fileName=$PACKAGE_FILE_NAME" >> $GITHUB_OUTPUT
|
||||
# * Run this step only if the previous step failed, and Playwright generated a report
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ failure() && hashFiles(format('{0}/playwright-report/**', matrix.package.path)) != ''}}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-${{ steps.file-name.outputs.fileName }}
|
||||
path: ${{format('{0}/playwright-report/**', matrix.package.path)}}
|
||||
|
||||
1
.github/workflows/dashboard.yaml
vendored
1
.github/workflows/dashboard.yaml
vendored
@@ -9,6 +9,7 @@ env:
|
||||
NEXT_PUBLIC_ENV: dev
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
|
||||
89
.github/workflows/renovate.yaml
vendored
89
.github/workflows/renovate.yaml
vendored
@@ -1,89 +0,0 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [closed]
|
||||
paths-ignore:
|
||||
- 'assets/**'
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: nhost
|
||||
|
||||
jobs:
|
||||
renovate-changeset:
|
||||
name: Add changeset
|
||||
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'renovate/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
# * Install Node and dependencies. Package downloads will be cached for the next jobs.
|
||||
- name: Install Node and dependencies
|
||||
uses: ./.github/actions/install-dependencies
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||
BUILD: 'none'
|
||||
- name: Determine bumps
|
||||
id: bumps
|
||||
run: |
|
||||
LAST_NON_PR_SHA=$(git log --no-merges main origin/${{ github.head_ref }} --format=format:%h -- | head -2 | tail -1)
|
||||
echo "result<<EOF" >> $GITHUB_OUTPUT
|
||||
pnpm recursive list --depth -1 --parseable \
|
||||
--filter='!nhost-root' \
|
||||
--filter=[$LAST_NON_PR_SHA] \
|
||||
| xargs -I@ jq ".name" @/package.json \
|
||||
| sort \
|
||||
| uniq -u \
|
||||
| awk '$0=$0": patch"' \
|
||||
>> $GITHUB_OUTPUT
|
||||
echo 'EOF' >> $GITHUB_OUTPUT
|
||||
- name: Install dictionary
|
||||
if: steps.bumps.outputs.result != ''
|
||||
run: sudo apt-get install wbritish
|
||||
- name: Generate changeset file name
|
||||
id: file_name
|
||||
if: steps.bumps.outputs.result != ''
|
||||
run: |
|
||||
FILE_NAME=$(shuf -n 3 /usr/share/dict/words | tr '\n' '-' | sed 's/-$//' | sed 's/'"'"'s//g' | tr '[:upper:]' '[:lower:]')
|
||||
echo "result=./.changeset/${FILE_NAME}.md" >> $GITHUB_OUTPUT
|
||||
- name: Create changeset file
|
||||
if: steps.bumps.outputs.result != ''
|
||||
run: |
|
||||
cat <<EOF > ${{ steps.file_name.outputs.result }}
|
||||
---
|
||||
${{ steps.bumps.outputs.result }}
|
||||
---
|
||||
|
||||
${{ github.event.pull_request.title }}
|
||||
EOF
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
with:
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
commit-message: ${{ github.event.pull_request.title }}
|
||||
branch: renovate-changesets
|
||||
delete-branch: true
|
||||
title: 'chore: create changesest from Renovate bumps'
|
||||
labels: |
|
||||
dependencies
|
||||
body: |
|
||||
This PR creates the changesets from the Renovate dependencies that have been merged to main.
|
||||
- name: Enable Pull Request Automerge
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
uses: peter-evans/enable-pull-request-automerge@v2
|
||||
with:
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
- name: Auto approve
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
uses: juliangruber/approve-pull-request-action@v2
|
||||
with:
|
||||
github-token: ${{ secrets.GH_PAT }}
|
||||
number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
@@ -23,8 +23,8 @@ module.exports = {
|
||||
'e2e/**/*.ts',
|
||||
'e2e/**/*.d.ts'
|
||||
],
|
||||
plugins: ['@typescript-eslint', 'cypress'],
|
||||
extends: ['plugin:cypress/recommended'],
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module'
|
||||
|
||||
@@ -20,7 +20,9 @@ export default defineConfig({
|
||||
exclude: ['**/*.spec.ts', '**/*.test.ts', '**/tests/**'],
|
||||
entryRoot: 'src',
|
||||
// Was defaulting to true until version 1.7
|
||||
skipDiagnostics: true
|
||||
skipDiagnostics: true,
|
||||
// Was defaulting to true until version 2.0
|
||||
copyDtsFiles: true
|
||||
})
|
||||
],
|
||||
test: {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# General Environment Variables
|
||||
NEXT_PUBLIC_ENV=dev
|
||||
NEXT_PUBLIC_NHOST_HASURA_URL=http://localhost:9695
|
||||
NEXT_PUBLIC_NHOST_MIGRATIONS_URL=http://localhost:9693
|
||||
NEXT_PUBLIC_NHOST_BACKEND_URL=http://localhost:1337
|
||||
NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||
|
||||
# Environment Variables for Self Hosting and Local Development
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
|
||||
|
||||
# Environment Variables when running the Nhost Dashboard against the Nhost Backend
|
||||
NEXT_PUBLIC_STRIPE_PK=<nhost_stripe_public_key>
|
||||
NEXT_PUBLIC_GITHUB_APP_INSTALL_URL=<github_app_install_url>
|
||||
NEXT_PUBLIC_ANALYTICS_WRITE_KEY=<analytics_write_key>
|
||||
|
||||
@@ -21,6 +21,7 @@ module.exports = {
|
||||
'error',
|
||||
{ allowArrowFunctions: true, allowFunctions: true },
|
||||
],
|
||||
'import/prefer-default-export': 'off',
|
||||
'import/no-extraneous-dependencies': ['error', { devDependencies: true }],
|
||||
curly: ['error', 'all'],
|
||||
'no-restricted-exports': 'off',
|
||||
@@ -30,6 +31,7 @@ module.exports = {
|
||||
'error',
|
||||
{ ignoreTypeReferences: true },
|
||||
],
|
||||
'no-console': ['warn', { allow: ['error'] }],
|
||||
'no-shadow': 'off',
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
|
||||
7
dashboard/.gitignore
vendored
7
dashboard/.gitignore
vendored
@@ -49,4 +49,9 @@ tailwind.json
|
||||
.idea
|
||||
|
||||
# Do not ignore Logs page
|
||||
!src/**/logs*
|
||||
!src/**/logs*
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
storageState.json
|
||||
e2e/.auth/*
|
||||
@@ -51,7 +51,7 @@ export const decorators = [
|
||||
(Story) => (
|
||||
<NhostApolloProvider
|
||||
fetchPolicy="cache-first"
|
||||
graphqlUrl="http://localhost:1337/v1/graphql"
|
||||
graphqlUrl="https://local.graphql.nhost.run/v1"
|
||||
>
|
||||
<Story />
|
||||
</NhostApolloProvider>
|
||||
|
||||
@@ -1,5 +1,173 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.14.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.16
|
||||
|
||||
## 0.14.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3cb67300: fix(logs): don't break UI when clearing time picker
|
||||
- 7453bf3b: feat(projects): show project creator info
|
||||
- c166dad0: chore(tests): improve auth page tests
|
||||
- 6a290bb2: chore(deps): bump `@types/react` to 18.0.32
|
||||
|
||||
## 0.14.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.15
|
||||
- @nhost/nextjs@1.13.19
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6e1f03ea: feat(dashboard): add support for the Azure AD provider
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1bd2c373: chore(deps): bump `turbo` to 1.8.6
|
||||
- d329b621: chore(deps): bump `@types/react` to 18.0.30
|
||||
- cb248f0d: fix(tests): avoid name collision in database tests
|
||||
- 867c8076: chore(deps): bump `@types/react` to 18.0.29
|
||||
|
||||
## 0.13.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e93b06ab: fix(dashboard): remove left margin from workspace list on mobile
|
||||
- 1c4806bf: chore(deps): bump `sharp` to 0.32.0
|
||||
- @nhost/react-apollo@5.0.14
|
||||
- @nhost/nextjs@1.13.18
|
||||
|
||||
## 0.13.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 912ed76c: chore(dashboard): bump `@apollo/client` to 3.7.10
|
||||
- Updated dependencies [912ed76c]
|
||||
- @nhost/react-apollo@5.0.13
|
||||
|
||||
## 0.13.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7c127372: chore(dashboard): bump `react-error-boundary` to v4
|
||||
|
||||
## 0.13.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9130ab12: chore(dashboard): bump `yup` to v1 and `@hookform/resolvers` to v3
|
||||
|
||||
## 0.13.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 253dd235: using new mutation to create projects + refactor Create Project page.
|
||||
|
||||
## 0.13.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.12
|
||||
- @nhost/nextjs@1.13.17
|
||||
|
||||
## 0.13.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b48bc034: fix(dashboard): disable new users
|
||||
- 798e591b: fix(dashboard): show correct date in data grid
|
||||
|
||||
## 0.13.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bfb4c1a6: chore(dashboard): remove `useAxios` property
|
||||
- d8d8394b: Dashboard: allow to override hasura admin secret in docker
|
||||
- Updated dependencies [ce1ee40d]
|
||||
- @nhost/nextjs@1.13.16
|
||||
- @nhost/react-apollo@5.0.11
|
||||
|
||||
## 0.13.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- beed2eba: Fix docker entrypoint for dashboard
|
||||
- 2c8559a3: fix(dashboard): refresh project list after deleting a project
|
||||
- 4329d048: chore(dashboard): bump `graphiql` dependencies
|
||||
|
||||
## 0.13.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cbb1fc5b: chore(dashboard): cleanup GraphQL operations
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 088584e7: feat(dashboard): add support for custom local subdomains
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2ac90dfd: fix(dashboard): improve mobile responsive layout
|
||||
- Updated dependencies [f375eacc]
|
||||
- @nhost/nextjs@1.13.15
|
||||
- @nhost/react-apollo@5.0.10
|
||||
|
||||
## 0.12.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.9
|
||||
- @nhost/nextjs@1.13.14
|
||||
|
||||
## 0.12.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2b1338f7: chore(dashboard): bump `turbo` to 1.8.3
|
||||
- 5223ee93: fix(dashboard): show correct deployment status on the main page
|
||||
- 850a049c: chore(deps): update docker/build-push-action action to v4
|
||||
- Updated dependencies [850a049c]
|
||||
- @nhost/nextjs@1.13.13
|
||||
- @nhost/react-apollo@5.0.8
|
||||
|
||||
## 0.12.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4bf40995: chore(deps): bump `typescript` to `4.9.5`
|
||||
- 8bb097c9: chore(deps): bump `vitest`
|
||||
- 35d52aab: chore(deps): replace `cross-fetch` with `isomorphic-unfetch`
|
||||
- Updated dependencies [4bf40995]
|
||||
- Updated dependencies [8bb097c9]
|
||||
- Updated dependencies [35d52aab]
|
||||
- @nhost/react-apollo@5.0.7
|
||||
- @nhost/nextjs@1.13.12
|
||||
|
||||
## 0.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c96d7ccd: fix(dashboard): fix docker builds
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d1671210: feat(dashboard): use mimir to manage project configuration
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f65e4de9: chore(deps): bump @graphql-codegen monorepo to v3
|
||||
|
||||
## 0.11.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -3,7 +3,7 @@ RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn global add turbo@1.6.3
|
||||
RUN yarn global add turbo@1.8.6
|
||||
COPY . .
|
||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||
|
||||
@@ -11,7 +11,7 @@ FROM node:16-alpine AS builder
|
||||
ARG TURBO_TOKEN
|
||||
ARG TURBO_TEAM
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
@@ -19,10 +19,15 @@ ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_PUBLIC_ENV dev
|
||||
ENV NEXT_PUBLIC_NHOST_PLATFORM false
|
||||
|
||||
# placeholders for ports, will be replaced on runtime by entrypoint script
|
||||
ENV NEXT_PUBLIC_NHOST_MIGRATIONS_PORT __NEXT_PUBLIC_NHOST_MIGRATIONS_PORT__
|
||||
ENV NEXT_PUBLIC_NHOST_HASURA_PORT __NEXT_PUBLIC_NHOST_HASURA_PORT__
|
||||
ENV NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT __NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT__
|
||||
# placeholders for URLs, will be replaced on runtime by entrypoint script
|
||||
ENV NEXT_PUBLIC_NHOST_ADMIN_SECRET __NEXT_PUBLIC_NHOST_ADMIN_SECRET__
|
||||
ENV NEXT_PUBLIC_NHOST_AUTH_URL __NEXT_PUBLIC_NHOST_AUTH_URL__
|
||||
ENV NEXT_PUBLIC_NHOST_FUNCTIONS_URL __NEXT_PUBLIC_NHOST_FUNCTIONS_URL__
|
||||
ENV NEXT_PUBLIC_NHOST_GRAPHQL_URL __NEXT_PUBLIC_NHOST_GRAPHQL_URL__
|
||||
ENV NEXT_PUBLIC_NHOST_STORAGE_URL __NEXT_PUBLIC_NHOST_STORAGE_URL__
|
||||
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@7.17.0
|
||||
COPY .gitignore .gitignore
|
||||
|
||||
@@ -35,8 +35,17 @@ You can connect the Nhost Dashboard to your locally running backend by setting t
|
||||
```bash
|
||||
NEXT_PUBLIC_ENV=dev
|
||||
NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
|
||||
```
|
||||
|
||||
This will connect the Nhost Dashboard to your locally running Nhost backend.
|
||||
|
||||
### Storybook
|
||||
|
||||
Components are documented using [Storybook](https://storybook.js.org/). To run Storybook, run the following command:
|
||||
@@ -45,20 +54,39 @@ Components are documented using [Storybook](https://storybook.js.org/). To run S
|
||||
pnpm storybook
|
||||
```
|
||||
|
||||
### Full list of environment variables
|
||||
### General Environment Variables
|
||||
|
||||
| Name | Description |
|
||||
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_ENV` | `dev`, `staging` or `prod`. Should be set to `dev` in most cases. |
|
||||
| `NEXT_PUBLIC_NHOST_PLATFORM` | This should be set to `false` to connect the Nhost Dashboard to a locally running Nhost backend. Setting this to `true` turns off local development. |
|
||||
| `NEXT_PUBLIC_NHOST_LOCAL_MIGRATIONS_PORT` | Custom port that was passed to the CLI. Used only if local development is enabled. Default: `9693` |
|
||||
| `NEXT_PUBLIC_NHOST_LOCAL_HASURA_PORT` | Custom port that was passed to the CLI. Used only if local development is enabled and `NEXT_PUBLIC_ENV` is `dev`. Default: `9695` |
|
||||
| `NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT` | Custom port that was passed to the CLI. Used only if local development is enabled. Default: `1337` |
|
||||
| `NEXT_PUBLIC_NHOST_BACKEND_URL` | Backend URL. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. Not necessary for local development. |
|
||||
| Name | Description |
|
||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_ENV` | `dev`, `staging` or `prod`. This should be set to `dev` in most cases. |
|
||||
| `NEXT_PUBLIC_NHOST_ADMIN_SECRET` | Admin secret for Hasura. Default: `nhost-admin-secret` |
|
||||
| `NEXT_PUBLIC_NHOST_PLATFORM` | This should be set to `false` to connect the Nhost Dashboard to a locally running or a self-hosted Nhost backend. Setting this to `true` will connect the Nhost Dashboard to the cloud environment. Default: `false` |
|
||||
|
||||
### Environment Variables for Local Development and Self-Hosting
|
||||
|
||||
| Name | Description |
|
||||
| ---- | ----------- |
|
||||
|
||||
| `NEXT_PUBLIC_NHOST_AUTH_URL` | The URL of the Auth service. When working locally, point it to the Auth service started by the CLI. When self-hosting, point it to the self-hosted Auth service. |
|
||||
| `NEXT_PUBLIC_NHOST_FUNCTIONS_URL` | The URL of the Functions service. When working locally, point it to the Functions service started by the CLI. When self-hosting, point it to the self-hosted Functions service. |
|
||||
| `NEXT_PUBLIC_NHOST_GRAPHQL_URL` | The URL of the GraphQL service. When working locally, point it to the GraphQL service started by the CLI. When self-hosting, point it to the self-hosted GraphQL service. |
|
||||
| `NEXT_PUBLIC_NHOST_STORAGE_URL` | The URL of the Storage service. When working locally, point it to the Storage service started by the CLI. When self-hosting, point it to the self-hosted Storage service. |
|
||||
| `NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL` | The URL of the Hasura Console. When working locally, point it to the Hasura Console started by the CLI. When self-hosting, point it to the self-hosted Hasura Console. |
|
||||
| `NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL` | The URL of Hasura's Migrations service. When working locally, point it to the Migrations service started by the CLI. |
|
||||
| `NEXT_PUBLIC_NHOST_HASURA_API_URL` | The URL of Hasura's Schema and Metadata API. When working locally, point it to the Schema and Metadata API started by the CLI. When self-hosting, point it to the self-hosted Schema and Metadata API. |
|
||||
|
||||
### Other Environment Variables
|
||||
|
||||
| Name | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_NHOST_BACKEND_URL` | Backend URL. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||
| `NEXT_PUBLIC_MAINTENANCE_ACTIVE` | Determines whether or not maintenance mode is active. |
|
||||
| `NEXT_PUBLIC_MAINTENANCE_END_DATE` | Date when maintenance mode will end. |
|
||||
| `NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET` | Secret that can be used to bypass maintenance mode. |
|
||||
|
||||
## ESLint Rules
|
||||
|
||||
@@ -83,3 +111,22 @@ pnpm storybook
|
||||
| `@typescript-eslint/consistent-type-imports` | Enforces `import type { Type } from 'module'` syntax. It prevents false positive circular dependency errors. |
|
||||
| `@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
|
||||
|
||||
End-to-end tests are written using [Playwright](https://playwright.dev/). To run the tests, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm e2e
|
||||
```
|
||||
|
||||
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`:
|
||||
|
||||
```
|
||||
NHOST_TEST_DASHBOARD_URL=<test_dashboard_url>
|
||||
NHOST_TEST_USER_EMAIL=<test_user_email>
|
||||
NHOST_TEST_USER_PASSWORD=<test_user_password>
|
||||
NHOST_TEST_WORKSPACE_NAME=<test_workspace_name>
|
||||
NHOST_TEST_PROJECT_NAME=<test_project_name>
|
||||
NHOST_TEST_PROJECT_ADMIN_SECRET=<test_project_admin_secret>
|
||||
```
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
# read ports from env variables or use defaults
|
||||
NEXT_PUBLIC_NHOST_MIGRATIONS_PORT="${NEXT_PUBLIC_NHOST_MIGRATIONS_PORT:=9693}"
|
||||
NEXT_PUBLIC_NHOST_HASURA_PORT="${NEXT_PUBLIC_NHOST_HASURA_PORT:=9695}"
|
||||
NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT="${NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT:=1337}"
|
||||
# read URLs from env variables (with defaults)
|
||||
NEXT_PUBLIC_NHOST_ADMIN_SECRET="${NEXT_PUBLIC_NHOST_ADMIN_SECRET:-nhost-admin-secret}"
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL="${NEXT_PUBLIC_NHOST_AUTH_URL:-http://localhost:1337/v1/auth}"
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL="${NEXT_PUBLIC_NHOST_FUNCTIONS_URL:-http://localhost:1337/v1/functions}"
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL="${NEXT_PUBLIC_NHOST_GRAPHQL_URL:-http://localhost:1337/v1/graphql}"
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL="${NEXT_PUBLIC_NHOST_STORAGE_URL:-http://localhost:1337/v1/storage}"
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL="${NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL:-http://localhost:9695}"
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL="${NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL:-http://localhost:9693}"
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL="${NEXT_PUBLIC_NHOST_HASURA_API_URL:-http://localhost:8080}"
|
||||
|
||||
# replace placeholders
|
||||
find dashboard -type f -exec sed -i "s/__NEXT_PUBLIC_NHOST_MIGRATIONS_PORT__/${NEXT_PUBLIC_NHOST_MIGRATIONS_PORT}/g" {} +
|
||||
find dashboard -type f -exec sed -i "s/__NEXT_PUBLIC_NHOST_HASURA_PORT__/${NEXT_PUBLIC_NHOST_HASURA_PORT}/g" {} +
|
||||
find dashboard -type f -exec sed -i "s/__NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT__/${NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT}/g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_ADMIN_SECRET__~${NEXT_PUBLIC_NHOST_ADMIN_SECRET}~g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_AUTH_URL__~${NEXT_PUBLIC_NHOST_AUTH_URL}~g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_FUNCTIONS_URL__~${NEXT_PUBLIC_NHOST_FUNCTIONS_URL}~g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_GRAPHQL_URL__~${NEXT_PUBLIC_NHOST_GRAPHQL_URL}~g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_STORAGE_URL__~${NEXT_PUBLIC_NHOST_STORAGE_URL}~g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL__~${NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL}~g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__~${NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL}~g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_API_URL__~${NEXT_PUBLIC_NHOST_HASURA_API_URL}~g" {} +
|
||||
|
||||
exec "$@"
|
||||
|
||||
50
dashboard/e2e/auth/ban-user.test.ts
Normal file
50
dashboard/e2e/auth/ban-user.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import test, { expect } from '@playwright/test';
|
||||
|
||||
test('should be able to ban and unban a user', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: /main navigation/i })
|
||||
.getByRole('link', { name: /auth/i })
|
||||
.click();
|
||||
|
||||
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
|
||||
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||
.click();
|
||||
await page.getByRole('button', { name: /actions/i }).click();
|
||||
await page.getByRole('menuitem', { name: /ban user/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/user has been banned successfully./i),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('form').getByText(/^banned$/i)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /actions/i }).click();
|
||||
await page.getByRole('menuitem', { name: /unban user/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/user has been unbanned successfully./i),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('form').getByText(/^banned$/i)).not.toBeVisible();
|
||||
});
|
||||
65
dashboard/e2e/auth/create-user.test.ts
Normal file
65
dashboard/e2e/auth/create-user.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import test, { expect } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: /main navigation/i })
|
||||
.getByRole('link', { name: /auth/i })
|
||||
.click();
|
||||
|
||||
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create a user', async () => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${email}`, exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not be able to create a user with an existing email', async () => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${email}`, exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog').getByText(/email already in use/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
96
dashboard/e2e/auth/delete-user.test.ts
Normal file
96
dashboard/e2e/auth/delete-user.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import test, { expect } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: /main navigation/i })
|
||||
.getByRole('link', { name: /auth/i })
|
||||
.click();
|
||||
|
||||
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should be able to delete a user', async () => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${email}`, exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `More options for ${email}`, exact: true })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: /delete user/i }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /delete user/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`Are you sure you want to delete the "${email}" user?`),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /delete/i, exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${email}`, exact: true }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to delete a user from the details page', async () => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||
.click();
|
||||
|
||||
await page.getByRole('button', { name: /actions/i }).click();
|
||||
await page.getByRole('menuitem', { name: /delete user/i }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /delete user/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`Are you sure you want to delete the "${email}" user?`),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /delete/i, exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${email}`, exact: true }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
103
dashboard/e2e/auth/verify-user.test.ts
Normal file
103
dashboard/e2e/auth/verify-user.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: /main navigation/i })
|
||||
.getByRole('link', { name: /auth/i })
|
||||
.click();
|
||||
|
||||
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should be able to verify the email of a user', async () => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /email verified/i }),
|
||||
).not.toBeChecked();
|
||||
await page.getByRole('checkbox', { name: /email verified/i }).check();
|
||||
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/user settings have been updated successfully./i),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /email verified/i }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
test('should be able to verify the phone number of a user', async () => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
const phoneNumber = faker.phone.number();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /phone number verified/i }),
|
||||
).toBeDisabled();
|
||||
|
||||
await page.getByRole('textbox', { name: /phone number/i }).fill(phoneNumber);
|
||||
await page.getByRole('checkbox', { name: /phone number verified/i }).check();
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/user settings have been updated successfully./i),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: /phone number/i }),
|
||||
).toHaveValue(phoneNumber);
|
||||
|
||||
await expect(
|
||||
page.getByRole('checkbox', { name: /phone number verified/i }),
|
||||
).toBeChecked();
|
||||
});
|
||||
280
dashboard/e2e/database/create-table.test.ts
Normal file
280
dashboard/e2e/database/create-table.test.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { openProject, prepareTable } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: /main navigation/i })
|
||||
.getByRole('link', { name: /database/i })
|
||||
.click();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create a simple table', async () => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
],
|
||||
});
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with unique constraints', async () => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text', unique: true },
|
||||
{ name: 'isbn', type: 'text', unique: true },
|
||||
],
|
||||
});
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with nullable columns', async () => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text', nullable: true },
|
||||
{ name: 'description', type: 'text', nullable: true },
|
||||
],
|
||||
});
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with an identity column', async () => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'int4' },
|
||||
{ name: 'title', type: 'text', nullable: true },
|
||||
{ name: 'description', type: 'text', nullable: true },
|
||||
],
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /identity/i }).click();
|
||||
await page.getByRole('option', { name: /id/i }).click();
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create table with foreign key constraint', async () => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const firstTableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: firstTableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'name', type: 'text' },
|
||||
],
|
||||
});
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${firstTableName}`,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const secondTableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: secondTableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'author_id', type: 'uuid' },
|
||||
],
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /add foreign key/i }).click();
|
||||
|
||||
// select column in current table
|
||||
await page
|
||||
.getByRole('button', { name: /column/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('option', { name: /author_id/i }).click();
|
||||
|
||||
// select reference schema
|
||||
await page.getByRole('button', { name: /schema/i }).click();
|
||||
await page.getByRole('option', { name: /public/i }).click();
|
||||
|
||||
// select reference table
|
||||
await page.getByRole('button', { name: /table/i }).click();
|
||||
await page.getByRole('option', { name: firstTableName, exact: true }).click();
|
||||
|
||||
// select reference column
|
||||
await page
|
||||
.getByRole('button', { name: /column/i })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole('option', { name: /id/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /add/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(`public.${firstTableName}.id`, { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${secondTableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: secondTableName, exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not be able to create a table with a name that already exists', async () => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'name', type: 'text' },
|
||||
],
|
||||
});
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'author_id', type: 'uuid' },
|
||||
],
|
||||
});
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/error: a table with this name already exists/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
165
dashboard/e2e/database/delete-table.test.ts
Normal file
165
dashboard/e2e/database/delete-table.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { deleteTable, openProject, prepareTable } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
await page
|
||||
.getByRole('navigation', { name: /main navigation/i })
|
||||
.getByRole('link', { name: /database/i })
|
||||
.click();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should delete a table', async () => {
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
],
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await deleteTable({
|
||||
page,
|
||||
name: tableName,
|
||||
});
|
||||
|
||||
// navigate to next URL
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/**`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should not be able to delete a table if other tables have foreign keys referencing it', async () => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const firstTableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: firstTableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'name', type: 'text' },
|
||||
],
|
||||
});
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${firstTableName}`,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const secondTableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: secondTableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'author_id', type: 'uuid' },
|
||||
],
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /add foreign key/i }).click();
|
||||
|
||||
// select column in current table
|
||||
await page
|
||||
.getByRole('button', { name: /column/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('option', { name: /author_id/i }).click();
|
||||
|
||||
// select reference schema
|
||||
await page.getByRole('button', { name: /schema/i }).click();
|
||||
await page.getByRole('option', { name: /public/i }).click();
|
||||
|
||||
// select reference table
|
||||
await page.getByRole('button', { name: /table/i }).click();
|
||||
await page.getByRole('option', { name: firstTableName, exact: true }).click();
|
||||
|
||||
// select reference column
|
||||
await page
|
||||
.getByRole('button', { name: /column/i })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByRole('option', { name: /id/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /add/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(`public.${firstTableName}.id`, { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${secondTableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: secondTableName, exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// try to delete the first table that is referenced by the second table
|
||||
await deleteTable({
|
||||
page,
|
||||
name: firstTableName,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
/constraint [a-zA-Z_]+ on table [a-zA-Z_]+ depends on table [a-zA-Z_]+/i,
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
48
dashboard/e2e/env.ts
Normal file
48
dashboard/e2e/env.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import slugify from 'slugify';
|
||||
|
||||
/**
|
||||
* URL of the dashboard to test against.
|
||||
*/
|
||||
export const TEST_DASHBOARD_URL = process.env.NHOST_TEST_DASHBOARD_URL;
|
||||
|
||||
/**
|
||||
* Name of the workspace to test against.
|
||||
*/
|
||||
export const TEST_WORKSPACE_NAME = process.env.NHOST_TEST_WORKSPACE_NAME;
|
||||
|
||||
/**
|
||||
* Slugified name of the workspace to test against.
|
||||
*/
|
||||
export const TEST_WORKSPACE_SLUG = slugify(TEST_WORKSPACE_NAME, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Name of the project to test against.
|
||||
*/
|
||||
export const TEST_PROJECT_NAME = process.env.NHOST_TEST_PROJECT_NAME;
|
||||
|
||||
/**
|
||||
* Slugified name of the project to test against.
|
||||
*/
|
||||
export const TEST_PROJECT_SLUG = slugify(TEST_PROJECT_NAME, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Hasura admin secret of the test project to use.
|
||||
*/
|
||||
export const TEST_PROJECT_ADMIN_SECRET =
|
||||
process.env.NHOST_TEST_PROJECT_ADMIN_SECRET;
|
||||
|
||||
/**
|
||||
* Email of the test account to use.
|
||||
*/
|
||||
export const TEST_USER_EMAIL = process.env.NHOST_TEST_USER_EMAIL;
|
||||
|
||||
/**
|
||||
* Password of the test account to use.
|
||||
*/
|
||||
export const TEST_USER_PASSWORD = process.env.NHOST_TEST_USER_PASSWORD;
|
||||
109
dashboard/e2e/overview/overview.test.ts
Normal file
109
dashboard/e2e/overview/overview.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_NAME,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { openProject } from '@/e2e/utils';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
|
||||
await page.goto('/');
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should show a sidebar with menu items', async () => {
|
||||
const navLocator = page.getByRole('navigation', { name: /main navigation/i });
|
||||
await expect(navLocator).toBeVisible();
|
||||
await expect(navLocator.getByRole('list').getByRole('listitem')).toHaveCount(
|
||||
10,
|
||||
);
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /overview/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /database/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /graphql/i }),
|
||||
).toBeVisible();
|
||||
await expect(navLocator.getByRole('link', { name: /hasura/i })).toBeVisible();
|
||||
await expect(navLocator.getByRole('link', { name: /auth/i })).toBeVisible();
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /storage/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /deployments/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /backups/i }),
|
||||
).toBeVisible();
|
||||
await expect(navLocator.getByRole('link', { name: /logs/i })).toBeVisible();
|
||||
await expect(
|
||||
navLocator.getByRole('link', { name: /settings/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show a header with a logo, the workspace name, and the project name', async () => {
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('link', { name: TEST_WORKSPACE_NAME }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('banner').getByRole('link', { name: TEST_PROJECT_NAME }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show the project's name, the Upgrade button and the Settings button", async () => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: TEST_PROJECT_NAME }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/starter/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /upgrade/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('main').getByRole('link', { name: /settings/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show the project's region and subdomain", async () => {
|
||||
await expect(page.locator('p:has-text("Region") + div p').nth(0)).toHaveText(
|
||||
/frankfurt \(eu-central-1\)/i,
|
||||
);
|
||||
await expect(
|
||||
page.locator('p:has-text("Subdomain") + div p').nth(0),
|
||||
).toHaveText(/[a-z]{20}/i);
|
||||
});
|
||||
|
||||
test('should not have a GitHub repository connected', async () => {
|
||||
await expect(
|
||||
page.getByRole('button', { name: /connect to github/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show proper limits for the free project', async () => {
|
||||
// Limit for Database
|
||||
await expect(page.getByText(/of 500 MB/i)).toBeVisible();
|
||||
|
||||
// Limit for Storage
|
||||
await expect(page.getByText(/of 1 GB/i)).toBeVisible();
|
||||
|
||||
// Limit for Users
|
||||
await expect(page.getByText(/of 10000/i)).toBeVisible();
|
||||
|
||||
// Limit for Functions
|
||||
await expect(page.getByText(/of 10$/i, { exact: true })).toBeVisible();
|
||||
});
|
||||
20
dashboard/e2e/setup/auth.setup.ts
Normal file
20
dashboard/e2e/setup/auth.setup.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
TEST_DASHBOARD_URL,
|
||||
TEST_USER_EMAIL,
|
||||
TEST_USER_PASSWORD,
|
||||
} from '@/e2e/env';
|
||||
import { test as setup } from '@playwright/test';
|
||||
|
||||
setup('authenticate user', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForURL('/signin');
|
||||
await page.getByRole('link', { name: /continue with email/i }).click();
|
||||
|
||||
await page.waitForURL('/signin/email');
|
||||
await page.getByLabel('Email').fill(TEST_USER_EMAIL);
|
||||
await page.getByLabel('Password').fill(TEST_USER_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
await page.waitForURL(TEST_DASHBOARD_URL);
|
||||
await page.context().storageState({ path: 'e2e/.auth/user.json' });
|
||||
});
|
||||
189
dashboard/e2e/utils.ts
Normal file
189
dashboard/e2e/utils.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Open a project by navigating to the project's overview page.
|
||||
*
|
||||
* @param page - The Playwright page object.
|
||||
* @param workspaceSlug - The slug of the workspace that contains the project.
|
||||
* @param projectSlug - The slug of the project to open.
|
||||
* @param projectName - The name of the project to open.
|
||||
* @returns A promise that resolves when the project is opened.
|
||||
*/
|
||||
export async function openProject({
|
||||
page,
|
||||
projectName,
|
||||
workspaceSlug,
|
||||
projectSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
workspaceSlug: string;
|
||||
projectSlug: string;
|
||||
projectName: string;
|
||||
}) {
|
||||
await page.getByRole('link', { name: projectName }).click();
|
||||
await page.waitForURL(`/${workspaceSlug}/${projectSlug}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a table by filling out the form.
|
||||
*
|
||||
* @param page - The Playwright page object.
|
||||
* @param name - The name of the table to create.
|
||||
* @param columns - The columns to create in the table.
|
||||
* @returns A promise that resolves when the table is prepared.
|
||||
*/
|
||||
export async function prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey,
|
||||
columns,
|
||||
}: {
|
||||
page: Page;
|
||||
name: string;
|
||||
primaryKey: string;
|
||||
columns: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
nullable?: boolean;
|
||||
unique?: boolean;
|
||||
defaultValue?: string;
|
||||
}>;
|
||||
}) {
|
||||
if (!columns.some(({ name }) => name === primaryKey)) {
|
||||
throw new Error('Primary key must be one of the columns.');
|
||||
}
|
||||
|
||||
await page.getByRole('textbox', { name: /name/i }).first().fill(tableName);
|
||||
|
||||
await Promise.all(
|
||||
columns.map(
|
||||
async (
|
||||
{ name: columnName, type, nullable, unique, defaultValue },
|
||||
index,
|
||||
) => {
|
||||
// set name
|
||||
await page.getByPlaceholder(/name/i).nth(index).fill(columnName);
|
||||
|
||||
// set type
|
||||
await page
|
||||
.getByRole('table')
|
||||
.getByRole('combobox', { name: /type/i })
|
||||
.nth(index)
|
||||
.type(type);
|
||||
await page
|
||||
.getByRole('table')
|
||||
.getByRole('option', { name: type })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// optionally set default value
|
||||
if (defaultValue) {
|
||||
await page
|
||||
.getByRole('table')
|
||||
.getByRole('combobox', { name: /default value/i })
|
||||
.nth(index)
|
||||
.type(defaultValue);
|
||||
await page
|
||||
.getByRole('table')
|
||||
.getByRole('option', { name: defaultValue })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
// optionally check unique
|
||||
if (unique) {
|
||||
await page
|
||||
.getByRole('checkbox', { name: /unique/i })
|
||||
.nth(index)
|
||||
.check();
|
||||
}
|
||||
|
||||
// optionally check nullable
|
||||
if (nullable) {
|
||||
await page
|
||||
.getByRole('checkbox', { name: /nullable/i })
|
||||
.nth(index)
|
||||
.check();
|
||||
}
|
||||
|
||||
// add new column if not last
|
||||
if (index < columns.length - 1) {
|
||||
await page.getByRole('button', { name: /add column/i }).click();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// select the first column as primary key
|
||||
await page.getByRole('button', { name: /primary key/i }).click();
|
||||
await page.getByRole('option', { name: primaryKey, exact: true }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a table with the given name.
|
||||
*
|
||||
* @param page - The Playwright page object.
|
||||
* @param name - The name of the table to delete.
|
||||
* @returns A promise that resolves when the table is deleted.
|
||||
*/
|
||||
export async function deleteTable({
|
||||
page,
|
||||
name,
|
||||
}: {
|
||||
page: Page;
|
||||
name: string;
|
||||
}) {
|
||||
const tableLink = page.getByRole('link', {
|
||||
name,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await tableLink.hover();
|
||||
await page
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: name })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: /delete table/i }).click();
|
||||
await page.getByRole('button', { name: /delete/i }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user.
|
||||
*
|
||||
* @param page - The Playwright page object.
|
||||
* @param email - The email of the user to create.
|
||||
* @param password - The password of the user to create.
|
||||
* @returns A promise that resolves when the user is created.
|
||||
*/
|
||||
export async function createUser({
|
||||
page,
|
||||
email,
|
||||
password,
|
||||
}: {
|
||||
page: Page;
|
||||
email: string;
|
||||
password: string;
|
||||
}) {
|
||||
await page
|
||||
.getByRole('button', { name: /create user/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await page.getByRole('textbox', { name: /email/i }).fill(email);
|
||||
await page.getByRole('textbox', { name: /password/i }).fill(password);
|
||||
await page.getByRole('button', { name: /create/i, exact: true }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a test email address with the given prefix (if provided).
|
||||
*
|
||||
* @param prefix - The prefix to use for the email address. (Default: `Nhost_Test_`)
|
||||
*/
|
||||
export function generateTestEmail(prefix: string = 'Nhost_Test_') {
|
||||
const email = faker.internet.email();
|
||||
|
||||
return [prefix, email].join('');
|
||||
}
|
||||
66
dashboard/global-teardown.ts
Normal file
66
dashboard/global-teardown.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
TEST_DASHBOARD_URL,
|
||||
TEST_PROJECT_ADMIN_SECRET,
|
||||
TEST_PROJECT_NAME,
|
||||
TEST_PROJECT_SLUG,
|
||||
TEST_WORKSPACE_SLUG,
|
||||
} from '@/e2e/env';
|
||||
import { openProject } from '@/e2e/utils';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
async function globalTeardown() {
|
||||
const browser = await chromium.launch();
|
||||
|
||||
const context = await browser.newContext({
|
||||
baseURL: TEST_DASHBOARD_URL,
|
||||
storageState: 'e2e/.auth/user.json',
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await openProject({
|
||||
page,
|
||||
projectName: TEST_PROJECT_NAME,
|
||||
workspaceSlug: TEST_WORKSPACE_SLUG,
|
||||
projectSlug: TEST_PROJECT_SLUG,
|
||||
});
|
||||
|
||||
const pagePromise = context.waitForEvent('page');
|
||||
|
||||
await page.getByRole('link', { name: /hasura/i }).click();
|
||||
await page.getByRole('link', { name: /open hasura/i }).click();
|
||||
|
||||
const hasuraPage = await pagePromise;
|
||||
await hasuraPage.waitForLoadState();
|
||||
|
||||
const adminSecretInput = hasuraPage.getByPlaceholder(/enter admin-secret/i);
|
||||
|
||||
// note: a more ideal way would be to paste from clipboard, but Playwright
|
||||
// doesn't support that yet
|
||||
await adminSecretInput.fill(TEST_PROJECT_ADMIN_SECRET);
|
||||
await adminSecretInput.press('Enter');
|
||||
|
||||
// note: getByRole doesn't work here
|
||||
await hasuraPage.locator('a', { hasText: /data/i }).click();
|
||||
await hasuraPage.getByRole('link', { name: /sql/i }).click();
|
||||
|
||||
await hasuraPage.getByRole('textbox').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 $$;
|
||||
`);
|
||||
|
||||
await hasuraPage.getByRole('button', { name: /run!/i }).click();
|
||||
await hasuraPage.getByText(/sql executed!/i).waitFor();
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
@@ -1,5 +1,5 @@
|
||||
schema:
|
||||
- http://localhost:1337/v1/graphql:
|
||||
- https://local.graphql.nhost.run/v1:
|
||||
headers:
|
||||
x-hasura-admin-secret: nhost-admin-secret
|
||||
generates:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.11.20",
|
||||
"version": "0.14.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -14,10 +14,11 @@
|
||||
"nhost:dev": "nhost dev -d",
|
||||
"format": "prettier --write \"src/**/*.{js,ts,tsx,jsx,json,md}\" --plugin-search-dir=.",
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook"
|
||||
"build-storybook": "build-storybook",
|
||||
"e2e": "npx playwright@1.31.2 install --with-deps && playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.3",
|
||||
"@apollo/client": "^3.7.10",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@emotion/cache": "^11.10.5",
|
||||
"@emotion/react": "^11.10.5",
|
||||
@@ -25,11 +26,11 @@
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@fontsource/inter": "^4.5.14",
|
||||
"@fontsource/roboto-mono": "^4.5.8",
|
||||
"@graphiql/react": "^0.15.0",
|
||||
"@graphiql/toolkit": "^0.8.0",
|
||||
"@graphiql/react": "^0.17.0",
|
||||
"@graphiql/toolkit": "^0.8.2",
|
||||
"@headlessui/react": "^1.6.5",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@hookform/resolvers": "^2.9.10",
|
||||
"@hookform/resolvers": "^3.0.0",
|
||||
"@mui/base": "^5.0.0-alpha.106",
|
||||
"@mui/material": "^5.10.14",
|
||||
"@mui/system": "^5.10.14",
|
||||
@@ -37,7 +38,7 @@
|
||||
"@nhost/nextjs": "workspace:*",
|
||||
"@nhost/react-apollo": "workspace:*",
|
||||
"@segment/snippet": "^4.15.3",
|
||||
"@stripe/react-stripe-js": "^1.10.0",
|
||||
"@stripe/react-stripe-js": "^2.0.0",
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tanstack/react-query": "^4.16.1",
|
||||
@@ -46,10 +47,9 @@
|
||||
"analytics-node": "^6.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^1.2.1",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"date-fns": "^2.29.3",
|
||||
"generate-password": "^1.7.0",
|
||||
"graphiql": "^2.2.0",
|
||||
"graphiql": "^2.4.0",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^4.3.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
@@ -63,31 +63,33 @@
|
||||
"prettysize": "^2.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-hook-form": "^7.39.5",
|
||||
"react-error-boundary": "^4.0.0",
|
||||
"react-hook-form": "^7.42.1",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"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.31.2",
|
||||
"sharp": "^0.32.0",
|
||||
"slugify": "^1.6.5",
|
||||
"stripe": "^10.17.0",
|
||||
"tailwind-merge": "^1.8.0",
|
||||
"utility-types": "^3.10.0",
|
||||
"validator": "^13.7.0",
|
||||
"yup": "^0.32.11",
|
||||
"yup": "^1.0.2",
|
||||
"yup-password": "^0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.2",
|
||||
"@graphql-codegen/cli": "^2.8.0",
|
||||
"@graphql-codegen/typescript": "^2.7.1",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@graphql-codegen/cli": "^3.0.0",
|
||||
"@graphql-codegen/typescript": "^3.0.0",
|
||||
"@graphql-codegen/typescript-graphql-request": "^4.5.1",
|
||||
"@graphql-codegen/typescript-operations": "^2.5.1",
|
||||
"@graphql-codegen/typescript-operations": "^3.0.0",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.1",
|
||||
"@next/bundle-analyzer": "^12.3.1",
|
||||
"@playwright/test": "^1.31.2",
|
||||
"@storybook/addon-actions": "^6.5.14",
|
||||
"@storybook/addon-essentials": "^6.5.14",
|
||||
"@storybook/addon-interactions": "^6.5.14",
|
||||
@@ -104,7 +106,7 @@
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^16.11.7",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react": "18.0.32",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/react-table": "^7.7.12",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
@@ -112,11 +114,13 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||
"@typescript-eslint/parser": "^5.43.0",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"@vitest/coverage-c8": "^0.27.0",
|
||||
"@vitest/coverage-c8": "^0.29.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"babel-loader": "^8.3.0",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"csstype": "^3.0.10",
|
||||
"dotenv": "^16.0.3",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
@@ -130,20 +134,21 @@
|
||||
"lint-staged": ">=13",
|
||||
"msw": "^1.0.1",
|
||||
"msw-storybook-addon": "^1.6.3",
|
||||
"node-fetch": "^3.3.0",
|
||||
"postcss": "^8.4.19",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.0",
|
||||
"prettier-plugin-tailwindcss": "^0.2.0",
|
||||
"react-date-fns-hooks": "^0.9.4",
|
||||
"require-from-string": "^2.0.2",
|
||||
"snake-case": "^3.0.4",
|
||||
"storybook-addon-next-router": "^4.0.1",
|
||||
"tailwindcss": "^3.1.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||
"typescript": "^4.8.4",
|
||||
"vite": "^4.0.2",
|
||||
"vite-tsconfig-paths": "^4.0.3",
|
||||
"vitest": "^0.27.0",
|
||||
"vitest": "^0.29.0",
|
||||
"webpack": "^5.75.0"
|
||||
},
|
||||
"browserslist": {
|
||||
@@ -161,4 +166,4 @@
|
||||
"msw": {
|
||||
"workerDirectory": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
dashboard/playwright.config.ts
Normal file
38
dashboard/playwright.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env.test') });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
globalTeardown: require.resolve('./global-teardown'),
|
||||
use: {
|
||||
actionTimeout: 0,
|
||||
trace: 'on-first-retry',
|
||||
baseURL: process.env.NHOST_TEST_DASHBOARD_URL,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: ['**/setup/*.setup.ts'],
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'e2e/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
});
|
||||
12
dashboard/public/assets/brands/azuread.svg
Normal file
12
dashboard/public/assets/brands/azuread.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
@@ -1,4 +1,7 @@
|
||||
import { useDeleteApplicationMutation } from '@/generated/graphql';
|
||||
import {
|
||||
GetOneUserDocument,
|
||||
useDeleteApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import ArrowRightIcon from '@/ui/v2/icons/ArrowRightIcon';
|
||||
@@ -6,26 +9,39 @@ import Link from '@/ui/v2/Link';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getApplicationStatusString } from '@/utils/helpers';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import { useRouter } from 'next/router';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function ApplicationInfo() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [deleteApplication, { client }] = useDeleteApplicationMutation();
|
||||
const [deleteApplication] = useDeleteApplicationMutation({
|
||||
refetchQueries: [GetOneUserDocument],
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
async function handleClickRemove() {
|
||||
await deleteApplication({
|
||||
variables: {
|
||||
appId: currentApplication.id,
|
||||
},
|
||||
});
|
||||
await router.push('/');
|
||||
await client.refetchQueries({
|
||||
include: ['getOneUser'],
|
||||
});
|
||||
triggerToast(`${currentApplication.name} deleted`);
|
||||
try {
|
||||
await toast.promise(
|
||||
deleteApplication({
|
||||
variables: {
|
||||
appId: currentApplication.id,
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: 'Deleting project...',
|
||||
success: 'The project has been deleted successfully.',
|
||||
error: getServerError(
|
||||
'An error occurred while deleting the project. Please try again.',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
await router.push('/');
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import MaintenanceAlert from '@/components/common/MaintenanceAlert';
|
||||
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
||||
import Container from '@/components/layout/Container';
|
||||
import { features } from '@/components/overview/features';
|
||||
@@ -52,6 +53,7 @@ export default function ApplicationLive() {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<MaintenanceAlert />
|
||||
<OverviewTopBar />
|
||||
|
||||
<div className="grid grid-cols-1 gap-12 pt-3 lg:grid-cols-3">
|
||||
|
||||
@@ -3,54 +3,81 @@ import { ChangePlanModal } from '@/components/applications/ChangePlanModal';
|
||||
import { StagingMetadata } from '@/components/applications/StagingMetadata';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Container from '@/components/layout/Container';
|
||||
import { useUpdateApplicationMutation } from '@/generated/graphql';
|
||||
import {
|
||||
GetOneUserDocument,
|
||||
useGetFreeAndActiveProjectsQuery,
|
||||
useUnpauseApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { Modal } from '@/ui';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { updateOwnCache } from '@/utils/updateOwnCache';
|
||||
import { MAX_FREE_PROJECTS } from '@/utils/CONSTANTS';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { RemoveApplicationModal } from './RemoveApplicationModal';
|
||||
|
||||
export default function ApplicationPaused() {
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { currentWorkspace, currentApplication } =
|
||||
useCurrentWorkspaceAndApplication();
|
||||
const [changingApplicationStateLoading, setChangingApplicationStateLoading] =
|
||||
useState(false);
|
||||
const [updateApplication, { client }] = useUpdateApplicationMutation();
|
||||
const { id, email } = useUserData();
|
||||
const { id } = useUserData();
|
||||
const isOwner = currentWorkspace.members.some(
|
||||
({ userId, type }) => userId === id && type === 'owner',
|
||||
);
|
||||
const isPro = currentApplication.plan.name === 'Pro';
|
||||
const [showDeletingModal, setShowDeletingModal] = useState(false);
|
||||
const [unpauseApplication, { loading: changingApplicationStateLoading }] =
|
||||
useUnpauseApplicationMutation({
|
||||
refetchQueries: [GetOneUserDocument],
|
||||
});
|
||||
|
||||
const { data, loading } = useGetFreeAndActiveProjectsQuery({
|
||||
variables: { userId: id },
|
||||
fetchPolicy: 'cache-and-network',
|
||||
});
|
||||
|
||||
const numberOfFreeAndLiveProjects = data?.freeAndActiveProjects.length || 0;
|
||||
const wakeUpDisabled = numberOfFreeAndLiveProjects >= MAX_FREE_PROJECTS;
|
||||
|
||||
async function handleTriggerUnpausing() {
|
||||
setChangingApplicationStateLoading(true);
|
||||
try {
|
||||
await updateApplication({
|
||||
variables: {
|
||||
appId: currentApplication.id,
|
||||
app: {
|
||||
desiredState: ApplicationStatus.Live,
|
||||
await toast.promise(
|
||||
unpauseApplication({ variables: { appId: currentApplication.id } }),
|
||||
{
|
||||
loading: 'Starting the project...',
|
||||
success: `The project has been started successfully.`,
|
||||
error: (arg: ApolloError) => {
|
||||
// we need to get the internal error message from the GraphQL error
|
||||
const { internal } = arg.graphQLErrors[0]?.extensions || {};
|
||||
const { message } = (internal as Record<string, any>)?.error || {};
|
||||
|
||||
// we use the default Apollo error message if we can't find the
|
||||
// internal error message
|
||||
return (
|
||||
message ||
|
||||
arg.message ||
|
||||
'An error occurred while waking up the project. Please try again.'
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
await updateOwnCache(client);
|
||||
discordAnnounce(
|
||||
`App ${currentApplication.name} (${email}) set to awake.`,
|
||||
getToastStyleProps(),
|
||||
);
|
||||
triggerToast(`${currentApplication.name} set to awake.`);
|
||||
} catch (e) {
|
||||
triggerToast(`Error trying to awake ${currentApplication.name}`);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <ActivityIndicator label="Loading user data..." delay={1000} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -65,7 +92,7 @@ export default function ApplicationPaused() {
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Container className="mx-auto mt-20 grid max-w-sm grid-flow-row gap-2 text-center">
|
||||
<Container className="mx-auto mt-20 grid max-w-lg grid-flow-row gap-4 text-center">
|
||||
<div className="mx-auto flex w-centImage flex-col text-center">
|
||||
<Image
|
||||
src="/assets/PausedApp.svg"
|
||||
@@ -75,16 +102,18 @@ export default function ApplicationPaused() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text variant="h3" component="h1" className="mt-4">
|
||||
{currentApplication.name} is sleeping
|
||||
</Text>
|
||||
<Box className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h1">
|
||||
{currentApplication.name} is sleeping
|
||||
</Text>
|
||||
|
||||
<Text className="mt-1">
|
||||
Projects on the free plan stop responding to API calls after 7 days of
|
||||
no traffic.
|
||||
</Text>
|
||||
<Text>
|
||||
Starter projects stop responding to API calls after 7 days of
|
||||
inactivity. Upgrade to Pro to avoid autosleep.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{!isPro && (
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
onClick={() => {
|
||||
@@ -101,32 +130,41 @@ export default function ApplicationPaused() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
Upgrade to Pro to avoid autosleep
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
loading={changingApplicationStateLoading}
|
||||
disabled={changingApplicationStateLoading}
|
||||
onClick={handleTriggerUnpausing}
|
||||
>
|
||||
Wake Up
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
|
||||
{isOwner && (
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
variant="borderless"
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
onClick={() => setShowDeletingModal(true)}
|
||||
loading={changingApplicationStateLoading}
|
||||
disabled={changingApplicationStateLoading || wakeUpDisabled}
|
||||
onClick={handleTriggerUnpausing}
|
||||
>
|
||||
Delete Project
|
||||
Wake Up
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{wakeUpDisabled && (
|
||||
<Alert severity="warning" className="mx-auto max-w-xs text-left">
|
||||
Note: Only one free project can be active at any given time.
|
||||
Please pause your active free project before unpausing{' '}
|
||||
{currentApplication.name}.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<Button
|
||||
color="error"
|
||||
variant="borderless"
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
onClick={() => setShowDeletingModal(true)}
|
||||
>
|
||||
Delete Project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<StagingMetadata>
|
||||
<ApplicationInfo />
|
||||
</StagingMetadata>
|
||||
|
||||
@@ -32,12 +32,12 @@ function Plan({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="my-4 grid w-full grid-flow-col items-center justify-between px-1"
|
||||
className="my-4 grid w-full grid-flow-col items-center justify-between gap-2 px-1"
|
||||
onClick={setPlan}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="grid grid-flow-row gap-y-0.5">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="grid grid-flow-col items-center justify-start gap-2">
|
||||
<Checkbox
|
||||
onChange={setPlan}
|
||||
checked={selectedPlanId === planId}
|
||||
@@ -47,12 +47,13 @@ function Plan({
|
||||
<Text
|
||||
variant="h3"
|
||||
component="p"
|
||||
className="ml-2 self-center font-medium"
|
||||
className="self-center text-left font-medium"
|
||||
>
|
||||
{currentPlan.price > price ? 'Downgrade' : 'Upgrade'} to {planName}
|
||||
</Text>
|
||||
</div>
|
||||
<Text variant="subtitle2" className="w-64 text-start">
|
||||
|
||||
<Text variant="subtitle2" className="w-full max-w-[256px] text-start">
|
||||
{planDescriptions[planName]}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -142,7 +143,7 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="w-welcome rounded-lg p-6 text-left">
|
||||
<Box className="w-full max-w-xl rounded-lg p-6 text-left">
|
||||
<Modal
|
||||
showModal={paymentModal}
|
||||
close={closePaymentModal}
|
||||
|
||||
@@ -12,7 +12,7 @@ import generateAppServiceUrl, {
|
||||
defaultRemoteBackendSlugs,
|
||||
} from '@/utils/common/generateAppServiceUrl';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { LOCAL_HASURA_URL } from '@/utils/env';
|
||||
import { getHasuraConsoleServiceUrl } from '@/utils/env';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface HasuraDataProps {
|
||||
@@ -22,17 +22,15 @@ interface HasuraDataProps {
|
||||
export function HasuraData({ close }: HasuraDataProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const isPlatform = useIsPlatform();
|
||||
const projectAdminSecret = currentApplication?.config?.hasura.adminSecret;
|
||||
|
||||
if (
|
||||
!currentApplication?.subdomain ||
|
||||
!currentApplication?.hasuraGraphqlAdminSecret
|
||||
) {
|
||||
if (!currentApplication?.subdomain || !projectAdminSecret) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
const hasuraUrl =
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev' || !isPlatform
|
||||
? `${LOCAL_HASURA_URL}/console`
|
||||
? `${getHasuraConsoleServiceUrl()}`
|
||||
: generateAppServiceUrl(
|
||||
currentApplication?.subdomain,
|
||||
currentApplication?.region.awsName,
|
||||
@@ -71,18 +69,11 @@ export function HasuraData({ close }: HasuraDataProps) {
|
||||
|
||||
<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(currentApplication.hasuraGraphqlAdminSecret.length)
|
||||
.fill('•')
|
||||
.join('')}
|
||||
{Array(projectAdminSecret.length).fill('•').join('')}
|
||||
</Text>
|
||||
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
copy(
|
||||
currentApplication.hasuraGraphqlAdminSecret,
|
||||
'Hasura admin secret',
|
||||
)
|
||||
}
|
||||
onClick={() => copy(projectAdminSecret, 'Hasura admin secret')}
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="min-w-0 p-1"
|
||||
|
||||
@@ -6,7 +6,10 @@ import Divider from '@/ui/v2/Divider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useDeleteApplicationMutation } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
GetOneUserDocument,
|
||||
useDeleteApplicationMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import router from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -42,7 +45,9 @@ export function RemoveApplicationModal({
|
||||
description,
|
||||
className,
|
||||
}: RemoveApplicationModalProps) {
|
||||
const [deleteApplication, { client }] = useDeleteApplicationMutation();
|
||||
const [deleteApplication] = useDeleteApplicationMutation({
|
||||
refetchQueries: [GetOneUserDocument],
|
||||
});
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
@@ -73,9 +78,6 @@ export function RemoveApplicationModal({
|
||||
}
|
||||
close();
|
||||
await router.push('/');
|
||||
await client.refetchQueries({
|
||||
include: ['getOneUser'],
|
||||
});
|
||||
triggerToast(`${currentApplication.name} deleted`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import DeploymentStatusMessage from '@/components/deployments/DeploymentStatusMessage';
|
||||
import { FindOldApps } from '@/components/home';
|
||||
import type { UserData } from '@/hooks/useGetAllUserWorkspacesAndApplications';
|
||||
import type { Application, ApplicationState } from '@/types/application';
|
||||
import type { ApplicationState } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import StateBadge from '@/ui/StateBadge';
|
||||
import type { DeploymentStatus } from '@/ui/StatusCircle';
|
||||
import { StatusCircle } from '@/ui/StatusCircle';
|
||||
@@ -10,59 +10,11 @@ import Divider from '@/ui/v2/Divider';
|
||||
import Link from '@/ui/v2/Link';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { getApplicationStatusString } from '@/utils/helpers';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import Image from 'next/image';
|
||||
import NavLink from 'next/link';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
function ApplicationCreatedAt({ createdAt }: any) {
|
||||
return (
|
||||
<Text component="span" className="text-sm">
|
||||
created{' '}
|
||||
{formatDistance(new Date(createdAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function LastSuccessfulDeployment({ deployment }: any) {
|
||||
return (
|
||||
<span className="flex flex-row">
|
||||
<Avatar
|
||||
component="span"
|
||||
name={deployment.commitUserName}
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
className="mr-1 h-4 w-4 self-center"
|
||||
/>
|
||||
<Text component="span" className="self-center text-sm">
|
||||
{deployment.commitUserName} deployed{' '}
|
||||
{formatDistance(new Date(deployment.deploymentEndedAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentDeployment({ deployment }: any) {
|
||||
return (
|
||||
<span className="flex flex-row">
|
||||
<Avatar
|
||||
component="span"
|
||||
name={deployment.commitUserName}
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
className="mr-1 h-4 w-4 self-center"
|
||||
/>
|
||||
<Text className="self-center text-sm">
|
||||
{deployment.commitUserName} updated just now
|
||||
</Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function checkStatusOfTheApplication(
|
||||
stateHistory: ApplicationState[] | [],
|
||||
) {
|
||||
@@ -103,7 +55,7 @@ export function RenderWorkspacesWithApps({
|
||||
}
|
||||
|
||||
const workspaceProjects = workspace.applications
|
||||
.filter((app: Application) =>
|
||||
.filter((app) =>
|
||||
app.name.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
.sort((appA, appB) => {
|
||||
@@ -141,29 +93,26 @@ export function RenderWorkspacesWithApps({
|
||||
</NavLink>
|
||||
<List className="grid grid-flow-row border-y">
|
||||
{workspaceProjects.map((app, index) => {
|
||||
const isDeployingToProduction = app.deployments[0]
|
||||
? app.deployments[0].deploymentStatus === 'DEPLOYING'
|
||||
: false;
|
||||
const [latestDeployment] = app.deployments;
|
||||
|
||||
return (
|
||||
<Fragment key={app.slug}>
|
||||
<ListItem.Root
|
||||
secondaryAction={
|
||||
<div className="grid grid-flow-col gap-px">
|
||||
{app.deployments[0] && (
|
||||
{latestDeployment && (
|
||||
<div className="mr-2 flex self-center align-middle">
|
||||
<StatusCircle
|
||||
status={
|
||||
app.deployments[0]
|
||||
.deploymentStatus as DeploymentStatus
|
||||
latestDeployment.deploymentStatus as DeploymentStatus
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StateBadge
|
||||
status={checkStatusOfTheApplication(
|
||||
app.appStates,
|
||||
)}
|
||||
state={checkStatusOfTheApplication(app.appStates)}
|
||||
desiredState={app.desiredState}
|
||||
title={getApplicationStatusString(
|
||||
checkStatusOfTheApplication(app.appStates),
|
||||
)}
|
||||
@@ -190,27 +139,10 @@ export function RenderWorkspacesWithApps({
|
||||
<ListItem.Text
|
||||
primary={app.name}
|
||||
secondary={
|
||||
<>
|
||||
{isDeployingToProduction && (
|
||||
<CurrentDeployment
|
||||
deployment={app.deployments[0]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isDeployingToProduction &&
|
||||
app.deployments[0] && (
|
||||
<LastSuccessfulDeployment
|
||||
deployment={app.deployments[0]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isDeployingToProduction &&
|
||||
!app.deployments[0] && (
|
||||
<ApplicationCreatedAt
|
||||
createdAt={app.createdAt}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<DeploymentStatusMessage
|
||||
appCreatedAt={app.createdAt}
|
||||
deployment={latestDeployment}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ListItem.Button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { PropsWithChildren } from 'react';
|
||||
export function StagingMetadata({ children }: PropsWithChildren<unknown>) {
|
||||
return (
|
||||
isDevOrStaging() && (
|
||||
<div className="mt-10">
|
||||
<div className="mx-auto mt-10 max-w-sm">
|
||||
<Box className="mx-auto flex flex-col rounded-md border p-5 text-center">
|
||||
<Status status={StatusEnum.Deploying}>Internal info</Status>
|
||||
{children}
|
||||
|
||||
@@ -33,11 +33,10 @@ export function UnlockFeatureByUpgrading({
|
||||
title: 'Upgrade your plan.',
|
||||
payload: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0' },
|
||||
PaperProps: { className: 'p-0 max-w-xl w-full' },
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
hideTitle: true,
|
||||
maxWidth: 'lg',
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Option from '@/ui/v2/Option';
|
||||
@@ -18,10 +19,12 @@ export interface UserSelectProps {
|
||||
}
|
||||
|
||||
export function UserSelect({ onUserChange, ...props }: UserSelectProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const userApplicationClient = useRemoteApplicationGQLClient();
|
||||
const { data, loading, error } = useRemoteAppGetUsersCustomQuery({
|
||||
client: userApplicationClient,
|
||||
variables: { where: {}, limit: 250, offset: 0 },
|
||||
skip: !currentApplication,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -36,8 +39,6 @@ export function UserSelect({ onUserChange, ...props }: UserSelectProps) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { users } = data;
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...props}
|
||||
@@ -57,7 +58,7 @@ export function UserSelect({ onUserChange, ...props }: UserSelectProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user: RemoteAppGetUsersCustomQuery['users'][0] = users.find(
|
||||
const user: RemoteAppGetUsersCustomQuery['users'][0] = data?.users.find(
|
||||
({ id }) => id === userId,
|
||||
);
|
||||
|
||||
@@ -68,7 +69,7 @@ export function UserSelect({ onUserChange, ...props }: UserSelectProps) {
|
||||
>
|
||||
<Option value="admin">Admin</Option>
|
||||
|
||||
{users.map(({ id, displayName, email, phoneNumber }) => (
|
||||
{data?.users.map(({ id, displayName, email, phoneNumber }) => (
|
||||
<Option key={id} value={id}>
|
||||
{displayName || email || phoneNumber || id}
|
||||
</Option>
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PK!);
|
||||
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PK
|
||||
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PK)
|
||||
: null;
|
||||
|
||||
type AddPaymentMethodFormProps = {
|
||||
close: () => void;
|
||||
@@ -74,7 +76,8 @@ function AddPaymentMethodForm({
|
||||
|
||||
if (createPaymentMethodError) {
|
||||
throw new Error(
|
||||
createPaymentMethodError.message || 'Unknown error occurred.',
|
||||
createPaymentMethodError.message ||
|
||||
'An unknown error occurred. Please try again.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +91,10 @@ function AddPaymentMethodForm({
|
||||
);
|
||||
|
||||
if (attachPaymentMethodError) {
|
||||
throw Error((attachPaymentMethodError as any).response.data);
|
||||
throw new Error(
|
||||
(attachPaymentMethodError as any)?.response?.data ||
|
||||
'An unknown error occurred. Please try again.',
|
||||
);
|
||||
}
|
||||
|
||||
// update workspace with new country code in database
|
||||
@@ -149,7 +155,7 @@ function AddPaymentMethodForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="w-modal2 px-6 pt-6 pb-6 text-left rounded-lg">
|
||||
<Box className="w-modal2 rounded-lg px-6 pt-6 pb-6 text-left">
|
||||
<div className="flex flex-col">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text className="text-center text-lg font-medium">
|
||||
@@ -201,7 +207,7 @@ function AddPaymentMethodForm({
|
||||
|
||||
type BillingPaymentMethodFormProps = {
|
||||
close: () => void;
|
||||
onPaymentMethodAdded?: () => Promise<void>;
|
||||
onPaymentMethodAdded?: (e?: any) => Promise<void>;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function DataGridDateCell<TData extends object>({
|
||||
: undefined;
|
||||
|
||||
const { year, month, day, hour, minute, second } = getDateComponents(date, {
|
||||
adjustTimezone: specificType === 'timetz' || specificType === 'timestamptz',
|
||||
adjustTimezone: ['date', 'timetz', 'timestamptz'].includes(specificType),
|
||||
});
|
||||
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function DataGridPreviewCell<TData extends object>({
|
||||
}
|
||||
|
||||
const { presignedUrl } = await appClient.storage
|
||||
.setAdminSecret(currentApplication.hasuraGraphqlAdminSecret)
|
||||
.setAdminSecret(currentApplication.config?.hasura.adminSecret)
|
||||
.getPresignedUrl({ fileId: id });
|
||||
|
||||
if (!presignedUrl) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BoxProps } from '@/ui/v2/Box';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
export interface FormProps extends BoxProps {
|
||||
@@ -11,6 +12,7 @@ export interface FormProps extends BoxProps {
|
||||
}
|
||||
|
||||
export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
|
||||
const formRef = useRef<HTMLDivElement>();
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
@@ -25,6 +27,15 @@ export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submitButton = Array.from(
|
||||
formRef.current.getElementsByTagName('button'),
|
||||
).find((item) => item.type === 'submit');
|
||||
|
||||
// Disabling submit if the submit button is disabled
|
||||
if (submitButton?.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
handleSubmit(onSubmit)(event);
|
||||
@@ -35,6 +46,7 @@ export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
|
||||
// so keyboard events must be handled on the form element itself.
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||
<Box
|
||||
ref={formRef}
|
||||
component="form"
|
||||
{...props}
|
||||
onKeyDown={(event) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="header"
|
||||
className={twMerge(
|
||||
'z-40 grid w-full transform-gpu grid-flow-col items-center justify-between gap-2 border-b-1 px-4 py-3',
|
||||
className,
|
||||
|
||||
@@ -38,6 +38,12 @@ function IconLink(
|
||||
: [icon.props?.sx]),
|
||||
{
|
||||
color: (theme) => {
|
||||
if (props.disabled) {
|
||||
return theme.palette.mode === 'dark'
|
||||
? 'text.secondary'
|
||||
: 'text.primary';
|
||||
}
|
||||
|
||||
if (active) {
|
||||
return 'primary.main';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
|
||||
export default function MaintenanceAlert() {
|
||||
const { maintenanceActive, maintenanceEndDate } = useUI();
|
||||
|
||||
if (!maintenanceActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateTimeFormat = Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
const parts = dateTimeFormat.formatToParts(maintenanceEndDate);
|
||||
|
||||
const year = parts.find((part) => part.type === 'year')?.value;
|
||||
const month = parts.find((part) => part.type === 'month')?.value;
|
||||
const day = parts.find((part) => part.type === 'day')?.value;
|
||||
const hour = parts.find((part) => part.type === 'hour')?.value;
|
||||
const minute = parts.find((part) => part.type === 'minute')?.value;
|
||||
const timeZone = parts.find((part) => part.type === 'timeZoneName')?.value;
|
||||
|
||||
return (
|
||||
<Alert severity="warning" className="mt-4">
|
||||
<p>
|
||||
We're currently doing maintenance on our infrastructure. Project
|
||||
creation and project settings are temporarily disabled during the
|
||||
maintenance period.
|
||||
</p>
|
||||
|
||||
{maintenanceEndDate && (
|
||||
<p>
|
||||
Maintenance is expected to be completed at {year}-{month}-{day} {hour}
|
||||
:{minute} {timeZone}.
|
||||
</p>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MaintenanceAlert';
|
||||
@@ -1,4 +1,4 @@
|
||||
import customClaimsQuery from '@/utils/msw/mocks/graphql/customClaimsQuery';
|
||||
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
@@ -6,7 +6,11 @@ import { setupServer } from 'msw/node';
|
||||
import { test, vi } from 'vitest';
|
||||
import ColumnAutocomplete from './ColumnAutocomplete';
|
||||
|
||||
const server = setupServer(tableQuery, hasuraMetadataQuery, customClaimsQuery);
|
||||
const server = setupServer(
|
||||
tableQuery,
|
||||
hasuraMetadataQuery,
|
||||
permissionVariablesQuery,
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
@@ -547,7 +547,7 @@ export default function DataBrowserSidebar({
|
||||
document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed);
|
||||
}, []);
|
||||
|
||||
if (isPlatform && !currentApplication?.hasuraGraphqlAdminSecret) {
|
||||
if (isPlatform && !currentApplication?.config?.hasura.adminSecret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import convertToHasuraPermissions from '@/utils/dataBrowser/convertToHasuraPermissions';
|
||||
import convertToRuleGroup from '@/utils/dataBrowser/convertToRuleGroup';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
@@ -258,7 +259,7 @@ export default function RolePermissionEditorForm({
|
||||
{
|
||||
loading: 'Saving permission...',
|
||||
success: 'Permission has been saved successfully.',
|
||||
error: 'An error occurred while saving the permission.',
|
||||
error: getServerError('An error occurred while saving the permission.'),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
@@ -297,7 +298,9 @@ export default function RolePermissionEditorForm({
|
||||
{
|
||||
loading: 'Deleting permission...',
|
||||
success: 'Permission has been deleted successfully.',
|
||||
error: 'An error occurred while deleting the permission.',
|
||||
error: getServerError(
|
||||
'An error occurred while deleting the permission.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
@@ -11,8 +11,8 @@ import XIcon from '@/ui/v2/icons/XIcon';
|
||||
import InputLabel from '@/ui/v2/InputLabel';
|
||||
import Option from '@/ui/v2/Option';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
|
||||
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
|
||||
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
|
||||
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
|
||||
import { useTheme } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
@@ -51,8 +51,8 @@ export default function ColumnPresetsSection({
|
||||
} = useTableQuery([`default.${schema}.${table}`], { schema, table });
|
||||
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data: customClaimsData } = useGetAppCustomClaimsQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data: permissionVariablesData } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
skip: !currentApplication?.id,
|
||||
});
|
||||
const {
|
||||
@@ -74,8 +74,8 @@ export default function ColumnPresetsSection({
|
||||
throw tableError;
|
||||
}
|
||||
|
||||
const permissionVariableOptions = getPermissionVariablesArray(
|
||||
customClaimsData?.app?.authJwtCustomClaims,
|
||||
const permissionVariableOptions = getAllPermissionVariables(
|
||||
permissionVariablesData?.config?.auth?.session?.accessToken?.customClaims,
|
||||
).map(({ key }) => ({
|
||||
label: `X-Hasura-${key}`,
|
||||
value: `X-Hasura-${key}`,
|
||||
@@ -136,7 +136,7 @@ export default function ColumnPresetsSection({
|
||||
disableClearable={false}
|
||||
clearIcon={
|
||||
<XIcon
|
||||
className="w-4 h-4 mt-px"
|
||||
className="mt-px h-4 w-4"
|
||||
sx={{ color: theme.palette.text.primary }}
|
||||
/>
|
||||
}
|
||||
@@ -187,7 +187,7 @@ export default function ColumnPresetsSection({
|
||||
disabled={disabled}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className="shrink-0 grow-0 flex-[40px]"
|
||||
className="flex-[40px] shrink-0 grow-0"
|
||||
onClick={() => {
|
||||
if (fields.length === 1) {
|
||||
remove(index);
|
||||
@@ -199,7 +199,7 @@ export default function ColumnPresetsSection({
|
||||
remove(index);
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
<XIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -39,17 +39,17 @@ const ruleGroupSchema = Yup.object().shape({
|
||||
|
||||
const baseValidationSchema = Yup.object().shape({
|
||||
filter: ruleGroupSchema.nullable().required('Please select a filter type.'),
|
||||
columns: Yup.array().of(Yup.string()).nullable(true),
|
||||
columns: Yup.array().of(Yup.string()).nullable(),
|
||||
});
|
||||
|
||||
const selectValidationSchema = baseValidationSchema.shape({
|
||||
limit: Yup.number()
|
||||
.label('Limit')
|
||||
.min(0, 'Limit must not be negative.')
|
||||
.nullable(true),
|
||||
allowAggregations: Yup.boolean().nullable(true),
|
||||
queryRootFields: Yup.array().of(Yup.string()).nullable(true),
|
||||
subscriptionRootFields: Yup.array().of(Yup.string()).nullable(true),
|
||||
.nullable(),
|
||||
allowAggregations: Yup.boolean().nullable(),
|
||||
queryRootFields: Yup.array().of(Yup.string()).nullable(),
|
||||
subscriptionRootFields: Yup.array().of(Yup.string()).nullable(),
|
||||
});
|
||||
|
||||
const columnPresetSchema = Yup.object().shape({
|
||||
@@ -88,17 +88,17 @@ const columnPresetSchema = Yup.object().shape({
|
||||
});
|
||||
|
||||
const insertValidationSchema = baseValidationSchema.shape({
|
||||
backendOnly: Yup.boolean().nullable(true),
|
||||
columnPresets: Yup.array().of(columnPresetSchema).nullable(true),
|
||||
backendOnly: Yup.boolean().nullable(),
|
||||
columnPresets: Yup.array().of(columnPresetSchema).nullable(),
|
||||
});
|
||||
|
||||
const updateValidationSchema = baseValidationSchema.shape({
|
||||
backendOnly: Yup.boolean().nullable(true),
|
||||
columnPresets: Yup.array().of(columnPresetSchema).nullable(true),
|
||||
backendOnly: Yup.boolean().nullable(),
|
||||
columnPresets: Yup.array().of(columnPresetSchema).nullable(),
|
||||
});
|
||||
|
||||
const deleteValidationSchema = baseValidationSchema.shape({
|
||||
columnPresets: Yup.array().of(columnPresetSchema).nullable(true),
|
||||
columnPresets: Yup.array().of(columnPresetSchema).nullable(),
|
||||
});
|
||||
|
||||
const validationSchemas: Record<DatabaseAction, Yup.ObjectSchema<any>> = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import Form from '@/components/common/Form';
|
||||
import type { RuleGroup } from '@/types/dataBrowser';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import customClaimsQuery from '@/utils/msw/mocks/graphql/customClaimsQuery';
|
||||
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
@@ -36,7 +36,7 @@ const defaultParameters = {
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers: [tableQuery, hasuraMetadataQuery, customClaimsQuery],
|
||||
handlers: [tableQuery, hasuraMetadataQuery, permissionVariablesQuery],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import type { AutocompleteOption } from '@/ui/v2/Autocomplete';
|
||||
import type { InputProps } from '@/ui/v2/Input';
|
||||
import { inputClasses } from '@/ui/v2/Input';
|
||||
import Option from '@/ui/v2/Option';
|
||||
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
|
||||
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
|
||||
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
|
||||
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
|
||||
import { useController, useFormContext, useWatch } from 'react-hook-form';
|
||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||
|
||||
@@ -117,8 +117,8 @@ export default function RuleValueInput({
|
||||
data,
|
||||
loading,
|
||||
error: customClaimsError,
|
||||
} = useGetAppCustomClaimsQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
} = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
skip: !isHasuraInput || !currentApplication?.id,
|
||||
});
|
||||
|
||||
@@ -200,8 +200,8 @@ export default function RuleValueInput({
|
||||
);
|
||||
}
|
||||
|
||||
const availableHasuraPermissionVariables = getPermissionVariablesArray(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
const availableHasuraPermissionVariables = getAllPermissionVariables(
|
||||
data?.config?.auth?.session?.accessToken?.customClaims,
|
||||
).map(({ key }) => ({
|
||||
value: `X-Hasura-${key}`,
|
||||
label: `X-Hasura-${key}`,
|
||||
|
||||
@@ -10,6 +10,7 @@ import ArrowCounterclockwiseIcon from '@/ui/v2/icons/ArrowCounterclockwiseIcon';
|
||||
import ChevronRightIcon from '@/ui/v2/icons/ChevronRightIcon';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
|
||||
import { useInsertDeploymentMutation } from '@/utils/__generated__/graphql';
|
||||
@@ -58,12 +59,12 @@ export default function DeploymentListItem({
|
||||
return (
|
||||
<ListItem.Root>
|
||||
<ListItem.Button
|
||||
className="grid grid-flow-col items-center justify-between gap-2 rounded-none px-2 py-2"
|
||||
className="grid grid-flow-col items-center justify-between gap-2 rounded-none p-2"
|
||||
component={NavLink}
|
||||
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
|
||||
aria-label={commitMessage || 'No commit message'}
|
||||
>
|
||||
<div className="flex cursor-pointer flex-row items-center justify-center space-x-2 self-center">
|
||||
<div className="grid grid-flow-col items-center justify-center gap-2 self-center">
|
||||
<ListItem.Avatar>
|
||||
<Avatar
|
||||
name={deployment.commitUserName}
|
||||
@@ -84,7 +85,7 @@ export default function DeploymentListItem({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-flow-col items-center gap-2">
|
||||
<div className="grid grid-flow-col items-center justify-end gap-2">
|
||||
{showRedeploy && (
|
||||
<Tooltip
|
||||
title={
|
||||
@@ -122,7 +123,9 @@ export default function DeploymentListItem({
|
||||
{
|
||||
loading: 'Scheduling deployment...',
|
||||
success: 'Deployment has been scheduled successfully.',
|
||||
error: 'An error occurred when scheduling deployment.',
|
||||
error: getServerError(
|
||||
'An error occurred when scheduling deployment.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
@@ -139,16 +142,16 @@ export default function DeploymentListItem({
|
||||
)}
|
||||
|
||||
{isLive && (
|
||||
<div className="flex w-12 justify-end">
|
||||
<div className="hidden w-12 justify-end sm:flex">
|
||||
<Chip size="small" color="success" label="Live" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-16 text-right font-mono text-sm- font-medium">
|
||||
<div className="hidden w-16 text-right font-mono text-sm- font-medium sm:block">
|
||||
{deployment.commitSHA.substring(0, 7)}
|
||||
</div>
|
||||
|
||||
<div className="w-[80px] text-right font-mono text-sm- font-medium">
|
||||
<div className="text-right font-mono text-sm- font-medium sm:w-20">
|
||||
<AppDeploymentDuration
|
||||
startedAt={deployment.deploymentStartedAt}
|
||||
endedAt={deployment.deploymentEndedAt}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Deployment } from '@/types/application';
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
import { test, vi } from 'vitest';
|
||||
import DeploymentStatusMessage from './DeploymentStatusMessage';
|
||||
|
||||
const defaultDeployment: Deployment = {
|
||||
id: 'de305d54-75b4-431b-adb2-eb6b9e546013',
|
||||
commitUserName: 'john.doe',
|
||||
commitUserAvatarUrl: 'https://example.com/avatar.png',
|
||||
deploymentStartedAt: '2023-02-24T12:00:00.000Z',
|
||||
deploymentEndedAt: null,
|
||||
deploymentStatus: 'SCHEDULED',
|
||||
commitSHA: '1234567890',
|
||||
commitMessage: 'Update README.md',
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('should render the avatar of the user who deployed the application', () => {
|
||||
render(
|
||||
<DeploymentStatusMessage
|
||||
deployment={defaultDeployment}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('img', {
|
||||
name: `Avatar of ${defaultDeployment.commitUserName}`,
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
'style',
|
||||
`background-image: url(${defaultDeployment.commitUserAvatarUrl});`,
|
||||
);
|
||||
});
|
||||
|
||||
test('should render "updated just now" when the deployment is in progress and has not ended', () => {
|
||||
render(
|
||||
<DeploymentStatusMessage
|
||||
deployment={defaultDeployment}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/updated just now/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render "updated just now" when the deployment\'s status is DEPLOYED, but it doesn\'t have an end date for some reason', () => {
|
||||
render(
|
||||
<DeploymentStatusMessage
|
||||
deployment={{
|
||||
...defaultDeployment,
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
deploymentEndedAt: null,
|
||||
}}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/updated just now/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render "deployed 1 day ago" when the deployment has ended', () => {
|
||||
vi.setSystemTime(new Date('2023-02-25T12:25:00.000Z'));
|
||||
|
||||
render(
|
||||
<DeploymentStatusMessage
|
||||
deployment={{
|
||||
...defaultDeployment,
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
deploymentEndedAt: '2023-02-24T12:15:00.000Z',
|
||||
}}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/deployed 1 day ago/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render "created 1 day ago" if the application does not have a deployment', () => {
|
||||
vi.setSystemTime(new Date('2023-02-25T12:25:00.000Z'));
|
||||
|
||||
render(
|
||||
<DeploymentStatusMessage deployment={null} appCreatedAt="2023-02-24" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/created 1 day ago/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Deployment } from '@/types/application';
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import formatDistance from 'date-fns/formatDistance';
|
||||
|
||||
export interface DeploymentStatusMessageProps {
|
||||
/**
|
||||
* The deployment to render the status message for.
|
||||
*/
|
||||
deployment: Partial<Deployment>;
|
||||
/**
|
||||
* The date the application was created.
|
||||
*/
|
||||
appCreatedAt: string;
|
||||
}
|
||||
|
||||
export default function DeploymentStatusMessage({
|
||||
deployment,
|
||||
appCreatedAt,
|
||||
}: DeploymentStatusMessageProps) {
|
||||
const isDeployingToProduction = [
|
||||
'SCHEDULED',
|
||||
'PENDING',
|
||||
'DEPLOYING',
|
||||
].includes(deployment?.deploymentStatus);
|
||||
|
||||
if (
|
||||
isDeployingToProduction ||
|
||||
(deployment && !deployment.deploymentEndedAt)
|
||||
) {
|
||||
return (
|
||||
<span className="flex flex-row">
|
||||
<Avatar
|
||||
component="span"
|
||||
name={deployment.commitUserName}
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
className="mr-1 h-4 w-4 self-center"
|
||||
/>
|
||||
<Text component="span" className="self-center text-sm">
|
||||
{deployment.commitUserName} updated just now
|
||||
</Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDeployingToProduction && deployment?.deploymentEndedAt) {
|
||||
return (
|
||||
<span className="grid grid-flow-col">
|
||||
<Avatar
|
||||
component="span"
|
||||
name={deployment.commitUserName}
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
className="mr-1 h-4 w-4 self-center"
|
||||
/>
|
||||
<Text component="span" className="self-center truncate text-sm">
|
||||
{deployment.commitUserName} deployed{' '}
|
||||
{formatDistance(new Date(deployment.deploymentEndedAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text component="span" className="text-sm">
|
||||
created{' '}
|
||||
{formatDistance(new Date(appCreatedAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DeploymentStatusMessage';
|
||||
export { default } from './DeploymentStatusMessage';
|
||||
@@ -12,6 +12,7 @@ import useBuckets from '@/hooks/useBuckets';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import useFiles from '@/hooks/useFiles';
|
||||
import useFilesAggregate from '@/hooks/useFilesAggregate';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { showLoadingToast, triggerToast } from '@/utils/toast';
|
||||
import type { Files } from '@/utils/__generated__/graphql';
|
||||
import { Order_By as OrderBy } from '@/utils/__generated__/graphql';
|
||||
@@ -261,8 +262,8 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
|
||||
const { fileMetadata, error: fileError } = await appClient.storage
|
||||
.setAdminSecret(
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? 'nhost-admin-secret'
|
||||
: currentApplication.hasuraGraphqlAdminSecret,
|
||||
? getHasuraAdminSecret()
|
||||
: currentApplication.config?.hasura.adminSecret,
|
||||
)
|
||||
.upload({
|
||||
file,
|
||||
|
||||
@@ -12,6 +12,7 @@ import Button from '@/ui/v2/Button';
|
||||
import Chip from '@/ui/v2/Chip';
|
||||
import type { InputProps } from '@/ui/v2/Input';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import type { Files } from '@/utils/__generated__/graphql';
|
||||
import type { PropsWithoutRef } from 'react';
|
||||
@@ -71,8 +72,8 @@ export default function FilesDataGridControls({
|
||||
try {
|
||||
const storageWithAdminSecret = appClient.storage.setAdminSecret(
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? 'nhost-admin-secret'
|
||||
: currentApplication.hasuraGraphqlAdminSecret,
|
||||
? getHasuraAdminSecret()
|
||||
: currentApplication.config?.hasura.adminSecret,
|
||||
);
|
||||
|
||||
// note: this is not an optimal solution, but we don't have a better way
|
||||
@@ -120,7 +121,7 @@ export default function FilesDataGridControls({
|
||||
{...props}
|
||||
>
|
||||
{numberOfSelectedFiles > 0 ? (
|
||||
<div className="mx-auto h-[40px] grid grid-flow-col justify-start items-center gap-2">
|
||||
<div className="mx-auto grid h-[40px] grid-flow-col items-center justify-start gap-2">
|
||||
<Chip
|
||||
color="info"
|
||||
size="small"
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DialogFormProps } from '@/types/common';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { slugifyString } from '@/utils/helpers';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
refetchGetOneUserQuery,
|
||||
@@ -141,7 +142,9 @@ export default function EditWorkspaceNameForm({
|
||||
{
|
||||
loading: 'Updating workspace name...',
|
||||
success: 'Workspace name has been updated successfully.',
|
||||
error: 'An error occurred while updating the workspace name.',
|
||||
error: getServerError(
|
||||
'An error occurred while updating the workspace name.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
@@ -168,7 +171,9 @@ export default function EditWorkspaceNameForm({
|
||||
{
|
||||
loading: 'Creating new workspace...',
|
||||
success: 'The new workspace has been created successfully.',
|
||||
error: 'An error occurred while creating the new workspace.',
|
||||
error: getServerError(
|
||||
'An error occurred while creating the new workspace.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import PlusCircleIcon from '@/ui/v2/icons/PlusCircleIcon';
|
||||
import SearchIcon from '@/ui/v2/icons/SearchIcon';
|
||||
@@ -11,6 +12,8 @@ interface IndexHeaderAppsProps {
|
||||
}
|
||||
|
||||
export function IndexHeaderApps({ query, setQuery }: IndexHeaderAppsProps) {
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
return (
|
||||
<div className="mx-auto mb-6 grid w-full grid-flow-col place-content-between items-center py-2">
|
||||
<Text variant="h2" component="h1" className="hidden md:block">
|
||||
@@ -36,6 +39,7 @@ export function IndexHeaderApps({ query, setQuery }: IndexHeaderAppsProps) {
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon={<PlusCircleIcon />}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
New Project
|
||||
</Button>
|
||||
|
||||
@@ -99,7 +99,6 @@ export function InviteAnnounce() {
|
||||
workspaceMemberInviteId: inviteId,
|
||||
isAccepted: false,
|
||||
},
|
||||
{ useAxios: false },
|
||||
);
|
||||
|
||||
if (ignoreError) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import Link from 'next/link';
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-8 mt-2 ml-10 w-full md:grid md:w-workspaceSidebar content-start">
|
||||
<div className="mt-2 grid w-full grid-flow-row content-start gap-8 md:ml-10 md:grid md:w-workspaceSidebar">
|
||||
<WorkspaceSection />
|
||||
<Resources />
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useGetAllUserWorkspacesAndApplications } from '@/hooks/useGetAllUserWor
|
||||
import { useNavigationVisible } from '@/hooks/useNavigationVisible';
|
||||
import useNotFoundRedirect from '@/hooks/useNotFoundRedirect';
|
||||
import { useSetAppWorkspaceContextFromUserContext } from '@/hooks/useSetAppWorkspaceContextFromUserContext';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import type { BoxProps } from '@/ui/v2/Box';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import { NextSeo } from 'next-seo';
|
||||
@@ -30,9 +31,15 @@ function ProjectLayoutContent({
|
||||
...mainContainerProps
|
||||
} = {},
|
||||
}: ProjectLayoutProps) {
|
||||
const { currentApplication, currentWorkspace } =
|
||||
// TODO: This will be removed once we migrated every occurrence to
|
||||
// useCurrentWorkspaceAndProject()
|
||||
const { currentWorkspace, currentApplication } =
|
||||
useCurrentWorkspaceAndApplication();
|
||||
|
||||
const { currentProject, loading, error } = useCurrentWorkspaceAndProject({
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const shouldDisplayNav = useNavigationVisible();
|
||||
const isPlatform = useIsPlatform();
|
||||
@@ -63,10 +70,14 @@ function ProjectLayoutContent({
|
||||
}
|
||||
}, [isPlatform, isRestrictedPath, router]);
|
||||
|
||||
if (!currentWorkspace || !currentApplication || isRestrictedPath) {
|
||||
if (!currentWorkspace || !currentApplication || isRestrictedPath || loading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!isPlatform) {
|
||||
return (
|
||||
<>
|
||||
@@ -104,7 +115,7 @@ function ProjectLayoutContent({
|
||||
>
|
||||
{children}
|
||||
|
||||
<NextSeo title={currentApplication.name} />
|
||||
<NextSeo title={currentProject.name} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useDropdown } from '@/ui/v2/Dropdown';
|
||||
import type { InputProps } from '@/ui/v2/Input';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { format, set } from 'date-fns';
|
||||
import type { ChangeEvent } from 'react';
|
||||
|
||||
export interface LogTimePickerProps extends InputProps {
|
||||
/**
|
||||
@@ -22,21 +23,21 @@ function LogsTimePicker({
|
||||
}: any) {
|
||||
const { handleClose } = useDropdown();
|
||||
|
||||
const handleCancel = () => {
|
||||
function handleCancel() {
|
||||
handleClose();
|
||||
};
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
function handleApply() {
|
||||
onChange(selectedDate);
|
||||
handleClose();
|
||||
};
|
||||
}
|
||||
|
||||
const handleTimePicking = (event) => {
|
||||
const [hours, minutes, seconds] = event.target.value.split(':');
|
||||
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const [hours, minutes, seconds] = event.target.value?.split(':') || [];
|
||||
|
||||
const hoursNumber = parseInt(hours, 10);
|
||||
const minutesNumber = parseInt(minutes, 10);
|
||||
const secondsNumber = parseInt(seconds, 10);
|
||||
const hoursNumber = parseInt(hours || '0', 10);
|
||||
const minutesNumber = parseInt(minutes || '0', 10);
|
||||
const secondsNumber = parseInt(seconds || '0', 10);
|
||||
|
||||
const newDate = set(new Date(selectedDate), {
|
||||
hours: hoursNumber,
|
||||
@@ -51,7 +52,7 @@ function LogsTimePicker({
|
||||
}
|
||||
|
||||
setSelectedDate(newDate);
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid grid-flow-row items-center self-center">
|
||||
@@ -64,7 +65,7 @@ function LogsTimePicker({
|
||||
formControl: { className: 'grid grid-flow-col gap-x-3' },
|
||||
label: { sx: { fontSize: '14px' } },
|
||||
}}
|
||||
onChange={handleTimePicking}
|
||||
onChange={handleChange}
|
||||
type="time"
|
||||
label="Select Time"
|
||||
sx={{
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { UserDataProvider } from '@/context/workspace1-context';
|
||||
import type { Application } from '@/types/application';
|
||||
import type { Project } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { Workspace } from '@/types/workspace';
|
||||
import nhostGraphQLLink from '@/utils/msw/mocks/graphql/nhostGraphQLLink';
|
||||
import { render, screen, waitForElementToBeRemoved } from '@/utils/testUtils';
|
||||
import { graphql, rest } from 'msw';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, beforeAll, vi } from 'vitest';
|
||||
import OverviewDeployments from '.';
|
||||
@@ -36,12 +37,11 @@ vi.mock('next/router', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockApplication: Application = {
|
||||
const mockApplication: Project = {
|
||||
id: '1',
|
||||
name: 'Test Application',
|
||||
slug: 'test-application',
|
||||
appStates: [],
|
||||
hasuraGraphqlAdminSecret: 'nhost-admin-secret',
|
||||
subdomain: '',
|
||||
isProvisioned: true,
|
||||
region: {
|
||||
@@ -56,6 +56,14 @@ const mockApplication: Application = {
|
||||
featureFlags: [],
|
||||
providersUpdated: true,
|
||||
githubRepository: { fullName: 'test/git-project' },
|
||||
repositoryProductionBranch: null,
|
||||
nhostBaseFolder: null,
|
||||
plan: null,
|
||||
config: {
|
||||
hasura: {
|
||||
adminSecret: 'nhost-admin-secret',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockWorkspace: Workspace = {
|
||||
@@ -66,13 +74,11 @@ const mockWorkspace: Workspace = {
|
||||
applications: [mockApplication],
|
||||
};
|
||||
|
||||
const mockGraphqlLink = graphql.link('http://localhost:1337/v1/graphql');
|
||||
|
||||
const server = setupServer(
|
||||
rest.get('http://localhost:1337/v1/graphql', (req, res, ctx) =>
|
||||
rest.get('https://local.graphql.nhost.run/v1', (_req, res, ctx) =>
|
||||
res(ctx.status(200)),
|
||||
),
|
||||
mockGraphqlLink.operation(async (req, res, ctx) =>
|
||||
nhostGraphQLLink.operation(async (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
deployments: [],
|
||||
@@ -136,7 +142,7 @@ test('should render an empty state when GitHub is connected, but there are no de
|
||||
|
||||
test('should render a list of deployments', async () => {
|
||||
server.use(
|
||||
mockGraphqlLink.operation(async (req, res, ctx) => {
|
||||
nhostGraphQLLink.operation(async (req, res, ctx) => {
|
||||
const requestPayload = await req.json();
|
||||
|
||||
if (requestPayload.operationName === 'ScheduledOrPendingDeploymentsSub') {
|
||||
@@ -186,7 +192,7 @@ test('should render a list of deployments', async () => {
|
||||
|
||||
test('should disable redeployments if a deployment is already in progress', async () => {
|
||||
server.use(
|
||||
mockGraphqlLink.operation(async (req, res, ctx) => {
|
||||
nhostGraphQLLink.operation(async (req, res, ctx) => {
|
||||
const requestPayload = await req.json();
|
||||
|
||||
if (requestPayload.operationName === 'ScheduledOrPendingDeploymentsSub') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import useGitHubModal from '@/components/applications/github/useGitHubModal';
|
||||
import DeploymentListItem from '@/components/deployments/DeploymentListItem';
|
||||
import GithubIcon from '@/components/icons/GithubIcon';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
@@ -75,7 +76,7 @@ function OverviewDeploymentList() {
|
||||
|
||||
if (!deployments?.length) {
|
||||
return (
|
||||
<Box className="grid grid-flow-row items-center justify-items-center gap-5 overflow-hidden rounded-lg border-1 py-12 px-48 shadow-sm">
|
||||
<Box className="grid grid-flow-row items-center justify-items-center gap-5 overflow-hidden rounded-lg border-1 py-12 px-4 shadow-sm">
|
||||
<RocketIcon
|
||||
strokeWidth={1}
|
||||
className="h-10 w-10"
|
||||
@@ -85,7 +86,7 @@ function OverviewDeploymentList() {
|
||||
<Text className="text-center font-medium" variant="h3">
|
||||
No Deployments
|
||||
</Text>
|
||||
<Text variant="subtitle1" className="text-center">
|
||||
<Text variant="subtitle1" className="max-w-md text-center">
|
||||
We'll deploy changes automatically when you push to the
|
||||
deployment branch in your connected GitHub repository
|
||||
</Text>
|
||||
@@ -146,6 +147,7 @@ function OverviewDeploymentList() {
|
||||
export default function OverviewDeployments() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { openGitHubModal } = useGitHubModal();
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
const { githubRepository } = currentApplication || {};
|
||||
|
||||
@@ -164,14 +166,14 @@ export default function OverviewDeployments() {
|
||||
<div className="flex flex-col">
|
||||
<OverviewDeploymentsTopBar />
|
||||
|
||||
<Box className="grid grid-flow-row items-center justify-items-center gap-5 rounded-lg border-1 py-12 px-48 shadow-sm">
|
||||
<Box className="grid grid-flow-row items-center justify-items-center gap-5 rounded-lg border-1 py-12 px-4 shadow-sm">
|
||||
<RocketIcon strokeWidth={1} className="h-10 w-10" />
|
||||
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text className="text-center font-medium" variant="h3">
|
||||
No Deployments
|
||||
</Text>
|
||||
<Text variant="subtitle1" className="text-center">
|
||||
<Text variant="subtitle1" className="max-w-sm text-center">
|
||||
Connect your project with a GitHub repository to create your first
|
||||
deployment
|
||||
</Text>
|
||||
@@ -183,6 +185,7 @@ export default function OverviewDeployments() {
|
||||
color="primary"
|
||||
className="w-full"
|
||||
onClick={openGitHubModal}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
<GithubIcon className="mr-1.5 h-4 w-4 self-center" />
|
||||
Connect to GitHub
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import InfoCard from '@/components/overview/InfoCard';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function OverviewProjectInfo() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { region, subdomain } = currentApplication || {};
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { region, subdomain } = currentProject || {};
|
||||
const isRegionAvailable =
|
||||
region?.awsName && region?.countryCode && region?.city;
|
||||
|
||||
@@ -13,14 +13,14 @@ export default function OverviewProjectInfo() {
|
||||
<div className="grid grid-flow-row content-start gap-6">
|
||||
<Text variant="h3">Project Info</Text>
|
||||
|
||||
{currentApplication && (
|
||||
{currentProject && (
|
||||
<div className="grid grid-flow-row gap-3">
|
||||
<InfoCard
|
||||
title="Region"
|
||||
value={region?.awsName}
|
||||
customValue={
|
||||
region.countryCode &&
|
||||
region.city && (
|
||||
region?.countryCode &&
|
||||
region?.city && (
|
||||
<div className="grid grid-flow-col items-center gap-1 self-center">
|
||||
<Image
|
||||
src={`/assets/flags/${region.countryCode}.svg`}
|
||||
@@ -29,7 +29,7 @@ export default function OverviewProjectInfo() {
|
||||
height={12}
|
||||
/>
|
||||
|
||||
<Text className="text-sm font-medium truncate">
|
||||
<Text className="truncate text-sm font-medium">
|
||||
{region.city} ({region.awsName})
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import GithubIcon from '@/components/icons/GithubIcon';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -8,6 +9,7 @@ import NavLink from 'next/link';
|
||||
export default function OverviewRepository() {
|
||||
const { currentWorkspace, currentApplication } =
|
||||
useCurrentWorkspaceAndApplication();
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -28,6 +30,7 @@ export default function OverviewRepository() {
|
||||
color="secondary"
|
||||
className="w-full border-1 hover:border-1"
|
||||
startIcon={<GithubIcon />}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Connect to GitHub
|
||||
</Button>
|
||||
@@ -39,7 +42,7 @@ export default function OverviewRepository() {
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
>
|
||||
<Box
|
||||
className="grid grid-flow-col gap-1.5 ml-2"
|
||||
className="ml-2 grid grid-flow-col gap-1.5"
|
||||
sx={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<GithubIcon className="h-4 w-4 self-center" />
|
||||
@@ -52,7 +55,11 @@ export default function OverviewRepository() {
|
||||
href={`/${currentWorkspace.slug}/${currentApplication.slug}/settings/git`}
|
||||
passHref
|
||||
>
|
||||
<Button variant="borderless" size="small">
|
||||
<Button
|
||||
variant="borderless"
|
||||
size="small"
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</NavLink>
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { ChangePlanModal } from '@/components/applications/ChangePlanModal';
|
||||
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Chip from '@/ui/v2/Chip';
|
||||
import CogIcon from '@/ui/v2/icons/CogIcon';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function OverviewTopBar() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentWorkspace, currentApplication } =
|
||||
useCurrentWorkspaceAndApplication();
|
||||
const isPro = !currentApplication?.plan?.isFree;
|
||||
const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isPro = !currentProject?.plan?.isFree;
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
if (!isPlatform) {
|
||||
return (
|
||||
@@ -41,69 +42,100 @@ export default function OverviewTopBar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row place-content-between items-center py-5">
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<div className="grid grid-flow-col items-center gap-2">
|
||||
<div className="grid items-center gap-4 pb-5 md:grid-flow-col md:place-content-between md:py-5">
|
||||
<div className="grid items-center gap-4 md:grid-flow-col">
|
||||
<div className="grid grid-flow-col items-center justify-start gap-2">
|
||||
<div className="h-10 w-10 overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
width={40}
|
||||
height={40}
|
||||
width={56}
|
||||
height={56}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text variant="h2" component="h1">
|
||||
{currentApplication.name}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-flow-row">
|
||||
<div className="grid grid-flow-row items-center justify-start md:grid-flow-col md:gap-3">
|
||||
<Text
|
||||
variant="h2"
|
||||
component="h1"
|
||||
className="grid grid-flow-col items-center gap-3"
|
||||
>
|
||||
{currentProject.name}
|
||||
</Text>
|
||||
|
||||
{isPro ? (
|
||||
<Chip
|
||||
className="self-center font-medium"
|
||||
size="small"
|
||||
label="Pro Plan"
|
||||
color="primary"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Chip
|
||||
className="self-center font-medium"
|
||||
size="small"
|
||||
label="Free Plan"
|
||||
color="default"
|
||||
variant="filled"
|
||||
/>
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="mr-2"
|
||||
onClick={() => {
|
||||
openAlertDialog({
|
||||
title: 'Upgrade your plan.',
|
||||
payload: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0' },
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
hideTitle: true,
|
||||
maxWidth: 'lg',
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentProject.creator && (
|
||||
<Text
|
||||
color="secondary"
|
||||
variant="subtitle2"
|
||||
className="md:hidden"
|
||||
>
|
||||
Created by{' '}
|
||||
{currentProject.creator?.displayName ||
|
||||
currentProject.creator?.email}{' '}
|
||||
{formatDistanceToNowStrict(
|
||||
parseISO(currentProject.createdAt),
|
||||
)}{' '}
|
||||
ago
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="mt-1 inline-grid grid-flow-col items-center justify-start gap-2 md:mt-0">
|
||||
<Chip
|
||||
size="small"
|
||||
label={isPro ? 'Pro' : 'Starter'}
|
||||
color={isPro ? 'primary' : 'default'}
|
||||
/>
|
||||
|
||||
{!isPro && (
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="mr-2"
|
||||
onClick={() => {
|
||||
openAlertDialog({
|
||||
title: 'Upgrade your plan.',
|
||||
payload: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-xl w-full' },
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
hideTitle: true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentProject.creator && (
|
||||
<Text
|
||||
color="secondary"
|
||||
variant="subtitle2"
|
||||
className="hidden md:block"
|
||||
>
|
||||
Created by{' '}
|
||||
{currentProject.creator?.displayName ||
|
||||
currentProject.creator?.email}{' '}
|
||||
{formatDistanceToNowStrict(parseISO(currentProject.createdAt))}{' '}
|
||||
ago
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/${currentWorkspace.slug}/${currentApplication.slug}/settings/general`}
|
||||
href={`/${currentWorkspace.slug}/${currentProject.slug}/settings/general`}
|
||||
passHref
|
||||
>
|
||||
<Button
|
||||
endIcon={<CogIcon className="h-4 w-4" />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
@@ -79,6 +79,7 @@ export function OverviewUsageMetrics() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const remoteAppClient = useRemoteApplicationGQLClient();
|
||||
|
||||
const [metrics, setMetrics] = useState({
|
||||
functions: 0,
|
||||
storage: 0,
|
||||
@@ -98,6 +99,7 @@ export function OverviewUsageMetrics() {
|
||||
|
||||
const { data: remoteAppMetricsData } = useGetRemoteAppMetricsQuery({
|
||||
client: remoteAppClient,
|
||||
skip: !currentApplication,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
useResetPostgresPasswordMutation,
|
||||
useUpdateApplicationMutation,
|
||||
@@ -28,6 +29,7 @@ export interface ResetDatabasePasswordFormValues {
|
||||
|
||||
export default function ResetDatabasePasswordSettings() {
|
||||
const [updateApplication] = useUpdateApplicationMutation();
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
const form = useForm<ResetDatabasePasswordFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
@@ -44,11 +46,10 @@ export default function ResetDatabasePasswordSettings() {
|
||||
setValue,
|
||||
getValues,
|
||||
register,
|
||||
formState: { errors },
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = form;
|
||||
|
||||
const [resetPostgresPasswordMutation, { loading }] =
|
||||
useResetPostgresPasswordMutation();
|
||||
const [resetPostgresPasswordMutation] = useResetPostgresPasswordMutation();
|
||||
const user = useUserData();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
@@ -99,12 +100,16 @@ export default function ResetDatabasePasswordSettings() {
|
||||
title="Reset Password"
|
||||
description="This password is used for accessing your database."
|
||||
submitButtonText="Reset"
|
||||
rootClassName="border-[#F87171]"
|
||||
primaryActionButtonProps={{
|
||||
variant: 'contained',
|
||||
color: 'error',
|
||||
disabled: Boolean(errors?.databasePassword),
|
||||
loading,
|
||||
slotProps={{
|
||||
root: {
|
||||
sx: { borderColor: (theme) => theme.palette.error.main },
|
||||
},
|
||||
submitButton: {
|
||||
variant: 'contained',
|
||||
color: 'error',
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row pb-4"
|
||||
>
|
||||
|
||||
@@ -179,6 +179,15 @@ export default function SettingsSidebar({
|
||||
>
|
||||
Environment Variables
|
||||
</SettingsNavLink>
|
||||
|
||||
<SettingsNavLink
|
||||
href="/secrets"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
className="hidden"
|
||||
>
|
||||
Secrets
|
||||
</SettingsNavLink>
|
||||
</List>
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
@@ -1,68 +1,61 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useEffect } from 'react';
|
||||
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';
|
||||
|
||||
export interface AllowedEmailSettingsFormValues {
|
||||
/**
|
||||
* Determines whether or not the allowed email settings are enabled.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Set of email that are allowed to be used for project's users authentication.
|
||||
*/
|
||||
authAccessControlAllowedEmails: string;
|
||||
/**
|
||||
* Set of email domains that are allowed to be used for project's users authentication.
|
||||
* @example 'nhost.io'
|
||||
*/
|
||||
authAccessControlAllowedEmailDomains: string;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
allowedEmails: Yup.string().label('Allowed Emails'),
|
||||
allowedEmailDomains: Yup.string().label('Allowed Email Domains'),
|
||||
});
|
||||
|
||||
export type AllowedEmailSettingsFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function AllowedEmailDomainsSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
|
||||
const { data, loading, error } = useGetAppQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { email, emailDomains } = data?.config?.auth?.user || {};
|
||||
|
||||
const form = useForm<AllowedEmailSettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled:
|
||||
Boolean(data?.app?.authAccessControlAllowedEmails) ||
|
||||
Boolean(data?.app?.authAccessControlAllowedEmailDomains),
|
||||
authAccessControlAllowedEmails: data?.app?.authAccessControlAllowedEmails,
|
||||
authAccessControlAllowedEmailDomains:
|
||||
data?.app?.authAccessControlAllowedEmailDomains,
|
||||
enabled: email?.allowed?.length > 0 || emailDomains?.allowed?.length > 0,
|
||||
allowedEmails: email?.allowed?.join(', ') || '',
|
||||
allowedEmailDomains: emailDomains?.allowed?.join(', ') || '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState, setValue, watch } = form;
|
||||
const { register, formState, watch } = form;
|
||||
const enabled = watch('enabled');
|
||||
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!data.app?.authAccessControlAllowedEmails &&
|
||||
!data.app?.authAccessControlAllowedEmailDomains
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('enabled', true, { shouldDirty: false });
|
||||
}, [data.app, setValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -80,29 +73,51 @@ export default function AllowedEmailDomainsSettings() {
|
||||
const handleAllowedEmailDomainsChange = async (
|
||||
values: AllowedEmailSettingsFormValues,
|
||||
) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
authAccessControlAllowedEmails: values.enabled
|
||||
? values.authAccessControlAllowedEmails
|
||||
: '',
|
||||
authAccessControlAllowedEmailDomains: values.enabled
|
||||
? values.authAccessControlAllowedEmailDomains
|
||||
: '',
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
email: {
|
||||
blocked: email.blocked,
|
||||
allowed:
|
||||
values.enabled && values.allowedEmails
|
||||
? values.allowedEmails
|
||||
.split(',')
|
||||
.map((allowedEmail) => allowedEmail.trim())
|
||||
: [],
|
||||
},
|
||||
emailDomains: {
|
||||
blocked: emailDomains.blocked,
|
||||
allowed:
|
||||
values.enabled && values.allowedEmailDomains
|
||||
? values.allowedEmailDomains
|
||||
.split(',')
|
||||
.map((allowedEmailDomain) => allowedEmailDomain.trim())
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Allowed email settings are being updated...`,
|
||||
success: `Allowed email settings have been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's allowed email settings.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Allowed email settings are being updated...`,
|
||||
success: `Allowed email settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's allowed email settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} catch {
|
||||
// Note: The toast will handle the error
|
||||
}
|
||||
|
||||
form.reset(values);
|
||||
};
|
||||
@@ -115,15 +130,12 @@ export default function AllowedEmailDomainsSettings() {
|
||||
description="Allow specific email addresses and domains to sign up."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isValid || !isDirty,
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#allowed-emails-and-domains"
|
||||
enabled={enabled}
|
||||
onEnabledChange={(switchEnabled) =>
|
||||
setValue('enabled', switchEnabled, { shouldDirty: true })
|
||||
}
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
|
||||
@@ -131,9 +143,9 @@ export default function AllowedEmailDomainsSettings() {
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
{...register('authAccessControlAllowedEmails')}
|
||||
name="authAccessControlAllowedEmails"
|
||||
id="authAccessControlAllowedEmails"
|
||||
{...register('allowedEmails')}
|
||||
name="allowedEmails"
|
||||
id="allowedEmails"
|
||||
placeholder="These emails (separated by comma, e.g, david@ikea.com, lisa@mycompany.com)"
|
||||
className="col-span-2"
|
||||
label="Allowed Emails (comma separated)"
|
||||
@@ -141,9 +153,9 @@ export default function AllowedEmailDomainsSettings() {
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
<Input
|
||||
{...register('authAccessControlAllowedEmailDomains')}
|
||||
name="authAccessControlAllowedEmailDomains"
|
||||
id="authAccessControlAllowedEmailDomains"
|
||||
{...register('allowedEmailDomains')}
|
||||
name="allowedEmailDomains"
|
||||
id="allowedEmailDomains"
|
||||
label="Allowed Email Domains (comma sepated list)"
|
||||
placeholder="These email domains (separated by comma, e.g, ikea.com, mycompany.com)"
|
||||
className="col-span-2"
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface AllowedRedirectURLFormValues {
|
||||
/**
|
||||
* Set of URLs that are allowed to be redirected to after project's users authentication.
|
||||
*/
|
||||
authAccessControlAllowedRedirectUrls: string;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
allowedUrls: Yup.string().label('Allowed Redirect URLs'),
|
||||
});
|
||||
|
||||
export type AllowedRedirectURLFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function AllowedRedirectURLsSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
|
||||
const { data, loading, error } = useGetAppQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { allowedUrls } = data?.config?.auth?.redirections || {};
|
||||
|
||||
const form = useForm<AllowedRedirectURLFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
authAccessControlAllowedRedirectUrls:
|
||||
data?.app?.authAccessControlAllowedRedirectUrls,
|
||||
allowedUrls: allowedUrls?.join(', ') || '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -52,26 +65,38 @@ export default function AllowedRedirectURLsSettings() {
|
||||
const handleAllowedRedirectURLsChange = async (
|
||||
values: AllowedRedirectURLFormValues,
|
||||
) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
redirections: {
|
||||
allowedUrls: values.allowedUrls
|
||||
? values.allowedUrls.split(',').map((url) => url.trim())
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Allowed redirect URL settings are being updated...`,
|
||||
success: `Allowed redirect URL settings have been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's allowed redirect URL settings.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Allowed redirect URL settings are being updated...`,
|
||||
success: `Allowed redirect URL settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's allowed redirect URL settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -80,17 +105,19 @@ export default function AllowedRedirectURLsSettings() {
|
||||
<SettingsContainer
|
||||
title="Allowed Redirect URLs"
|
||||
description="Allowed URLs where users can be redirected to after authentication. Separate multiple redirect URLs with comma."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#allowed-redirect-urls"
|
||||
className="grid grid-flow-row px-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('authAccessControlAllowedRedirectUrls')}
|
||||
name="authAccessControlAllowedRedirectUrls"
|
||||
id="authAccessControlAllowedRedirectUrls"
|
||||
{...register('allowedUrls')}
|
||||
name="allowedUrls"
|
||||
id="allowedUrls"
|
||||
placeholder="http://localhost:3000, http://localhost:4000"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
|
||||
@@ -1,67 +1,58 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useEffect } from 'react';
|
||||
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';
|
||||
|
||||
export interface BlockedEmailFormValues {
|
||||
/**
|
||||
* Determines whether or not the blocked email settings are enabled.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Set of emails that are blocked from registering to the user's project.
|
||||
*/
|
||||
authAccessControlBlockedEmails: string;
|
||||
/**
|
||||
* Set of email domains that are blocked from registering to the user's project.
|
||||
*/
|
||||
authAccessControlBlockedEmailDomains: string;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
blockedEmails: Yup.string().label('Blocked Emails'),
|
||||
blockedEmailDomains: Yup.string().label('Blocked Email Domains'),
|
||||
});
|
||||
|
||||
export type BlockedEmailFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function BlockedEmailSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
|
||||
const { data, loading, error } = useGetAppQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { email, emailDomains } = data?.config?.auth?.user || {};
|
||||
|
||||
const form = useForm<BlockedEmailFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled:
|
||||
Boolean(data?.app?.authAccessControlBlockedEmails) ||
|
||||
Boolean(data?.app?.authAccessControlBlockedEmailDomains),
|
||||
authAccessControlBlockedEmails: data?.app?.authAccessControlBlockedEmails,
|
||||
authAccessControlBlockedEmailDomains:
|
||||
data?.app?.authAccessControlBlockedEmailDomains,
|
||||
enabled: email?.blocked?.length > 0 || emailDomains?.blocked?.length > 0,
|
||||
blockedEmails: email?.blocked?.join(', ') || '',
|
||||
blockedEmailDomains: emailDomains?.blocked?.join(', ') || '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState, setValue, watch } = form;
|
||||
const { register, formState, watch } = form;
|
||||
const enabled = watch('enabled');
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!data.app?.authAccessControlBlockedEmails &&
|
||||
!data.app?.authAccessControlBlockedEmailDomains
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('enabled', true, { shouldDirty: false });
|
||||
}, [data.app, setValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -79,31 +70,63 @@ export default function BlockedEmailSettings() {
|
||||
const handleAllowedEmailDomainsChange = async (
|
||||
values: BlockedEmailFormValues,
|
||||
) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
authAccessControlBlockedEmails: values.enabled
|
||||
? values.authAccessControlBlockedEmails
|
||||
: '',
|
||||
authAccessControlBlockedEmailDomains: values.enabled
|
||||
? values.authAccessControlBlockedEmailDomains
|
||||
: '',
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
email: {
|
||||
allowed: email.allowed,
|
||||
blocked:
|
||||
values.enabled && values.blockedEmails
|
||||
? [
|
||||
...new Set(
|
||||
values.blockedEmails
|
||||
.split(',')
|
||||
.map((blockedEmail) => blockedEmail.trim()),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
},
|
||||
emailDomains: {
|
||||
allowed: emailDomains.allowed,
|
||||
blocked:
|
||||
values.enabled && values.blockedEmailDomains
|
||||
? [
|
||||
...new Set(
|
||||
values.blockedEmailDomains
|
||||
.split(',')
|
||||
.map((blockedEmailDomain) =>
|
||||
blockedEmailDomain.trim(),
|
||||
),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Blocked email and domain settings are being updated...`,
|
||||
success: `Blocked email and domain settings have been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's blocked email and domain settings.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Blocked email and domain settings are being updated...`,
|
||||
success: `Blocked email and domain settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's blocked email and domain settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -114,15 +137,12 @@ export default function BlockedEmailSettings() {
|
||||
description="Block specific email addresses and domains to sign up."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isValid || !isDirty,
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#blocked-emails-and-domains"
|
||||
enabled={enabled}
|
||||
onEnabledChange={(switchEnabled) =>
|
||||
setValue('enabled', switchEnabled, { shouldDirty: true })
|
||||
}
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
|
||||
@@ -130,9 +150,9 @@ export default function BlockedEmailSettings() {
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
{...register('authAccessControlBlockedEmails')}
|
||||
name="authAccessControlBlockedEmails"
|
||||
id="authAccessControlBlockedEmails"
|
||||
{...register('blockedEmails')}
|
||||
name="blockedEmails"
|
||||
id="blockedEmails"
|
||||
placeholder="These emails (separated by comma, e.g, david@ikea.com, lisa@mycompany.com)"
|
||||
className="col-span-2"
|
||||
label="Blocked Emails (comma separated)"
|
||||
@@ -140,9 +160,9 @@ export default function BlockedEmailSettings() {
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
<Input
|
||||
{...register('authAccessControlBlockedEmailDomains')}
|
||||
name="authAccessControlBlockedEmailDomains"
|
||||
id="authAccessControlBlockedEmailDomains"
|
||||
{...register('blockedEmailDomains')}
|
||||
name="blockedEmailDomains"
|
||||
id="blockedEmailDomains"
|
||||
label="Blocked Email Domains (comma sepated list)"
|
||||
placeholder="These email domains (separated by comma, e.g, ikea.com, mycompany.com)"
|
||||
className="col-span-2"
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface ClientURLFormValues {
|
||||
/**
|
||||
* The URL of the frontend app of where users are redirected after authenticating.
|
||||
*/
|
||||
authClientUrl: string;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
clientUrl: Yup.string().label('Client URL'),
|
||||
});
|
||||
|
||||
export type ClientURLFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function ClientURLSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation({ refetchQueries: ['GetApp'] });
|
||||
|
||||
const { data, loading, error } = useGetAppQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
fetchPolicy: 'cache-first',
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { clientUrl, allowedUrls } = data?.config?.auth?.redirections || {};
|
||||
|
||||
const form = useForm<ClientURLFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
authClientUrl: data?.app?.authClientUrl,
|
||||
clientUrl,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -50,26 +61,37 @@ export default function ClientURLSettings() {
|
||||
const { register, formState } = form;
|
||||
|
||||
const handleClientURLChange = async (values: ClientURLFormValues) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
redirections: {
|
||||
...values,
|
||||
allowedUrls,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Client URL is being updated...`,
|
||||
success: `Client URL has been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's Client URL.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Client URL is being updated...`,
|
||||
success: `Client URL has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's Client URL.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -78,22 +100,26 @@ export default function ClientURLSettings() {
|
||||
<SettingsContainer
|
||||
title="Client URL"
|
||||
description="This should be the URL of your frontend app where users are redirected after authenticating."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#client-url"
|
||||
className="grid grid-flow-row lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('authClientUrl')}
|
||||
name="authClientUrl"
|
||||
id="authClientUrl"
|
||||
{...register('clientUrl')}
|
||||
name="clientUrl"
|
||||
id="clientUrl"
|
||||
placeholder="http://localhost:3000"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
aria-label="Client URL"
|
||||
error={!!formState.errors?.clientUrl}
|
||||
helperText={formState.errors?.clientUrl?.message}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
|
||||
@@ -1,46 +1,44 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetAuthSettingsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface DisableNewUsersFormValues {
|
||||
/**
|
||||
* Disable new users from signing up to this project
|
||||
*/
|
||||
authDisableNewUsers: boolean;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
disabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
export type DisableNewUsersFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function DisableNewUsersSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthSettingsQuery({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
},
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const form = useForm<DisableNewUsersFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
authDisableNewUsers: data?.app?.authDisableNewUsers,
|
||||
disabled: !data?.config?.auth?.signUp?.enabled,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(() => ({
|
||||
authDisableNewUsers: data?.app?.authDisableNewUsers,
|
||||
}));
|
||||
}, [data?.app?.authDisableNewUsers, form, form.reset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -55,32 +53,41 @@ export default function DisableNewUsersSettings() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, watch } = form;
|
||||
const authDisableNewUsers = watch('authDisableNewUsers');
|
||||
const { formState } = form;
|
||||
|
||||
const handleDisableNewUsersChange = async (
|
||||
values: DisableNewUsersFormValues,
|
||||
) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
signUp: {
|
||||
enabled: !values.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Disabling new user sign ups...`,
|
||||
success: `New user sign ups have been disabled successfully.`,
|
||||
error: `An error occurred while trying to disable new user sign ups.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Disabling new user sign ups...`,
|
||||
success: `New user sign ups have been disabled successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to disable new user sign ups.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -88,14 +95,15 @@ export default function DisableNewUsersSettings() {
|
||||
<Form onSubmit={handleDisableNewUsersChange}>
|
||||
<SettingsContainer
|
||||
title="Disable New Users"
|
||||
description="If set, newly registered users are disabled and won’t be able to sign in."
|
||||
description="If set, newly registered users are disabled and won't be able to sign in."
|
||||
docsLink="https://docs.nhost.io/authentication#disable-new-users"
|
||||
switchId="authDisableNewUsers"
|
||||
switchId="disabled"
|
||||
showSwitch
|
||||
enabled={authDisableNewUsers}
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
@@ -1,65 +1,63 @@
|
||||
import ControlledSelect from '@/components/common/ControlledSelect';
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
useGetGravatarSettingsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Option from '@/ui/v2/Option';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import {
|
||||
AUTH_GRAVATAR_DEFAULT,
|
||||
AUTH_GRAVATAR_RATING,
|
||||
getToastStyleProps,
|
||||
} from '@/utils/settings/settingsConstants';
|
||||
import { useEffect } from 'react';
|
||||
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';
|
||||
|
||||
export interface GravatarFormValues {
|
||||
/**
|
||||
* Gravatar image to use as default.
|
||||
*/
|
||||
authGravatarDefault: string;
|
||||
/**
|
||||
* Gravatar image rating.
|
||||
*/
|
||||
authGravatarRating: string;
|
||||
/**
|
||||
* Enable Gravatar for this project
|
||||
*/
|
||||
authGravatarEnabled: boolean;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
default: Yup.string().label('Default Gravatar'),
|
||||
rating: Yup.string().label('Gravatar Rating'),
|
||||
});
|
||||
|
||||
export type GravatarFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function GravatarSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
|
||||
const { data, loading, error } = useGetGravatarSettingsQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const {
|
||||
default: defaultGravatar,
|
||||
rating,
|
||||
enabled,
|
||||
} = data?.config?.auth?.user?.gravatar || {};
|
||||
|
||||
const form = useForm<GravatarFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
authGravatarDefault: data?.app?.authGravatarDefault || '',
|
||||
authGravatarRating: data?.app?.authGravatarRating || '',
|
||||
authGravatarEnabled: data?.app?.authGravatarEnabled || false,
|
||||
default: defaultGravatar || '',
|
||||
rating: rating || '',
|
||||
enabled: enabled || false,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(() => ({
|
||||
authGravatarDefault: data?.app?.authGravatarDefault || '',
|
||||
authGravatarRating: data?.app?.authGravatarRating || '',
|
||||
authGravatarEnabled: data?.app?.authGravatarEnabled || false,
|
||||
}));
|
||||
}, [data?.app, form, form.reset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -75,29 +73,39 @@ export default function GravatarSettings() {
|
||||
}
|
||||
|
||||
const { register, formState, watch } = form;
|
||||
const authGravatarEnabled = watch('authGravatarEnabled');
|
||||
const gravatarEnabled = watch('enabled') ?? false;
|
||||
|
||||
const handleGravatarSettingsChange = async (values: GravatarFormValues) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
gravatar: values,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Gravatar settings are being updated...`,
|
||||
success: `Gravatar settings have been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's Gravatar settings.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Gravatar settings are being updated...`,
|
||||
success: `Gravatar settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's Gravatar settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -106,22 +114,23 @@ export default function GravatarSettings() {
|
||||
<SettingsContainer
|
||||
title="Gravatar"
|
||||
description="Use Gravatars for avatar URLs for users."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#gravatar"
|
||||
switchId="authGravatarEnabled"
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
enabled={authGravatarEnabled}
|
||||
className={twMerge(
|
||||
'grid grid-flow-col grid-cols-5 grid-rows-2 gap-y-6',
|
||||
!authGravatarEnabled && 'hidden',
|
||||
!gravatarEnabled && 'hidden',
|
||||
)}
|
||||
>
|
||||
<ControlledSelect
|
||||
{...register('authGravatarDefault')}
|
||||
id="authGravatarDefault"
|
||||
{...register('default')}
|
||||
id="default"
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="Default Gravatar"
|
||||
hideEmptyHelperText
|
||||
@@ -135,8 +144,8 @@ export default function GravatarSettings() {
|
||||
))}
|
||||
</ControlledSelect>
|
||||
<ControlledSelect
|
||||
{...register('authGravatarRating')}
|
||||
id="authGravatarRating"
|
||||
{...register('rating')}
|
||||
id="rating"
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="Gravatar Rating"
|
||||
hideEmptyHelperText
|
||||
|
||||
@@ -1,54 +1,52 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
useGetAuthSettingsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useEffect } from 'react';
|
||||
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';
|
||||
|
||||
export interface MFASettingsFormValues {
|
||||
/**
|
||||
* One Time Password issuer
|
||||
*/
|
||||
authMfaTotpIssuer: string;
|
||||
/**
|
||||
* Enable Multi Factor Authentication for this project
|
||||
*/
|
||||
authMfaEnabled: boolean;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
issuer: Yup.string().label('OTP Issuer').nullable().required(),
|
||||
});
|
||||
|
||||
export type MFASettingsFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function MFASettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
|
||||
const { data, loading, error } = useGetAuthSettingsQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { enabled, issuer } = data?.config?.auth?.totp || {};
|
||||
|
||||
const form = useForm<MFASettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
authMfaTotpIssuer: data?.app?.authMfaTotpIssuer,
|
||||
authMfaEnabled: data?.app?.authMfaEnabled,
|
||||
issuer,
|
||||
enabled,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(() => ({
|
||||
authMfaTotpIssuer: data?.app?.authMfaTotpIssuer,
|
||||
authMfaEnabled: data?.app?.authMfaEnabled,
|
||||
}));
|
||||
}, [data?.app, form, form.reset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -64,29 +62,37 @@ export default function MFASettings() {
|
||||
}
|
||||
|
||||
const { register, formState, watch } = form;
|
||||
const authMfaEnabled = watch('authMfaEnabled');
|
||||
const authMfaEnabled = watch('enabled');
|
||||
|
||||
const handleMFASettingsChange = async (values: MFASettingsFormValues) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
totp: values,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Multi-factor authentication settings are being updated...`,
|
||||
success: `Multi-factor authentication settings have been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's multi-factor authentication settings.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Multi-factor authentication settings are being updated...`,
|
||||
success: `Multi-factor authentication settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's multi-factor authentication settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -95,13 +101,14 @@ export default function MFASettings() {
|
||||
<SettingsContainer
|
||||
title="Multi-Factor Authentication"
|
||||
description="Enable users to use MFA to sign in"
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#multi-factor-authentication"
|
||||
switchId="authMfaEnabled"
|
||||
enabled={authMfaEnabled}
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
'grid grid-flow-row lg:grid-cols-5',
|
||||
@@ -109,14 +116,16 @@ export default function MFASettings() {
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
{...register('authMfaTotpIssuer')}
|
||||
name="authMfaTotpIssuer"
|
||||
id="authMfaTotpIssuer"
|
||||
{...register('issuer')}
|
||||
name="issuer"
|
||||
id="issuer"
|
||||
label="OTP Issuer"
|
||||
placeholder="Name of the One Time Password (OTP) issuer"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
error={!!formState.errors?.issuer}
|
||||
helperText={formState.errors?.issuer?.message}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
|
||||
@@ -8,25 +8,6 @@ import { useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface BaseEnvironmentVariableFormValues {
|
||||
/**
|
||||
* Identifier of the environment variable.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The name of the role.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Development environment variable value.
|
||||
*/
|
||||
devValue: string;
|
||||
/**
|
||||
* Production environment variable value.
|
||||
*/
|
||||
prodValue: string;
|
||||
}
|
||||
|
||||
export interface BaseEnvironmentVariableFormProps extends DialogFormProps {
|
||||
/**
|
||||
* Determines the mode of the form.
|
||||
@@ -51,8 +32,11 @@ export interface BaseEnvironmentVariableFormProps extends DialogFormProps {
|
||||
}
|
||||
|
||||
export const baseEnvironmentVariableFormValidationSchema = Yup.object({
|
||||
id: Yup.string().label('ID'),
|
||||
name: Yup.string()
|
||||
.required('This field is required.')
|
||||
.label('Name')
|
||||
.nullable()
|
||||
.required()
|
||||
.test(
|
||||
'isEnvVarPermitted',
|
||||
'This is a reserved name.',
|
||||
@@ -78,13 +62,18 @@ export const baseEnvironmentVariableFormValidationSchema = Yup.object({
|
||||
(prefix) => !value.startsWith(prefix),
|
||||
),
|
||||
)
|
||||
.test('isEnvVarValid', `The name must start with a letter.`, (value) =>
|
||||
/^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
|
||||
.test(
|
||||
'isEnvVarValid',
|
||||
'A name must start with a letter and can only contain letters, numbers, and underscores.',
|
||||
(value) => /^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
|
||||
),
|
||||
devValue: Yup.string().required('This field is required.'),
|
||||
prodValue: Yup.string().required('This field is required.'),
|
||||
value: Yup.string().label('Value').nullable().required(),
|
||||
});
|
||||
|
||||
export type BaseEnvironmentVariableFormValues = Yup.InferType<
|
||||
typeof baseEnvironmentVariableFormValidationSchema
|
||||
>;
|
||||
|
||||
export default function BaseEnvironmentVariableForm({
|
||||
mode = 'edit',
|
||||
onSubmit,
|
||||
@@ -117,21 +106,7 @@ export default function BaseEnvironmentVariableForm({
|
||||
|
||||
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
|
||||
<Input
|
||||
{...register('name', {
|
||||
onChange: (event) => {
|
||||
if (
|
||||
event.target.value &&
|
||||
!/^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/g.test(event.target.value)
|
||||
) {
|
||||
// we need to prevent invalid characters from being entered
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.target.value = event.target.value.replace(
|
||||
/[^a-zA-Z0-9_]/g,
|
||||
'',
|
||||
);
|
||||
}
|
||||
},
|
||||
})}
|
||||
{...register('name')}
|
||||
id="name"
|
||||
label="Name"
|
||||
placeholder="EXAMPLE_NAME"
|
||||
@@ -145,30 +120,18 @@ export default function BaseEnvironmentVariableForm({
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('prodValue')}
|
||||
id="prodValue"
|
||||
label="Production Value"
|
||||
{...register('value')}
|
||||
id="value"
|
||||
label="Value"
|
||||
placeholder="Enter value"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.prodValue}
|
||||
helperText={errors?.prodValue?.message}
|
||||
error={!!errors.value}
|
||||
helperText={errors?.value?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus={mode === 'edit'}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('devValue')}
|
||||
id="devValue"
|
||||
label="Development Value"
|
||||
placeholder="Enter value"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.devValue}
|
||||
helperText={errors?.devValue?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{submitButtonText}
|
||||
|
||||
@@ -7,10 +7,12 @@ import BaseEnvironmentVariableForm, {
|
||||
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
GetEnvironmentVariablesDocument,
|
||||
useGetEnvironmentVariablesQuery,
|
||||
useInsertEnvironmentVariablesMutation,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -31,8 +33,7 @@ export default function CreateEnvironmentVariableForm({
|
||||
const form = useForm<BaseEnvironmentVariableFormValues>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
devValue: '',
|
||||
prodValue: '',
|
||||
value: '',
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
|
||||
@@ -41,14 +42,14 @@ export default function CreateEnvironmentVariableForm({
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
const { data, loading, error } = useGetEnvironmentVariablesQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const [insertEnvironmentVariables] = useInsertEnvironmentVariablesMutation({
|
||||
refetchQueries: ['getEnvironmentVariables'],
|
||||
const availableEnvironmentVariables = data?.config?.global?.environment || [];
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetEnvironmentVariablesDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -68,13 +69,10 @@ export default function CreateEnvironmentVariableForm({
|
||||
|
||||
async function handleSubmit({
|
||||
name,
|
||||
prodValue,
|
||||
devValue,
|
||||
value,
|
||||
}: BaseEnvironmentVariableFormValues) {
|
||||
if (
|
||||
data?.environmentVariables?.some(
|
||||
(environmentVariable) => environmentVariable.name === name,
|
||||
)
|
||||
availableEnvironmentVariables?.some((variable) => variable.name === name)
|
||||
) {
|
||||
setError('name', {
|
||||
message: 'This environment variable already exists.',
|
||||
@@ -83,20 +81,34 @@ export default function CreateEnvironmentVariableForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const insertEnvironmentVariablePromise = insertEnvironmentVariables({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
environmentVariables: [
|
||||
{ appId: currentApplication.id, name, prodValue, devValue },
|
||||
],
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
global: {
|
||||
environment: [
|
||||
...(availableEnvironmentVariables?.map((variable) => ({
|
||||
name: variable.name,
|
||||
value: variable.value,
|
||||
})) || []),
|
||||
{
|
||||
name,
|
||||
value,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
insertEnvironmentVariablePromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Creating environment variable...',
|
||||
success: 'Environment variable has been created successfully.',
|
||||
error: 'An error occurred while creating the environment variable.',
|
||||
error: getServerError(
|
||||
'An error occurred while creating the environment variable.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
@@ -8,10 +8,12 @@ import BaseEnvironmentVariableForm, {
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { EnvironmentVariable } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
GetEnvironmentVariablesDocument,
|
||||
useGetEnvironmentVariablesQuery,
|
||||
useUpdateEnvironmentVariableMutation,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -38,8 +40,7 @@ export default function EditEnvironmentVariableForm({
|
||||
defaultValues: {
|
||||
id: originalEnvironmentVariable.id || '',
|
||||
name: originalEnvironmentVariable.name || '',
|
||||
devValue: originalEnvironmentVariable.devValue || '',
|
||||
prodValue: originalEnvironmentVariable.prodValue || '',
|
||||
value: originalEnvironmentVariable.value || '',
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
|
||||
@@ -48,14 +49,14 @@ export default function EditEnvironmentVariableForm({
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
const { data, loading, error } = useGetEnvironmentVariablesQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const [updateEnvironmentVariable] = useUpdateEnvironmentVariableMutation({
|
||||
refetchQueries: ['getEnvironmentVariables'],
|
||||
const availableEnvironmentVariables = data?.config?.global?.environment || [];
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetEnvironmentVariablesDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -76,14 +77,13 @@ export default function EditEnvironmentVariableForm({
|
||||
async function handleSubmit({
|
||||
id,
|
||||
name,
|
||||
prodValue,
|
||||
devValue,
|
||||
value,
|
||||
}: BaseEnvironmentVariableFormValues) {
|
||||
if (
|
||||
data?.environmentVariables?.some(
|
||||
(environmentVariable) =>
|
||||
environmentVariable.name === name &&
|
||||
environmentVariable.name !== originalEnvironmentVariable.name,
|
||||
availableEnvironmentVariables.some(
|
||||
(variable) =>
|
||||
variable.name === name &&
|
||||
variable.name !== originalEnvironmentVariable.name,
|
||||
)
|
||||
) {
|
||||
setError('name', {
|
||||
@@ -93,22 +93,36 @@ export default function EditEnvironmentVariableForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const updateEnvironmentVariablePromise = updateEnvironmentVariable({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id,
|
||||
environmentVariable: {
|
||||
prodValue,
|
||||
devValue,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
global: {
|
||||
environment: [
|
||||
...availableEnvironmentVariables
|
||||
.filter((variable) => variable.id !== id)
|
||||
.map((variable) => ({
|
||||
name: variable.name,
|
||||
value: variable.value,
|
||||
})),
|
||||
{
|
||||
name,
|
||||
value,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateEnvironmentVariablePromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating environment variable...',
|
||||
success: 'Environment variable has been updated successfully.',
|
||||
error: 'An error occurred while updating the environment variable.',
|
||||
error: getServerError(
|
||||
'An error occurred while updating the environment variable.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@ import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
refetchGetAppInjectedVariablesQuery,
|
||||
useUpdateApplicationMutation,
|
||||
GetEnvironmentVariablesDocument,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
@@ -65,10 +65,8 @@ export default function EditJwtSecretForm({
|
||||
location,
|
||||
}: EditJwtSecretFormProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApplication] = useUpdateApplicationMutation({
|
||||
refetchQueries: [
|
||||
refetchGetAppInjectedVariablesQuery({ id: currentApplication?.id }),
|
||||
],
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetEnvironmentVariablesDocument],
|
||||
});
|
||||
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
@@ -90,26 +88,38 @@ export default function EditJwtSecretForm({
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
async function handleSubmit(values: EditJwtSecretFormValues) {
|
||||
const updateAppPromise = updateApplication({
|
||||
const parsedJwtSecret = JSON.parse(values.jwtSecret);
|
||||
const isArray = Array.isArray(parsedJwtSecret);
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentApplication?.id,
|
||||
app: {
|
||||
hasuraGraphqlJwtSecret: values.jwtSecret,
|
||||
config: {
|
||||
hasura: {
|
||||
jwtSecrets: isArray ? parsedJwtSecret : [parsedJwtSecret],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
{
|
||||
loading: 'Updating JWT secret...',
|
||||
success: 'JWT secret has been updated successfully.',
|
||||
error: 'An error occurred while updating the JWT secret.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating JWT secret...',
|
||||
success: 'JWT secret has been updated successfully.',
|
||||
error: (arg: Error) =>
|
||||
arg?.message
|
||||
? `Error: ${arg.message}`
|
||||
: 'An error occurred while updating the JWT secret.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onSubmit?.();
|
||||
onSubmit?.();
|
||||
} catch {
|
||||
// Note: error is handled above
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useDialog } from '@/components/common/DialogProvider';
|
||||
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
|
||||
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { EnvironmentVariable } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
@@ -15,12 +16,13 @@ import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useDeleteEnvironmentVariableMutation,
|
||||
GetEnvironmentVariablesDocument,
|
||||
useGetEnvironmentVariablesQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
|
||||
import { Fragment } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -34,15 +36,29 @@ export interface EnvironmentVariableSettingsFormValues {
|
||||
|
||||
export default function EnvironmentVariableSettings() {
|
||||
const { openDialog, openAlertDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data, loading, error } = useGetEnvironmentVariablesQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const [deleteEnvironmentVariable] = useDeleteEnvironmentVariableMutation({
|
||||
refetchQueries: ['getEnvironmentVariables'],
|
||||
const availableEnvironmentVariables = [
|
||||
...(data?.config?.global?.environment || []),
|
||||
].sort((a, b) => {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name > b.name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetEnvironmentVariablesDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -59,21 +75,37 @@ export default function EnvironmentVariableSettings() {
|
||||
}
|
||||
|
||||
async function handleDeleteVariable({ id }: EnvironmentVariable) {
|
||||
const deleteEnvironmentVariablePromise = deleteEnvironmentVariable({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
global: {
|
||||
environment: availableEnvironmentVariables
|
||||
.filter((variable) => variable.id !== id)
|
||||
.map((variable) => ({
|
||||
name: variable.name,
|
||||
value: variable.value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
deleteEnvironmentVariablePromise,
|
||||
{
|
||||
loading: 'Deleting environment variable...',
|
||||
success: 'Environment variable has been deleted successfully.',
|
||||
error: 'An error occurred while deleting the environment variable.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Deleting environment variable...',
|
||||
success: 'Environment variable has been deleted successfully.',
|
||||
error: getServerError(
|
||||
'An error occurred while deleting the environment variable.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenCreator() {
|
||||
@@ -120,12 +152,6 @@ export default function EnvironmentVariableSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
const availableEnvironmentVariables =
|
||||
[...data.environmentVariables].sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
) || [];
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Project Environment Variables"
|
||||
@@ -141,95 +167,79 @@ export default function EnvironmentVariableSettings() {
|
||||
>
|
||||
<Box className="grid grid-cols-2 gap-2 border-b-1 px-4 py-3 lg:grid-cols-3">
|
||||
<Text className="font-medium">Variable Name</Text>
|
||||
<Text className="font-medium lg:col-span-2">Updated</Text>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
{availableEnvironmentVariables.length > 0 && (
|
||||
<List>
|
||||
{availableEnvironmentVariables.map((environmentVariable, index) => {
|
||||
const timestamp = formatDistanceToNowStrict(
|
||||
parseISO(environmentVariable.updatedAt),
|
||||
{ addSuffix: true, roundingMethod: 'floor' },
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={environmentVariable.id}>
|
||||
<ListItem.Root
|
||||
className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
{availableEnvironmentVariables.map((environmentVariable, index) => (
|
||||
<Fragment key={environmentVariable.id}>
|
||||
<ListItem.Root
|
||||
className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3"
|
||||
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}
|
||||
>
|
||||
<IconButton variant="borderless" color="secondary">
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleOpenEditor(environmentVariable)}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() =>
|
||||
handleOpenEditor(environmentVariable)
|
||||
}
|
||||
>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
|
||||
<Divider component="li" />
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
onClick={() =>
|
||||
handleConfirmDelete(environmentVariable)
|
||||
}
|
||||
>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text className="truncate">
|
||||
{environmentVariable.name}
|
||||
</ListItem.Text>
|
||||
<Dropdown.Item
|
||||
onClick={() =>
|
||||
handleConfirmDelete(environmentVariable)
|
||||
}
|
||||
>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text className="truncate">
|
||||
{environmentVariable.name}
|
||||
</ListItem.Text>
|
||||
</ListItem.Root>
|
||||
|
||||
<Text
|
||||
variant="subtitle1"
|
||||
className="truncate lg:col-span-2"
|
||||
>
|
||||
{timestamp === '0 seconds ago' ||
|
||||
timestamp === 'in 0 seconds'
|
||||
? 'Now'
|
||||
: timestamp}
|
||||
</Text>
|
||||
</ListItem.Root>
|
||||
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availableEnvironmentVariables.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availableEnvironmentVariables.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
@@ -238,6 +248,7 @@ export default function EnvironmentVariableSettings() {
|
||||
variant="borderless"
|
||||
startIcon={<PlusIcon />}
|
||||
onClick={handleOpenCreator}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Create Environment Variable
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useDialog } from '@/components/common/DialogProvider';
|
||||
import InlineCode from '@/components/common/InlineCode';
|
||||
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||
import { useAppClient } from '@/hooks/useAppClient';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
@@ -19,9 +20,10 @@ import generateAppServiceUrl, {
|
||||
defaultLocalBackendSlugs,
|
||||
defaultRemoteBackendSlugs,
|
||||
} from '@/utils/common/generateAppServiceUrl';
|
||||
import { LOCAL_HASURA_URL } from '@/utils/env';
|
||||
import { getHasuraConsoleServiceUrl } from '@/utils/env';
|
||||
import { generateRemoteAppUrl } from '@/utils/helpers';
|
||||
import { useGetAppInjectedVariablesQuery } from '@/utils/__generated__/graphql';
|
||||
import getJwtSecretsWithoutFalsyValues from '@/utils/settings/getJwtSecretsWithoutFalsyValues';
|
||||
import { useGetEnvironmentVariablesQuery } from '@/utils/__generated__/graphql';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
export default function SystemEnvironmentVariableSettings() {
|
||||
@@ -29,10 +31,22 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data, loading, error } = useGetAppInjectedVariablesQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, loading, error } = useGetEnvironmentVariablesQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { jwtSecrets, webhookSecret, adminSecret } = data?.config?.hasura || {};
|
||||
const jwtSecretsWithoutFalsyValues = getJwtSecretsWithoutFalsyValues(
|
||||
jwtSecrets || [],
|
||||
);
|
||||
const stringifiedJwtSecrets =
|
||||
jwtSecretsWithoutFalsyValues.length === 1
|
||||
? JSON.stringify(jwtSecretsWithoutFalsyValues[0], null, 2)
|
||||
: JSON.stringify(jwtSecretsWithoutFalsyValues, null, 2);
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const appClient = useAppClient();
|
||||
@@ -63,10 +77,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
</span>
|
||||
),
|
||||
component: (
|
||||
<EditJwtSecretForm
|
||||
disabled
|
||||
jwtSecret={data?.app?.hasuraGraphqlJwtSecret}
|
||||
/>
|
||||
<EditJwtSecretForm disabled jwtSecret={stringifiedJwtSecrets} />
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -83,9 +94,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
component: (
|
||||
<EditJwtSecretForm jwtSecret={data?.app?.hasuraGraphqlJwtSecret} />
|
||||
),
|
||||
component: <EditJwtSecretForm jwtSecret={stringifiedJwtSecrets} />,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,7 +109,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
key: 'NHOST_HASURA_URL',
|
||||
value:
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev' || !isPlatform
|
||||
? `${LOCAL_HASURA_URL}/console`
|
||||
? `${getHasuraConsoleServiceUrl()}/console`
|
||||
: generateAppServiceUrl(
|
||||
currentApplication?.subdomain,
|
||||
currentApplication?.region.awsName,
|
||||
@@ -137,7 +146,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
<Text className="truncate" color="secondary">
|
||||
{showAdminSecret ? (
|
||||
<InlineCode className="!text-sm font-medium">
|
||||
{currentApplication?.hasuraGraphqlAdminSecret}
|
||||
{adminSecret}
|
||||
</InlineCode>
|
||||
) : (
|
||||
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
|
||||
@@ -170,7 +179,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
<Text className="truncate" color="secondary">
|
||||
{showWebhookSecret ? (
|
||||
<InlineCode className="!text-sm font-medium">
|
||||
{data?.app?.webhookSecret}
|
||||
{webhookSecret}
|
||||
</InlineCode>
|
||||
) : (
|
||||
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
|
||||
@@ -234,6 +243,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
variant="borderless"
|
||||
onClick={showEditJwtSecretModal}
|
||||
size="small"
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Edit JWT Secret
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import InlineCode from '@/components/common/InlineCode';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useEffect } from 'react';
|
||||
@@ -20,6 +22,7 @@ export interface BaseDirectoryFormValues {
|
||||
}
|
||||
|
||||
export default function BaseDirectorySettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const client = useApolloClient();
|
||||
@@ -54,7 +57,9 @@ export default function BaseDirectorySettings() {
|
||||
{
|
||||
loading: `The base directory is being updated...`,
|
||||
success: `The base directory has been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's base directory.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's base directory.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
@@ -84,9 +89,11 @@ export default function BaseDirectorySettings() {
|
||||
<InlineCode className="text-xs">nhost</InlineCode> folder.
|
||||
</>
|
||||
}
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/github-integration#base-directory"
|
||||
className="grid grid-flow-row lg:grid-cols-5"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useEffect } from 'react';
|
||||
@@ -19,6 +21,7 @@ export interface DeploymentBranchFormValues {
|
||||
}
|
||||
|
||||
export default function DeploymentBranchSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const client = useApolloClient();
|
||||
@@ -57,7 +60,9 @@ export default function DeploymentBranchSettings() {
|
||||
{
|
||||
loading: `The deployment branch is being updated...`,
|
||||
success: `The deployment branch has been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's deployment branch.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's deployment branch.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
@@ -79,9 +84,11 @@ export default function DeploymentBranchSettings() {
|
||||
<SettingsContainer
|
||||
title="Deployment Branch"
|
||||
description="All commits pushed to this deployment branch will trigger a deployment. You can switch to a different branch here."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/github-integration#deployment-branch"
|
||||
className="grid grid-flow-row lg:grid-cols-5"
|
||||
|
||||
@@ -26,8 +26,8 @@ export interface BasePermissionVariableFormProps extends DialogFormProps {
|
||||
}
|
||||
|
||||
export const basePermissionVariableValidationSchema = Yup.object({
|
||||
key: Yup.string().required('This field is required.'),
|
||||
value: Yup.string().required('This field is required.'),
|
||||
key: Yup.string().label('Field Name').nullable().required(),
|
||||
value: Yup.string().label('Path').nullable().required(),
|
||||
});
|
||||
|
||||
export type BasePermissionVariableFormValues = Yup.InferType<
|
||||
|
||||
@@ -7,12 +7,12 @@ import BasePermissionVariableForm, {
|
||||
} from '@/components/settings/permissions/BasePermissionVariableForm';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
|
||||
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
|
||||
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetAppCustomClaimsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -32,11 +32,14 @@ export default function CreatePermissionVariableForm({
|
||||
}: CreatePermissionVariableFormProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
const { data, error, loading } = useGetAppCustomClaimsQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, error, loading } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { customClaims: permissionVariables } =
|
||||
data?.config?.auth?.session?.accessToken || {};
|
||||
|
||||
const form = useForm<BasePermissionVariableFormValues>({
|
||||
defaultValues: {
|
||||
key: '',
|
||||
@@ -46,8 +49,8 @@ export default function CreatePermissionVariableForm({
|
||||
resolver: yupResolver(basePermissionVariableValidationSchema),
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getAppCustomClaims'],
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -61,9 +64,8 @@ export default function CreatePermissionVariableForm({
|
||||
}
|
||||
|
||||
const { setError } = form;
|
||||
const availablePermissionVariables = getPermissionVariablesArray(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
);
|
||||
const availablePermissionVariables =
|
||||
getAllPermissionVariables(permissionVariables);
|
||||
|
||||
async function handleSubmit({
|
||||
key,
|
||||
@@ -79,26 +81,29 @@ export default function CreatePermissionVariableForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const permissionVariablesObject = getPermissionVariablesObject(
|
||||
availablePermissionVariables.filter(
|
||||
(permissionVariable) => !permissionVariable.isSystemClaim,
|
||||
),
|
||||
);
|
||||
const existingPermissionVariables =
|
||||
permissionVariables?.map((permissionVariable) => ({
|
||||
key: permissionVariable.key,
|
||||
value: permissionVariable.value,
|
||||
})) || [];
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authJwtCustomClaims: {
|
||||
...permissionVariablesObject,
|
||||
[key]: value,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
session: {
|
||||
accessToken: {
|
||||
customClaims: [...existingPermissionVariables, { key, value }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Creating permission variable...',
|
||||
success: 'Permission variable has been created successfully.',
|
||||
|
||||
@@ -6,14 +6,14 @@ import BasePermissionVariableForm, {
|
||||
basePermissionVariableValidationSchema,
|
||||
} from '@/components/settings/permissions/BasePermissionVariableForm';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { CustomClaim } from '@/types/application';
|
||||
import type { PermissionVariable } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getPermissionVariables from '@/utils/settings/getPermissionVariablesArray';
|
||||
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
|
||||
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetAppCustomClaimsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -24,7 +24,7 @@ export interface EditPermissionVariableFormProps
|
||||
/**
|
||||
* The permission variable to be edited.
|
||||
*/
|
||||
originalVariable: CustomClaim;
|
||||
originalVariable: PermissionVariable;
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
@@ -38,11 +38,14 @@ export default function EditPermissionVariableForm({
|
||||
}: EditPermissionVariableFormProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
const { data, error, loading } = useGetAppCustomClaimsQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, error, loading } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { customClaims: permissionVariables } =
|
||||
data?.config?.auth?.session?.accessToken || {};
|
||||
|
||||
const form = useForm<BasePermissionVariableFormValues>({
|
||||
defaultValues: {
|
||||
key: originalVariable.key || '',
|
||||
@@ -52,8 +55,8 @@ export default function EditPermissionVariableForm({
|
||||
resolver: yupResolver(basePermissionVariableValidationSchema),
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getAppCustomClaims'],
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -67,9 +70,8 @@ export default function EditPermissionVariableForm({
|
||||
}
|
||||
|
||||
const { setError } = form;
|
||||
const availablePermissionVariables = getPermissionVariables(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
);
|
||||
const availablePermissionVariables =
|
||||
getAllPermissionVariables(permissionVariables);
|
||||
|
||||
async function handleSubmit({
|
||||
key,
|
||||
@@ -92,36 +94,43 @@ export default function EditPermissionVariableForm({
|
||||
(permissionVariable) => permissionVariable.key === originalVariable.key,
|
||||
);
|
||||
|
||||
const updatedPermissionVariables = availablePermissionVariables.map(
|
||||
(permissionVariable, index) => {
|
||||
if (index === originalPermissionVariableIndex) {
|
||||
return { key, value };
|
||||
const updatedPermissionVariables = availablePermissionVariables
|
||||
.map((permissionVariable, index) => {
|
||||
if (permissionVariable.isSystemVariable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return permissionVariable;
|
||||
},
|
||||
);
|
||||
if (index === originalPermissionVariableIndex) {
|
||||
return {
|
||||
key,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
const permissionVariablesObject = getPermissionVariablesObject(
|
||||
updatedPermissionVariables.filter(
|
||||
(permissionVariable) => !permissionVariable.isSystemClaim,
|
||||
),
|
||||
);
|
||||
return {
|
||||
key: permissionVariable.key,
|
||||
value: permissionVariable.value,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authJwtCustomClaims: {
|
||||
...permissionVariablesObject,
|
||||
[key]: value,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
session: {
|
||||
accessToken: {
|
||||
customClaims: updatedPermissionVariables,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating permission variable...',
|
||||
success: 'Permission variable has been updated successfully.',
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useDialog } from '@/components/common/DialogProvider';
|
||||
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
|
||||
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { CustomClaim } from '@/types/application';
|
||||
import type { PermissionVariable } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -17,35 +18,33 @@ import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
|
||||
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetAppCustomClaimsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { Fragment } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface PermissionVariableSettingsFormValues {
|
||||
/**
|
||||
* Permission variables.
|
||||
*/
|
||||
authJwtCustomClaims: CustomClaim[];
|
||||
}
|
||||
|
||||
export default function PermissionVariableSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { openDialog, openAlertDialog } = useDialog();
|
||||
|
||||
const { data, loading, error } = useGetAppCustomClaimsQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const { data, loading, error } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getAppCustomClaims'],
|
||||
const { customClaims: permissionVariables } =
|
||||
data?.config?.auth?.session?.accessToken || {};
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -58,32 +57,35 @@ export default function PermissionVariableSettings() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleDeleteVariable({ key }: CustomClaim) {
|
||||
const filteredCustomClaims = Object.keys(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
).filter((customClaimKey) => customClaimKey !== key);
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
async function handleDeleteVariable({ id }: PermissionVariable) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authJwtCustomClaims: filteredCustomClaims.reduce(
|
||||
(customClaims, currentKey) => ({
|
||||
...customClaims,
|
||||
[currentKey]: data?.app?.authJwtCustomClaims[currentKey],
|
||||
}),
|
||||
{},
|
||||
),
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
session: {
|
||||
accessToken: {
|
||||
customClaims: permissionVariables
|
||||
?.filter((permissionVariable) => permissionVariable.id !== id)
|
||||
.map((permissionVariable) => ({
|
||||
key: permissionVariable.key,
|
||||
value: permissionVariable.value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Deleting permission variable...',
|
||||
success: 'Permission variable has been deleted successfully.',
|
||||
error: 'An error occurred while trying to delete permission variable.',
|
||||
error: getServerError(
|
||||
'An error occurred while trying to delete permission variable.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
@@ -100,7 +102,7 @@ export default function PermissionVariableSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenEditor(originalVariable: CustomClaim) {
|
||||
function handleOpenEditor(originalVariable: PermissionVariable) {
|
||||
openDialog({
|
||||
title: 'Edit Permission Variable',
|
||||
component: (
|
||||
@@ -113,7 +115,7 @@ export default function PermissionVariableSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirmDelete(originalVariable: CustomClaim) {
|
||||
function handleConfirmDelete(originalVariable: PermissionVariable) {
|
||||
openAlertDialog({
|
||||
title: 'Delete Permission Variable',
|
||||
payload: (
|
||||
@@ -131,9 +133,8 @@ export default function PermissionVariableSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
const availablePermissionVariables = getPermissionVariablesArray(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
);
|
||||
const availablePermissionVariables =
|
||||
getAllPermissionVariables(permissionVariables);
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
@@ -151,28 +152,33 @@ export default function PermissionVariableSettings() {
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<List>
|
||||
{availablePermissionVariables.map((customClaim, index) => (
|
||||
<Fragment key={customClaim.key}>
|
||||
{availablePermissionVariables.map((permissionVariable, index) => (
|
||||
<Fragment key={permissionVariable.id}>
|
||||
<ListItem.Root
|
||||
className="grid grid-cols-2 px-4"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Tooltip
|
||||
title={
|
||||
customClaim.isSystemClaim
|
||||
permissionVariable.isSystemVariable
|
||||
? "You can't edit system permission variables"
|
||||
: ''
|
||||
}
|
||||
placement="right"
|
||||
disableHoverListener={!customClaim.isSystemClaim}
|
||||
hasDisabledChildren={customClaim.isSystemClaim}
|
||||
disableHoverListener={
|
||||
!permissionVariable.isSystemVariable
|
||||
}
|
||||
hasDisabledChildren={permissionVariable.isSystemVariable}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<Dropdown.Trigger asChild hideChevron>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={customClaim.isSystemClaim}
|
||||
disabled={
|
||||
permissionVariable.isSystemVariable ||
|
||||
maintenanceActive
|
||||
}
|
||||
>
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
@@ -192,7 +198,7 @@ export default function PermissionVariableSettings() {
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleOpenEditor(customClaim)}
|
||||
onClick={() => handleOpenEditor(permissionVariable)}
|
||||
>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
@@ -200,7 +206,7 @@ export default function PermissionVariableSettings() {
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
onClick={() => handleConfirmDelete(customClaim)}
|
||||
onClick={() => handleConfirmDelete(permissionVariable)}
|
||||
>
|
||||
<Text
|
||||
className="font-medium"
|
||||
@@ -218,15 +224,17 @@ export default function PermissionVariableSettings() {
|
||||
<ListItem.Text
|
||||
primary={
|
||||
<>
|
||||
X-Hasura-{customClaim.key}{' '}
|
||||
{customClaim.isSystemClaim && (
|
||||
X-Hasura-{permissionVariable.key}{' '}
|
||||
{permissionVariable.isSystemVariable && (
|
||||
<LockIcon className="h-4 w-4" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Text className="font-medium">user.{customClaim.value}</Text>
|
||||
<Text className="font-medium">
|
||||
user.{permissionVariable.value}
|
||||
</Text>
|
||||
</ListItem.Root>
|
||||
|
||||
<Divider
|
||||
@@ -246,6 +254,7 @@ export default function PermissionVariableSettings() {
|
||||
variant="borderless"
|
||||
startIcon={<PlusIcon />}
|
||||
onClick={handleOpenCreator}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Create Permission Variable
|
||||
</Button>
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from './PermissionVariableSettings';
|
||||
export { default } from './PermissionVariableSettings';
|
||||
|
||||
@@ -7,11 +7,13 @@ import BaseRoleForm, {
|
||||
} from '@/components/settings/roles/BaseRoleForm';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -30,10 +32,11 @@ export default function CreateRoleForm({
|
||||
...props
|
||||
}: CreateRoleFormProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data, loading, error } = useGetRolesQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, loading, error } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
const { allowed: allowedRoles } = data?.config?.auth?.user?.roles || {};
|
||||
|
||||
const form = useForm<BaseRoleFormValues>({
|
||||
defaultValues: {},
|
||||
@@ -41,7 +44,9 @@ export default function CreateRoleForm({
|
||||
resolver: yupResolver(baseRoleFormValidationSchema),
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({ refetchQueries: ['getRoles'] });
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <ActivityIndicator delay={1000} label="Loading roles..." />;
|
||||
@@ -52,7 +57,7 @@ export default function CreateRoleForm({
|
||||
}
|
||||
|
||||
const { setError } = form;
|
||||
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
|
||||
const availableRoles = getUserRoles(allowedRoles);
|
||||
|
||||
async function handleSubmit({ name }: BaseRoleFormValues) {
|
||||
if (availableRoles.some((role) => role.name === name)) {
|
||||
@@ -61,26 +66,40 @@ export default function CreateRoleForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
const updatedAllowedRoles = allowedRoles ? [...allowedRoles, name] : [name];
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authUserDefaultAllowedRoles: `${data?.app?.authUserDefaultAllowedRoles},${name}`,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
roles: {
|
||||
allowed: updatedAllowedRoles,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
{
|
||||
loading: 'Creating role...',
|
||||
success: 'Role has been created successfully.',
|
||||
error: 'An error occurred while trying to create the role.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Creating role...',
|
||||
success: 'Role has been created successfully.',
|
||||
error: getServerError(
|
||||
'An error occurred while trying to create the role.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
await onSubmit?.();
|
||||
onSubmit?.();
|
||||
} catch (updateConfigError) {
|
||||
console.error(updateConfigError);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,11 +8,13 @@ import BaseRoleForm, {
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { Role } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -36,11 +38,14 @@ export default function EditRoleForm({
|
||||
...props
|
||||
}: EditRoleFormProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data, loading, error } = useGetRolesQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, loading, error } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { allowed: allowedRoles, default: defaultRole } =
|
||||
data?.config?.auth?.user?.roles || {};
|
||||
|
||||
const form = useForm<BaseRoleFormValues>({
|
||||
defaultValues: {
|
||||
name: originalRole.name || '',
|
||||
@@ -49,8 +54,8 @@ export default function EditRoleForm({
|
||||
resolver: yupResolver(baseRoleFormValidationSchema),
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getRoles'],
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -62,7 +67,7 @@ export default function EditRoleForm({
|
||||
}
|
||||
|
||||
const { setError } = form;
|
||||
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
|
||||
const availableRoles = getUserRoles(allowedRoles);
|
||||
|
||||
async function handleSubmit({ name }: BaseRoleFormValues) {
|
||||
if (
|
||||
@@ -75,47 +80,55 @@ export default function EditRoleForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultAllowedRolesList =
|
||||
data?.app?.authUserDefaultAllowedRoles.split(',') || [];
|
||||
const defaultAllowedRolesList = allowedRoles || [];
|
||||
|
||||
const originalRoleIndex = defaultAllowedRolesList.findIndex(
|
||||
(role) => role.trim() === originalRole.name,
|
||||
);
|
||||
|
||||
const updatedDefaultAllowedRoles = defaultAllowedRolesList
|
||||
.map((role, index) => {
|
||||
const updatedDefaultAllowedRoles = defaultAllowedRolesList.map(
|
||||
(role, index) => {
|
||||
if (index === originalRoleIndex) {
|
||||
return name;
|
||||
}
|
||||
|
||||
return role;
|
||||
})
|
||||
.join(',');
|
||||
},
|
||||
);
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authUserDefaultRole:
|
||||
data?.app?.authUserDefaultRole === originalRole.name
|
||||
? name
|
||||
: data?.app?.authUserDefaultRole,
|
||||
authUserDefaultAllowedRoles: updatedDefaultAllowedRoles,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
roles: {
|
||||
default: defaultRole === originalRole.name ? name : defaultRole,
|
||||
allowed: updatedDefaultAllowedRoles,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
{
|
||||
loading: 'Updating role...',
|
||||
success: 'Role has been updated successfully.',
|
||||
error: 'An error occurred while trying to update the role.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating role...',
|
||||
success: 'Role has been updated successfully.',
|
||||
error: getServerError(
|
||||
'An error occurred while trying to update the role.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
await onSubmit?.();
|
||||
onSubmit?.();
|
||||
} catch (updateConfigError) {
|
||||
console.error(updateConfigError);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useDialog } from '@/components/common/DialogProvider';
|
||||
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
|
||||
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { Role } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
@@ -17,11 +18,13 @@ import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { Fragment } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -39,15 +42,20 @@ export interface RoleSettingsFormValues {
|
||||
}
|
||||
|
||||
export default function RoleSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { openDialog, openAlertDialog } = useDialog();
|
||||
|
||||
const { data, loading, error } = useGetRolesQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, loading, error } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getRoles'],
|
||||
const { allowed: allowedRoles, default: defaultRole } =
|
||||
data?.config?.auth?.user?.roles || {};
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -59,51 +67,60 @@ export default function RoleSettings() {
|
||||
}
|
||||
|
||||
async function handleSetAsDefault({ name }: Role) {
|
||||
const updateAppPromise = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authUserDefaultRole: name,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
roles: {
|
||||
allowed: allowedRoles,
|
||||
default: name,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating default role...',
|
||||
success: 'Default role has been updated successfully.',
|
||||
error: 'An error occurred while trying to update the default role.',
|
||||
error: getServerError(
|
||||
'An error occurred while trying to update the default role.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteRole({ name }: Role) {
|
||||
const filteredRoles = data?.app?.authUserDefaultAllowedRoles
|
||||
.split(',')
|
||||
.filter((role) => role !== name)
|
||||
.join(',');
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authUserDefaultAllowedRoles: filteredRoles,
|
||||
authUserDefaultRole:
|
||||
name === data?.app?.authUserDefaultRole
|
||||
? 'user'
|
||||
: data?.app?.authUserDefaultRole,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
roles: {
|
||||
allowed: allowedRoles.filter((role) => role !== name),
|
||||
default: name === defaultRole ? 'user' : defaultRole,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Deleting allowed role...',
|
||||
success: 'Allowed Role has been deleted successfully.',
|
||||
error: 'An error occurred while trying to delete the allowed role.',
|
||||
error: getServerError(
|
||||
'An error occurred while trying to delete the allowed role.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
@@ -148,9 +165,7 @@ export default function RoleSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
const availableAllowedRoles = getUserRoles(
|
||||
data?.app?.authUserDefaultAllowedRoles,
|
||||
);
|
||||
const availableAllowedRoles = getUserRoles(allowedRoles);
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
@@ -158,7 +173,10 @@ export default function RoleSettings() {
|
||||
description="Allowed roles are roles users get automatically when they sign up."
|
||||
docsLink="https://docs.nhost.io/authentication/users#allowed-roles"
|
||||
rootClassName="gap-0"
|
||||
className="my-2 px-0"
|
||||
className={twMerge(
|
||||
'my-2 px-0',
|
||||
availableAllowedRoles.length === 0 && 'gap-2',
|
||||
)}
|
||||
slotProps={{ submitButton: { className: 'invisible' } }}
|
||||
>
|
||||
<Box className="border-b-1 px-4 py-3">
|
||||
@@ -166,103 +184,110 @@ export default function RoleSettings() {
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<List>
|
||||
{availableAllowedRoles.map((role, index) => (
|
||||
<Fragment key={role.name}>
|
||||
<ListItem.Root
|
||||
className="px-4"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<IconButton variant="borderless" color="secondary">
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item onClick={() => handleSetAsDefault(role)}>
|
||||
<Text className="font-medium">Set as Default</Text>
|
||||
</Dropdown.Item>
|
||||
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
disabled={role.isSystemRole}
|
||||
onClick={() => handleOpenEditor(role)}
|
||||
{availableAllowedRoles.length > 0 && (
|
||||
<List>
|
||||
{availableAllowedRoles.map((role, index) => (
|
||||
<Fragment key={role.name}>
|
||||
<ListItem.Root
|
||||
className="px-4"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
disabled={role.isSystemRole}
|
||||
onClick={() => handleConfirmDelete(role)}
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text
|
||||
primaryTypographyProps={{
|
||||
className:
|
||||
'inline-grid grid-flow-col gap-1 items-center h-6 font-medium',
|
||||
}}
|
||||
primary={
|
||||
<>
|
||||
{role.name}
|
||||
<Dropdown.Item onClick={() => handleSetAsDefault(role)}>
|
||||
<Text className="font-medium">Set as Default</Text>
|
||||
</Dropdown.Item>
|
||||
|
||||
{role.isSystemRole && <LockIcon className="h-4 w-4" />}
|
||||
<Divider component="li" />
|
||||
|
||||
{data?.app?.authUserDefaultRole === role.name && (
|
||||
<Chip
|
||||
component="span"
|
||||
color="info"
|
||||
size="small"
|
||||
label="Default"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<Dropdown.Item
|
||||
disabled={role.isSystemRole}
|
||||
onClick={() => handleOpenEditor(role)}
|
||||
>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
disabled={role.isSystemRole}
|
||||
onClick={() => handleConfirmDelete(role)}
|
||||
>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
>
|
||||
<ListItem.Text
|
||||
primaryTypographyProps={{
|
||||
className:
|
||||
'inline-grid grid-flow-col gap-1 items-center h-6 font-medium',
|
||||
}}
|
||||
primary={
|
||||
<>
|
||||
{role.name}
|
||||
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availableAllowedRoles.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
{role.isSystemRole && <LockIcon className="h-4 w-4" />}
|
||||
|
||||
{defaultRole === role.name && (
|
||||
<Chip
|
||||
component="span"
|
||||
color="info"
|
||||
size="small"
|
||||
label="Default"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availableAllowedRoles.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="mx-4 justify-self-start"
|
||||
variant="borderless"
|
||||
startIcon={<PlusIcon />}
|
||||
onClick={handleOpenCreator}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Create Allowed Role
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface BaseSecretFormProps {
|
||||
/**
|
||||
* Determines the mode of the form.
|
||||
*
|
||||
* @default 'edit'
|
||||
*/
|
||||
mode?: 'edit' | 'create';
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit: (values: BaseSecretFormValues) => void;
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
/**
|
||||
* Submit button text.
|
||||
*
|
||||
* @default 'Save'
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
}
|
||||
|
||||
export const baseSecretFormValidationSchema = Yup.object({
|
||||
name: Yup.string()
|
||||
.required('This field is required.')
|
||||
.test(
|
||||
'isSecretValid',
|
||||
'A name must start with a letter and can only contain letters, numbers, and underscores.',
|
||||
(value) => /^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
|
||||
),
|
||||
value: Yup.string().required('This field is required.'),
|
||||
});
|
||||
|
||||
export type BaseSecretFormValues = Yup.InferType<
|
||||
typeof baseSecretFormValidationSchema
|
||||
>;
|
||||
|
||||
export default function BaseSecretForm({
|
||||
mode = 'edit',
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
}: BaseSecretFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const form = useFormContext<BaseSecretFormValues>();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, dirtyFields, isSubmitting },
|
||||
} = form;
|
||||
|
||||
// react-hook-form's isDirty gets true even if an input field is focused, then
|
||||
// immediately unfocused - we can't rely on that information
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'dialog');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-6 px-6 pb-6">
|
||||
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
|
||||
<Input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
label="Name"
|
||||
placeholder="EXAMPLE_NAME"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.name}
|
||||
helperText={errors?.name?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus={mode === 'create'}
|
||||
disabled={mode === 'edit'}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('value')}
|
||||
id="value"
|
||||
label="Value"
|
||||
placeholder="Enter value"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.value}
|
||||
helperText={errors?.value?.message}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={5}
|
||||
autoComplete="off"
|
||||
autoFocus={mode === 'edit'}
|
||||
/>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './BaseSecretForm';
|
||||
export { default } from './BaseSecretForm';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user