Compare commits
299 Commits
@nhost/rea
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b2e3fbd1d | ||
|
|
6f4fdcf73f | ||
|
|
cb529dc60c | ||
|
|
68a449dbfc | ||
|
|
7d0c6d083a | ||
|
|
1353477da1 | ||
|
|
549c7cb7eb | ||
|
|
e131c12d5d | ||
|
|
8bb097c9a7 | ||
|
|
ea31e64a71 | ||
|
|
e1ec5c1be2 | ||
|
|
9822a160d4 | ||
|
|
7c67a2c437 | ||
|
|
8e8884f4e1 | ||
|
|
9923be41ce | ||
|
|
9c22a616a7 | ||
|
|
6bc67e95a5 | ||
|
|
0f6074c16f | ||
|
|
c96d7ccdf2 | ||
|
|
fde7ac7c1c | ||
|
|
24ef6071cc | ||
|
|
bb993b6b03 | ||
|
|
89ca34be9a | ||
|
|
b66d095c95 | ||
|
|
0bad9ff4fa | ||
|
|
9a761f4fec | ||
|
|
bd6b55868a | ||
|
|
afb3fe490e | ||
|
|
eaebd2b028 | ||
|
|
f03ecd91a9 | ||
|
|
96f17c39b1 | ||
|
|
cb7c8c6398 | ||
|
|
4bf40995b5 | ||
|
|
ab5f704280 | ||
|
|
f65e4de955 | ||
|
|
7e0e4d05aa | ||
|
|
decb0b057c | ||
|
|
0954a44f84 | ||
|
|
700cbd9e47 | ||
|
|
3238543b08 | ||
|
|
fc79b890df | ||
|
|
211eb42af5 | ||
|
|
a7398451e3 | ||
|
|
4b4f0d0150 | ||
|
|
f37e2a23e2 | ||
|
|
3f8d68ffab | ||
|
|
f7e706724c | ||
|
|
2832d7299f | ||
|
|
44c5b386c3 | ||
|
|
1a4a061284 | ||
|
|
5a91c477f0 | ||
|
|
66f73d06a8 | ||
|
|
35d52aab87 | ||
|
|
ddd41aae99 | ||
|
|
78555c7e85 | ||
|
|
01ded8ffff | ||
|
|
3c7cf92edf | ||
|
|
bb4301fd34 | ||
|
|
832210d8ad | ||
|
|
a09dad060c | ||
|
|
76b63debf0 | ||
|
|
c8c8948755 | ||
|
|
17e9e5899e | ||
|
|
bd22c48131 | ||
|
|
095d6e918c | ||
|
|
89a239ff3a | ||
|
|
0131886011 | ||
|
|
340c014fe8 | ||
|
|
bc9c8b9456 | ||
|
|
c22b2621ba | ||
|
|
726746c4d3 | ||
|
|
c431570783 | ||
|
|
445d8ef449 | ||
|
|
0f4ea18e42 | ||
|
|
dae7c5d517 | ||
|
|
f673adea00 | ||
|
|
1c6f1e3b33 | ||
|
|
6593e8d3eb | ||
|
|
d1365ea516 | ||
|
|
72dbba7881 | ||
|
|
a3f3991d5a | ||
|
|
c71fe2cf72 | ||
|
|
24c5ed3ea4 | ||
|
|
2d9145f918 | ||
|
|
9a0ab5b887 | ||
|
|
1ddf704c5b | ||
|
|
6f4ee845c6 | ||
|
|
0368663dea | ||
|
|
76ce7d7b6e | ||
|
|
538bfbcb3e | ||
|
|
07b35d1e5f | ||
|
|
2200a0ed07 | ||
|
|
9219838127 | ||
|
|
df23d97126 | ||
|
|
43b68a79eb | ||
|
|
104f149369 | ||
|
|
01228583a0 | ||
|
|
93309dd851 | ||
|
|
2cc18dcb51 | ||
|
|
3b48a62790 | ||
|
|
8897dec056 | ||
|
|
324dda8309 | ||
|
|
ac845c6d92 | ||
|
|
95f62bed07 | ||
|
|
f4af81020b | ||
|
|
0e4d8ff118 | ||
|
|
6999562b59 | ||
|
|
baec5bada7 | ||
|
|
4e56cfc628 | ||
|
|
d167121093 | ||
|
|
822e251b11 | ||
|
|
328c6bb486 | ||
|
|
bef8198cbf | ||
|
|
179313d8a2 | ||
|
|
54bc91923f | ||
|
|
c3ce004f46 | ||
|
|
77b12feb95 | ||
|
|
32d4670bbb | ||
|
|
1dc09942d2 | ||
|
|
3343a36358 | ||
|
|
b755e9086c | ||
|
|
48866d0ee1 | ||
|
|
5b3b76bd41 | ||
|
|
7f7e7ea7d4 | ||
|
|
aaaf2dc9c5 | ||
|
|
fa9c1ea28c | ||
|
|
87eda76e2b | ||
|
|
8a596f2a9e | ||
|
|
d6d2381598 | ||
|
|
284ef7e7f2 | ||
|
|
6d5c202da9 | ||
|
|
962563d6a0 | ||
|
|
8bf58ba26b | ||
|
|
0c175e7a11 | ||
|
|
70f2fbcfc2 | ||
|
|
d2c4ad3260 | ||
|
|
a9ca2c2946 | ||
|
|
d854dd74b1 | ||
|
|
6f0ac5706c | ||
|
|
9342937440 | ||
|
|
e89cd4e262 | ||
|
|
a05438352b | ||
|
|
78437959bb | ||
|
|
e1a7433adb | ||
|
|
b45aa420d9 | ||
|
|
1d76de3f60 | ||
|
|
e23cf74975 | ||
|
|
a3d01c4fad | ||
|
|
4cdcef9ef5 | ||
|
|
df894ef7e2 | ||
|
|
f7dd6a9fc6 | ||
|
|
2949ff0f62 | ||
|
|
9e37ca4cbc | ||
|
|
af57ccce0f | ||
|
|
5f44aefcc6 | ||
|
|
1527b0a455 | ||
|
|
375e53a3f0 | ||
|
|
96e3ca5a32 | ||
|
|
0e570df9c5 | ||
|
|
1f4c67283e | ||
|
|
fc1c4861a3 | ||
|
|
74feaf6add | ||
|
|
8cd97206cc | ||
|
|
02197639f2 | ||
|
|
38b594aef9 | ||
|
|
f3a8886cd0 | ||
|
|
96f9278c8f | ||
|
|
8d76cf8d40 | ||
|
|
3e1fb974e4 | ||
|
|
9fe2ecd317 | ||
|
|
ada5309b49 | ||
|
|
08698f8246 | ||
|
|
f74871d872 | ||
|
|
0b56e31408 | ||
|
|
c4e3e3f91f | ||
|
|
483fd6c7f4 | ||
|
|
ac37d7bcae | ||
|
|
9adf91ba87 | ||
|
|
d11f6eebb0 | ||
|
|
8a678fbc87 | ||
|
|
6411ec3ec3 | ||
|
|
5187fe76aa | ||
|
|
859f457e4a | ||
|
|
dc2b5b4429 | ||
|
|
b7645e7892 | ||
|
|
3f26056688 | ||
|
|
b1338246aa | ||
|
|
d04ccd600e | ||
|
|
d483ad5602 | ||
|
|
bcf3e6bc2c | ||
|
|
575ff4e9b5 | ||
|
|
2010638540 | ||
|
|
0346495a79 | ||
|
|
2babb0b6f3 | ||
|
|
1f293d0f0c | ||
|
|
af4c886437 | ||
|
|
c182b3ca4b | ||
|
|
d5344ed31f | ||
|
|
6a7801be93 | ||
|
|
7bc5bb857c | ||
|
|
c957039d75 | ||
|
|
96c4032424 | ||
|
|
ec70126b56 | ||
|
|
86b9f9040c | ||
|
|
222f03725b | ||
|
|
10b786e5c6 | ||
|
|
aa8ae88d12 | ||
|
|
0f2c86b41a | ||
|
|
a4c76892dd | ||
|
|
00d278b2cc | ||
|
|
cb6b5faeb9 | ||
|
|
7c4c847b91 | ||
|
|
908887d8c5 | ||
|
|
a2d67bc2db | ||
|
|
1a6cd78254 | ||
|
|
6500629c4b | ||
|
|
add3c2c10e | ||
|
|
dd29b06260 | ||
|
|
490cb25a0f | ||
|
|
0df0dd741e | ||
|
|
2172946879 | ||
|
|
40e50f0e75 | ||
|
|
65cf0888b5 | ||
|
|
21833019ca | ||
|
|
b3171ba3e9 | ||
|
|
6f01f19d02 | ||
|
|
ce92b01eac | ||
|
|
e24a177434 | ||
|
|
56a52b6d48 | ||
|
|
92bfa8c723 | ||
|
|
2a52aaa4a6 | ||
|
|
8280a3e9d8 | ||
|
|
523f60bf68 | ||
|
|
19b11d4084 | ||
|
|
805bae1507 | ||
|
|
f6c014c06f | ||
|
|
c5794f4596 | ||
|
|
fc28817380 | ||
|
|
80bbd3a165 | ||
|
|
7a10617a72 | ||
|
|
f0b6dca1a5 | ||
|
|
5db20adc34 | ||
|
|
12dc41a517 | ||
|
|
768fd56891 | ||
|
|
8a508cb1cc | ||
|
|
34f6a8eef4 | ||
|
|
c9d2d31a9b | ||
|
|
68fb23a361 | ||
|
|
476139e528 | ||
|
|
6a850818a0 | ||
|
|
3970dbba0d | ||
|
|
8ee2166f0d | ||
|
|
e13500a185 | ||
|
|
411f574a51 | ||
|
|
7fc91b992e | ||
|
|
b840012be0 | ||
|
|
645c51a9dc | ||
|
|
0ce6f05539 | ||
|
|
8b1188af53 | ||
|
|
12b01f8dee | ||
|
|
60f4faf409 | ||
|
|
528dff3f1b | ||
|
|
d429fb4a3e | ||
|
|
816c916709 | ||
|
|
b7a2b8b537 | ||
|
|
261d8cf434 | ||
|
|
41f49bde76 | ||
|
|
65f685bdb2 | ||
|
|
f52a7f4aac | ||
|
|
e71b9903d9 | ||
|
|
325fd08aef | ||
|
|
3888704464 | ||
|
|
38e8a10a29 | ||
|
|
d8545eae12 | ||
|
|
3d5bfd87d2 | ||
|
|
e66c5626bd | ||
|
|
a227c6561e | ||
|
|
e885c159df | ||
|
|
09fcb74bef | ||
|
|
a089197197 | ||
|
|
34f843875b | ||
|
|
ca278a8c39 | ||
|
|
75603786e0 | ||
|
|
4e4e699b94 | ||
|
|
da31fa9fba | ||
|
|
95e2afaf47 | ||
|
|
958a56dde9 | ||
|
|
74cb15930e | ||
|
|
aa37a98424 | ||
|
|
11cbdda3a5 | ||
|
|
6d1f4adf10 | ||
|
|
ddbc50c15e | ||
|
|
b2cbf570a3 | ||
|
|
22b8e65031 | ||
|
|
63c94d2036 | ||
|
|
010df48c1e | ||
|
|
fdc11db93d | ||
|
|
cb4749f168 | ||
|
|
46a8fcf471 |
18
.github/CODEOWNERS
vendored
18
.github/CODEOWNERS
vendored
@@ -1,14 +1,14 @@
|
||||
# Documentation
|
||||
# https://help.github.com/en/articles/about-code-owners
|
||||
|
||||
/packages @plmercereau @szilarddoro
|
||||
/packages @szilarddoro
|
||||
/packages/docgen @szilarddoro
|
||||
/integrations/stripe-graphql-js @elitan
|
||||
/.github @plmercereau
|
||||
/dashboard/ @szilarddoro @guicurcio
|
||||
/docs/ @guicurcio @elitan
|
||||
/config/ @plmercereau @szilarddoro
|
||||
/examples/ @plmercereau
|
||||
/examples/codegen-react-apollo @elitan @plmercereau
|
||||
/examples/codegen-react-query @elitan @plmercereau
|
||||
/examples/react-apollo-crm @elitan @plmercereau
|
||||
/.github @szilarddoro
|
||||
/dashboard/ @szilarddoro
|
||||
/docs/ @elitan
|
||||
/config/ @szilarddoro
|
||||
/examples/ @szilarddoro
|
||||
/examples/codegen-react-apollo @elitan @szilarddoro
|
||||
/examples/codegen-react-query @elitan @szilarddoro
|
||||
/examples/react-apollo-crm @elitan @szilarddoro
|
||||
|
||||
@@ -40,14 +40,14 @@ runs:
|
||||
- shell: bash
|
||||
name: Build packages
|
||||
if: ${{ inputs.BUILD == 'all' }}
|
||||
run: pnpm build:all
|
||||
run: pnpm run build:all
|
||||
env:
|
||||
TURBO_TOKEN: ${{ inputs.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ inputs.TURBO_TEAM }}
|
||||
- shell: bash
|
||||
name: Build everything in the monorepo
|
||||
if: ${{ inputs.BUILD == 'default' }}
|
||||
run: pnpm build
|
||||
run: pnpm run build
|
||||
env:
|
||||
TURBO_TOKEN: ${{ inputs.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ inputs.TURBO_TEAM }}
|
||||
|
||||
3
.github/workflows/ci.yaml
vendored
3
.github/workflows/ci.yaml
vendored
@@ -8,7 +8,6 @@ on:
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize]
|
||||
paths-ignore:
|
||||
- 'assets/**'
|
||||
@@ -56,7 +55,7 @@ jobs:
|
||||
| xargs -I@ realpath --relative-to=$PWD @ \
|
||||
| xargs -I@ jq "if (.scripts.e2e | length) != 0 then {name: .name, path: \"@\"} else null end" @/package.json \
|
||||
| awk "!/null/" \
|
||||
| jq -c --slurp)
|
||||
| jq -c --slurp 'map(select(length > 0))')
|
||||
echo "matrix=$PACKAGES" >> $GITHUB_OUTPUT
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
"@nhost/docgen": [
|
||||
"../packages/docgen/src/index.ts"
|
||||
],
|
||||
"@nhost/graphql-js": [
|
||||
"../packages/graphql-js/src/index.ts"
|
||||
],
|
||||
"@nhost/hasura-auth-js": [
|
||||
"../packages/hasura-auth-js/src/index.ts"
|
||||
],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import replace from '@rollup/plugin-replace'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import dts from 'vite-plugin-dts'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
@@ -21,7 +20,9 @@ export default defineConfig({
|
||||
exclude: ['**/*.spec.ts', '**/*.test.ts', '**/tests/**'],
|
||||
entryRoot: 'src',
|
||||
// Was defaulting to true until version 1.7
|
||||
skipDiagnostics: true
|
||||
skipDiagnostics: true,
|
||||
// Was defaulting to true until version 2.0
|
||||
copyDtsFiles: true
|
||||
})
|
||||
],
|
||||
test: {
|
||||
@@ -61,7 +62,6 @@ export default defineConfig({
|
||||
'@apollo/client/utilities': '@apollo/client/utilities',
|
||||
'graphql-ws': 'graphql-ws',
|
||||
xstate: 'xstate',
|
||||
axios: 'axios',
|
||||
'js-cookie': 'Cookies',
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
|
||||
@@ -30,6 +30,7 @@ module.exports = {
|
||||
'error',
|
||||
{ ignoreTypeReferences: true },
|
||||
],
|
||||
'no-console': ['warn', { allow: ['error'] }],
|
||||
'no-shadow': 'off',
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
|
||||
@@ -1,5 +1,107 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.12.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4bf40995: chore(deps): bump `typescript` to `4.9.5`
|
||||
- 8bb097c9: chore(deps): bump `vitest`
|
||||
- 35d52aab: chore(deps): replace `cross-fetch` with `isomorphic-unfetch`
|
||||
- Updated dependencies [4bf40995]
|
||||
- Updated dependencies [8bb097c9]
|
||||
- Updated dependencies [35d52aab]
|
||||
- @nhost/react-apollo@5.0.7
|
||||
- @nhost/nextjs@1.13.12
|
||||
|
||||
## 0.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c96d7ccd: fix(dashboard): fix docker builds
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d1671210: feat(dashboard): use mimir to manage project configuration
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f65e4de9: chore(deps): bump @graphql-codegen monorepo to v3
|
||||
|
||||
## 0.11.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4b4f0d01: chore(dashboard): improve dialog management
|
||||
|
||||
## 0.11.19
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.6
|
||||
- @nhost/nextjs@1.13.11
|
||||
|
||||
## 0.11.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 01318860: fix(nhost-js): use correct URL for functions requests
|
||||
- Updated dependencies [01318860]
|
||||
- @nhost/react-apollo@5.0.5
|
||||
- @nhost/nextjs@1.13.10
|
||||
|
||||
## 0.11.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f673adea: fix(dashboard): set correct Content-Type for user creation
|
||||
- 445d8ef4: chore(deps): bump `@nhost/react-apollo` to 5.0.4
|
||||
- 445d8ef4: chore(deps): bump `@nhost/nextjs` to 1.13.9
|
||||
- 0368663d: fix(dashboard): allow permission editing for auth and storage schemas
|
||||
- Updated dependencies [445d8ef4]
|
||||
- Updated dependencies [445d8ef4]
|
||||
- @nhost/react-apollo@5.0.4
|
||||
- @nhost/nextjs@1.13.9
|
||||
|
||||
## 0.11.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b755e908: fix(dashboard): use correct date for last seen
|
||||
- 2d9145f9: chore(deps): revert GraphQL client
|
||||
- 1ddf704c: fix(dashboard): don't show false positive message for failed user creation
|
||||
- @nhost/react-apollo@5.0.3
|
||||
- @nhost/nextjs@1.13.8
|
||||
|
||||
## 0.11.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react-apollo@5.0.2
|
||||
- @nhost/nextjs@1.13.7
|
||||
|
||||
## 0.11.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2cc18dcb: fix(dashboard): prevent permission editor dropdown from being always open
|
||||
|
||||
## 0.11.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3343a363: chore(dashboard): bump `@testing-library/react` to v14 and `@testing-library/dom` to v9
|
||||
- @nhost/react-apollo@5.0.1
|
||||
- @nhost/nextjs@1.13.6
|
||||
|
||||
## 0.11.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 87eda76e: chore(dashboard): bump `@types/react` to v18.0.28 and `@types/react-dom` to v18.0.11
|
||||
- 6f0ac570: feat(dashboard): show dashboard version in account menu
|
||||
|
||||
## 0.11.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -11,7 +11,7 @@ FROM node:16-alpine AS builder
|
||||
ARG TURBO_TOKEN
|
||||
ARG TURBO_TEAM
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ pnpm storybook
|
||||
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. Not necessary for local development. |
|
||||
| `NEXT_PUBLIC_MAINTENANCE_ACTIVE` | Determines whether or not maintenance mode is active. |
|
||||
| `NEXT_PUBLIC_MAINTENANCE_END_DATE` | Date when maintenance mode will end. |
|
||||
| `NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET` | Secret that can be used to bypass maintenance mode. |
|
||||
|
||||
## ESLint Rules
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const path = require('path');
|
||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
});
|
||||
const { version } = require('./package.json');
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
reactStrictMode: true,
|
||||
@@ -10,6 +11,9 @@ module.exports = withBundleAnalyzer({
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
},
|
||||
publicRuntimeConfig: {
|
||||
version,
|
||||
},
|
||||
eslint: {
|
||||
dirs: ['src'],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.11.11",
|
||||
"version": "0.12.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -44,10 +44,8 @@
|
||||
"@tanstack/react-table": "^8.5.30",
|
||||
"@tanstack/react-virtual": "^3.0.0-beta.23",
|
||||
"analytics-node": "^6.2.0",
|
||||
"axios": "^0.27.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"clsx": "^1.2.1",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"date-fns": "^2.29.3",
|
||||
"generate-password": "^1.7.0",
|
||||
"graphiql": "^2.2.0",
|
||||
@@ -65,7 +63,7 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-hook-form": "^7.39.5",
|
||||
"react-hook-form": "^7.42.1",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-is": "18.2.0",
|
||||
"react-loading-skeleton": "^2.2.0",
|
||||
@@ -83,10 +81,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.2",
|
||||
"@graphql-codegen/cli": "^2.8.0",
|
||||
"@graphql-codegen/typescript": "^2.7.1",
|
||||
"@graphql-codegen/cli": "^3.0.0",
|
||||
"@graphql-codegen/typescript": "^3.0.0",
|
||||
"@graphql-codegen/typescript-graphql-request": "^4.5.1",
|
||||
"@graphql-codegen/typescript-operations": "^2.5.1",
|
||||
"@graphql-codegen/typescript-operations": "^3.0.0",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.1",
|
||||
"@next/bundle-analyzer": "^12.3.1",
|
||||
"@storybook/addon-actions": "^6.5.14",
|
||||
@@ -98,22 +96,22 @@
|
||||
"@storybook/manager-webpack5": "^6.5.14",
|
||||
"@storybook/react": "^6.5.14",
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
"@testing-library/dom": "^8.19.0",
|
||||
"@testing-library/dom": "^9.0.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^16.11.7",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/react": "18.0.25",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"@types/react": "18.0.28",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/react-table": "^7.7.12",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/validator": "^13.7.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||
"@typescript-eslint/parser": "^5.43.0",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"@vitest/coverage-c8": "^0.27.0",
|
||||
"@vitest/coverage-c8": "^0.29.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"babel-loader": "^8.3.0",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
@@ -131,6 +129,7 @@
|
||||
"lint-staged": ">=13",
|
||||
"msw": "^1.0.1",
|
||||
"msw-storybook-addon": "^1.6.3",
|
||||
"node-fetch": "^3.3.0",
|
||||
"postcss": "^8.4.19",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.0",
|
||||
@@ -141,10 +140,9 @@
|
||||
"tailwindcss": "^3.1.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||
"typescript": "^4.8.4",
|
||||
"vite": "^4.0.2",
|
||||
"vite-tsconfig-paths": "^4.0.3",
|
||||
"vitest": "^0.27.0",
|
||||
"vitest": "^0.29.0",
|
||||
"webpack": "^5.75.0"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 176 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 140 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" fill="white" fill-opacity="0.15"/>
|
||||
<rect width="200" height="200" fill="#263245" fill-opacity="0.08"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M71 84C71 67.9837 83.9837 55 100 55C116.016 55 129 67.9837 129 84C129 100.016 116.016 113 100 113C83.9837 113 71 100.016 71 84ZM100 49C80.67 49 65 64.67 65 84C65 97.6014 72.7585 109.391 84.0914 115.184C79.3584 116.509 74.7892 118.425 70.496 120.903C61.5257 126.08 54.0757 133.527 48.8946 142.495C48.0657 143.929 48.5568 145.764 49.9914 146.593C51.4261 147.422 53.261 146.931 54.0898 145.496C58.7443 137.44 65.4368 130.75 73.4952 126.099C81.5536 121.448 90.694 119 99.9982 119C109.302 119 118.443 121.449 126.501 126.1C134.559 130.751 141.252 137.441 145.906 145.497C146.735 146.932 148.57 147.423 150.004 146.594C151.439 145.765 151.93 143.93 151.101 142.496C145.92 133.527 138.471 126.081 129.5 120.903C125.208 118.426 120.639 116.509 115.907 115.185C127.241 109.392 135 97.6021 135 84C135 64.67 119.33 49 100 49Z" fill="white" fill-opacity="0.15"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M71 84C71 67.9837 83.9837 55 100 55C116.016 55 129 67.9837 129 84C129 100.016 116.016 113 100 113C83.9837 113 71 100.016 71 84ZM100 49C80.67 49 65 64.67 65 84C65 97.6014 72.7585 109.391 84.0914 115.184C79.3584 116.509 74.7892 118.425 70.496 120.903C61.5257 126.08 54.0757 133.527 48.8946 142.495C48.0657 143.929 48.5568 145.764 49.9914 146.593C51.4261 147.422 53.261 146.931 54.0898 145.496C58.7443 137.44 65.4368 130.75 73.4952 126.099C81.5536 121.448 90.694 119 99.9982 119C109.302 119 118.443 121.449 126.501 126.1C134.559 130.751 141.252 137.441 145.906 145.497C146.735 146.932 148.57 147.423 150.004 146.594C151.439 145.765 151.93 143.93 151.101 142.496C145.92 133.527 138.471 126.081 129.5 120.903C125.208 118.426 120.639 116.509 115.907 115.185C127.241 109.392 135 97.6021 135 84C135 64.67 119.33 49 100 49Z" fill="#263245" fill-opacity="0.25"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,3 +1,4 @@
|
||||
import MaintenanceAlert from '@/components/common/MaintenanceAlert';
|
||||
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
||||
import Container from '@/components/layout/Container';
|
||||
import { features } from '@/components/overview/features';
|
||||
@@ -52,6 +53,7 @@ export default function ApplicationLive() {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<MaintenanceAlert />
|
||||
<OverviewTopBar />
|
||||
|
||||
<div className="grid grid-cols-1 gap-12 pt-3 lg:grid-cols-3">
|
||||
|
||||
@@ -22,11 +22,9 @@ interface HasuraDataProps {
|
||||
export function HasuraData({ close }: HasuraDataProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const isPlatform = useIsPlatform();
|
||||
const projectAdminSecret = currentApplication?.config?.hasura.adminSecret;
|
||||
|
||||
if (
|
||||
!currentApplication?.subdomain ||
|
||||
!currentApplication?.hasuraGraphqlAdminSecret
|
||||
) {
|
||||
if (!currentApplication?.subdomain || !projectAdminSecret) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
@@ -71,18 +69,11 @@ export function HasuraData({ close }: HasuraDataProps) {
|
||||
|
||||
<div className="col-span-1 grid grid-flow-col items-center justify-center gap-2 sm:col-span-2 sm:justify-end">
|
||||
<Text className="font-medium" variant="subtitle2">
|
||||
{Array(currentApplication.hasuraGraphqlAdminSecret.length)
|
||||
.fill('•')
|
||||
.join('')}
|
||||
{Array(projectAdminSecret.length).fill('•').join('')}
|
||||
</Text>
|
||||
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
copy(
|
||||
currentApplication.hasuraGraphqlAdminSecret,
|
||||
'Hasura admin secret',
|
||||
)
|
||||
}
|
||||
onClick={() => copy(projectAdminSecret, 'Hasura admin secret')}
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="min-w-0 p-1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FindOldApps } from '@/components/home';
|
||||
import type { UserData } from '@/hooks/useGetAllUserWorkspacesAndApplications';
|
||||
import type { Application, ApplicationState } from '@/types/application';
|
||||
import type { ApplicationState } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import StateBadge from '@/ui/StateBadge';
|
||||
@@ -56,7 +56,7 @@ function CurrentDeployment({ deployment }: any) {
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
className="mr-1 h-4 w-4 self-center"
|
||||
/>
|
||||
<Text className="self-center text-sm">
|
||||
<Text component="span" className="self-center text-sm">
|
||||
{deployment.commitUserName} updated just now
|
||||
</Text>
|
||||
</span>
|
||||
@@ -103,7 +103,7 @@ export function RenderWorkspacesWithApps({
|
||||
}
|
||||
|
||||
const workspaceProjects = workspace.applications
|
||||
.filter((app: Application) =>
|
||||
.filter((app) =>
|
||||
app.name.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
.sort((appA, appB) => {
|
||||
@@ -142,7 +142,9 @@ export function RenderWorkspacesWithApps({
|
||||
<List className="grid grid-flow-row border-y">
|
||||
{workspaceProjects.map((app, index) => {
|
||||
const isDeployingToProduction = app.deployments[0]
|
||||
? app.deployments[0].deploymentStatus === 'DEPLOYING'
|
||||
? ['SCHEDULED', 'PENDING', 'DEPLOYING'].includes(
|
||||
app.deployments[0].deploymentStatus,
|
||||
)
|
||||
: false;
|
||||
|
||||
return (
|
||||
|
||||
@@ -52,7 +52,9 @@ function ControlledAutocomplete(
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
inputValue={typeof field.value === 'string' ? field.value : undefined}
|
||||
inputValue={
|
||||
typeof field.value !== 'object' ? field.value.toString() : undefined
|
||||
}
|
||||
{...props}
|
||||
{...field}
|
||||
ref={mergeRefs([field.ref, ref])}
|
||||
|
||||
@@ -205,7 +205,7 @@ export default function DataGridPreviewCell<TData extends object>({
|
||||
}
|
||||
|
||||
const { presignedUrl } = await appClient.storage
|
||||
.setAdminSecret(currentApplication.hasuraGraphqlAdminSecret)
|
||||
.setAdminSecret(currentApplication.config?.hasura.adminSecret)
|
||||
.getPresignedUrl({ fileId: id });
|
||||
|
||||
if (!presignedUrl) {
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import type { CommonDialogProps } from '@/ui/v2/Dialog';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { createContext } from 'react';
|
||||
|
||||
/**
|
||||
* Available dialog types.
|
||||
*/
|
||||
export type DialogType =
|
||||
| 'EDIT_WORKSPACE_NAME'
|
||||
| 'CREATE_RECORD'
|
||||
| 'CREATE_COLUMN'
|
||||
| 'EDIT_COLUMN'
|
||||
| 'CREATE_TABLE'
|
||||
| 'EDIT_TABLE'
|
||||
| 'EDIT_PERMISSIONS'
|
||||
| 'CREATE_FOREIGN_KEY'
|
||||
| 'EDIT_FOREIGN_KEY'
|
||||
| 'CREATE_ROLE'
|
||||
| 'EDIT_ROLE'
|
||||
| 'CREATE_USER'
|
||||
| 'CREATE_PERMISSION_VARIABLE'
|
||||
| 'EDIT_PERMISSION_VARIABLE'
|
||||
| 'CREATE_ENVIRONMENT_VARIABLE'
|
||||
| 'EDIT_ENVIRONMENT_VARIABLE'
|
||||
| 'EDIT_USER'
|
||||
| 'EDIT_USER_PASSWORD'
|
||||
| 'EDIT_JWT_SECRET';
|
||||
|
||||
export interface DialogConfig<TPayload = unknown> {
|
||||
/**
|
||||
* Title of the dialog.
|
||||
@@ -41,21 +18,36 @@ export interface DialogConfig<TPayload = unknown> {
|
||||
payload?: TPayload;
|
||||
}
|
||||
|
||||
export interface OpenDialogOptions {
|
||||
/**
|
||||
* Title of the dialog.
|
||||
*/
|
||||
title: ReactNode;
|
||||
/**
|
||||
* Component to render inside the dialog skeleton.
|
||||
*/
|
||||
component: ReactElement<{
|
||||
location?: 'drawer' | 'dialog';
|
||||
onCancel?: () => void;
|
||||
onSubmit?: (args?: any) => Promise<any> | void;
|
||||
}>;
|
||||
/**
|
||||
* Props to pass to the root dialog component.
|
||||
*/
|
||||
props?: Partial<CommonDialogProps>;
|
||||
}
|
||||
|
||||
export interface DialogContextProps {
|
||||
/**
|
||||
* Call this function to open a dialog.
|
||||
* Call this function to open a dialog. It will automatically apply the
|
||||
* necessary functionality to the dialog.
|
||||
*/
|
||||
openDialog: <TPayload = unknown>(
|
||||
type: DialogType,
|
||||
config?: DialogConfig<TPayload>,
|
||||
) => void;
|
||||
openDialog: (options: OpenDialogOptions) => void;
|
||||
/**
|
||||
* Call this function to open a drawer.
|
||||
* Call this function to open a drawer. It will automatically apply the
|
||||
* necessary functionality to the drawer.
|
||||
*/
|
||||
openDrawer: <TPayload = unknown>(
|
||||
type: DialogType,
|
||||
config?: DialogConfig<TPayload>,
|
||||
) => void;
|
||||
openDrawer: (options: OpenDialogOptions) => void;
|
||||
/**
|
||||
* Call this function to open an alert dialog.
|
||||
*/
|
||||
@@ -87,7 +79,7 @@ export interface DialogContextProps {
|
||||
*/
|
||||
onDirtyStateChange: (
|
||||
isDirty: boolean,
|
||||
location?: 'drawer' | 'dialog',
|
||||
location?: DialogFormProps['location'],
|
||||
) => void;
|
||||
/**
|
||||
* Call this function to open a dirty confirmation dialog.
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
||||
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
|
||||
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
|
||||
import EditWorkspaceNameForm from '@/components/home/EditWorkspaceNameForm';
|
||||
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
|
||||
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
|
||||
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
|
||||
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 CreateUserForm from '@/components/users/CreateUserForm';
|
||||
import EditUserForm from '@/components/users/EditUserForm';
|
||||
import EditUserPasswordForm from '@/components/users/EditUserPasswordForm';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import AlertDialog from '@/ui/v2/AlertDialog';
|
||||
import { BaseDialog } from '@/ui/v2/Dialog';
|
||||
import Drawer from '@/ui/v2/Drawer';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/router';
|
||||
import type {
|
||||
BaseSyntheticEvent,
|
||||
DetailedHTMLProps,
|
||||
HTMLProps,
|
||||
PropsWithChildren,
|
||||
} from 'react';
|
||||
import type { BaseSyntheticEvent, PropsWithChildren } from 'react';
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -33,7 +15,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { DialogConfig, DialogType } from './DialogContext';
|
||||
import type { DialogConfig, OpenDialogOptions } from './DialogContext';
|
||||
import DialogContext from './DialogContext';
|
||||
import {
|
||||
alertDialogReducer,
|
||||
@@ -41,67 +23,11 @@ import {
|
||||
drawerReducer,
|
||||
} from './dialogReducers';
|
||||
|
||||
function LoadingComponent({
|
||||
className,
|
||||
...props
|
||||
}: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> = {}) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={twMerge(
|
||||
'grid items-center justify-center px-6 py-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ActivityIndicator
|
||||
circularProgressProps={{ className: 'w-5 h-5' }}
|
||||
label="Loading form..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CreateRecordForm = dynamic(
|
||||
() => import('@/components/dataBrowser/CreateRecordForm'),
|
||||
{ ssr: false, loading: () => LoadingComponent() },
|
||||
);
|
||||
|
||||
const CreateColumnForm = dynamic(
|
||||
() => import('@/components/dataBrowser/CreateColumnForm'),
|
||||
{ ssr: false, loading: () => LoadingComponent() },
|
||||
);
|
||||
|
||||
const EditColumnForm = dynamic(
|
||||
() => import('@/components/dataBrowser/EditColumnForm'),
|
||||
{ ssr: false, loading: () => LoadingComponent() },
|
||||
);
|
||||
|
||||
const CreateTableForm = dynamic(
|
||||
() => import('@/components/dataBrowser/CreateTableForm'),
|
||||
{ ssr: false, loading: () => LoadingComponent() },
|
||||
);
|
||||
|
||||
const EditTableForm = dynamic(
|
||||
() => import('@/components/dataBrowser/EditTableForm'),
|
||||
{ ssr: false, loading: () => LoadingComponent() },
|
||||
);
|
||||
|
||||
const EditPermissionsForm = dynamic(
|
||||
() => import('@/components/dataBrowser/EditPermissionsForm'),
|
||||
{ ssr: false, loading: () => LoadingComponent() },
|
||||
);
|
||||
|
||||
function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
const router = useRouter();
|
||||
|
||||
const [
|
||||
{
|
||||
open: dialogOpen,
|
||||
activeDialogType,
|
||||
dialogProps,
|
||||
title: dialogTitle,
|
||||
payload: dialogPayload,
|
||||
},
|
||||
{ open: dialogOpen, title: dialogTitle, activeDialog, dialogProps },
|
||||
dialogDispatch,
|
||||
] = useReducer(dialogReducer, {
|
||||
open: false,
|
||||
@@ -110,10 +36,9 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
const [
|
||||
{
|
||||
open: drawerOpen,
|
||||
activeDialogType: activeDrawerType,
|
||||
dialogProps: drawerProps,
|
||||
title: drawerTitle,
|
||||
payload: drawerPayload,
|
||||
activeDialog: activeDrawer,
|
||||
dialogProps: drawerProps,
|
||||
},
|
||||
drawerDispatch,
|
||||
] = useReducer(drawerReducer, {
|
||||
@@ -136,12 +61,9 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
const isDialogDirty = useRef(false);
|
||||
const [showDirtyConfirmation, setShowDirtyConfirmation] = useState(false);
|
||||
|
||||
const openDialog = useCallback(
|
||||
<TConfig,>(type: DialogType, config?: DialogConfig<TConfig>) => {
|
||||
dialogDispatch({ type: 'OPEN_DIALOG', payload: { type, config } });
|
||||
},
|
||||
[],
|
||||
);
|
||||
const openDialog = useCallback((options: OpenDialogOptions) => {
|
||||
dialogDispatch({ type: 'OPEN_DIALOG', payload: options });
|
||||
}, []);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
dialogDispatch({ type: 'HIDE_DIALOG' });
|
||||
@@ -152,12 +74,9 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
dialogDispatch({ type: 'CLEAR_DIALOG_CONTENT' });
|
||||
}, []);
|
||||
|
||||
const openDrawer = useCallback(
|
||||
<TConfig,>(type: DialogType, config?: DialogConfig<TConfig>) => {
|
||||
drawerDispatch({ type: 'OPEN_DRAWER', payload: { type, config } });
|
||||
},
|
||||
[],
|
||||
);
|
||||
const openDrawer = useCallback((options: OpenDialogOptions) => {
|
||||
drawerDispatch({ type: 'OPEN_DRAWER', payload: options });
|
||||
}, []);
|
||||
|
||||
const closeDrawer = useCallback(() => {
|
||||
drawerDispatch({ type: 'HIDE_DRAWER' });
|
||||
@@ -228,9 +147,6 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
[closeDialog, openDirtyConfirmation],
|
||||
);
|
||||
|
||||
// We are coupling this logic with the location of the dialog content which is
|
||||
// not ideal. We shoule figure out a better logic for tracking the dirty
|
||||
// state in the future.
|
||||
const onDirtyStateChange = useCallback(
|
||||
(dirty: boolean, location: 'drawer' | 'dialog' = 'drawer') => {
|
||||
if (location === 'dialog') {
|
||||
@@ -271,25 +187,6 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
],
|
||||
);
|
||||
|
||||
const sharedDialogProps = {
|
||||
...dialogPayload,
|
||||
onSubmit: async (values: any) => {
|
||||
await dialogPayload?.onSubmit?.(values);
|
||||
|
||||
closeDialog();
|
||||
},
|
||||
onCancel: closeDialogWithDirtyGuard,
|
||||
};
|
||||
|
||||
const sharedDrawerProps = {
|
||||
onSubmit: async () => {
|
||||
await drawerPayload?.onSubmit();
|
||||
|
||||
closeDrawer();
|
||||
},
|
||||
onCancel: closeDrawerWithDirtyGuard,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleCloseDrawerAndDialog() {
|
||||
if (isDrawerDirty.current || isDialogDirty.current) {
|
||||
@@ -367,56 +264,20 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
<RetryableErrorBoundary
|
||||
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
|
||||
>
|
||||
{activeDialogType === 'EDIT_WORKSPACE_NAME' && (
|
||||
<EditWorkspaceNameForm {...sharedDialogProps} />
|
||||
)}
|
||||
|
||||
{activeDialogType === 'CREATE_FOREIGN_KEY' && (
|
||||
<CreateForeignKeyForm {...sharedDialogProps} />
|
||||
)}
|
||||
|
||||
{activeDialogType === 'EDIT_FOREIGN_KEY' && (
|
||||
<EditForeignKeyForm {...sharedDialogProps} />
|
||||
)}
|
||||
|
||||
{activeDialogType === 'CREATE_ROLE' && (
|
||||
<CreateRoleForm {...sharedDialogProps} />
|
||||
)}
|
||||
|
||||
{activeDialogType === 'EDIT_ROLE' && (
|
||||
<EditRoleForm {...sharedDialogProps} />
|
||||
)}
|
||||
|
||||
{activeDialogType === 'CREATE_USER' && (
|
||||
<CreateUserForm {...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} />
|
||||
)}
|
||||
|
||||
{activeDialogType === 'EDIT_USER_PASSWORD' && (
|
||||
<EditUserPasswordForm
|
||||
{...sharedDialogProps}
|
||||
user={sharedDialogProps?.user}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDialogType === 'EDIT_JWT_SECRET' && (
|
||||
<EditJwtSecretForm {...sharedDialogProps} />
|
||||
)}
|
||||
{isValidElement(activeDialog)
|
||||
? cloneElement(activeDialog, {
|
||||
...activeDialog.props,
|
||||
location: 'dialog',
|
||||
onSubmit: async (values?: any) => {
|
||||
await activeDialog?.props?.onSubmit?.(values);
|
||||
closeDialog();
|
||||
},
|
||||
onCancel: () => {
|
||||
activeDialog?.props?.onCancel?.();
|
||||
closeDialogWithDirtyGuard();
|
||||
},
|
||||
})
|
||||
: null}
|
||||
</RetryableErrorBoundary>
|
||||
</BaseDialog>
|
||||
|
||||
@@ -436,51 +297,20 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
}}
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
{activeDrawerType === 'CREATE_RECORD' && (
|
||||
<CreateRecordForm
|
||||
{...sharedDrawerProps}
|
||||
columns={drawerPayload?.columns}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDrawerType === 'CREATE_COLUMN' && (
|
||||
<CreateColumnForm {...sharedDrawerProps} />
|
||||
)}
|
||||
|
||||
{activeDrawerType === 'EDIT_COLUMN' && (
|
||||
<EditColumnForm
|
||||
{...sharedDrawerProps}
|
||||
column={drawerPayload?.column}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDrawerType === 'CREATE_TABLE' && (
|
||||
<CreateTableForm
|
||||
{...sharedDrawerProps}
|
||||
schema={drawerPayload?.schema}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDrawerType === 'EDIT_TABLE' && (
|
||||
<EditTableForm
|
||||
{...sharedDrawerProps}
|
||||
table={drawerPayload?.table}
|
||||
schema={drawerPayload?.schema}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDrawerType === 'EDIT_PERMISSIONS' && (
|
||||
<EditPermissionsForm
|
||||
{...sharedDrawerProps}
|
||||
disabled={drawerPayload?.disabled}
|
||||
schema={drawerPayload?.schema}
|
||||
table={drawerPayload?.table}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDrawerType === 'EDIT_USER' && (
|
||||
<EditUserForm {...sharedDrawerProps} {...drawerPayload} />
|
||||
)}
|
||||
{isValidElement(activeDrawer)
|
||||
? cloneElement(activeDrawer, {
|
||||
...activeDrawer.props,
|
||||
location: 'drawer',
|
||||
onSubmit: async (values?: any) => {
|
||||
await activeDrawer?.props?.onSubmit?.(values);
|
||||
closeDrawer();
|
||||
},
|
||||
onCancel: () => {
|
||||
activeDrawer?.props?.onCancel?.();
|
||||
closeDrawerWithDirtyGuard();
|
||||
},
|
||||
})
|
||||
: null}
|
||||
</RetryableErrorBoundary>
|
||||
</Drawer>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommonDialogProps } from '@/ui/v2/Dialog';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { DialogConfig, DialogType } from './DialogContext';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import type { DialogConfig, OpenDialogOptions } from './DialogContext';
|
||||
|
||||
export interface DialogState {
|
||||
/**
|
||||
@@ -12,9 +12,13 @@ export interface DialogState {
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Type of the currently active dialog.
|
||||
* Component to render inside the dialog skeleton.
|
||||
*/
|
||||
activeDialogType?: DialogType;
|
||||
activeDialog?: ReactElement<{
|
||||
location?: 'drawer' | 'dialog';
|
||||
onCancel?: () => void;
|
||||
onSubmit?: (args?: any) => Promise<any> | void;
|
||||
}>;
|
||||
/**
|
||||
* Props passed to the currently active dialog.
|
||||
*/
|
||||
@@ -27,10 +31,7 @@ export interface DialogState {
|
||||
}
|
||||
|
||||
export type DialogAction =
|
||||
| {
|
||||
type: 'OPEN_DIALOG';
|
||||
payload: { type: DialogType; config?: DialogConfig };
|
||||
}
|
||||
| { type: 'OPEN_DIALOG'; payload: OpenDialogOptions }
|
||||
| { type: 'HIDE_DIALOG' }
|
||||
| { type: 'CLEAR_DIALOG_CONTENT' };
|
||||
|
||||
@@ -50,10 +51,9 @@ export function dialogReducer(
|
||||
return {
|
||||
...state,
|
||||
open: true,
|
||||
activeDialogType: action.payload?.type,
|
||||
dialogProps: action.payload.config?.props,
|
||||
title: action.payload.config?.title,
|
||||
payload: action.payload.config?.payload,
|
||||
title: action.payload.title,
|
||||
activeDialog: action.payload.component,
|
||||
dialogProps: action.payload.props,
|
||||
};
|
||||
case 'HIDE_DIALOG':
|
||||
return {
|
||||
@@ -64,8 +64,7 @@ export function dialogReducer(
|
||||
return {
|
||||
...state,
|
||||
title: undefined,
|
||||
payload: undefined,
|
||||
activeDialogType: undefined,
|
||||
activeDialog: undefined,
|
||||
dialogProps: undefined,
|
||||
};
|
||||
default:
|
||||
@@ -74,10 +73,7 @@ export function dialogReducer(
|
||||
}
|
||||
|
||||
export type DrawerAction =
|
||||
| {
|
||||
type: 'OPEN_DRAWER';
|
||||
payload: { type: DialogType; config?: DialogConfig };
|
||||
}
|
||||
| { type: 'OPEN_DRAWER'; payload: OpenDialogOptions }
|
||||
| { type: 'HIDE_DRAWER' }
|
||||
| { type: 'CLEAR_DRAWER_CONTENT' };
|
||||
|
||||
@@ -97,10 +93,9 @@ export function drawerReducer(
|
||||
return {
|
||||
...state,
|
||||
open: true,
|
||||
activeDialogType: action.payload?.type,
|
||||
dialogProps: action.payload.config?.props,
|
||||
title: action.payload.config?.title,
|
||||
payload: action.payload.config?.payload,
|
||||
title: action.payload.title,
|
||||
activeDialog: action.payload.component,
|
||||
dialogProps: action.payload.props,
|
||||
};
|
||||
case 'HIDE_DRAWER':
|
||||
return {
|
||||
@@ -111,8 +106,7 @@ export function drawerReducer(
|
||||
return {
|
||||
...state,
|
||||
title: undefined,
|
||||
payload: undefined,
|
||||
activeDialogType: undefined,
|
||||
activeDialog: undefined,
|
||||
dialogProps: undefined,
|
||||
};
|
||||
default:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BoxProps } from '@/ui/v2/Box';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
export interface FormProps extends BoxProps {
|
||||
@@ -11,6 +12,7 @@ export interface FormProps extends BoxProps {
|
||||
}
|
||||
|
||||
export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
|
||||
const formRef = useRef<HTMLDivElement>();
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
@@ -25,6 +27,15 @@ export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const submitButton = Array.from(
|
||||
formRef.current.getElementsByTagName('button'),
|
||||
).find((item) => item.type === 'submit');
|
||||
|
||||
// Disabling submit if the submit button is disabled
|
||||
if (submitButton?.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
handleSubmit(onSubmit)(event);
|
||||
@@ -35,6 +46,7 @@ export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
|
||||
// so keyboard events must be handled on the form element itself.
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||
<Box
|
||||
ref={formRef}
|
||||
component="form"
|
||||
{...props}
|
||||
onKeyDown={(event) => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import type { BoxProps } from '@/ui/v2/Box';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface FormActivityIndicatorProps extends BoxProps {}
|
||||
|
||||
export default function FormActivityIndicator({
|
||||
className,
|
||||
...props
|
||||
}: FormActivityIndicatorProps) {
|
||||
return (
|
||||
<Box
|
||||
{...props}
|
||||
className={twMerge(
|
||||
'grid items-center justify-center px-6 py-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ActivityIndicator
|
||||
circularProgressProps={{ className: 'w-5 h-5' }}
|
||||
label="Loading form..."
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './FormActivityIndicator';
|
||||
export { default } from './FormActivityIndicator';
|
||||
@@ -1,9 +1,9 @@
|
||||
import Breadcrumbs from '@/components/common/Breadcrumbs';
|
||||
import FeedbackForm from '@/components/common/FeedbackForm';
|
||||
import LocalAccountMenu from '@/components/common/LocalAccountMenu';
|
||||
import Logo from '@/components/common/Logo';
|
||||
import MobileNav from '@/components/common/MobileNav';
|
||||
import NavLink from '@/components/common/NavLink';
|
||||
import ThemeSwitcher from '@/components/common/ThemeSwitcher';
|
||||
import { AccountMenu } from '@/components/dashboard/AccountMenu';
|
||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||
import Box from '@/ui/v2/Box';
|
||||
@@ -23,6 +23,7 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="header"
|
||||
className={twMerge(
|
||||
'z-40 grid w-full transform-gpu grid-flow-col items-center justify-between gap-2 border-b-1 px-4 py-3',
|
||||
className,
|
||||
@@ -73,7 +74,7 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
Docs
|
||||
</NavLink>
|
||||
|
||||
{isPlatform ? <AccountMenu /> : <ThemeSwitcher className="w-52" />}
|
||||
{isPlatform ? <AccountMenu /> : <LocalAccountMenu />}
|
||||
</div>
|
||||
|
||||
<MobileNav className="sm:hidden" />
|
||||
|
||||
@@ -38,6 +38,12 @@ function IconLink(
|
||||
: [icon.props?.sx]),
|
||||
{
|
||||
color: (theme) => {
|
||||
if (props.disabled) {
|
||||
return theme.palette.mode === 'dark'
|
||||
? 'text.secondary'
|
||||
: 'text.primary';
|
||||
}
|
||||
|
||||
if (active) {
|
||||
return 'primary.main';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import ThemeSwitcher from '@/components/common/ThemeSwitcher';
|
||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||
import IconButton from '@/ui/v2/IconButton';
|
||||
import UserIcon from '@/ui/v2/icons/UserIcon';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import getConfig from 'next/config';
|
||||
|
||||
export default function LocalAccountMenu() {
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
return (
|
||||
<Dropdown.Root className="justify-self-center">
|
||||
<Dropdown.Trigger hideChevron asChild>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="h-7 w-7 rounded-full"
|
||||
sx={{
|
||||
backgroundColor: (theme) => `${theme.palette.grey[300]} !important`,
|
||||
}}
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
PaperProps={{
|
||||
className: 'mt-1 p-6 grid grid-flow-row gap-4 w-full max-w-xs',
|
||||
}}
|
||||
>
|
||||
<ThemeSwitcher label="Theme" />
|
||||
|
||||
<Text className="text-center text-xs" color="disabled">
|
||||
Dashboard Version: {publicRuntimeConfig?.version || 'n/a'}
|
||||
</Text>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './LocalAccountMenu';
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
|
||||
export default function MaintenanceAlert() {
|
||||
const { maintenanceActive, maintenanceEndDate } = useUI();
|
||||
|
||||
if (!maintenanceActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateTimeFormat = Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
const parts = dateTimeFormat.formatToParts(maintenanceEndDate);
|
||||
|
||||
const year = parts.find((part) => part.type === 'year')?.value;
|
||||
const month = parts.find((part) => part.type === 'month')?.value;
|
||||
const day = parts.find((part) => part.type === 'day')?.value;
|
||||
const hour = parts.find((part) => part.type === 'hour')?.value;
|
||||
const minute = parts.find((part) => part.type === 'minute')?.value;
|
||||
const timeZone = parts.find((part) => part.type === 'timeZoneName')?.value;
|
||||
|
||||
return (
|
||||
<Alert severity="warning" className="mt-4">
|
||||
<p>
|
||||
We're currently doing maintenance on our infrastructure. Project
|
||||
creation and project settings are temporarily disabled during the
|
||||
maintenance period.
|
||||
</p>
|
||||
|
||||
{maintenanceEndDate && (
|
||||
<p>
|
||||
Maintenance is expected to be completed at {year}-{month}-{day} {hour}
|
||||
:{minute} {timeZone}.
|
||||
</p>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MaintenanceAlert';
|
||||
@@ -20,6 +20,7 @@ import type { ListItemButtonProps } from '@/ui/v2/ListItem';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { useSignOut } from '@nhost/nextjs';
|
||||
import getConfig from 'next/config';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cloneElement, Fragment, isValidElement, useState } from 'react';
|
||||
@@ -89,6 +90,7 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
const { signOut } = useSignOut();
|
||||
const { setUserContext } = useUserDataContext();
|
||||
const router = useRouter();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -256,6 +258,10 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
</List>
|
||||
|
||||
<Text className="text-center text-xs" color="secondary">
|
||||
Dashboard Version: {publicRuntimeConfig?.version || 'n/a'}
|
||||
</Text>
|
||||
</section>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -21,6 +21,13 @@ export default function ThemeSwitcher({
|
||||
|
||||
onChange?.(event, value);
|
||||
}}
|
||||
slotProps={{
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Option value="light">Light</Option>
|
||||
<Option value="dark">Dark</Option>
|
||||
|
||||
@@ -10,6 +10,7 @@ import Text from '@/ui/v2/Text';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import getConfig from 'next/config';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -24,23 +25,10 @@ function AccountMenuContent({
|
||||
const router = useRouter();
|
||||
const client = useApolloClient();
|
||||
const { handleClose } = useDropdown();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
return (
|
||||
<Box className="relative grid w-account grid-flow-row gap-5 p-6">
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="absolute top-6 right-4 grid grid-flow-col items-center gap-px self-start font-medium"
|
||||
onClick={async () => {
|
||||
await nhost.auth.signOut();
|
||||
router.push('/signin');
|
||||
await client.resetStore();
|
||||
}}
|
||||
endIcon={<PowerIcon className="mr-1 h-4 w-4" />}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
|
||||
<Box className="relative grid w-full grid-flow-row gap-5 p-6">
|
||||
<div className="grid grid-flow-row justify-center">
|
||||
<Avatar
|
||||
className="mx-auto mb-2 h-16 w-16 rounded-full"
|
||||
@@ -72,9 +60,26 @@ function AccountMenuContent({
|
||||
<Button color="error" disabled>
|
||||
Remove Account
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={async () => {
|
||||
await nhost.auth.signOut();
|
||||
router.push('/signin');
|
||||
await client.resetStore();
|
||||
}}
|
||||
endIcon={<PowerIcon className="mr-1 h-4 w-4" />}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ThemeSwitcher label="Theme" fullWidth />
|
||||
<ThemeSwitcher label="Theme" />
|
||||
|
||||
<Text className="text-center text-xs" color="disabled">
|
||||
Dashboard Version: {publicRuntimeConfig?.version || 'n/a'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -107,7 +112,7 @@ export function AccountMenu() {
|
||||
/>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content PaperProps={{ className: 'mt-1' }}>
|
||||
<Dropdown.Content PaperProps={{ className: 'mt-1 max-w-xs w-full' }}>
|
||||
<AccountMenuContent
|
||||
onChangePasswordClick={() => setChangePasswordModal(true)}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import ControlledCheckbox from '@/components/common/ControlledCheckbox';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import InlineCode from '@/components/common/InlineCode';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import type { ColumnType, DatabaseColumn } from '@/types/dataBrowser';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -22,7 +23,7 @@ import ForeignKeyEditor from './ForeignKeyEditor';
|
||||
|
||||
export type BaseColumnFormValues = DatabaseColumn;
|
||||
|
||||
export interface BaseColumnFormProps {
|
||||
export interface BaseColumnFormProps extends DialogFormProps {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
@@ -60,6 +61,7 @@ export default function BaseColumnForm({
|
||||
onSubmit: handleExternalSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
location,
|
||||
}: BaseColumnFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
|
||||
@@ -91,8 +93,8 @@ export default function BaseColumnForm({
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'drawer');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import type { BaseForeignKeyFormValues } from '@/components/dataBrowser/BaseForeignKeyForm';
|
||||
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
|
||||
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
|
||||
import type { DatabaseColumn } from '@/types/dataBrowser';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -29,7 +30,7 @@ const ForeignKeyEditorInput = forwardRef(
|
||||
) => {
|
||||
const { openDialog } = useDialog();
|
||||
const { setValue } = useFormContext();
|
||||
const column = useWatch<Partial<DatabaseColumn>>();
|
||||
const column = useWatch() as DatabaseColumn;
|
||||
const { foreignKeyRelation } = column;
|
||||
|
||||
if (!column.foreignKeyRelation) {
|
||||
@@ -39,8 +40,8 @@ const ForeignKeyEditorInput = forwardRef(
|
||||
className="py-1"
|
||||
disabled={!column.name || !column.type}
|
||||
ref={ref}
|
||||
onClick={() =>
|
||||
openDialog('CREATE_FOREIGN_KEY', {
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Add a Foreign Key Relation</span>
|
||||
@@ -51,16 +52,18 @@ const ForeignKeyEditorInput = forwardRef(
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
payload: {
|
||||
selectedColumn: column.name,
|
||||
availableColumns: [column],
|
||||
onSubmit: (values: BaseForeignKeyFormValues) => {
|
||||
setValue('foreignKeyRelation', values);
|
||||
onCreateSubmit();
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
component: (
|
||||
<CreateForeignKeyForm
|
||||
selectedColumn={column.name}
|
||||
availableColumns={[column]}
|
||||
onSubmit={(values) => {
|
||||
setValue('foreignKeyRelation', values);
|
||||
onCreateSubmit();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add Foreign Key
|
||||
</Button>
|
||||
@@ -86,20 +89,22 @@ const ForeignKeyEditorInput = forwardRef(
|
||||
<div className="grid grid-flow-col">
|
||||
<Button
|
||||
ref={ref}
|
||||
onClick={() =>
|
||||
openDialog('EDIT_FOREIGN_KEY', {
|
||||
onClick={() => {
|
||||
openDialog({
|
||||
title: 'Edit Foreign Key Relation',
|
||||
payload: {
|
||||
foreignKeyRelation,
|
||||
availableColumns: [column],
|
||||
selectedColumn: column.name,
|
||||
onSubmit: (values: BaseForeignKeyFormValues) => {
|
||||
setValue('foreignKeyRelation', values);
|
||||
onEditSubmit();
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
component: (
|
||||
<EditForeignKeyForm
|
||||
foreignKeyRelation={foreignKeyRelation}
|
||||
selectedColumn={column.name}
|
||||
availableColumns={[column]}
|
||||
onSubmit={(values) => {
|
||||
setValue('foreignKeyRelation', values);
|
||||
onEditSubmit();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}}
|
||||
variant="borderless"
|
||||
className="min-w-[initial] py-1 px-2"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import ControlledSelect from '@/components/common/ControlledSelect';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import useDatabaseQuery from '@/hooks/dataBrowser/useDatabaseQuery';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import type { DatabaseColumn, ForeignKeyRelation } from '@/types/dataBrowser';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -23,7 +24,7 @@ export interface BaseForeignKeyFormValues extends ForeignKeyRelation {
|
||||
disableOriginColumn?: boolean;
|
||||
}
|
||||
|
||||
export interface BaseForeignKeyFormProps {
|
||||
export interface BaseForeignKeyFormProps extends DialogFormProps {
|
||||
/**
|
||||
* Available columns in the table.
|
||||
*/
|
||||
@@ -64,6 +65,7 @@ export function BaseForeignKeyForm({
|
||||
onSubmit: handleExternalSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
location,
|
||||
}: BaseForeignKeyFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
|
||||
@@ -86,8 +88,8 @@ export function BaseForeignKeyForm({
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'dialog');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import DatabaseRecordInputGroup from '@/components/dataBrowser/DatabaseRecordInputGroup';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import type {
|
||||
ColumnInsertOptions,
|
||||
DataBrowserGridColumn,
|
||||
@@ -10,7 +11,7 @@ import Button from '@/ui/v2/Button';
|
||||
import { useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
export interface BaseRecordFormProps {
|
||||
export interface BaseRecordFormProps extends DialogFormProps {
|
||||
/**
|
||||
* The columns of the table.
|
||||
*/
|
||||
@@ -36,6 +37,7 @@ export default function BaseRecordForm({
|
||||
onSubmit: handleExternalSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
location,
|
||||
}: BaseRecordFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const { requiredColumns, optionalColumns } = columns.reduce(
|
||||
@@ -70,8 +72,8 @@ export default function BaseRecordForm({
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'drawer');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
// Stores columns in a map to have constant time lookup. This is necessary
|
||||
// for tables with many columns.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import { baseColumnValidationSchema } from '@/components/dataBrowser/BaseColumnForm';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import type { DatabaseTable, ForeignKeyRelation } from '@/types/dataBrowser';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -30,7 +31,7 @@ export interface BaseTableFormValues
|
||||
foreignKeyRelations?: ForeignKeyRelation[];
|
||||
}
|
||||
|
||||
export interface BaseTableFormProps {
|
||||
export interface BaseTableFormProps extends DialogFormProps {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
@@ -99,7 +100,9 @@ function NameInput() {
|
||||
function FormFooter({
|
||||
onCancel,
|
||||
submitButtonText,
|
||||
}: Pick<BaseTableFormProps, 'onCancel' | 'submitButtonText'>) {
|
||||
location,
|
||||
}: Pick<BaseTableFormProps, 'onCancel' | 'submitButtonText'> &
|
||||
Pick<DialogFormProps, 'location'>) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const { isSubmitting, dirtyFields } = useFormState();
|
||||
|
||||
@@ -108,8 +111,8 @@ function FormFooter({
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'drawer');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
return (
|
||||
<Box className="grid flex-shrink-0 grid-flow-col justify-between gap-3 border-t-1 p-2">
|
||||
@@ -135,6 +138,7 @@ function FormFooter({
|
||||
}
|
||||
|
||||
export default function BaseTableForm({
|
||||
location,
|
||||
onSubmit: handleExternalSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
@@ -168,7 +172,11 @@ export default function BaseTableForm({
|
||||
<ForeignKeyEditorSection />
|
||||
</div>
|
||||
|
||||
<FormFooter onCancel={onCancel} submitButtonText={submitButtonText} />
|
||||
<FormFooter
|
||||
onCancel={onCancel}
|
||||
submitButtonText={submitButtonText}
|
||||
location={location}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import type { BaseForeignKeyFormValues } from '@/components/dataBrowser/BaseForeignKeyForm';
|
||||
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
|
||||
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
|
||||
import type { DatabaseColumn, ForeignKeyRelation } from '@/types/dataBrowser';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||
@@ -68,18 +70,19 @@ export default function ForeignKeyEditorSection() {
|
||||
onEdit={() => {
|
||||
const primaryKeyIndex = getValues('primaryKeyIndex');
|
||||
|
||||
openDialog('EDIT_FOREIGN_KEY', {
|
||||
openDialog({
|
||||
title: 'Edit Foreign Key Relation',
|
||||
payload: {
|
||||
foreignKeyRelation: fields[index],
|
||||
availableColumns: columns.map((column, columnIndex) =>
|
||||
columnIndex === primaryKeyIndex
|
||||
? { ...column, isPrimary: true }
|
||||
: column,
|
||||
),
|
||||
onSubmit: (values: BaseForeignKeyFormValues) =>
|
||||
handleEdit(values, index),
|
||||
},
|
||||
component: (
|
||||
<EditForeignKeyForm
|
||||
foreignKeyRelation={fields[index] as ForeignKeyRelation}
|
||||
availableColumns={columns.map((column, columnIndex) =>
|
||||
columnIndex === primaryKeyIndex
|
||||
? { ...column, isPrimary: true }
|
||||
: column,
|
||||
)}
|
||||
onSubmit={(values) => handleEdit(values, index)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}}
|
||||
onDelete={() => remove(index)}
|
||||
@@ -105,7 +108,7 @@ export default function ForeignKeyEditorSection() {
|
||||
onClick={() => {
|
||||
const primaryKeyIndex = getValues('primaryKeyIndex');
|
||||
|
||||
openDialog('CREATE_FOREIGN_KEY', {
|
||||
openDialog({
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Add a Foreign Key Relation</span>
|
||||
@@ -116,14 +119,16 @@ export default function ForeignKeyEditorSection() {
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
payload: {
|
||||
availableColumns: columns.map((column, index) =>
|
||||
index === primaryKeyIndex
|
||||
? { ...column, isPrimary: true }
|
||||
: column,
|
||||
),
|
||||
onSubmit: handleCreate,
|
||||
},
|
||||
component: (
|
||||
<CreateForeignKeyForm
|
||||
availableColumns={columns.map((column, index) =>
|
||||
index === primaryKeyIndex
|
||||
? { ...column, isPrimary: true }
|
||||
: column,
|
||||
)}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import customClaimsQuery from '@/utils/msw/mocks/graphql/customClaimsQuery';
|
||||
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
@@ -6,7 +6,11 @@ import { setupServer } from 'msw/node';
|
||||
import { test, vi } from 'vitest';
|
||||
import ColumnAutocomplete from './ColumnAutocomplete';
|
||||
|
||||
const server = setupServer(tableQuery, hasuraMetadataQuery, customClaimsQuery);
|
||||
const server = setupServer(
|
||||
tableQuery,
|
||||
hasuraMetadataQuery,
|
||||
permissionVariablesQuery,
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
@@ -15,11 +15,11 @@ import { useRouter } from 'next/router';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
export interface CreateColumnFormProps
|
||||
extends Pick<BaseColumnFormProps, 'onCancel'> {
|
||||
extends Pick<BaseColumnFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit?: () => Promise<void>;
|
||||
onSubmit?: (args?: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export default function CreateColumnForm({
|
||||
|
||||
@@ -13,7 +13,10 @@ import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
export interface CreateForeignKeyFormProps
|
||||
extends Pick<BaseForeignKeyFormProps, 'onCancel' | 'availableColumns'> {
|
||||
extends Pick<
|
||||
BaseForeignKeyFormProps,
|
||||
'onCancel' | 'availableColumns' | 'location'
|
||||
> {
|
||||
/**
|
||||
* Column selected by default.
|
||||
*/
|
||||
@@ -21,7 +24,7 @@ export interface CreateForeignKeyFormProps
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
|
||||
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export default function CreateForeignKeyForm({
|
||||
@@ -51,9 +54,7 @@ export default function CreateForeignKeyForm({
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
if (onSubmit) {
|
||||
await onSubmit(values);
|
||||
}
|
||||
await onSubmit?.(values);
|
||||
} catch (submitError) {
|
||||
if (submitError && submitError instanceof Error) {
|
||||
setError(submitError);
|
||||
|
||||
@@ -10,11 +10,11 @@ import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
export interface CreateRecordFormProps
|
||||
extends Pick<BaseRecordFormProps, 'columns' | 'onCancel'> {
|
||||
extends Pick<BaseRecordFormProps, 'columns' | 'onCancel' | 'location'> {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit?: () => Promise<void>;
|
||||
onSubmit?: (args?: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export default function CreateRecordForm({
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useRouter } from 'next/router';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
export interface CreateTableFormProps
|
||||
extends Pick<BaseTableFormProps, 'onCancel'> {
|
||||
extends Pick<BaseTableFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* Schema where the table should be created.
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ export interface CreateTableFormProps
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit?: () => Promise<void>;
|
||||
onSubmit?: (args?: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export default function CreateTableForm({
|
||||
|
||||
@@ -5,6 +5,7 @@ import DataGridDateCell from '@/components/common/DataGridDateCell';
|
||||
import DataGridNumericCell from '@/components/common/DataGridNumericCell';
|
||||
import DataGridTextCell from '@/components/common/DataGridTextCell';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import FormActivityIndicator from '@/components/common/FormActivityIndicator';
|
||||
import InlineCode from '@/components/common/InlineCode';
|
||||
import DataBrowserEmptyState from '@/components/dataBrowser/DataBrowserEmptyState';
|
||||
import DataBrowserGridControls from '@/components/dataBrowser/DataBrowserGridControls';
|
||||
@@ -28,9 +29,25 @@ import {
|
||||
} from '@/utils/dataBrowser/postgresqlConstants';
|
||||
import { isSchemaLocked } from '@/utils/dataBrowser/schemaHelpers';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
const CreateColumnForm = dynamic(
|
||||
() => import('@/components/dataBrowser/CreateColumnForm'),
|
||||
{ ssr: false, loading: () => <FormActivityIndicator /> },
|
||||
);
|
||||
|
||||
const EditColumnForm = dynamic(
|
||||
() => import('@/components/dataBrowser/EditColumnForm'),
|
||||
{ ssr: false, loading: () => <FormActivityIndicator /> },
|
||||
);
|
||||
|
||||
const CreateRecordForm = dynamic(
|
||||
() => import('@/components/dataBrowser/CreateRecordForm'),
|
||||
{ ssr: false, loading: () => <FormActivityIndicator /> },
|
||||
);
|
||||
|
||||
export interface DataBrowserGridProps extends Partial<DataGridProps<any>> {}
|
||||
|
||||
export function createDataGridColumn(
|
||||
@@ -273,33 +290,36 @@ export default function DataBrowserGrid({
|
||||
const memoizedData = useMemo(() => rows, [rows]);
|
||||
|
||||
async function handleInsertRowClick() {
|
||||
openDrawer('CREATE_RECORD', {
|
||||
openDrawer({
|
||||
title: 'Insert a New Row',
|
||||
payload: {
|
||||
columns: memoizedColumns,
|
||||
onSubmit: refetch,
|
||||
},
|
||||
component: (
|
||||
<CreateRecordForm
|
||||
// TODO: Create proper typings for data browser columns
|
||||
columns={memoizedColumns as unknown as DataBrowserGridColumn[]}
|
||||
onSubmit={refetch}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInsertColumnClick() {
|
||||
openDrawer('CREATE_COLUMN', {
|
||||
openDrawer({
|
||||
title: 'Insert a New Column',
|
||||
payload: {
|
||||
onSubmit: refetch,
|
||||
},
|
||||
component: <CreateColumnForm onSubmit={refetch} />,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEditColumnClick(
|
||||
column: DataBrowserGridColumn<NormalizedQueryDataRow>,
|
||||
) {
|
||||
openDrawer('EDIT_COLUMN', {
|
||||
openDrawer({
|
||||
title: 'Edit Column',
|
||||
payload: {
|
||||
column,
|
||||
onSubmit: () => queryClient.refetchQueries([currentTablePath]),
|
||||
},
|
||||
component: (
|
||||
<EditColumnForm
|
||||
column={column}
|
||||
onSubmit={() => queryClient.refetchQueries([currentTablePath])}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import FormActivityIndicator from '@/components/common/FormActivityIndicator';
|
||||
import InlineCode from '@/components/common/InlineCode';
|
||||
import NavLink from '@/components/common/NavLink';
|
||||
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
||||
@@ -31,11 +32,36 @@ import Select from '@/ui/v2/Select';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { isSchemaLocked } from '@/utils/dataBrowser/schemaHelpers';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const CreateTableForm = dynamic(
|
||||
() => import('@/components/dataBrowser/CreateTableForm'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <FormActivityIndicator />,
|
||||
},
|
||||
);
|
||||
|
||||
const EditTableForm = dynamic(
|
||||
() => import('@/components/dataBrowser/EditTableForm'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <FormActivityIndicator />,
|
||||
},
|
||||
);
|
||||
|
||||
const EditPermissionsForm = dynamic(
|
||||
() => import('@/components/dataBrowser/EditPermissionsForm'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <FormActivityIndicator />,
|
||||
},
|
||||
);
|
||||
|
||||
export interface DataBrowserSidebarProps extends Omit<BoxProps, 'children'> {
|
||||
/**
|
||||
* Function to be called when a sidebar item is clicked.
|
||||
@@ -200,7 +226,7 @@ function DataBrowserSidebarContent({
|
||||
table: string,
|
||||
disabled?: boolean,
|
||||
) {
|
||||
openDrawer('EDIT_PERMISSIONS', {
|
||||
openDrawer({
|
||||
title: (
|
||||
<span className="inline-grid grid-flow-col items-center gap-2">
|
||||
Permissions
|
||||
@@ -208,22 +234,18 @@ function DataBrowserSidebarContent({
|
||||
<Chip label="Preview" size="small" color="info" component="span" />
|
||||
</span>
|
||||
),
|
||||
component: (
|
||||
<EditPermissionsForm
|
||||
disabled={disabled}
|
||||
schema={schema}
|
||||
table={table}
|
||||
/>
|
||||
),
|
||||
props: {
|
||||
PaperProps: {
|
||||
className: 'lg:w-[65%] lg:max-w-7xl',
|
||||
},
|
||||
},
|
||||
payload: {
|
||||
onSubmit: async () => {
|
||||
await queryClient.refetchQueries([
|
||||
`${dataSourceSlug}.${schema}.${table}`,
|
||||
]);
|
||||
await refetch();
|
||||
},
|
||||
disabled,
|
||||
schema,
|
||||
table,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,9 +318,11 @@ function DataBrowserSidebarContent({
|
||||
endIcon={<PlusIcon />}
|
||||
className="mt-1 w-full justify-between px-2"
|
||||
onClick={() => {
|
||||
openDrawer('CREATE_TABLE', {
|
||||
openDrawer({
|
||||
title: 'Create a New Table',
|
||||
payload: { onSubmit: refetch, schema: selectedSchema },
|
||||
component: (
|
||||
<CreateTableForm onSubmit={refetch} schema={selectedSchema} />
|
||||
),
|
||||
});
|
||||
|
||||
onSidebarItemClick();
|
||||
@@ -328,69 +352,68 @@ function DataBrowserSidebarContent({
|
||||
className="group"
|
||||
key={tablePath}
|
||||
secondaryAction={
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Root
|
||||
id="table-management-menu"
|
||||
onOpen={() => setSidebarMenuTable(tablePath)}
|
||||
onClose={() => setSidebarMenuTable(undefined)}
|
||||
<Dropdown.Root
|
||||
id="table-management-menu"
|
||||
onOpen={() => setSidebarMenuTable(tablePath)}
|
||||
onClose={() => setSidebarMenuTable(undefined)}
|
||||
>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
disabled={tablePath === removableTable}
|
||||
>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
disabled={tablePath === removableTable}
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color={isSelected ? 'primary' : 'secondary'}
|
||||
className={twMerge(
|
||||
!isSelected &&
|
||||
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color={isSelected ? 'primary' : 'secondary'}
|
||||
className={twMerge(
|
||||
!isSelected &&
|
||||
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
|
||||
)}
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content menu PaperProps={{ className: 'w-52' }}>
|
||||
{isGitHubConnected ? (
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
true,
|
||||
)
|
||||
}
|
||||
>
|
||||
<DotsHorizontalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<UsersIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-52' }}
|
||||
>
|
||||
{isGitHubConnected ? (
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
true,
|
||||
)
|
||||
}
|
||||
>
|
||||
<UsersIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>View Permissions</span>
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
[
|
||||
<span>View Permissions</span>
|
||||
</Dropdown.Item>
|
||||
) : (
|
||||
[
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Item
|
||||
key="edit-table"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
openDrawer('EDIT_TABLE', {
|
||||
openDrawer({
|
||||
title: 'Edit Table',
|
||||
payload: {
|
||||
onSubmit: async () => {
|
||||
await queryClient.refetchQueries([
|
||||
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
|
||||
]);
|
||||
await refetch();
|
||||
},
|
||||
schema: table.table_schema,
|
||||
table,
|
||||
},
|
||||
component: (
|
||||
<EditTableForm
|
||||
onSubmit={async () => {
|
||||
await queryClient.refetchQueries([
|
||||
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
|
||||
]);
|
||||
await refetch();
|
||||
}}
|
||||
schema={table.table_schema}
|
||||
table={table}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -400,32 +423,38 @@ function DataBrowserSidebarContent({
|
||||
/>
|
||||
|
||||
<span>Edit Table</span>
|
||||
</Dropdown.Item>,
|
||||
</Dropdown.Item>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-table-separator"
|
||||
component="li"
|
||||
/>,
|
||||
<Dropdown.Item
|
||||
key="edit-permissions"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
)
|
||||
}
|
||||
>
|
||||
<UsersIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
/>
|
||||
),
|
||||
<Dropdown.Item
|
||||
key="edit-permissions"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
onClick={() =>
|
||||
handleEditPermissionClick(
|
||||
table.table_schema,
|
||||
table.table_name,
|
||||
)
|
||||
}
|
||||
>
|
||||
<UsersIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Edit Permissions</span>
|
||||
</Dropdown.Item>,
|
||||
<span>Edit Permissions</span>
|
||||
</Dropdown.Item>,
|
||||
!isSelectedSchemaLocked && (
|
||||
<Divider
|
||||
key="edit-permissions-separator"
|
||||
component="li"
|
||||
/>,
|
||||
/>
|
||||
),
|
||||
!isSelectedSchemaLocked && (
|
||||
<Dropdown.Item
|
||||
key="delete-table"
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
@@ -443,12 +472,12 @@ function DataBrowserSidebarContent({
|
||||
/>
|
||||
|
||||
<span>Delete Table</span>
|
||||
</Dropdown.Item>,
|
||||
]
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
)
|
||||
</Dropdown.Item>
|
||||
),
|
||||
]
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Button
|
||||
@@ -518,7 +547,7 @@ export default function DataBrowserSidebar({
|
||||
document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed);
|
||||
}, []);
|
||||
|
||||
if (isPlatform && !currentApplication?.hasuraGraphqlAdminSecret) {
|
||||
if (isPlatform && !currentApplication?.config?.hasura.adminSecret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useRouter } from 'next/router';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
export interface EditColumnFormProps
|
||||
extends Pick<BaseColumnFormProps, 'onCancel'> {
|
||||
extends Pick<BaseColumnFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* Column to be edited.
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,10 @@ import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
export interface EditForeignKeyFormProps
|
||||
extends Pick<BaseForeignKeyFormProps, 'onCancel' | 'availableColumns'> {
|
||||
extends Pick<
|
||||
BaseForeignKeyFormProps,
|
||||
'onCancel' | 'availableColumns' | 'location'
|
||||
> {
|
||||
/**
|
||||
* Foreign key relation to be edited.
|
||||
*/
|
||||
@@ -26,7 +29,7 @@ export interface EditForeignKeyFormProps
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
|
||||
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export default function EditForeignKeyForm({
|
||||
@@ -57,9 +60,7 @@ export default function EditForeignKeyForm({
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
if (onSubmit) {
|
||||
await onSubmit(values);
|
||||
}
|
||||
await onSubmit?.(values);
|
||||
} catch (submitError) {
|
||||
if (submitError && submitError instanceof Error) {
|
||||
setError(submitError);
|
||||
|
||||
@@ -3,6 +3,7 @@ import useMetadataQuery from '@/hooks/dataBrowser/useMetadataQuery';
|
||||
import useTableQuery from '@/hooks/dataBrowser/useTableQuery';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import type {
|
||||
DatabaseAccessLevel,
|
||||
DatabaseAction,
|
||||
@@ -30,7 +31,7 @@ import { twMerge } from 'tailwind-merge';
|
||||
import RolePermissionEditorForm from './RolePermissionEditorForm';
|
||||
import RolePermissionsRow from './RolePermissionsRow';
|
||||
|
||||
export interface EditPermissionsFormProps {
|
||||
export interface EditPermissionsFormProps extends DialogFormProps {
|
||||
/**
|
||||
* Determines whether the form is disabled or not.
|
||||
*/
|
||||
@@ -54,6 +55,7 @@ export default function EditPermissionsForm({
|
||||
schema,
|
||||
table,
|
||||
onCancel,
|
||||
location,
|
||||
}: EditPermissionsFormProps) {
|
||||
const [role, setRole] = useState<string>();
|
||||
const [action, setAction] = useState<DatabaseAction>();
|
||||
@@ -181,6 +183,7 @@ export default function EditPermissionsForm({
|
||||
|
||||
return (
|
||||
<RolePermissionEditorForm
|
||||
location={location}
|
||||
resourceVersion={metadata?.resourceVersion}
|
||||
disabled={disabled}
|
||||
schema={schema}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import HighlightedText from '@/components/common/HighlightedText';
|
||||
import useManagePermissionMutation from '@/hooks/dataBrowser/useManagePermissionMutation';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import type {
|
||||
DatabaseAction,
|
||||
HasuraMetadataPermission,
|
||||
@@ -13,6 +14,7 @@ import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import convertToHasuraPermissions from '@/utils/dataBrowser/convertToHasuraPermissions';
|
||||
import convertToRuleGroup from '@/utils/dataBrowser/convertToRuleGroup';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
@@ -72,7 +74,7 @@ export interface RolePermissionEditorFormValues {
|
||||
computedFields?: string[];
|
||||
}
|
||||
|
||||
export interface RolePermissionEditorFormProps {
|
||||
export interface RolePermissionEditorFormProps extends DialogFormProps {
|
||||
/**
|
||||
* Determines whether or not the form is disabled.
|
||||
*/
|
||||
@@ -169,6 +171,7 @@ export default function RolePermissionEditorForm({
|
||||
onCancel,
|
||||
permission,
|
||||
disabled,
|
||||
location,
|
||||
}: RolePermissionEditorFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
@@ -214,8 +217,8 @@ export default function RolePermissionEditorForm({
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'drawer');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
async function handleSubmit(values: RolePermissionEditorFormValues) {
|
||||
const managePermissionPromise = managePermission({
|
||||
@@ -245,7 +248,7 @@ export default function RolePermissionEditorForm({
|
||||
: permission?.check,
|
||||
backend_only: values.backendOnly,
|
||||
computed_fields:
|
||||
permission?.computed_fields.length > 0
|
||||
permission?.computed_fields?.length > 0
|
||||
? permission?.computed_fields
|
||||
: null,
|
||||
},
|
||||
@@ -256,12 +259,12 @@ export default function RolePermissionEditorForm({
|
||||
{
|
||||
loading: 'Saving permission...',
|
||||
success: 'Permission has been saved successfully.',
|
||||
error: 'An error occurred while saving the permission.',
|
||||
error: getServerError('An error occurred while saving the permission.'),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onDirtyStateChange(false, 'drawer');
|
||||
onDirtyStateChange(false, location);
|
||||
onSubmit?.();
|
||||
}
|
||||
|
||||
@@ -270,7 +273,7 @@ export default function RolePermissionEditorForm({
|
||||
openDirtyConfirmation({
|
||||
props: {
|
||||
onPrimaryAction: () => {
|
||||
onDirtyStateChange(false, 'drawer');
|
||||
onDirtyStateChange(false, location);
|
||||
onCancel?.();
|
||||
},
|
||||
},
|
||||
@@ -295,12 +298,14 @@ export default function RolePermissionEditorForm({
|
||||
{
|
||||
loading: 'Deleting permission...',
|
||||
success: 'Permission has been deleted successfully.',
|
||||
error: 'An error occurred while deleting the permission.',
|
||||
error: getServerError(
|
||||
'An error occurred while deleting the permission.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onDirtyStateChange(false, 'drawer');
|
||||
onDirtyStateChange(false, location);
|
||||
onSubmit?.();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import XIcon from '@/ui/v2/icons/XIcon';
|
||||
import InputLabel from '@/ui/v2/InputLabel';
|
||||
import Option from '@/ui/v2/Option';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
|
||||
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
|
||||
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
|
||||
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
|
||||
import { useTheme } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
@@ -51,8 +51,8 @@ export default function ColumnPresetsSection({
|
||||
} = useTableQuery([`default.${schema}.${table}`], { schema, table });
|
||||
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data: customClaimsData } = useGetAppCustomClaimsQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data: permissionVariablesData } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
skip: !currentApplication?.id,
|
||||
});
|
||||
const {
|
||||
@@ -74,8 +74,8 @@ export default function ColumnPresetsSection({
|
||||
throw tableError;
|
||||
}
|
||||
|
||||
const permissionVariableOptions = getPermissionVariablesArray(
|
||||
customClaimsData?.app?.authJwtCustomClaims,
|
||||
const permissionVariableOptions = getAllPermissionVariables(
|
||||
permissionVariablesData?.config?.auth?.session?.accessToken?.customClaims,
|
||||
).map(({ key }) => ({
|
||||
label: `X-Hasura-${key}`,
|
||||
value: `X-Hasura-${key}`,
|
||||
@@ -136,7 +136,7 @@ export default function ColumnPresetsSection({
|
||||
disableClearable={false}
|
||||
clearIcon={
|
||||
<XIcon
|
||||
className="w-4 h-4 mt-px"
|
||||
className="mt-px h-4 w-4"
|
||||
sx={{ color: theme.palette.text.primary }}
|
||||
/>
|
||||
}
|
||||
@@ -187,7 +187,7 @@ export default function ColumnPresetsSection({
|
||||
disabled={disabled}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className="shrink-0 grow-0 flex-[40px]"
|
||||
className="flex-[40px] shrink-0 grow-0"
|
||||
onClick={() => {
|
||||
if (fields.length === 1) {
|
||||
remove(index);
|
||||
@@ -199,7 +199,7 @@ export default function ColumnPresetsSection({
|
||||
remove(index);
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
<XIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Input from '@/ui/v2/Input';
|
||||
import Radio from '@/ui/v2/Radio';
|
||||
import RadioGroup from '@/ui/v2/RadioGroup';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import type { FocusEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import PermissionSettingsSection from './PermissionSettingsSection';
|
||||
@@ -130,7 +131,13 @@ export default function RowPermissionsSection({
|
||||
|
||||
{action === 'select' && (
|
||||
<Input
|
||||
{...register('limit')}
|
||||
{...register('limit', {
|
||||
onBlur: (event: FocusEvent<HTMLInputElement>) => {
|
||||
if (!event.target.value) {
|
||||
setValue('limit', null);
|
||||
}
|
||||
},
|
||||
})}
|
||||
disabled={disabled}
|
||||
id="limit"
|
||||
type="number"
|
||||
|
||||
@@ -43,7 +43,10 @@ const baseValidationSchema = Yup.object().shape({
|
||||
});
|
||||
|
||||
const selectValidationSchema = baseValidationSchema.shape({
|
||||
limit: Yup.number().min(0, 'Limit must not be negative.').nullable(true),
|
||||
limit: Yup.number()
|
||||
.label('Limit')
|
||||
.min(0, 'Limit must not be negative.')
|
||||
.nullable(true),
|
||||
allowAggregations: Yup.boolean().nullable(true),
|
||||
queryRootFields: Yup.array().of(Yup.string()).nullable(true),
|
||||
subscriptionRootFields: Yup.array().of(Yup.string()).nullable(true),
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
export interface EditTableFormProps
|
||||
extends Pick<BaseTableFormProps, 'onCancel'> {
|
||||
extends Pick<BaseTableFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* Schema where the table is located.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@ import Form from '@/components/common/Form';
|
||||
import type { RuleGroup } from '@/types/dataBrowser';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import customClaimsQuery from '@/utils/msw/mocks/graphql/customClaimsQuery';
|
||||
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
@@ -36,7 +36,7 @@ const defaultParameters = {
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers: [tableQuery, hasuraMetadataQuery, customClaimsQuery],
|
||||
handlers: [tableQuery, hasuraMetadataQuery, permissionVariablesQuery],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@ import ColumnAutocomplete from '@/components/dataBrowser/ColumnAutocomplete';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { HasuraOperator } from '@/types/dataBrowser';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import type { AutocompleteOption } from '@/ui/v2/Autocomplete';
|
||||
import type { InputProps } from '@/ui/v2/Input';
|
||||
import { inputClasses } from '@/ui/v2/Input';
|
||||
import Option from '@/ui/v2/Option';
|
||||
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
|
||||
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
|
||||
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
|
||||
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
|
||||
import { useController, useFormContext, useWatch } from 'react-hook-form';
|
||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||
|
||||
@@ -116,8 +117,8 @@ export default function RuleValueInput({
|
||||
data,
|
||||
loading,
|
||||
error: customClaimsError,
|
||||
} = useGetAppCustomClaimsQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
} = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
skip: !isHasuraInput || !currentApplication?.id,
|
||||
});
|
||||
|
||||
@@ -199,8 +200,8 @@ export default function RuleValueInput({
|
||||
);
|
||||
}
|
||||
|
||||
const availableHasuraPermissionVariables = getPermissionVariablesArray(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
const availableHasuraPermissionVariables = getAllPermissionVariables(
|
||||
data?.config?.auth?.session?.accessToken?.customClaims,
|
||||
).map(({ key }) => ({
|
||||
value: `X-Hasura-${key}`,
|
||||
label: `X-Hasura-${key}`,
|
||||
@@ -211,12 +212,13 @@ export default function RuleValueInput({
|
||||
<ControlledAutocomplete
|
||||
disabled={disabled}
|
||||
freeSolo={!isHasuraInput}
|
||||
autoSelect={!isHasuraInput}
|
||||
autoHighlight={isHasuraInput}
|
||||
open
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
if (typeof value === 'string') {
|
||||
return option.value.toLowerCase() === (value as string).toLowerCase();
|
||||
isOptionEqualToValue={(
|
||||
option,
|
||||
value: string | number | AutocompleteOption<string>,
|
||||
) => {
|
||||
if (typeof value !== 'object') {
|
||||
return option.value.toLowerCase() === value?.toString().toLowerCase();
|
||||
}
|
||||
|
||||
return option.value.toLowerCase() === value.value.toLowerCase();
|
||||
|
||||
@@ -10,6 +10,7 @@ import ArrowCounterclockwiseIcon from '@/ui/v2/icons/ArrowCounterclockwiseIcon';
|
||||
import ChevronRightIcon from '@/ui/v2/icons/ChevronRightIcon';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
|
||||
import { useInsertDeploymentMutation } from '@/utils/__generated__/graphql';
|
||||
@@ -122,7 +123,9 @@ export default function DeploymentListItem({
|
||||
{
|
||||
loading: 'Scheduling deployment...',
|
||||
success: 'Deployment has been scheduled successfully.',
|
||||
error: 'An error occurred when scheduling deployment.',
|
||||
error: getServerError(
|
||||
'An error occurred when scheduling deployment.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
@@ -262,7 +262,7 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
|
||||
.setAdminSecret(
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? 'nhost-admin-secret'
|
||||
: currentApplication.hasuraGraphqlAdminSecret,
|
||||
: currentApplication.config?.hasura.adminSecret,
|
||||
)
|
||||
.upload({
|
||||
file,
|
||||
@@ -277,7 +277,7 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
|
||||
}
|
||||
|
||||
if (fileError) {
|
||||
throw fileError;
|
||||
throw new Error(fileError.message);
|
||||
}
|
||||
|
||||
triggerToast(`File has been uploaded successfully (${fileMetadata?.id})`);
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function FilesDataGridControls({
|
||||
const storageWithAdminSecret = appClient.storage.setAdminSecret(
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? 'nhost-admin-secret'
|
||||
: currentApplication.hasuraGraphqlAdminSecret,
|
||||
: currentApplication.config?.hasura.adminSecret,
|
||||
);
|
||||
|
||||
// note: this is not an optimal solution, but we don't have a better way
|
||||
@@ -120,7 +120,7 @@ export default function FilesDataGridControls({
|
||||
{...props}
|
||||
>
|
||||
{numberOfSelectedFiles > 0 ? (
|
||||
<div className="mx-auto h-[40px] grid grid-flow-col justify-start items-center gap-2">
|
||||
<div className="mx-auto grid h-[40px] grid-flow-col items-center justify-start gap-2">
|
||||
<Chip
|
||||
color="info"
|
||||
size="small"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { slugifyString } from '@/utils/helpers';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
refetchGetOneUserQuery,
|
||||
@@ -11,11 +14,12 @@ import {
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface EditWorkspaceNameFormProps {
|
||||
export interface EditWorkspaceNameFormProps extends DialogFormProps {
|
||||
/**
|
||||
* The current workspace name if this is an edit operation.
|
||||
*/
|
||||
@@ -44,14 +48,7 @@ export interface EditWorkspaceNameFormProps {
|
||||
onCancel?: VoidFunction;
|
||||
}
|
||||
|
||||
export interface EditWorkspaceNameFormValues {
|
||||
/**
|
||||
* New workspace name.
|
||||
*/
|
||||
newWorkspaceName: string;
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
const validationSchema = Yup.object({
|
||||
newWorkspaceName: Yup.string()
|
||||
.required('Workspace name is required.')
|
||||
.min(4, 'The new Workspace name must be at least 4 characters.')
|
||||
@@ -71,14 +68,20 @@ const validationSchema = Yup.object().shape({
|
||||
),
|
||||
});
|
||||
|
||||
export default function EditWorkspaceName({
|
||||
export type EditWorkspaceNameFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function EditWorkspaceNameForm({
|
||||
disabled,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
currentWorkspaceName,
|
||||
currentWorkspaceId,
|
||||
submitButtonText = 'Create',
|
||||
location,
|
||||
}: EditWorkspaceNameFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const currentUser = useUserData();
|
||||
const [insertWorkspace, { client }] = useInsertWorkspaceMutation();
|
||||
const [updateWorkspaceName] = useUpdateWorkspaceMutation({
|
||||
@@ -105,6 +108,10 @@ export default function EditWorkspaceName({
|
||||
} = form;
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
async function handleSubmit({
|
||||
newWorkspaceName,
|
||||
}: EditWorkspaceNameFormValues) {
|
||||
@@ -112,6 +119,8 @@ export default function EditWorkspaceName({
|
||||
|
||||
try {
|
||||
if (currentWorkspaceId) {
|
||||
onDirtyStateChange(false, location);
|
||||
|
||||
// In this bit of code we spread the props of the current path (e.g. /workspace/...) and add one key-value pair: `mutating: true`.
|
||||
// We want to indicate that the currently we're in the process of running a mutation state that will affect the routing behaviour of the website
|
||||
// i.e. redirecting to 404 if there's no workspace/project with that slug.
|
||||
@@ -133,7 +142,9 @@ export default function EditWorkspaceName({
|
||||
{
|
||||
loading: 'Updating workspace name...',
|
||||
success: 'Workspace name has been updated successfully.',
|
||||
error: 'An error occurred while updating the workspace name.',
|
||||
error: getServerError(
|
||||
'An error occurred while updating the workspace name.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
@@ -160,7 +171,9 @@ export default function EditWorkspaceName({
|
||||
{
|
||||
loading: 'Creating new workspace...',
|
||||
success: 'The new workspace has been created successfully.',
|
||||
error: 'An error occurred while creating the new workspace.',
|
||||
error: getServerError(
|
||||
'An error occurred while creating the new workspace.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
@@ -186,6 +199,9 @@ export default function EditWorkspaceName({
|
||||
include: ['getOneUser'],
|
||||
});
|
||||
|
||||
// The form has been submitted, it's not dirty anymore
|
||||
onDirtyStateChange(false, location);
|
||||
|
||||
await router.push(slug);
|
||||
onSubmit?.();
|
||||
}
|
||||
@@ -194,9 +210,9 @@ export default function EditWorkspaceName({
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col content-between flex-auto pt-2 pb-6 overflow-hidden"
|
||||
className="flex flex-auto flex-col content-between overflow-hidden pt-2 pb-6"
|
||||
>
|
||||
<div className="flex-auto px-6 overflow-y-auto">
|
||||
<div className="flex-auto overflow-y-auto px-6">
|
||||
<Input
|
||||
{...register('newWorkspaceName')}
|
||||
error={Boolean(errors.newWorkspaceName?.message)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import PlusCircleIcon from '@/ui/v2/icons/PlusCircleIcon';
|
||||
import SearchIcon from '@/ui/v2/icons/SearchIcon';
|
||||
@@ -11,6 +12,8 @@ interface IndexHeaderAppsProps {
|
||||
}
|
||||
|
||||
export function IndexHeaderApps({ query, setQuery }: IndexHeaderAppsProps) {
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
return (
|
||||
<div className="mx-auto mb-6 grid w-full grid-flow-col place-content-between items-center py-2">
|
||||
<Text variant="h2" component="h1" className="hidden md:block">
|
||||
@@ -36,6 +39,7 @@ export function IndexHeaderApps({ query, setQuery }: IndexHeaderAppsProps) {
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon={<PlusCircleIcon />}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
New Project
|
||||
</Button>
|
||||
|
||||
@@ -58,16 +58,19 @@ export function InviteAnnounce() {
|
||||
error: null,
|
||||
loading: true,
|
||||
});
|
||||
const res = await nhost.functions.call('/accept-workspace-invite', {
|
||||
workspaceMemberInviteId: invite.id,
|
||||
isAccepted: true,
|
||||
});
|
||||
const { res, error: acceptError } = await nhost.functions.call(
|
||||
'/accept-workspace-invite',
|
||||
{
|
||||
workspaceMemberInviteId: invite.id,
|
||||
isAccepted: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.res?.status !== 200) {
|
||||
if (res?.status !== 200) {
|
||||
triggerToast('An error occurred when trying to accept the invitation.');
|
||||
|
||||
return setSubmitState({
|
||||
error: res.error,
|
||||
error: new Error(acceptError.message),
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
@@ -90,7 +93,7 @@ export function InviteAnnounce() {
|
||||
error: null,
|
||||
});
|
||||
|
||||
const res = await nhost.functions.call(
|
||||
const { error: ignoreError } = await nhost.functions.call(
|
||||
'/accept-workspace-invite',
|
||||
{
|
||||
workspaceMemberInviteId: inviteId,
|
||||
@@ -99,12 +102,12 @@ export function InviteAnnounce() {
|
||||
{ useAxios: false },
|
||||
);
|
||||
|
||||
if (res?.error) {
|
||||
if (ignoreError) {
|
||||
triggerToast('An error occurred when trying to ignore the invitation.');
|
||||
|
||||
setIgnoreState({
|
||||
loading: false,
|
||||
error: new Error(res.error.message),
|
||||
error: new Error(ignoreError.message),
|
||||
});
|
||||
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UserDataProvider } from '@/context/workspace1-context';
|
||||
import type { Application } from '@/types/application';
|
||||
import type { Project } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { Workspace } from '@/types/workspace';
|
||||
import { render, screen, waitForElementToBeRemoved } from '@/utils/testUtils';
|
||||
@@ -36,12 +36,11 @@ vi.mock('next/router', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockApplication: Application = {
|
||||
const mockApplication: Project = {
|
||||
id: '1',
|
||||
name: 'Test Application',
|
||||
slug: 'test-application',
|
||||
appStates: [],
|
||||
hasuraGraphqlAdminSecret: 'nhost-admin-secret',
|
||||
subdomain: '',
|
||||
isProvisioned: true,
|
||||
region: {
|
||||
@@ -56,6 +55,14 @@ const mockApplication: Application = {
|
||||
featureFlags: [],
|
||||
providersUpdated: true,
|
||||
githubRepository: { fullName: 'test/git-project' },
|
||||
repositoryProductionBranch: null,
|
||||
nhostBaseFolder: null,
|
||||
plan: null,
|
||||
config: {
|
||||
hasura: {
|
||||
adminSecret: 'nhost-admin-secret',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockWorkspace: Workspace = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import useGitHubModal from '@/components/applications/github/useGitHubModal';
|
||||
import DeploymentListItem from '@/components/deployments/DeploymentListItem';
|
||||
import GithubIcon from '@/components/icons/GithubIcon';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
@@ -146,6 +147,7 @@ function OverviewDeploymentList() {
|
||||
export default function OverviewDeployments() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { openGitHubModal } = useGitHubModal();
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
const { githubRepository } = currentApplication || {};
|
||||
|
||||
@@ -183,6 +185,7 @@ export default function OverviewDeployments() {
|
||||
color="primary"
|
||||
className="w-full"
|
||||
onClick={openGitHubModal}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
<GithubIcon className="mr-1.5 h-4 w-4 self-center" />
|
||||
Connect to GitHub
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import GithubIcon from '@/components/icons/GithubIcon';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -8,6 +9,7 @@ import NavLink from 'next/link';
|
||||
export default function OverviewRepository() {
|
||||
const { currentWorkspace, currentApplication } =
|
||||
useCurrentWorkspaceAndApplication();
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -28,6 +30,7 @@ export default function OverviewRepository() {
|
||||
color="secondary"
|
||||
className="w-full border-1 hover:border-1"
|
||||
startIcon={<GithubIcon />}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Connect to GitHub
|
||||
</Button>
|
||||
@@ -39,7 +42,7 @@ export default function OverviewRepository() {
|
||||
sx={{ backgroundColor: 'grey.200' }}
|
||||
>
|
||||
<Box
|
||||
className="grid grid-flow-col gap-1.5 ml-2"
|
||||
className="ml-2 grid grid-flow-col gap-1.5"
|
||||
sx={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<GithubIcon className="h-4 w-4 self-center" />
|
||||
@@ -52,7 +55,11 @@ export default function OverviewRepository() {
|
||||
href={`/${currentWorkspace.slug}/${currentApplication.slug}/settings/git`}
|
||||
passHref
|
||||
>
|
||||
<Button variant="borderless" size="small">
|
||||
<Button
|
||||
variant="borderless"
|
||||
size="small"
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</NavLink>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangePlanModal } from '@/components/applications/ChangePlanModal';
|
||||
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -16,6 +17,7 @@ export default function OverviewTopBar() {
|
||||
useCurrentWorkspaceAndApplication();
|
||||
const isPro = !currentApplication?.plan?.isFree;
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
if (!isPlatform) {
|
||||
return (
|
||||
@@ -104,6 +106,7 @@ export default function OverviewTopBar() {
|
||||
endIcon={<CogIcon className="h-4 w-4" />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
useResetPostgresPasswordMutation,
|
||||
useUpdateApplicationMutation,
|
||||
@@ -28,6 +29,7 @@ export interface ResetDatabasePasswordFormValues {
|
||||
|
||||
export default function ResetDatabasePasswordSettings() {
|
||||
const [updateApplication] = useUpdateApplicationMutation();
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
const form = useForm<ResetDatabasePasswordFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
@@ -44,11 +46,10 @@ export default function ResetDatabasePasswordSettings() {
|
||||
setValue,
|
||||
getValues,
|
||||
register,
|
||||
formState: { errors },
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = form;
|
||||
|
||||
const [resetPostgresPasswordMutation, { loading }] =
|
||||
useResetPostgresPasswordMutation();
|
||||
const [resetPostgresPasswordMutation] = useResetPostgresPasswordMutation();
|
||||
const user = useUserData();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
@@ -99,12 +100,16 @@ export default function ResetDatabasePasswordSettings() {
|
||||
title="Reset Password"
|
||||
description="This password is used for accessing your database."
|
||||
submitButtonText="Reset"
|
||||
rootClassName="border-[#F87171]"
|
||||
primaryActionButtonProps={{
|
||||
variant: 'contained',
|
||||
color: 'error',
|
||||
disabled: Boolean(errors?.databasePassword),
|
||||
loading,
|
||||
slotProps={{
|
||||
root: {
|
||||
sx: { borderColor: (theme) => theme.palette.error.main },
|
||||
},
|
||||
submitButton: {
|
||||
variant: 'contained',
|
||||
color: 'error',
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row pb-4"
|
||||
>
|
||||
|
||||
@@ -179,6 +179,15 @@ export default function SettingsSidebar({
|
||||
>
|
||||
Environment Variables
|
||||
</SettingsNavLink>
|
||||
|
||||
<SettingsNavLink
|
||||
href="/secrets"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
className="hidden"
|
||||
>
|
||||
Secrets
|
||||
</SettingsNavLink>
|
||||
</List>
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
@@ -1,68 +1,61 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useEffect } from 'react';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface AllowedEmailSettingsFormValues {
|
||||
/**
|
||||
* Determines whether or not the allowed email settings are enabled.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Set of email that are allowed to be used for project's users authentication.
|
||||
*/
|
||||
authAccessControlAllowedEmails: string;
|
||||
/**
|
||||
* Set of email domains that are allowed to be used for project's users authentication.
|
||||
* @example 'nhost.io'
|
||||
*/
|
||||
authAccessControlAllowedEmailDomains: string;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
allowedEmails: Yup.string().label('Allowed Emails'),
|
||||
allowedEmailDomains: Yup.string().label('Allowed Email Domains'),
|
||||
});
|
||||
|
||||
export type AllowedEmailSettingsFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function AllowedEmailDomainsSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
|
||||
const { data, loading, error } = useGetAppQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { email, emailDomains } = data?.config?.auth?.user || {};
|
||||
|
||||
const form = useForm<AllowedEmailSettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled:
|
||||
Boolean(data?.app?.authAccessControlAllowedEmails) ||
|
||||
Boolean(data?.app?.authAccessControlAllowedEmailDomains),
|
||||
authAccessControlAllowedEmails: data?.app?.authAccessControlAllowedEmails,
|
||||
authAccessControlAllowedEmailDomains:
|
||||
data?.app?.authAccessControlAllowedEmailDomains,
|
||||
enabled: email?.allowed?.length > 0 || emailDomains?.allowed?.length > 0,
|
||||
allowedEmails: email?.allowed?.join(', ') || '',
|
||||
allowedEmailDomains: emailDomains?.allowed?.join(', ') || '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState, setValue, watch } = form;
|
||||
const { register, formState, watch } = form;
|
||||
const enabled = watch('enabled');
|
||||
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!data.app?.authAccessControlAllowedEmails &&
|
||||
!data.app?.authAccessControlAllowedEmailDomains
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('enabled', true, { shouldDirty: false });
|
||||
}, [data.app, setValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -80,29 +73,51 @@ export default function AllowedEmailDomainsSettings() {
|
||||
const handleAllowedEmailDomainsChange = async (
|
||||
values: AllowedEmailSettingsFormValues,
|
||||
) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
authAccessControlAllowedEmails: values.enabled
|
||||
? values.authAccessControlAllowedEmails
|
||||
: '',
|
||||
authAccessControlAllowedEmailDomains: values.enabled
|
||||
? values.authAccessControlAllowedEmailDomains
|
||||
: '',
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
email: {
|
||||
blocked: email.blocked,
|
||||
allowed:
|
||||
values.enabled && values.allowedEmails
|
||||
? values.allowedEmails
|
||||
.split(',')
|
||||
.map((allowedEmail) => allowedEmail.trim())
|
||||
: [],
|
||||
},
|
||||
emailDomains: {
|
||||
blocked: emailDomains.blocked,
|
||||
allowed:
|
||||
values.enabled && values.allowedEmailDomains
|
||||
? values.allowedEmailDomains
|
||||
.split(',')
|
||||
.map((allowedEmailDomain) => allowedEmailDomain.trim())
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Allowed email settings are being updated...`,
|
||||
success: `Allowed email settings have been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's allowed email settings.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Allowed email settings are being updated...`,
|
||||
success: `Allowed email settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's allowed email settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} catch {
|
||||
// Note: The toast will handle the error
|
||||
}
|
||||
|
||||
form.reset(values);
|
||||
};
|
||||
@@ -115,15 +130,12 @@ export default function AllowedEmailDomainsSettings() {
|
||||
description="Allow specific email addresses and domains to sign up."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isValid || !isDirty,
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#allowed-emails-and-domains"
|
||||
enabled={enabled}
|
||||
onEnabledChange={(switchEnabled) =>
|
||||
setValue('enabled', switchEnabled, { shouldDirty: true })
|
||||
}
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
|
||||
@@ -131,9 +143,9 @@ export default function AllowedEmailDomainsSettings() {
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
{...register('authAccessControlAllowedEmails')}
|
||||
name="authAccessControlAllowedEmails"
|
||||
id="authAccessControlAllowedEmails"
|
||||
{...register('allowedEmails')}
|
||||
name="allowedEmails"
|
||||
id="allowedEmails"
|
||||
placeholder="These emails (separated by comma, e.g, david@ikea.com, lisa@mycompany.com)"
|
||||
className="col-span-2"
|
||||
label="Allowed Emails (comma separated)"
|
||||
@@ -141,9 +153,9 @@ export default function AllowedEmailDomainsSettings() {
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
<Input
|
||||
{...register('authAccessControlAllowedEmailDomains')}
|
||||
name="authAccessControlAllowedEmailDomains"
|
||||
id="authAccessControlAllowedEmailDomains"
|
||||
{...register('allowedEmailDomains')}
|
||||
name="allowedEmailDomains"
|
||||
id="allowedEmailDomains"
|
||||
label="Allowed Email Domains (comma sepated list)"
|
||||
placeholder="These email domains (separated by comma, e.g, ikea.com, mycompany.com)"
|
||||
className="col-span-2"
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface AllowedRedirectURLFormValues {
|
||||
/**
|
||||
* Set of URLs that are allowed to be redirected to after project's users authentication.
|
||||
*/
|
||||
authAccessControlAllowedRedirectUrls: string;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
allowedUrls: Yup.string().label('Allowed Redirect URLs'),
|
||||
});
|
||||
|
||||
export type AllowedRedirectURLFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function AllowedRedirectURLsSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
|
||||
const { data, loading, error } = useGetAppQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { allowedUrls } = data?.config?.auth?.redirections || {};
|
||||
|
||||
const form = useForm<AllowedRedirectURLFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
authAccessControlAllowedRedirectUrls:
|
||||
data?.app?.authAccessControlAllowedRedirectUrls,
|
||||
allowedUrls: allowedUrls?.join(', ') || '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -52,26 +65,38 @@ export default function AllowedRedirectURLsSettings() {
|
||||
const handleAllowedRedirectURLsChange = async (
|
||||
values: AllowedRedirectURLFormValues,
|
||||
) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
redirections: {
|
||||
allowedUrls: values.allowedUrls
|
||||
? values.allowedUrls.split(',').map((url) => url.trim())
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Allowed redirect URL settings are being updated...`,
|
||||
success: `Allowed redirect URL settings have been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's allowed redirect URL settings.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Allowed redirect URL settings are being updated...`,
|
||||
success: `Allowed redirect URL settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's allowed redirect URL settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -80,17 +105,19 @@ export default function AllowedRedirectURLsSettings() {
|
||||
<SettingsContainer
|
||||
title="Allowed Redirect URLs"
|
||||
description="Allowed URLs where users can be redirected to after authentication. Separate multiple redirect URLs with comma."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#allowed-redirect-urls"
|
||||
className="grid grid-flow-row px-4 lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('authAccessControlAllowedRedirectUrls')}
|
||||
name="authAccessControlAllowedRedirectUrls"
|
||||
id="authAccessControlAllowedRedirectUrls"
|
||||
{...register('allowedUrls')}
|
||||
name="allowedUrls"
|
||||
id="allowedUrls"
|
||||
placeholder="http://localhost:3000, http://localhost:4000"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
|
||||
@@ -1,67 +1,58 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useEffect } from 'react';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface BlockedEmailFormValues {
|
||||
/**
|
||||
* Determines whether or not the blocked email settings are enabled.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Set of emails that are blocked from registering to the user's project.
|
||||
*/
|
||||
authAccessControlBlockedEmails: string;
|
||||
/**
|
||||
* Set of email domains that are blocked from registering to the user's project.
|
||||
*/
|
||||
authAccessControlBlockedEmailDomains: string;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
blockedEmails: Yup.string().label('Blocked Emails'),
|
||||
blockedEmailDomains: Yup.string().label('Blocked Email Domains'),
|
||||
});
|
||||
|
||||
export type BlockedEmailFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function BlockedEmailSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
|
||||
const { data, loading, error } = useGetAppQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { email, emailDomains } = data?.config?.auth?.user || {};
|
||||
|
||||
const form = useForm<BlockedEmailFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
enabled:
|
||||
Boolean(data?.app?.authAccessControlBlockedEmails) ||
|
||||
Boolean(data?.app?.authAccessControlBlockedEmailDomains),
|
||||
authAccessControlBlockedEmails: data?.app?.authAccessControlBlockedEmails,
|
||||
authAccessControlBlockedEmailDomains:
|
||||
data?.app?.authAccessControlBlockedEmailDomains,
|
||||
enabled: email?.blocked?.length > 0 || emailDomains?.blocked?.length > 0,
|
||||
blockedEmails: email?.blocked?.join(', ') || '',
|
||||
blockedEmailDomains: emailDomains?.blocked?.join(', ') || '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState, setValue, watch } = form;
|
||||
const { register, formState, watch } = form;
|
||||
const enabled = watch('enabled');
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!data.app?.authAccessControlBlockedEmails &&
|
||||
!data.app?.authAccessControlBlockedEmailDomains
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('enabled', true, { shouldDirty: false });
|
||||
}, [data.app, setValue]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -79,31 +70,63 @@ export default function BlockedEmailSettings() {
|
||||
const handleAllowedEmailDomainsChange = async (
|
||||
values: BlockedEmailFormValues,
|
||||
) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
authAccessControlBlockedEmails: values.enabled
|
||||
? values.authAccessControlBlockedEmails
|
||||
: '',
|
||||
authAccessControlBlockedEmailDomains: values.enabled
|
||||
? values.authAccessControlBlockedEmailDomains
|
||||
: '',
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
email: {
|
||||
allowed: email.allowed,
|
||||
blocked:
|
||||
values.enabled && values.blockedEmails
|
||||
? [
|
||||
...new Set(
|
||||
values.blockedEmails
|
||||
.split(',')
|
||||
.map((blockedEmail) => blockedEmail.trim()),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
},
|
||||
emailDomains: {
|
||||
allowed: emailDomains.allowed,
|
||||
blocked:
|
||||
values.enabled && values.blockedEmailDomains
|
||||
? [
|
||||
...new Set(
|
||||
values.blockedEmailDomains
|
||||
.split(',')
|
||||
.map((blockedEmailDomain) =>
|
||||
blockedEmailDomain.trim(),
|
||||
),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Blocked email and domain settings are being updated...`,
|
||||
success: `Blocked email and domain settings have been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's blocked email and domain settings.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Blocked email and domain settings are being updated...`,
|
||||
success: `Blocked email and domain settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's blocked email and domain settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -114,15 +137,12 @@ export default function BlockedEmailSettings() {
|
||||
description="Block specific email addresses and domains to sign up."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isValid || !isDirty,
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#blocked-emails-and-domains"
|
||||
enabled={enabled}
|
||||
onEnabledChange={(switchEnabled) =>
|
||||
setValue('enabled', switchEnabled, { shouldDirty: true })
|
||||
}
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
|
||||
@@ -130,9 +150,9 @@ export default function BlockedEmailSettings() {
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
{...register('authAccessControlBlockedEmails')}
|
||||
name="authAccessControlBlockedEmails"
|
||||
id="authAccessControlBlockedEmails"
|
||||
{...register('blockedEmails')}
|
||||
name="blockedEmails"
|
||||
id="blockedEmails"
|
||||
placeholder="These emails (separated by comma, e.g, david@ikea.com, lisa@mycompany.com)"
|
||||
className="col-span-2"
|
||||
label="Blocked Emails (comma separated)"
|
||||
@@ -140,9 +160,9 @@ export default function BlockedEmailSettings() {
|
||||
hideEmptyHelperText
|
||||
/>
|
||||
<Input
|
||||
{...register('authAccessControlBlockedEmailDomains')}
|
||||
name="authAccessControlBlockedEmailDomains"
|
||||
id="authAccessControlBlockedEmailDomains"
|
||||
{...register('blockedEmailDomains')}
|
||||
name="blockedEmailDomains"
|
||||
id="blockedEmailDomains"
|
||||
label="Blocked Email Domains (comma sepated list)"
|
||||
placeholder="These email domains (separated by comma, e.g, ikea.com, mycompany.com)"
|
||||
className="col-span-2"
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface ClientURLFormValues {
|
||||
/**
|
||||
* The URL of the frontend app of where users are redirected after authenticating.
|
||||
*/
|
||||
authClientUrl: string;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
clientUrl: Yup.string().label('Client URL'),
|
||||
});
|
||||
|
||||
export type ClientURLFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function ClientURLSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation({ refetchQueries: ['GetApp'] });
|
||||
|
||||
const { data, loading, error } = useGetAppQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
fetchPolicy: 'cache-first',
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { clientUrl, allowedUrls } = data?.config?.auth?.redirections || {};
|
||||
|
||||
const form = useForm<ClientURLFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
authClientUrl: data?.app?.authClientUrl,
|
||||
clientUrl,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -50,26 +61,37 @@ export default function ClientURLSettings() {
|
||||
const { register, formState } = form;
|
||||
|
||||
const handleClientURLChange = async (values: ClientURLFormValues) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
redirections: {
|
||||
...values,
|
||||
allowedUrls,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Client URL is being updated...`,
|
||||
success: `Client URL has been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's Client URL.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Client URL is being updated...`,
|
||||
success: `Client URL has been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's Client URL.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -78,22 +100,26 @@ export default function ClientURLSettings() {
|
||||
<SettingsContainer
|
||||
title="Client URL"
|
||||
description="This should be the URL of your frontend app where users are redirected after authenticating."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#client-url"
|
||||
className="grid grid-flow-row lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('authClientUrl')}
|
||||
name="authClientUrl"
|
||||
id="authClientUrl"
|
||||
{...register('clientUrl')}
|
||||
name="clientUrl"
|
||||
id="clientUrl"
|
||||
placeholder="http://localhost:3000"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
aria-label="Client URL"
|
||||
error={!!formState.errors?.clientUrl}
|
||||
helperText={formState.errors?.clientUrl?.message}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
|
||||
@@ -1,46 +1,44 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetAuthSettingsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface DisableNewUsersFormValues {
|
||||
/**
|
||||
* Disable new users from signing up to this project
|
||||
*/
|
||||
authDisableNewUsers: boolean;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
disabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
export type DisableNewUsersFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function DisableNewUsersSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthSettingsQuery({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
},
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const form = useForm<DisableNewUsersFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
authDisableNewUsers: data?.app?.authDisableNewUsers,
|
||||
disabled: !!data?.config?.auth?.signUp?.enabled,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(() => ({
|
||||
authDisableNewUsers: data?.app?.authDisableNewUsers,
|
||||
}));
|
||||
}, [data?.app?.authDisableNewUsers, form, form.reset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -55,32 +53,41 @@ export default function DisableNewUsersSettings() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { formState, watch } = form;
|
||||
const authDisableNewUsers = watch('authDisableNewUsers');
|
||||
const { formState } = form;
|
||||
|
||||
const handleDisableNewUsersChange = async (
|
||||
values: DisableNewUsersFormValues,
|
||||
) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
signUp: {
|
||||
enabled: !values.disabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Disabling new user sign ups...`,
|
||||
success: `New user sign ups have been disabled successfully.`,
|
||||
error: `An error occurred while trying to disable new user sign ups.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Disabling new user sign ups...`,
|
||||
success: `New user sign ups have been disabled successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to disable new user sign ups.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -88,14 +95,15 @@ export default function DisableNewUsersSettings() {
|
||||
<Form onSubmit={handleDisableNewUsersChange}>
|
||||
<SettingsContainer
|
||||
title="Disable New Users"
|
||||
description="If set, newly registered users are disabled and won’t be able to sign in."
|
||||
description="If set, newly registered users are disabled and won't be able to sign in."
|
||||
docsLink="https://docs.nhost.io/authentication#disable-new-users"
|
||||
switchId="authDisableNewUsers"
|
||||
switchId="disabled"
|
||||
showSwitch
|
||||
enabled={authDisableNewUsers}
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
@@ -1,65 +1,63 @@
|
||||
import ControlledSelect from '@/components/common/ControlledSelect';
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
useGetGravatarSettingsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Option from '@/ui/v2/Option';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import {
|
||||
AUTH_GRAVATAR_DEFAULT,
|
||||
AUTH_GRAVATAR_RATING,
|
||||
getToastStyleProps,
|
||||
} from '@/utils/settings/settingsConstants';
|
||||
import { useEffect } from 'react';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface GravatarFormValues {
|
||||
/**
|
||||
* Gravatar image to use as default.
|
||||
*/
|
||||
authGravatarDefault: string;
|
||||
/**
|
||||
* Gravatar image rating.
|
||||
*/
|
||||
authGravatarRating: string;
|
||||
/**
|
||||
* Enable Gravatar for this project
|
||||
*/
|
||||
authGravatarEnabled: boolean;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
default: Yup.string().label('Default Gravatar'),
|
||||
rating: Yup.string().label('Gravatar Rating'),
|
||||
});
|
||||
|
||||
export type GravatarFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function GravatarSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
|
||||
const { data, loading, error } = useGetGravatarSettingsQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const {
|
||||
default: defaultGravatar,
|
||||
rating,
|
||||
enabled,
|
||||
} = data?.config?.auth?.user?.gravatar || {};
|
||||
|
||||
const form = useForm<GravatarFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
authGravatarDefault: data?.app?.authGravatarDefault || '',
|
||||
authGravatarRating: data?.app?.authGravatarRating || '',
|
||||
authGravatarEnabled: data?.app?.authGravatarEnabled || false,
|
||||
default: defaultGravatar || '',
|
||||
rating: rating || '',
|
||||
enabled: enabled || false,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(() => ({
|
||||
authGravatarDefault: data?.app?.authGravatarDefault || '',
|
||||
authGravatarRating: data?.app?.authGravatarRating || '',
|
||||
authGravatarEnabled: data?.app?.authGravatarEnabled || false,
|
||||
}));
|
||||
}, [data?.app, form, form.reset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -75,29 +73,39 @@ export default function GravatarSettings() {
|
||||
}
|
||||
|
||||
const { register, formState, watch } = form;
|
||||
const authGravatarEnabled = watch('authGravatarEnabled');
|
||||
const gravatarEnabled = watch('enabled') ?? false;
|
||||
|
||||
const handleGravatarSettingsChange = async (values: GravatarFormValues) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
gravatar: values,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Gravatar settings are being updated...`,
|
||||
success: `Gravatar settings have been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's Gravatar settings.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Gravatar settings are being updated...`,
|
||||
success: `Gravatar settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's Gravatar settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -106,22 +114,23 @@ export default function GravatarSettings() {
|
||||
<SettingsContainer
|
||||
title="Gravatar"
|
||||
description="Use Gravatars for avatar URLs for users."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#gravatar"
|
||||
switchId="authGravatarEnabled"
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
enabled={authGravatarEnabled}
|
||||
className={twMerge(
|
||||
'grid grid-flow-col grid-cols-5 grid-rows-2 gap-y-6',
|
||||
!authGravatarEnabled && 'hidden',
|
||||
!gravatarEnabled && 'hidden',
|
||||
)}
|
||||
>
|
||||
<ControlledSelect
|
||||
{...register('authGravatarDefault')}
|
||||
id="authGravatarDefault"
|
||||
{...register('default')}
|
||||
id="default"
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="Default Gravatar"
|
||||
hideEmptyHelperText
|
||||
@@ -135,8 +144,8 @@ export default function GravatarSettings() {
|
||||
))}
|
||||
</ControlledSelect>
|
||||
<ControlledSelect
|
||||
{...register('authGravatarRating')}
|
||||
id="authGravatarRating"
|
||||
{...register('rating')}
|
||||
id="rating"
|
||||
className="col-span-5 lg:col-span-2"
|
||||
placeholder="Gravatar Rating"
|
||||
hideEmptyHelperText
|
||||
|
||||
@@ -1,54 +1,52 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
useGetAuthSettingsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetAuthenticationSettingsDocument,
|
||||
useGetAuthenticationSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useEffect } from 'react';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface MFASettingsFormValues {
|
||||
/**
|
||||
* One Time Password issuer
|
||||
*/
|
||||
authMfaTotpIssuer: string;
|
||||
/**
|
||||
* Enable Multi Factor Authentication for this project
|
||||
*/
|
||||
authMfaEnabled: boolean;
|
||||
}
|
||||
const validationSchema = Yup.object({
|
||||
enabled: Yup.boolean().label('Enabled'),
|
||||
issuer: Yup.string().label('OTP Issuer').nullable().required(),
|
||||
});
|
||||
|
||||
export type MFASettingsFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function MFASettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
|
||||
const { data, loading, error } = useGetAuthSettingsQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetAuthenticationSettingsDocument],
|
||||
});
|
||||
|
||||
const { data, loading, error } = useGetAuthenticationSettingsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { enabled, issuer } = data?.config?.auth?.totp || {};
|
||||
|
||||
const form = useForm<MFASettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
authMfaTotpIssuer: data?.app?.authMfaTotpIssuer,
|
||||
authMfaEnabled: data?.app?.authMfaEnabled,
|
||||
issuer,
|
||||
enabled,
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(() => ({
|
||||
authMfaTotpIssuer: data?.app?.authMfaTotpIssuer,
|
||||
authMfaEnabled: data?.app?.authMfaEnabled,
|
||||
}));
|
||||
}, [data?.app, form, form.reset]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
@@ -64,29 +62,37 @@ export default function MFASettings() {
|
||||
}
|
||||
|
||||
const { register, formState, watch } = form;
|
||||
const authMfaEnabled = watch('authMfaEnabled');
|
||||
const authMfaEnabled = watch('enabled');
|
||||
|
||||
const handleMFASettingsChange = async (values: MFASettingsFormValues) => {
|
||||
const updateAppMutation = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication.id,
|
||||
app: {
|
||||
...values,
|
||||
appId: currentApplication.id,
|
||||
config: {
|
||||
auth: {
|
||||
totp: values,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppMutation,
|
||||
{
|
||||
loading: `Multi-factor authentication settings are being updated...`,
|
||||
success: `Multi-factor authentication settings have been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's multi-factor authentication settings.`,
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: `Multi-factor authentication settings are being updated...`,
|
||||
success: `Multi-factor authentication settings have been updated successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's multi-factor authentication settings.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
form.reset(values);
|
||||
form.reset(values);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -95,13 +101,14 @@ export default function MFASettings() {
|
||||
<SettingsContainer
|
||||
title="Multi-Factor Authentication"
|
||||
description="Enable users to use MFA to sign in"
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/authentication#multi-factor-authentication"
|
||||
switchId="authMfaEnabled"
|
||||
enabled={authMfaEnabled}
|
||||
switchId="enabled"
|
||||
showSwitch
|
||||
className={twMerge(
|
||||
'grid grid-flow-row lg:grid-cols-5',
|
||||
@@ -109,14 +116,16 @@ export default function MFASettings() {
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
{...register('authMfaTotpIssuer')}
|
||||
name="authMfaTotpIssuer"
|
||||
id="authMfaTotpIssuer"
|
||||
{...register('issuer')}
|
||||
name="issuer"
|
||||
id="issuer"
|
||||
label="OTP Issuer"
|
||||
placeholder="Name of the One Time Password (OTP) issuer"
|
||||
className="col-span-2"
|
||||
fullWidth
|
||||
hideEmptyHelperText
|
||||
error={!!formState.errors?.issuer}
|
||||
helperText={formState.errors?.issuer?.message}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import Text from '@/ui/v2/Text';
|
||||
@@ -7,26 +8,7 @@ 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 {
|
||||
export interface BaseEnvironmentVariableFormProps extends DialogFormProps {
|
||||
/**
|
||||
* Determines the mode of the form.
|
||||
*
|
||||
@@ -50,8 +32,11 @@ export interface BaseEnvironmentVariableFormProps {
|
||||
}
|
||||
|
||||
export const baseEnvironmentVariableFormValidationSchema = Yup.object({
|
||||
id: Yup.string().label('ID'),
|
||||
name: Yup.string()
|
||||
.required('This field is required.')
|
||||
.label('Name')
|
||||
.nullable()
|
||||
.required()
|
||||
.test(
|
||||
'isEnvVarPermitted',
|
||||
'This is a reserved name.',
|
||||
@@ -77,18 +62,24 @@ export const baseEnvironmentVariableFormValidationSchema = Yup.object({
|
||||
(prefix) => !value.startsWith(prefix),
|
||||
),
|
||||
)
|
||||
.test('isEnvVarValid', `The name must start with a letter.`, (value) =>
|
||||
/^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
|
||||
.test(
|
||||
'isEnvVarValid',
|
||||
'A name must start with a letter and can only contain letters, numbers, and underscores.',
|
||||
(value) => /^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
|
||||
),
|
||||
devValue: Yup.string().required('This field is required.'),
|
||||
prodValue: Yup.string().required('This field is required.'),
|
||||
value: Yup.string().label('Value').nullable().required(),
|
||||
});
|
||||
|
||||
export type BaseEnvironmentVariableFormValues = Yup.InferType<
|
||||
typeof baseEnvironmentVariableFormValidationSchema
|
||||
>;
|
||||
|
||||
export default function BaseEnvironmentVariableForm({
|
||||
mode = 'edit',
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
location,
|
||||
}: BaseEnvironmentVariableFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const form = useFormContext<BaseEnvironmentVariableFormValues>();
|
||||
@@ -103,8 +94,8 @@ export default function BaseEnvironmentVariableForm({
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'dialog');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-6 px-6 pb-6">
|
||||
@@ -115,21 +106,7 @@ export default function BaseEnvironmentVariableForm({
|
||||
|
||||
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
|
||||
<Input
|
||||
{...register('name', {
|
||||
onChange: (event) => {
|
||||
if (
|
||||
event.target.value &&
|
||||
!/^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/g.test(event.target.value)
|
||||
) {
|
||||
// we need to prevent invalid characters from being entered
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.target.value = event.target.value.replace(
|
||||
/[^a-zA-Z0-9_]/g,
|
||||
'',
|
||||
);
|
||||
}
|
||||
},
|
||||
})}
|
||||
{...register('name')}
|
||||
id="name"
|
||||
label="Name"
|
||||
placeholder="EXAMPLE_NAME"
|
||||
@@ -143,30 +120,18 @@ export default function BaseEnvironmentVariableForm({
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('prodValue')}
|
||||
id="prodValue"
|
||||
label="Production Value"
|
||||
{...register('value')}
|
||||
id="value"
|
||||
label="Value"
|
||||
placeholder="Enter value"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.prodValue}
|
||||
helperText={errors?.prodValue?.message}
|
||||
error={!!errors.value}
|
||||
helperText={errors?.value?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus={mode === 'edit'}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('devValue')}
|
||||
id="devValue"
|
||||
label="Development Value"
|
||||
placeholder="Enter value"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.devValue}
|
||||
helperText={errors?.devValue?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{submitButtonText}
|
||||
|
||||
@@ -7,17 +7,19 @@ import BaseEnvironmentVariableForm, {
|
||||
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
GetEnvironmentVariablesDocument,
|
||||
useGetEnvironmentVariablesQuery,
|
||||
useInsertEnvironmentVariablesMutation,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export interface CreateEnvironmentVariableFormProps
|
||||
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel'> {
|
||||
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
@@ -31,8 +33,7 @@ export default function CreateEnvironmentVariableForm({
|
||||
const form = useForm<BaseEnvironmentVariableFormValues>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
devValue: '',
|
||||
prodValue: '',
|
||||
value: '',
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
|
||||
@@ -41,14 +42,14 @@ export default function CreateEnvironmentVariableForm({
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
const { data, loading, error } = useGetEnvironmentVariablesQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const [insertEnvironmentVariables] = useInsertEnvironmentVariablesMutation({
|
||||
refetchQueries: ['getEnvironmentVariables'],
|
||||
const availableEnvironmentVariables = data?.config?.global?.environment || [];
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetEnvironmentVariablesDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -68,13 +69,10 @@ export default function CreateEnvironmentVariableForm({
|
||||
|
||||
async function handleSubmit({
|
||||
name,
|
||||
prodValue,
|
||||
devValue,
|
||||
value,
|
||||
}: BaseEnvironmentVariableFormValues) {
|
||||
if (
|
||||
data?.environmentVariables?.some(
|
||||
(environmentVariable) => environmentVariable.name === name,
|
||||
)
|
||||
availableEnvironmentVariables?.some((variable) => variable.name === name)
|
||||
) {
|
||||
setError('name', {
|
||||
message: 'This environment variable already exists.',
|
||||
@@ -83,20 +81,34 @@ export default function CreateEnvironmentVariableForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const insertEnvironmentVariablePromise = insertEnvironmentVariables({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
environmentVariables: [
|
||||
{ appId: currentApplication.id, name, prodValue, devValue },
|
||||
],
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
global: {
|
||||
environment: [
|
||||
...(availableEnvironmentVariables?.map((variable) => ({
|
||||
name: variable.name,
|
||||
value: variable.value,
|
||||
})) || []),
|
||||
{
|
||||
name,
|
||||
value,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
insertEnvironmentVariablePromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Creating environment variable...',
|
||||
success: 'Environment variable has been created successfully.',
|
||||
error: 'An error occurred while creating the environment variable.',
|
||||
error: getServerError(
|
||||
'An error occurred while creating the environment variable.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
@@ -8,17 +8,19 @@ import BaseEnvironmentVariableForm, {
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { EnvironmentVariable } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
GetEnvironmentVariablesDocument,
|
||||
useGetEnvironmentVariablesQuery,
|
||||
useUpdateEnvironmentVariableMutation,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export interface EditEnvironmentVariableFormProps
|
||||
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel'> {
|
||||
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* The environment variable to edit.
|
||||
*/
|
||||
@@ -38,8 +40,7 @@ export default function EditEnvironmentVariableForm({
|
||||
defaultValues: {
|
||||
id: originalEnvironmentVariable.id || '',
|
||||
name: originalEnvironmentVariable.name || '',
|
||||
devValue: originalEnvironmentVariable.devValue || '',
|
||||
prodValue: originalEnvironmentVariable.prodValue || '',
|
||||
value: originalEnvironmentVariable.value || '',
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
|
||||
@@ -48,14 +49,14 @@ export default function EditEnvironmentVariableForm({
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
const { data, loading, error } = useGetEnvironmentVariablesQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const [updateEnvironmentVariable] = useUpdateEnvironmentVariableMutation({
|
||||
refetchQueries: ['getEnvironmentVariables'],
|
||||
const availableEnvironmentVariables = data?.config?.global?.environment || [];
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetEnvironmentVariablesDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -76,14 +77,13 @@ export default function EditEnvironmentVariableForm({
|
||||
async function handleSubmit({
|
||||
id,
|
||||
name,
|
||||
prodValue,
|
||||
devValue,
|
||||
value,
|
||||
}: BaseEnvironmentVariableFormValues) {
|
||||
if (
|
||||
data?.environmentVariables?.some(
|
||||
(environmentVariable) =>
|
||||
environmentVariable.name === name &&
|
||||
environmentVariable.name !== originalEnvironmentVariable.name,
|
||||
availableEnvironmentVariables.some(
|
||||
(variable) =>
|
||||
variable.name === name &&
|
||||
variable.name !== originalEnvironmentVariable.name,
|
||||
)
|
||||
) {
|
||||
setError('name', {
|
||||
@@ -93,22 +93,36 @@ export default function EditEnvironmentVariableForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const updateEnvironmentVariablePromise = updateEnvironmentVariable({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id,
|
||||
environmentVariable: {
|
||||
prodValue,
|
||||
devValue,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
global: {
|
||||
environment: [
|
||||
...availableEnvironmentVariables
|
||||
.filter((variable) => variable.id !== id)
|
||||
.map((variable) => ({
|
||||
name: variable.name,
|
||||
value: variable.value,
|
||||
})),
|
||||
{
|
||||
name,
|
||||
value,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateEnvironmentVariablePromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating environment variable...',
|
||||
success: 'Environment variable has been updated successfully.',
|
||||
error: 'An error occurred while updating the environment variable.',
|
||||
error: getServerError(
|
||||
'An error occurred while updating the environment variable.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
refetchGetAppInjectedVariablesQuery,
|
||||
useUpdateApplicationMutation,
|
||||
GetEnvironmentVariablesDocument,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
@@ -14,7 +15,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface EditJwtSecretFormProps {
|
||||
export interface EditJwtSecretFormProps extends DialogFormProps {
|
||||
/**
|
||||
* Initial JWT secret.
|
||||
*/
|
||||
@@ -39,14 +40,7 @@ export interface EditJwtSecretFormProps {
|
||||
onCancel?: VoidFunction;
|
||||
}
|
||||
|
||||
export interface EditJwtSecretFormValues {
|
||||
/**
|
||||
* JWT secret.
|
||||
*/
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
const validationSchema = Yup.object({
|
||||
jwtSecret: Yup.string()
|
||||
.nullable()
|
||||
.required('This field is required.')
|
||||
@@ -60,18 +54,19 @@ const validationSchema = Yup.object().shape({
|
||||
}),
|
||||
});
|
||||
|
||||
export type EditJwtSecretFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function EditJwtSecretForm({
|
||||
disabled,
|
||||
jwtSecret,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
location,
|
||||
}: EditJwtSecretFormProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApplication] = useUpdateApplicationMutation({
|
||||
refetchQueries: [
|
||||
refetchGetAppInjectedVariablesQuery({ id: currentApplication?.id }),
|
||||
],
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetEnvironmentVariablesDocument],
|
||||
});
|
||||
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
@@ -89,30 +84,42 @@ export default function EditJwtSecretForm({
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'dialog');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
async function handleSubmit(values: EditJwtSecretFormValues) {
|
||||
const updateAppPromise = updateApplication({
|
||||
const parsedJwtSecret = JSON.parse(values.jwtSecret);
|
||||
const isArray = Array.isArray(parsedJwtSecret);
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentApplication?.id,
|
||||
app: {
|
||||
hasuraGraphqlJwtSecret: values.jwtSecret,
|
||||
config: {
|
||||
hasura: {
|
||||
jwtSecrets: isArray ? parsedJwtSecret : [parsedJwtSecret],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
{
|
||||
loading: 'Updating JWT secret...',
|
||||
success: 'JWT secret has been updated successfully.',
|
||||
error: 'An error occurred while updating the JWT secret.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating JWT secret...',
|
||||
success: 'JWT secret has been updated successfully.',
|
||||
error: (arg: Error) =>
|
||||
arg?.message
|
||||
? `Error: ${arg.message}`
|
||||
: 'An error occurred while updating the JWT secret.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onSubmit?.();
|
||||
onSubmit?.();
|
||||
} catch {
|
||||
// Note: error is handled above
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -121,7 +128,7 @@ export default function EditJwtSecretForm({
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-auto flex-col content-between overflow-hidden pb-4"
|
||||
>
|
||||
<div className="px-6 overflow-y-auto flex-auto">
|
||||
<div className="flex-auto overflow-y-auto px-6">
|
||||
<Input
|
||||
{...register('jwtSecret')}
|
||||
error={Boolean(errors.jwtSecret?.message)}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
|
||||
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { EnvironmentVariable } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
@@ -13,34 +16,49 @@ import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useDeleteEnvironmentVariableMutation,
|
||||
GetEnvironmentVariablesDocument,
|
||||
useGetEnvironmentVariablesQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
|
||||
import { Fragment } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface PermissionVariableSettingsFormValues {
|
||||
export interface EnvironmentVariableSettingsFormValues {
|
||||
/**
|
||||
* Permission variables.
|
||||
* Environment variables.
|
||||
*/
|
||||
environmentVariables: EnvironmentVariable[];
|
||||
}
|
||||
|
||||
export default function EnvironmentVariableSettings() {
|
||||
const { openDialog, openAlertDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data, loading, error } = useGetEnvironmentVariablesQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const [deleteEnvironmentVariable] = useDeleteEnvironmentVariableMutation({
|
||||
refetchQueries: ['getEnvironmentVariables'],
|
||||
const availableEnvironmentVariables = [
|
||||
...(data?.config?.global?.environment || []),
|
||||
].sort((a, b) => {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name > b.name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetEnvironmentVariablesDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -57,26 +75,43 @@ export default function EnvironmentVariableSettings() {
|
||||
}
|
||||
|
||||
async function handleDeleteVariable({ id }: EnvironmentVariable) {
|
||||
const deleteEnvironmentVariablePromise = deleteEnvironmentVariable({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
global: {
|
||||
environment: availableEnvironmentVariables
|
||||
.filter((variable) => variable.id !== id)
|
||||
.map((variable) => ({
|
||||
name: variable.name,
|
||||
value: variable.value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
deleteEnvironmentVariablePromise,
|
||||
{
|
||||
loading: 'Deleting environment variable...',
|
||||
success: 'Environment variable has been deleted successfully.',
|
||||
error: 'An error occurred while deleting the environment variable.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Deleting environment variable...',
|
||||
success: 'Environment variable has been deleted successfully.',
|
||||
error: getServerError(
|
||||
'An error occurred while deleting the environment variable.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenCreator() {
|
||||
openDialog('CREATE_ENVIRONMENT_VARIABLE', {
|
||||
openDialog({
|
||||
title: 'Create Environment Variable',
|
||||
component: <CreateEnvironmentVariableForm />,
|
||||
props: {
|
||||
titleProps: { className: '!pb-0' },
|
||||
PaperProps: { className: 'gap-2 max-w-sm' },
|
||||
@@ -85,9 +120,13 @@ export default function EnvironmentVariableSettings() {
|
||||
}
|
||||
|
||||
function handleOpenEditor(originalVariable: EnvironmentVariable) {
|
||||
openDialog('EDIT_ENVIRONMENT_VARIABLE', {
|
||||
title: 'Edit Environment Variables',
|
||||
payload: { originalEnvironmentVariable: originalVariable },
|
||||
openDialog({
|
||||
title: 'Edit Environment Variable',
|
||||
component: (
|
||||
<EditEnvironmentVariableForm
|
||||
originalEnvironmentVariable={originalVariable}
|
||||
/>
|
||||
),
|
||||
props: {
|
||||
titleProps: { className: '!pb-0' },
|
||||
PaperProps: { className: 'gap-2 max-w-sm' },
|
||||
@@ -113,12 +152,6 @@ export default function EnvironmentVariableSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
const availableEnvironmentVariables =
|
||||
[...data.environmentVariables].sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
) || [];
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Project Environment Variables"
|
||||
@@ -134,95 +167,79 @@ export default function EnvironmentVariableSettings() {
|
||||
>
|
||||
<Box className="grid grid-cols-2 gap-2 border-b-1 px-4 py-3 lg:grid-cols-3">
|
||||
<Text className="font-medium">Variable Name</Text>
|
||||
<Text className="font-medium lg:col-span-2">Updated</Text>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
{availableEnvironmentVariables.length > 0 && (
|
||||
<List>
|
||||
{availableEnvironmentVariables.map((environmentVariable, index) => {
|
||||
const timestamp = formatDistanceToNowStrict(
|
||||
parseISO(environmentVariable.updatedAt),
|
||||
{ addSuffix: true, roundingMethod: 'floor' },
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={environmentVariable.id}>
|
||||
<ListItem.Root
|
||||
className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
{availableEnvironmentVariables.map((environmentVariable, index) => (
|
||||
<Fragment key={environmentVariable.id}>
|
||||
<ListItem.Root
|
||||
className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
<IconButton variant="borderless" color="secondary">
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleOpenEditor(environmentVariable)}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() =>
|
||||
handleOpenEditor(environmentVariable)
|
||||
}
|
||||
>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
|
||||
<Divider component="li" />
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
onClick={() =>
|
||||
handleConfirmDelete(environmentVariable)
|
||||
}
|
||||
>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text className="truncate">
|
||||
{environmentVariable.name}
|
||||
</ListItem.Text>
|
||||
<Dropdown.Item
|
||||
onClick={() =>
|
||||
handleConfirmDelete(environmentVariable)
|
||||
}
|
||||
>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text className="truncate">
|
||||
{environmentVariable.name}
|
||||
</ListItem.Text>
|
||||
</ListItem.Root>
|
||||
|
||||
<Text
|
||||
variant="subtitle1"
|
||||
className="truncate lg:col-span-2"
|
||||
>
|
||||
{timestamp === '0 seconds ago' ||
|
||||
timestamp === 'in 0 seconds'
|
||||
? 'Now'
|
||||
: timestamp}
|
||||
</Text>
|
||||
</ListItem.Root>
|
||||
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availableEnvironmentVariables.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availableEnvironmentVariables.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
@@ -231,6 +248,7 @@ export default function EnvironmentVariableSettings() {
|
||||
variant="borderless"
|
||||
startIcon={<PlusIcon />}
|
||||
onClick={handleOpenCreator}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Create Environment Variable
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import InlineCode from '@/components/common/InlineCode';
|
||||
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||
import { useAppClient } from '@/hooks/useAppClient';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
@@ -20,7 +22,8 @@ import generateAppServiceUrl, {
|
||||
} from '@/utils/common/generateAppServiceUrl';
|
||||
import { LOCAL_HASURA_URL } from '@/utils/env';
|
||||
import { generateRemoteAppUrl } from '@/utils/helpers';
|
||||
import { useGetAppInjectedVariablesQuery } from '@/utils/__generated__/graphql';
|
||||
import getJwtSecretsWithoutFalsyValues from '@/utils/settings/getJwtSecretsWithoutFalsyValues';
|
||||
import { useGetEnvironmentVariablesQuery } from '@/utils/__generated__/graphql';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
export default function SystemEnvironmentVariableSettings() {
|
||||
@@ -28,10 +31,22 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data, loading, error } = useGetAppInjectedVariablesQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, loading, error } = useGetEnvironmentVariablesQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { jwtSecrets, webhookSecret, adminSecret } = data?.config?.hasura || {};
|
||||
const jwtSecretsWithoutFalsyValues = getJwtSecretsWithoutFalsyValues(
|
||||
jwtSecrets || [],
|
||||
);
|
||||
const stringifiedJwtSecrets =
|
||||
jwtSecretsWithoutFalsyValues.length === 1
|
||||
? JSON.stringify(jwtSecretsWithoutFalsyValues[0], null, 2)
|
||||
: JSON.stringify(jwtSecretsWithoutFalsyValues, null, 2);
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const appClient = useAppClient();
|
||||
@@ -50,7 +65,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
}
|
||||
|
||||
function showViewJwtSecretModal() {
|
||||
openDialog('EDIT_JWT_SECRET', {
|
||||
openDialog({
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Auth JWT Secret</span>
|
||||
@@ -61,15 +76,14 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
payload: {
|
||||
disabled: true,
|
||||
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
|
||||
},
|
||||
component: (
|
||||
<EditJwtSecretForm disabled jwtSecret={stringifiedJwtSecrets} />
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function showEditJwtSecretModal() {
|
||||
openDialog('EDIT_JWT_SECRET', {
|
||||
openDialog({
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Edit JWT Secret</span>
|
||||
@@ -80,9 +94,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
payload: {
|
||||
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
|
||||
},
|
||||
component: <EditJwtSecretForm jwtSecret={stringifiedJwtSecrets} />,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,7 +119,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
),
|
||||
},
|
||||
{ key: 'NHOST_AUTH_URL', value: appClient.auth.url },
|
||||
{ key: 'NHOST_GRAPHQL_URL', value: appClient.graphql.url },
|
||||
{ key: 'NHOST_GRAPHQL_URL', value: appClient.graphql.httpUrl },
|
||||
{ key: 'NHOST_STORAGE_URL', value: appClient.storage.url },
|
||||
{ key: 'NHOST_FUNCTIONS_URL', value: appClient.functions.url },
|
||||
];
|
||||
@@ -134,7 +146,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
<Text className="truncate" color="secondary">
|
||||
{showAdminSecret ? (
|
||||
<InlineCode className="!text-sm font-medium">
|
||||
{currentApplication?.hasuraGraphqlAdminSecret}
|
||||
{adminSecret}
|
||||
</InlineCode>
|
||||
) : (
|
||||
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
|
||||
@@ -167,7 +179,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
<Text className="truncate" color="secondary">
|
||||
{showWebhookSecret ? (
|
||||
<InlineCode className="!text-sm font-medium">
|
||||
{data?.app?.webhookSecret}
|
||||
{webhookSecret}
|
||||
</InlineCode>
|
||||
) : (
|
||||
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
|
||||
@@ -231,6 +243,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
variant="borderless"
|
||||
onClick={showEditJwtSecretModal}
|
||||
size="small"
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Edit JWT Secret
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import InlineCode from '@/components/common/InlineCode';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useEffect } from 'react';
|
||||
@@ -20,6 +22,7 @@ export interface BaseDirectoryFormValues {
|
||||
}
|
||||
|
||||
export default function BaseDirectorySettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const client = useApolloClient();
|
||||
@@ -54,7 +57,9 @@ export default function BaseDirectorySettings() {
|
||||
{
|
||||
loading: `The base directory is being updated...`,
|
||||
success: `The base directory has been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's base directory.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's base directory.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
@@ -84,9 +89,11 @@ export default function BaseDirectorySettings() {
|
||||
<InlineCode className="text-xs">nhost</InlineCode> folder.
|
||||
</>
|
||||
}
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/github-integration#base-directory"
|
||||
className="grid grid-flow-row lg:grid-cols-5"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useEffect } from 'react';
|
||||
@@ -19,6 +21,7 @@ export interface DeploymentBranchFormValues {
|
||||
}
|
||||
|
||||
export default function DeploymentBranchSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const client = useApolloClient();
|
||||
@@ -57,7 +60,9 @@ export default function DeploymentBranchSettings() {
|
||||
{
|
||||
loading: `The deployment branch is being updated...`,
|
||||
success: `The deployment branch has been updated successfully.`,
|
||||
error: `An error occurred while trying to update the project's deployment branch.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to update the project's deployment branch.`,
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
@@ -79,9 +84,11 @@ export default function DeploymentBranchSettings() {
|
||||
<SettingsContainer
|
||||
title="Deployment Branch"
|
||||
description="All commits pushed to this deployment branch will trigger a deployment. You can switch to a different branch here."
|
||||
primaryActionButtonProps={{
|
||||
disabled: !formState.isValid || !formState.isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !formState.isDirty || maintenanceActive,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
docsLink="https://docs.nhost.io/platform/github-integration#deployment-branch"
|
||||
className="grid grid-flow-row lg:grid-cols-5"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import Text from '@/ui/v2/Text';
|
||||
@@ -7,18 +8,7 @@ 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 {
|
||||
export interface BasePermissionVariableFormProps extends DialogFormProps {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
@@ -36,14 +26,19 @@ export interface BasePermissionVariableFormProps {
|
||||
}
|
||||
|
||||
export const basePermissionVariableValidationSchema = Yup.object({
|
||||
key: Yup.string().required('This field is required.'),
|
||||
value: Yup.string().required('This field is required.'),
|
||||
key: Yup.string().label('Field Name').nullable().required(),
|
||||
value: Yup.string().label('Path').nullable().required(),
|
||||
});
|
||||
|
||||
export type BasePermissionVariableFormValues = Yup.InferType<
|
||||
typeof basePermissionVariableValidationSchema
|
||||
>;
|
||||
|
||||
export default function BasePermissionVariableForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
location,
|
||||
}: BasePermissionVariableFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const form = useFormContext<BasePermissionVariableFormValues>();
|
||||
@@ -56,8 +51,8 @@ export default function BasePermissionVariableForm({
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'dialog');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-2 px-6 pb-6">
|
||||
|
||||
@@ -7,19 +7,19 @@ import BasePermissionVariableForm, {
|
||||
} from '@/components/settings/permissions/BasePermissionVariableForm';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
|
||||
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
|
||||
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetAppCustomClaimsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export interface CreatePermissionVariableFormProps
|
||||
extends Pick<BasePermissionVariableFormProps, 'onCancel'> {
|
||||
extends Pick<BasePermissionVariableFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
@@ -32,11 +32,14 @@ export default function CreatePermissionVariableForm({
|
||||
}: CreatePermissionVariableFormProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
const { data, error, loading } = useGetAppCustomClaimsQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, error, loading } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { customClaims: permissionVariables } =
|
||||
data?.config?.auth?.session?.accessToken || {};
|
||||
|
||||
const form = useForm<BasePermissionVariableFormValues>({
|
||||
defaultValues: {
|
||||
key: '',
|
||||
@@ -46,8 +49,8 @@ export default function CreatePermissionVariableForm({
|
||||
resolver: yupResolver(basePermissionVariableValidationSchema),
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getAppCustomClaims'],
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -61,9 +64,8 @@ export default function CreatePermissionVariableForm({
|
||||
}
|
||||
|
||||
const { setError } = form;
|
||||
const availablePermissionVariables = getPermissionVariablesArray(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
);
|
||||
const availablePermissionVariables =
|
||||
getAllPermissionVariables(permissionVariables);
|
||||
|
||||
async function handleSubmit({
|
||||
key,
|
||||
@@ -79,26 +81,29 @@ export default function CreatePermissionVariableForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const permissionVariablesObject = getPermissionVariablesObject(
|
||||
availablePermissionVariables.filter(
|
||||
(permissionVariable) => !permissionVariable.isSystemClaim,
|
||||
),
|
||||
);
|
||||
const existingPermissionVariables =
|
||||
permissionVariables?.map((permissionVariable) => ({
|
||||
key: permissionVariable.key,
|
||||
value: permissionVariable.value,
|
||||
})) || [];
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authJwtCustomClaims: {
|
||||
...permissionVariablesObject,
|
||||
[key]: value,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
session: {
|
||||
accessToken: {
|
||||
customClaims: [...existingPermissionVariables, { key, value }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Creating permission variable...',
|
||||
success: 'Permission variable has been created successfully.',
|
||||
|
||||
@@ -6,25 +6,25 @@ import BasePermissionVariableForm, {
|
||||
basePermissionVariableValidationSchema,
|
||||
} from '@/components/settings/permissions/BasePermissionVariableForm';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { CustomClaim } from '@/types/application';
|
||||
import type { PermissionVariable } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getPermissionVariables from '@/utils/settings/getPermissionVariablesArray';
|
||||
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
|
||||
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetAppCustomClaimsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export interface EditPermissionVariableFormProps
|
||||
extends Pick<BasePermissionVariableFormProps, 'onCancel'> {
|
||||
extends Pick<BasePermissionVariableFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* The permission variable to be edited.
|
||||
*/
|
||||
originalVariable: CustomClaim;
|
||||
originalVariable: PermissionVariable;
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
@@ -38,11 +38,14 @@ export default function EditPermissionVariableForm({
|
||||
}: EditPermissionVariableFormProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
|
||||
const { data, error, loading } = useGetAppCustomClaimsQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, error, loading } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { customClaims: permissionVariables } =
|
||||
data?.config?.auth?.session?.accessToken || {};
|
||||
|
||||
const form = useForm<BasePermissionVariableFormValues>({
|
||||
defaultValues: {
|
||||
key: originalVariable.key || '',
|
||||
@@ -52,8 +55,8 @@ export default function EditPermissionVariableForm({
|
||||
resolver: yupResolver(basePermissionVariableValidationSchema),
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getAppCustomClaims'],
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -67,9 +70,8 @@ export default function EditPermissionVariableForm({
|
||||
}
|
||||
|
||||
const { setError } = form;
|
||||
const availablePermissionVariables = getPermissionVariables(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
);
|
||||
const availablePermissionVariables =
|
||||
getAllPermissionVariables(permissionVariables);
|
||||
|
||||
async function handleSubmit({
|
||||
key,
|
||||
@@ -92,36 +94,43 @@ export default function EditPermissionVariableForm({
|
||||
(permissionVariable) => permissionVariable.key === originalVariable.key,
|
||||
);
|
||||
|
||||
const updatedPermissionVariables = availablePermissionVariables.map(
|
||||
(permissionVariable, index) => {
|
||||
if (index === originalPermissionVariableIndex) {
|
||||
return { key, value };
|
||||
const updatedPermissionVariables = availablePermissionVariables
|
||||
.map((permissionVariable, index) => {
|
||||
if (permissionVariable.isSystemVariable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return permissionVariable;
|
||||
},
|
||||
);
|
||||
if (index === originalPermissionVariableIndex) {
|
||||
return {
|
||||
key,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
const permissionVariablesObject = getPermissionVariablesObject(
|
||||
updatedPermissionVariables.filter(
|
||||
(permissionVariable) => !permissionVariable.isSystemClaim,
|
||||
),
|
||||
);
|
||||
return {
|
||||
key: permissionVariable.key,
|
||||
value: permissionVariable.value,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authJwtCustomClaims: {
|
||||
...permissionVariablesObject,
|
||||
[key]: value,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
session: {
|
||||
accessToken: {
|
||||
customClaims: updatedPermissionVariables,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating permission variable...',
|
||||
success: 'Permission variable has been updated successfully.',
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
|
||||
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { CustomClaim } from '@/types/application';
|
||||
import type { PermissionVariable } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -15,35 +18,33 @@ import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import Tooltip from '@/ui/v2/Tooltip';
|
||||
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
|
||||
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetAppCustomClaimsQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { Fragment } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface PermissionVariableSettingsFormValues {
|
||||
/**
|
||||
* Permission variables.
|
||||
*/
|
||||
authJwtCustomClaims: CustomClaim[];
|
||||
}
|
||||
|
||||
export default function PermissionVariableSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { openDialog, openAlertDialog } = useDialog();
|
||||
|
||||
const { data, loading, error } = useGetAppCustomClaimsQuery({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
},
|
||||
const { data, loading, error } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getAppCustomClaims'],
|
||||
const { customClaims: permissionVariables } =
|
||||
data?.config?.auth?.session?.accessToken || {};
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -56,40 +57,44 @@ export default function PermissionVariableSettings() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleDeleteVariable({ key }: CustomClaim) {
|
||||
const filteredCustomClaims = Object.keys(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
).filter((customClaimKey) => customClaimKey !== key);
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
async function handleDeleteVariable({ id }: PermissionVariable) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authJwtCustomClaims: filteredCustomClaims.reduce(
|
||||
(customClaims, currentKey) => ({
|
||||
...customClaims,
|
||||
[currentKey]: data?.app?.authJwtCustomClaims[currentKey],
|
||||
}),
|
||||
{},
|
||||
),
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
session: {
|
||||
accessToken: {
|
||||
customClaims: permissionVariables
|
||||
?.filter((permissionVariable) => permissionVariable.id !== id)
|
||||
.map((permissionVariable) => ({
|
||||
key: permissionVariable.key,
|
||||
value: permissionVariable.value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Deleting permission variable...',
|
||||
success: 'Permission variable has been deleted successfully.',
|
||||
error: 'An error occurred while trying to delete permission variable.',
|
||||
error: getServerError(
|
||||
'An error occurred while trying to delete permission variable.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
function handleOpenCreator() {
|
||||
openDialog('CREATE_PERMISSION_VARIABLE', {
|
||||
openDialog({
|
||||
title: 'Create Permission Variable',
|
||||
component: <CreatePermissionVariableForm />,
|
||||
props: {
|
||||
titleProps: { className: '!pb-0' },
|
||||
PaperProps: { className: 'max-w-sm' },
|
||||
@@ -97,10 +102,12 @@ export default function PermissionVariableSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleOpenEditor(originalVariable: CustomClaim) {
|
||||
openDialog('EDIT_PERMISSION_VARIABLE', {
|
||||
function handleOpenEditor(originalVariable: PermissionVariable) {
|
||||
openDialog({
|
||||
title: 'Edit Permission Variable',
|
||||
payload: { originalVariable },
|
||||
component: (
|
||||
<EditPermissionVariableForm originalVariable={originalVariable} />
|
||||
),
|
||||
props: {
|
||||
titleProps: { className: '!pb-0' },
|
||||
PaperProps: { className: 'max-w-sm' },
|
||||
@@ -108,7 +115,7 @@ export default function PermissionVariableSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirmDelete(originalVariable: CustomClaim) {
|
||||
function handleConfirmDelete(originalVariable: PermissionVariable) {
|
||||
openAlertDialog({
|
||||
title: 'Delete Permission Variable',
|
||||
payload: (
|
||||
@@ -126,9 +133,8 @@ export default function PermissionVariableSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
const availablePermissionVariables = getPermissionVariablesArray(
|
||||
data?.app?.authJwtCustomClaims,
|
||||
);
|
||||
const availablePermissionVariables =
|
||||
getAllPermissionVariables(permissionVariables);
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
@@ -136,7 +142,7 @@ export default function PermissionVariableSettings() {
|
||||
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"
|
||||
className="my-2 px-0"
|
||||
slotProps={{ submitButton: { className: 'invisible' } }}
|
||||
>
|
||||
<Box className="grid grid-cols-2 border-b-1 px-4 py-3">
|
||||
@@ -146,28 +152,33 @@ export default function PermissionVariableSettings() {
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<List>
|
||||
{availablePermissionVariables.map((customClaim, index) => (
|
||||
<Fragment key={customClaim.key}>
|
||||
{availablePermissionVariables.map((permissionVariable, index) => (
|
||||
<Fragment key={permissionVariable.id}>
|
||||
<ListItem.Root
|
||||
className="px-4 grid grid-cols-2"
|
||||
className="grid grid-cols-2 px-4"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Tooltip
|
||||
title={
|
||||
customClaim.isSystemClaim
|
||||
permissionVariable.isSystemVariable
|
||||
? "You can't edit system permission variables"
|
||||
: ''
|
||||
}
|
||||
placement="right"
|
||||
disableHoverListener={!customClaim.isSystemClaim}
|
||||
hasDisabledChildren={customClaim.isSystemClaim}
|
||||
disableHoverListener={
|
||||
!permissionVariable.isSystemVariable
|
||||
}
|
||||
hasDisabledChildren={permissionVariable.isSystemVariable}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<Dropdown.Trigger asChild hideChevron>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={customClaim.isSystemClaim}
|
||||
disabled={
|
||||
permissionVariable.isSystemVariable ||
|
||||
maintenanceActive
|
||||
}
|
||||
>
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
@@ -187,7 +198,7 @@ export default function PermissionVariableSettings() {
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleOpenEditor(customClaim)}
|
||||
onClick={() => handleOpenEditor(permissionVariable)}
|
||||
>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
@@ -195,7 +206,7 @@ export default function PermissionVariableSettings() {
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
onClick={() => handleConfirmDelete(customClaim)}
|
||||
onClick={() => handleConfirmDelete(permissionVariable)}
|
||||
>
|
||||
<Text
|
||||
className="font-medium"
|
||||
@@ -213,15 +224,17 @@ export default function PermissionVariableSettings() {
|
||||
<ListItem.Text
|
||||
primary={
|
||||
<>
|
||||
X-Hasura-{customClaim.key}{' '}
|
||||
{customClaim.isSystemClaim && (
|
||||
<LockIcon className="w-4 h-4" />
|
||||
X-Hasura-{permissionVariable.key}{' '}
|
||||
{permissionVariable.isSystemVariable && (
|
||||
<LockIcon className="h-4 w-4" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Text className="font-medium">user.{customClaim.value}</Text>
|
||||
<Text className="font-medium">
|
||||
user.{permissionVariable.value}
|
||||
</Text>
|
||||
</ListItem.Root>
|
||||
|
||||
<Divider
|
||||
@@ -237,10 +250,11 @@ export default function PermissionVariableSettings() {
|
||||
</List>
|
||||
|
||||
<Button
|
||||
className="justify-self-start mx-4"
|
||||
className="mx-4 justify-self-start"
|
||||
variant="borderless"
|
||||
startIcon={<PlusIcon />}
|
||||
onClick={handleOpenCreator}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Create Permission Variable
|
||||
</Button>
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from './PermissionVariableSettings';
|
||||
export { default } from './PermissionVariableSettings';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
@@ -8,14 +9,7 @@ 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 {
|
||||
export interface BaseRoleFormProps extends DialogFormProps {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
@@ -36,10 +30,15 @@ export const baseRoleFormValidationSchema = Yup.object({
|
||||
name: Yup.string().required('This field is required.'),
|
||||
});
|
||||
|
||||
export type BaseRoleFormValues = Yup.InferType<
|
||||
typeof baseRoleFormValidationSchema
|
||||
>;
|
||||
|
||||
export default function BaseRoleForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
location,
|
||||
}: BaseRoleFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const form = useFormContext<BaseRoleFormValues>();
|
||||
@@ -52,8 +51,8 @@ export default function BaseRoleForm({
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'dialog');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-3 px-6 pb-6">
|
||||
|
||||
@@ -7,18 +7,20 @@ import BaseRoleForm, {
|
||||
} from '@/components/settings/roles/BaseRoleForm';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export interface CreateRoleFormProps
|
||||
extends Pick<BaseRoleFormProps, 'onCancel'> {
|
||||
extends Pick<BaseRoleFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
@@ -30,10 +32,11 @@ export default function CreateRoleForm({
|
||||
...props
|
||||
}: CreateRoleFormProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data, loading, error } = useGetRolesQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, loading, error } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
const { allowed: allowedRoles } = data?.config?.auth?.user?.roles || {};
|
||||
|
||||
const form = useForm<BaseRoleFormValues>({
|
||||
defaultValues: {},
|
||||
@@ -41,7 +44,9 @@ export default function CreateRoleForm({
|
||||
resolver: yupResolver(baseRoleFormValidationSchema),
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({ refetchQueries: ['getRoles'] });
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <ActivityIndicator delay={1000} label="Loading roles..." />;
|
||||
@@ -52,7 +57,7 @@ export default function CreateRoleForm({
|
||||
}
|
||||
|
||||
const { setError } = form;
|
||||
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
|
||||
const availableRoles = getUserRoles(allowedRoles);
|
||||
|
||||
async function handleSubmit({ name }: BaseRoleFormValues) {
|
||||
if (availableRoles.some((role) => role.name === name)) {
|
||||
@@ -61,26 +66,40 @@ export default function CreateRoleForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
const updatedAllowedRoles = allowedRoles ? [...allowedRoles, name] : [name];
|
||||
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authUserDefaultAllowedRoles: `${data?.app?.authUserDefaultAllowedRoles},${name}`,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
roles: {
|
||||
allowed: updatedAllowedRoles,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
{
|
||||
loading: 'Creating role...',
|
||||
success: 'Role has been created successfully.',
|
||||
error: 'An error occurred while trying to create the role.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Creating role...',
|
||||
success: 'Role has been created successfully.',
|
||||
error: getServerError(
|
||||
'An error occurred while trying to create the role.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
await onSubmit?.();
|
||||
onSubmit?.();
|
||||
} catch (updateConfigError) {
|
||||
console.error(updateConfigError);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,17 +8,20 @@ import BaseRoleForm, {
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { Role } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export interface EditRoleFormProps extends Pick<BaseRoleFormProps, 'onCancel'> {
|
||||
export interface EditRoleFormProps
|
||||
extends Pick<BaseRoleFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* The role to be edited.
|
||||
*/
|
||||
@@ -35,11 +38,14 @@ export default function EditRoleForm({
|
||||
...props
|
||||
}: EditRoleFormProps) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data, loading, error } = useGetRolesQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, loading, error } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const { allowed: allowedRoles, default: defaultRole } =
|
||||
data?.config?.auth?.user?.roles || {};
|
||||
|
||||
const form = useForm<BaseRoleFormValues>({
|
||||
defaultValues: {
|
||||
name: originalRole.name || '',
|
||||
@@ -48,8 +54,8 @@ export default function EditRoleForm({
|
||||
resolver: yupResolver(baseRoleFormValidationSchema),
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getRoles'],
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -61,7 +67,7 @@ export default function EditRoleForm({
|
||||
}
|
||||
|
||||
const { setError } = form;
|
||||
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
|
||||
const availableRoles = getUserRoles(allowedRoles);
|
||||
|
||||
async function handleSubmit({ name }: BaseRoleFormValues) {
|
||||
if (
|
||||
@@ -74,47 +80,55 @@ export default function EditRoleForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultAllowedRolesList =
|
||||
data?.app?.authUserDefaultAllowedRoles.split(',') || [];
|
||||
const defaultAllowedRolesList = allowedRoles || [];
|
||||
|
||||
const originalRoleIndex = defaultAllowedRolesList.findIndex(
|
||||
(role) => role.trim() === originalRole.name,
|
||||
);
|
||||
|
||||
const updatedDefaultAllowedRoles = defaultAllowedRolesList
|
||||
.map((role, index) => {
|
||||
const updatedDefaultAllowedRoles = defaultAllowedRolesList.map(
|
||||
(role, index) => {
|
||||
if (index === originalRoleIndex) {
|
||||
return name;
|
||||
}
|
||||
|
||||
return role;
|
||||
})
|
||||
.join(',');
|
||||
},
|
||||
);
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authUserDefaultRole:
|
||||
data?.app?.authUserDefaultRole === originalRole.name
|
||||
? name
|
||||
: data?.app?.authUserDefaultRole,
|
||||
authUserDefaultAllowedRoles: updatedDefaultAllowedRoles,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
roles: {
|
||||
default: defaultRole === originalRole.name ? name : defaultRole,
|
||||
allowed: updatedDefaultAllowedRoles,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
{
|
||||
loading: 'Updating role...',
|
||||
success: 'Role has been updated successfully.',
|
||||
error: 'An error occurred while trying to update the role.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating role...',
|
||||
success: 'Role has been updated successfully.',
|
||||
error: getServerError(
|
||||
'An error occurred while trying to update the role.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
await onSubmit?.();
|
||||
onSubmit?.();
|
||||
} catch (updateConfigError) {
|
||||
console.error(updateConfigError);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
|
||||
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import type { Role } from '@/types/application';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
@@ -15,11 +18,13 @@ import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
GetRolesPermissionsDocument,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { Fragment } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -37,15 +42,20 @@ export interface RoleSettingsFormValues {
|
||||
}
|
||||
|
||||
export default function RoleSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { openDialog, openAlertDialog } = useDialog();
|
||||
|
||||
const { data, loading, error } = useGetRolesQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
const { data, loading, error } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
fetchPolicy: 'cache-only',
|
||||
});
|
||||
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
refetchQueries: ['getRoles'],
|
||||
const { allowed: allowedRoles, default: defaultRole } =
|
||||
data?.config?.auth?.user?.roles || {};
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetRolesPermissionsDocument],
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
@@ -57,59 +67,69 @@ export default function RoleSettings() {
|
||||
}
|
||||
|
||||
async function handleSetAsDefault({ name }: Role) {
|
||||
const updateAppPromise = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authUserDefaultRole: name,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
roles: {
|
||||
allowed: allowedRoles,
|
||||
default: name,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating default role...',
|
||||
success: 'Default role has been updated successfully.',
|
||||
error: 'An error occurred while trying to update the default role.',
|
||||
error: getServerError(
|
||||
'An error occurred while trying to update the default role.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteRole({ name }: Role) {
|
||||
const filteredRoles = data?.app?.authUserDefaultAllowedRoles
|
||||
.split(',')
|
||||
.filter((role) => role !== name)
|
||||
.join(',');
|
||||
|
||||
const updateAppPromise = updateApp({
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
id: currentApplication?.id,
|
||||
app: {
|
||||
authUserDefaultAllowedRoles: filteredRoles,
|
||||
authUserDefaultRole:
|
||||
name === data?.app?.authUserDefaultRole
|
||||
? 'user'
|
||||
: data?.app?.authUserDefaultRole,
|
||||
appId: currentApplication?.id,
|
||||
config: {
|
||||
auth: {
|
||||
user: {
|
||||
roles: {
|
||||
allowed: allowedRoles.filter((role) => role !== name),
|
||||
default: name === defaultRole ? 'user' : defaultRole,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await toast.promise(
|
||||
updateAppPromise,
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Deleting allowed role...',
|
||||
success: 'Allowed Role has been deleted successfully.',
|
||||
error: 'An error occurred while trying to delete the allowed role.',
|
||||
error: getServerError(
|
||||
'An error occurred while trying to delete the allowed role.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
|
||||
function handleOpenCreator() {
|
||||
openDialog('CREATE_ROLE', {
|
||||
openDialog({
|
||||
title: 'Create Allowed Role',
|
||||
component: <CreateRoleForm />,
|
||||
props: {
|
||||
titleProps: { className: '!pb-0' },
|
||||
PaperProps: { className: 'max-w-sm' },
|
||||
@@ -118,9 +138,9 @@ export default function RoleSettings() {
|
||||
}
|
||||
|
||||
function handleOpenEditor(originalRole: Role) {
|
||||
openDialog('EDIT_ROLE', {
|
||||
openDialog({
|
||||
title: 'Edit Allowed Role',
|
||||
payload: { originalRole },
|
||||
component: <EditRoleForm originalRole={originalRole} />,
|
||||
props: {
|
||||
titleProps: { className: '!pb-0' },
|
||||
PaperProps: { className: 'max-w-sm' },
|
||||
@@ -145,9 +165,7 @@ export default function RoleSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
const availableAllowedRoles = getUserRoles(
|
||||
data?.app?.authUserDefaultAllowedRoles,
|
||||
);
|
||||
const availableAllowedRoles = getUserRoles(allowedRoles);
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
@@ -155,7 +173,10 @@ export default function RoleSettings() {
|
||||
description="Allowed roles are roles users get automatically when they sign up."
|
||||
docsLink="https://docs.nhost.io/authentication/users#allowed-roles"
|
||||
rootClassName="gap-0"
|
||||
className="my-2 px-0"
|
||||
className={twMerge(
|
||||
'my-2 px-0',
|
||||
availableAllowedRoles.length === 0 && 'gap-2',
|
||||
)}
|
||||
slotProps={{ submitButton: { className: 'invisible' } }}
|
||||
>
|
||||
<Box className="border-b-1 px-4 py-3">
|
||||
@@ -163,103 +184,110 @@ export default function RoleSettings() {
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<List>
|
||||
{availableAllowedRoles.map((role, index) => (
|
||||
<Fragment key={role.name}>
|
||||
<ListItem.Root
|
||||
className="px-4"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<IconButton variant="borderless" color="secondary">
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Item onClick={() => handleSetAsDefault(role)}>
|
||||
<Text className="font-medium">Set as Default</Text>
|
||||
</Dropdown.Item>
|
||||
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
disabled={role.isSystemRole}
|
||||
onClick={() => handleOpenEditor(role)}
|
||||
{availableAllowedRoles.length > 0 && (
|
||||
<List>
|
||||
{availableAllowedRoles.map((role, index) => (
|
||||
<Fragment key={role.name}>
|
||||
<ListItem.Root
|
||||
className="px-4"
|
||||
secondaryAction={
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
<DotsVerticalIcon />
|
||||
</IconButton>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
disabled={role.isSystemRole}
|
||||
onClick={() => handleConfirmDelete(role)}
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-32' }}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
>
|
||||
<ListItem.Text
|
||||
primaryTypographyProps={{
|
||||
className:
|
||||
'inline-grid grid-flow-col gap-1 items-center h-6 font-medium',
|
||||
}}
|
||||
primary={
|
||||
<>
|
||||
{role.name}
|
||||
<Dropdown.Item onClick={() => handleSetAsDefault(role)}>
|
||||
<Text className="font-medium">Set as Default</Text>
|
||||
</Dropdown.Item>
|
||||
|
||||
{role.isSystemRole && <LockIcon className="h-4 w-4" />}
|
||||
<Divider component="li" />
|
||||
|
||||
{data?.app?.authUserDefaultRole === role.name && (
|
||||
<Chip
|
||||
component="span"
|
||||
color="info"
|
||||
size="small"
|
||||
label="Default"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<Dropdown.Item
|
||||
disabled={role.isSystemRole}
|
||||
onClick={() => handleOpenEditor(role)}
|
||||
>
|
||||
<Text className="font-medium">Edit</Text>
|
||||
</Dropdown.Item>
|
||||
|
||||
<Divider component="li" />
|
||||
|
||||
<Dropdown.Item
|
||||
disabled={role.isSystemRole}
|
||||
onClick={() => handleConfirmDelete(role)}
|
||||
>
|
||||
<Text className="font-medium" color="error">
|
||||
Delete
|
||||
</Text>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
>
|
||||
<ListItem.Text
|
||||
primaryTypographyProps={{
|
||||
className:
|
||||
'inline-grid grid-flow-col gap-1 items-center h-6 font-medium',
|
||||
}}
|
||||
primary={
|
||||
<>
|
||||
{role.name}
|
||||
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availableAllowedRoles.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
{role.isSystemRole && <LockIcon className="h-4 w-4" />}
|
||||
|
||||
{defaultRole === role.name && (
|
||||
<Chip
|
||||
component="span"
|
||||
color="info"
|
||||
size="small"
|
||||
label="Default"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
|
||||
<Divider
|
||||
component="li"
|
||||
className={twMerge(
|
||||
index === availableAllowedRoles.length - 1
|
||||
? '!mt-4'
|
||||
: '!my-4',
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="mx-4 justify-self-start"
|
||||
variant="borderless"
|
||||
startIcon={<PlusIcon />}
|
||||
onClick={handleOpenCreator}
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Create Allowed Role
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { useEffect } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export interface BaseSecretFormProps {
|
||||
/**
|
||||
* Determines the mode of the form.
|
||||
*
|
||||
* @default 'edit'
|
||||
*/
|
||||
mode?: 'edit' | 'create';
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit: (values: BaseSecretFormValues) => void;
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
/**
|
||||
* Submit button text.
|
||||
*
|
||||
* @default 'Save'
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
}
|
||||
|
||||
export const baseSecretFormValidationSchema = Yup.object({
|
||||
name: Yup.string()
|
||||
.required('This field is required.')
|
||||
.test(
|
||||
'isSecretValid',
|
||||
'A name must start with a letter and can only contain letters, numbers, and underscores.',
|
||||
(value) => /^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
|
||||
),
|
||||
value: Yup.string().required('This field is required.'),
|
||||
});
|
||||
|
||||
export type BaseSecretFormValues = Yup.InferType<
|
||||
typeof baseSecretFormValidationSchema
|
||||
>;
|
||||
|
||||
export default function BaseSecretForm({
|
||||
mode = 'edit',
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Save',
|
||||
}: BaseSecretFormProps) {
|
||||
const { onDirtyStateChange } = useDialog();
|
||||
const form = useFormContext<BaseSecretFormValues>();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, dirtyFields, isSubmitting },
|
||||
} = form;
|
||||
|
||||
// react-hook-form's isDirty gets true even if an input field is focused, then
|
||||
// immediately unfocused - we can't rely on that information
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, 'dialog');
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-6 px-6 pb-6">
|
||||
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
|
||||
<Input
|
||||
{...register('name')}
|
||||
id="name"
|
||||
label="Name"
|
||||
placeholder="EXAMPLE_NAME"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.name}
|
||||
helperText={errors?.name?.message}
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
autoFocus={mode === 'create'}
|
||||
disabled={mode === 'edit'}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('value')}
|
||||
id="value"
|
||||
label="Value"
|
||||
placeholder="Enter value"
|
||||
hideEmptyHelperText
|
||||
error={!!errors.value}
|
||||
helperText={errors?.value?.message}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={5}
|
||||
autoComplete="off"
|
||||
autoFocus={mode === 'edit'}
|
||||
/>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './BaseSecretForm';
|
||||
export { default } from './BaseSecretForm';
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
BaseSecretFormProps,
|
||||
BaseSecretFormValues,
|
||||
} from '@/components/settings/secrets/BaseSecretForm';
|
||||
import BaseSecretForm, {
|
||||
baseSecretFormValidationSchema,
|
||||
} from '@/components/settings/secrets/BaseSecretForm';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
GetSecretsDocument,
|
||||
useInsertSecretMutation,
|
||||
} 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 CreateSecretFormProps
|
||||
extends Pick<BaseSecretFormProps, 'onCancel'> {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function CreateSecretForm({
|
||||
onSubmit,
|
||||
...props
|
||||
}: CreateSecretFormProps) {
|
||||
const form = useForm<BaseSecretFormValues>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
value: '',
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(baseSecretFormValidationSchema),
|
||||
});
|
||||
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [insertSecret] = useInsertSecretMutation({
|
||||
refetchQueries: [GetSecretsDocument],
|
||||
});
|
||||
|
||||
async function handleSubmit({ name, value }: BaseSecretFormValues) {
|
||||
const insertSecretPromise = insertSecret({
|
||||
variables: {
|
||||
appId: currentApplication?.id,
|
||||
secret: {
|
||||
name,
|
||||
value,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
insertSecretPromise,
|
||||
{
|
||||
loading: 'Creating secret...',
|
||||
success: 'Secret has been created successfully.',
|
||||
error: (arg: Error) =>
|
||||
arg?.message
|
||||
? `Error: ${arg?.message}`
|
||||
: 'An error occurred while creating the secret.',
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
onSubmit?.();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<BaseSecretForm
|
||||
mode="create"
|
||||
submitButtonText="Create"
|
||||
onSubmit={handleSubmit}
|
||||
{...props}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './CreateSecretForm';
|
||||
export { default } from './CreateSecretForm';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user