Compare commits
158 Commits
@nhost/rea
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8857314e22 | ||
|
|
85f1c4a98e | ||
|
|
efa6b5755d | ||
|
|
2b19416787 | ||
|
|
4e5d43f300 | ||
|
|
db342f453e | ||
|
|
54386a3b56 | ||
|
|
ff40b99f84 | ||
|
|
33f8f1d78a | ||
|
|
c50fe47ab4 | ||
|
|
0580f832c8 | ||
|
|
7d1eb099c0 | ||
|
|
e15322296b | ||
|
|
91a2bf905b | ||
|
|
0f9393fe27 | ||
|
|
aebb822549 | ||
|
|
1e2be6fadf | ||
|
|
aafbf5173d | ||
|
|
01e13e2f8c | ||
|
|
4364647501 | ||
|
|
ef117c284e | ||
|
|
3f919c0a80 | ||
|
|
49e447e7b7 | ||
|
|
66b4f3d0be | ||
|
|
aa7fdafe8b | ||
|
|
7d6de3b289 | ||
|
|
57e41f77a9 | ||
|
|
f5c2a0ef4f | ||
|
|
d52bc8cca5 | ||
|
|
04a3e4c965 | ||
|
|
853c0c5775 | ||
|
|
2e6923dc73 | ||
|
|
7d6d70d0c7 | ||
|
|
7a2100cc17 | ||
|
|
5d55f3fa60 | ||
|
|
8b0c44a93c | ||
|
|
e0cc7cce0a | ||
|
|
6e7d5e0dd4 | ||
|
|
54c143ebf6 | ||
|
|
8b9fa0b150 | ||
|
|
c3bb79e1dd | ||
|
|
128d21e4ec | ||
|
|
40e503c356 | ||
|
|
d007e0ade8 | ||
|
|
fa32513ba7 | ||
|
|
8893d9e010 | ||
|
|
81d2fd865c | ||
|
|
fe3c462099 | ||
|
|
f8b082cb02 | ||
|
|
0c748e6ee6 | ||
|
|
e2c4ca85b3 | ||
|
|
0165b998c2 | ||
|
|
5d970cc229 | ||
|
|
7167170663 | ||
|
|
0f77de2dd0 | ||
|
|
6ae91e48d1 | ||
|
|
69db1594cc | ||
|
|
158cf0da49 | ||
|
|
7992fc3baa | ||
|
|
85d9596956 | ||
|
|
16d383516e | ||
|
|
2ca193ccf3 | ||
|
|
ab8e12003d | ||
|
|
29cdf6b125 | ||
|
|
41cc3dc5d0 | ||
|
|
6b67c9996a | ||
|
|
23274dee41 | ||
|
|
a5b55c2667 | ||
|
|
1263676eb3 | ||
|
|
b1b647ad96 | ||
|
|
21bbaf5e95 | ||
|
|
eef9c91403 | ||
|
|
1742cb444d | ||
|
|
c4f374d7f3 | ||
|
|
369ec13070 | ||
|
|
101129eef2 | ||
|
|
228fda0364 | ||
|
|
74085c67a2 | ||
|
|
a273725419 | ||
|
|
c5240f8d74 | ||
|
|
4490068257 | ||
|
|
3601de3f85 | ||
|
|
ac9404610b | ||
|
|
63570db57c | ||
|
|
538ed78f5a | ||
|
|
b1a31ecb00 | ||
|
|
3d151c448c | ||
|
|
bac8ace434 | ||
|
|
fdd417ed25 | ||
|
|
a402fc17de | ||
|
|
4416ceb9cf | ||
|
|
4762ebf61e | ||
|
|
73e28b5831 | ||
|
|
2a7dc5060f | ||
|
|
9b8ede40a9 | ||
|
|
f005c20d99 | ||
|
|
4adfd613b6 | ||
|
|
b6da82c8e3 | ||
|
|
816456edc4 | ||
|
|
deaf0e86d4 | ||
|
|
23f8206f18 | ||
|
|
9dde4d7988 | ||
|
|
26385b9cf9 | ||
|
|
6d318206ef | ||
|
|
4d727b78a1 | ||
|
|
de0a125e98 | ||
|
|
ea1ad29031 | ||
|
|
3da40e5712 | ||
|
|
b9087a4add | ||
|
|
1b7a6d0252 | ||
|
|
1417d3e794 | ||
|
|
e187923858 | ||
|
|
8a60ed4074 | ||
|
|
d7d11a44a7 | ||
|
|
062e4691cd | ||
|
|
a95d49fa2c | ||
|
|
d14fc96899 | ||
|
|
93db718254 | ||
|
|
c367bd58b9 | ||
|
|
0bfed4d9e1 | ||
|
|
1f3aecd379 | ||
|
|
42306ea3bb | ||
|
|
1b12a175f6 | ||
|
|
32060aaea0 | ||
|
|
f94cace5f2 | ||
|
|
5de965d9a5 | ||
|
|
e10b3adc11 | ||
|
|
457db76b06 | ||
|
|
1e952a026e | ||
|
|
2f4c040789 | ||
|
|
74648752b4 | ||
|
|
09d218a3fe | ||
|
|
2e8938dbb0 | ||
|
|
ec60d03536 | ||
|
|
2f3767552f | ||
|
|
bc401c0dd2 | ||
|
|
2145243b19 | ||
|
|
ca012d790c | ||
|
|
aeda14ef53 | ||
|
|
3fa5e2005a | ||
|
|
beadd84adb | ||
|
|
f8f55d2b99 | ||
|
|
03a98d4f3a | ||
|
|
8ed8e04ab6 | ||
|
|
587efd4551 | ||
|
|
a48dd5bf74 | ||
|
|
ef53df5cb3 | ||
|
|
7055ffc37a | ||
|
|
c68ce6d480 | ||
|
|
98a149c8bf | ||
|
|
ceb558975e | ||
|
|
7a87321a7e | ||
|
|
9349766c0a | ||
|
|
31655191a3 | ||
|
|
e3b91efa84 | ||
|
|
cfe736776a | ||
|
|
481bf237cc | ||
|
|
33ce9bf1b9 |
36
.github/workflows/changesets.yaml
vendored
36
.github/workflows/changesets.yaml
vendored
@@ -13,11 +13,12 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: nhost
|
||||||
DASHBOARD_PACKAGE: '@nhost/dashboard'
|
DASHBOARD_PACKAGE: '@nhost/dashboard'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version:
|
version:
|
||||||
|
name: Version
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
hasChangesets: ${{ steps.changesets.outputs.hasChangesets }}
|
hasChangesets: ${{ steps.changesets.outputs.hasChangesets }}
|
||||||
@@ -30,8 +31,8 @@ jobs:
|
|||||||
- name: Install Node and dependencies
|
- name: Install Node and dependencies
|
||||||
uses: ./.github/actions/install-dependencies
|
uses: ./.github/actions/install-dependencies
|
||||||
with:
|
with:
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||||
- name: Create PR or Publish release
|
- name: Create PR or Publish release
|
||||||
id: changesets
|
id: changesets
|
||||||
uses: changesets/action@v1
|
uses: changesets/action@v1
|
||||||
@@ -60,7 +61,8 @@ jobs:
|
|||||||
uses: ./.github/workflows/dashboard.yaml
|
uses: ./.github/workflows/dashboard.yaml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
publish:
|
publish-docker:
|
||||||
|
name: Publish to Docker Hub
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
@@ -120,3 +122,29 @@ jobs:
|
|||||||
- name: Remove tag on failure
|
- name: Remove tag on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: git push --delete origin ${{ env.DASHBOARD_PACKAGE }}@${{ needs.version.outputs.dashboardVersion }}
|
run: git push --delete origin ${{ env.DASHBOARD_PACKAGE }}@${{ needs.version.outputs.dashboardVersion }}
|
||||||
|
|
||||||
|
publish-vercel:
|
||||||
|
name: Publish to Vercel
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- publish-docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Install Node and dependencies
|
||||||
|
uses: ./.github/actions/install-dependencies
|
||||||
|
with:
|
||||||
|
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||||
|
- name: Setup Vercel CLI
|
||||||
|
run: pnpm add -g vercel
|
||||||
|
- name: Trigger a Vercel deployment
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
|
||||||
|
run: |
|
||||||
|
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||||
|
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||||
|
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||||
|
|||||||
2
.github/workflows/dashboard.yaml
vendored
2
.github/workflows/dashboard.yaml
vendored
@@ -11,7 +11,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: nhost
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
|
|||||||
2
.github/workflows/packages.yaml
vendored
2
.github/workflows/packages.yaml
vendored
@@ -21,7 +21,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: nhost
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build @nhost packages
|
name: Build @nhost packages
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -48,6 +48,10 @@ todo.md
|
|||||||
.netlify
|
.netlify
|
||||||
.monorepo-example
|
.monorepo-example
|
||||||
|
|
||||||
|
# Local Vercel folder
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
.next
|
.next
|
||||||
|
|
||||||
# TypeDoc output
|
# TypeDoc output
|
||||||
|
|||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.organizeImports": true
|
"source.organizeImports": true
|
||||||
}
|
},
|
||||||
}
|
"eslint.workingDirectories": ["./dashboard"]
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,11 +99,6 @@ You can take a look at the changeset documentation: [How to add a changeset](htt
|
|||||||
|
|
||||||
You'll notice that `git commit` takes a few seconds to run. We set a commit hook that scans the changes in the code, automatically generates documentation from the inline [TSDoc](https://tsdoc.org/) annotations, and adds these generated documentation files to the commit. They automatically update the [reference documentation](https://docs.nhost.io/reference).
|
You'll notice that `git commit` takes a few seconds to run. We set a commit hook that scans the changes in the code, automatically generates documentation from the inline [TSDoc](https://tsdoc.org/) annotations, and adds these generated documentation files to the commit. They automatically update the [reference documentation](https://docs.nhost.io/reference).
|
||||||
|
|
||||||
The document generation script that is run in the pre-commit hook requires to be built first. You may need to run the following command before the commit:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
<!-- ## Good practices
|
<!-- ## Good practices
|
||||||
- lint
|
- lint
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -179,14 +179,21 @@ Here are some ways of contributing to making Nhost better:
|
|||||||
<sub><b>Grégory D'Angelo</b></sub>
|
<sub><b>Grégory D'Angelo</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/ejkkan">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/32518962?v=4" width="100;" alt="ejkkan"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>Erik Magnusson</b></sub>
|
||||||
|
</a>
|
||||||
|
</td></tr>
|
||||||
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/guicurcio">
|
<a href="https://github.com/guicurcio">
|
||||||
<img src="https://avatars.githubusercontent.com/u/20285232?v=4" width="100;" alt="guicurcio"/>
|
<img src="https://avatars.githubusercontent.com/u/20285232?v=4" width="100;" alt="guicurcio"/>
|
||||||
<br />
|
<br />
|
||||||
<sub><b>Guido Curcio</b></sub>
|
<sub><b>Guido Curcio</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr>
|
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/subatuba21">
|
<a href="https://github.com/subatuba21">
|
||||||
<img src="https://avatars.githubusercontent.com/u/34824571?v=4" width="100;" alt="subatuba21"/>
|
<img src="https://avatars.githubusercontent.com/u/34824571?v=4" width="100;" alt="subatuba21"/>
|
||||||
@@ -221,15 +228,15 @@ Here are some ways of contributing to making Nhost better:
|
|||||||
<br />
|
<br />
|
||||||
<sub><b>Christopher Möller</b></sub>
|
<sub><b>Christopher Möller</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/GavanWilhite">
|
<a href="https://github.com/GavanWilhite">
|
||||||
<img src="https://avatars.githubusercontent.com/u/2085119?v=4" width="100;" alt="GavanWilhite"/>
|
<img src="https://avatars.githubusercontent.com/u/2085119?v=4" width="100;" alt="GavanWilhite"/>
|
||||||
<br />
|
<br />
|
||||||
<sub><b>Gavan Wilhite</b></sub>
|
<sub><b>Gavan Wilhite</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr>
|
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/FuzzyReason">
|
<a href="https://github.com/FuzzyReason">
|
||||||
<img src="https://avatars.githubusercontent.com/u/62517920?v=4" width="100;" alt="FuzzyReason"/>
|
<img src="https://avatars.githubusercontent.com/u/62517920?v=4" width="100;" alt="FuzzyReason"/>
|
||||||
@@ -237,13 +244,6 @@ Here are some ways of contributing to making Nhost better:
|
|||||||
<sub><b>Vadim Smirnov</b></sub>
|
<sub><b>Vadim Smirnov</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
|
||||||
<a href="https://github.com/ejkkan">
|
|
||||||
<img src="https://avatars.githubusercontent.com/u/32518962?v=4" width="100;" alt="ejkkan"/>
|
|
||||||
<br />
|
|
||||||
<sub><b>Erik Magnusson</b></sub>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/macmac49">
|
<a href="https://github.com/macmac49">
|
||||||
<img src="https://avatars.githubusercontent.com/u/831190?v=4" width="100;" alt="macmac49"/>
|
<img src="https://avatars.githubusercontent.com/u/831190?v=4" width="100;" alt="macmac49"/>
|
||||||
@@ -495,6 +495,13 @@ Here are some ways of contributing to making Nhost better:
|
|||||||
<sub><b>Quentin Decré</b></sub>
|
<sub><b>Quentin Decré</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/elephant3">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/48279149?v=4" width="100;" alt="elephant3"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>Siarhei Lipchyk</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/altschuler">
|
<a href="https://github.com/altschuler">
|
||||||
<img src="https://avatars.githubusercontent.com/u/956928?v=4" width="100;" alt="altschuler"/>
|
<img src="https://avatars.githubusercontent.com/u/956928?v=4" width="100;" alt="altschuler"/>
|
||||||
@@ -522,15 +529,15 @@ Here are some ways of contributing to making Nhost better:
|
|||||||
<br />
|
<br />
|
||||||
<sub><b>Vadim</b></sub>
|
<sub><b>Vadim</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td></tr>
|
||||||
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/TheRedLancer">
|
<a href="https://github.com/TheRedLancer">
|
||||||
<img src="https://avatars.githubusercontent.com/u/58493767?v=4" width="100;" alt="TheRedLancer"/>
|
<img src="https://avatars.githubusercontent.com/u/58493767?v=4" width="100;" alt="TheRedLancer"/>
|
||||||
<br />
|
<br />
|
||||||
<sub><b>Zach Burnaby</b></sub>
|
<sub><b>Zach Burnaby</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td></tr>
|
</td>
|
||||||
<tr>
|
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/komninoschat">
|
<a href="https://github.com/komninoschat">
|
||||||
<img src="https://avatars.githubusercontent.com/u/29049104?v=4" width="100;" alt="komninoschat"/>
|
<img src="https://avatars.githubusercontent.com/u/29049104?v=4" width="100;" alt="komninoschat"/>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ module.exports = {
|
|||||||
'tests/**/*.ts',
|
'tests/**/*.ts',
|
||||||
'tests/**/*.d.ts'
|
'tests/**/*.d.ts'
|
||||||
],
|
],
|
||||||
plugins: ['@typescript-eslint', 'simple-import-sort', 'cypress'],
|
plugins: ['@typescript-eslint', 'cypress'],
|
||||||
extends: ['plugin:cypress/recommended'],
|
extends: ['plugin:cypress/recommended'],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
@@ -30,31 +30,6 @@ module.exports = {
|
|||||||
rules: {
|
rules: {
|
||||||
'react/prop-types': 'off',
|
'react/prop-types': 'off',
|
||||||
'no-use-before-define': 'off',
|
'no-use-before-define': 'off',
|
||||||
'simple-import-sort/exports': 'error',
|
|
||||||
'simple-import-sort/imports': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
groups: [
|
|
||||||
// Node.js builtins. You could also generate this regex if you use a `.js` config.
|
|
||||||
// For example: `^(${require("module").builtinModules.join("|")})(/|$)`
|
|
||||||
[
|
|
||||||
'^(assert|buffer|child_process|cluster|console|constants|crypto|dgram|dns|domain|events|fs|http|https|module|net|os|path|punycode|querystring|readline|repl|stream|string_decoder|sys|timers|tls|tty|url|util|vm|zlib|freelist|v8|process|async_hooks|http2|perf_hooks)(/.*|$)'
|
|
||||||
],
|
|
||||||
// Packages
|
|
||||||
['^\\w'],
|
|
||||||
// Internal packages.
|
|
||||||
['^(@|config/)(/*|$)'],
|
|
||||||
// Side effect imports.
|
|
||||||
['^\\u0000'],
|
|
||||||
// Parent imports. Put `..` last.
|
|
||||||
['^\\.\\.(?!/?$)', '^\\.\\./?$'],
|
|
||||||
// Other relative imports. Put same-folder imports and `.` last.
|
|
||||||
['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
|
|
||||||
// Style imports.
|
|
||||||
['^.+\\.s?css$']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'import/no-anonymous-default-export': [
|
'import/no-anonymous-default-export': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/** @type {import('eslint').Linter.Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
extends: ['next', 'airbnb', 'airbnb-typescript', 'airbnb/hooks', 'prettier'],
|
extends: ['next', 'airbnb', 'airbnb-typescript', 'airbnb/hooks', 'prettier'],
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
# @nhost/dashboard
|
# @nhost/dashboard
|
||||||
|
|
||||||
|
## 0.7.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- db342f45: chore(dashboard): refactor Roles and Permissions settings sections
|
||||||
|
- 8b9fa0b1: feat(dashboard): add Environment Variables page
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [66b4f3d0]
|
||||||
|
- Updated dependencies [2e6923dc]
|
||||||
|
- Updated dependencies [ef117c28]
|
||||||
|
- Updated dependencies [aebb8225]
|
||||||
|
- @nhost/core@0.9.4
|
||||||
|
- @nhost/nhost-js@1.6.2
|
||||||
|
- @nhost/nextjs@1.9.1
|
||||||
|
- @nhost/react@0.15.1
|
||||||
|
- @nhost/react-apollo@4.9.1
|
||||||
|
|
||||||
|
## 0.6.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- eef9c914: feat(dashboard): add Roles and Permissions page
|
||||||
|
|
||||||
|
## 0.5.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- a48dd5bf: feat(dashboard): make backend port configurable
|
||||||
|
|
||||||
|
## 0.4.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 5de965d9: fix(dashboard): alphabetic ordering of providers
|
||||||
|
- b9087a4a: fix(dashboard): console -> dashboard terminology
|
||||||
|
- ca012d79: docs(workos): WorkOS Docs
|
||||||
|
|
||||||
## 0.4.2
|
## 0.4.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
FROM node:16-alpine AS pruner
|
FROM node:16-alpine AS pruner
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN apk update
|
RUN apk update
|
||||||
@@ -17,10 +16,13 @@ RUN apk update
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
ENV NEXT_PUBLIC_NHOST_PLATFORM false
|
|
||||||
ENV NEXT_PUBLIC_NHOST_MIGRATIONS_URL http://localhost:9693
|
|
||||||
ENV NEXT_PUBLIC_NHOST_HASURA_URL http://localhost:9695
|
|
||||||
ENV NEXT_PUBLIC_ENV dev
|
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__
|
||||||
|
|
||||||
RUN yarn global add pnpm@7.17.0
|
RUN yarn global add pnpm@7.17.0
|
||||||
COPY .gitignore .gitignore
|
COPY .gitignore .gitignore
|
||||||
@@ -40,11 +42,14 @@ RUN addgroup --system --gid 1001 nodejs
|
|||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
COPY --from=builder /app/dashboard/next.config.js .
|
COPY --chown=nextjs:nodejs dashboard/docker-entrypoint.sh .
|
||||||
COPY --from=builder /app/dashboard/package.json .
|
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/next.config.js .
|
||||||
COPY --from=builder /app/dashboard/public ./dashboard/public
|
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/package.json .
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/public ./dashboard/public
|
||||||
|
|
||||||
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/standalone/app ./
|
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/standalone/app ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/static ./dashboard/.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/static ./dashboard/.next/static
|
||||||
|
|
||||||
CMD node dashboard/server.js
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "dashboard/server.js"]
|
||||||
|
|||||||
@@ -30,31 +30,27 @@ First, you need to run the following command to start your backend locally:
|
|||||||
cd <your_nhost_project> && nhost dev
|
cd <your_nhost_project> && nhost dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Two environment variables are required to connect the Nhost Dashboard to your local backend:
|
You can connect the Nhost Dashboard to your locally running backend by setting the following environment variables in `.env.development.local`:
|
||||||
|
|
||||||
- `NEXT_PUBLIC_NHOST_PLATFORM` should be set to `false`, because otherwise the Nhost Dashboard will try to connect to the Nhost platform.
|
```bash
|
||||||
- `NEXT_PUBLIC_NHOST_MIGRATIONS_URL` should be set to `http://localhost:9693` unless Hasura is configured to run on a different port. This is the URL of Hasura's migrations endpoint.
|
NEXT_PUBLIC_ENV=dev
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
NEXT_PUBLIC_NHOST_PLATFORM=false
|
NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||||
NEXT_PUBLIC_NHOST_MIGRATIONS_URL=http://localhost:9693
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Full list of environment variables
|
### Full list of environment variables
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `NEXT_PUBLIC_NHOST_PLATFORM` | This should be set to `false` to connect the Nhost Dashboard to a locally running Nhost backend. |
|
| `NEXT_PUBLIC_ENV` | `dev`, `staging` or `prod`. Should be set to `dev` in most cases. |
|
||||||
| `NEXT_PUBLIC_NHOST_MIGRATIONS_URL` | URL of Hasura's migrations endpoint. Used only if local development is enabled. |
|
| `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_HASURA_URL` | URL of the Hasura Console. Used only when `NEXT_PUBLIC_ENV` is `dev`. |
|
| `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_ENV` | `dev`, `staging` or `prod`. Should be set to `dev` in most cases. |
|
| `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_BACKEND_URL` | Backend URL. Not necessary for local development. |
|
| `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_STRIPE_PK` | Stripe public key. Not necessary for local development. |
|
| `NEXT_PUBLIC_NHOST_BACKEND_URL` | Backend URL. Not necessary for local development. |
|
||||||
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. Not necessary for local development. |
|
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. Not necessary for local development. |
|
||||||
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. Not necessary for local development. |
|
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. Not necessary for local development. |
|
||||||
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. 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. |
|
||||||
|
|
||||||
## ESLint Rules
|
## ESLint Rules
|
||||||
|
|
||||||
@@ -67,6 +63,7 @@ NEXT_PUBLIC_NHOST_MIGRATIONS_URL=http://localhost:9693
|
|||||||
| `import/extensions` | JS / TS files should be imported without file extensions. |
|
| `import/extensions` | JS / TS files should be imported without file extensions. |
|
||||||
| `react/jsx-filename-extension` | JSX should only appear in `.jsx` and `.tsx` files. |
|
| `react/jsx-filename-extension` | JSX should only appear in `.jsx` and `.tsx` files. |
|
||||||
| `react/jsx-no-bind` | Further investigation must be made on the performance impact of functions directly passed as props to components. |
|
| `react/jsx-no-bind` | Further investigation must be made on the performance impact of functions directly passed as props to components. |
|
||||||
|
| `import/order` | Until we have a better auto-formatter, we disable this rule. |
|
||||||
| `import/no-extraneous-dependencies` | `devDependencies` should be excluded from the list of disallowed imports. |
|
| `import/no-extraneous-dependencies` | `devDependencies` should be excluded from the list of disallowed imports. |
|
||||||
| `curly` | By default it only enforces curly braces for multi-line blocks, but it should be enforced for single-line blocks as well. |
|
| `curly` | By default it only enforces curly braces for multi-line blocks, but it should be enforced for single-line blocks as well. |
|
||||||
| `no-restricted-exports` | `export { default } from './module'` is used heavily in `@/ui/v2` which is a restricted export by default. |
|
| `no-restricted-exports` | `export { default } from './module'` is used heavily in `@/ui/v2` which is a restricted export by default. |
|
||||||
|
|||||||
15
dashboard/docker-entrypoint.sh
Executable file
15
dashboard/docker-entrypoint.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
|
||||||
|
# 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" {} +
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/dashboard",
|
"name": "@nhost/dashboard",
|
||||||
"version": "0.4.2",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "next build --no-lint",
|
"build": "next build --no-lint",
|
||||||
"analyze": "ANALYZE=true pnpm build --no-lint",
|
"analyze": "ANALYZE=true pnpm build --no-lint",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint --max-warnings 6",
|
"lint": "next lint --max-warnings 3",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
|
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
|
||||||
"nhost:dev": "nhost dev -d",
|
"nhost:dev": "nhost dev -d",
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
"@mui/material": "^5.10.14",
|
"@mui/material": "^5.10.14",
|
||||||
"@mui/system": "^5.10.14",
|
"@mui/system": "^5.10.14",
|
||||||
"@mui/x-date-pickers": "^5.0.8",
|
"@mui/x-date-pickers": "^5.0.8",
|
||||||
"@nhost/core": "^0.9.3",
|
"@nhost/core": "^0.9.4",
|
||||||
"@nhost/nextjs": "^1.9.0",
|
"@nhost/nextjs": "^1.9.1",
|
||||||
"@nhost/nhost-js": "^1.6.1",
|
"@nhost/nhost-js": "^1.6.2",
|
||||||
"@nhost/react": "^0.15.0",
|
"@nhost/react": "^0.15.1",
|
||||||
"@nhost/react-apollo": "^4.9.0",
|
"@nhost/react-apollo": "^4.9.1",
|
||||||
"@segment/snippet": "^4.15.3",
|
"@segment/snippet": "^4.15.3",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
"@tanstack/react-query": "^4.16.1",
|
"@tanstack/react-query": "^4.16.1",
|
||||||
|
|||||||
1
dashboard/public/assets/twilio.svg
Normal file
1
dashboard/public/assets/twilio.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="21" height="21" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.08 0c5.578 0 10.08 4.507 10.08 10.09 0 5.584-4.502 10.09-10.08 10.09A10.072 10.072 0 0 1 0 10.09C0 4.507 4.503 0 10.08 0Zm0 2.69a7.375 7.375 0 0 0-7.392 7.4c0 4.104 3.293 7.4 7.392 7.4 4.1 0 7.392-3.296 7.392-7.4 0-4.103-3.293-7.4-7.392-7.4Zm-2.486 7.804c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086a2.095 2.095 0 0 1-2.083-2.086c0-1.143.94-2.085 2.083-2.085Zm4.973 0c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086a2.095 2.095 0 0 1-2.084-2.086c0-1.143.941-2.085 2.084-2.085Zm0-4.978c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086A2.095 2.095 0 0 1 10.483 7.6c0-1.143.941-2.085 2.084-2.085Zm-4.973 0c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086A2.095 2.095 0 0 1 5.51 7.6c0-1.143.94-2.085 2.083-2.085Z" fill="#F22F46"/></svg>
|
||||||
|
After Width: | Height: | Size: 869 B |
@@ -1,166 +0,0 @@
|
|||||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
|
||||||
import { Modal } from '@/ui/Modal';
|
|
||||||
import Button from '@/ui/v2/Button';
|
|
||||||
import Input from '@/ui/v2/Input';
|
|
||||||
import Text from '@/ui/v2/Text';
|
|
||||||
import { triggerToast } from '@/utils/toast';
|
|
||||||
import type { EnvironmentVariableFragment } from '@/utils/__generated__/graphql';
|
|
||||||
import {
|
|
||||||
refetchGetEnvironmentVariablesWhereQuery,
|
|
||||||
useDeleteEnvironmentVariableMutation,
|
|
||||||
useUpdateEnvironmentVariableMutation,
|
|
||||||
} from '@/utils/__generated__/graphql';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
type EnvModalProps = {
|
|
||||||
show: boolean;
|
|
||||||
close: VoidFunction;
|
|
||||||
envVar: EnvironmentVariableFragment;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function EditEnvVarModal({
|
|
||||||
show,
|
|
||||||
close,
|
|
||||||
envVar,
|
|
||||||
}: EnvModalProps) {
|
|
||||||
const { workspaceContext } = useWorkspaceContext();
|
|
||||||
const { appId } = workspaceContext;
|
|
||||||
|
|
||||||
const [updateEnvVar, { loading: updateLoading }] =
|
|
||||||
useUpdateEnvironmentVariableMutation({
|
|
||||||
refetchQueries: [
|
|
||||||
refetchGetEnvironmentVariablesWhereQuery({
|
|
||||||
where: {
|
|
||||||
appId: {
|
|
||||||
_eq: appId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [deleteEnvVar, { loading: deleteLoading }] =
|
|
||||||
useDeleteEnvironmentVariableMutation({
|
|
||||||
refetchQueries: [
|
|
||||||
refetchGetEnvironmentVariablesWhereQuery({
|
|
||||||
where: {
|
|
||||||
appId: {
|
|
||||||
_eq: appId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [prodValue, setProdValue] = useState(envVar.prodValue || '');
|
|
||||||
const [devValue, setDevValue] = useState(envVar.devValue || '');
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateEnvVar({
|
|
||||||
variables: {
|
|
||||||
id: envVar.id,
|
|
||||||
environmentVariable: {
|
|
||||||
prodValue,
|
|
||||||
devValue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
close();
|
|
||||||
triggerToast('Error updating environment variable');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
triggerToast(`Environment variable ${envVar.name} updated successfully`);
|
|
||||||
close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
try {
|
|
||||||
await deleteEnvVar({
|
|
||||||
variables: {
|
|
||||||
id: envVar.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
close();
|
|
||||||
triggerToast('Error deleting environment variable');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
triggerToast(`Environment variable ${envVar.name} removed successfully`);
|
|
||||||
close();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal showModal={show} close={close}>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="w-modal px-6 py-6 text-left">
|
|
||||||
<div className="grid grid-flow-row gap-1">
|
|
||||||
<Text variant="h3" component="h2">
|
|
||||||
{envVar.name}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text variant="subtitle2">
|
|
||||||
The default value will be available in all environments, unless
|
|
||||||
you override it. All values are encrypted.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div className="my-2 grid grid-flow-row gap-2">
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
label="Name"
|
|
||||||
autoFocus
|
|
||||||
disabled
|
|
||||||
autoComplete="off"
|
|
||||||
defaultValue={envVar.name}
|
|
||||||
fullWidth
|
|
||||||
hideEmptyHelperText
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="prodValue"
|
|
||||||
label="Production Value"
|
|
||||||
fullWidth
|
|
||||||
placeholder="Enter a value"
|
|
||||||
value={prodValue}
|
|
||||||
onChange={(event) => setProdValue(event.target.value)}
|
|
||||||
hideEmptyHelperText
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="devValue"
|
|
||||||
label="Development Value"
|
|
||||||
fullWidth
|
|
||||||
placeholder="Enter a value"
|
|
||||||
value={devValue}
|
|
||||||
onChange={(event) => setDevValue(event.target.value)}
|
|
||||||
hideEmptyHelperText
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-flow-row gap-2">
|
|
||||||
<Button type="submit" loading={updateLoading}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
loading={deleteLoading}
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button onClick={close} variant="outlined" color="secondary">
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import Button from '@/ui/v2/Button';
|
|||||||
import ArrowSquareOutIcon from '@/ui/v2/icons/ArrowSquareOutIcon';
|
import ArrowSquareOutIcon from '@/ui/v2/icons/ArrowSquareOutIcon';
|
||||||
import Link from '@/ui/v2/Link';
|
import Link from '@/ui/v2/Link';
|
||||||
import Text from '@/ui/v2/Text';
|
import Text from '@/ui/v2/Text';
|
||||||
|
import { LOCAL_HASURA_URL } from '@/utils/env';
|
||||||
import { generateRemoteAppUrl } from '@/utils/helpers';
|
import { generateRemoteAppUrl } from '@/utils/helpers';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export function HasuraData({ close }: HasuraDataProps) {
|
|||||||
|
|
||||||
const hasuraUrl =
|
const hasuraUrl =
|
||||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||||
? process.env.NEXT_PUBLIC_NHOST_HASURA_URL || 'http://localhost:9695'
|
? LOCAL_HASURA_URL
|
||||||
: generateRemoteAppUrl(currentApplication.subdomain);
|
: generateRemoteAppUrl(currentApplication.subdomain);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { SelectorOption } from '@/ui/Selector';
|
|
||||||
import Selector from '@/ui/Selector';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Toggle } from '@/ui/Toggle';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
export interface PermissionSettingsProps {
|
|
||||||
text: string;
|
|
||||||
desc?: string;
|
|
||||||
toggle?: boolean;
|
|
||||||
onChange?: any;
|
|
||||||
checked?: boolean;
|
|
||||||
options?: any;
|
|
||||||
value?: SelectorOption;
|
|
||||||
} // @TODO: Fix alt attribute on images.
|
|
||||||
// @FIX: Double border
|
|
||||||
|
|
||||||
export function PermissionSetting({
|
|
||||||
text,
|
|
||||||
desc,
|
|
||||||
toggle,
|
|
||||||
checked = false,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
value,
|
|
||||||
}: PermissionSettingsProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-row place-content-between py-2">
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'flex flex-col space-y-1 self-center px-0.5',
|
|
||||||
!desc && 'py-3.5',
|
|
||||||
desc && 'py-2',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
variant="body"
|
|
||||||
size="normal"
|
|
||||||
className="font-medium"
|
|
||||||
color="greyscaleDark"
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</Text>
|
|
||||||
{desc && (
|
|
||||||
<Text
|
|
||||||
variant="body"
|
|
||||||
size="tiny"
|
|
||||||
className="font-normal"
|
|
||||||
color="greyscaleDark"
|
|
||||||
>
|
|
||||||
{desc}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{toggle ? (
|
|
||||||
<div className="flex flex-row">
|
|
||||||
<Toggle checked={checked} onChange={onChange} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-row self-center">
|
|
||||||
<Selector
|
|
||||||
width="w-28"
|
|
||||||
options={options}
|
|
||||||
onChange={onChange}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { Provider as ProviderType } from '@/types/providers';
|
|
||||||
import Status, { StatusEnum } from '@/ui/Status';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { ChevronRightIcon } from '@heroicons/react/solid';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
|
|
||||||
interface ProviderProps {
|
|
||||||
provider: ProviderType;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Provider({ provider, enabled }: ProviderProps) {
|
|
||||||
const { name, logo } = provider;
|
|
||||||
|
|
||||||
const {
|
|
||||||
query: { workspaceSlug, appSlug },
|
|
||||||
} = useRouter();
|
|
||||||
|
|
||||||
const nameLowerCase = name.toLowerCase();
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/${workspaceSlug}/${appSlug}/settings/sign-in-methods/${nameLowerCase}`}
|
|
||||||
passHref
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`${workspaceSlug}/${appSlug}/settings/sign-in-methods/${nameLowerCase}`}
|
|
||||||
className="flex cursor-pointer flex-row place-content-between border-t py-2.5"
|
|
||||||
>
|
|
||||||
<div className="grid grid-flow-col items-center gap-2">
|
|
||||||
<div className="h-6 w-6">
|
|
||||||
<Image
|
|
||||||
src={logo}
|
|
||||||
alt={`Logo of ${name}`}
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
layout="responsive"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Text className="font-medium" color="greyscaleDark" size="normal">
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row">
|
|
||||||
{enabled ? (
|
|
||||||
<Status status={StatusEnum.Live}>Enabled</Status>
|
|
||||||
) : (
|
|
||||||
<Status status={StatusEnum.Closed}>Disabled</Status>
|
|
||||||
)}
|
|
||||||
<ChevronRightIcon className="ml-2 h-4 w-4 cursor-pointer self-center" />
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Provider;
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
import { CreateUserRoleModal } from '@/components/applications/users/roles/CreateRoleModal';
|
|
||||||
import { EditUserRoleModal } from '@/components/applications/users/roles/EditUserRoleModal';
|
|
||||||
import Lock from '@/components/icons/Lock';
|
|
||||||
import type { GetRolesQuery } from '@/generated/graphql';
|
|
||||||
import { Modal } from '@/ui';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { ChevronRightIcon } from '@heroicons/react/solid';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import type { Dispatch, MouseEvent, MouseEventHandler } from 'react';
|
|
||||||
import { useReducer } from 'react';
|
|
||||||
|
|
||||||
function RolesTableHead() {
|
|
||||||
return (
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="w-64 py-3 text-left font-medium text-base">
|
|
||||||
<Text className="text-xs font-bold text-greyscaleDark">Role</Text>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserRoleProps {
|
|
||||||
role: string;
|
|
||||||
isSystemRole: boolean;
|
|
||||||
onClick?: MouseEventHandler<HTMLTableRowElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserRole({ role, isSystemRole, onClick }: UserRoleProps) {
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
className={clsx(isSystemRole ? 'cursor-not-allowed' : 'cursor-pointer')}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<td className="py-2">
|
|
||||||
<Text
|
|
||||||
size="normal"
|
|
||||||
className={clsx(
|
|
||||||
isSystemRole ? 'text-greyscaleGrey' : 'text-greyscaleDark',
|
|
||||||
'pl-1 font-medium',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{role}
|
|
||||||
</Text>
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{isSystemRole ? (
|
|
||||||
<div className="inline-flex pr-1">
|
|
||||||
<Text
|
|
||||||
size="tiny"
|
|
||||||
className=" font-mono text-xs font-medium uppercase tracking-wide text-greyscaleGrey"
|
|
||||||
>
|
|
||||||
System Role
|
|
||||||
</Text>
|
|
||||||
<Lock className="ml-1 h-5 w-5 text-greyscaleGrey" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="inline-flex self-center py-2 pr-1.5">
|
|
||||||
<ChevronRightIcon className="h-4.5 w-4.5 text-greyscaleDark" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UserRoleDetails = {
|
|
||||||
name: string;
|
|
||||||
isSystemRole: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUserRoles = (data): UserRoleDetails[] => {
|
|
||||||
const authUserDefaultAllowedRoles =
|
|
||||||
data.app.authUserDefaultAllowedRoles.split(',');
|
|
||||||
|
|
||||||
return authUserDefaultAllowedRoles.map((role: string) => ({
|
|
||||||
name: role,
|
|
||||||
isSystemRole: ['user', 'me'].includes(role),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
type ModalState = {
|
|
||||||
visible: boolean;
|
|
||||||
type: 'create' | 'edit';
|
|
||||||
payload: UserRoleDetails;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ModalAction = {
|
|
||||||
type: 'OPEN_CREATE_MODAL' | 'OPEN_EDIT_MODAL' | 'CLOSE_MODAL';
|
|
||||||
payload?: UserRoleDetails;
|
|
||||||
};
|
|
||||||
|
|
||||||
function modalStateReducer(state: ModalState, action: ModalAction): ModalState {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'OPEN_CREATE_MODAL':
|
|
||||||
return { ...state, visible: true, type: 'create', payload: null };
|
|
||||||
case 'OPEN_EDIT_MODAL':
|
|
||||||
return { ...state, visible: true, type: 'edit', payload: action.payload };
|
|
||||||
case 'CLOSE_MODAL':
|
|
||||||
return { ...state, visible: false };
|
|
||||||
default:
|
|
||||||
throw new Error(`Action type ${action.type} is not supported.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddNewUserRole({ dispatch }: { dispatch: Dispatch<ModalAction> }) {
|
|
||||||
return (
|
|
||||||
<tr className="cursor-pointer border-y-1 border-solid border-gray-300">
|
|
||||||
<td className="p-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => dispatch({ type: 'OPEN_CREATE_MODAL' })}
|
|
||||||
>
|
|
||||||
<Text className="text-sm+ font-medium text-blue">
|
|
||||||
Create New Role
|
|
||||||
</Text>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td />
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RolesTableBody({ data }: { data: GetRolesQuery }) {
|
|
||||||
const userRoles = getUserRoles(data);
|
|
||||||
const [
|
|
||||||
{ visible: modalVisible, type: modalType, payload: modalPayload },
|
|
||||||
dispatch,
|
|
||||||
] = useReducer(modalStateReducer, {
|
|
||||||
visible: false,
|
|
||||||
type: null,
|
|
||||||
payload: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleRoleEdit(event: MouseEvent<HTMLTableRowElement>, role: any) {
|
|
||||||
dispatch({ type: 'OPEN_EDIT_MODAL', payload: role });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
showModal={modalVisible}
|
|
||||||
close={() => dispatch({ type: 'CLOSE_MODAL' })}
|
|
||||||
>
|
|
||||||
{modalType === 'create' ? (
|
|
||||||
<CreateUserRoleModal
|
|
||||||
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EditUserRoleModal
|
|
||||||
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
|
|
||||||
payload={modalPayload}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
<tbody className="divide-y-1 border-t-1 border-b-1 border-solid border-gray-300 ">
|
|
||||||
{userRoles.map((role) => (
|
|
||||||
<UserRole
|
|
||||||
key={role.name}
|
|
||||||
role={role.name}
|
|
||||||
isSystemRole={role.isSystemRole}
|
|
||||||
onClick={
|
|
||||||
role.isSystemRole
|
|
||||||
? undefined
|
|
||||||
: (event) => handleRoleEdit(event, role)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<AddNewUserRole dispatch={dispatch} />
|
|
||||||
</tbody>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RolesTable({ data }: { data: GetRolesQuery }) {
|
|
||||||
return (
|
|
||||||
<table className="w-full table-fixed overflow-x-auto">
|
|
||||||
<RolesTableHead />
|
|
||||||
<RolesTableBody data={data} />
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import type { TextProps } from '@/ui/Text';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import type { PropsWithChildren, ReactNode } from 'react';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
export interface SettingsSectionProps {
|
|
||||||
/**
|
|
||||||
* Title of this section.
|
|
||||||
*/
|
|
||||||
title: ReactNode;
|
|
||||||
/**
|
|
||||||
* Props to be passed to the title component.
|
|
||||||
*/
|
|
||||||
titleProps?: TextProps;
|
|
||||||
/**
|
|
||||||
* Props to be passed to the wrapper component.
|
|
||||||
*/
|
|
||||||
wrapperProps?: TextProps;
|
|
||||||
/**
|
|
||||||
* Description of this section.
|
|
||||||
*/
|
|
||||||
desc?: ReactNode;
|
|
||||||
/**
|
|
||||||
* Props to be passed to the description component.
|
|
||||||
*/
|
|
||||||
descriptionProps?: TextProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsSection({
|
|
||||||
children,
|
|
||||||
title,
|
|
||||||
titleProps,
|
|
||||||
descriptionProps,
|
|
||||||
desc,
|
|
||||||
wrapperProps,
|
|
||||||
}: PropsWithChildren<SettingsSectionProps>) {
|
|
||||||
const { className: titleClassName, ...restTitleProps } = titleProps || {};
|
|
||||||
const { className: wrapperClassName } = wrapperProps || {};
|
|
||||||
const { className: descriptionClassName, ...restDescriptionProps } =
|
|
||||||
descriptionProps || {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={twMerge('mt-10', wrapperClassName)}>
|
|
||||||
<div className="mx-auto font-display">
|
|
||||||
<div className="flex flex-col place-content-between">
|
|
||||||
<div>
|
|
||||||
<Text
|
|
||||||
size="large"
|
|
||||||
variant="heading"
|
|
||||||
className={twMerge('mb-1.5 font-medium', titleClassName)}
|
|
||||||
color="greyscaleDark"
|
|
||||||
{...restTitleProps}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{desc && (
|
|
||||||
<Text
|
|
||||||
variant="body"
|
|
||||||
size="normal"
|
|
||||||
color="greyscaleDark"
|
|
||||||
className={twMerge('mb-3 font-normal', descriptionClassName)}
|
|
||||||
{...restDescriptionProps}
|
|
||||||
>
|
|
||||||
{desc}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
|
||||||
import useCustomClaims from '@/hooks/useCustomClaims';
|
|
||||||
import type { CustomClaim } from '@/types/application';
|
|
||||||
import { Alert } from '@/ui/Alert';
|
|
||||||
import { triggerToast } from '@/utils/toast';
|
|
||||||
import {
|
|
||||||
refetchGetAppCustomClaimsQuery,
|
|
||||||
useUpdateAppMutation,
|
|
||||||
} from '@/utils/__generated__/graphql';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
|
||||||
import type {
|
|
||||||
CreatePermissionVariableBaseFormData,
|
|
||||||
CreatePermissionVariableModalBaseProps,
|
|
||||||
} from './CreatePermissionVariableModalBase';
|
|
||||||
import CreatePermissionVariableModalBase from './CreatePermissionVariableModalBase';
|
|
||||||
|
|
||||||
export type CreatePermissionVariableFormData =
|
|
||||||
CreatePermissionVariableBaseFormData;
|
|
||||||
|
|
||||||
export type CreatePermissionVariableModalProps = Pick<
|
|
||||||
CreatePermissionVariableModalBaseProps,
|
|
||||||
'onClose'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export default function CreatePermissionVariableModal({
|
|
||||||
onClose,
|
|
||||||
}: CreatePermissionVariableModalProps) {
|
|
||||||
const [error, setError] = useState<Error>();
|
|
||||||
|
|
||||||
const form = useForm<CreatePermissionVariableFormData>({
|
|
||||||
reValidateMode: 'onSubmit',
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
workspaceContext: { appId },
|
|
||||||
} = useWorkspaceContext();
|
|
||||||
|
|
||||||
const { data: customClaims } = useCustomClaims({ appId });
|
|
||||||
|
|
||||||
const [updateApp] = useUpdateAppMutation({
|
|
||||||
refetchQueries: [refetchGetAppCustomClaimsQuery({ id: appId })],
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleSubmit(permissionVariable: CustomClaim) {
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
customClaims.some(
|
|
||||||
(claim) =>
|
|
||||||
claim.key.toLowerCase() === permissionVariable.key.toLowerCase(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
'Permission variable with this field name already exists.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateApp({
|
|
||||||
variables: {
|
|
||||||
id: appId,
|
|
||||||
app: {
|
|
||||||
authJwtCustomClaims: [...customClaims, permissionVariable]
|
|
||||||
.filter((claim) => !claim.system)
|
|
||||||
.reduce(
|
|
||||||
(authJwtCustomClaims, claim) => ({
|
|
||||||
...authJwtCustomClaims,
|
|
||||||
[claim.key]: claim.value,
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
triggerToast('Permission variable created');
|
|
||||||
|
|
||||||
if (!onClose) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
} catch (updateError) {
|
|
||||||
if (updateError instanceof Error) {
|
|
||||||
setError(updateError);
|
|
||||||
} else {
|
|
||||||
setError(new Error(updateError));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<CreatePermissionVariableModalBase
|
|
||||||
title="Create Permission Variable"
|
|
||||||
type="create"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onClose={onClose}
|
|
||||||
errorComponent={
|
|
||||||
error && (
|
|
||||||
<Alert className="mt-4" severity="error">
|
|
||||||
{error.message}
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import Button from '@/ui/v2/Button';
|
|
||||||
import Input from '@/ui/v2/Input';
|
|
||||||
import Text from '@/ui/v2/Text';
|
|
||||||
import type { ChangeEvent, MouseEventHandler, ReactNode } from 'react';
|
|
||||||
import type { SubmitHandler } from 'react-hook-form';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
|
|
||||||
export interface CreatePermissionVariableBaseFormData {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateModalBaseProps<T> {
|
|
||||||
/**
|
|
||||||
* Title of this modal.
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
/**
|
|
||||||
* Type of this modal.
|
|
||||||
*/
|
|
||||||
type?: 'create' | 'edit';
|
|
||||||
/**
|
|
||||||
* Callback to be called when the modal is closed.
|
|
||||||
*/
|
|
||||||
onClose?: VoidFunction;
|
|
||||||
/**
|
|
||||||
* Callback to be called when remove button is clicked.
|
|
||||||
*/
|
|
||||||
onRemove?: MouseEventHandler<HTMLButtonElement>;
|
|
||||||
/**
|
|
||||||
* Callback to be called when the form is submitted.
|
|
||||||
*/
|
|
||||||
onSubmit: SubmitHandler<T>;
|
|
||||||
/**
|
|
||||||
* Error to be displayed.
|
|
||||||
*/
|
|
||||||
errorComponent?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreatePermissionVariableModalBaseProps =
|
|
||||||
CreateModalBaseProps<CreatePermissionVariableBaseFormData>;
|
|
||||||
|
|
||||||
export default function CreatePermissionVariableModalBase({
|
|
||||||
title,
|
|
||||||
type,
|
|
||||||
onClose,
|
|
||||||
onRemove,
|
|
||||||
onSubmit,
|
|
||||||
errorComponent,
|
|
||||||
}: CreatePermissionVariableModalBaseProps) {
|
|
||||||
const {
|
|
||||||
handleSubmit,
|
|
||||||
watch,
|
|
||||||
register,
|
|
||||||
formState: { isSubmitting, errors },
|
|
||||||
} = useFormContext<CreatePermissionVariableBaseFormData>();
|
|
||||||
|
|
||||||
const keyHandlers = register('key', {
|
|
||||||
required: true,
|
|
||||||
pattern: {
|
|
||||||
value: /^[a-zA-Z-]+$/i,
|
|
||||||
message: 'Must contain only letters and hyphens',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const valueHandlers = register('value', {
|
|
||||||
required: true,
|
|
||||||
pattern: {
|
|
||||||
value: /^[a-zA-Z0-9._[\]]+$/i,
|
|
||||||
message: 'Must contain only letters, dots, brackets, and underscores',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isComplete = !!watch('key') && !!watch('value');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-modal p-6 text-left">
|
|
||||||
<div className="grid w-full grid-flow-col items-center justify-between">
|
|
||||||
<Text variant="h3" component="h2">
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{type === 'edit' && onRemove && (
|
|
||||||
<Button variant="borderless" color="error" onClick={onRemove}>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Text className="mt-2 text-sm+ text-greyscaleDark">
|
|
||||||
Enter the field name and the path you want to use in this permission
|
|
||||||
variable.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{errorComponent}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
|
|
||||||
<div className="my-4 grid grid-flow-row divide-y-1 divide-solid divide-gray-200 border-y border-gray-200">
|
|
||||||
<Input
|
|
||||||
{...keyHandlers}
|
|
||||||
value={watch('key')}
|
|
||||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (
|
|
||||||
event.target.value &&
|
|
||||||
!/^[a-zA-Z-]+$/gi.test(event.target.value)
|
|
||||||
) {
|
|
||||||
// prevent the user from entering invalid characters
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
keyHandlers.onChange(event);
|
|
||||||
}}
|
|
||||||
id="key"
|
|
||||||
variant="inline"
|
|
||||||
inlineInputProportion="66%"
|
|
||||||
label="Field name"
|
|
||||||
fullWidth
|
|
||||||
startAdornment={
|
|
||||||
<Text className="min-w-[73px] text-sm+ text-greyscaleGrey">
|
|
||||||
X-Hasura-
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
componentsProps={{
|
|
||||||
inputWrapper: { className: 'my-1' },
|
|
||||||
input: {
|
|
||||||
className: 'border-transparent focus-within:border-solid pl-2',
|
|
||||||
},
|
|
||||||
inputRoot: { className: '!pl-[1px]' },
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
error={!!errors?.key?.message}
|
|
||||||
helperText={errors?.key?.message}
|
|
||||||
hideEmptyHelperText
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
{...valueHandlers}
|
|
||||||
value={watch('value')}
|
|
||||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (
|
|
||||||
event.target.value &&
|
|
||||||
!/^[a-zA-Z-]+$/gi.test(event.target.value)
|
|
||||||
) {
|
|
||||||
// prevent the user from entering invalid characters
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
valueHandlers.onChange(event);
|
|
||||||
}}
|
|
||||||
id="value"
|
|
||||||
variant="inline"
|
|
||||||
inlineInputProportion="66%"
|
|
||||||
label="Path"
|
|
||||||
fullWidth
|
|
||||||
startAdornment={
|
|
||||||
<Text className="text-sm+ text-greyscaleGrey">user.</Text>
|
|
||||||
}
|
|
||||||
componentsProps={{
|
|
||||||
inputWrapper: { className: 'my-1' },
|
|
||||||
input: {
|
|
||||||
className: 'border-transparent focus-within:border-solid pl-2',
|
|
||||||
},
|
|
||||||
inputRoot: { className: '!pl-[1px]' },
|
|
||||||
}}
|
|
||||||
error={!!errors?.value?.message}
|
|
||||||
helperText={errors?.value?.message}
|
|
||||||
hideEmptyHelperText
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={isSubmitting}
|
|
||||||
disabled={isSubmitting || !isComplete}
|
|
||||||
>
|
|
||||||
{type === 'create' ? 'Create Permission Variable' : 'Save Changes'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="outlined" color="secondary" onClick={onClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
|
||||||
import useCustomClaims from '@/hooks/useCustomClaims';
|
|
||||||
import type { CustomClaim } from '@/types/application';
|
|
||||||
import { Alert } from '@/ui/Alert';
|
|
||||||
import { Modal } from '@/ui/Modal';
|
|
||||||
import Button from '@/ui/v2/Button';
|
|
||||||
import Text from '@/ui/v2/Text';
|
|
||||||
import { triggerToast } from '@/utils/toast';
|
|
||||||
import {
|
|
||||||
refetchGetAppCustomClaimsQuery,
|
|
||||||
useUpdateAppMutation,
|
|
||||||
} from '@/utils/__generated__/graphql';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
|
||||||
import type {
|
|
||||||
CreatePermissionVariableBaseFormData,
|
|
||||||
CreatePermissionVariableModalBaseProps,
|
|
||||||
} from './CreatePermissionVariableModalBase';
|
|
||||||
import CreatePermissionVariableModalBase from './CreatePermissionVariableModalBase';
|
|
||||||
|
|
||||||
export type EditPermissionVariableFormData =
|
|
||||||
CreatePermissionVariableBaseFormData;
|
|
||||||
|
|
||||||
export type EditPermissionVariableModalProps = Pick<
|
|
||||||
CreatePermissionVariableModalBaseProps,
|
|
||||||
'onClose'
|
|
||||||
> & {
|
|
||||||
/**
|
|
||||||
* The permission variable to edit.
|
|
||||||
*/
|
|
||||||
payload: CustomClaim;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function EditPermissionVariableModal({
|
|
||||||
payload: originalCustomClaim,
|
|
||||||
...props
|
|
||||||
}: EditPermissionVariableModalProps) {
|
|
||||||
const [error, setError] = useState<Error>();
|
|
||||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<EditPermissionVariableFormData>({
|
|
||||||
reValidateMode: 'onSubmit',
|
|
||||||
defaultValues: {
|
|
||||||
key: originalCustomClaim.key || '',
|
|
||||||
value: originalCustomClaim.value || '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
workspaceContext: { appId },
|
|
||||||
} = useWorkspaceContext();
|
|
||||||
|
|
||||||
const { data: customClaims } = useCustomClaims({ appId });
|
|
||||||
|
|
||||||
const [updateApp] = useUpdateAppMutation({
|
|
||||||
refetchQueries: [refetchGetAppCustomClaimsQuery({ id: appId })],
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleSubmit(permissionVariable: CustomClaim) {
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
originalCustomClaim.key.toLowerCase() !==
|
|
||||||
permissionVariable.key.toLowerCase() &&
|
|
||||||
customClaims.some(
|
|
||||||
(claim) =>
|
|
||||||
claim.key.toLowerCase() === permissionVariable.key.toLowerCase(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
'Permission variable with this field name already exists.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// we need to preserve the original position of the permission variable
|
|
||||||
const currentIndex = customClaims.findIndex(
|
|
||||||
(claim) =>
|
|
||||||
claim.key.toLowerCase() === originalCustomClaim.key.toLowerCase(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await updateApp({
|
|
||||||
variables: {
|
|
||||||
id: appId,
|
|
||||||
app: {
|
|
||||||
authJwtCustomClaims: customClaims
|
|
||||||
.slice(0, currentIndex)
|
|
||||||
.concat(permissionVariable)
|
|
||||||
.concat(customClaims.slice(currentIndex + 1))
|
|
||||||
.filter((claim) => !claim.system)
|
|
||||||
.reduce(
|
|
||||||
(authJwtCustomClaims, claim) => ({
|
|
||||||
...authJwtCustomClaims,
|
|
||||||
[claim.key]: claim.value,
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
triggerToast(`Permission variable updated`);
|
|
||||||
|
|
||||||
if (props.onClose) {
|
|
||||||
props.onClose();
|
|
||||||
}
|
|
||||||
} catch (updateError) {
|
|
||||||
if (updateError instanceof Error) {
|
|
||||||
setError(updateError);
|
|
||||||
} else {
|
|
||||||
setError(new Error(updateError));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemove() {
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateApp({
|
|
||||||
variables: {
|
|
||||||
id: appId,
|
|
||||||
app: {
|
|
||||||
authJwtCustomClaims: customClaims
|
|
||||||
.filter(
|
|
||||||
(claim) =>
|
|
||||||
claim.key !== originalCustomClaim.key && !claim.system,
|
|
||||||
)
|
|
||||||
.reduce(
|
|
||||||
(authJwtCustomClaims, claim) => ({
|
|
||||||
...authJwtCustomClaims,
|
|
||||||
[claim.key]: claim.value,
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setShowRemoveModal(false);
|
|
||||||
|
|
||||||
triggerToast('Permission variable removed');
|
|
||||||
|
|
||||||
if (props.onClose) {
|
|
||||||
props.onClose();
|
|
||||||
}
|
|
||||||
} catch (updateError) {
|
|
||||||
if (updateError instanceof Error) {
|
|
||||||
setError(updateError);
|
|
||||||
} else {
|
|
||||||
setError(new Error(updateError));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
showModal={showRemoveModal}
|
|
||||||
close={() => setShowRemoveModal(false)}
|
|
||||||
>
|
|
||||||
<div className="grid w-96 grid-flow-row gap-2 p-6 text-left text-greyscaleDark">
|
|
||||||
<Text variant="h3" component="h2">
|
|
||||||
Remove {originalCustomClaim.key}?
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text>You will not be able to use it in permissions anymore.</Text>
|
|
||||||
|
|
||||||
<Text>
|
|
||||||
If you have permission checks currently using this property, they
|
|
||||||
will never resolve to true.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div className="mt-2 grid grid-flow-row gap-2">
|
|
||||||
<Button color="error" onClick={handleRemove} className="w-full">
|
|
||||||
Remove Permission Variable
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
onClick={() => setShowRemoveModal(false)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<CreatePermissionVariableModalBase
|
|
||||||
title="Edit Permission Variable"
|
|
||||||
type="edit"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onRemove={() => setShowRemoveModal(true)}
|
|
||||||
errorComponent={
|
|
||||||
error && (
|
|
||||||
<Alert className="mt-4" severity="error">
|
|
||||||
{error.message}
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</FormProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
|
||||||
import { Alert } from '@/ui/Alert';
|
|
||||||
import Loading from '@/ui/Loading';
|
|
||||||
import {
|
|
||||||
refetchGetRolesQuery,
|
|
||||||
useGetRolesQuery,
|
|
||||||
useUpdateAppMutation,
|
|
||||||
} from '@/utils/__generated__/graphql';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
CreateUserRoleBaseFormData,
|
|
||||||
CreateUserRoleModalBaseProps,
|
|
||||||
} from './CreateUserRoleModalBase';
|
|
||||||
import { CreateUserRoleModalBase } from './CreateUserRoleModalBase';
|
|
||||||
|
|
||||||
export type CreateUserRoleFormData = CreateUserRoleBaseFormData;
|
|
||||||
|
|
||||||
export type CreateUserRoleModalProps = Pick<
|
|
||||||
CreateUserRoleModalBaseProps,
|
|
||||||
'onClose'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function CreateUserRoleModal({ onClose }: CreateUserRoleModalProps) {
|
|
||||||
const [error, setError] = useState<Error>();
|
|
||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
|
||||||
|
|
||||||
const form = useForm<CreateUserRoleBaseFormData>({
|
|
||||||
reValidateMode: 'onSubmit',
|
|
||||||
});
|
|
||||||
|
|
||||||
const [updateApp] = useUpdateAppMutation({
|
|
||||||
refetchQueries: [refetchGetRolesQuery({ id: currentApplication.id })],
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: currentRolesData,
|
|
||||||
loading,
|
|
||||||
error: getRolesError,
|
|
||||||
} = useGetRolesQuery({
|
|
||||||
variables: {
|
|
||||||
id: currentApplication.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getRolesError) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-2.5xl">
|
|
||||||
<Alert severity="error">{error.message}</Alert>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(data) {
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
const newAuthUserDefaultAllowedRoles = `${currentRolesData.app.authUserDefaultAllowedRoles},${data.roleName}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateApp({
|
|
||||||
variables: {
|
|
||||||
id: currentApplication.id,
|
|
||||||
app: {
|
|
||||||
authUserDefaultAllowedRoles: newAuthUserDefaultAllowedRoles,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!onClose) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
} catch (updateError) {
|
|
||||||
setError(updateError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<CreateUserRoleModalBase
|
|
||||||
title="Create New Role"
|
|
||||||
type="create"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onClose={onClose}
|
|
||||||
errorComponent={
|
|
||||||
error && (
|
|
||||||
<Alert className="mt-4" severity="error">
|
|
||||||
{error.message}
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import type { CreateModalBaseProps } from '@/components/applications/users/permissions/modal/CreatePermissionVariableModalBase';
|
|
||||||
import { Input } from '@/ui';
|
|
||||||
import { Button } from '@/ui/Button';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Controller, useFormContext } from 'react-hook-form';
|
|
||||||
|
|
||||||
export interface CreateUserRoleBaseFormData {
|
|
||||||
roleName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateUserRoleModalBaseProps =
|
|
||||||
CreateModalBaseProps<CreateUserRoleBaseFormData>;
|
|
||||||
export type CreateUserRoleModal = Pick<CreateUserRoleModalBaseProps, 'onClose'>;
|
|
||||||
|
|
||||||
export function CreateUserRoleModalBase({
|
|
||||||
title,
|
|
||||||
type,
|
|
||||||
onRemove,
|
|
||||||
onSubmit,
|
|
||||||
errorComponent,
|
|
||||||
}: CreateUserRoleModalBaseProps) {
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { isSubmitting },
|
|
||||||
} = useFormContext<CreateUserRoleBaseFormData>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-modal- p-6 text-left">
|
|
||||||
<div className="mx-auto items-center justify-between">
|
|
||||||
<Text
|
|
||||||
variant="heading"
|
|
||||||
className="text-center text-lg font-medium text-greyscaleDark"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{errorComponent}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
|
|
||||||
<div className="mt-3 mb-3 divide-y border-t border-b py-1">
|
|
||||||
<div className="flex flex-row place-content-between py-2">
|
|
||||||
<div className="flex w-full flex-row">
|
|
||||||
<Text
|
|
||||||
color="greyscaleDark"
|
|
||||||
className="self-center font-medium"
|
|
||||||
size="normal"
|
|
||||||
>
|
|
||||||
New Role Name
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full">
|
|
||||||
<Controller
|
|
||||||
name="roleName"
|
|
||||||
control={control}
|
|
||||||
rules={{
|
|
||||||
required: true,
|
|
||||||
pattern: {
|
|
||||||
value: /^[a-zA-Z0-9-_]+$/,
|
|
||||||
message: 'Must contain only letters, hyphens, and numbers.',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
id="roleName"
|
|
||||||
required
|
|
||||||
value={field.value || ''}
|
|
||||||
onChange={(value: string) => {
|
|
||||||
if (value && !/^[a-zA-Z0-9-_]+$/gi.test(value)) {
|
|
||||||
// prevent the user from entering invalid characters
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
field.onChange(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
type="submit"
|
|
||||||
loading={isSubmitting}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{type === 'create' ? 'Create New User Role' : 'Save Changes'}
|
|
||||||
</Button>
|
|
||||||
{type === 'edit' && onRemove && (
|
|
||||||
<Button variant="menu" border onClick={onRemove}>
|
|
||||||
<Text className="text-sm+ font-medium text-red">Remove Role</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import type { GetRolesQuery } from '@/generated/graphql';
|
|
||||||
import {
|
|
||||||
refetchGetRolesQuery,
|
|
||||||
useGetRolesQuery,
|
|
||||||
useUpdateAppMutation,
|
|
||||||
} from '@/generated/graphql';
|
|
||||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
|
||||||
import { Alert } from '@/ui/Alert';
|
|
||||||
import { Button } from '@/ui/Button';
|
|
||||||
import Loading from '@/ui/Loading';
|
|
||||||
import { Modal } from '@/ui/Modal';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { triggerToast } from '@/utils/toast';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
CreateUserRoleBaseFormData,
|
|
||||||
CreateUserRoleModalBaseProps,
|
|
||||||
} from './CreateUserRoleModalBase';
|
|
||||||
import { CreateUserRoleModalBase } from './CreateUserRoleModalBase';
|
|
||||||
|
|
||||||
export type EditUserRoleFormData = CreateUserRoleBaseFormData;
|
|
||||||
|
|
||||||
export type EditUserRoleModalProps = Pick<
|
|
||||||
CreateUserRoleModalBaseProps,
|
|
||||||
'onClose'
|
|
||||||
> & {
|
|
||||||
/**
|
|
||||||
* The permission variable to edit.
|
|
||||||
*/
|
|
||||||
payload: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EditUserRoleModal({
|
|
||||||
payload: originalRole,
|
|
||||||
...props
|
|
||||||
}: EditUserRoleModalProps) {
|
|
||||||
const [error, setError] = useState<Error>();
|
|
||||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
|
||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
|
||||||
const form = useForm<EditUserRoleFormData>({
|
|
||||||
reValidateMode: 'onSubmit',
|
|
||||||
defaultValues: {
|
|
||||||
roleName: originalRole.name || '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [updateApp, { loading: loadingUpdateAppMutation }] =
|
|
||||||
useUpdateAppMutation({
|
|
||||||
refetchQueries: [refetchGetRolesQuery({ id: currentApplication.id })],
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: currentRolesData,
|
|
||||||
loading,
|
|
||||||
error: getRolesError,
|
|
||||||
} = useGetRolesQuery({
|
|
||||||
variables: {
|
|
||||||
id: currentApplication.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getRolesError) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-2.5xl">
|
|
||||||
<Alert severity="error">{error.message}</Alert>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(data: EditUserRoleFormData) {
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
const currentUserRoles =
|
|
||||||
currentRolesData.app.authUserDefaultAllowedRoles.split(',');
|
|
||||||
|
|
||||||
const roleBeingEdited = currentUserRoles.find(
|
|
||||||
(role) => role === originalRole.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
const indexofRoleBeingEdited = currentUserRoles.indexOf(roleBeingEdited);
|
|
||||||
const newRoleName = data.roleName;
|
|
||||||
|
|
||||||
const newAuthUserDefaultAllowedRoles = currentUserRoles.slice();
|
|
||||||
|
|
||||||
if (data.roleName !== originalRole.name) {
|
|
||||||
newAuthUserDefaultAllowedRoles[indexofRoleBeingEdited] = newRoleName;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateApp({
|
|
||||||
variables: {
|
|
||||||
id: currentApplication.id,
|
|
||||||
app: {
|
|
||||||
authUserDefaultAllowedRoles:
|
|
||||||
newAuthUserDefaultAllowedRoles.join(','),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
triggerToast(`Role "${data.roleName}" updated successfully`);
|
|
||||||
props.onClose();
|
|
||||||
} catch (updateError) {
|
|
||||||
setError(updateError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemove(data: GetRolesQuery) {
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
// Get the current roles of this application.
|
|
||||||
const currentUserRoles = data.app.authUserDefaultAllowedRoles.split(',');
|
|
||||||
|
|
||||||
// Remove the role from the current roles.
|
|
||||||
const filteredCurrentUserRoles = currentUserRoles.filter(
|
|
||||||
(role) => role !== originalRole.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
const newAuthUserDefaultAllowedRoles = filteredCurrentUserRoles.join(',');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateApp({
|
|
||||||
variables: {
|
|
||||||
id: currentApplication.id,
|
|
||||||
app: {
|
|
||||||
authUserDefaultAllowedRoles: newAuthUserDefaultAllowedRoles,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
props.onClose();
|
|
||||||
triggerToast(`Role "${originalRole.name}" removed successfully`);
|
|
||||||
} catch (updateError) {
|
|
||||||
setError(updateError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
showModal={showRemoveModal}
|
|
||||||
close={() => setShowRemoveModal(false)}
|
|
||||||
>
|
|
||||||
<div className="px-6 pt-5 text-center text-greyscaleDark">
|
|
||||||
<Text variant="heading" className="mb-2 text-lg font-medium">
|
|
||||||
Remove Role "{originalRole.name}"?
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div className="my-4">
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
onClick={() => handleRemove(currentRolesData)}
|
|
||||||
className="w-full"
|
|
||||||
loading={loadingUpdateAppMutation}
|
|
||||||
>
|
|
||||||
Remove Role
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowRemoveModal(false)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<CreateUserRoleModalBase
|
|
||||||
title="Edit Role"
|
|
||||||
type="edit"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onRemove={() => setShowRemoveModal(true)}
|
|
||||||
errorComponent={
|
|
||||||
error && (
|
|
||||||
<Alert className="mt-4" severity="error">
|
|
||||||
{error.message}
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</FormProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import { Alert } from '@/ui/Alert';
|
|
||||||
import Button from '@/ui/v2/Button';
|
|
||||||
import Input from '@/ui/v2/Input';
|
|
||||||
import Text from '@/ui/v2/Text';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
type EnvModalProps = {
|
|
||||||
onSubmit: (props: {
|
|
||||||
name: string;
|
|
||||||
prodValue: string;
|
|
||||||
devValue: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
name?: string;
|
|
||||||
prodValue?: string;
|
|
||||||
devValue?: string;
|
|
||||||
close: VoidFunction;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AddEnvVarModalVariablesError {
|
|
||||||
hasError: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DISABLED_START_ENV_VARIABLES = [
|
|
||||||
'NHOST_',
|
|
||||||
'HASURA_',
|
|
||||||
'AUTH_',
|
|
||||||
'STORAGE_',
|
|
||||||
'POSTGRES_',
|
|
||||||
];
|
|
||||||
|
|
||||||
const DISABLED_ENV_VARIABLES = [
|
|
||||||
'PATH',
|
|
||||||
'NODE_PATH',
|
|
||||||
'PYTHONPATH',
|
|
||||||
'GEM_PATH',
|
|
||||||
'HOSTNAME',
|
|
||||||
'TERM',
|
|
||||||
'NODE_VERSION',
|
|
||||||
'YARN_VERSION',
|
|
||||||
'NODE_ENV',
|
|
||||||
'HOME',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AddEnvVarModal({
|
|
||||||
name: externalName,
|
|
||||||
prodValue: externalProdValue,
|
|
||||||
devValue: externalDevValue,
|
|
||||||
close,
|
|
||||||
onSubmit,
|
|
||||||
}: EnvModalProps) {
|
|
||||||
const [name, setName] = useState(externalName || '');
|
|
||||||
const [prodValue, setProdValue] = useState(externalProdValue || '');
|
|
||||||
const [devValue, setDevValue] = useState(externalDevValue || '');
|
|
||||||
const [error, setError] = useState<AddEnvVarModalVariablesError>({
|
|
||||||
hasError: false,
|
|
||||||
message: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const noError: AddEnvVarModalVariablesError = {
|
|
||||||
hasError: false,
|
|
||||||
message: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
|
||||||
setError({ hasError: false, message: '' });
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (
|
|
||||||
DISABLED_START_ENV_VARIABLES.some((envVar) =>
|
|
||||||
name.toUpperCase().startsWith(envVar),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setError({
|
|
||||||
hasError: true,
|
|
||||||
message:
|
|
||||||
'The environment variable name cannot start with a value that is reserved for an internal environment variable.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
DISABLED_ENV_VARIABLES.some((envVar) => envVar === name.toUpperCase())
|
|
||||||
) {
|
|
||||||
setError({
|
|
||||||
hasError: true,
|
|
||||||
message:
|
|
||||||
'The environment variable name cannot be a value that is reserved for internal use.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// only allow alphabet characters and underscores
|
|
||||||
const onlyLettersWithNumbersStartsWithLetter = /^[a-zA-Z_]+[a-zA-Z0-9_]*$/;
|
|
||||||
if (!onlyLettersWithNumbersStartsWithLetter.test(name)) {
|
|
||||||
setError({
|
|
||||||
hasError: true,
|
|
||||||
message:
|
|
||||||
'The name contains invalid characters. Only letters, digits, and underscores are allowed. Furthermore, the name should start with a letter.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
setError({ hasError: true, message: 'Variable name is required.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!prodValue) {
|
|
||||||
setError({ hasError: true, message: 'Production value is required.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!devValue) {
|
|
||||||
setError({ hasError: true, message: 'Development value is required.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await onSubmit({
|
|
||||||
name,
|
|
||||||
prodValue,
|
|
||||||
devValue,
|
|
||||||
});
|
|
||||||
close();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="w-modal px-6 py-6 text-left">
|
|
||||||
<div className="grid grid-flow-row gap-1">
|
|
||||||
<Text variant="h3" component="h2">
|
|
||||||
{name || 'EXAMPLE_NAME'}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text variant="subtitle2">
|
|
||||||
The default value will be available in all environments, unless you
|
|
||||||
override it. All values are encrypted.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div className="my-2 grid grid-flow-row gap-2">
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
label="Name"
|
|
||||||
autoFocus
|
|
||||||
autoComplete="off"
|
|
||||||
fullWidth
|
|
||||||
placeholder="EXAMPLE_NAME"
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => {
|
|
||||||
setError(noError);
|
|
||||||
setName(event.target.value);
|
|
||||||
}}
|
|
||||||
hideEmptyHelperText
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="prodValue"
|
|
||||||
label="Production Value"
|
|
||||||
fullWidth
|
|
||||||
placeholder="Enter a value"
|
|
||||||
value={prodValue}
|
|
||||||
onChange={(event) => {
|
|
||||||
setError(noError);
|
|
||||||
setProdValue(event.target.value);
|
|
||||||
}}
|
|
||||||
hideEmptyHelperText
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="devValue"
|
|
||||||
label="Development Value"
|
|
||||||
fullWidth
|
|
||||||
placeholder="Enter a value"
|
|
||||||
value={devValue}
|
|
||||||
onChange={(event) => {
|
|
||||||
setError(noError);
|
|
||||||
setDevValue(event.target.value);
|
|
||||||
}}
|
|
||||||
hideEmptyHelperText
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error.hasError && (
|
|
||||||
<Alert severity="warning" className="mb-2">
|
|
||||||
<Text className="font-medium">Warning</Text>
|
|
||||||
<Text>{error.message}</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-flow-row gap-2">
|
|
||||||
<Button type="submit">Add</Button>
|
|
||||||
|
|
||||||
<Button onClick={close} variant="outlined" color="secondary">
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,13 @@ export type DialogType =
|
|||||||
| 'CREATE_TABLE'
|
| 'CREATE_TABLE'
|
||||||
| 'EDIT_TABLE'
|
| 'EDIT_TABLE'
|
||||||
| 'CREATE_FOREIGN_KEY'
|
| 'CREATE_FOREIGN_KEY'
|
||||||
| 'EDIT_FOREIGN_KEY';
|
| 'EDIT_FOREIGN_KEY'
|
||||||
|
| 'CREATE_ROLE'
|
||||||
|
| 'EDIT_ROLE'
|
||||||
|
| 'CREATE_PERMISSION_VARIABLE'
|
||||||
|
| 'EDIT_PERMISSION_VARIABLE'
|
||||||
|
| 'CREATE_ENVIRONMENT_VARIABLE'
|
||||||
|
| 'EDIT_ENVIRONMENT_VARIABLE';
|
||||||
|
|
||||||
export interface DialogConfig<TPayload = unknown> {
|
export interface DialogConfig<TPayload = unknown> {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
||||||
import { CreateForeignKeyForm } from '@/components/data-browser/CreateForeignKeyForm';
|
import CreateForeignKeyForm from '@/components/data-browser/CreateForeignKeyForm';
|
||||||
import { EditForeignKeyForm } from '@/components/data-browser/EditForeignKeyForm';
|
import EditForeignKeyForm from '@/components/data-browser/EditForeignKeyForm';
|
||||||
|
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
|
||||||
|
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
|
||||||
|
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
|
||||||
|
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
|
||||||
|
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
|
||||||
|
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
|
||||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
import AlertDialog from '@/ui/v2/AlertDialog';
|
import AlertDialog from '@/ui/v2/AlertDialog';
|
||||||
import { BaseDialog } from '@/ui/v2/Dialog';
|
import { BaseDialog } from '@/ui/v2/Dialog';
|
||||||
import Drawer from '@/ui/v2/Drawer';
|
import Drawer from '@/ui/v2/Drawer';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import type { BaseSyntheticEvent, PropsWithChildren } from 'react';
|
import type {
|
||||||
|
BaseSyntheticEvent,
|
||||||
|
DetailedHTMLProps,
|
||||||
|
HTMLProps,
|
||||||
|
PropsWithChildren,
|
||||||
|
} from 'react';
|
||||||
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
|
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
import type { DialogConfig, DialogType } from './DialogContext';
|
import type { DialogConfig, DialogType } from './DialogContext';
|
||||||
import DialogContext from './DialogContext';
|
import DialogContext from './DialogContext';
|
||||||
import {
|
import {
|
||||||
@@ -16,13 +28,21 @@ import {
|
|||||||
drawerReducer,
|
drawerReducer,
|
||||||
} from './dialogReducers';
|
} from './dialogReducers';
|
||||||
|
|
||||||
function LoadingComponent() {
|
function LoadingComponent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> = {}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid items-center justify-center px-6 py-4">
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
'grid items-center justify-center px-6 py-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ActivityIndicator
|
<ActivityIndicator
|
||||||
circularProgressProps={{ className: 'w-5 h-5' }}
|
circularProgressProps={{ className: 'w-5 h-5' }}
|
||||||
label="Loading form..."
|
label="Loading form..."
|
||||||
delay={500}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -30,27 +50,27 @@ function LoadingComponent() {
|
|||||||
|
|
||||||
const CreateRecordForm = dynamic(
|
const CreateRecordForm = dynamic(
|
||||||
() => import('@/components/data-browser/CreateRecordForm'),
|
() => import('@/components/data-browser/CreateRecordForm'),
|
||||||
{ ssr: false, loading: LoadingComponent },
|
{ ssr: false, loading: () => LoadingComponent() },
|
||||||
);
|
);
|
||||||
|
|
||||||
const CreateColumnForm = dynamic(
|
const CreateColumnForm = dynamic(
|
||||||
() => import('@/components/data-browser/CreateColumnForm'),
|
() => import('@/components/data-browser/CreateColumnForm'),
|
||||||
{ ssr: false, loading: LoadingComponent },
|
{ ssr: false, loading: () => LoadingComponent() },
|
||||||
);
|
);
|
||||||
|
|
||||||
const EditColumnForm = dynamic(
|
const EditColumnForm = dynamic(
|
||||||
() => import('@/components/data-browser/EditColumnForm'),
|
() => import('@/components/data-browser/EditColumnForm'),
|
||||||
{ ssr: false, loading: LoadingComponent },
|
{ ssr: false, loading: () => LoadingComponent() },
|
||||||
);
|
);
|
||||||
|
|
||||||
const CreateTableForm = dynamic(
|
const CreateTableForm = dynamic(
|
||||||
() => import('@/components/data-browser/CreateTableForm'),
|
() => import('@/components/data-browser/CreateTableForm'),
|
||||||
{ ssr: false, loading: LoadingComponent },
|
{ ssr: false, loading: () => LoadingComponent() },
|
||||||
);
|
);
|
||||||
|
|
||||||
const EditTableForm = dynamic(
|
const EditTableForm = dynamic(
|
||||||
() => import('@/components/data-browser/EditTableForm'),
|
() => import('@/components/data-browser/EditTableForm'),
|
||||||
{ ssr: false, loading: LoadingComponent },
|
{ ssr: false, loading: () => LoadingComponent() },
|
||||||
);
|
);
|
||||||
|
|
||||||
function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||||
@@ -209,6 +229,25 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
|||||||
[closeDialog, closeDrawer, onDirtyStateChange, openDialog, openDrawer],
|
[closeDialog, closeDrawer, onDirtyStateChange, openDialog, openDrawer],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sharedDialogProps = {
|
||||||
|
...dialogPayload,
|
||||||
|
onSubmit: async (values: any) => {
|
||||||
|
await dialogPayload?.onSubmit?.(values);
|
||||||
|
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
onCancel: closeDialogWithDirtyGuard,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharedDrawerProps = {
|
||||||
|
onSubmit: async () => {
|
||||||
|
await drawerPayload?.onSubmit();
|
||||||
|
|
||||||
|
closeDrawer();
|
||||||
|
},
|
||||||
|
onCancel: closeDrawerWithDirtyGuard,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider value={contextValue}>
|
<DialogContext.Provider value={contextValue}>
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
@@ -249,33 +288,47 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
|||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onClose={closeDialogWithDirtyGuard}
|
onClose={closeDialogWithDirtyGuard}
|
||||||
TransitionProps={{ onExited: clearDialogContent, unmountOnExit: false }}
|
TransitionProps={{ onExited: clearDialogContent, unmountOnExit: false }}
|
||||||
PaperProps={{ className: 'max-w-md w-full' }}
|
PaperProps={{
|
||||||
|
...dialogProps?.PaperProps,
|
||||||
|
className: twMerge(
|
||||||
|
'max-w-md w-full',
|
||||||
|
dialogProps?.PaperProps?.className,
|
||||||
|
),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RetryableErrorBoundary
|
<RetryableErrorBoundary
|
||||||
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
|
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
|
||||||
>
|
>
|
||||||
{activeDialogType === 'CREATE_FOREIGN_KEY' && (
|
{activeDialogType === 'CREATE_FOREIGN_KEY' && (
|
||||||
<CreateForeignKeyForm
|
<CreateForeignKeyForm {...sharedDialogProps} />
|
||||||
{...dialogPayload}
|
|
||||||
onSubmit={async (values) => {
|
|
||||||
await dialogPayload?.onSubmit(values);
|
|
||||||
|
|
||||||
closeDialog();
|
|
||||||
}}
|
|
||||||
onCancel={closeDialogWithDirtyGuard}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeDialogType === 'EDIT_FOREIGN_KEY' && (
|
{activeDialogType === 'EDIT_FOREIGN_KEY' && (
|
||||||
<EditForeignKeyForm
|
<EditForeignKeyForm {...sharedDialogProps} />
|
||||||
{...dialogPayload}
|
)}
|
||||||
onSubmit={async (values) => {
|
|
||||||
await dialogPayload?.onSubmit(values);
|
|
||||||
|
|
||||||
closeDialog();
|
{activeDialogType === 'CREATE_ROLE' && (
|
||||||
}}
|
<CreateRoleForm {...sharedDialogProps} />
|
||||||
onCancel={closeDialogWithDirtyGuard}
|
)}
|
||||||
/>
|
|
||||||
|
{activeDialogType === 'EDIT_ROLE' && (
|
||||||
|
<EditRoleForm {...sharedDialogProps} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeDialogType === 'CREATE_PERMISSION_VARIABLE' && (
|
||||||
|
<CreatePermissionVariableForm {...sharedDialogProps} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeDialogType === 'EDIT_PERMISSION_VARIABLE' && (
|
||||||
|
<EditPermissionVariableForm {...sharedDialogProps} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeDialogType === 'CREATE_ENVIRONMENT_VARIABLE' && (
|
||||||
|
<CreateEnvironmentVariableForm {...sharedDialogProps} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeDialogType === 'EDIT_ENVIRONMENT_VARIABLE' && (
|
||||||
|
<EditEnvironmentVariableForm {...sharedDialogProps} />
|
||||||
)}
|
)}
|
||||||
</RetryableErrorBoundary>
|
</RetryableErrorBoundary>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
@@ -292,61 +345,34 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
|||||||
<RetryableErrorBoundary>
|
<RetryableErrorBoundary>
|
||||||
{activeDrawerType === 'CREATE_RECORD' && (
|
{activeDrawerType === 'CREATE_RECORD' && (
|
||||||
<CreateRecordForm
|
<CreateRecordForm
|
||||||
|
{...sharedDrawerProps}
|
||||||
columns={drawerPayload?.columns}
|
columns={drawerPayload?.columns}
|
||||||
onSubmit={async () => {
|
|
||||||
await drawerPayload?.onSubmit();
|
|
||||||
|
|
||||||
closeDrawer();
|
|
||||||
}}
|
|
||||||
onCancel={closeDrawerWithDirtyGuard}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeDrawerType === 'CREATE_COLUMN' && (
|
{activeDrawerType === 'CREATE_COLUMN' && (
|
||||||
<CreateColumnForm
|
<CreateColumnForm {...sharedDrawerProps} />
|
||||||
onSubmit={async () => {
|
|
||||||
await drawerPayload?.onSubmit();
|
|
||||||
|
|
||||||
closeDrawer();
|
|
||||||
}}
|
|
||||||
onCancel={closeDrawerWithDirtyGuard}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeDrawerType === 'EDIT_COLUMN' && (
|
{activeDrawerType === 'EDIT_COLUMN' && (
|
||||||
<EditColumnForm
|
<EditColumnForm
|
||||||
|
{...sharedDrawerProps}
|
||||||
column={drawerPayload?.column}
|
column={drawerPayload?.column}
|
||||||
onSubmit={async () => {
|
|
||||||
await drawerPayload?.onSubmit();
|
|
||||||
|
|
||||||
closeDrawer();
|
|
||||||
}}
|
|
||||||
onCancel={closeDrawerWithDirtyGuard}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeDrawerType === 'CREATE_TABLE' && (
|
{activeDrawerType === 'CREATE_TABLE' && (
|
||||||
<CreateTableForm
|
<CreateTableForm
|
||||||
|
{...sharedDrawerProps}
|
||||||
schema={drawerPayload?.schema}
|
schema={drawerPayload?.schema}
|
||||||
onSubmit={async () => {
|
|
||||||
await drawerPayload?.onSubmit();
|
|
||||||
|
|
||||||
closeDrawer();
|
|
||||||
}}
|
|
||||||
onCancel={closeDrawerWithDirtyGuard}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeDrawerType === 'EDIT_TABLE' && (
|
{activeDrawerType === 'EDIT_TABLE' && (
|
||||||
<EditTableForm
|
<EditTableForm
|
||||||
|
{...sharedDrawerProps}
|
||||||
table={drawerPayload?.table}
|
table={drawerPayload?.table}
|
||||||
schema={drawerPayload?.schema}
|
schema={drawerPayload?.schema}
|
||||||
onSubmit={async () => {
|
|
||||||
await drawerPayload?.onSubmit();
|
|
||||||
|
|
||||||
closeDrawer();
|
|
||||||
}}
|
|
||||||
onCancel={closeDrawerWithDirtyGuard}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</RetryableErrorBoundary>
|
</RetryableErrorBoundary>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface CreateForeignKeyFormProps
|
|||||||
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
|
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateForeignKeyForm({
|
export default function CreateForeignKeyForm({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
selectedColumn,
|
selectedColumn,
|
||||||
...props
|
...props
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './CreateForeignKeyForm';
|
export * from './CreateForeignKeyForm';
|
||||||
export { CreateForeignKeyForm as default } from './CreateForeignKeyForm';
|
export { default } from './CreateForeignKeyForm';
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export interface EditForeignKeyFormProps
|
|||||||
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
|
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditForeignKeyForm({
|
export default function EditForeignKeyForm({
|
||||||
foreignKeyRelation,
|
foreignKeyRelation,
|
||||||
selectedColumn,
|
selectedColumn,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './EditForeignKeyForm';
|
export * from './EditForeignKeyForm';
|
||||||
export { EditForeignKeyForm as default } from './EditForeignKeyForm';
|
export { default } from './EditForeignKeyForm';
|
||||||
|
|||||||
@@ -4,19 +4,19 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
export interface ContainerProps
|
export interface ContainerProps
|
||||||
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
|
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
|
||||||
/**
|
/**
|
||||||
* Class name passed to the wrapper element.
|
* Class name passed to the root element.
|
||||||
*/
|
*/
|
||||||
wrapperClassName?: string;
|
rootClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Container({
|
export default function Container({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
wrapperClassName,
|
rootClassName,
|
||||||
...props
|
...props
|
||||||
}: ContainerProps) {
|
}: ContainerProps) {
|
||||||
return (
|
return (
|
||||||
<div className={twMerge('mx-auto w-full bg-white', wrapperClassName)}>
|
<div className={twMerge('mx-auto w-full bg-white', rootClassName)}>
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'mx-auto max-w-7xl bg-white px-5 pt-6 pb-20',
|
'mx-auto max-w-7xl bg-white px-5 pt-6 pb-20',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import Switch from '@/ui/v2/Switch';
|
|||||||
import Text from '@/ui/v2/Text';
|
import Text from '@/ui/v2/Text';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import type { DetailedHTMLProps, HTMLProps, ReactNode } from 'react';
|
import type { DetailedHTMLProps, HTMLProps, ReactNode } from 'react';
|
||||||
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export interface SettingsContainerProps
|
export interface SettingsContainerProps
|
||||||
@@ -31,7 +30,7 @@ export interface SettingsContainerProps
|
|||||||
/**
|
/**
|
||||||
* The description for the section.
|
* The description for the section.
|
||||||
*/
|
*/
|
||||||
description: string | ReactNode;
|
description?: string | ReactNode;
|
||||||
/**
|
/**
|
||||||
* Link to the documentation.
|
* Link to the documentation.
|
||||||
*
|
*
|
||||||
@@ -40,6 +39,8 @@ export interface SettingsContainerProps
|
|||||||
docsLink?: string;
|
docsLink?: string;
|
||||||
/**
|
/**
|
||||||
* Props for the primary action.
|
* Props for the primary action.
|
||||||
|
*
|
||||||
|
* @deprecated Use `slotProps.submitButton` instead.
|
||||||
*/
|
*/
|
||||||
primaryActionButtonProps?: ButtonProps;
|
primaryActionButtonProps?: ButtonProps;
|
||||||
/**
|
/**
|
||||||
@@ -75,9 +76,26 @@ export interface SettingsContainerProps
|
|||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Props to be passed to the Switch component.
|
* Props to be passed to different slots inside the component.
|
||||||
*/
|
*/
|
||||||
switchProps?: SwitchProps;
|
slotProps?: {
|
||||||
|
/**
|
||||||
|
* Props to be passed to the root element.
|
||||||
|
*/
|
||||||
|
root?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
|
||||||
|
/**
|
||||||
|
* Props to be passed to the `<Switch />` component.
|
||||||
|
*/
|
||||||
|
switch?: SwitchProps;
|
||||||
|
/**
|
||||||
|
* Props to be passed to the footer element.
|
||||||
|
*/
|
||||||
|
submitButton?: ButtonProps;
|
||||||
|
/**
|
||||||
|
* Props to be passed to the footer element.
|
||||||
|
*/
|
||||||
|
footer?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsContainer({
|
export default function SettingsContainer({
|
||||||
@@ -94,14 +112,15 @@ export default function SettingsContainer({
|
|||||||
switchId,
|
switchId,
|
||||||
showSwitch = false,
|
showSwitch = false,
|
||||||
rootClassName,
|
rootClassName,
|
||||||
switchProps,
|
|
||||||
docsTitle,
|
docsTitle,
|
||||||
|
slotProps: { root, switch: switchSlot, submitButton, footer } = {},
|
||||||
}: SettingsContainerProps) {
|
}: SettingsContainerProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
{...root}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'grid grid-flow-row gap-4 rounded-lg border-1 border-gray-200 bg-white py-4',
|
'grid grid-flow-row gap-4 rounded-lg border-1 border-gray-200 bg-white py-4',
|
||||||
rootClassName,
|
root?.className || rootClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-flow-col place-content-between gap-3 px-4">
|
<div className="grid grid-flow-col place-content-between gap-3 px-4">
|
||||||
@@ -131,14 +150,14 @@ export default function SettingsContainer({
|
|||||||
checked={enabled}
|
checked={enabled}
|
||||||
onChange={(e) => onEnabledChange(e.target.checked)}
|
onChange={(e) => onEnabledChange(e.target.checked)}
|
||||||
className="self-center"
|
className="self-center"
|
||||||
{...switchProps}
|
{...switchSlot}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{switchId && showSwitch && (
|
{switchId && showSwitch && (
|
||||||
<ControlledSwitch
|
<ControlledSwitch
|
||||||
className="self-center"
|
className="self-center"
|
||||||
name={switchId}
|
name={switchId}
|
||||||
{...switchProps}
|
{...switchSlot}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,9 +167,11 @@ export default function SettingsContainer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
{...footer}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'grid grid-flow-col items-center gap-x-2 border-t border-gray-200 px-4 pt-3.5',
|
'grid grid-flow-col items-center gap-x-2 border-t border-gray-200 px-4 pt-3.5',
|
||||||
docsLink ? 'place-content-between' : 'justify-end',
|
docsLink ? 'place-content-between' : 'justify-end',
|
||||||
|
footer?.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{docsLink && (
|
{docsLink && (
|
||||||
@@ -173,11 +194,17 @@ export default function SettingsContainer({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant={
|
||||||
primaryActionButtonProps?.disabled ? 'outlined' : 'contained'
|
(submitButton || primaryActionButtonProps)?.disabled
|
||||||
|
? 'outlined'
|
||||||
|
: 'contained'
|
||||||
|
}
|
||||||
|
color={
|
||||||
|
(submitButton || primaryActionButtonProps)?.disabled
|
||||||
|
? 'secondary'
|
||||||
|
: 'primary'
|
||||||
}
|
}
|
||||||
color={primaryActionButtonProps?.disabled ? 'secondary' : 'primary'}
|
|
||||||
type="submit"
|
type="submit"
|
||||||
{...primaryActionButtonProps}
|
{...(submitButton || primaryActionButtonProps)}
|
||||||
>
|
>
|
||||||
{submitButtonText}
|
{submitButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
||||||
import type { ProjectLayoutProps } from '@/components/layout/ProjectLayout';
|
import type { ProjectLayoutProps } from '@/components/layout/ProjectLayout';
|
||||||
import ProjectLayout from '@/components/layout/ProjectLayout';
|
import ProjectLayout from '@/components/layout/ProjectLayout';
|
||||||
import type { SettingsSidebarProps } from '@/components/settings/SettingsSidebar';
|
import type { SettingsSidebarProps } from '@/components/settings/SettingsSidebar';
|
||||||
@@ -33,8 +34,8 @@ export default function SettingsLayout({
|
|||||||
{...sidebarProps}
|
{...sidebarProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full flex-auto flex-col overflow-x-hidden">
|
<div className="flex w-full flex-auto flex-col overflow-x-hidden bg-[#fafafa]">
|
||||||
{children}
|
<RetryableErrorBoundary>{children}</RetryableErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</ProjectLayout>
|
</ProjectLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
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 Text from '@/ui/v2/Text';
|
||||||
|
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 {
|
||||||
|
/**
|
||||||
|
* Determines the mode of the form.
|
||||||
|
*
|
||||||
|
* @default 'edit'
|
||||||
|
*/
|
||||||
|
mode?: 'edit' | 'create';
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit: (values: BaseEnvironmentVariableFormValues) => void;
|
||||||
|
/**
|
||||||
|
* Function to be called when the operation is cancelled.
|
||||||
|
*/
|
||||||
|
onCancel?: VoidFunction;
|
||||||
|
/**
|
||||||
|
* Submit button text.
|
||||||
|
*
|
||||||
|
* @default 'Save'
|
||||||
|
*/
|
||||||
|
submitButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const baseEnvironmentVariableFormValidationSchema = Yup.object({
|
||||||
|
name: Yup.string()
|
||||||
|
.required('This field is required.')
|
||||||
|
.test(
|
||||||
|
'isEnvVarPermitted',
|
||||||
|
'This is a reserved name.',
|
||||||
|
(value) =>
|
||||||
|
![
|
||||||
|
'PATH',
|
||||||
|
'NODE_PATH',
|
||||||
|
'PYTHONPATH',
|
||||||
|
'GEM_PATH',
|
||||||
|
'HOSTNAME',
|
||||||
|
'TERM',
|
||||||
|
'NODE_VERSION',
|
||||||
|
'YARN_VERSION',
|
||||||
|
'NODE_ENV',
|
||||||
|
'HOME',
|
||||||
|
].includes(value),
|
||||||
|
)
|
||||||
|
.test(
|
||||||
|
'isEnvVarPrefixPermitted',
|
||||||
|
`The name can't start with NHOST_, HASURA_, AUTH_, STORAGE_ or POSTGRES_.`,
|
||||||
|
(value) =>
|
||||||
|
['NHOST_', 'HASURA_', 'AUTH_', 'STORAGE_', 'POSTGRES_'].every(
|
||||||
|
(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),
|
||||||
|
),
|
||||||
|
devValue: Yup.string().required('This field is required.'),
|
||||||
|
prodValue: Yup.string().required('This field is required.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function BaseEnvironmentVariableForm({
|
||||||
|
mode = 'edit',
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitButtonText = 'Save',
|
||||||
|
}: BaseEnvironmentVariableFormProps) {
|
||||||
|
const { onDirtyStateChange } = useDialog();
|
||||||
|
const form = useFormContext<BaseEnvironmentVariableFormValues>();
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Text variant="subtitle1" component="span">
|
||||||
|
Environment Variables are made available to all your services. All
|
||||||
|
values are encrypted.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<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,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
inputProps={{ maxLength: 100 }}
|
||||||
|
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('prodValue')}
|
||||||
|
inputProps={{ maxLength: 100 }}
|
||||||
|
id="prodValue"
|
||||||
|
label="Production Value"
|
||||||
|
placeholder="Enter value"
|
||||||
|
hideEmptyHelperText
|
||||||
|
error={!!errors.prodValue}
|
||||||
|
helperText={errors?.prodValue?.message}
|
||||||
|
fullWidth
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus={mode === 'edit'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
{...register('devValue')}
|
||||||
|
inputProps={{ maxLength: 100 }}
|
||||||
|
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}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './BaseEnvironmentVariableForm';
|
||||||
|
export { default } from './BaseEnvironmentVariableForm';
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type {
|
||||||
|
BaseEnvironmentVariableFormProps,
|
||||||
|
BaseEnvironmentVariableFormValues,
|
||||||
|
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
|
||||||
|
import BaseEnvironmentVariableForm, {
|
||||||
|
baseEnvironmentVariableFormValidationSchema,
|
||||||
|
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
|
||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
|
import {
|
||||||
|
useGetEnvironmentVariablesQuery,
|
||||||
|
useInsertEnvironmentVariablesMutation,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export interface CreateEnvironmentVariableFormProps
|
||||||
|
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel'> {
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateEnvironmentVariableForm({
|
||||||
|
onSubmit,
|
||||||
|
...props
|
||||||
|
}: CreateEnvironmentVariableFormProps) {
|
||||||
|
const form = useForm<BaseEnvironmentVariableFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
devValue: '',
|
||||||
|
prodValue: '',
|
||||||
|
},
|
||||||
|
reValidateMode: 'onSubmit',
|
||||||
|
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
|
|
||||||
|
const { data, loading, error } = useGetEnvironmentVariablesQuery({
|
||||||
|
variables: {
|
||||||
|
id: currentApplication?.id,
|
||||||
|
},
|
||||||
|
fetchPolicy: 'cache-only',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [insertEnvironmentVariables] = useInsertEnvironmentVariablesMutation({
|
||||||
|
refetchQueries: ['getEnvironmentVariables'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ActivityIndicator
|
||||||
|
delay={1000}
|
||||||
|
label="Loading environment variables..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { setError } = form;
|
||||||
|
|
||||||
|
async function handleSubmit({
|
||||||
|
name,
|
||||||
|
prodValue,
|
||||||
|
devValue,
|
||||||
|
}: BaseEnvironmentVariableFormValues) {
|
||||||
|
if (
|
||||||
|
data?.environmentVariables?.some(
|
||||||
|
(environmentVariable) => environmentVariable.name === name,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setError('name', {
|
||||||
|
message: 'This environment variable already exists.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertEnvironmentVariablePromise = insertEnvironmentVariables({
|
||||||
|
variables: {
|
||||||
|
environmentVariables: [
|
||||||
|
{ appId: currentApplication.id, name, prodValue, devValue },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
insertEnvironmentVariablePromise,
|
||||||
|
{
|
||||||
|
loading: 'Creating environment variable...',
|
||||||
|
success: 'Environment variable has been created successfully.',
|
||||||
|
error: 'An error occurred while creating the environment variable.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
onSubmit?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<BaseEnvironmentVariableForm
|
||||||
|
mode="create"
|
||||||
|
submitButtonText="Create"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './CreateEnvironmentVariableForm';
|
||||||
|
export { default } from './CreateEnvironmentVariableForm';
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type {
|
||||||
|
BaseEnvironmentVariableFormProps,
|
||||||
|
BaseEnvironmentVariableFormValues,
|
||||||
|
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
|
||||||
|
import BaseEnvironmentVariableForm, {
|
||||||
|
baseEnvironmentVariableFormValidationSchema,
|
||||||
|
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
|
||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
|
import type { EnvironmentVariable } from '@/types/application';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
|
import {
|
||||||
|
useGetEnvironmentVariablesQuery,
|
||||||
|
useUpdateEnvironmentVariableMutation,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export interface EditEnvironmentVariableFormProps
|
||||||
|
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel'> {
|
||||||
|
/**
|
||||||
|
* The environment variable to edit.
|
||||||
|
*/
|
||||||
|
originalEnvironmentVariable: EnvironmentVariable;
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditEnvironmentVariableForm({
|
||||||
|
originalEnvironmentVariable,
|
||||||
|
onSubmit,
|
||||||
|
...props
|
||||||
|
}: EditEnvironmentVariableFormProps) {
|
||||||
|
const form = useForm<BaseEnvironmentVariableFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
id: originalEnvironmentVariable.id || '',
|
||||||
|
name: originalEnvironmentVariable.name || '',
|
||||||
|
devValue: originalEnvironmentVariable.devValue || '',
|
||||||
|
prodValue: originalEnvironmentVariable.prodValue || '',
|
||||||
|
},
|
||||||
|
reValidateMode: 'onSubmit',
|
||||||
|
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
|
|
||||||
|
const { data, loading, error } = useGetEnvironmentVariablesQuery({
|
||||||
|
variables: {
|
||||||
|
id: currentApplication?.id,
|
||||||
|
},
|
||||||
|
fetchPolicy: 'cache-only',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateEnvironmentVariable] = useUpdateEnvironmentVariableMutation({
|
||||||
|
refetchQueries: ['getEnvironmentVariables'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ActivityIndicator
|
||||||
|
delay={1000}
|
||||||
|
label="Loading environment variables..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { setError } = form;
|
||||||
|
|
||||||
|
async function handleSubmit({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
prodValue,
|
||||||
|
devValue,
|
||||||
|
}: BaseEnvironmentVariableFormValues) {
|
||||||
|
if (
|
||||||
|
data?.environmentVariables?.some(
|
||||||
|
(environmentVariable) =>
|
||||||
|
environmentVariable.name === name &&
|
||||||
|
environmentVariable.name !== originalEnvironmentVariable.name,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setError('name', {
|
||||||
|
message: 'This environment variable already exists.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateEnvironmentVariablePromise = updateEnvironmentVariable({
|
||||||
|
variables: {
|
||||||
|
id,
|
||||||
|
environmentVariable: {
|
||||||
|
prodValue,
|
||||||
|
devValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
updateEnvironmentVariablePromise,
|
||||||
|
{
|
||||||
|
loading: 'Updating environment variable...',
|
||||||
|
success: 'Environment variable has been updated successfully.',
|
||||||
|
error: 'An error occurred while updating the environment variable.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
onSubmit?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<BaseEnvironmentVariableForm onSubmit={handleSubmit} {...props} />
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './EditEnvironmentVariableForm';
|
||||||
|
export { default } from './EditEnvironmentVariableForm';
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
|
import type { EnvironmentVariable } from '@/types/application';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import Button from '@/ui/v2/Button';
|
||||||
|
import Divider from '@/ui/v2/Divider';
|
||||||
|
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||||
|
import IconButton from '@/ui/v2/IconButton';
|
||||||
|
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
|
||||||
|
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 { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
|
import {
|
||||||
|
useDeleteEnvironmentVariableMutation,
|
||||||
|
useGetEnvironmentVariablesQuery,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
export interface PermissionVariableSettingsFormValues {
|
||||||
|
/**
|
||||||
|
* Permission variables.
|
||||||
|
*/
|
||||||
|
environmentVariables: EnvironmentVariable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EnvironmentVariableSettings() {
|
||||||
|
const { openDialog, openAlertDialog } = useDialog();
|
||||||
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
|
const { data, loading, error } = useGetEnvironmentVariablesQuery({
|
||||||
|
variables: {
|
||||||
|
id: currentApplication?.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deleteEnvironmentVariable] = useDeleteEnvironmentVariableMutation({
|
||||||
|
refetchQueries: ['getEnvironmentVariables'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ActivityIndicator
|
||||||
|
delay={1000}
|
||||||
|
label="Loading environment variables..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteVariable({ id }: EnvironmentVariable) {
|
||||||
|
const deleteEnvironmentVariablePromise = deleteEnvironmentVariable({
|
||||||
|
variables: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
deleteEnvironmentVariablePromise,
|
||||||
|
{
|
||||||
|
loading: 'Deleting environment variable...',
|
||||||
|
success: 'Environment variable has been deleted successfully.',
|
||||||
|
error: 'An error occurred while deleting the environment variable.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenCreator() {
|
||||||
|
openDialog('CREATE_ENVIRONMENT_VARIABLE', {
|
||||||
|
title: 'Create Environment Variable',
|
||||||
|
props: {
|
||||||
|
titleProps: { className: '!pb-0' },
|
||||||
|
PaperProps: { className: 'gap-2 max-w-sm' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenEditor(originalVariable: EnvironmentVariable) {
|
||||||
|
openDialog('EDIT_ENVIRONMENT_VARIABLE', {
|
||||||
|
title: 'Edit Environment Variables',
|
||||||
|
payload: { originalEnvironmentVariable: originalVariable },
|
||||||
|
props: {
|
||||||
|
titleProps: { className: '!pb-0' },
|
||||||
|
PaperProps: { className: 'gap-2 max-w-sm' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmDelete(originalVariable: EnvironmentVariable) {
|
||||||
|
openAlertDialog({
|
||||||
|
title: 'Delete Environment Variable',
|
||||||
|
payload: (
|
||||||
|
<Text>
|
||||||
|
Are you sure you want to delete the "
|
||||||
|
<strong>{originalVariable.name}</strong>" environment variable?
|
||||||
|
This cannot be undone.
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
props: {
|
||||||
|
primaryButtonColor: 'error',
|
||||||
|
primaryButtonText: 'Delete',
|
||||||
|
onPrimaryAction: () => handleDeleteVariable(originalVariable),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableEnvironmentVariables =
|
||||||
|
[...data.environmentVariables].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer
|
||||||
|
title="Project Environment Variables"
|
||||||
|
description="Environment Variables are key-value pairs configured outside your source code. They are used to store environment-specific values such as API keys."
|
||||||
|
docsLink="https://docs.nhost.io/platform/environment-variables"
|
||||||
|
docsTitle="Environment Variables"
|
||||||
|
rootClassName="gap-0"
|
||||||
|
className="px-0 my-2"
|
||||||
|
slotProps={{ submitButton: { className: 'hidden' } }}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-2 border-b-1 border-gray-200 px-4 py-3">
|
||||||
|
<Text className="font-medium">Variable Name</Text>
|
||||||
|
<Text className="font-medium lg:col-span-2">Updated</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-flow-row gap-2">
|
||||||
|
<List>
|
||||||
|
{availableEnvironmentVariables.map((environmentVariable, index) => {
|
||||||
|
const timestamp = formatDistanceToNowStrict(
|
||||||
|
parseISO(environmentVariable.updatedAt),
|
||||||
|
{ addSuffix: true, roundingMethod: 'floor' },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={environmentVariable.id}>
|
||||||
|
<ListItem.Root
|
||||||
|
className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2"
|
||||||
|
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={() => handleOpenEditor(environmentVariable)}
|
||||||
|
>
|
||||||
|
<Text className="font-medium">Edit</Text>
|
||||||
|
</Dropdown.Item>
|
||||||
|
|
||||||
|
<Divider component="li" />
|
||||||
|
|
||||||
|
<Dropdown.Item
|
||||||
|
onClick={() =>
|
||||||
|
handleConfirmDelete(environmentVariable)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="font-medium"
|
||||||
|
sx={{
|
||||||
|
color: (theme) => theme.palette.error.main,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Text>
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Content>
|
||||||
|
</Dropdown.Root>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem.Text className="truncate">
|
||||||
|
{environmentVariable.name}
|
||||||
|
</ListItem.Text>
|
||||||
|
|
||||||
|
<Text variant="subtitle1" className="lg:col-span-2 truncate">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="justify-self-start mx-4"
|
||||||
|
variant="borderless"
|
||||||
|
startIcon={<PlusIcon />}
|
||||||
|
onClick={handleOpenCreator}
|
||||||
|
>
|
||||||
|
Create Environment Variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './EnvironmentVariableSettings';
|
||||||
|
export { default } from './EnvironmentVariableSettings';
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import InlineCode from '@/components/common/InlineCode';
|
||||||
|
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||||
|
import { useAppClient } from '@/hooks/useAppClient';
|
||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import Button from '@/ui/v2/Button';
|
||||||
|
import Divider from '@/ui/v2/Divider';
|
||||||
|
import IconButton from '@/ui/v2/IconButton';
|
||||||
|
import EyeIcon from '@/ui/v2/icons/EyeIcon';
|
||||||
|
import EyeOffIcon from '@/ui/v2/icons/EyeOffIcon';
|
||||||
|
import Input from '@/ui/v2/Input';
|
||||||
|
import List from '@/ui/v2/List';
|
||||||
|
import { ListItem } from '@/ui/v2/ListItem';
|
||||||
|
import Text from '@/ui/v2/Text';
|
||||||
|
import { LOCAL_HASURA_URL } from '@/utils/env';
|
||||||
|
import { generateRemoteAppUrl } from '@/utils/helpers';
|
||||||
|
import { useGetAppInjectedVariablesQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import { Fragment, useState } from 'react';
|
||||||
|
|
||||||
|
export default function SystemEnvironmentVariableSettings() {
|
||||||
|
const [showAdminSecret, setShowAdminSecret] = useState(false);
|
||||||
|
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||||
|
|
||||||
|
const { openAlertDialog } = useDialog();
|
||||||
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
|
const { data, loading, error } = useGetAppInjectedVariablesQuery({
|
||||||
|
variables: { id: currentApplication?.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const appClient = useAppClient({ start: false });
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ActivityIndicator
|
||||||
|
delay={1000}
|
||||||
|
label="Loading system environment variables..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showJwtSecret() {
|
||||||
|
openAlertDialog({
|
||||||
|
title: 'Auth JWT Secret',
|
||||||
|
payload: (
|
||||||
|
<div className="grid grid-flow-row gap-2">
|
||||||
|
<Text variant="subtitle2">
|
||||||
|
This is the key used for generating JWTs. It's HMAC-SHA-based
|
||||||
|
and the same as configured in Hasura.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
defaultValue={data?.app?.hasuraGraphqlJwtSecret}
|
||||||
|
disabled
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={5}
|
||||||
|
hideEmptyHelperText
|
||||||
|
inputProps={{ className: 'font-mono' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
props: {
|
||||||
|
hidePrimaryAction: true,
|
||||||
|
secondaryButtonText: 'Close',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasuraUrl =
|
||||||
|
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||||
|
? LOCAL_HASURA_URL
|
||||||
|
: generateRemoteAppUrl(currentApplication.subdomain);
|
||||||
|
|
||||||
|
const systemEnvironmentVariables = [
|
||||||
|
{
|
||||||
|
key: 'NHOST_BACKEND_URL',
|
||||||
|
value: generateRemoteAppUrl(currentApplication.subdomain),
|
||||||
|
},
|
||||||
|
{ key: 'NHOST_SUBDOMAIN', value: currentApplication.subdomain },
|
||||||
|
{ key: 'NHOST_REGION', value: currentApplication.region.awsName },
|
||||||
|
{ key: 'NHOST_HASURA_URL', value: `${hasuraUrl}/console` },
|
||||||
|
{ key: 'NHOST_AUTH_URL', value: appClient.auth.url },
|
||||||
|
{ key: 'NHOST_GRAPHQL_URL', value: appClient.graphql.url },
|
||||||
|
{ key: 'NHOST_STORAGE_URL', value: appClient.storage.url },
|
||||||
|
{ key: 'NHOST_FUNCTIONS_URL', value: appClient.functions.url },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer
|
||||||
|
title="System Environment Variables"
|
||||||
|
description="Environment Variables are key-value pairs configured outside your source code. They are used to store environment-specific values such as API keys."
|
||||||
|
docsLink="https://docs.nhost.io/platform/environment-variables#system-environment-variables"
|
||||||
|
rootClassName="gap-0"
|
||||||
|
className="px-0 mt-2 mb-2.5"
|
||||||
|
slotProps={{ submitButton: { className: 'invisible' } }}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 border-b-1 gap-2 border-gray-200 px-4 py-3">
|
||||||
|
<Text className="font-medium">Variable Name</Text>
|
||||||
|
<Text className="font-medium lg:col-span-2">Value</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<List>
|
||||||
|
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
|
<ListItem.Text>NHOST_ADMIN_SECRET</ListItem.Text>
|
||||||
|
|
||||||
|
<div className="grid grid-flow-col lg:col-span-2 gap-2 items-center justify-start">
|
||||||
|
<Text className="text-greyscaleGreyDark truncate">
|
||||||
|
{showAdminSecret ? (
|
||||||
|
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]">
|
||||||
|
{currentApplication?.hasuraGraphqlAdminSecret}
|
||||||
|
</InlineCode>
|
||||||
|
) : (
|
||||||
|
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
aria-label={
|
||||||
|
showAdminSecret ? 'Hide Admin Secret' : 'Show Admin Secret'
|
||||||
|
}
|
||||||
|
onClick={() => setShowAdminSecret((show) => !show)}
|
||||||
|
>
|
||||||
|
{showAdminSecret ? (
|
||||||
|
<EyeOffIcon className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</ListItem.Root>
|
||||||
|
|
||||||
|
<Divider component="li" className="!my-4" />
|
||||||
|
|
||||||
|
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
|
<ListItem.Text>NHOST_WEBHOOK_SECRET</ListItem.Text>
|
||||||
|
|
||||||
|
<div className="grid grid-flow-col gap-2 lg:col-span-2 items-center justify-start">
|
||||||
|
<Text className="text-greyscaleGreyDark truncate">
|
||||||
|
{showWebhookSecret ? (
|
||||||
|
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]">
|
||||||
|
{data?.app?.webhookSecret}
|
||||||
|
</InlineCode>
|
||||||
|
) : (
|
||||||
|
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
aria-label={
|
||||||
|
showWebhookSecret
|
||||||
|
? 'Hide Webhook Secret'
|
||||||
|
: 'Show Webhook Secret'
|
||||||
|
}
|
||||||
|
onClick={() => setShowWebhookSecret((show) => !show)}
|
||||||
|
>
|
||||||
|
{showWebhookSecret ? (
|
||||||
|
<EyeOffIcon className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</ListItem.Root>
|
||||||
|
|
||||||
|
<Divider component="li" className="!my-4" />
|
||||||
|
|
||||||
|
{systemEnvironmentVariables.map((environmentVariable, index) => (
|
||||||
|
<Fragment key={environmentVariable.key}>
|
||||||
|
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
|
<ListItem.Text>{environmentVariable.key}</ListItem.Text>
|
||||||
|
|
||||||
|
<Text className="truncate lg:col-span-2">
|
||||||
|
{environmentVariable.value}
|
||||||
|
</Text>
|
||||||
|
</ListItem.Root>
|
||||||
|
|
||||||
|
{index !== systemEnvironmentVariables.length - 1 && (
|
||||||
|
<Divider className="!my-4" />
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Divider component="li" className="!mt-4 !mb-2.5" />
|
||||||
|
|
||||||
|
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 justify-start">
|
||||||
|
<ListItem.Text>NHOST_JWT_SECRET</ListItem.Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
onClick={showJwtSecret}
|
||||||
|
size="small"
|
||||||
|
className="justify-self-start"
|
||||||
|
>
|
||||||
|
Show JWT Secret
|
||||||
|
</Button>
|
||||||
|
</ListItem.Root>
|
||||||
|
</List>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './SystemEnvironmentVariableSettings';
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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 Text from '@/ui/v2/Text';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
export interface BasePermissionVariableFormValues {
|
||||||
|
/**
|
||||||
|
* Permission variable key.
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
/**
|
||||||
|
* Permission variable value.
|
||||||
|
*/
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BasePermissionVariableFormProps {
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit: (values: BasePermissionVariableFormValues) => void;
|
||||||
|
/**
|
||||||
|
* Function to be called when the operation is cancelled.
|
||||||
|
*/
|
||||||
|
onCancel?: VoidFunction;
|
||||||
|
/**
|
||||||
|
* Submit button text.
|
||||||
|
*
|
||||||
|
* @default 'Save'
|
||||||
|
*/
|
||||||
|
submitButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const basePermissionVariableValidationSchema = Yup.object({
|
||||||
|
key: Yup.string().required('This field is required.'),
|
||||||
|
value: Yup.string().required('This field is required.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function BasePermissionVariableForm({
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitButtonText = 'Save',
|
||||||
|
}: BasePermissionVariableFormProps) {
|
||||||
|
const { onDirtyStateChange } = useDialog();
|
||||||
|
const form = useFormContext<BasePermissionVariableFormValues>();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { dirtyFields, errors, isSubmitting },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyStateChange(isDirty, 'dialog');
|
||||||
|
}, [isDirty, onDirtyStateChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-flow-row gap-2 px-6 pb-6">
|
||||||
|
<Text variant="subtitle1" component="span">
|
||||||
|
Enter the field name and the path you want to use in this permission
|
||||||
|
variable.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
|
||||||
|
<Input
|
||||||
|
{...register('key', {
|
||||||
|
onChange: (event) => {
|
||||||
|
if (
|
||||||
|
event.target.value &&
|
||||||
|
!/^[a-zA-Z-]+$/gi.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-Z-]/gi,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
id="key"
|
||||||
|
label="Field Name"
|
||||||
|
hideEmptyHelperText
|
||||||
|
error={!!errors.key}
|
||||||
|
helperText={errors?.key?.message}
|
||||||
|
fullWidth
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
slotProps={{ input: { className: '!pl-px' } }}
|
||||||
|
startAdornment={
|
||||||
|
<Text className="shrink-0 pl-2 text-greyscaleGrey">X-Hasura-</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
{...register('value', {
|
||||||
|
onChange: (event) => {
|
||||||
|
if (
|
||||||
|
event.target.value &&
|
||||||
|
!/^[a-zA-Z-_.[\]]+$/gi.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-Z-.[\]]/gi,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
id="value"
|
||||||
|
label="Path"
|
||||||
|
hideEmptyHelperText
|
||||||
|
error={!!errors.value}
|
||||||
|
helperText={errors?.value?.message}
|
||||||
|
fullWidth
|
||||||
|
autoComplete="off"
|
||||||
|
slotProps={{ input: { className: '!pl-px' } }}
|
||||||
|
startAdornment={
|
||||||
|
<Text className="shrink-0 pl-2 text-greyscaleGrey">user.</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 './BasePermissionVariableForm';
|
||||||
|
export { default } from './BasePermissionVariableForm';
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type {
|
||||||
|
BasePermissionVariableFormProps,
|
||||||
|
BasePermissionVariableFormValues,
|
||||||
|
} from '@/components/settings/permissions/BasePermissionVariableForm';
|
||||||
|
import BasePermissionVariableForm, {
|
||||||
|
basePermissionVariableValidationSchema,
|
||||||
|
} 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 { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
|
import {
|
||||||
|
useGetAppCustomClaimsQuery,
|
||||||
|
useUpdateAppMutation,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export interface CreatePermissionVariableFormProps
|
||||||
|
extends Pick<BasePermissionVariableFormProps, 'onCancel'> {
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreatePermissionVariableForm({
|
||||||
|
onSubmit,
|
||||||
|
...props
|
||||||
|
}: CreatePermissionVariableFormProps) {
|
||||||
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
|
|
||||||
|
const { data, error, loading } = useGetAppCustomClaimsQuery({
|
||||||
|
variables: { id: currentApplication?.id },
|
||||||
|
fetchPolicy: 'cache-only',
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<BasePermissionVariableFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
key: '',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
reValidateMode: 'onSubmit',
|
||||||
|
resolver: yupResolver(basePermissionVariableValidationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateApp] = useUpdateAppMutation({
|
||||||
|
refetchQueries: ['getAppCustomClaims'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ActivityIndicator delay={1000} label="Loading permission variables..." />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { setError } = form;
|
||||||
|
const availablePermissionVariables = getPermissionVariablesArray(
|
||||||
|
data?.app?.authJwtCustomClaims,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
}: BasePermissionVariableFormValues) {
|
||||||
|
if (
|
||||||
|
availablePermissionVariables.some(
|
||||||
|
(permissionVariable) => permissionVariable.key === key,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setError('key', { message: 'This key is already in use.' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionVariablesObject = getPermissionVariablesObject(
|
||||||
|
availablePermissionVariables.filter(
|
||||||
|
(permissionVariable) => !permissionVariable.isSystemClaim,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateAppPromise = updateApp({
|
||||||
|
variables: {
|
||||||
|
id: currentApplication?.id,
|
||||||
|
app: {
|
||||||
|
authJwtCustomClaims: {
|
||||||
|
...permissionVariablesObject,
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
updateAppPromise,
|
||||||
|
{
|
||||||
|
loading: 'Creating permission variable...',
|
||||||
|
success: 'Permission variable has been created successfully.',
|
||||||
|
error:
|
||||||
|
'An error occurred while trying to create the permission variable.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
await onSubmit?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<BasePermissionVariableForm onSubmit={handleSubmit} {...props} />
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './CreatePermissionVariableForm';
|
||||||
|
export { default } from './CreatePermissionVariableForm';
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type {
|
||||||
|
BasePermissionVariableFormProps,
|
||||||
|
BasePermissionVariableFormValues,
|
||||||
|
} from '@/components/settings/permissions/BasePermissionVariableForm';
|
||||||
|
import BasePermissionVariableForm, {
|
||||||
|
basePermissionVariableValidationSchema,
|
||||||
|
} from '@/components/settings/permissions/BasePermissionVariableForm';
|
||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
|
import type { CustomClaim } from '@/types/application';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import getPermissionVariables from '@/utils/settings/getPermissionVariablesArray';
|
||||||
|
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
|
||||||
|
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
|
import {
|
||||||
|
useGetAppCustomClaimsQuery,
|
||||||
|
useUpdateAppMutation,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export interface EditPermissionVariableFormProps
|
||||||
|
extends Pick<BasePermissionVariableFormProps, 'onCancel'> {
|
||||||
|
/**
|
||||||
|
* The permission variable to be edited.
|
||||||
|
*/
|
||||||
|
originalVariable: CustomClaim;
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditPermissionVariableForm({
|
||||||
|
originalVariable,
|
||||||
|
onSubmit,
|
||||||
|
...props
|
||||||
|
}: EditPermissionVariableFormProps) {
|
||||||
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
|
|
||||||
|
const { data, error, loading } = useGetAppCustomClaimsQuery({
|
||||||
|
variables: { id: currentApplication?.id },
|
||||||
|
fetchPolicy: 'cache-only',
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<BasePermissionVariableFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
key: originalVariable.key || '',
|
||||||
|
value: originalVariable.value || '',
|
||||||
|
},
|
||||||
|
reValidateMode: 'onSubmit',
|
||||||
|
resolver: yupResolver(basePermissionVariableValidationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateApp] = useUpdateAppMutation({
|
||||||
|
refetchQueries: ['getAppCustomClaims'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ActivityIndicator delay={1000} label="Loading permission variables..." />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { setError } = form;
|
||||||
|
const availablePermissionVariables = getPermissionVariables(
|
||||||
|
data?.app?.authJwtCustomClaims,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
}: BasePermissionVariableFormValues) {
|
||||||
|
if (
|
||||||
|
availablePermissionVariables.some(
|
||||||
|
(permissionVariable) =>
|
||||||
|
permissionVariable.key === key &&
|
||||||
|
permissionVariable.key !== originalVariable.key,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setError('key', { message: 'This key is already in use.' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalPermissionVariableIndex =
|
||||||
|
availablePermissionVariables.findIndex(
|
||||||
|
(permissionVariable) => permissionVariable.key === originalVariable.key,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedPermissionVariables = availablePermissionVariables.map(
|
||||||
|
(permissionVariable, index) => {
|
||||||
|
if (index === originalPermissionVariableIndex) {
|
||||||
|
return { key, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissionVariable;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissionVariablesObject = getPermissionVariablesObject(
|
||||||
|
updatedPermissionVariables.filter(
|
||||||
|
(permissionVariable) => !permissionVariable.isSystemClaim,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateAppPromise = updateApp({
|
||||||
|
variables: {
|
||||||
|
id: currentApplication?.id,
|
||||||
|
app: {
|
||||||
|
authJwtCustomClaims: {
|
||||||
|
...permissionVariablesObject,
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
updateAppPromise,
|
||||||
|
{
|
||||||
|
loading: 'Updating permission variable...',
|
||||||
|
success: 'Permission variable has been updated successfully.',
|
||||||
|
error:
|
||||||
|
'An error occurred while trying to update the permission variable.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
await onSubmit?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<BasePermissionVariableForm onSubmit={handleSubmit} {...props} />
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './EditPermissionVariableForm';
|
||||||
|
export { default } from './EditPermissionVariableForm';
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
|
import type { CustomClaim } from '@/types/application';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import Button from '@/ui/v2/Button';
|
||||||
|
import Divider from '@/ui/v2/Divider';
|
||||||
|
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||||
|
import IconButton from '@/ui/v2/IconButton';
|
||||||
|
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
|
||||||
|
import LockIcon from '@/ui/v2/icons/LockIcon';
|
||||||
|
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 Tooltip from '@/ui/v2/Tooltip';
|
||||||
|
import getPermissionVariables from '@/utils/settings/getPermissionVariablesArray';
|
||||||
|
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
|
import {
|
||||||
|
useGetAppCustomClaimsQuery,
|
||||||
|
useUpdateAppMutation,
|
||||||
|
} 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 { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
|
const { openDialog, openAlertDialog } = useDialog();
|
||||||
|
|
||||||
|
const { data, loading, error } = useGetAppCustomClaimsQuery({
|
||||||
|
variables: {
|
||||||
|
id: currentApplication?.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateApp] = useUpdateAppMutation({
|
||||||
|
refetchQueries: ['getAppCustomClaims'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ActivityIndicator delay={1000} label="Loading permission variables..." />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteVariable({ key }: CustomClaim) {
|
||||||
|
const filteredCustomClaims = Object.keys(
|
||||||
|
data?.app?.authJwtCustomClaims,
|
||||||
|
).filter((customClaimKey) => customClaimKey !== key);
|
||||||
|
|
||||||
|
const updateAppPromise = updateApp({
|
||||||
|
variables: {
|
||||||
|
id: currentApplication?.id,
|
||||||
|
app: {
|
||||||
|
authJwtCustomClaims: filteredCustomClaims.reduce(
|
||||||
|
(customClaims, currentKey) => ({
|
||||||
|
...customClaims,
|
||||||
|
[currentKey]: data?.app?.authJwtCustomClaims[currentKey],
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
updateAppPromise,
|
||||||
|
{
|
||||||
|
loading: 'Deleting permission variable...',
|
||||||
|
success: 'Permission variable has been deleted successfully.',
|
||||||
|
error: 'An error occurred while trying to delete permission variable.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenCreator() {
|
||||||
|
openDialog('CREATE_PERMISSION_VARIABLE', {
|
||||||
|
title: 'Create Permission Variable',
|
||||||
|
props: {
|
||||||
|
titleProps: { className: '!pb-0' },
|
||||||
|
PaperProps: { className: 'max-w-sm' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenEditor(originalVariable: CustomClaim) {
|
||||||
|
openDialog('EDIT_PERMISSION_VARIABLE', {
|
||||||
|
title: 'Edit Permission Variable',
|
||||||
|
payload: { originalVariable },
|
||||||
|
props: {
|
||||||
|
titleProps: { className: '!pb-0' },
|
||||||
|
PaperProps: { className: 'max-w-sm' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmDelete(originalVariable: CustomClaim) {
|
||||||
|
openAlertDialog({
|
||||||
|
title: 'Delete Permission Variable',
|
||||||
|
payload: (
|
||||||
|
<Text>
|
||||||
|
Are you sure you want to delete the "
|
||||||
|
<strong>X-Hasura-{originalVariable.key}</strong>" permission
|
||||||
|
variable? This cannot be undone.
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
props: {
|
||||||
|
onPrimaryAction: () => handleDeleteVariable(originalVariable),
|
||||||
|
primaryButtonColor: 'error',
|
||||||
|
primaryButtonText: 'Delete',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const availablePermissionVariables = getPermissionVariables(
|
||||||
|
data?.app?.authJwtCustomClaims,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer
|
||||||
|
title="Permission Variables"
|
||||||
|
description="Permission variables are used to define permission rules in the GraphQL API."
|
||||||
|
docsLink="https://docs.nhost.io/graphql/permissions"
|
||||||
|
rootClassName="gap-0"
|
||||||
|
className="px-0 my-2"
|
||||||
|
slotProps={{ submitButton: { className: 'invisible' } }}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 border-b-1 border-gray-200 px-4 py-3">
|
||||||
|
<Text className="font-medium">Field name</Text>
|
||||||
|
<Text className="font-medium">Path</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-flow-row gap-2">
|
||||||
|
<List>
|
||||||
|
{availablePermissionVariables.map((customClaim, index) => (
|
||||||
|
<Fragment key={customClaim.key}>
|
||||||
|
<ListItem.Root
|
||||||
|
className="px-4 grid grid-cols-2"
|
||||||
|
secondaryAction={
|
||||||
|
<Dropdown.Root>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
customClaim.isSystemClaim
|
||||||
|
? "You can't edit system permission variables"
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
placement="right"
|
||||||
|
disableHoverListener={!customClaim.isSystemClaim}
|
||||||
|
hasDisabledChildren={customClaim.isSystemClaim}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<Dropdown.Trigger asChild hideChevron>
|
||||||
|
<IconButton
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
disabled={customClaim.isSystemClaim}
|
||||||
|
>
|
||||||
|
<DotsVerticalIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Dropdown.Trigger>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Dropdown.Content
|
||||||
|
menu
|
||||||
|
PaperProps={{ className: 'w-32' }}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dropdown.Item
|
||||||
|
onClick={() => handleOpenEditor(customClaim)}
|
||||||
|
>
|
||||||
|
<Text className="font-medium">Edit</Text>
|
||||||
|
</Dropdown.Item>
|
||||||
|
|
||||||
|
<Divider component="li" />
|
||||||
|
|
||||||
|
<Dropdown.Item
|
||||||
|
onClick={() => handleConfirmDelete(customClaim)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="font-medium"
|
||||||
|
sx={{
|
||||||
|
color: (theme) => theme.palette.error.main,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Text>
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Content>
|
||||||
|
</Dropdown.Root>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem.Text
|
||||||
|
primary={
|
||||||
|
<>
|
||||||
|
X-Hasura-{customClaim.key}{' '}
|
||||||
|
{customClaim.isSystemClaim && (
|
||||||
|
<LockIcon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text className="font-medium">user.{customClaim.value}</Text>
|
||||||
|
</ListItem.Root>
|
||||||
|
|
||||||
|
<Divider
|
||||||
|
component="li"
|
||||||
|
className={twMerge(
|
||||||
|
index === availablePermissionVariables.length - 1
|
||||||
|
? '!mt-4'
|
||||||
|
: '!my-4',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="justify-self-start mx-4"
|
||||||
|
variant="borderless"
|
||||||
|
startIcon={<PlusIcon />}
|
||||||
|
onClick={handleOpenCreator}
|
||||||
|
>
|
||||||
|
Create Permission Variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './PermissionVariableSettings';
|
||||||
|
export { default } from './PermissionVariableSettings';
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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 Text from '@/ui/v2/Text';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
export interface BaseRoleFormValues {
|
||||||
|
/**
|
||||||
|
* The name of the role.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseRoleFormProps {
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit: (values: BaseRoleFormValues) => void;
|
||||||
|
/**
|
||||||
|
* Function to be called when the operation is cancelled.
|
||||||
|
*/
|
||||||
|
onCancel?: VoidFunction;
|
||||||
|
/**
|
||||||
|
* Submit button text.
|
||||||
|
*
|
||||||
|
* @default 'Save'
|
||||||
|
*/
|
||||||
|
submitButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const baseRoleFormValidationSchema = Yup.object({
|
||||||
|
name: Yup.string().required('This field is required.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function BaseRoleForm({
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitButtonText = 'Save',
|
||||||
|
}: BaseRoleFormProps) {
|
||||||
|
const { onDirtyStateChange } = useDialog();
|
||||||
|
const form = useFormContext<BaseRoleFormValues>();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors, dirtyFields, isSubmitting },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyStateChange(isDirty, 'dialog');
|
||||||
|
}, [isDirty, onDirtyStateChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-flow-row gap-2 px-6 pb-6">
|
||||||
|
<Text variant="subtitle1" component="span">
|
||||||
|
Enter the name for the role below.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
|
||||||
|
<Input
|
||||||
|
{...register('name')}
|
||||||
|
inputProps={{ maxLength: 100 }}
|
||||||
|
id="name"
|
||||||
|
label="Name"
|
||||||
|
placeholder="Enter value"
|
||||||
|
hideEmptyHelperText
|
||||||
|
error={!!errors.name}
|
||||||
|
helperText={errors?.name?.message}
|
||||||
|
fullWidth
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 './BaseRoleForm';
|
||||||
|
export { default } from './BaseRoleForm';
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type {
|
||||||
|
BaseRoleFormProps,
|
||||||
|
BaseRoleFormValues,
|
||||||
|
} from '@/components/settings/roles/BaseRoleForm';
|
||||||
|
import BaseRoleForm, {
|
||||||
|
baseRoleFormValidationSchema,
|
||||||
|
} from '@/components/settings/roles/BaseRoleForm';
|
||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||||
|
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
|
import {
|
||||||
|
useGetRolesQuery,
|
||||||
|
useUpdateAppMutation,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export interface CreateRoleFormProps
|
||||||
|
extends Pick<BaseRoleFormProps, 'onCancel'> {
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateRoleForm({
|
||||||
|
onSubmit,
|
||||||
|
...props
|
||||||
|
}: CreateRoleFormProps) {
|
||||||
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
|
const { data, loading, error } = useGetRolesQuery({
|
||||||
|
variables: { id: currentApplication?.id },
|
||||||
|
fetchPolicy: 'cache-only',
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<BaseRoleFormValues>({
|
||||||
|
defaultValues: {},
|
||||||
|
reValidateMode: 'onSubmit',
|
||||||
|
resolver: yupResolver(baseRoleFormValidationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateApp] = useUpdateAppMutation({ refetchQueries: ['getRoles'] });
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <ActivityIndicator delay={1000} label="Loading roles..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { setError } = form;
|
||||||
|
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
|
||||||
|
|
||||||
|
async function handleSubmit({ name }: BaseRoleFormValues) {
|
||||||
|
if (availableRoles.some((role) => role.name === name)) {
|
||||||
|
setError('name', { message: 'This role already exists.' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAppPromise = updateApp({
|
||||||
|
variables: {
|
||||||
|
id: currentApplication?.id,
|
||||||
|
app: {
|
||||||
|
authUserDefaultAllowedRoles: `${data?.app?.authUserDefaultAllowedRoles},${name}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
updateAppPromise,
|
||||||
|
{
|
||||||
|
loading: 'Creating role...',
|
||||||
|
success: 'Role has been created successfully.',
|
||||||
|
error: 'An error occurred while trying to create the role.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
await onSubmit?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<BaseRoleForm
|
||||||
|
submitButtonText="Create"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './CreateRoleForm';
|
||||||
|
export { default } from './CreateRoleForm';
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type {
|
||||||
|
BaseRoleFormProps,
|
||||||
|
BaseRoleFormValues,
|
||||||
|
} from '@/components/settings/roles/BaseRoleForm';
|
||||||
|
import BaseRoleForm, {
|
||||||
|
baseRoleFormValidationSchema,
|
||||||
|
} from '@/components/settings/roles/BaseRoleForm';
|
||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
|
import type { Role } from '@/types/application';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||||
|
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
|
import {
|
||||||
|
useGetRolesQuery,
|
||||||
|
useUpdateAppMutation,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export interface EditRoleFormProps extends Pick<BaseRoleFormProps, 'onCancel'> {
|
||||||
|
/**
|
||||||
|
* The role to be edited.
|
||||||
|
*/
|
||||||
|
originalRole: Role;
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditRoleForm({
|
||||||
|
originalRole,
|
||||||
|
onSubmit,
|
||||||
|
...props
|
||||||
|
}: EditRoleFormProps) {
|
||||||
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
|
const { data, loading, error } = useGetRolesQuery({
|
||||||
|
variables: { id: currentApplication?.id },
|
||||||
|
fetchPolicy: 'cache-only',
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<BaseRoleFormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
name: originalRole.name || '',
|
||||||
|
},
|
||||||
|
reValidateMode: 'onSubmit',
|
||||||
|
resolver: yupResolver(baseRoleFormValidationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateApp] = useUpdateAppMutation({
|
||||||
|
refetchQueries: ['getRoles'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <ActivityIndicator delay={1000} label="Loading roles..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { setError } = form;
|
||||||
|
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
|
||||||
|
|
||||||
|
async function handleSubmit({ name }: BaseRoleFormValues) {
|
||||||
|
if (
|
||||||
|
availableRoles.some(
|
||||||
|
(role) => role.name === name && role.name !== originalRole.name,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setError('name', { message: 'This role already exists.' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAllowedRolesList =
|
||||||
|
data?.app?.authUserDefaultAllowedRoles.split(',') || [];
|
||||||
|
|
||||||
|
const originalRoleIndex = defaultAllowedRolesList.findIndex(
|
||||||
|
(role) => role.trim() === originalRole.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedDefaultAllowedRoles = defaultAllowedRolesList
|
||||||
|
.map((role, index) => {
|
||||||
|
if (index === originalRoleIndex) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return role;
|
||||||
|
})
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
const updateAppPromise = updateApp({
|
||||||
|
variables: {
|
||||||
|
id: currentApplication?.id,
|
||||||
|
app: {
|
||||||
|
authUserDefaultRole:
|
||||||
|
data?.app?.authUserDefaultRole === originalRole.name
|
||||||
|
? name
|
||||||
|
: data?.app?.authUserDefaultRole,
|
||||||
|
authUserDefaultAllowedRoles: updatedDefaultAllowedRoles,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
updateAppPromise,
|
||||||
|
{
|
||||||
|
loading: 'Updating role...',
|
||||||
|
success: 'Role has been updated successfully.',
|
||||||
|
error: 'An error occurred while trying to update the role.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
await onSubmit?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<BaseRoleForm onSubmit={handleSubmit} {...props} />
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './EditRoleForm';
|
||||||
|
export { default } from './EditRoleForm';
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
|
import type { Role } from '@/types/application';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import Button from '@/ui/v2/Button';
|
||||||
|
import Chip from '@/ui/v2/Chip';
|
||||||
|
import Divider from '@/ui/v2/Divider';
|
||||||
|
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||||
|
import IconButton from '@/ui/v2/IconButton';
|
||||||
|
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
|
||||||
|
import LockIcon from '@/ui/v2/icons/LockIcon';
|
||||||
|
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 getUserRoles from '@/utils/settings/getUserRoles';
|
||||||
|
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
|
import {
|
||||||
|
useGetRolesQuery,
|
||||||
|
useUpdateAppMutation,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export interface RoleSettingsFormValues {
|
||||||
|
/**
|
||||||
|
* Default role.
|
||||||
|
*/
|
||||||
|
authUserDefaultRole: string;
|
||||||
|
/**
|
||||||
|
* Allowed roles for the project.
|
||||||
|
*/
|
||||||
|
authUserDefaultAllowedRoles: Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoleSettings() {
|
||||||
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
|
const { openDialog, openAlertDialog } = useDialog();
|
||||||
|
|
||||||
|
const { data, loading, error } = useGetRolesQuery({
|
||||||
|
variables: { id: currentApplication?.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateApp] = useUpdateAppMutation({
|
||||||
|
refetchQueries: ['getRoles'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <ActivityIndicator delay={1000} label="Loading user roles..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetAsDefault({ name }: Role) {
|
||||||
|
const updateAppPromise = updateApp({
|
||||||
|
variables: {
|
||||||
|
id: currentApplication?.id,
|
||||||
|
app: {
|
||||||
|
authUserDefaultRole: name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
updateAppPromise,
|
||||||
|
{
|
||||||
|
loading: 'Updating default role...',
|
||||||
|
success: 'Default role has been updated successfully.',
|
||||||
|
error: 'An error occurred while trying to update the default role.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteRole({ name }: Role) {
|
||||||
|
const filteredRoles = data?.app?.authUserDefaultAllowedRoles
|
||||||
|
.split(',')
|
||||||
|
.filter((role) => role !== name)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
const updateAppPromise = updateApp({
|
||||||
|
variables: {
|
||||||
|
id: currentApplication?.id,
|
||||||
|
app: {
|
||||||
|
authUserDefaultAllowedRoles: filteredRoles,
|
||||||
|
authUserDefaultRole:
|
||||||
|
name === data?.app?.authUserDefaultRole
|
||||||
|
? 'user'
|
||||||
|
: data?.app?.authUserDefaultRole,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
updateAppPromise,
|
||||||
|
{
|
||||||
|
loading: 'Deleting role...',
|
||||||
|
success: 'Role has been deleted successfully.',
|
||||||
|
error: 'An error occurred while trying to delete the role.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenCreator() {
|
||||||
|
openDialog('CREATE_ROLE', {
|
||||||
|
title: 'Create Role',
|
||||||
|
props: {
|
||||||
|
titleProps: { className: '!pb-0' },
|
||||||
|
PaperProps: { className: 'max-w-sm' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenEditor(originalRole: Role) {
|
||||||
|
openDialog('EDIT_ROLE', {
|
||||||
|
title: 'Edit Role',
|
||||||
|
payload: { originalRole },
|
||||||
|
props: {
|
||||||
|
titleProps: { className: '!pb-0' },
|
||||||
|
PaperProps: { className: 'max-w-sm' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmDelete(originalRole: Role) {
|
||||||
|
openAlertDialog({
|
||||||
|
title: 'Delete Role',
|
||||||
|
payload: (
|
||||||
|
<Text>
|
||||||
|
Are you sure you want to delete the "
|
||||||
|
<strong>{originalRole.name}</strong>" role? This cannot be
|
||||||
|
undone.
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
props: {
|
||||||
|
onPrimaryAction: () => handleDeleteRole(originalRole),
|
||||||
|
primaryButtonColor: 'error',
|
||||||
|
primaryButtonText: 'Delete',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer
|
||||||
|
title="Roles"
|
||||||
|
description="Roles are used to control access to your application."
|
||||||
|
docsLink="https://docs.nhost.io/authentication/users#roles"
|
||||||
|
rootClassName="gap-0"
|
||||||
|
className="px-0 my-2"
|
||||||
|
slotProps={{ submitButton: { className: 'invisible' } }}
|
||||||
|
>
|
||||||
|
<div className="border-b-1 border-gray-200 px-4 py-3">
|
||||||
|
<Text className="font-medium">Name</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-flow-row gap-2">
|
||||||
|
<List>
|
||||||
|
{availableRoles.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)}
|
||||||
|
>
|
||||||
|
<Text className="font-medium">Edit</Text>
|
||||||
|
</Dropdown.Item>
|
||||||
|
|
||||||
|
<Divider component="li" />
|
||||||
|
|
||||||
|
<Dropdown.Item
|
||||||
|
disabled={role.isSystemRole}
|
||||||
|
onClick={() => handleConfirmDelete(role)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="font-medium"
|
||||||
|
sx={{
|
||||||
|
color: (theme) => theme.palette.error.main,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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}
|
||||||
|
|
||||||
|
{role.isSystemRole && <LockIcon className="w-4 h-4" />}
|
||||||
|
|
||||||
|
{data?.app?.authUserDefaultRole === role.name && (
|
||||||
|
<Chip
|
||||||
|
component="span"
|
||||||
|
color="info"
|
||||||
|
size="small"
|
||||||
|
label="Default"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem.Root>
|
||||||
|
|
||||||
|
<Divider
|
||||||
|
component="li"
|
||||||
|
className={twMerge(
|
||||||
|
index === availableRoles.length - 1 ? '!mt-4' : '!my-4',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="justify-self-start mx-4"
|
||||||
|
variant="borderless"
|
||||||
|
startIcon={<PlusIcon />}
|
||||||
|
onClick={handleOpenCreator}
|
||||||
|
>
|
||||||
|
Create Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './RoleSettings';
|
||||||
|
export { default } from './RoleSettings';
|
||||||
@@ -79,7 +79,7 @@ export default function AnonymousSignInSettings() {
|
|||||||
<Form onSubmit={handlePasswordProtectionSettingsChange}>
|
<Form onSubmit={handlePasswordProtectionSettingsChange}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Anonymous Users"
|
title="Anonymous Users"
|
||||||
description="Allow users to sign-in anonymously."
|
description="Allow users to sign in anonymously."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled:
|
disabled:
|
||||||
form.formState.isSubmitting ||
|
form.formState.isSubmitting ||
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function AppleProviderSettings() {
|
|||||||
<Form onSubmit={handleProviderUpdate}>
|
<Form onSubmit={handleProviderUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Apple"
|
title="Apple"
|
||||||
description="Allows users to sign in with Apple."
|
description="Allow users to sign in with Apple."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function DiscordProviderSettings() {
|
|||||||
<Form onSubmit={handleProviderUpdate}>
|
<Form onSubmit={handleProviderUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Discord"
|
title="Discord"
|
||||||
description="Allows users to sign in with Discord."
|
description="Allow users to sign in with Discord."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface EmailAndPasswordFormValues {
|
|||||||
authPasswordHibpEnabled: boolean;
|
authPasswordHibpEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmailSettings() {
|
export default function EmailAndPasswordSettings() {
|
||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
const [updateApp] = useUpdateAppMutation({
|
const [updateApp] = useUpdateAppMutation({
|
||||||
refetchQueries: [GetAppLoginDataDocument],
|
refetchQueries: [GetAppLoginDataDocument],
|
||||||
@@ -61,7 +61,7 @@ export default function EmailSettings() {
|
|||||||
|
|
||||||
const { formState } = form;
|
const { formState } = form;
|
||||||
|
|
||||||
const handleEmailSettingsChange = async (
|
const handleEmailAndPasswordSettingsChange = async (
|
||||||
values: EmailAndPasswordFormValues,
|
values: EmailAndPasswordFormValues,
|
||||||
) => {
|
) => {
|
||||||
const updateAppMutation = updateApp({
|
const updateAppMutation = updateApp({
|
||||||
@@ -90,29 +90,31 @@ export default function EmailSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<Form onSubmit={handleEmailSettingsChange}>
|
<Form onSubmit={handleEmailAndPasswordSettingsChange}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Email and Password"
|
title="Email and Password"
|
||||||
description="Sign in users using email and password."
|
description="Allow users to sign in with email and password."
|
||||||
docsLink="https://docs.nhost.io/authentication/sign-in-with-email-and-password"
|
docsLink="https://docs.nhost.io/authentication/sign-in-with-email-and-password"
|
||||||
docsTitle="how to sign in users with email and password"
|
docsTitle="how to sign in users with email and password"
|
||||||
className="grid grid-flow-row"
|
className="grid grid-flow-row"
|
||||||
showSwitch
|
showSwitch
|
||||||
enabled
|
enabled
|
||||||
switchProps={{ disabled: true }}
|
slotProps={{
|
||||||
primaryActionButtonProps={{
|
switch: {
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: true,
|
||||||
loading: formState.isSubmitting,
|
},
|
||||||
|
submitButton: {
|
||||||
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
|
loading: formState.isSubmitting,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ControlledCheckbox
|
<ControlledCheckbox
|
||||||
name="authEmailSigninEmailVerifiedRequired"
|
name="authEmailSigninEmailVerifiedRequired"
|
||||||
id="authEmailSigninEmailVerifiedRequired"
|
id="authEmailSigninEmailVerifiedRequired"
|
||||||
label={
|
label={
|
||||||
<span className="inline-grid grid-flow-row gap-y-[2px] text-[15px]">
|
<span className="inline-grid grid-flow-row gap-y-0.5 text-sm+">
|
||||||
<span className="text-[15px] font-medium">
|
<span className="font-medium">Require Verified Emails</span>
|
||||||
Require Verified Emails
|
|
||||||
</span>
|
|
||||||
<span className="font-normal text-greyscaleMedium">
|
<span className="font-normal text-greyscaleMedium">
|
||||||
Users must verify their email to be able to sign in.
|
Users must verify their email to be able to sign in.
|
||||||
</span>
|
</span>
|
||||||
@@ -124,11 +126,9 @@ export default function EmailSettings() {
|
|||||||
name="authPasswordHibpEnabled"
|
name="authPasswordHibpEnabled"
|
||||||
id="authPasswordHibpEnabled"
|
id="authPasswordHibpEnabled"
|
||||||
label={
|
label={
|
||||||
<span className="inline-grid grid-flow-row gap-y-[2px] text-[15px]">
|
<span className="inline-grid grid-flow-row gap-y-0.5 text-sm+">
|
||||||
<span className="text-[15px] font-medium">
|
<span className="font-medium">Password Protection</span>
|
||||||
Password Protection
|
<span className="font-normal text-greyscaleMedium">
|
||||||
</span>
|
|
||||||
<span className="text-[12px] font-normal text-greyscaleMedium">
|
|
||||||
Passwords must pass haveibeenpwned.com during sign-up.
|
Passwords must pass haveibeenpwned.com during sign-up.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './EmailAndPasswordSettings';
|
||||||
|
export { default } from './EmailAndPasswordSettings';
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './EmailSettings';
|
|
||||||
export { default } from './EmailSettings';
|
|
||||||
@@ -88,7 +88,7 @@ export default function FacebookProviderSettings() {
|
|||||||
<Form onSubmit={handleProviderUpdate}>
|
<Form onSubmit={handleProviderUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Facebook"
|
title="Facebook"
|
||||||
description="Allows users to sign in with Facebook."
|
description="Allow users to sign in with Facebook."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function GitHubProviderSettings() {
|
|||||||
<Form onSubmit={handleProviderUpdate}>
|
<Form onSubmit={handleProviderUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="GitHub"
|
title="GitHub"
|
||||||
description="Allows users to sign in with GitHub."
|
description="Allow users to sign in with GitHub."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function GoogleProviderSettings() {
|
|||||||
<Form onSubmit={handleProviderUpdate}>
|
<Form onSubmit={handleProviderUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Google"
|
title="Google"
|
||||||
description="Allows users to sign in with Google."
|
description="Allow users to sign in with Google."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function LinkedInProviderSettings() {
|
|||||||
<Form onSubmit={handleProviderUpdate}>
|
<Form onSubmit={handleProviderUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="LinkedIn"
|
title="LinkedIn"
|
||||||
description="Allows users to sign in with LinkedIn"
|
description="Allow users to sign in with LinkedIn."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default function MagicLinkSettings() {
|
|||||||
<Form onSubmit={handleMagicLinkSettingsUpdate}>
|
<Form onSubmit={handleMagicLinkSettingsUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Magic Link"
|
title="Magic Link"
|
||||||
description="Allow users to sign-in with a magic link."
|
description="Allow users to sign in with a magic link."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function SpotifyProviderSettings() {
|
|||||||
<Form onSubmit={handleProviderUpdate}>
|
<Form onSubmit={handleProviderUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Spotify"
|
title="Spotify"
|
||||||
description="Allows users to sign in with Spotify."
|
description="Allow users to sign in with Spotify."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function TwitchProviderSettings() {
|
|||||||
<Form onSubmit={handleProviderUpdate}>
|
<Form onSubmit={handleProviderUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Twitch"
|
title="Twitch"
|
||||||
description="Allows users to sign in with Twitch."
|
description="Allow users to sign in with Twitch."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function TwitterProviderSettings() {
|
|||||||
<Form onSubmit={handleProviderUpdate}>
|
<Form onSubmit={handleProviderUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Twitter"
|
title="Twitter"
|
||||||
description="Allows users to sign in with Twitter."
|
description="Allow users to sign in with Twitter."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default function WebAuthnSettings() {
|
|||||||
<Form onSubmit={handleWebAuthnSettingsUpdate}>
|
<Form onSubmit={handleWebAuthnSettingsUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Security Keys"
|
title="Security Keys"
|
||||||
description="Allow users to sign-in with security keys using WebAuthn."
|
description="Allow users to sign in with security keys using WebAuthn."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function WindowsLiveProviderSettings() {
|
|||||||
<Form onSubmit={handleProviderUpdate}>
|
<Form onSubmit={handleProviderUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Windows Live"
|
title="Windows Live"
|
||||||
description="Allows users to sign in with Windows Live."
|
description="Allow users to sign in with Windows Live."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
|
|||||||
@@ -94,11 +94,13 @@ export default function WorkOsProviderSettings() {
|
|||||||
<Form onSubmit={handleProviderUpdate}>
|
<Form onSubmit={handleProviderUpdate}>
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="WorkOS"
|
title="WorkOS"
|
||||||
description="Allows users to sign in with WorkOS."
|
description="Allow users to sign in with WorkOS."
|
||||||
primaryActionButtonProps={{
|
primaryActionButtonProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
disabled: !formState.isValid || !formState.isDirty,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
}}
|
}}
|
||||||
|
docsLink="https://docs.nhost.io/authentication/sign-in-with-workos"
|
||||||
|
docsTitle="how to sign in users with WorkOS"
|
||||||
icon="/logos/WorkOs.svg"
|
icon="/logos/WorkOs.svg"
|
||||||
switchId="authWorkOsEnabled"
|
switchId="authWorkOsEnabled"
|
||||||
showSwitch
|
showSwitch
|
||||||
@@ -112,8 +114,8 @@ export default function WorkOsProviderSettings() {
|
|||||||
{...register(`authWorkOsClientId`)}
|
{...register(`authWorkOsClientId`)}
|
||||||
name="authWorkOsClientId"
|
name="authWorkOsClientId"
|
||||||
id="authWorkOsClientId"
|
id="authWorkOsClientId"
|
||||||
label="WorkOS Client ID"
|
label="Client ID"
|
||||||
placeholder="WorkOS Client ID"
|
placeholder="Enter your Client ID"
|
||||||
className="col-span-3"
|
className="col-span-3"
|
||||||
fullWidth
|
fullWidth
|
||||||
hideEmptyHelperText
|
hideEmptyHelperText
|
||||||
@@ -122,28 +124,28 @@ export default function WorkOsProviderSettings() {
|
|||||||
{...register('authWorkOsClientSecret')}
|
{...register('authWorkOsClientSecret')}
|
||||||
name="authWorkOsClientSecret"
|
name="authWorkOsClientSecret"
|
||||||
id="authWorkOsClientSecret"
|
id="authWorkOsClientSecret"
|
||||||
label="WorkOS Client Secret"
|
label="Client Secret"
|
||||||
placeholder="WorkOS Client Secret"
|
placeholder="Enter your Client Secret"
|
||||||
className="col-span-3"
|
className="col-span-3"
|
||||||
fullWidth
|
fullWidth
|
||||||
hideEmptyHelperText
|
hideEmptyHelperText
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
{...register('authWorkOsDefaultDomain')}
|
|
||||||
name="authWorkOsDefaultDomain"
|
|
||||||
id="authWorkOsDefaultDomain"
|
|
||||||
label="Default Domain"
|
|
||||||
placeholder="Default Domain"
|
|
||||||
className="col-span-2"
|
|
||||||
fullWidth
|
|
||||||
hideEmptyHelperText
|
|
||||||
/>
|
|
||||||
<Input
|
<Input
|
||||||
{...register('authWorkOsDefaultOrganization')}
|
{...register('authWorkOsDefaultOrganization')}
|
||||||
name="authWorkOsDefaultOrganization"
|
name="authWorkOsDefaultOrganization"
|
||||||
id="authWorkOsDefaultOrganization"
|
id="authWorkOsDefaultOrganization"
|
||||||
label="Default Organization"
|
label="Default Organization ID (optional)"
|
||||||
placeholder="Default Organization"
|
placeholder="Default Organization ID"
|
||||||
|
className="col-span-2"
|
||||||
|
fullWidth
|
||||||
|
hideEmptyHelperText
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('authWorkOsDefaultDomain')}
|
||||||
|
name="authWorkOsDefaultDomain"
|
||||||
|
id="authWorkOsDefaultDomain"
|
||||||
|
label="Default Domain (optional)"
|
||||||
|
placeholder="Default Domain"
|
||||||
className="col-span-2"
|
className="col-span-2"
|
||||||
fullWidth
|
fullWidth
|
||||||
hideEmptyHelperText
|
hideEmptyHelperText
|
||||||
@@ -152,7 +154,7 @@ export default function WorkOsProviderSettings() {
|
|||||||
{...register('authWorkOsDefaultConnection')}
|
{...register('authWorkOsDefaultConnection')}
|
||||||
name="authWorkOsDefaultConnection"
|
name="authWorkOsDefaultConnection"
|
||||||
id="authWorkOsDefaultConnection"
|
id="authWorkOsDefaultConnection"
|
||||||
label="Default Connection"
|
label="Default Connection (optional)"
|
||||||
placeholder="Default Connection"
|
placeholder="Default Connection"
|
||||||
className="col-span-2"
|
className="col-span-2"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const BaseButton = forwardRef(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sx={[
|
sx={[
|
||||||
props.size === 'small' && {
|
props.size === 'small' && {
|
||||||
padding: (theme) => theme.spacing(0.5, 0.75),
|
padding: (theme) => theme.spacing(0.5, 0.5),
|
||||||
},
|
},
|
||||||
props.size === 'medium' && {
|
props.size === 'medium' && {
|
||||||
padding: (theme) => theme.spacing(0.875, 1),
|
padding: (theme) => theme.spacing(0.875, 1),
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import type { ChipProps as MaterialChipProps } from '@mui/material/Chip';
|
import type { ChipProps as MaterialChipProps } from '@mui/material/Chip';
|
||||||
import MaterialChip from '@mui/material/Chip';
|
import MaterialChip, { chipClasses } from '@mui/material/Chip';
|
||||||
|
import type { ElementType } from 'react';
|
||||||
|
|
||||||
export interface ChipProps extends MaterialChipProps {}
|
export interface ChipProps extends MaterialChipProps {
|
||||||
|
/**
|
||||||
|
* Custom component for the root node.
|
||||||
|
*/
|
||||||
|
component?: string | ElementType;
|
||||||
|
}
|
||||||
|
|
||||||
const Chip = styled(MaterialChip)(({ theme }) => ({
|
const Chip = styled(MaterialChip)<ChipProps>(({ theme }) => ({
|
||||||
fontFamily: theme.typography.fontFamily,
|
fontFamily: theme.typography.fontFamily,
|
||||||
fontSize: '0.75rem',
|
fontSize: theme.typography.pxToRem(12),
|
||||||
|
lineHeight: theme.typography.pxToRem(16),
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
lineHeight: '16px',
|
|
||||||
padding: theme.spacing(1.5, 0.25),
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
borderRadius: '9999px',
|
borderRadius: '9999px',
|
||||||
backgroundColor: '#EAEDF0',
|
padding: theme.spacing(0, 0.25),
|
||||||
|
[`&.${chipClasses.colorInfo}`]: {
|
||||||
|
backgroundColor: theme.palette.primary.light,
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
Chip.displayName = 'NhostChip';
|
Chip.displayName = 'NhostChip';
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ const StyledMenu = styled(MaterialMenu)({
|
|||||||
[`& .${materialMenuClasses.list}`]: {
|
[`& .${materialMenuClasses.list}`]: {
|
||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
|
[`& .${materialMenuClasses.paper}`]: {
|
||||||
|
boxShadow: '0px 4px 10px rgba(33, 50, 75, 0.25)',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function DropdownContent({
|
function DropdownContent({
|
||||||
@@ -68,8 +71,7 @@ function DropdownContent({
|
|||||||
sx: [
|
sx: [
|
||||||
{
|
{
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
boxShadow:
|
boxShadow: '0px 4px 10px rgba(33, 50, 75, 0.25)',
|
||||||
'0px 1px 4px rgba(14, 24, 39, 0.1), 0px 8px 24px rgba(14, 24, 39, 0.1)',
|
|
||||||
fontFamily: (theme) => theme.typography.fontFamily,
|
fontFamily: (theme) => theme.typography.fontFamily,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const StyledListItemButton = styled(MaterialListItemButton)(({ theme }) => ({
|
|||||||
padding: theme.spacing(0.75, 1.25),
|
padding: theme.spacing(0.75, 1.25),
|
||||||
},
|
},
|
||||||
[`&.${listItemButtonClasses.selected}`]: {
|
[`&.${listItemButtonClasses.selected}`]: {
|
||||||
backgroundColor: `#ebf3ff`,
|
backgroundColor: theme.palette.primary.light,
|
||||||
color: theme.palette.primary.main,
|
color: theme.palette.primary.main,
|
||||||
},
|
},
|
||||||
[`&.${listItemButtonClasses.selected} > .${listItemTextClasses.root}`]: {
|
[`&.${listItemButtonClasses.selected} > .${listItemTextClasses.root}`]: {
|
||||||
@@ -50,7 +50,7 @@ const StyledListItemButton = styled(MaterialListItemButton)(({ theme }) => ({
|
|||||||
color: theme.palette.primary.main,
|
color: theme.palette.primary.main,
|
||||||
},
|
},
|
||||||
[`&.${listItemButtonClasses.selected}:hover`]: {
|
[`&.${listItemButtonClasses.selected}:hover`]: {
|
||||||
backgroundColor: `#ebf3ff`,
|
backgroundColor: theme.palette.primary.light,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ export interface ListItemTextProps extends MaterialListItemTextProps {}
|
|||||||
|
|
||||||
const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
|
const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
|
||||||
color: theme.palette.text.primary,
|
color: theme.palette.text.primary,
|
||||||
|
display: 'grid',
|
||||||
|
justifyContent: 'start',
|
||||||
|
gridAutoFlow: 'row',
|
||||||
|
gap: theme.spacing(0.5),
|
||||||
|
fontSize: theme.typography.pxToRem(15),
|
||||||
[`&.${listItemTextClasses.root}`]: {
|
[`&.${listItemTextClasses.root}`]: {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
},
|
},
|
||||||
[`& > .${listItemTextClasses.primary}`]: {
|
[`& > .${listItemTextClasses.primary}`]: {
|
||||||
fontSize: '0.9375rem',
|
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { IconProps } from '@/ui/v2/icons';
|
||||||
|
import SvgIcon from '@mui/material/SvgIcon';
|
||||||
|
|
||||||
|
function DotsVerticalIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<SvgIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-label="Three vertical dots"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 4.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 14.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DotsVerticalIcon.displayName = 'NhostDotsVerticalIcon';
|
||||||
|
|
||||||
|
export default DotsVerticalIcon;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './DotsVerticalIcon';
|
||||||
36
dashboard/src/components/ui/v2/icons/EyeIcon/EyeIcon.tsx
Normal file
36
dashboard/src/components/ui/v2/icons/EyeIcon/EyeIcon.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { IconProps } from '@/ui/v2/icons';
|
||||||
|
import SvgIcon from '@mui/material/SvgIcon';
|
||||||
|
|
||||||
|
function EyeIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<SvgIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-label="Eye"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8 3.5C3 3.5 1 8 1 8s2 4.5 7 4.5S15 8 15 8s-2-4.5-7-4.5Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 10.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EyeIcon.displayName = 'NhostEyeIcon';
|
||||||
|
|
||||||
|
export default EyeIcon;
|
||||||
1
dashboard/src/components/ui/v2/icons/EyeIcon/index.ts
Normal file
1
dashboard/src/components/ui/v2/icons/EyeIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './EyeIcon';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { IconProps } from '@/ui/v2/icons';
|
||||||
|
import SvgIcon from '@mui/material/SvgIcon';
|
||||||
|
|
||||||
|
function EyeOffIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<SvgIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-label="Eye crossed out"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m3 2.5 10 11M9.682 9.85a2.5 2.5 0 0 1-3.364-3.7"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4.625 4.287C2.077 5.577 1 8 1 8s2 4.5 7 4.5a7.376 7.376 0 0 0 3.375-.788M13.038 10.569C14.401 9.349 15 8 15 8s-2-4.5-7-4.5c-.433-.001-.865.034-1.292.105"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8.47 5.544a2.502 2.502 0 0 1 2.02 2.22"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EyeOffIcon.displayName = 'NhostEyeOffIcon';
|
||||||
|
|
||||||
|
export default EyeOffIcon;
|
||||||
1
dashboard/src/components/ui/v2/icons/EyeOffIcon/index.ts
Normal file
1
dashboard/src/components/ui/v2/icons/EyeOffIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './EyeOffIcon';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
query getEnvironmentVariables($id: uuid!) {
|
||||||
|
environmentVariables(where: { appId: { _eq: $id } }) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
updatedAt
|
||||||
|
prodValue
|
||||||
|
devValue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
fragment EnvironmentVariable on environmentVariables {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
updatedAt
|
|
||||||
prodValue
|
|
||||||
devValue
|
|
||||||
}
|
|
||||||
|
|
||||||
query getEnvironmentVariablesWhere($where: environmentVariables_bool_exp!) {
|
|
||||||
environmentVariables(where: $where) {
|
|
||||||
...EnvironmentVariable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
dashboard/src/hooks/common/useLeaveConfirm/index.ts
Normal file
2
dashboard/src/hooks/common/useLeaveConfirm/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './useLeaveConfirm';
|
||||||
|
export { default } from './useLeaveConfirm';
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export interface UseLeaveConfirmProps {
|
||||||
|
isDirty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useLeaveConfirm({ isDirty }: UseLeaveConfirmProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { openAlertDialog } = useDialog();
|
||||||
|
const [isConfirmed, setConfirmed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onRouteChangeStart(route: string) {
|
||||||
|
if (!isDirty || isConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openAlertDialog({
|
||||||
|
title: 'Unsaved changes',
|
||||||
|
payload:
|
||||||
|
'You have unsaved local changes. Are you sure you want to discard them?',
|
||||||
|
props: {
|
||||||
|
primaryButtonColor: 'error',
|
||||||
|
primaryButtonText: 'Discard',
|
||||||
|
onPrimaryAction: () => {
|
||||||
|
setConfirmed(true);
|
||||||
|
router.push(route);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error('Route change aborted');
|
||||||
|
}
|
||||||
|
|
||||||
|
router.events.on('routeChangeStart', onRouteChangeStart);
|
||||||
|
|
||||||
|
return () => router.events.off('routeChangeStart', onRouteChangeStart);
|
||||||
|
}, [isConfirmed, isDirty, openAlertDialog, router, router.events]);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
} from '@/types/data-browser';
|
} from '@/types/data-browser';
|
||||||
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
|
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
|
||||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||||
|
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||||
import prepareCreateColumnQuery from './prepareCreateColumnQuery';
|
import prepareCreateColumnQuery from './prepareCreateColumnQuery';
|
||||||
|
|
||||||
export interface CreateColumnMigrationVariables {
|
export interface CreateColumnMigrationVariables {
|
||||||
@@ -33,30 +34,27 @@ export default async function createColumnMigration({
|
|||||||
column,
|
column,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
method: 'POST',
|
'x-hasura-admin-secret': adminSecret,
|
||||||
headers: {
|
|
||||||
'x-hasura-admin-secret': adminSecret,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
dataSource,
|
|
||||||
skip_execution: false,
|
|
||||||
name: `alter_table_${schema}_${table}_add_column_${column.name}`,
|
|
||||||
down: [
|
|
||||||
getPreparedHasuraQuery(
|
|
||||||
dataSource,
|
|
||||||
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I',
|
|
||||||
schema,
|
|
||||||
table,
|
|
||||||
column.name,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
up: args,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
body: JSON.stringify({
|
||||||
|
dataSource,
|
||||||
|
skip_execution: false,
|
||||||
|
name: `alter_table_${schema}_${table}_add_column_${column.name}`,
|
||||||
|
down: [
|
||||||
|
getPreparedHasuraQuery(
|
||||||
|
dataSource,
|
||||||
|
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I',
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
column.name,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
up: args,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||||
await response.json();
|
await response.json();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
} from '@/types/data-browser';
|
} from '@/types/data-browser';
|
||||||
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
|
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
|
||||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||||
|
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||||
import prepareCreateTableQuery from './prepareCreateTableQuery';
|
import prepareCreateTableQuery from './prepareCreateTableQuery';
|
||||||
|
|
||||||
export interface CreateTableMigrationVariables {
|
export interface CreateTableMigrationVariables {
|
||||||
@@ -27,29 +28,26 @@ export default async function createTableMigration({
|
|||||||
}: CreateTableMigrationOptions & CreateTableMigrationVariables) {
|
}: CreateTableMigrationOptions & CreateTableMigrationVariables) {
|
||||||
const args = prepareCreateTableQuery({ dataSource, schema, table });
|
const args = prepareCreateTableQuery({ dataSource, schema, table });
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
method: 'POST',
|
'x-hasura-admin-secret': adminSecret,
|
||||||
headers: {
|
|
||||||
'x-hasura-admin-secret': adminSecret,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
dataSource,
|
|
||||||
skip_execution: false,
|
|
||||||
name: `create_table_${schema}_${table.name}`,
|
|
||||||
down: [
|
|
||||||
getPreparedHasuraQuery(
|
|
||||||
dataSource,
|
|
||||||
'DROP TABLE IF EXISTS %I.%I',
|
|
||||||
schema,
|
|
||||||
table.name,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
up: args,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
body: JSON.stringify({
|
||||||
|
dataSource,
|
||||||
|
skip_execution: false,
|
||||||
|
name: `create_table_${schema}_${table.name}`,
|
||||||
|
down: [
|
||||||
|
getPreparedHasuraQuery(
|
||||||
|
dataSource,
|
||||||
|
'DROP TABLE IF EXISTS %I.%I',
|
||||||
|
schema,
|
||||||
|
table.name,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
up: args,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||||
await response.json();
|
await response.json();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from '@/types/data-browser';
|
} from '@/types/data-browser';
|
||||||
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
|
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
|
||||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||||
|
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||||
|
|
||||||
export interface DeleteColumnMigrationVariables {
|
export interface DeleteColumnMigrationVariables {
|
||||||
/**
|
/**
|
||||||
@@ -45,30 +46,27 @@ export default async function deleteColumnMigration({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
method: 'POST',
|
'x-hasura-admin-secret': adminSecret,
|
||||||
headers: {
|
|
||||||
'x-hasura-admin-secret': adminSecret,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
dataSource,
|
|
||||||
skip_execution: false,
|
|
||||||
name: `alter_table_${schema}_${table}_drop_column_${column.id}`,
|
|
||||||
down: recreateColumnArgs,
|
|
||||||
up: [
|
|
||||||
getPreparedHasuraQuery(
|
|
||||||
dataSource,
|
|
||||||
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I CASCADE',
|
|
||||||
schema,
|
|
||||||
table,
|
|
||||||
column.id,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
body: JSON.stringify({
|
||||||
|
dataSource,
|
||||||
|
skip_execution: false,
|
||||||
|
name: `alter_table_${schema}_${table}_drop_column_${column.id}`,
|
||||||
|
down: recreateColumnArgs,
|
||||||
|
up: [
|
||||||
|
getPreparedHasuraQuery(
|
||||||
|
dataSource,
|
||||||
|
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I CASCADE',
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
column.id,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||||
await response.json();
|
await response.json();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getPreparedHasuraQuery,
|
getPreparedHasuraQuery,
|
||||||
} from '@/utils/dataBrowser/hasuraQueryHelpers';
|
} from '@/utils/dataBrowser/hasuraQueryHelpers';
|
||||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||||
|
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||||
|
|
||||||
export interface DeleteTableMigrationVariables {
|
export interface DeleteTableMigrationVariables {
|
||||||
/**
|
/**
|
||||||
@@ -39,32 +40,29 @@ export default async function deleteTable({
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
method: 'POST',
|
'x-hasura-admin-secret': adminSecret,
|
||||||
headers: {
|
|
||||||
'x-hasura-admin-secret': adminSecret,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
dataSource,
|
|
||||||
skip_execution: false,
|
|
||||||
name: `drop_table_${schema}_${table}`,
|
|
||||||
down: [
|
|
||||||
{
|
|
||||||
type: 'run_sql',
|
|
||||||
args: {
|
|
||||||
cascade: false,
|
|
||||||
read_only: false,
|
|
||||||
source: '',
|
|
||||||
sql: getEmptyDownMigrationMessage(deleteTableArgs),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
up: deleteTableArgs,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
body: JSON.stringify({
|
||||||
|
dataSource,
|
||||||
|
skip_execution: false,
|
||||||
|
name: `drop_table_${schema}_${table}`,
|
||||||
|
down: [
|
||||||
|
{
|
||||||
|
type: 'run_sql',
|
||||||
|
args: {
|
||||||
|
cascade: false,
|
||||||
|
read_only: false,
|
||||||
|
source: '',
|
||||||
|
sql: getEmptyDownMigrationMessage(deleteTableArgs),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
up: deleteTableArgs,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||||
await response.json();
|
await response.json();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
QueryResult,
|
QueryResult,
|
||||||
} from '@/types/data-browser';
|
} from '@/types/data-browser';
|
||||||
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
|
||||||
|
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
|
||||||
import prepareTrackForeignKeyRelationsMetadata from './prepareTrackForeignKeyRelationsMetadata';
|
import prepareTrackForeignKeyRelationsMetadata from './prepareTrackForeignKeyRelationsMetadata';
|
||||||
|
|
||||||
export interface TrackForeignKeyRelationsMigrationVariables {
|
export interface TrackForeignKeyRelationsMigrationVariables {
|
||||||
@@ -45,23 +46,20 @@ export default async function trackForeignKeyRelationsMigration({
|
|||||||
foreignKeyRelations,
|
foreignKeyRelations,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
|
||||||
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
|
method: 'POST',
|
||||||
{
|
headers: {
|
||||||
method: 'POST',
|
'x-hasura-admin-secret': adminSecret,
|
||||||
headers: {
|
|
||||||
'x-hasura-admin-secret': adminSecret,
|
|
||||||
},
|
|
||||||
|
|
||||||
body: JSON.stringify({
|
|
||||||
dataSource,
|
|
||||||
skip_execution: false,
|
|
||||||
name: `track_foreign_key_relations_${schema}_${table}`,
|
|
||||||
down: [],
|
|
||||||
up: creatableRelationships,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
dataSource,
|
||||||
|
skip_execution: false,
|
||||||
|
name: `track_foreign_key_relations_${schema}_${table}`,
|
||||||
|
down: [],
|
||||||
|
up: creatableRelationships,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||||
await response.json();
|
await response.json();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user