Compare commits

...

188 Commits

Author SHA1 Message Date
Szilárd Dóró
8857314e22 Merge pull request #1237 from nhost/changeset-release/main
chore: update versions
2022-11-29 20:57:22 +01:00
github-actions[bot]
85f1c4a98e chore: update versions 2022-11-29 19:33:07 +00:00
Pilou
efa6b5755d Merge pull request #1248 from nhost/chore/remove-sort-lint-rule
chore: remove eslint plugin simple-import-sort
2022-11-29 20:31:24 +01:00
Pierre-Louis Mercereau
2b19416787 chore: remove eslint plugin simple-import-sort 2022-11-29 20:28:13 +01:00
Szilárd Dóró
4e5d43f300 Merge pull request #1245 from nhost/chore/settings-roles-and-permissions-refactor
chore(dashboard): refactor Roles and Permissions settings sections
2022-11-29 17:00:12 +01:00
Szilárd Dóró
db342f453e chore(dashboard): add changesets 2022-11-29 16:15:45 +01:00
Szilárd Dóró
54386a3b56 chore(dashboard): simplify env var dialog props 2022-11-29 16:14:20 +01:00
Pilou
ff40b99f84 Merge pull request #1242 from nhost/fix/set-access-token
fix: 🐛 Distribute the access token to all the sub-clients
2022-11-29 16:08:00 +01:00
Szilárd Dóró
33f8f1d78a Merge remote-tracking branch 'origin/main' into chore/settings-roles-and-permissions-refactor 2022-11-29 16:07:19 +01:00
Szilárd Dóró
c50fe47ab4 Merge pull request #1215 from nhost/feat/settings-environment-variables
feat(dashboard): add Environment Variables page
2022-11-29 16:04:09 +01:00
Pilou
0580f832c8 Merge pull request #1239 from nhost/chore/version-bumps
chore: 🤖 bump to axios 1.2.0
2022-11-29 15:59:38 +01:00
Szilárd Dóró
7d1eb099c0 fix(dashboard): correct system environment variables 2022-11-29 15:53:11 +01:00
Pierre-Louis Mercereau
e15322296b chore: lint 2022-11-29 15:52:25 +01:00
Pierre-Louis Mercereau
91a2bf905b refactor: simplify 2022-11-29 15:37:31 +01:00
Szilárd Dóró
0f9393fe27 fix(dashboard): change system env vars layout 2022-11-29 15:10:37 +01:00
Szilárd Dóró
aebb822549 feat(dashboard): extend system variables 2022-11-29 15:05:58 +01:00
Pierre-Louis Mercereau
1e2be6fadf Merge branch 'main' into chore/version-bumps 2022-11-29 13:41:00 +01:00
Pilou
aafbf5173d Merge pull request #1240 from nhost/test/nhost-js
test: 💍 `nhost.graphql.request` as an authenticated user
2022-11-29 13:00:46 +01:00
Pierre-Louis Mercereau
01e13e2f8c chore: 🤖 lint 2022-11-29 12:52:34 +01:00
Pierre-Louis Mercereau
4364647501 fix: 🐛 accept any encoding 2022-11-29 12:16:35 +01:00
Pierre-Louis Mercereau
ef117c284e fix: 🐛 Distribute the access token to all the sub-clients 2022-11-29 11:49:59 +01:00
Szilárd Dóró
3f919c0a80 chore(dashboard): refactor system variable layout 2022-11-29 11:30:31 +01:00
Pierre-Louis Mercereau
49e447e7b7 test: 💍 nhost.graphql.request as an authenticated user 2022-11-29 11:04:56 +01:00
Pierre-Louis Mercereau
66b4f3d0be chore: 🤖 bump to axios 1.2.0 2022-11-29 10:29:51 +01:00
Szilárd Dóró
aa7fdafe8b chore(dashboard): split Permission Variable dialog 2022-11-28 21:45:15 +01:00
Pilou
7d6de3b289 Merge pull request #1234 from nhost/chore/bump-hasura-auth
chore: 🤖 bump hasura-auth version
2022-11-28 19:51:10 +01:00
Pilou
57e41f77a9 Merge pull request #1238 from nhost/ci/turbo-team
ci: hardcode turbo team
2022-11-28 19:36:48 +01:00
Pierre-Louis Mercereau
f5c2a0ef4f ci: hardcode turbo team 2022-11-28 18:38:03 +01:00
Szilárd Dóró
d52bc8cca5 chore(dashboard): refactor role delete and default 2022-11-28 16:58:28 +01:00
Pilou
04a3e4c965 Merge pull request #1236 from nhost/refactor/state-snapshots
refactor: use state snapshots
2022-11-28 16:52:27 +01:00
Szilárd Dóró
853c0c5775 chore(dashboard): split RoleForm into multiple forms 2022-11-28 16:44:02 +01:00
Pierre-Louis Mercereau
2e6923dc73 refactor: use state snapshots 2022-11-28 16:41:53 +01:00
Pierre-Louis Mercereau
7d6d70d0c7 chore: 🤖 bump hasura-auth version 2022-11-28 15:59:04 +01:00
Szilárd Dóró
7a2100cc17 fix(dashboard): do not try to autofocus a disable input 2022-11-28 15:19:42 +01:00
Szilárd Dóró
5d55f3fa60 chore(dashboard): remove async form loading 2022-11-28 15:13:16 +01:00
Szilárd Dóró
8b0c44a93c chore(dashboard): rename env var forms
- chore(dashboard): do not convert env vars to uppercase by default
2022-11-28 14:59:27 +01:00
Szilárd Dóró
e0cc7cce0a chore(dashboard): update env var dialog subtitle 2022-11-28 13:40:25 +01:00
Szilárd Dóró
6e7d5e0dd4 fix(dashboard): change env var name placeholder 2022-11-28 13:19:17 +01:00
Szilárd Dóró
54c143ebf6 fix(dashboard): allow lowercase letters, convert variable to uppercase 2022-11-28 13:18:16 +01:00
Szilárd Dóró
8b9fa0b150 feat(dashboard): env var validation 2022-11-28 13:05:42 +01:00
Szilárd Dóró
c3bb79e1dd chore(dashboard): refactor env var forms 2022-11-28 11:15:38 +01:00
Szilárd Dóró
128d21e4ec Merge branch 'main' into feat/settings-environment-variables 2022-11-28 09:21:56 +01:00
Szilárd Dóró
40e503c356 Merge pull request #1227 from nhost/fix/vercel-deployment-token
fix(changesets): add Vercel deployment token to CI
2022-11-28 08:28:33 +01:00
Szilárd Dóró
d007e0ade8 chore(changesets): do not create dedicated env var for deploy token 2022-11-28 08:26:11 +01:00
Pilou
fa32513ba7 Merge pull request #1231 from nhost/docs/docker-compose-dashboard
docs: run hasura console from the cli to run the dashboard
2022-11-27 22:50:14 +01:00
Pierre-Louis Mercereau
8893d9e010 docs: run hasura console from the cli to run the dashboard 2022-11-27 21:37:37 +01:00
Szilárd Dóró
81d2fd865c fix(changesets): add Vercel deployment token to CI 2022-11-27 19:51:07 +01:00
Szilárd Dóró
fe3c462099 Merge pull request #1217 from nhost/fix/vercel-pipeline
fix(changesets): add missing `pnpm` command, pre-build project
2022-11-26 11:49:08 +01:00
Szilárd Dóró
f8b082cb02 chore(changesets): split Vercel CLI command 2022-11-25 16:59:56 +01:00
Szilárd Dóró
0c748e6ee6 feat(dashboard): add env var management 2022-11-25 16:57:22 +01:00
Pilou
e2c4ca85b3 Merge pull request #1219 from nhost/docs/docker-compose-dashboard
docs(docker-compose): add the dashboard to the docker-compose example
2022-11-25 16:38:54 +01:00
Szilárd Dóró
0165b998c2 chore(changesets): create separate step for Vercel 2022-11-25 16:33:09 +01:00
Pierre-Louis Mercereau
5d970cc229 feat(docs): add the dashboard to the docker-compose example 2022-11-25 16:14:46 +01:00
Szilárd Dóró
7167170663 feat(dashboard): add env var management dialog 2022-11-25 15:47:34 +01:00
Szilárd Dóró
0f77de2dd0 feat(dashboard): add menu to env vars 2022-11-25 15:13:54 +01:00
Szilárd Dóró
6ae91e48d1 feat(dashboard): list env vars 2022-11-25 14:52:47 +01:00
Szilárd Dóró
69db1594cc fix(changesets): add missing pnpm command, pre-build project 2022-11-25 14:36:54 +01:00
Szilárd Dóró
158cf0da49 Merge pull request #1216 from nhost/changeset-release/main
chore: update versions
2022-11-25 14:12:10 +01:00
github-actions[bot]
7992fc3baa chore: update versions 2022-11-25 13:09:54 +00:00
Szilárd Dóró
85d9596956 Merge branch 'main' into feat/settings-environment-variables 2022-11-25 14:08:46 +01:00
Szilárd Dóró
16d383516e Merge pull request #1206 from nhost/feat/settings-roles-and-permissions
feat(dashboard): Roles and Permissions
2022-11-25 14:08:13 +01:00
Szilárd Dóró
2ca193ccf3 chore(dashboard): improve inline secrets 2022-11-25 14:07:44 +01:00
Szilárd Dóró
ab8e12003d feat(dashboard): show JWT secret 2022-11-25 14:02:48 +01:00
Szilárd Dóró
29cdf6b125 Merge pull request #1214 from nhost/elitan-patch-3
Update serverless-functions.mdx
2022-11-25 13:51:24 +01:00
Szilárd Dóró
41cc3dc5d0 feat(dashboard): settings page for Environment Variables 2022-11-25 13:50:08 +01:00
Johan Eliasson
6b67c9996a Update serverless-functions.mdx 2022-11-25 12:50:39 +01:00
Szilárd Dóró
23274dee41 chore(docs): add changeset 2022-11-25 11:59:42 +01:00
Szilárd Dóró
a5b55c2667 chore(docs): update permission variables image 2022-11-25 11:58:46 +01:00
Szilárd Dóró
1263676eb3 fix(dashboard): permission variable validation 2022-11-25 11:49:46 +01:00
Szilárd Dóró
b1b647ad96 fix(dashboard): reset default role on role removal 2022-11-25 11:34:51 +01:00
Szilárd Dóró
21bbaf5e95 feat(dashboard): add docs link to roles section 2022-11-25 10:56:14 +01:00
Szilárd Dóró
eef9c91403 feat(dashboard): add support for default roles
- remove unnecessary helper labels from roles and permissions
2022-11-25 10:55:20 +01:00
Johan Eliasson
1742cb444d Merge pull request #1212 from nhost/szilarddoro-patch-1
chore(docs): add WorkOS to Authentication page
2022-11-25 10:49:45 +01:00
Szilárd Dóró
c4f374d7f3 Merge remote-tracking branch 'origin/main' into feat/settings-roles-and-permissions 2022-11-25 09:36:57 +01:00
Szilárd Dóró
369ec13070 chore(docs): add WorkOS to Authentication page 2022-11-25 09:36:21 +01:00
Szilárd Dóró
101129eef2 Update dashboard/src/components/settings/permissions/PermissionVariableSettings/PermissionVariableSettings.tsx
Co-authored-by: Nuno Pato <nunopato@gmail.com>
2022-11-25 09:33:33 +01:00
Szilárd Dóró
228fda0364 Merge pull request #1210 from nhost/fix/vercel-deployment 2022-11-25 09:05:57 +01:00
Szilárd Dóró
74085c67a2 fix(dashboard): correct production deployment 2022-11-24 22:31:11 +01:00
Szilárd Dóró
a273725419 Merge pull request #1209 from nhost/fix/precommit-hook
fix(docgen): prevent docgen from breaking the pre-commit hook
2022-11-24 21:07:40 +01:00
Pierre-Louis Mercereau
c5240f8d74 docs: ✏️ remove irrelevant workaround to fixed docgen git hook 2022-11-24 20:53:17 +01:00
Szilárd Dóró
4490068257 chore(docgen): copy binary to node_modules
execute `pnpm i` to copy the `docgen` binary to every package
2022-11-24 20:41:59 +01:00
Szilárd Dóró
3601de3f85 Merge pull request #1208 from nhost/changeset-release/main
chore: update versions
2022-11-24 20:20:45 +01:00
github-actions[bot]
ac9404610b chore: update versions 2022-11-24 19:09:19 +00:00
Szilárd Dóró
63570db57c Merge pull request #1207 from nhost/contributors-readme-action-MBHUTcRQyD
contributors readme action update
2022-11-24 20:08:33 +01:00
github-actions[bot]
538ed78f5a contrib-readme-action has updated readme 2022-11-24 19:07:02 +00:00
Szilárd Dóró
b1a31ecb00 Merge pull request #1181 from nhost/fix/dashboard-custom-local-ports
feat(dashboard): make backend port configurable
2022-11-24 20:06:44 +01:00
Szilárd Dóró
3d151c448c chore(precommit): extend precommit hook with docgen build 2022-11-24 19:42:23 +01:00
Siarhei Lipchyk
bac8ace434 Add support for custom ports via placeholders 2022-11-24 19:03:02 +01:00
Szilárd Dóró
fdd417ed25 feat(dashboard): add router cancellation to permission form
- chore(dashboard): update terminology
2022-11-24 18:03:04 +01:00
Pilou
a402fc17de Merge pull request #1192 from nhost/changeset-release/main
chore: update versions
2022-11-24 17:21:07 +01:00
Szilárd Dóró
4416ceb9cf chore(dashboard): cleanup unused files 2022-11-24 17:00:35 +01:00
Szilárd Dóró
4762ebf61e fix(dashboard): correct original value for role editing 2022-11-24 16:54:01 +01:00
Szilárd Dóró
73e28b5831 feat(dashboard): simplify role management form 2022-11-24 16:52:26 +01:00
Szilárd Dóró
2a7dc5060f feat(dashboard): add support for permission variable management 2022-11-24 16:25:14 +01:00
Szilárd Dóró
9b8ede40a9 feat(dashboard): prevent invalid characters for variables 2022-11-24 15:50:14 +01:00
Szilárd Dóró
f005c20d99 feat(dashboard): add modals for permission variable management 2022-11-24 15:23:30 +01:00
Szilárd Dóró
4adfd613b6 feat(dashboard): support variable listing
- chore(dashboard): rename RolesSettings to RoleSettings
2022-11-24 15:01:51 +01:00
Szilárd Dóró
b6da82c8e3 fix(dashboard): wrong tooltip appearance 2022-11-24 14:25:49 +01:00
Szilárd Dóró
816456edc4 fix(dashboard): remove unnecessary manual focus 2022-11-24 14:24:07 +01:00
Szilárd Dóró
deaf0e86d4 fix(dashboard): changed role form's logic 2022-11-24 14:16:02 +01:00
Szilárd Dóró
23f8206f18 feat(dashboard): add support for role deletion 2022-11-24 12:46:41 +01:00
Szilárd Dóró
9dde4d7988 feat(dashboard): add support for role editing 2022-11-24 12:31:48 +01:00
Szilárd Dóró
26385b9cf9 fix(dashboard): fix ESLint working directories 2022-11-24 12:17:50 +01:00
Szilárd Dóró
6d318206ef feat(dashboard): finalize Create Role modal
- fix(dashboard): lint errors
- chore(dashboard): reduce max allowed linter warnings
2022-11-24 12:11:41 +01:00
Szilárd Dóró
4d727b78a1 feat(dashboard): updated Roles and Permissions page
- created modal for role creation
2022-11-24 11:46:56 +01:00
github-actions[bot]
de0a125e98 chore: update versions 2022-11-24 09:38:59 +00:00
Pilou
ea1ad29031 Merge pull request #1200 from nhost/plmercereau-patch-storage-tag
Update docker-compose.yaml
2022-11-24 10:37:25 +01:00
Szilárd Dóró
3da40e5712 Merge pull request #1205 from nhost/fix/dashboard-meta
fix(dashboard): update terminology
2022-11-24 10:35:48 +01:00
Szilárd Dóró
b9087a4add fix(dashboard): console / dashboard terminology 2022-11-24 09:54:44 +01:00
Szilárd Dóró
1b7a6d0252 fix(dashboard): update terminology 2022-11-24 09:23:08 +01:00
Szilárd Dóró
1417d3e794 Merge pull request #1203 from nhost/contributors-readme-action-FFIU1CawPP
contributors readme action update
2022-11-24 08:55:41 +01:00
github-actions[bot]
e187923858 contrib-readme-action has updated readme 2022-11-24 07:52:33 +00:00
Johan Eliasson
8a60ed4074 Merge pull request #1199 from nhost/functions-more-examples
examples(serverless-functions): smtp + async/await
2022-11-24 08:52:19 +01:00
Pilou
d7d11a44a7 Update docker-compose.yaml
There's no latest tag on storage
2022-11-23 22:04:40 +01:00
Johan Eliasson
062e4691cd added examples 2022-11-23 21:53:25 +01:00
Pilou
a95d49fa2c Merge pull request #1197 from nhost/docs/custom-claims-singleton-array
docs: custom claims and singleton arrays
2022-11-23 19:06:34 +01:00
Johan Eliasson
d14fc96899 Merge pull request #1036 from ejkkan/feat/add-charges-to-stripe-package
feat(stripe-graphql-js): add charges, payment intents and connected accounts
2022-11-23 19:03:37 +01:00
Johan Eliasson
93db718254 Create blue-ghosts-accept.md 2022-11-23 19:03:12 +01:00
Pierre-Louis Mercereau
c367bd58b9 docs: custom claims and singleton arrays 2022-11-23 19:01:29 +01:00
Pilou
0bfed4d9e1 Merge pull request #1196 from nhost/plmercereau-patch-1
Update docker-compose.yaml
2022-11-23 18:43:36 +01:00
Pilou
1f3aecd379 Merge pull request #1193 from nhost/chore/bump-service-versions
ci: 🎡 bump services versions and trigger CI
2022-11-23 18:43:20 +01:00
Pilou
42306ea3bb Update docker-compose.yaml 2022-11-23 18:19:41 +01:00
Pilou
1b12a175f6 Update docker-compose.yaml 2022-11-23 18:18:06 +01:00
Szilárd Dóró
32060aaea0 Merge pull request #1195 from nhost/chore/dashboard-version
chore(dashboard): add changeset
2022-11-23 16:00:18 +01:00
Szilárd Dóró
f94cace5f2 Merge pull request #1194 from nhost/feat/dashboard-vercel-deployment
feat(dashboard): add Vercel deployment
2022-11-23 15:22:09 +01:00
Szilárd Dóró
5de965d9a5 chore(dashboard): add changeset 2022-11-23 15:15:08 +01:00
Szilárd Dóró
e10b3adc11 chore(changesets): incorporate Vercel deployment into publish process 2022-11-23 15:08:21 +01:00
Szilárd Dóró
457db76b06 Merge pull request #1191 from nhost/fix/sign-in-methods-order
fix(dashboard): alphabetic ordering of providers
2022-11-23 15:04:26 +01:00
Szilárd Dóró
1e952a026e chore(changesets): update publish step's name 2022-11-23 15:01:55 +01:00
Szilárd Dóró
2f4c040789 feat(dashboard): add Vercel deployment 2022-11-23 14:33:00 +01:00
Pierre-Louis Mercereau
74648752b4 ci: use correct hasura version 2022-11-23 13:12:32 +01:00
Szilárd Dóró
09d218a3fe fix(dashboard): sign-in method phrasing 2022-11-23 13:12:24 +01:00
Szilárd Dóró
2e8938dbb0 fix(dashboard): add missing Twilio icon 2022-11-23 13:00:12 +01:00
Pierre-Louis Mercereau
ec60d03536 ci: 🎡 bump services versions and trigger CI 2022-11-23 12:57:04 +01:00
Johan Eliasson
2f3767552f Merge pull request #1189 from nhost/docs-workos
docs(workos): WorkOS Docs
2022-11-23 12:14:57 +01:00
Szilárd Dóró
bc401c0dd2 fix(dashboard): alphabetic ordering of providers
- fixes #1188
- fix checkbox font size on the Settings page
2022-11-23 12:12:39 +01:00
Pilou
2907ecb7ff Merge pull request #1179 from nhost/changeset-release/main
chore: update versions
2022-11-23 11:24:55 +01:00
Pierre-Louis Mercereau
05d7f5207f chore: no major bump of peer dependencies 2022-11-23 11:22:55 +01:00
github-actions[bot]
07a053ee80 chore: update versions 2022-11-23 09:58:39 +00:00
Szilárd Dóró
2145243b19 chore(dashboard): update environment variables 2022-11-23 10:56:47 +01:00
Pilou
61e4414a8f Merge pull request #1190 from nhost/changeset-react-components
react components changeset added
2022-11-23 10:55:04 +01:00
Johan Eliasson
4601d84e0e changeset added 2022-11-23 10:29:19 +01:00
Johan Eliasson
ca012d790c Create tidy-teachers-flow.md 2022-11-23 10:27:23 +01:00
Johan Eliasson
aeda14ef53 docs link 2022-11-23 10:25:18 +01:00
Johan Eliasson
3fa5e2005a updates 2022-11-23 10:23:40 +01:00
Johan Eliasson
4dd2e99159 Merge pull request #1187 from nhost/docs-git-8g712asd
docs(git): git docs updates
2022-11-23 08:27:17 +01:00
Johan Eliasson
282c6c6d24 git docs update 2022-11-23 08:16:55 +01:00
Johan Eliasson
beadd84adb moving files 2022-11-23 07:52:57 +01:00
Johan Eliasson
f8f55d2b99 Merge branch 'main' into feat/add-charges-to-stripe-package 2022-11-23 07:49:17 +01:00
Johan Eliasson
03a98d4f3a config updates 2022-11-23 07:36:31 +01:00
Johan Eliasson
8ed8e04ab6 merge 2022-11-23 07:31:37 +01:00
Erik Magnusson
587efd4551 re-applied isAdmin checks for connected account field types 2022-11-23 08:15:44 +02:00
Johan Eliasson
c78227b085 Merge pull request #1185 from nhost/docs/node-16
docs: update developer's guide
2022-11-23 07:06:10 +01:00
Pierre-Louis Mercereau
d87e520307 docs: lessons learned from Sheena and Chris 2022-11-22 21:44:48 +01:00
Pilou
bbed04e4da Merge pull request #1158 from nhost/fix/use-user-roles
fix: 🐛 make `useUserRoles` reactive
2022-11-22 21:35:06 +01:00
Pilou
273afc9740 Merge pull request #1184 from nhost/contributors-readme-action-h-P6q9XKTD
contributors readme action update
2022-11-22 21:32:07 +01:00
github-actions[bot]
f4083aa4b3 contrib-readme-action has updated readme 2022-11-22 20:12:23 +00:00
Pilou
ddd2641726 Merge pull request #1183 from massless/min-version-node
Update minimum version of node
2022-11-22 21:12:04 +01:00
Chris Wetherell
4658aeb31e Update minimum version of node 2022-11-22 12:08:47 -08:00
Johan Eliasson
cc8e5fe4a9 Merge pull request #1180 from nhost/react-components-iy8gasd9
React Auth components: SignedIn and SignedOut
2022-11-22 19:54:19 +01:00
Szilárd Dóró
85c897c717 chore(docs): update source code references 2022-11-22 18:32:48 +01:00
Szilárd Dóró
c99e5552e6 chore(react): simplify component signature 2022-11-22 18:31:39 +01:00
Szilárd Dóró
97a2520ea1 feat(docgen): add support for components 2022-11-22 18:24:23 +01:00
Johan Eliasson
964af2912b inline docs 2022-11-22 17:04:39 +01:00
Szilárd Dóró
a48dd5bf74 chore(dashboard): add changeset 2022-11-22 14:32:53 +01:00
Szilárd Dóró
ef53df5cb3 feat(dashboard): make backend port configurable
- additionally: improve the way Hasura service ports are. configured through environment variables
2022-11-22 14:32:13 +01:00
Johan Eliasson
afea682a8c merge 2022-11-22 14:09:38 +01:00
Pierre-Louis Mercereau
fefa2baa2e refactor: readability 2022-11-22 13:28:09 +01:00
Pilou
f09b3cfd24 Merge pull request #1178 from nhost/ci/freeze-pnpm-version
ci: set explicit pnpm version in the dashboard dockerfile
2022-11-22 13:20:35 +01:00
Pilou
dd3b2c41f1 Merge pull request #1171 from nhost/chore/vue-file-upload-changeset
chore: add changeset to vue, and correct inline documentation
2022-11-22 13:20:21 +01:00
Szilárd Dóró
aaced20f31 Merge pull request #1173 from nhost/fix/dashboard-provider-redirect-url
fix(dashboard): correct redirect URL input opacity
2022-11-22 13:15:35 +01:00
Pierre-Louis Mercereau
3e91c19e13 ci: set explicit pnpm version in the dashboard dockerfile 2022-11-22 13:11:00 +01:00
Johan Eliasson
d4fd4ec3e9 signedin and signedout 2022-11-22 10:48:24 +01:00
Szilárd Dóró
89bd37bc28 chore(dashboard): add changeset 2022-11-22 10:20:52 +01:00
Szilárd Dóró
0df73a41c9 fix(dashboard): redirect URL opacity 2022-11-22 10:20:09 +01:00
Pierre-Louis Mercereau
f6d2042adb chore: add changeset to vue, and correct inline documentation 2022-11-22 09:34:47 +01:00
Pierre-Louis Mercereau
843087cb11 fix: 🐛 make useUserRoles reactive 2022-11-21 15:58:55 +01:00
Erik Magnusson
7055ffc37a Merge branch 'feat/add-charges-to-stripe-package' of https://github.com/ejkkan/nhost into feat/add-charges-to-stripe-package 2022-10-20 20:57:56 +02:00
Erik Magnusson
c68ce6d480 removed isAdmin checks for connected accounts related schema fields 2022-10-20 20:57:01 +02:00
Erik Magnusson
98a149c8bf Update packages/stripe-graphql-js/src/schema/charge.ts
Co-authored-by: Johan Eliasson <johan@eliasson.me>
2022-10-18 08:48:58 +02:00
Erik Magnusson
ceb558975e Update packages/stripe-graphql-js/src/schema/charge.ts
Co-authored-by: Johan Eliasson <johan@eliasson.me>
2022-10-18 08:48:46 +02:00
Erik Magnusson
7a87321a7e fixd imports and linting 2022-10-14 21:21:25 +02:00
Erik Magnusson
9349766c0a corrected spelling 2022-10-14 21:19:23 +02:00
Erik Magnusson
31655191a3 added connected accounts 2022-10-14 21:05:57 +02:00
Erik Magnusson
e3b91efa84 feat/added charges 2022-10-14 20:13:35 +02:00
Erik Magnusson
cfe736776a feat/added charges 2022-10-14 20:03:19 +02:00
Erik Magnusson
481bf237cc updated naming convention to camel casing 2022-10-14 10:11:02 +02:00
Erik Magnusson
33ce9bf1b9 added payment intents type for customer and invoice objects 2022-10-04 18:10:02 +02:00
245 changed files with 5388 additions and 3508 deletions

View File

@@ -13,11 +13,12 @@ on:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TEAM: nhost
DASHBOARD_PACKAGE: '@nhost/dashboard'
jobs:
version:
name: Version
runs-on: ubuntu-latest
outputs:
hasChangesets: ${{ steps.changesets.outputs.hasChangesets }}
@@ -30,8 +31,8 @@ jobs:
- name: Install Node and dependencies
uses: ./.github/actions/install-dependencies
with:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
TURBO_TEAM: ${{ env.TURBO_TEAM }}
- name: Create PR or Publish release
id: changesets
uses: changesets/action@v1
@@ -60,7 +61,8 @@ jobs:
uses: ./.github/workflows/dashboard.yaml
secrets: inherit
publish:
publish-docker:
name: Publish to Docker Hub
runs-on: ubuntu-latest
needs:
- test
@@ -120,3 +122,29 @@ jobs:
- name: Remove tag on failure
if: failure()
run: git push --delete origin ${{ env.DASHBOARD_PACKAGE }}@${{ needs.version.outputs.dashboardVersion }}
publish-vercel:
name: Publish to Vercel
runs-on: ubuntu-latest
needs:
- publish-docker
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install Node and dependencies
uses: ./.github/actions/install-dependencies
with:
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
TURBO_TEAM: ${{ env.TURBO_TEAM }}
- name: Setup Vercel CLI
run: pnpm add -g vercel
- name: Trigger a Vercel deployment
env:
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
run: |
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}

View File

@@ -11,7 +11,7 @@ on:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TEAM: nhost
jobs:
build:
name: Build

View File

@@ -21,7 +21,7 @@ on:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TEAM: nhost
jobs:
build:
name: Build @nhost packages

4
.gitignore vendored
View File

@@ -48,6 +48,10 @@ todo.md
.netlify
.monorepo-example
# Local Vercel folder
.vercel
# Next.js build output
.next
# TypeDoc output

View File

@@ -1,5 +1,6 @@
{
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
}
},
"eslint.workingDirectories": ["./dashboard"]
}

View File

@@ -2,6 +2,8 @@
## Requirements
- This repository works with **Node 16**
- We use [pnpm](https://pnpm.io/) as a package manager to speed up development and builds, and as a basis for our monorepo. You need to make sure it's installed on your machine. There are [several ways to install it](https://pnpm.io/installation), but the easiest way is with `npm`:
```sh
@@ -97,6 +99,7 @@ You can take a look at the changeset documentation: [How to add a changeset](htt
You'll notice that `git commit` takes a few seconds to run. We set a commit hook that scans the changes in the code, automatically generates documentation from the inline [TSDoc](https://tsdoc.org/) annotations, and adds these generated documentation files to the commit. They automatically update the [reference documentation](https://docs.nhost.io/reference).
<!-- ## Good practices
- lint
- prettier

View File

@@ -179,14 +179,21 @@ Here are some ways of contributing to making Nhost better:
<sub><b>Grégory D'Angelo</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/ejkkan">
<img src="https://avatars.githubusercontent.com/u/32518962?v=4" width="100;" alt="ejkkan"/>
<br />
<sub><b>Erik Magnusson</b></sub>
</a>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/guicurcio">
<img src="https://avatars.githubusercontent.com/u/20285232?v=4" width="100;" alt="guicurcio"/>
<br />
<sub><b>Guido Curcio</b></sub>
</a>
</td></tr>
<tr>
</td>
<td align="center">
<a href="https://github.com/subatuba21">
<img src="https://avatars.githubusercontent.com/u/34824571?v=4" width="100;" alt="subatuba21"/>
@@ -221,15 +228,15 @@ Here are some ways of contributing to making Nhost better:
<br />
<sub><b>Christopher Möller</b></sub>
</a>
</td>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/GavanWilhite">
<img src="https://avatars.githubusercontent.com/u/2085119?v=4" width="100;" alt="GavanWilhite"/>
<br />
<sub><b>Gavan Wilhite</b></sub>
</a>
</td></tr>
<tr>
</td>
<td align="center">
<a href="https://github.com/FuzzyReason">
<img src="https://avatars.githubusercontent.com/u/62517920?v=4" width="100;" alt="FuzzyReason"/>
@@ -237,13 +244,6 @@ Here are some ways of contributing to making Nhost better:
<sub><b>Vadim Smirnov</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/ejkkan">
<img src="https://avatars.githubusercontent.com/u/32518962?v=4" width="100;" alt="ejkkan"/>
<br />
<sub><b>Erik Magnusson</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/macmac49">
<img src="https://avatars.githubusercontent.com/u/831190?v=4" width="100;" alt="macmac49"/>
@@ -380,6 +380,13 @@ Here are some ways of contributing to making Nhost better:
<sub><b>Chris</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/massless">
<img src="https://avatars.githubusercontent.com/u/44389?v=4" width="100;" alt="massless"/>
<br />
<sub><b>Chris Wetherell</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/rustyb">
<img src="https://avatars.githubusercontent.com/u/53086?v=4" width="100;" alt="rustyb"/>
@@ -393,15 +400,15 @@ Here are some ways of contributing to making Nhost better:
<br />
<sub><b>Dago</b></sub>
</a>
</td>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/dminkovsky">
<img src="https://avatars.githubusercontent.com/u/218725?v=4" width="100;" alt="dminkovsky"/>
<br />
<sub><b>Dmitry Minkovsky</b></sub>
</a>
</td></tr>
<tr>
</td>
<td align="center">
<a href="https://github.com/dohomi">
<img src="https://avatars.githubusercontent.com/u/489221?v=4" width="100;" alt="dohomi"/>
@@ -436,15 +443,15 @@ Here are some ways of contributing to making Nhost better:
<br />
<sub><b>Ikko Ashimine</b></sub>
</a>
</td>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/jladuval">
<img src="https://avatars.githubusercontent.com/u/1935359?v=4" width="100;" alt="jladuval"/>
<br />
<sub><b>Jacob Duval</b></sub>
</a>
</td></tr>
<tr>
</td>
<td align="center">
<a href="https://github.com/leothorp">
<img src="https://avatars.githubusercontent.com/u/12928449?v=4" width="100;" alt="leothorp"/>
@@ -479,15 +486,22 @@ Here are some ways of contributing to making Nhost better:
<br />
<sub><b>Nirmalya Ghosh</b></sub>
</a>
</td>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/quentin-decre">
<img src="https://avatars.githubusercontent.com/u/1137511?v=4" width="100;" alt="quentin-decre"/>
<br />
<sub><b>Quentin Decré</b></sub>
</a>
</td></tr>
<tr>
</td>
<td align="center">
<a href="https://github.com/elephant3">
<img src="https://avatars.githubusercontent.com/u/48279149?v=4" width="100;" alt="elephant3"/>
<br />
<sub><b>Siarhei Lipchyk</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/altschuler">
<img src="https://avatars.githubusercontent.com/u/956928?v=4" width="100;" alt="altschuler"/>
@@ -515,7 +529,8 @@ Here are some ways of contributing to making Nhost better:
<br />
<sub><b>Vadim</b></sub>
</a>
</td>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/TheRedLancer">
<img src="https://avatars.githubusercontent.com/u/58493767?v=4" width="100;" alt="TheRedLancer"/>
@@ -529,8 +544,7 @@ Here are some ways of contributing to making Nhost better:
<br />
<sub><b>Komninos</b></sub>
</a>
</td></tr>
<tr>
</td>
<td align="center">
<a href="https://github.com/meesvandongen">
<img src="https://avatars.githubusercontent.com/u/35409045?v=4" width="100;" alt="meesvandongen"/>

View File

@@ -21,7 +21,7 @@ module.exports = {
'tests/**/*.ts',
'tests/**/*.d.ts'
],
plugins: ['@typescript-eslint', 'simple-import-sort', 'cypress'],
plugins: ['@typescript-eslint', 'cypress'],
extends: ['plugin:cypress/recommended'],
parserOptions: {
ecmaVersion: 2020,
@@ -30,31 +30,6 @@ module.exports = {
rules: {
'react/prop-types': 'off',
'no-use-before-define': 'off',
'simple-import-sort/exports': 'error',
'simple-import-sort/imports': [
'error',
{
groups: [
// Node.js builtins. You could also generate this regex if you use a `.js` config.
// For example: `^(${require("module").builtinModules.join("|")})(/|$)`
[
'^(assert|buffer|child_process|cluster|console|constants|crypto|dgram|dns|domain|events|fs|http|https|module|net|os|path|punycode|querystring|readline|repl|stream|string_decoder|sys|timers|tls|tty|url|util|vm|zlib|freelist|v8|process|async_hooks|http2|perf_hooks)(/.*|$)'
],
// Packages
['^\\w'],
// Internal packages.
['^(@|config/)(/*|$)'],
// Side effect imports.
['^\\u0000'],
// Parent imports. Put `..` last.
['^\\.\\.(?!/?$)', '^\\.\\./?$'],
// Other relative imports. Put same-folder imports and `.` last.
['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
// Style imports.
['^.+\\.s?css$']
]
}
],
'import/no-anonymous-default-export': [
'error',
{

View File

@@ -1,3 +1,4 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: ['next', 'airbnb', 'airbnb-typescript', 'airbnb/hooks', 'prettier'],

View File

@@ -1,5 +1,55 @@
# @nhost/dashboard
## 0.7.0
### Minor Changes
- db342f45: chore(dashboard): refactor Roles and Permissions settings sections
- 8b9fa0b1: feat(dashboard): add Environment Variables page
### Patch Changes
- Updated dependencies [66b4f3d0]
- Updated dependencies [2e6923dc]
- Updated dependencies [ef117c28]
- Updated dependencies [aebb8225]
- @nhost/core@0.9.4
- @nhost/nhost-js@1.6.2
- @nhost/nextjs@1.9.1
- @nhost/react@0.15.1
- @nhost/react-apollo@4.9.1
## 0.6.0
### Minor Changes
- eef9c914: feat(dashboard): add Roles and Permissions page
## 0.5.0
### Minor Changes
- a48dd5bf: feat(dashboard): make backend port configurable
## 0.4.3
### Patch Changes
- 5de965d9: fix(dashboard): alphabetic ordering of providers
- b9087a4a: fix(dashboard): console -> dashboard terminology
- ca012d79: docs(workos): WorkOS Docs
## 0.4.2
### Patch Changes
- 89bd37bc: fix(dashboard): correct redirect URL input opacity
- Updated dependencies [4601d84e]
- Updated dependencies [843087cb]
- @nhost/react@0.15.0
- @nhost/nextjs@1.9.0
- @nhost/react-apollo@4.9.0
## 0.4.1
### Patch Changes

View File

@@ -1,4 +1,3 @@
FROM node:16-alpine AS pruner
RUN apk add --no-cache libc6-compat
RUN apk update
@@ -17,12 +16,15 @@ RUN apk update
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_PUBLIC_NHOST_PLATFORM false
ENV NEXT_PUBLIC_NHOST_MIGRATIONS_URL http://localhost:9693
ENV NEXT_PUBLIC_NHOST_HASURA_URL http://localhost:9695
ENV NEXT_PUBLIC_ENV dev
ENV NEXT_PUBLIC_NHOST_PLATFORM false
RUN yarn global add pnpm
# placeholders for ports, will be replaced on runtime by entrypoint script
ENV NEXT_PUBLIC_NHOST_MIGRATIONS_PORT __NEXT_PUBLIC_NHOST_MIGRATIONS_PORT__
ENV NEXT_PUBLIC_NHOST_HASURA_PORT __NEXT_PUBLIC_NHOST_HASURA_PORT__
ENV NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT __NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT__
RUN yarn global add pnpm@7.17.0
COPY .gitignore .gitignore
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-*.yaml .
@@ -40,11 +42,14 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
COPY --from=builder /app/dashboard/next.config.js .
COPY --from=builder /app/dashboard/package.json .
COPY --from=builder /app/dashboard/public ./dashboard/public
COPY --chown=nextjs:nodejs dashboard/docker-entrypoint.sh .
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/next.config.js .
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/package.json .
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/public ./dashboard/public
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/standalone/app ./
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/static ./dashboard/.next/static
CMD node dashboard/server.js
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "dashboard/server.js"]

View File

@@ -30,31 +30,27 @@ First, you need to run the following command to start your backend locally:
cd <your_nhost_project> && nhost dev
```
Two environment variables are required to connect the Nhost Dashboard to your local backend:
You can connect the Nhost Dashboard to your locally running backend by setting the following environment variables in `.env.development.local`:
- `NEXT_PUBLIC_NHOST_PLATFORM` should be set to `false`, because otherwise the Nhost Dashboard will try to connect to the Nhost platform.
- `NEXT_PUBLIC_NHOST_MIGRATIONS_URL` should be set to `http://localhost:9693` unless Hasura is configured to run on a different port. This is the URL of Hasura's migrations endpoint.
Example:
```
```bash
NEXT_PUBLIC_ENV=dev
NEXT_PUBLIC_NHOST_PLATFORM=false
NEXT_PUBLIC_NHOST_MIGRATIONS_URL=http://localhost:9693
```
### Full list of environment variables
| Name | Description |
| ------------------------------------ | ------------------------------------------------------------------------------------------------ |
| `NEXT_PUBLIC_NHOST_PLATFORM` | This should be set to `false` to connect the Nhost Dashboard to a locally running Nhost backend. |
| `NEXT_PUBLIC_NHOST_MIGRATIONS_URL` | URL of Hasura's migrations endpoint. Used only if local development is enabled. |
| `NEXT_PUBLIC_NHOST_HASURA_URL` | URL of the Hasura Console. Used only when `NEXT_PUBLIC_ENV` is `dev`. |
| `NEXT_PUBLIC_ENV` | `dev`, `staging` or `prod`. Should be set to `dev` in most cases. |
| `NEXT_PUBLIC_NHOST_BACKEND_URL` | Backend URL. Not necessary for local development. |
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. Not necessary for local development. |
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. Not necessary for local development. |
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. Not necessary for local development. |
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. Not necessary for local development. |
| Name | Description |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_ENV` | `dev`, `staging` or `prod`. Should be set to `dev` in most cases. |
| `NEXT_PUBLIC_NHOST_PLATFORM` | This should be set to `false` to connect the Nhost Dashboard to a locally running Nhost backend. Setting this to `true` turns off local development. |
| `NEXT_PUBLIC_NHOST_LOCAL_MIGRATIONS_PORT` | Custom port that was passed to the CLI. Used only if local development is enabled. Default: `9693` |
| `NEXT_PUBLIC_NHOST_LOCAL_HASURA_PORT` | Custom port that was passed to the CLI. Used only if local development is enabled and `NEXT_PUBLIC_ENV` is `dev`. Default: `9695` |
| `NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT` | Custom port that was passed to the CLI. Used only if local development is enabled. Default: `1337` |
| `NEXT_PUBLIC_NHOST_BACKEND_URL` | Backend URL. Not necessary for local development. |
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. Not necessary for local development. |
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. Not necessary for local development. |
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. Not necessary for local development. |
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. Not necessary for local development. |
## ESLint Rules
@@ -67,6 +63,7 @@ NEXT_PUBLIC_NHOST_MIGRATIONS_URL=http://localhost:9693
| `import/extensions` | JS / TS files should be imported without file extensions. |
| `react/jsx-filename-extension` | JSX should only appear in `.jsx` and `.tsx` files. |
| `react/jsx-no-bind` | Further investigation must be made on the performance impact of functions directly passed as props to components. |
| `import/order` | Until we have a better auto-formatter, we disable this rule. |
| `import/no-extraneous-dependencies` | `devDependencies` should be excluded from the list of disallowed imports. |
| `curly` | By default it only enforces curly braces for multi-line blocks, but it should be enforced for single-line blocks as well. |
| `no-restricted-exports` | `export { default } from './module'` is used heavily in `@/ui/v2` which is a restricted export by default. |

15
dashboard/docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
set -e
# read ports from env variables or use defaults
NEXT_PUBLIC_NHOST_MIGRATIONS_PORT="${NEXT_PUBLIC_NHOST_MIGRATIONS_PORT:=9693}"
NEXT_PUBLIC_NHOST_HASURA_PORT="${NEXT_PUBLIC_NHOST_HASURA_PORT:=9695}"
NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT="${NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT:=1337}"
# replace placeholders
find dashboard -type f -exec sed -i "s/__NEXT_PUBLIC_NHOST_MIGRATIONS_PORT__/${NEXT_PUBLIC_NHOST_MIGRATIONS_PORT}/g" {} +
find dashboard -type f -exec sed -i "s/__NEXT_PUBLIC_NHOST_HASURA_PORT__/${NEXT_PUBLIC_NHOST_HASURA_PORT}/g" {} +
find dashboard -type f -exec sed -i "s/__NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT__/${NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT}/g" {} +
exec "$@"

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.4.1",
"version": "0.7.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -8,7 +8,7 @@
"build": "next build --no-lint",
"analyze": "ANALYZE=true pnpm build --no-lint",
"start": "next start",
"lint": "next lint --max-warnings 6",
"lint": "next lint --max-warnings 3",
"test": "vitest",
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
"nhost:dev": "nhost dev -d",
@@ -34,11 +34,11 @@
"@mui/material": "^5.10.14",
"@mui/system": "^5.10.14",
"@mui/x-date-pickers": "^5.0.8",
"@nhost/core": "^0.9.3",
"@nhost/nextjs": "^1.8.3",
"@nhost/nhost-js": "^1.6.1",
"@nhost/react": "^0.14.3",
"@nhost/react-apollo": "^4.8.3",
"@nhost/core": "^0.9.4",
"@nhost/nextjs": "^1.9.1",
"@nhost/nhost-js": "^1.6.2",
"@nhost/react": "^0.15.1",
"@nhost/react-apollo": "^4.9.1",
"@segment/snippet": "^4.15.3",
"@tailwindcss/forms": "^0.5.3",
"@tanstack/react-query": "^4.16.1",
@@ -169,4 +169,4 @@
"last 1 safari version"
]
}
}
}

View File

@@ -0,0 +1 @@
<svg width="21" height="21" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.08 0c5.578 0 10.08 4.507 10.08 10.09 0 5.584-4.502 10.09-10.08 10.09A10.072 10.072 0 0 1 0 10.09C0 4.507 4.503 0 10.08 0Zm0 2.69a7.375 7.375 0 0 0-7.392 7.4c0 4.104 3.293 7.4 7.392 7.4 4.1 0 7.392-3.296 7.392-7.4 0-4.103-3.293-7.4-7.392-7.4Zm-2.486 7.804c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086a2.095 2.095 0 0 1-2.083-2.086c0-1.143.94-2.085 2.083-2.085Zm4.973 0c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086a2.095 2.095 0 0 1-2.084-2.086c0-1.143.941-2.085 2.084-2.085Zm0-4.978c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086A2.095 2.095 0 0 1 10.483 7.6c0-1.143.941-2.085 2.084-2.085Zm-4.973 0c1.142 0 2.083.942 2.083 2.085 0 1.144-.94 2.086-2.083 2.086A2.095 2.095 0 0 1 5.51 7.6c0-1.143.94-2.085 2.083-2.085Z" fill="#F22F46"/></svg>

After

Width:  |  Height:  |  Size: 869 B

View File

@@ -1,166 +0,0 @@
import { useWorkspaceContext } from '@/context/workspace-context';
import { Modal } from '@/ui/Modal';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { triggerToast } from '@/utils/toast';
import type { EnvironmentVariableFragment } from '@/utils/__generated__/graphql';
import {
refetchGetEnvironmentVariablesWhereQuery,
useDeleteEnvironmentVariableMutation,
useUpdateEnvironmentVariableMutation,
} from '@/utils/__generated__/graphql';
import React, { useState } from 'react';
type EnvModalProps = {
show: boolean;
close: VoidFunction;
envVar: EnvironmentVariableFragment;
};
export default function EditEnvVarModal({
show,
close,
envVar,
}: EnvModalProps) {
const { workspaceContext } = useWorkspaceContext();
const { appId } = workspaceContext;
const [updateEnvVar, { loading: updateLoading }] =
useUpdateEnvironmentVariableMutation({
refetchQueries: [
refetchGetEnvironmentVariablesWhereQuery({
where: {
appId: {
_eq: appId,
},
},
}),
],
});
const [deleteEnvVar, { loading: deleteLoading }] =
useDeleteEnvironmentVariableMutation({
refetchQueries: [
refetchGetEnvironmentVariablesWhereQuery({
where: {
appId: {
_eq: appId,
},
},
}),
],
});
const [prodValue, setProdValue] = useState(envVar.prodValue || '');
const [devValue, setDevValue] = useState(envVar.devValue || '');
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
try {
await updateEnvVar({
variables: {
id: envVar.id,
environmentVariable: {
prodValue,
devValue,
},
},
});
} catch (error) {
close();
triggerToast('Error updating environment variable');
return;
}
triggerToast(`Environment variable ${envVar.name} updated successfully`);
close();
};
const handleDelete = async () => {
try {
await deleteEnvVar({
variables: {
id: envVar.id,
},
});
} catch (error) {
close();
triggerToast('Error deleting environment variable');
return;
}
triggerToast(`Environment variable ${envVar.name} removed successfully`);
close();
};
return (
<Modal showModal={show} close={close}>
<form onSubmit={handleSubmit}>
<div className="w-modal px-6 py-6 text-left">
<div className="grid grid-flow-row gap-1">
<Text variant="h3" component="h2">
{envVar.name}
</Text>
<Text variant="subtitle2">
The default value will be available in all environments, unless
you override it. All values are encrypted.
</Text>
<div className="my-2 grid grid-flow-row gap-2">
<Input
id="name"
label="Name"
autoFocus
disabled
autoComplete="off"
defaultValue={envVar.name}
fullWidth
hideEmptyHelperText
/>
<Input
id="prodValue"
label="Production Value"
fullWidth
placeholder="Enter a value"
value={prodValue}
onChange={(event) => setProdValue(event.target.value)}
hideEmptyHelperText
/>
<Input
id="devValue"
label="Development Value"
fullWidth
placeholder="Enter a value"
value={devValue}
onChange={(event) => setDevValue(event.target.value)}
hideEmptyHelperText
/>
</div>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={updateLoading}>
Save
</Button>
<Button
variant="outlined"
color="error"
loading={deleteLoading}
onClick={handleDelete}
>
Delete
</Button>
<Button onClick={close} variant="outlined" color="secondary">
Close
</Button>
</div>
</div>
</div>
</form>
</Modal>
);
}

View File

@@ -5,6 +5,7 @@ import Button from '@/ui/v2/Button';
import ArrowSquareOutIcon from '@/ui/v2/icons/ArrowSquareOutIcon';
import Link from '@/ui/v2/Link';
import Text from '@/ui/v2/Text';
import { LOCAL_HASURA_URL } from '@/utils/env';
import { generateRemoteAppUrl } from '@/utils/helpers';
import Image from 'next/image';
@@ -24,7 +25,7 @@ export function HasuraData({ close }: HasuraDataProps) {
const hasuraUrl =
process.env.NEXT_PUBLIC_ENV === 'dev'
? process.env.NEXT_PUBLIC_NHOST_HASURA_URL || 'http://localhost:9695'
? LOCAL_HASURA_URL
: generateRemoteAppUrl(currentApplication.subdomain);
return (

View File

@@ -1,71 +0,0 @@
import type { SelectorOption } from '@/ui/Selector';
import Selector from '@/ui/Selector';
import { Text } from '@/ui/Text';
import { Toggle } from '@/ui/Toggle';
import clsx from 'clsx';
export interface PermissionSettingsProps {
text: string;
desc?: string;
toggle?: boolean;
onChange?: any;
checked?: boolean;
options?: any;
value?: SelectorOption;
} // @TODO: Fix alt attribute on images.
// @FIX: Double border
export function PermissionSetting({
text,
desc,
toggle,
checked = false,
onChange,
options,
value,
}: PermissionSettingsProps) {
return (
<div className="flex flex-row place-content-between py-2">
<div
className={clsx(
'flex flex-col space-y-1 self-center px-0.5',
!desc && 'py-3.5',
desc && 'py-2',
)}
>
<Text
variant="body"
size="normal"
className="font-medium"
color="greyscaleDark"
>
{text}
</Text>
{desc && (
<Text
variant="body"
size="tiny"
className="font-normal"
color="greyscaleDark"
>
{desc}
</Text>
)}
</div>
{toggle ? (
<div className="flex flex-row">
<Toggle checked={checked} onChange={onChange} />
</div>
) : (
<div className="flex flex-row self-center">
<Selector
width="w-28"
options={options}
onChange={onChange}
value={value}
/>
</div>
)}
</div>
);
}

View File

@@ -1,58 +0,0 @@
import type { Provider as ProviderType } from '@/types/providers';
import Status, { StatusEnum } from '@/ui/Status';
import { Text } from '@/ui/Text';
import { ChevronRightIcon } from '@heroicons/react/solid';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
interface ProviderProps {
provider: ProviderType;
enabled: boolean;
}
export function Provider({ provider, enabled }: ProviderProps) {
const { name, logo } = provider;
const {
query: { workspaceSlug, appSlug },
} = useRouter();
const nameLowerCase = name.toLowerCase();
return (
<Link
href={`/${workspaceSlug}/${appSlug}/settings/sign-in-methods/${nameLowerCase}`}
passHref
>
<a
href={`${workspaceSlug}/${appSlug}/settings/sign-in-methods/${nameLowerCase}`}
className="flex cursor-pointer flex-row place-content-between border-t py-2.5"
>
<div className="grid grid-flow-col items-center gap-2">
<div className="h-6 w-6">
<Image
src={logo}
alt={`Logo of ${name}`}
width={24}
height={24}
layout="responsive"
/>
</div>
<Text className="font-medium" color="greyscaleDark" size="normal">
{name}
</Text>
</div>
<div className="flex flex-row">
{enabled ? (
<Status status={StatusEnum.Live}>Enabled</Status>
) : (
<Status status={StatusEnum.Closed}>Disabled</Status>
)}
<ChevronRightIcon className="ml-2 h-4 w-4 cursor-pointer self-center" />
</div>
</a>
</Link>
);
}
export default Provider;

View File

@@ -1,183 +0,0 @@
import { CreateUserRoleModal } from '@/components/applications/users/roles/CreateRoleModal';
import { EditUserRoleModal } from '@/components/applications/users/roles/EditUserRoleModal';
import Lock from '@/components/icons/Lock';
import type { GetRolesQuery } from '@/generated/graphql';
import { Modal } from '@/ui';
import { Text } from '@/ui/Text';
import { ChevronRightIcon } from '@heroicons/react/solid';
import clsx from 'clsx';
import type { Dispatch, MouseEvent, MouseEventHandler } from 'react';
import { useReducer } from 'react';
function RolesTableHead() {
return (
<thead>
<tr>
<th className="w-64 py-3 text-left font-medium text-base">
<Text className="text-xs font-bold text-greyscaleDark">Role</Text>
</th>
</tr>
</thead>
);
}
interface UserRoleProps {
role: string;
isSystemRole: boolean;
onClick?: MouseEventHandler<HTMLTableRowElement>;
}
function UserRole({ role, isSystemRole, onClick }: UserRoleProps) {
return (
<tr
className={clsx(isSystemRole ? 'cursor-not-allowed' : 'cursor-pointer')}
onClick={onClick}
>
<td className="py-2">
<Text
size="normal"
className={clsx(
isSystemRole ? 'text-greyscaleGrey' : 'text-greyscaleDark',
'pl-1 font-medium',
)}
>
{role}
</Text>
</td>
<td className="text-right">
{isSystemRole ? (
<div className="inline-flex pr-1">
<Text
size="tiny"
className=" font-mono text-xs font-medium uppercase tracking-wide text-greyscaleGrey"
>
System Role
</Text>
<Lock className="ml-1 h-5 w-5 text-greyscaleGrey" />
</div>
) : (
<div className="inline-flex self-center py-2 pr-1.5">
<ChevronRightIcon className="h-4.5 w-4.5 text-greyscaleDark" />
</div>
)}
</td>
</tr>
);
}
export type UserRoleDetails = {
name: string;
isSystemRole: boolean;
};
export const getUserRoles = (data): UserRoleDetails[] => {
const authUserDefaultAllowedRoles =
data.app.authUserDefaultAllowedRoles.split(',');
return authUserDefaultAllowedRoles.map((role: string) => ({
name: role,
isSystemRole: ['user', 'me'].includes(role),
}));
};
type ModalState = {
visible: boolean;
type: 'create' | 'edit';
payload: UserRoleDetails;
};
type ModalAction = {
type: 'OPEN_CREATE_MODAL' | 'OPEN_EDIT_MODAL' | 'CLOSE_MODAL';
payload?: UserRoleDetails;
};
function modalStateReducer(state: ModalState, action: ModalAction): ModalState {
switch (action.type) {
case 'OPEN_CREATE_MODAL':
return { ...state, visible: true, type: 'create', payload: null };
case 'OPEN_EDIT_MODAL':
return { ...state, visible: true, type: 'edit', payload: action.payload };
case 'CLOSE_MODAL':
return { ...state, visible: false };
default:
throw new Error(`Action type ${action.type} is not supported.`);
}
}
function AddNewUserRole({ dispatch }: { dispatch: Dispatch<ModalAction> }) {
return (
<tr className="cursor-pointer border-y-1 border-solid border-gray-300">
<td className="p-2">
<button
type="button"
onClick={() => dispatch({ type: 'OPEN_CREATE_MODAL' })}
>
<Text className="text-sm+ font-medium text-blue">
Create New Role
</Text>
</button>
</td>
<td />
</tr>
);
}
function RolesTableBody({ data }: { data: GetRolesQuery }) {
const userRoles = getUserRoles(data);
const [
{ visible: modalVisible, type: modalType, payload: modalPayload },
dispatch,
] = useReducer(modalStateReducer, {
visible: false,
type: null,
payload: null,
});
function handleRoleEdit(event: MouseEvent<HTMLTableRowElement>, role: any) {
dispatch({ type: 'OPEN_EDIT_MODAL', payload: role });
}
return (
<>
<Modal
showModal={modalVisible}
close={() => dispatch({ type: 'CLOSE_MODAL' })}
>
{modalType === 'create' ? (
<CreateUserRoleModal
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
/>
) : (
<EditUserRoleModal
onClose={() => dispatch({ type: 'CLOSE_MODAL' })}
payload={modalPayload}
/>
)}
</Modal>
<tbody className="divide-y-1 border-t-1 border-b-1 border-solid border-gray-300 ">
{userRoles.map((role) => (
<UserRole
key={role.name}
role={role.name}
isSystemRole={role.isSystemRole}
onClick={
role.isSystemRole
? undefined
: (event) => handleRoleEdit(event, role)
}
/>
))}
<AddNewUserRole dispatch={dispatch} />
</tbody>
</>
);
}
export function RolesTable({ data }: { data: GetRolesQuery }) {
return (
<table className="w-full table-fixed overflow-x-auto">
<RolesTableHead />
<RolesTableBody data={data} />
</table>
);
}

View File

@@ -1,73 +0,0 @@
import type { TextProps } from '@/ui/Text';
import { Text } from '@/ui/Text';
import type { PropsWithChildren, ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export interface SettingsSectionProps {
/**
* Title of this section.
*/
title: ReactNode;
/**
* Props to be passed to the title component.
*/
titleProps?: TextProps;
/**
* Props to be passed to the wrapper component.
*/
wrapperProps?: TextProps;
/**
* Description of this section.
*/
desc?: ReactNode;
/**
* Props to be passed to the description component.
*/
descriptionProps?: TextProps;
}
export function SettingsSection({
children,
title,
titleProps,
descriptionProps,
desc,
wrapperProps,
}: PropsWithChildren<SettingsSectionProps>) {
const { className: titleClassName, ...restTitleProps } = titleProps || {};
const { className: wrapperClassName } = wrapperProps || {};
const { className: descriptionClassName, ...restDescriptionProps } =
descriptionProps || {};
return (
<div className={twMerge('mt-10', wrapperClassName)}>
<div className="mx-auto font-display">
<div className="flex flex-col place-content-between">
<div>
<Text
size="large"
variant="heading"
className={twMerge('mb-1.5 font-medium', titleClassName)}
color="greyscaleDark"
{...restTitleProps}
>
{title}
</Text>
{desc && (
<Text
variant="body"
size="normal"
color="greyscaleDark"
className={twMerge('mb-3 font-normal', descriptionClassName)}
{...restDescriptionProps}
>
{desc}
</Text>
)}
</div>
</div>
{children}
</div>
</div>
);
}

View File

@@ -1,110 +0,0 @@
import { useWorkspaceContext } from '@/context/workspace-context';
import useCustomClaims from '@/hooks/useCustomClaims';
import type { CustomClaim } from '@/types/application';
import { Alert } from '@/ui/Alert';
import { triggerToast } from '@/utils/toast';
import {
refetchGetAppCustomClaimsQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type {
CreatePermissionVariableBaseFormData,
CreatePermissionVariableModalBaseProps,
} from './CreatePermissionVariableModalBase';
import CreatePermissionVariableModalBase from './CreatePermissionVariableModalBase';
export type CreatePermissionVariableFormData =
CreatePermissionVariableBaseFormData;
export type CreatePermissionVariableModalProps = Pick<
CreatePermissionVariableModalBaseProps,
'onClose'
>;
export default function CreatePermissionVariableModal({
onClose,
}: CreatePermissionVariableModalProps) {
const [error, setError] = useState<Error>();
const form = useForm<CreatePermissionVariableFormData>({
reValidateMode: 'onSubmit',
});
const {
workspaceContext: { appId },
} = useWorkspaceContext();
const { data: customClaims } = useCustomClaims({ appId });
const [updateApp] = useUpdateAppMutation({
refetchQueries: [refetchGetAppCustomClaimsQuery({ id: appId })],
});
async function handleSubmit(permissionVariable: CustomClaim) {
setError(undefined);
try {
if (
customClaims.some(
(claim) =>
claim.key.toLowerCase() === permissionVariable.key.toLowerCase(),
)
) {
throw new Error(
'Permission variable with this field name already exists.',
);
}
await updateApp({
variables: {
id: appId,
app: {
authJwtCustomClaims: [...customClaims, permissionVariable]
.filter((claim) => !claim.system)
.reduce(
(authJwtCustomClaims, claim) => ({
...authJwtCustomClaims,
[claim.key]: claim.value,
}),
{},
),
},
},
});
triggerToast('Permission variable created');
if (!onClose) {
return;
}
onClose();
} catch (updateError) {
if (updateError instanceof Error) {
setError(updateError);
} else {
setError(new Error(updateError));
}
}
}
return (
<FormProvider {...form}>
<CreatePermissionVariableModalBase
title="Create Permission Variable"
type="create"
onSubmit={handleSubmit}
onClose={onClose}
errorComponent={
error && (
<Alert className="mt-4" severity="error">
{error.message}
</Alert>
)
}
/>
</FormProvider>
);
}

View File

@@ -1,187 +0,0 @@
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import type { ChangeEvent, MouseEventHandler, ReactNode } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
export interface CreatePermissionVariableBaseFormData {
key: string;
value: string;
}
export interface CreateModalBaseProps<T> {
/**
* Title of this modal.
*/
title: string;
/**
* Type of this modal.
*/
type?: 'create' | 'edit';
/**
* Callback to be called when the modal is closed.
*/
onClose?: VoidFunction;
/**
* Callback to be called when remove button is clicked.
*/
onRemove?: MouseEventHandler<HTMLButtonElement>;
/**
* Callback to be called when the form is submitted.
*/
onSubmit: SubmitHandler<T>;
/**
* Error to be displayed.
*/
errorComponent?: ReactNode;
}
export type CreatePermissionVariableModalBaseProps =
CreateModalBaseProps<CreatePermissionVariableBaseFormData>;
export default function CreatePermissionVariableModalBase({
title,
type,
onClose,
onRemove,
onSubmit,
errorComponent,
}: CreatePermissionVariableModalBaseProps) {
const {
handleSubmit,
watch,
register,
formState: { isSubmitting, errors },
} = useFormContext<CreatePermissionVariableBaseFormData>();
const keyHandlers = register('key', {
required: true,
pattern: {
value: /^[a-zA-Z-]+$/i,
message: 'Must contain only letters and hyphens',
},
});
const valueHandlers = register('value', {
required: true,
pattern: {
value: /^[a-zA-Z0-9._[\]]+$/i,
message: 'Must contain only letters, dots, brackets, and underscores',
},
});
const isComplete = !!watch('key') && !!watch('value');
return (
<div className="w-modal p-6 text-left">
<div className="grid w-full grid-flow-col items-center justify-between">
<Text variant="h3" component="h2">
{title}
</Text>
{type === 'edit' && onRemove && (
<Button variant="borderless" color="error" onClick={onRemove}>
Remove
</Button>
)}
</div>
<Text className="mt-2 text-sm+ text-greyscaleDark">
Enter the field name and the path you want to use in this permission
variable.
</Text>
{errorComponent}
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
<div className="my-4 grid grid-flow-row divide-y-1 divide-solid divide-gray-200 border-y border-gray-200">
<Input
{...keyHandlers}
value={watch('key')}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
if (
event.target.value &&
!/^[a-zA-Z-]+$/gi.test(event.target.value)
) {
// prevent the user from entering invalid characters
return;
}
keyHandlers.onChange(event);
}}
id="key"
variant="inline"
inlineInputProportion="66%"
label="Field name"
fullWidth
startAdornment={
<Text className="min-w-[73px] text-sm+ text-greyscaleGrey">
X-Hasura-
</Text>
}
componentsProps={{
inputWrapper: { className: 'my-1' },
input: {
className: 'border-transparent focus-within:border-solid pl-2',
},
inputRoot: { className: '!pl-[1px]' },
}}
autoFocus
error={!!errors?.key?.message}
helperText={errors?.key?.message}
hideEmptyHelperText
/>
<Input
{...valueHandlers}
value={watch('value')}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
if (
event.target.value &&
!/^[a-zA-Z-]+$/gi.test(event.target.value)
) {
// prevent the user from entering invalid characters
return;
}
valueHandlers.onChange(event);
}}
id="value"
variant="inline"
inlineInputProportion="66%"
label="Path"
fullWidth
startAdornment={
<Text className="text-sm+ text-greyscaleGrey">user.</Text>
}
componentsProps={{
inputWrapper: { className: 'my-1' },
input: {
className: 'border-transparent focus-within:border-solid pl-2',
},
inputRoot: { className: '!pl-[1px]' },
}}
error={!!errors?.value?.message}
helperText={errors?.value?.message}
hideEmptyHelperText
/>
</div>
<div className="grid gap-2">
<Button
type="submit"
loading={isSubmitting}
disabled={isSubmitting || !isComplete}
>
{type === 'create' ? 'Create Permission Variable' : 'Save Changes'}
</Button>
<Button variant="outlined" color="secondary" onClick={onClose}>
Close
</Button>
</div>
</form>
</div>
);
}

View File

@@ -1,209 +0,0 @@
import { useWorkspaceContext } from '@/context/workspace-context';
import useCustomClaims from '@/hooks/useCustomClaims';
import type { CustomClaim } from '@/types/application';
import { Alert } from '@/ui/Alert';
import { Modal } from '@/ui/Modal';
import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text';
import { triggerToast } from '@/utils/toast';
import {
refetchGetAppCustomClaimsQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type {
CreatePermissionVariableBaseFormData,
CreatePermissionVariableModalBaseProps,
} from './CreatePermissionVariableModalBase';
import CreatePermissionVariableModalBase from './CreatePermissionVariableModalBase';
export type EditPermissionVariableFormData =
CreatePermissionVariableBaseFormData;
export type EditPermissionVariableModalProps = Pick<
CreatePermissionVariableModalBaseProps,
'onClose'
> & {
/**
* The permission variable to edit.
*/
payload: CustomClaim;
};
export default function EditPermissionVariableModal({
payload: originalCustomClaim,
...props
}: EditPermissionVariableModalProps) {
const [error, setError] = useState<Error>();
const [showRemoveModal, setShowRemoveModal] = useState(false);
const form = useForm<EditPermissionVariableFormData>({
reValidateMode: 'onSubmit',
defaultValues: {
key: originalCustomClaim.key || '',
value: originalCustomClaim.value || '',
},
});
const {
workspaceContext: { appId },
} = useWorkspaceContext();
const { data: customClaims } = useCustomClaims({ appId });
const [updateApp] = useUpdateAppMutation({
refetchQueries: [refetchGetAppCustomClaimsQuery({ id: appId })],
});
async function handleSubmit(permissionVariable: CustomClaim) {
setError(undefined);
try {
if (
originalCustomClaim.key.toLowerCase() !==
permissionVariable.key.toLowerCase() &&
customClaims.some(
(claim) =>
claim.key.toLowerCase() === permissionVariable.key.toLowerCase(),
)
) {
throw new Error(
'Permission variable with this field name already exists.',
);
}
// we need to preserve the original position of the permission variable
const currentIndex = customClaims.findIndex(
(claim) =>
claim.key.toLowerCase() === originalCustomClaim.key.toLowerCase(),
);
await updateApp({
variables: {
id: appId,
app: {
authJwtCustomClaims: customClaims
.slice(0, currentIndex)
.concat(permissionVariable)
.concat(customClaims.slice(currentIndex + 1))
.filter((claim) => !claim.system)
.reduce(
(authJwtCustomClaims, claim) => ({
...authJwtCustomClaims,
[claim.key]: claim.value,
}),
{},
),
},
},
});
triggerToast(`Permission variable updated`);
if (props.onClose) {
props.onClose();
}
} catch (updateError) {
if (updateError instanceof Error) {
setError(updateError);
} else {
setError(new Error(updateError));
}
}
}
async function handleRemove() {
setError(undefined);
try {
await updateApp({
variables: {
id: appId,
app: {
authJwtCustomClaims: customClaims
.filter(
(claim) =>
claim.key !== originalCustomClaim.key && !claim.system,
)
.reduce(
(authJwtCustomClaims, claim) => ({
...authJwtCustomClaims,
[claim.key]: claim.value,
}),
{},
),
},
},
});
setShowRemoveModal(false);
triggerToast('Permission variable removed');
if (props.onClose) {
props.onClose();
}
} catch (updateError) {
if (updateError instanceof Error) {
setError(updateError);
} else {
setError(new Error(updateError));
}
}
}
return (
<>
<Modal
showModal={showRemoveModal}
close={() => setShowRemoveModal(false)}
>
<div className="grid w-96 grid-flow-row gap-2 p-6 text-left text-greyscaleDark">
<Text variant="h3" component="h2">
Remove {originalCustomClaim.key}?
</Text>
<Text>You will not be able to use it in permissions anymore.</Text>
<Text>
If you have permission checks currently using this property, they
will never resolve to true.
</Text>
<div className="mt-2 grid grid-flow-row gap-2">
<Button color="error" onClick={handleRemove} className="w-full">
Remove Permission Variable
</Button>
<Button
variant="outlined"
color="secondary"
onClick={() => setShowRemoveModal(false)}
className="w-full"
>
Cancel
</Button>
</div>
</div>
</Modal>
<FormProvider {...form}>
<CreatePermissionVariableModalBase
title="Edit Permission Variable"
type="edit"
onSubmit={handleSubmit}
onRemove={() => setShowRemoveModal(true)}
errorComponent={
error && (
<Alert className="mt-4" severity="error">
{error.message}
</Alert>
)
}
{...props}
/>
</FormProvider>
</>
);
}

View File

@@ -1,101 +0,0 @@
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert';
import Loading from '@/ui/Loading';
import {
refetchGetRolesQuery,
useGetRolesQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type {
CreateUserRoleBaseFormData,
CreateUserRoleModalBaseProps,
} from './CreateUserRoleModalBase';
import { CreateUserRoleModalBase } from './CreateUserRoleModalBase';
export type CreateUserRoleFormData = CreateUserRoleBaseFormData;
export type CreateUserRoleModalProps = Pick<
CreateUserRoleModalBaseProps,
'onClose'
>;
export function CreateUserRoleModal({ onClose }: CreateUserRoleModalProps) {
const [error, setError] = useState<Error>();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const form = useForm<CreateUserRoleBaseFormData>({
reValidateMode: 'onSubmit',
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: [refetchGetRolesQuery({ id: currentApplication.id })],
});
const {
data: currentRolesData,
loading,
error: getRolesError,
} = useGetRolesQuery({
variables: {
id: currentApplication.id,
},
});
if (loading) {
return <Loading />;
}
if (getRolesError) {
return (
<div className="mx-auto max-w-2.5xl">
<Alert severity="error">{error.message}</Alert>
</div>
);
}
async function handleSubmit(data) {
setError(undefined);
const newAuthUserDefaultAllowedRoles = `${currentRolesData.app.authUserDefaultAllowedRoles},${data.roleName}`;
try {
await updateApp({
variables: {
id: currentApplication.id,
app: {
authUserDefaultAllowedRoles: newAuthUserDefaultAllowedRoles,
},
},
});
if (!onClose) {
return;
}
onClose();
} catch (updateError) {
setError(updateError);
}
}
return (
<FormProvider {...form}>
<CreateUserRoleModalBase
title="Create New Role"
type="create"
onSubmit={handleSubmit}
onClose={onClose}
errorComponent={
error && (
<Alert className="mt-4" severity="error">
{error.message}
</Alert>
)
}
/>
</FormProvider>
);
}

View File

@@ -1,102 +0,0 @@
import type { CreateModalBaseProps } from '@/components/applications/users/permissions/modal/CreatePermissionVariableModalBase';
import { Input } from '@/ui';
import { Button } from '@/ui/Button';
import { Text } from '@/ui/Text';
import { Controller, useFormContext } from 'react-hook-form';
export interface CreateUserRoleBaseFormData {
roleName: string;
}
export type CreateUserRoleModalBaseProps =
CreateModalBaseProps<CreateUserRoleBaseFormData>;
export type CreateUserRoleModal = Pick<CreateUserRoleModalBaseProps, 'onClose'>;
export function CreateUserRoleModalBase({
title,
type,
onRemove,
onSubmit,
errorComponent,
}: CreateUserRoleModalBaseProps) {
const {
control,
handleSubmit,
formState: { isSubmitting },
} = useFormContext<CreateUserRoleBaseFormData>();
return (
<div className="w-modal- p-6 text-left">
<div className="mx-auto items-center justify-between">
<Text
variant="heading"
className="text-center text-lg font-medium text-greyscaleDark"
>
{title}
</Text>
</div>
{errorComponent}
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
<div className="mt-3 mb-3 divide-y border-t border-b py-1">
<div className="flex flex-row place-content-between py-2">
<div className="flex w-full flex-row">
<Text
color="greyscaleDark"
className="self-center font-medium"
size="normal"
>
New Role Name
</Text>
</div>
<div className="flex w-full">
<Controller
name="roleName"
control={control}
rules={{
required: true,
pattern: {
value: /^[a-zA-Z0-9-_]+$/,
message: 'Must contain only letters, hyphens, and numbers.',
},
}}
render={({ field }) => (
<Input
{...field}
id="roleName"
required
value={field.value || ''}
onChange={(value: string) => {
if (value && !/^[a-zA-Z0-9-_]+$/gi.test(value)) {
// prevent the user from entering invalid characters
return;
}
field.onChange(value);
}}
/>
)}
/>
</div>
</div>
</div>
<div className="grid gap-2">
<Button
variant="primary"
type="submit"
loading={isSubmitting}
disabled={isSubmitting}
>
{type === 'create' ? 'Create New User Role' : 'Save Changes'}
</Button>
{type === 'edit' && onRemove && (
<Button variant="menu" border onClick={onRemove}>
<Text className="text-sm+ font-medium text-red">Remove Role</Text>
</Button>
)}
</div>
</form>
</div>
);
}

View File

@@ -1,190 +0,0 @@
import type { GetRolesQuery } from '@/generated/graphql';
import {
refetchGetRolesQuery,
useGetRolesQuery,
useUpdateAppMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert';
import { Button } from '@/ui/Button';
import Loading from '@/ui/Loading';
import { Modal } from '@/ui/Modal';
import { Text } from '@/ui/Text';
import { triggerToast } from '@/utils/toast';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type {
CreateUserRoleBaseFormData,
CreateUserRoleModalBaseProps,
} from './CreateUserRoleModalBase';
import { CreateUserRoleModalBase } from './CreateUserRoleModalBase';
export type EditUserRoleFormData = CreateUserRoleBaseFormData;
export type EditUserRoleModalProps = Pick<
CreateUserRoleModalBaseProps,
'onClose'
> & {
/**
* The permission variable to edit.
*/
payload: any;
};
export function EditUserRoleModal({
payload: originalRole,
...props
}: EditUserRoleModalProps) {
const [error, setError] = useState<Error>();
const [showRemoveModal, setShowRemoveModal] = useState(false);
const { currentApplication } = useCurrentWorkspaceAndApplication();
const form = useForm<EditUserRoleFormData>({
reValidateMode: 'onSubmit',
defaultValues: {
roleName: originalRole.name || '',
},
});
const [updateApp, { loading: loadingUpdateAppMutation }] =
useUpdateAppMutation({
refetchQueries: [refetchGetRolesQuery({ id: currentApplication.id })],
});
const {
data: currentRolesData,
loading,
error: getRolesError,
} = useGetRolesQuery({
variables: {
id: currentApplication.id,
},
});
if (loading) {
return <Loading />;
}
if (getRolesError) {
return (
<div className="mx-auto max-w-2.5xl">
<Alert severity="error">{error.message}</Alert>
</div>
);
}
async function handleSubmit(data: EditUserRoleFormData) {
setError(undefined);
const currentUserRoles =
currentRolesData.app.authUserDefaultAllowedRoles.split(',');
const roleBeingEdited = currentUserRoles.find(
(role) => role === originalRole.name,
);
const indexofRoleBeingEdited = currentUserRoles.indexOf(roleBeingEdited);
const newRoleName = data.roleName;
const newAuthUserDefaultAllowedRoles = currentUserRoles.slice();
if (data.roleName !== originalRole.name) {
newAuthUserDefaultAllowedRoles[indexofRoleBeingEdited] = newRoleName;
}
try {
await updateApp({
variables: {
id: currentApplication.id,
app: {
authUserDefaultAllowedRoles:
newAuthUserDefaultAllowedRoles.join(','),
},
},
});
triggerToast(`Role "${data.roleName}" updated successfully`);
props.onClose();
} catch (updateError) {
setError(updateError);
}
}
async function handleRemove(data: GetRolesQuery) {
setError(undefined);
// Get the current roles of this application.
const currentUserRoles = data.app.authUserDefaultAllowedRoles.split(',');
// Remove the role from the current roles.
const filteredCurrentUserRoles = currentUserRoles.filter(
(role) => role !== originalRole.name,
);
const newAuthUserDefaultAllowedRoles = filteredCurrentUserRoles.join(',');
try {
await updateApp({
variables: {
id: currentApplication.id,
app: {
authUserDefaultAllowedRoles: newAuthUserDefaultAllowedRoles,
},
},
});
props.onClose();
triggerToast(`Role "${originalRole.name}" removed successfully`);
} catch (updateError) {
setError(updateError);
}
}
return (
<>
<Modal
showModal={showRemoveModal}
close={() => setShowRemoveModal(false)}
>
<div className="px-6 pt-5 text-center text-greyscaleDark">
<Text variant="heading" className="mb-2 text-lg font-medium">
Remove Role &quot;{originalRole.name}&quot;?
</Text>
<div className="my-4">
<Button
variant="danger"
onClick={() => handleRemove(currentRolesData)}
className="w-full"
loading={loadingUpdateAppMutation}
>
Remove Role
</Button>
<Button
onClick={() => setShowRemoveModal(false)}
className="w-full"
>
Cancel
</Button>
</div>
</div>
</Modal>
<FormProvider {...form}>
<CreateUserRoleModalBase
title="Edit Role"
type="edit"
onSubmit={handleSubmit}
onRemove={() => setShowRemoveModal(true)}
errorComponent={
error && (
<Alert className="mt-4" severity="error">
{error.message}
</Alert>
)
}
{...props}
/>
</FormProvider>
</>
);
}

View File

@@ -1,201 +0,0 @@
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { useState } from 'react';
type EnvModalProps = {
onSubmit: (props: {
name: string;
prodValue: string;
devValue: string;
}) => Promise<void>;
name?: string;
prodValue?: string;
devValue?: string;
close: VoidFunction;
};
interface AddEnvVarModalVariablesError {
hasError: boolean;
message: string;
}
const DISABLED_START_ENV_VARIABLES = [
'NHOST_',
'HASURA_',
'AUTH_',
'STORAGE_',
'POSTGRES_',
];
const DISABLED_ENV_VARIABLES = [
'PATH',
'NODE_PATH',
'PYTHONPATH',
'GEM_PATH',
'HOSTNAME',
'TERM',
'NODE_VERSION',
'YARN_VERSION',
'NODE_ENV',
'HOME',
];
export default function AddEnvVarModal({
name: externalName,
prodValue: externalProdValue,
devValue: externalDevValue,
close,
onSubmit,
}: EnvModalProps) {
const [name, setName] = useState(externalName || '');
const [prodValue, setProdValue] = useState(externalProdValue || '');
const [devValue, setDevValue] = useState(externalDevValue || '');
const [error, setError] = useState<AddEnvVarModalVariablesError>({
hasError: false,
message: '',
});
const noError: AddEnvVarModalVariablesError = {
hasError: false,
message: '',
};
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
setError({ hasError: false, message: '' });
e.preventDefault();
if (
DISABLED_START_ENV_VARIABLES.some((envVar) =>
name.toUpperCase().startsWith(envVar),
)
) {
setError({
hasError: true,
message:
'The environment variable name cannot start with a value that is reserved for an internal environment variable.',
});
return;
}
if (
DISABLED_ENV_VARIABLES.some((envVar) => envVar === name.toUpperCase())
) {
setError({
hasError: true,
message:
'The environment variable name cannot be a value that is reserved for internal use.',
});
return;
}
// only allow alphabet characters and underscores
const onlyLettersWithNumbersStartsWithLetter = /^[a-zA-Z_]+[a-zA-Z0-9_]*$/;
if (!onlyLettersWithNumbersStartsWithLetter.test(name)) {
setError({
hasError: true,
message:
'The name contains invalid characters. Only letters, digits, and underscores are allowed. Furthermore, the name should start with a letter.',
});
return;
}
if (!name) {
setError({ hasError: true, message: 'Variable name is required.' });
return;
}
if (!prodValue) {
setError({ hasError: true, message: 'Production value is required.' });
return;
}
if (!devValue) {
setError({ hasError: true, message: 'Development value is required.' });
return;
}
await onSubmit({
name,
prodValue,
devValue,
});
close();
};
return (
<form onSubmit={handleSubmit}>
<div className="w-modal px-6 py-6 text-left">
<div className="grid grid-flow-row gap-1">
<Text variant="h3" component="h2">
{name || 'EXAMPLE_NAME'}
</Text>
<Text variant="subtitle2">
The default value will be available in all environments, unless you
override it. All values are encrypted.
</Text>
<div className="my-2 grid grid-flow-row gap-2">
<Input
id="name"
label="Name"
autoFocus
autoComplete="off"
fullWidth
placeholder="EXAMPLE_NAME"
value={name}
onChange={(event) => {
setError(noError);
setName(event.target.value);
}}
hideEmptyHelperText
/>
<Input
id="prodValue"
label="Production Value"
fullWidth
placeholder="Enter a value"
value={prodValue}
onChange={(event) => {
setError(noError);
setProdValue(event.target.value);
}}
hideEmptyHelperText
/>
<Input
id="devValue"
label="Development Value"
fullWidth
placeholder="Enter a value"
value={devValue}
onChange={(event) => {
setError(noError);
setDevValue(event.target.value);
}}
hideEmptyHelperText
/>
</div>
{error.hasError && (
<Alert severity="warning" className="mb-2">
<Text className="font-medium">Warning</Text>
<Text>{error.message}</Text>
</Alert>
)}
<div className="grid grid-flow-row gap-2">
<Button type="submit">Add</Button>
<Button onClick={close} variant="outlined" color="secondary">
Close
</Button>
</div>
</div>
</div>
</form>
);
}

View File

@@ -12,7 +12,13 @@ export type DialogType =
| 'CREATE_TABLE'
| 'EDIT_TABLE'
| 'CREATE_FOREIGN_KEY'
| 'EDIT_FOREIGN_KEY';
| 'EDIT_FOREIGN_KEY'
| 'CREATE_ROLE'
| 'EDIT_ROLE'
| 'CREATE_PERMISSION_VARIABLE'
| 'EDIT_PERMISSION_VARIABLE'
| 'CREATE_ENVIRONMENT_VARIABLE'
| 'EDIT_ENVIRONMENT_VARIABLE';
export interface DialogConfig<TPayload = unknown> {
/**

View File

@@ -1,13 +1,25 @@
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import { CreateForeignKeyForm } from '@/components/data-browser/CreateForeignKeyForm';
import { EditForeignKeyForm } from '@/components/data-browser/EditForeignKeyForm';
import CreateForeignKeyForm from '@/components/data-browser/CreateForeignKeyForm';
import EditForeignKeyForm from '@/components/data-browser/EditForeignKeyForm';
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import AlertDialog from '@/ui/v2/AlertDialog';
import { BaseDialog } from '@/ui/v2/Dialog';
import Drawer from '@/ui/v2/Drawer';
import dynamic from 'next/dynamic';
import type { BaseSyntheticEvent, PropsWithChildren } from 'react';
import type {
BaseSyntheticEvent,
DetailedHTMLProps,
HTMLProps,
PropsWithChildren,
} from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import type { DialogConfig, DialogType } from './DialogContext';
import DialogContext from './DialogContext';
import {
@@ -16,13 +28,21 @@ import {
drawerReducer,
} from './dialogReducers';
function LoadingComponent() {
function LoadingComponent({
className,
...props
}: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> = {}) {
return (
<div className="grid items-center justify-center px-6 py-4">
<div
{...props}
className={twMerge(
'grid items-center justify-center px-6 py-4',
className,
)}
>
<ActivityIndicator
circularProgressProps={{ className: 'w-5 h-5' }}
label="Loading form..."
delay={500}
/>
</div>
);
@@ -30,27 +50,27 @@ function LoadingComponent() {
const CreateRecordForm = dynamic(
() => import('@/components/data-browser/CreateRecordForm'),
{ ssr: false, loading: LoadingComponent },
{ ssr: false, loading: () => LoadingComponent() },
);
const CreateColumnForm = dynamic(
() => import('@/components/data-browser/CreateColumnForm'),
{ ssr: false, loading: LoadingComponent },
{ ssr: false, loading: () => LoadingComponent() },
);
const EditColumnForm = dynamic(
() => import('@/components/data-browser/EditColumnForm'),
{ ssr: false, loading: LoadingComponent },
{ ssr: false, loading: () => LoadingComponent() },
);
const CreateTableForm = dynamic(
() => import('@/components/data-browser/CreateTableForm'),
{ ssr: false, loading: LoadingComponent },
{ ssr: false, loading: () => LoadingComponent() },
);
const EditTableForm = dynamic(
() => import('@/components/data-browser/EditTableForm'),
{ ssr: false, loading: LoadingComponent },
{ ssr: false, loading: () => LoadingComponent() },
);
function DialogProvider({ children }: PropsWithChildren<unknown>) {
@@ -209,6 +229,25 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
[closeDialog, closeDrawer, onDirtyStateChange, openDialog, openDrawer],
);
const sharedDialogProps = {
...dialogPayload,
onSubmit: async (values: any) => {
await dialogPayload?.onSubmit?.(values);
closeDialog();
},
onCancel: closeDialogWithDirtyGuard,
};
const sharedDrawerProps = {
onSubmit: async () => {
await drawerPayload?.onSubmit();
closeDrawer();
},
onCancel: closeDrawerWithDirtyGuard,
};
return (
<DialogContext.Provider value={contextValue}>
<AlertDialog
@@ -249,33 +288,47 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
open={dialogOpen}
onClose={closeDialogWithDirtyGuard}
TransitionProps={{ onExited: clearDialogContent, unmountOnExit: false }}
PaperProps={{ className: 'max-w-md w-full' }}
PaperProps={{
...dialogProps?.PaperProps,
className: twMerge(
'max-w-md w-full',
dialogProps?.PaperProps?.className,
),
}}
>
<RetryableErrorBoundary
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
>
{activeDialogType === 'CREATE_FOREIGN_KEY' && (
<CreateForeignKeyForm
{...dialogPayload}
onSubmit={async (values) => {
await dialogPayload?.onSubmit(values);
closeDialog();
}}
onCancel={closeDialogWithDirtyGuard}
/>
<CreateForeignKeyForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_FOREIGN_KEY' && (
<EditForeignKeyForm
{...dialogPayload}
onSubmit={async (values) => {
await dialogPayload?.onSubmit(values);
<EditForeignKeyForm {...sharedDialogProps} />
)}
closeDialog();
}}
onCancel={closeDialogWithDirtyGuard}
/>
{activeDialogType === 'CREATE_ROLE' && (
<CreateRoleForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_ROLE' && (
<EditRoleForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_PERMISSION_VARIABLE' && (
<CreatePermissionVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_PERMISSION_VARIABLE' && (
<EditPermissionVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_ENVIRONMENT_VARIABLE' && (
<CreateEnvironmentVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_ENVIRONMENT_VARIABLE' && (
<EditEnvironmentVariableForm {...sharedDialogProps} />
)}
</RetryableErrorBoundary>
</BaseDialog>
@@ -292,61 +345,34 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<RetryableErrorBoundary>
{activeDrawerType === 'CREATE_RECORD' && (
<CreateRecordForm
{...sharedDrawerProps}
columns={drawerPayload?.columns}
onSubmit={async () => {
await drawerPayload?.onSubmit();
closeDrawer();
}}
onCancel={closeDrawerWithDirtyGuard}
/>
)}
{activeDrawerType === 'CREATE_COLUMN' && (
<CreateColumnForm
onSubmit={async () => {
await drawerPayload?.onSubmit();
closeDrawer();
}}
onCancel={closeDrawerWithDirtyGuard}
/>
<CreateColumnForm {...sharedDrawerProps} />
)}
{activeDrawerType === 'EDIT_COLUMN' && (
<EditColumnForm
{...sharedDrawerProps}
column={drawerPayload?.column}
onSubmit={async () => {
await drawerPayload?.onSubmit();
closeDrawer();
}}
onCancel={closeDrawerWithDirtyGuard}
/>
)}
{activeDrawerType === 'CREATE_TABLE' && (
<CreateTableForm
{...sharedDrawerProps}
schema={drawerPayload?.schema}
onSubmit={async () => {
await drawerPayload?.onSubmit();
closeDrawer();
}}
onCancel={closeDrawerWithDirtyGuard}
/>
)}
{activeDrawerType === 'EDIT_TABLE' && (
<EditTableForm
{...sharedDrawerProps}
table={drawerPayload?.table}
schema={drawerPayload?.schema}
onSubmit={async () => {
await drawerPayload?.onSubmit();
closeDrawer();
}}
onCancel={closeDrawerWithDirtyGuard}
/>
)}
</RetryableErrorBoundary>

View File

@@ -24,7 +24,7 @@ export interface CreateForeignKeyFormProps
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
}
export function CreateForeignKeyForm({
export default function CreateForeignKeyForm({
onSubmit,
selectedColumn,
...props

View File

@@ -1,2 +1,2 @@
export * from './CreateForeignKeyForm';
export { CreateForeignKeyForm as default } from './CreateForeignKeyForm';
export { default } from './CreateForeignKeyForm';

View File

@@ -29,7 +29,7 @@ export interface EditForeignKeyFormProps
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
}
export function EditForeignKeyForm({
export default function EditForeignKeyForm({
foreignKeyRelation,
selectedColumn,
onSubmit,

View File

@@ -1,2 +1,2 @@
export * from './EditForeignKeyForm';
export { EditForeignKeyForm as default } from './EditForeignKeyForm';
export { default } from './EditForeignKeyForm';

View File

@@ -4,19 +4,19 @@ import { twMerge } from 'tailwind-merge';
export interface ContainerProps
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
/**
* Class name passed to the wrapper element.
* Class name passed to the root element.
*/
wrapperClassName?: string;
rootClassName?: string;
}
export default function Container({
children,
className,
wrapperClassName,
rootClassName,
...props
}: ContainerProps) {
return (
<div className={twMerge('mx-auto w-full bg-white', wrapperClassName)}>
<div className={twMerge('mx-auto w-full bg-white', rootClassName)}>
<div
className={twMerge(
'mx-auto max-w-7xl bg-white px-5 pt-6 pb-20',

View File

@@ -8,7 +8,6 @@ import Switch from '@/ui/v2/Switch';
import Text from '@/ui/v2/Text';
import Image from 'next/image';
import type { DetailedHTMLProps, HTMLProps, ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export interface SettingsContainerProps
@@ -31,7 +30,7 @@ export interface SettingsContainerProps
/**
* The description for the section.
*/
description: string | ReactNode;
description?: string | ReactNode;
/**
* Link to the documentation.
*
@@ -40,6 +39,8 @@ export interface SettingsContainerProps
docsLink?: string;
/**
* Props for the primary action.
*
* @deprecated Use `slotProps.submitButton` instead.
*/
primaryActionButtonProps?: ButtonProps;
/**
@@ -75,9 +76,26 @@ export interface SettingsContainerProps
*/
className?: string;
/**
* Props to be passed to the Switch component.
* Props to be passed to different slots inside the component.
*/
switchProps?: SwitchProps;
slotProps?: {
/**
* Props to be passed to the root element.
*/
root?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
/**
* Props to be passed to the `<Switch />` component.
*/
switch?: SwitchProps;
/**
* Props to be passed to the footer element.
*/
submitButton?: ButtonProps;
/**
* Props to be passed to the footer element.
*/
footer?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
};
}
export default function SettingsContainer({
@@ -94,14 +112,15 @@ export default function SettingsContainer({
switchId,
showSwitch = false,
rootClassName,
switchProps,
docsTitle,
slotProps: { root, switch: switchSlot, submitButton, footer } = {},
}: SettingsContainerProps) {
return (
<div
{...root}
className={twMerge(
'grid grid-flow-row gap-4 rounded-lg border-1 border-gray-200 bg-white py-4',
rootClassName,
root?.className || rootClassName,
)}
>
<div className="grid grid-flow-col place-content-between gap-3 px-4">
@@ -131,14 +150,14 @@ export default function SettingsContainer({
checked={enabled}
onChange={(e) => onEnabledChange(e.target.checked)}
className="self-center"
{...switchProps}
{...switchSlot}
/>
)}
{switchId && showSwitch && (
<ControlledSwitch
className="self-center"
name={switchId}
{...switchProps}
{...switchSlot}
/>
)}
</div>
@@ -148,9 +167,11 @@ export default function SettingsContainer({
</div>
<div
{...footer}
className={twMerge(
'grid grid-flow-col items-center gap-x-2 border-t border-gray-200 px-4 pt-3.5',
docsLink ? 'place-content-between' : 'justify-end',
footer?.className,
)}
>
{docsLink && (
@@ -173,11 +194,17 @@ export default function SettingsContainer({
<Button
variant={
primaryActionButtonProps?.disabled ? 'outlined' : 'contained'
(submitButton || primaryActionButtonProps)?.disabled
? 'outlined'
: 'contained'
}
color={
(submitButton || primaryActionButtonProps)?.disabled
? 'secondary'
: 'primary'
}
color={primaryActionButtonProps?.disabled ? 'secondary' : 'primary'}
type="submit"
{...primaryActionButtonProps}
{...(submitButton || primaryActionButtonProps)}
>
{submitButtonText}
</Button>

View File

@@ -1,3 +1,4 @@
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import type { ProjectLayoutProps } from '@/components/layout/ProjectLayout';
import ProjectLayout from '@/components/layout/ProjectLayout';
import type { SettingsSidebarProps } from '@/components/settings/SettingsSidebar';
@@ -33,8 +34,8 @@ export default function SettingsLayout({
{...sidebarProps}
/>
<div className="flex w-full flex-auto flex-col overflow-x-hidden">
{children}
<div className="flex w-full flex-auto flex-col overflow-x-hidden bg-[#fafafa]">
<RetryableErrorBoundary>{children}</RetryableErrorBoundary>
</div>
</ProjectLayout>
);

View File

@@ -0,0 +1,185 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BaseEnvironmentVariableFormValues {
/**
* Identifier of the environment variable.
*/
id: string;
/**
* The name of the role.
*/
name: string;
/**
* Development environment variable value.
*/
devValue: string;
/**
* Production environment variable value.
*/
prodValue: string;
}
export interface BaseEnvironmentVariableFormProps {
/**
* Determines the mode of the form.
*
* @default 'edit'
*/
mode?: 'edit' | 'create';
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: BaseEnvironmentVariableFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Submit button text.
*
* @default 'Save'
*/
submitButtonText?: string;
}
export const baseEnvironmentVariableFormValidationSchema = Yup.object({
name: Yup.string()
.required('This field is required.')
.test(
'isEnvVarPermitted',
'This is a reserved name.',
(value) =>
![
'PATH',
'NODE_PATH',
'PYTHONPATH',
'GEM_PATH',
'HOSTNAME',
'TERM',
'NODE_VERSION',
'YARN_VERSION',
'NODE_ENV',
'HOME',
].includes(value),
)
.test(
'isEnvVarPrefixPermitted',
`The name can't start with NHOST_, HASURA_, AUTH_, STORAGE_ or POSTGRES_.`,
(value) =>
['NHOST_', 'HASURA_', 'AUTH_', 'STORAGE_', 'POSTGRES_'].every(
(prefix) => !value.startsWith(prefix),
),
)
.test('isEnvVarValid', `The name must start with a letter.`, (value) =>
/^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
),
devValue: Yup.string().required('This field is required.'),
prodValue: Yup.string().required('This field is required.'),
});
export default function BaseEnvironmentVariableForm({
mode = 'edit',
onSubmit,
onCancel,
submitButtonText = 'Save',
}: BaseEnvironmentVariableFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BaseEnvironmentVariableFormValues>();
const {
register,
formState: { errors, dirtyFields, isSubmitting },
} = form;
// react-hook-form's isDirty gets true even if an input field is focused, then
// immediately unfocused - we can't rely on that information
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-6 px-6 pb-6">
<Text variant="subtitle1" component="span">
Environment Variables are made available to all your services. All
values are encrypted.
</Text>
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input
{...register('name', {
onChange: (event) => {
if (
event.target.value &&
!/^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/g.test(event.target.value)
) {
// we need to prevent invalid characters from being entered
// eslint-disable-next-line no-param-reassign
event.target.value = event.target.value.replace(
/[^a-zA-Z0-9_]/g,
'',
);
}
},
})}
inputProps={{ maxLength: 100 }}
id="name"
label="Name"
placeholder="EXAMPLE_NAME"
hideEmptyHelperText
error={!!errors.name}
helperText={errors?.name?.message}
fullWidth
autoComplete="off"
autoFocus={mode === 'create'}
disabled={mode === 'edit'}
/>
<Input
{...register('prodValue')}
inputProps={{ maxLength: 100 }}
id="prodValue"
label="Production Value"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.prodValue}
helperText={errors?.prodValue?.message}
fullWidth
autoComplete="off"
autoFocus={mode === 'edit'}
/>
<Input
{...register('devValue')}
inputProps={{ maxLength: 100 }}
id="devValue"
label="Development Value"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.devValue}
helperText={errors?.devValue?.message}
fullWidth
autoComplete="off"
/>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
{submitButtonText}
</Button>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Form>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './BaseEnvironmentVariableForm';
export { default } from './BaseEnvironmentVariableForm';

View File

@@ -0,0 +1,117 @@
import type {
BaseEnvironmentVariableFormProps,
BaseEnvironmentVariableFormValues,
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
import BaseEnvironmentVariableForm, {
baseEnvironmentVariableFormValidationSchema,
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetEnvironmentVariablesQuery,
useInsertEnvironmentVariablesMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface CreateEnvironmentVariableFormProps
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function CreateEnvironmentVariableForm({
onSubmit,
...props
}: CreateEnvironmentVariableFormProps) {
const form = useForm<BaseEnvironmentVariableFormValues>({
defaultValues: {
name: '',
devValue: '',
prodValue: '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
});
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: {
id: currentApplication?.id,
},
fetchPolicy: 'cache-only',
});
const [insertEnvironmentVariables] = useInsertEnvironmentVariablesMutation({
refetchQueries: ['getEnvironmentVariables'],
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading environment variables..."
/>
);
}
if (error) {
throw error;
}
const { setError } = form;
async function handleSubmit({
name,
prodValue,
devValue,
}: BaseEnvironmentVariableFormValues) {
if (
data?.environmentVariables?.some(
(environmentVariable) => environmentVariable.name === name,
)
) {
setError('name', {
message: 'This environment variable already exists.',
});
return;
}
const insertEnvironmentVariablePromise = insertEnvironmentVariables({
variables: {
environmentVariables: [
{ appId: currentApplication.id, name, prodValue, devValue },
],
},
});
await toast.promise(
insertEnvironmentVariablePromise,
{
loading: 'Creating environment variable...',
success: 'Environment variable has been created successfully.',
error: 'An error occurred while creating the environment variable.',
},
toastStyleProps,
);
onSubmit?.();
}
return (
<FormProvider {...form}>
<BaseEnvironmentVariableForm
mode="create"
submitButtonText="Create"
onSubmit={handleSubmit}
{...props}
/>
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './CreateEnvironmentVariableForm';
export { default } from './CreateEnvironmentVariableForm';

View File

@@ -0,0 +1,124 @@
import type {
BaseEnvironmentVariableFormProps,
BaseEnvironmentVariableFormValues,
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
import BaseEnvironmentVariableForm, {
baseEnvironmentVariableFormValidationSchema,
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { EnvironmentVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetEnvironmentVariablesQuery,
useUpdateEnvironmentVariableMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface EditEnvironmentVariableFormProps
extends Pick<BaseEnvironmentVariableFormProps, 'onCancel'> {
/**
* The environment variable to edit.
*/
originalEnvironmentVariable: EnvironmentVariable;
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function EditEnvironmentVariableForm({
originalEnvironmentVariable,
onSubmit,
...props
}: EditEnvironmentVariableFormProps) {
const form = useForm<BaseEnvironmentVariableFormValues>({
defaultValues: {
id: originalEnvironmentVariable.id || '',
name: originalEnvironmentVariable.name || '',
devValue: originalEnvironmentVariable.devValue || '',
prodValue: originalEnvironmentVariable.prodValue || '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
});
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: {
id: currentApplication?.id,
},
fetchPolicy: 'cache-only',
});
const [updateEnvironmentVariable] = useUpdateEnvironmentVariableMutation({
refetchQueries: ['getEnvironmentVariables'],
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading environment variables..."
/>
);
}
if (error) {
throw error;
}
const { setError } = form;
async function handleSubmit({
id,
name,
prodValue,
devValue,
}: BaseEnvironmentVariableFormValues) {
if (
data?.environmentVariables?.some(
(environmentVariable) =>
environmentVariable.name === name &&
environmentVariable.name !== originalEnvironmentVariable.name,
)
) {
setError('name', {
message: 'This environment variable already exists.',
});
return;
}
const updateEnvironmentVariablePromise = updateEnvironmentVariable({
variables: {
id,
environmentVariable: {
prodValue,
devValue,
},
},
});
await toast.promise(
updateEnvironmentVariablePromise,
{
loading: 'Updating environment variable...',
success: 'Environment variable has been updated successfully.',
error: 'An error occurred while updating the environment variable.',
},
toastStyleProps,
);
onSubmit?.();
}
return (
<FormProvider {...form}>
<BaseEnvironmentVariableForm onSubmit={handleSubmit} {...props} />
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './EditEnvironmentVariableForm';
export { default } from './EditEnvironmentVariableForm';

View File

@@ -0,0 +1,234 @@
import { useDialog } from '@/components/common/DialogProvider';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { EnvironmentVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useDeleteEnvironmentVariableMutation,
useGetEnvironmentVariablesQuery,
} from '@/utils/__generated__/graphql';
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface PermissionVariableSettingsFormValues {
/**
* Permission variables.
*/
environmentVariables: EnvironmentVariable[];
}
export default function EnvironmentVariableSettings() {
const { openDialog, openAlertDialog } = useDialog();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: {
id: currentApplication?.id,
},
});
const [deleteEnvironmentVariable] = useDeleteEnvironmentVariableMutation({
refetchQueries: ['getEnvironmentVariables'],
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading environment variables..."
/>
);
}
if (error) {
throw error;
}
async function handleDeleteVariable({ id }: EnvironmentVariable) {
const deleteEnvironmentVariablePromise = deleteEnvironmentVariable({
variables: {
id,
},
});
await toast.promise(
deleteEnvironmentVariablePromise,
{
loading: 'Deleting environment variable...',
success: 'Environment variable has been deleted successfully.',
error: 'An error occurred while deleting the environment variable.',
},
toastStyleProps,
);
}
function handleOpenCreator() {
openDialog('CREATE_ENVIRONMENT_VARIABLE', {
title: 'Create Environment Variable',
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'gap-2 max-w-sm' },
},
});
}
function handleOpenEditor(originalVariable: EnvironmentVariable) {
openDialog('EDIT_ENVIRONMENT_VARIABLE', {
title: 'Edit Environment Variables',
payload: { originalEnvironmentVariable: originalVariable },
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'gap-2 max-w-sm' },
},
});
}
function handleConfirmDelete(originalVariable: EnvironmentVariable) {
openAlertDialog({
title: 'Delete Environment Variable',
payload: (
<Text>
Are you sure you want to delete the &quot;
<strong>{originalVariable.name}</strong>&quot; environment variable?
This cannot be undone.
</Text>
),
props: {
primaryButtonColor: 'error',
primaryButtonText: 'Delete',
onPrimaryAction: () => handleDeleteVariable(originalVariable),
},
});
}
const availableEnvironmentVariables =
[...data.environmentVariables].sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
) || [];
return (
<SettingsContainer
title="Project Environment Variables"
description="Environment Variables are key-value pairs configured outside your source code. They are used to store environment-specific values such as API keys."
docsLink="https://docs.nhost.io/platform/environment-variables"
docsTitle="Environment Variables"
rootClassName="gap-0"
className="px-0 my-2"
slotProps={{ submitButton: { className: 'hidden' } }}
>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-2 border-b-1 border-gray-200 px-4 py-3">
<Text className="font-medium">Variable Name</Text>
<Text className="font-medium lg:col-span-2">Updated</Text>
</div>
<div className="grid grid-flow-row gap-2">
<List>
{availableEnvironmentVariables.map((environmentVariable, index) => {
const timestamp = formatDistanceToNowStrict(
parseISO(environmentVariable.updatedAt),
{ addSuffix: true, roundingMethod: 'floor' },
);
return (
<Fragment key={environmentVariable.id}>
<ListItem.Root
className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2"
secondaryAction={
<Dropdown.Root>
<Dropdown.Trigger
asChild
hideChevron
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<IconButton variant="borderless" color="secondary">
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item
onClick={() => handleOpenEditor(environmentVariable)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
onClick={() =>
handleConfirmDelete(environmentVariable)
}
>
<Text
className="font-medium"
sx={{
color: (theme) => theme.palette.error.main,
}}
>
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text className="truncate">
{environmentVariable.name}
</ListItem.Text>
<Text variant="subtitle1" className="lg:col-span-2 truncate">
{timestamp === '0 seconds ago' ||
timestamp === 'in 0 seconds'
? 'Now'
: timestamp}
</Text>
</ListItem.Root>
<Divider
component="li"
className={twMerge(
index === availableEnvironmentVariables.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
);
})}
</List>
<Button
className="justify-self-start mx-4"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
>
Create Environment Variable
</Button>
</div>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,2 @@
export * from './EnvironmentVariableSettings';
export { default } from './EnvironmentVariableSettings';

View File

@@ -0,0 +1,209 @@
import { useDialog } from '@/components/common/DialogProvider';
import InlineCode from '@/components/common/InlineCode';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useAppClient } from '@/hooks/useAppClient';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import Divider from '@/ui/v2/Divider';
import IconButton from '@/ui/v2/IconButton';
import EyeIcon from '@/ui/v2/icons/EyeIcon';
import EyeOffIcon from '@/ui/v2/icons/EyeOffIcon';
import Input from '@/ui/v2/Input';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import { LOCAL_HASURA_URL } from '@/utils/env';
import { generateRemoteAppUrl } from '@/utils/helpers';
import { useGetAppInjectedVariablesQuery } from '@/utils/__generated__/graphql';
import { Fragment, useState } from 'react';
export default function SystemEnvironmentVariableSettings() {
const [showAdminSecret, setShowAdminSecret] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const { openAlertDialog } = useDialog();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetAppInjectedVariablesQuery({
variables: { id: currentApplication?.id },
});
const appClient = useAppClient({ start: false });
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading system environment variables..."
/>
);
}
if (error) {
throw error;
}
function showJwtSecret() {
openAlertDialog({
title: 'Auth JWT Secret',
payload: (
<div className="grid grid-flow-row gap-2">
<Text variant="subtitle2">
This is the key used for generating JWTs. It&apos;s HMAC-SHA-based
and the same as configured in Hasura.
</Text>
<Input
defaultValue={data?.app?.hasuraGraphqlJwtSecret}
disabled
fullWidth
multiline
minRows={5}
hideEmptyHelperText
inputProps={{ className: 'font-mono' }}
/>
</div>
),
props: {
hidePrimaryAction: true,
secondaryButtonText: 'Close',
},
});
}
const hasuraUrl =
process.env.NEXT_PUBLIC_ENV === 'dev'
? LOCAL_HASURA_URL
: generateRemoteAppUrl(currentApplication.subdomain);
const systemEnvironmentVariables = [
{
key: 'NHOST_BACKEND_URL',
value: generateRemoteAppUrl(currentApplication.subdomain),
},
{ key: 'NHOST_SUBDOMAIN', value: currentApplication.subdomain },
{ key: 'NHOST_REGION', value: currentApplication.region.awsName },
{ key: 'NHOST_HASURA_URL', value: `${hasuraUrl}/console` },
{ key: 'NHOST_AUTH_URL', value: appClient.auth.url },
{ key: 'NHOST_GRAPHQL_URL', value: appClient.graphql.url },
{ key: 'NHOST_STORAGE_URL', value: appClient.storage.url },
{ key: 'NHOST_FUNCTIONS_URL', value: appClient.functions.url },
];
return (
<SettingsContainer
title="System Environment Variables"
description="Environment Variables are key-value pairs configured outside your source code. They are used to store environment-specific values such as API keys."
docsLink="https://docs.nhost.io/platform/environment-variables#system-environment-variables"
rootClassName="gap-0"
className="px-0 mt-2 mb-2.5"
slotProps={{ submitButton: { className: 'invisible' } }}
>
<div className="grid grid-cols-3 border-b-1 gap-2 border-gray-200 px-4 py-3">
<Text className="font-medium">Variable Name</Text>
<Text className="font-medium lg:col-span-2">Value</Text>
</div>
<List>
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
<ListItem.Text>NHOST_ADMIN_SECRET</ListItem.Text>
<div className="grid grid-flow-col lg:col-span-2 gap-2 items-center justify-start">
<Text className="text-greyscaleGreyDark truncate">
{showAdminSecret ? (
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]">
{currentApplication?.hasuraGraphqlAdminSecret}
</InlineCode>
) : (
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
)}
</Text>
<IconButton
variant="borderless"
color="secondary"
aria-label={
showAdminSecret ? 'Hide Admin Secret' : 'Show Admin Secret'
}
onClick={() => setShowAdminSecret((show) => !show)}
>
{showAdminSecret ? (
<EyeOffIcon className="w-5 h-5" />
) : (
<EyeIcon className="w-5 h-5" />
)}
</IconButton>
</div>
</ListItem.Root>
<Divider component="li" className="!my-4" />
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
<ListItem.Text>NHOST_WEBHOOK_SECRET</ListItem.Text>
<div className="grid grid-flow-col gap-2 lg:col-span-2 items-center justify-start">
<Text className="text-greyscaleGreyDark truncate">
{showWebhookSecret ? (
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]">
{data?.app?.webhookSecret}
</InlineCode>
) : (
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
)}
</Text>
<IconButton
variant="borderless"
color="secondary"
aria-label={
showWebhookSecret
? 'Hide Webhook Secret'
: 'Show Webhook Secret'
}
onClick={() => setShowWebhookSecret((show) => !show)}
>
{showWebhookSecret ? (
<EyeOffIcon className="w-5 h-5" />
) : (
<EyeIcon className="w-5 h-5" />
)}
</IconButton>
</div>
</ListItem.Root>
<Divider component="li" className="!my-4" />
{systemEnvironmentVariables.map((environmentVariable, index) => (
<Fragment key={environmentVariable.key}>
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
<ListItem.Text>{environmentVariable.key}</ListItem.Text>
<Text className="truncate lg:col-span-2">
{environmentVariable.value}
</Text>
</ListItem.Root>
{index !== systemEnvironmentVariables.length - 1 && (
<Divider className="!my-4" />
)}
</Fragment>
))}
<Divider component="li" className="!mt-4 !mb-2.5" />
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 justify-start">
<ListItem.Text>NHOST_JWT_SECRET</ListItem.Text>
<Button
variant="borderless"
onClick={showJwtSecret}
size="small"
className="justify-self-start"
>
Show JWT Secret
</Button>
</ListItem.Root>
</List>
</SettingsContainer>
);
}

View File

@@ -0,0 +1 @@
export { default } from './SystemEnvironmentVariableSettings';

View File

@@ -0,0 +1,141 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BasePermissionVariableFormValues {
/**
* Permission variable key.
*/
key: string;
/**
* Permission variable value.
*/
value: string;
}
export interface BasePermissionVariableFormProps {
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: BasePermissionVariableFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Submit button text.
*
* @default 'Save'
*/
submitButtonText?: string;
}
export const basePermissionVariableValidationSchema = Yup.object({
key: Yup.string().required('This field is required.'),
value: Yup.string().required('This field is required.'),
});
export default function BasePermissionVariableForm({
onSubmit,
onCancel,
submitButtonText = 'Save',
}: BasePermissionVariableFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BasePermissionVariableFormValues>();
const {
register,
formState: { dirtyFields, errors, isSubmitting },
} = form;
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-2 px-6 pb-6">
<Text variant="subtitle1" component="span">
Enter the field name and the path you want to use in this permission
variable.
</Text>
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input
{...register('key', {
onChange: (event) => {
if (
event.target.value &&
!/^[a-zA-Z-]+$/gi.test(event.target.value)
) {
// we need to prevent invalid characters from being entered
// eslint-disable-next-line no-param-reassign
event.target.value = event.target.value.replace(
/[^a-zA-Z-]/gi,
'',
);
}
},
})}
id="key"
label="Field Name"
hideEmptyHelperText
error={!!errors.key}
helperText={errors?.key?.message}
fullWidth
autoComplete="off"
autoFocus
slotProps={{ input: { className: '!pl-px' } }}
startAdornment={
<Text className="shrink-0 pl-2 text-greyscaleGrey">X-Hasura-</Text>
}
/>
<Input
{...register('value', {
onChange: (event) => {
if (
event.target.value &&
!/^[a-zA-Z-_.[\]]+$/gi.test(event.target.value)
) {
// we need to prevent invalid characters from being entered
// eslint-disable-next-line no-param-reassign
event.target.value = event.target.value.replace(
/[^a-zA-Z-.[\]]/gi,
'',
);
}
},
})}
id="value"
label="Path"
hideEmptyHelperText
error={!!errors.value}
helperText={errors?.value?.message}
fullWidth
autoComplete="off"
slotProps={{ input: { className: '!pl-px' } }}
startAdornment={
<Text className="shrink-0 pl-2 text-greyscaleGrey">user.</Text>
}
/>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
{submitButtonText}
</Button>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Form>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './BasePermissionVariableForm';
export { default } from './BasePermissionVariableForm';

View File

@@ -0,0 +1,119 @@
import type {
BasePermissionVariableFormProps,
BasePermissionVariableFormValues,
} from '@/components/settings/permissions/BasePermissionVariableForm';
import BasePermissionVariableForm, {
basePermissionVariableValidationSchema,
} from '@/components/settings/permissions/BasePermissionVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAppCustomClaimsQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface CreatePermissionVariableFormProps
extends Pick<BasePermissionVariableFormProps, 'onCancel'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function CreatePermissionVariableForm({
onSubmit,
...props
}: CreatePermissionVariableFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, error, loading } = useGetAppCustomClaimsQuery({
variables: { id: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const form = useForm<BasePermissionVariableFormValues>({
defaultValues: {
key: '',
value: '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(basePermissionVariableValidationSchema),
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getAppCustomClaims'],
});
if (loading) {
return (
<ActivityIndicator delay={1000} label="Loading permission variables..." />
);
}
if (error) {
throw error;
}
const { setError } = form;
const availablePermissionVariables = getPermissionVariablesArray(
data?.app?.authJwtCustomClaims,
);
async function handleSubmit({
key,
value,
}: BasePermissionVariableFormValues) {
if (
availablePermissionVariables.some(
(permissionVariable) => permissionVariable.key === key,
)
) {
setError('key', { message: 'This key is already in use.' });
return;
}
const permissionVariablesObject = getPermissionVariablesObject(
availablePermissionVariables.filter(
(permissionVariable) => !permissionVariable.isSystemClaim,
),
);
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authJwtCustomClaims: {
...permissionVariablesObject,
[key]: value,
},
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Creating permission variable...',
success: 'Permission variable has been created successfully.',
error:
'An error occurred while trying to create the permission variable.',
},
toastStyleProps,
);
await onSubmit?.();
}
return (
<FormProvider {...form}>
<BasePermissionVariableForm onSubmit={handleSubmit} {...props} />
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './CreatePermissionVariableForm';
export { default } from './CreatePermissionVariableForm';

View File

@@ -0,0 +1,142 @@
import type {
BasePermissionVariableFormProps,
BasePermissionVariableFormValues,
} from '@/components/settings/permissions/BasePermissionVariableForm';
import BasePermissionVariableForm, {
basePermissionVariableValidationSchema,
} from '@/components/settings/permissions/BasePermissionVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { CustomClaim } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getPermissionVariables from '@/utils/settings/getPermissionVariablesArray';
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAppCustomClaimsQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface EditPermissionVariableFormProps
extends Pick<BasePermissionVariableFormProps, 'onCancel'> {
/**
* The permission variable to be edited.
*/
originalVariable: CustomClaim;
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function EditPermissionVariableForm({
originalVariable,
onSubmit,
...props
}: EditPermissionVariableFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, error, loading } = useGetAppCustomClaimsQuery({
variables: { id: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const form = useForm<BasePermissionVariableFormValues>({
defaultValues: {
key: originalVariable.key || '',
value: originalVariable.value || '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(basePermissionVariableValidationSchema),
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getAppCustomClaims'],
});
if (loading) {
return (
<ActivityIndicator delay={1000} label="Loading permission variables..." />
);
}
if (error) {
throw error;
}
const { setError } = form;
const availablePermissionVariables = getPermissionVariables(
data?.app?.authJwtCustomClaims,
);
async function handleSubmit({
key,
value,
}: BasePermissionVariableFormValues) {
if (
availablePermissionVariables.some(
(permissionVariable) =>
permissionVariable.key === key &&
permissionVariable.key !== originalVariable.key,
)
) {
setError('key', { message: 'This key is already in use.' });
return;
}
const originalPermissionVariableIndex =
availablePermissionVariables.findIndex(
(permissionVariable) => permissionVariable.key === originalVariable.key,
);
const updatedPermissionVariables = availablePermissionVariables.map(
(permissionVariable, index) => {
if (index === originalPermissionVariableIndex) {
return { key, value };
}
return permissionVariable;
},
);
const permissionVariablesObject = getPermissionVariablesObject(
updatedPermissionVariables.filter(
(permissionVariable) => !permissionVariable.isSystemClaim,
),
);
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authJwtCustomClaims: {
...permissionVariablesObject,
[key]: value,
},
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating permission variable...',
success: 'Permission variable has been updated successfully.',
error:
'An error occurred while trying to update the permission variable.',
},
toastStyleProps,
);
await onSubmit?.();
}
return (
<FormProvider {...form}>
<BasePermissionVariableForm onSubmit={handleSubmit} {...props} />
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './EditPermissionVariableForm';
export { default } from './EditPermissionVariableForm';

View File

@@ -0,0 +1,249 @@
import { useDialog } from '@/components/common/DialogProvider';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { CustomClaim } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import LockIcon from '@/ui/v2/icons/LockIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import Tooltip from '@/ui/v2/Tooltip';
import getPermissionVariables from '@/utils/settings/getPermissionVariablesArray';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAppCustomClaimsQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface PermissionVariableSettingsFormValues {
/**
* Permission variables.
*/
authJwtCustomClaims: CustomClaim[];
}
export default function PermissionVariableSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, openAlertDialog } = useDialog();
const { data, loading, error } = useGetAppCustomClaimsQuery({
variables: {
id: currentApplication?.id,
},
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getAppCustomClaims'],
});
if (loading) {
return (
<ActivityIndicator delay={1000} label="Loading permission variables..." />
);
}
if (error) {
throw error;
}
async function handleDeleteVariable({ key }: CustomClaim) {
const filteredCustomClaims = Object.keys(
data?.app?.authJwtCustomClaims,
).filter((customClaimKey) => customClaimKey !== key);
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authJwtCustomClaims: filteredCustomClaims.reduce(
(customClaims, currentKey) => ({
...customClaims,
[currentKey]: data?.app?.authJwtCustomClaims[currentKey],
}),
{},
),
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Deleting permission variable...',
success: 'Permission variable has been deleted successfully.',
error: 'An error occurred while trying to delete permission variable.',
},
toastStyleProps,
);
}
function handleOpenCreator() {
openDialog('CREATE_PERMISSION_VARIABLE', {
title: 'Create Permission Variable',
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleOpenEditor(originalVariable: CustomClaim) {
openDialog('EDIT_PERMISSION_VARIABLE', {
title: 'Edit Permission Variable',
payload: { originalVariable },
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleConfirmDelete(originalVariable: CustomClaim) {
openAlertDialog({
title: 'Delete Permission Variable',
payload: (
<Text>
Are you sure you want to delete the &quot;
<strong>X-Hasura-{originalVariable.key}</strong>&quot; permission
variable? This cannot be undone.
</Text>
),
props: {
onPrimaryAction: () => handleDeleteVariable(originalVariable),
primaryButtonColor: 'error',
primaryButtonText: 'Delete',
},
});
}
const availablePermissionVariables = getPermissionVariables(
data?.app?.authJwtCustomClaims,
);
return (
<SettingsContainer
title="Permission Variables"
description="Permission variables are used to define permission rules in the GraphQL API."
docsLink="https://docs.nhost.io/graphql/permissions"
rootClassName="gap-0"
className="px-0 my-2"
slotProps={{ submitButton: { className: 'invisible' } }}
>
<div className="grid grid-cols-2 border-b-1 border-gray-200 px-4 py-3">
<Text className="font-medium">Field name</Text>
<Text className="font-medium">Path</Text>
</div>
<div className="grid grid-flow-row gap-2">
<List>
{availablePermissionVariables.map((customClaim, index) => (
<Fragment key={customClaim.key}>
<ListItem.Root
className="px-4 grid grid-cols-2"
secondaryAction={
<Dropdown.Root>
<Tooltip
title={
customClaim.isSystemClaim
? "You can't edit system permission variables"
: ''
}
placement="right"
disableHoverListener={!customClaim.isSystemClaim}
hasDisabledChildren={customClaim.isSystemClaim}
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<Dropdown.Trigger asChild hideChevron>
<IconButton
variant="borderless"
color="secondary"
disabled={customClaim.isSystemClaim}
>
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
</Tooltip>
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item
onClick={() => handleOpenEditor(customClaim)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
onClick={() => handleConfirmDelete(customClaim)}
>
<Text
className="font-medium"
sx={{
color: (theme) => theme.palette.error.main,
}}
>
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text
primary={
<>
X-Hasura-{customClaim.key}{' '}
{customClaim.isSystemClaim && (
<LockIcon className="w-4 h-4" />
)}
</>
}
/>
<Text className="font-medium">user.{customClaim.value}</Text>
</ListItem.Root>
<Divider
component="li"
className={twMerge(
index === availablePermissionVariables.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
))}
</List>
<Button
className="justify-self-start mx-4"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
>
Create Permission Variable
</Button>
</div>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,2 @@
export * from './PermissionVariableSettings';
export { default } from './PermissionVariableSettings';

View File

@@ -0,0 +1,90 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BaseRoleFormValues {
/**
* The name of the role.
*/
name: string;
}
export interface BaseRoleFormProps {
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: BaseRoleFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Submit button text.
*
* @default 'Save'
*/
submitButtonText?: string;
}
export const baseRoleFormValidationSchema = Yup.object({
name: Yup.string().required('This field is required.'),
});
export default function BaseRoleForm({
onSubmit,
onCancel,
submitButtonText = 'Save',
}: BaseRoleFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BaseRoleFormValues>();
const {
register,
formState: { errors, dirtyFields, isSubmitting },
} = form;
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-2 px-6 pb-6">
<Text variant="subtitle1" component="span">
Enter the name for the role below.
</Text>
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input
{...register('name')}
inputProps={{ maxLength: 100 }}
id="name"
label="Name"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.name}
helperText={errors?.name?.message}
fullWidth
autoComplete="off"
autoFocus
/>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
{submitButtonText}
</Button>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Form>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './BaseRoleForm';
export { default } from './BaseRoleForm';

View File

@@ -0,0 +1,95 @@
import type {
BaseRoleFormProps,
BaseRoleFormValues,
} from '@/components/settings/roles/BaseRoleForm';
import BaseRoleForm, {
baseRoleFormValidationSchema,
} from '@/components/settings/roles/BaseRoleForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetRolesQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface CreateRoleFormProps
extends Pick<BaseRoleFormProps, 'onCancel'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function CreateRoleForm({
onSubmit,
...props
}: CreateRoleFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetRolesQuery({
variables: { id: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const form = useForm<BaseRoleFormValues>({
defaultValues: {},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseRoleFormValidationSchema),
});
const [updateApp] = useUpdateAppMutation({ refetchQueries: ['getRoles'] });
if (loading) {
return <ActivityIndicator delay={1000} label="Loading roles..." />;
}
if (error) {
throw error;
}
const { setError } = form;
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
async function handleSubmit({ name }: BaseRoleFormValues) {
if (availableRoles.some((role) => role.name === name)) {
setError('name', { message: 'This role already exists.' });
return;
}
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultAllowedRoles: `${data?.app?.authUserDefaultAllowedRoles},${name}`,
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Creating role...',
success: 'Role has been created successfully.',
error: 'An error occurred while trying to create the role.',
},
toastStyleProps,
);
await onSubmit?.();
}
return (
<FormProvider {...form}>
<BaseRoleForm
submitButtonText="Create"
onSubmit={handleSubmit}
{...props}
/>
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './CreateRoleForm';
export { default } from './CreateRoleForm';

View File

@@ -0,0 +1,125 @@
import type {
BaseRoleFormProps,
BaseRoleFormValues,
} from '@/components/settings/roles/BaseRoleForm';
import BaseRoleForm, {
baseRoleFormValidationSchema,
} from '@/components/settings/roles/BaseRoleForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Role } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetRolesQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface EditRoleFormProps extends Pick<BaseRoleFormProps, 'onCancel'> {
/**
* The role to be edited.
*/
originalRole: Role;
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function EditRoleForm({
originalRole,
onSubmit,
...props
}: EditRoleFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetRolesQuery({
variables: { id: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const form = useForm<BaseRoleFormValues>({
defaultValues: {
name: originalRole.name || '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseRoleFormValidationSchema),
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getRoles'],
});
if (loading) {
return <ActivityIndicator delay={1000} label="Loading roles..." />;
}
if (error) {
throw error;
}
const { setError } = form;
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
async function handleSubmit({ name }: BaseRoleFormValues) {
if (
availableRoles.some(
(role) => role.name === name && role.name !== originalRole.name,
)
) {
setError('name', { message: 'This role already exists.' });
return;
}
const defaultAllowedRolesList =
data?.app?.authUserDefaultAllowedRoles.split(',') || [];
const originalRoleIndex = defaultAllowedRolesList.findIndex(
(role) => role.trim() === originalRole.name,
);
const updatedDefaultAllowedRoles = defaultAllowedRolesList
.map((role, index) => {
if (index === originalRoleIndex) {
return name;
}
return role;
})
.join(',');
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultRole:
data?.app?.authUserDefaultRole === originalRole.name
? name
: data?.app?.authUserDefaultRole,
authUserDefaultAllowedRoles: updatedDefaultAllowedRoles,
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating role...',
success: 'Role has been updated successfully.',
error: 'An error occurred while trying to update the role.',
},
toastStyleProps,
);
await onSubmit?.();
}
return (
<FormProvider {...form}>
<BaseRoleForm onSubmit={handleSubmit} {...props} />
</FormProvider>
);
}

View File

@@ -0,0 +1,2 @@
export * from './EditRoleForm';
export { default } from './EditRoleForm';

View File

@@ -0,0 +1,270 @@
import { useDialog } from '@/components/common/DialogProvider';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Role } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import LockIcon from '@/ui/v2/icons/LockIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetRolesQuery,
useUpdateAppMutation,
} from '@/utils/__generated__/graphql';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface RoleSettingsFormValues {
/**
* Default role.
*/
authUserDefaultRole: string;
/**
* Allowed roles for the project.
*/
authUserDefaultAllowedRoles: Role[];
}
export default function RoleSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, openAlertDialog } = useDialog();
const { data, loading, error } = useGetRolesQuery({
variables: { id: currentApplication?.id },
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getRoles'],
});
if (loading) {
return <ActivityIndicator delay={1000} label="Loading user roles..." />;
}
if (error) {
throw error;
}
async function handleSetAsDefault({ name }: Role) {
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultRole: name,
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating default role...',
success: 'Default role has been updated successfully.',
error: 'An error occurred while trying to update the default role.',
},
toastStyleProps,
);
}
async function handleDeleteRole({ name }: Role) {
const filteredRoles = data?.app?.authUserDefaultAllowedRoles
.split(',')
.filter((role) => role !== name)
.join(',');
const updateAppPromise = updateApp({
variables: {
id: currentApplication?.id,
app: {
authUserDefaultAllowedRoles: filteredRoles,
authUserDefaultRole:
name === data?.app?.authUserDefaultRole
? 'user'
: data?.app?.authUserDefaultRole,
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Deleting role...',
success: 'Role has been deleted successfully.',
error: 'An error occurred while trying to delete the role.',
},
toastStyleProps,
);
}
function handleOpenCreator() {
openDialog('CREATE_ROLE', {
title: 'Create Role',
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleOpenEditor(originalRole: Role) {
openDialog('EDIT_ROLE', {
title: 'Edit Role',
payload: { originalRole },
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
},
});
}
function handleConfirmDelete(originalRole: Role) {
openAlertDialog({
title: 'Delete Role',
payload: (
<Text>
Are you sure you want to delete the &quot;
<strong>{originalRole.name}</strong>&quot; role? This cannot be
undone.
</Text>
),
props: {
onPrimaryAction: () => handleDeleteRole(originalRole),
primaryButtonColor: 'error',
primaryButtonText: 'Delete',
},
});
}
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles);
return (
<SettingsContainer
title="Roles"
description="Roles are used to control access to your application."
docsLink="https://docs.nhost.io/authentication/users#roles"
rootClassName="gap-0"
className="px-0 my-2"
slotProps={{ submitButton: { className: 'invisible' } }}
>
<div className="border-b-1 border-gray-200 px-4 py-3">
<Text className="font-medium">Name</Text>
</div>
<div className="grid grid-flow-row gap-2">
<List>
{availableRoles.map((role, index) => (
<Fragment key={role.name}>
<ListItem.Root
className="px-4"
secondaryAction={
<Dropdown.Root>
<Dropdown.Trigger
asChild
hideChevron
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<IconButton variant="borderless" color="secondary">
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item onClick={() => handleSetAsDefault(role)}>
<Text className="font-medium">Set as Default</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
disabled={role.isSystemRole}
onClick={() => handleOpenEditor(role)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
disabled={role.isSystemRole}
onClick={() => handleConfirmDelete(role)}
>
<Text
className="font-medium"
sx={{
color: (theme) => theme.palette.error.main,
}}
>
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Text
primaryTypographyProps={{
className:
'inline-grid grid-flow-col gap-1 items-center h-6 font-medium',
}}
primary={
<>
{role.name}
{role.isSystemRole && <LockIcon className="w-4 h-4" />}
{data?.app?.authUserDefaultRole === role.name && (
<Chip
component="span"
color="info"
size="small"
label="Default"
/>
)}
</>
}
/>
</ListItem.Root>
<Divider
component="li"
className={twMerge(
index === availableRoles.length - 1 ? '!mt-4' : '!my-4',
)}
/>
</Fragment>
))}
</List>
<Button
className="justify-self-start mx-4"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
>
Create Role
</Button>
</div>
</SettingsContainer>
);
}

View File

@@ -0,0 +1,2 @@
export * from './RoleSettings';
export { default } from './RoleSettings';

View File

@@ -79,7 +79,7 @@ export default function AnonymousSignInSettings() {
<Form onSubmit={handlePasswordProtectionSettingsChange}>
<SettingsContainer
title="Anonymous Users"
description="Allow users to sign-in anonymously."
description="Allow users to sign in anonymously."
primaryActionButtonProps={{
disabled:
form.formState.isSubmitting ||

View File

@@ -107,7 +107,7 @@ export default function AppleProviderSettings() {
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="Apple"
description="Allows users to sign in with Apple."
description="Allow users to sign in with Apple."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
@@ -168,7 +168,7 @@ export default function AppleProviderSettings() {
<Input
name="redirectUrl"
id="redirectUrl"
placeholder={`${generateRemoteAppUrl(
defaultValue={`${generateRemoteAppUrl(
currentApplication.subdomain,
)}/v1/auth/signin/provider/apple/callback`}
className="col-span-2"

View File

@@ -88,7 +88,7 @@ export default function DiscordProviderSettings() {
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="Discord"
description="Allows users to sign in with Discord."
description="Allow users to sign in with Discord."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
@@ -112,7 +112,7 @@ export default function DiscordProviderSettings() {
fullWidth
hideEmptyHelperText
label="Redirect URL"
value={`${generateRemoteAppUrl(
defaultValue={`${generateRemoteAppUrl(
currentApplication.subdomain,
)}/v1/auth/signin/provider/discord/callback`}
disabled

View File

@@ -23,7 +23,7 @@ export interface EmailAndPasswordFormValues {
authPasswordHibpEnabled: boolean;
}
export default function EmailSettings() {
export default function EmailAndPasswordSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation({
refetchQueries: [GetAppLoginDataDocument],
@@ -61,7 +61,7 @@ export default function EmailSettings() {
const { formState } = form;
const handleEmailSettingsChange = async (
const handleEmailAndPasswordSettingsChange = async (
values: EmailAndPasswordFormValues,
) => {
const updateAppMutation = updateApp({
@@ -90,29 +90,31 @@ export default function EmailSettings() {
return (
<FormProvider {...form}>
<Form onSubmit={handleEmailSettingsChange}>
<Form onSubmit={handleEmailAndPasswordSettingsChange}>
<SettingsContainer
title="Email and Password"
description="Sign in users using email and password."
description="Allow users to sign in with email and password."
docsLink="https://docs.nhost.io/authentication/sign-in-with-email-and-password"
docsTitle="how to sign in users with email and password"
className="grid grid-flow-row"
showSwitch
enabled
switchProps={{ disabled: true }}
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
switch: {
disabled: true,
},
submitButton: {
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
},
}}
>
<ControlledCheckbox
name="authEmailSigninEmailVerifiedRequired"
id="authEmailSigninEmailVerifiedRequired"
label={
<span className="inline-grid grid-flow-row gap-y-[2px] text-[15px]">
<span className="text-[15px] font-medium">
Require Verified Emails
</span>
<span className="inline-grid grid-flow-row gap-y-0.5 text-sm+">
<span className="font-medium">Require Verified Emails</span>
<span className="font-normal text-greyscaleMedium">
Users must verify their email to be able to sign in.
</span>
@@ -124,11 +126,9 @@ export default function EmailSettings() {
name="authPasswordHibpEnabled"
id="authPasswordHibpEnabled"
label={
<span className="inline-grid grid-flow-row gap-y-[2px] text-[15px]">
<span className="text-[15px] font-medium">
Password Protection
</span>
<span className="text-[12px] font-normal text-greyscaleMedium">
<span className="inline-grid grid-flow-row gap-y-0.5 text-sm+">
<span className="font-medium">Password Protection</span>
<span className="font-normal text-greyscaleMedium">
Passwords must pass haveibeenpwned.com during sign-up.
</span>
</span>

View File

@@ -0,0 +1,2 @@
export * from './EmailAndPasswordSettings';
export { default } from './EmailAndPasswordSettings';

View File

@@ -1,2 +0,0 @@
export * from './EmailSettings';
export { default } from './EmailSettings';

View File

@@ -88,7 +88,7 @@ export default function FacebookProviderSettings() {
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="Facebook"
description="Allows users to sign in with Facebook."
description="Allow users to sign in with Facebook."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
@@ -112,7 +112,7 @@ export default function FacebookProviderSettings() {
fullWidth
hideEmptyHelperText
label="Redirect URL"
value={`${generateRemoteAppUrl(
defaultValue={`${generateRemoteAppUrl(
currentApplication.subdomain,
)}/v1/auth/signin/provider/facebook/callback`}
disabled

View File

@@ -88,7 +88,7 @@ export default function GitHubProviderSettings() {
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="GitHub"
description="Allows users to sign in with GitHub."
description="Allow users to sign in with GitHub."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
@@ -112,7 +112,7 @@ export default function GitHubProviderSettings() {
fullWidth
hideEmptyHelperText
label="Redirect URL"
value={`${generateRemoteAppUrl(
defaultValue={`${generateRemoteAppUrl(
currentApplication.subdomain,
)}/v1/auth/signin/provider/github/callback`}
disabled

View File

@@ -88,7 +88,7 @@ export default function GoogleProviderSettings() {
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="Google"
description="Allows users to sign in with Google."
description="Allow users to sign in with Google."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
@@ -112,7 +112,7 @@ export default function GoogleProviderSettings() {
fullWidth
hideEmptyHelperText
label="Redirect URL"
value={`${generateRemoteAppUrl(
defaultValue={`${generateRemoteAppUrl(
currentApplication.subdomain,
)}/v1/auth/signin/provider/google/callback`}
disabled

View File

@@ -88,7 +88,7 @@ export default function LinkedInProviderSettings() {
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="LinkedIn"
description="Allows users to sign in with LinkedIn"
description="Allow users to sign in with LinkedIn."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
@@ -112,7 +112,7 @@ export default function LinkedInProviderSettings() {
fullWidth
hideEmptyHelperText
label="Redirect URL"
value={`${generateRemoteAppUrl(
defaultValue={`${generateRemoteAppUrl(
currentApplication.subdomain,
)}/v1/auth/signin/provider/linkedin/callback`}
disabled

View File

@@ -80,7 +80,7 @@ export default function MagicLinkSettings() {
<Form onSubmit={handleMagicLinkSettingsUpdate}>
<SettingsContainer
title="Magic Link"
description="Allow users to sign-in with a magic link."
description="Allow users to sign in with a magic link."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,

View File

@@ -88,7 +88,7 @@ export default function SpotifyProviderSettings() {
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="Spotify"
description="Allows users to sign in with Spotify."
description="Allow users to sign in with Spotify."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
@@ -112,7 +112,7 @@ export default function SpotifyProviderSettings() {
fullWidth
hideEmptyHelperText
label="Redirect URL"
value={`${generateRemoteAppUrl(
defaultValue={`${generateRemoteAppUrl(
currentApplication.subdomain,
)}/v1/auth/signin/provider/spotify/callback`}
disabled

View File

@@ -88,7 +88,7 @@ export default function TwitchProviderSettings() {
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="Twitch"
description="Allows users to sign in with Twitch."
description="Allow users to sign in with Twitch."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
@@ -112,7 +112,7 @@ export default function TwitchProviderSettings() {
fullWidth
hideEmptyHelperText
label="Redirect URL"
value={`${generateRemoteAppUrl(
defaultValue={`${generateRemoteAppUrl(
currentApplication.subdomain,
)}/v1/auth/signin/provider/twitch/callback`}
disabled

View File

@@ -87,7 +87,7 @@ export default function TwitterProviderSettings() {
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="Twitter"
description="Allows users to sign in with Twitter."
description="Allow users to sign in with Twitter."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
@@ -125,7 +125,7 @@ export default function TwitterProviderSettings() {
<Input
name="redirectUrl"
id="redirectUrl"
placeholder={`${generateRemoteAppUrl(
defaultValue={`${generateRemoteAppUrl(
currentApplication.subdomain,
)}/v1/auth/signin/provider/twitter/callback`}
className="col-span-2"
@@ -133,11 +133,6 @@ export default function TwitterProviderSettings() {
hideEmptyHelperText
label="Redirect URL"
disabled
slotProps={{
input: {
className: 'bg-opacity-5',
},
}}
endAdornment={
<InputAdornment position="end" className="absolute right-2">
<IconButton

View File

@@ -81,7 +81,7 @@ export default function WebAuthnSettings() {
<Form onSubmit={handleWebAuthnSettingsUpdate}>
<SettingsContainer
title="Security Keys"
description="Allow users to sign-in with security keys using WebAuthn."
description="Allow users to sign in with security keys using WebAuthn."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,

View File

@@ -88,7 +88,7 @@ export default function WindowsLiveProviderSettings() {
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="Windows Live"
description="Allows users to sign in with Windows Live."
description="Allow users to sign in with Windows Live."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
@@ -111,7 +111,7 @@ export default function WindowsLiveProviderSettings() {
fullWidth
hideEmptyHelperText
label="Redirect URL"
value={`${generateRemoteAppUrl(
defaultValue={`${generateRemoteAppUrl(
currentApplication.subdomain,
)}/v1/auth/signin/provider/microsoft/callback`}
disabled

View File

@@ -94,11 +94,13 @@ export default function WorkOsProviderSettings() {
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="WorkOS"
description="Allows users to sign in with WorkOS."
description="Allow users to sign in with WorkOS."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
}}
docsLink="https://docs.nhost.io/authentication/sign-in-with-workos"
docsTitle="how to sign in users with WorkOS"
icon="/logos/WorkOs.svg"
switchId="authWorkOsEnabled"
showSwitch
@@ -112,8 +114,8 @@ export default function WorkOsProviderSettings() {
{...register(`authWorkOsClientId`)}
name="authWorkOsClientId"
id="authWorkOsClientId"
label="WorkOS Client ID"
placeholder="WorkOS Client ID"
label="Client ID"
placeholder="Enter your Client ID"
className="col-span-3"
fullWidth
hideEmptyHelperText
@@ -122,28 +124,28 @@ export default function WorkOsProviderSettings() {
{...register('authWorkOsClientSecret')}
name="authWorkOsClientSecret"
id="authWorkOsClientSecret"
label="WorkOS Client Secret"
placeholder="WorkOS Client Secret"
label="Client Secret"
placeholder="Enter your Client Secret"
className="col-span-3"
fullWidth
hideEmptyHelperText
/>
<Input
{...register('authWorkOsDefaultDomain')}
name="authWorkOsDefaultDomain"
id="authWorkOsDefaultDomain"
label="Default Domain"
placeholder="Default Domain"
className="col-span-2"
fullWidth
hideEmptyHelperText
/>
<Input
{...register('authWorkOsDefaultOrganization')}
name="authWorkOsDefaultOrganization"
id="authWorkOsDefaultOrganization"
label="Default Organization"
placeholder="Default Organization"
label="Default Organization ID (optional)"
placeholder="Default Organization ID"
className="col-span-2"
fullWidth
hideEmptyHelperText
/>
<Input
{...register('authWorkOsDefaultDomain')}
name="authWorkOsDefaultDomain"
id="authWorkOsDefaultDomain"
label="Default Domain (optional)"
placeholder="Default Domain"
className="col-span-2"
fullWidth
hideEmptyHelperText
@@ -152,7 +154,7 @@ export default function WorkOsProviderSettings() {
{...register('authWorkOsDefaultConnection')}
name="authWorkOsDefaultConnection"
id="authWorkOsDefaultConnection"
label="Default Connection"
label="Default Connection (optional)"
placeholder="Default Connection"
className="col-span-2"
fullWidth
@@ -161,7 +163,7 @@ export default function WorkOsProviderSettings() {
<Input
name="redirectUrl"
id="redirectUrl"
placeholder={`${generateRemoteAppUrl(
defaultValue={`${generateRemoteAppUrl(
currentApplication.subdomain,
)}/v1/auth/signin/provider/workos/callback`}
className="col-span-6"
@@ -169,11 +171,6 @@ export default function WorkOsProviderSettings() {
hideEmptyHelperText
label="Redirect URL"
disabled
slotProps={{
input: {
className: 'bg-opacity-5',
},
}}
endAdornment={
<InputAdornment position="end" className="absolute right-2">
<IconButton

View File

@@ -60,7 +60,7 @@ const BaseButton = forwardRef(
ref={ref}
sx={[
props.size === 'small' && {
padding: (theme) => theme.spacing(0.5, 0.75),
padding: (theme) => theme.spacing(0.5, 0.5),
},
props.size === 'medium' && {
padding: (theme) => theme.spacing(0.875, 1),

View File

@@ -1,18 +1,26 @@
import { styled } from '@mui/material';
import type { ChipProps as MaterialChipProps } from '@mui/material/Chip';
import MaterialChip from '@mui/material/Chip';
import MaterialChip, { chipClasses } from '@mui/material/Chip';
import type { ElementType } from 'react';
export interface ChipProps extends MaterialChipProps {}
export interface ChipProps extends MaterialChipProps {
/**
* Custom component for the root node.
*/
component?: string | ElementType;
}
const Chip = styled(MaterialChip)(({ theme }) => ({
const Chip = styled(MaterialChip)<ChipProps>(({ theme }) => ({
fontFamily: theme.typography.fontFamily,
fontSize: '0.75rem',
fontSize: theme.typography.pxToRem(12),
lineHeight: theme.typography.pxToRem(16),
fontWeight: 500,
lineHeight: '16px',
padding: theme.spacing(1.5, 0.25),
color: theme.palette.text.primary,
borderRadius: '9999px',
backgroundColor: '#EAEDF0',
padding: theme.spacing(0, 0.25),
[`&.${chipClasses.colorInfo}`]: {
backgroundColor: theme.palette.primary.light,
color: theme.palette.primary.main,
},
}));
Chip.displayName = 'NhostChip';

View File

@@ -32,6 +32,9 @@ const StyledMenu = styled(MaterialMenu)({
[`& .${materialMenuClasses.list}`]: {
padding: 0,
},
[`& .${materialMenuClasses.paper}`]: {
boxShadow: '0px 4px 10px rgba(33, 50, 75, 0.25)',
},
});
function DropdownContent({
@@ -68,8 +71,7 @@ function DropdownContent({
sx: [
{
borderRadius: '0.5rem',
boxShadow:
'0px 1px 4px rgba(14, 24, 39, 0.1), 0px 8px 24px rgba(14, 24, 39, 0.1)',
boxShadow: '0px 4px 10px rgba(33, 50, 75, 0.25)',
fontFamily: (theme) => theme.typography.fontFamily,
},
],

View File

@@ -37,7 +37,7 @@ const StyledListItemButton = styled(MaterialListItemButton)(({ theme }) => ({
padding: theme.spacing(0.75, 1.25),
},
[`&.${listItemButtonClasses.selected}`]: {
backgroundColor: `#ebf3ff`,
backgroundColor: theme.palette.primary.light,
color: theme.palette.primary.main,
},
[`&.${listItemButtonClasses.selected} > .${listItemTextClasses.root}`]: {
@@ -50,7 +50,7 @@ const StyledListItemButton = styled(MaterialListItemButton)(({ theme }) => ({
color: theme.palette.primary.main,
},
[`&.${listItemButtonClasses.selected}:hover`]: {
backgroundColor: `#ebf3ff`,
backgroundColor: theme.palette.primary.light,
},
}));

View File

@@ -8,11 +8,15 @@ export interface ListItemTextProps extends MaterialListItemTextProps {}
const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
color: theme.palette.text.primary,
display: 'grid',
justifyContent: 'start',
gridAutoFlow: 'row',
gap: theme.spacing(0.5),
fontSize: theme.typography.pxToRem(15),
[`&.${listItemTextClasses.root}`]: {
margin: 0,
},
[`& > .${listItemTextClasses.primary}`]: {
fontSize: '0.9375rem',
fontWeight: 500,
textOverflow: 'ellipsis',
overflow: 'hidden',

View File

@@ -0,0 +1,24 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@mui/material/SvgIcon';
function DotsVerticalIcon(props: IconProps) {
return (
<SvgIcon
width="16"
height="16"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-label="Three vertical dots"
{...props}
>
<path
d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 4.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 14.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
fill="currentColor"
/>
</SvgIcon>
);
}
DotsVerticalIcon.displayName = 'NhostDotsVerticalIcon';
export default DotsVerticalIcon;

View File

@@ -0,0 +1 @@
export { default } from './DotsVerticalIcon';

View File

@@ -0,0 +1,36 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@mui/material/SvgIcon';
function EyeIcon(props: IconProps) {
return (
<SvgIcon
width="16"
height="16"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-label="Eye"
{...props}
>
<path
d="M8 3.5C3 3.5 1 8 1 8s2 4.5 7 4.5S15 8 15 8s-2-4.5-7-4.5Z"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 10.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</SvgIcon>
);
}
EyeIcon.displayName = 'NhostEyeIcon';
export default EyeIcon;

View File

@@ -0,0 +1 @@
export { default } from './EyeIcon';

View File

@@ -0,0 +1,44 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@mui/material/SvgIcon';
function EyeOffIcon(props: IconProps) {
return (
<SvgIcon
width="16"
height="16"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-label="Eye crossed out"
{...props}
>
<path
d="m3 2.5 10 11M9.682 9.85a2.5 2.5 0 0 1-3.364-3.7"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4.625 4.287C2.077 5.577 1 8 1 8s2 4.5 7 4.5a7.376 7.376 0 0 0 3.375-.788M13.038 10.569C14.401 9.349 15 8 15 8s-2-4.5-7-4.5c-.433-.001-.865.034-1.292.105"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8.47 5.544a2.502 2.502 0 0 1 2.02 2.22"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</SvgIcon>
);
}
EyeOffIcon.displayName = 'NhostEyeOffIcon';
export default EyeOffIcon;

View File

@@ -0,0 +1 @@
export { default } from './EyeOffIcon';

View File

@@ -0,0 +1,9 @@
query getEnvironmentVariables($id: uuid!) {
environmentVariables(where: { appId: { _eq: $id } }) {
id
name
updatedAt
prodValue
devValue
}
}

View File

@@ -1,13 +0,0 @@
fragment EnvironmentVariable on environmentVariables {
id
name
updatedAt
prodValue
devValue
}
query getEnvironmentVariablesWhere($where: environmentVariables_bool_exp!) {
environmentVariables(where: $where) {
...EnvironmentVariable
}
}

View File

@@ -0,0 +1,2 @@
export * from './useLeaveConfirm';
export { default } from './useLeaveConfirm';

View File

@@ -0,0 +1,41 @@
import { useDialog } from '@/components/common/DialogProvider';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
export interface UseLeaveConfirmProps {
isDirty?: boolean;
}
export default function useLeaveConfirm({ isDirty }: UseLeaveConfirmProps) {
const router = useRouter();
const { openAlertDialog } = useDialog();
const [isConfirmed, setConfirmed] = useState(false);
useEffect(() => {
function onRouteChangeStart(route: string) {
if (!isDirty || isConfirmed) {
return;
}
openAlertDialog({
title: 'Unsaved changes',
payload:
'You have unsaved local changes. Are you sure you want to discard them?',
props: {
primaryButtonColor: 'error',
primaryButtonText: 'Discard',
onPrimaryAction: () => {
setConfirmed(true);
router.push(route);
},
},
});
throw new Error('Route change aborted');
}
router.events.on('routeChangeStart', onRouteChangeStart);
return () => router.events.off('routeChangeStart', onRouteChangeStart);
}, [isConfirmed, isDirty, openAlertDialog, router, router.events]);
}

View File

@@ -7,6 +7,7 @@ import type {
} from '@/types/data-browser';
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
import prepareCreateColumnQuery from './prepareCreateColumnQuery';
export interface CreateColumnMigrationVariables {
@@ -33,30 +34,27 @@ export default async function createColumnMigration({
column,
});
const response = await fetch(
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
{
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
body: JSON.stringify({
dataSource,
skip_execution: false,
name: `alter_table_${schema}_${table}_add_column_${column.name}`,
down: [
getPreparedHasuraQuery(
dataSource,
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I',
schema,
table,
column.name,
),
],
up: args,
}),
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
);
body: JSON.stringify({
dataSource,
skip_execution: false,
name: `alter_table_${schema}_${table}_add_column_${column.name}`,
down: [
getPreparedHasuraQuery(
dataSource,
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I',
schema,
table,
column.name,
),
],
up: args,
}),
});
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
await response.json();

View File

@@ -7,6 +7,7 @@ import type {
} from '@/types/data-browser';
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
import prepareCreateTableQuery from './prepareCreateTableQuery';
export interface CreateTableMigrationVariables {
@@ -27,29 +28,26 @@ export default async function createTableMigration({
}: CreateTableMigrationOptions & CreateTableMigrationVariables) {
const args = prepareCreateTableQuery({ dataSource, schema, table });
const response = await fetch(
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
{
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
body: JSON.stringify({
dataSource,
skip_execution: false,
name: `create_table_${schema}_${table.name}`,
down: [
getPreparedHasuraQuery(
dataSource,
'DROP TABLE IF EXISTS %I.%I',
schema,
table.name,
),
],
up: args,
}),
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
);
body: JSON.stringify({
dataSource,
skip_execution: false,
name: `create_table_${schema}_${table.name}`,
down: [
getPreparedHasuraQuery(
dataSource,
'DROP TABLE IF EXISTS %I.%I',
schema,
table.name,
),
],
up: args,
}),
});
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
await response.json();

View File

@@ -8,6 +8,7 @@ import type {
} from '@/types/data-browser';
import { getPreparedHasuraQuery } from '@/utils/dataBrowser/hasuraQueryHelpers';
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
export interface DeleteColumnMigrationVariables {
/**
@@ -45,30 +46,27 @@ export default async function deleteColumnMigration({
},
});
const response = await fetch(
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
{
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
body: JSON.stringify({
dataSource,
skip_execution: false,
name: `alter_table_${schema}_${table}_drop_column_${column.id}`,
down: recreateColumnArgs,
up: [
getPreparedHasuraQuery(
dataSource,
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I CASCADE',
schema,
table,
column.id,
),
],
}),
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
);
body: JSON.stringify({
dataSource,
skip_execution: false,
name: `alter_table_${schema}_${table}_drop_column_${column.id}`,
down: recreateColumnArgs,
up: [
getPreparedHasuraQuery(
dataSource,
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I CASCADE',
schema,
table,
column.id,
),
],
}),
});
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
await response.json();

View File

@@ -9,6 +9,7 @@ import {
getPreparedHasuraQuery,
} from '@/utils/dataBrowser/hasuraQueryHelpers';
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
export interface DeleteTableMigrationVariables {
/**
@@ -39,32 +40,29 @@ export default async function deleteTable({
),
];
const response = await fetch(
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
{
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
body: JSON.stringify({
dataSource,
skip_execution: false,
name: `drop_table_${schema}_${table}`,
down: [
{
type: 'run_sql',
args: {
cascade: false,
read_only: false,
source: '',
sql: getEmptyDownMigrationMessage(deleteTableArgs),
},
},
],
up: deleteTableArgs,
}),
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
);
body: JSON.stringify({
dataSource,
skip_execution: false,
name: `drop_table_${schema}_${table}`,
down: [
{
type: 'run_sql',
args: {
cascade: false,
read_only: false,
source: '',
sql: getEmptyDownMigrationMessage(deleteTableArgs),
},
},
],
up: deleteTableArgs,
}),
});
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
await response.json();

View File

@@ -6,6 +6,7 @@ import type {
QueryResult,
} from '@/types/data-browser';
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
import prepareTrackForeignKeyRelationsMetadata from './prepareTrackForeignKeyRelationsMetadata';
export interface TrackForeignKeyRelationsMigrationVariables {
@@ -45,23 +46,20 @@ export default async function trackForeignKeyRelationsMigration({
foreignKeyRelations,
});
const response = await fetch(
`${process.env.NEXT_PUBLIC_NHOST_MIGRATIONS_URL}/apis/migrate`,
{
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
body: JSON.stringify({
dataSource,
skip_execution: false,
name: `track_foreign_key_relations_${schema}_${table}`,
down: [],
up: creatableRelationships,
}),
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
);
body: JSON.stringify({
dataSource,
skip_execution: false,
name: `track_foreign_key_relations_${schema}_${table}`,
down: [],
up: creatableRelationships,
}),
});
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
await response.json();

Some files were not shown because too many files have changed in this diff Show More