Compare commits

..

203 Commits

Author SHA1 Message Date
Szilárd Dóró
4b6df8b9d6 Merge pull request #1731 from nhost/changeset-release/main
chore: update versions
2023-03-16 10:23:45 +01:00
Szilárd Dóró
a2af5a674d fix(deps): fix @nhost/apollo version 2023-03-16 09:55:43 +01:00
github-actions[bot]
c33c1fd6b9 chore: update versions 2023-03-16 08:37:32 +00:00
Szilárd Dóró
041d9b98e3 Merge pull request #1741 from nhost/renovate/stripe-react-stripe-js-2.x
fix(deps): update dependency @stripe/react-stripe-js to v2
2023-03-16 09:37:26 +01:00
Szilárd Dóró
e4b4940397 Merge pull request #1730 from nhost/chore/remove-axios-deprecation
fix: remove `useAxios`, restore autogenerated docs
2023-03-16 09:36:09 +01:00
renovate[bot]
be91f4ed2a fix(deps): update dependency @stripe/react-stripe-js to v2 2023-03-13 22:14:47 +00:00
Siarhei Lipchyk
ec6ba846cf Merge pull request #1732 from nhost/chore/dashboard-hasura-admin-secret
Allow to override hasura admin secret in docker
2023-03-13 10:01:47 +01:00
Siarhei Lipchyk
d8d8394b3b Allow to override hasura admin secret in docker 2023-03-10 13:11:02 +01:00
Szilárd Dóró
f051a121b2 Merge pull request #1729 from nhost/fix/sdk-backend-url 2023-03-10 12:37:48 +01:00
Szilárd Dóró
6ed46ce2d4 fix(docs): fix broken link 2023-03-10 11:15:22 +01:00
Szilárd Dóró
bfb4c1a6cc fix docs and remove useAxios 2023-03-10 11:04:51 +01:00
Szilárd Dóró
776c8f9237 Merge pull request #1721 from nhost/changeset-release/main
chore: update versions
2023-03-10 11:03:55 +01:00
github-actions[bot]
c0773d82e9 chore: update versions 2023-03-10 09:38:58 +00:00
Siarhei Lipchyk
c46b1383f2 Merge pull request #1724 from nhost/fix/dashboard-docker-entrypoint
Fix default values for placeholders
2023-03-10 10:37:46 +01:00
Siarhei Lipchyk
beed2eba21 Fix default values for placeholders 2023-03-10 10:36:01 +01:00
Szilárd Dóró
70f9610041 Merge pull request #1723 from nhost/fix/provisioning-status-indicator
fix(dashboard): miscellaneous fixes
2023-03-10 10:23:22 +01:00
Szilárd Dóró
e91de1088d chore: remove unused helper 2023-03-10 10:22:56 +01:00
Szilárd Dóró
ce1ee40dab fix: deprecate backendUrl, allow other params 2023-03-10 10:22:11 +01:00
Szilárd Dóró
bd7929f5ed revert provisioning status changes 2023-03-10 09:35:36 +01:00
Szilárd Dóró
2c8559a319 fix(dashboard): misc fixes 2023-03-09 15:54:17 +01:00
Szilárd Dóró
bd5ea5ee3a Merge pull request #1722 from nhost/chore/renovate-ci
chore(ci): remove renovate changeset automation
2023-03-09 13:09:59 +01:00
Szilárd Dóró
3538dbac39 chore(ci): remove renovate changeset automation 2023-03-09 11:12:06 +01:00
Szilárd Dóró
03b5cda69a Merge pull request #1700 from nhost/renovate/graphiql-react-0.x
fix(deps): update dependency @graphiql/react to ^0.17.0
2023-03-09 11:08:04 +01:00
Szilárd Dóró
4329d04854 chore: bump graphiql dependencies 2023-03-09 10:41:46 +01:00
Szilárd Dóró
ca50c5ce0c Merge remote-tracking branch 'origin/main' into renovate/graphiql-react-0.x 2023-03-09 10:25:37 +01:00
Szilárd Dóró
a3271ed014 Merge pull request #1719 from nhost/changeset-release/main
chore: update versions
2023-03-09 10:14:06 +01:00
github-actions[bot]
d4fc99a77c chore: update versions 2023-03-09 08:20:32 +00:00
Szilárd Dóró
d90fcf3c24 Merge pull request #1713 from nhost/chore/mimir-cleanup
chore(dashboard): mimir migration cleanup
2023-03-09 09:19:06 +01:00
Szilárd Dóró
ee70b226fc Merge pull request #1716 from nhost/changeset-release/main
chore: update versions
2023-03-09 09:18:45 +01:00
github-actions[bot]
227ef968e6 chore: update versions 2023-03-08 09:26:55 +00:00
Szilárd Dóró
430b37b2e1 Merge pull request #1711 from nhost/fix/responsive-fixes
fix(dashboard): improve mobile responsive layout
2023-03-08 10:25:18 +01:00
Szilárd Dóró
124620c33e Merge pull request #1467 from nhost/fix/local-urls
feat: add support for custom local subdomains
2023-03-08 09:49:32 +01:00
Szilárd Dóró
ce3ece1ad7 Merge pull request #1714 from nhost/changeset-release/main
chore: update versions
2023-03-08 09:21:29 +01:00
github-actions[bot]
c81002622c chore: update versions 2023-03-07 16:27:40 +00:00
Szilárd Dóró
35fa6bb043 Merge pull request #1712 from nhost/fix/functions
fix(nhost-js): correct return type in functions
2023-03-07 17:24:07 +01:00
Siarhei Lipchyk
a4469a5942 Add default values for NEXT_PUBLIC_NHOST_* envs to make it work with current stable CLI version 2023-03-07 15:23:12 +01:00
Szilárd Dóró
b8f11a13d7 fix: add missing type 2023-03-07 15:09:35 +01:00
Szilárd Dóró
1d1555593f fix: correct return type in functions 2023-03-07 15:03:28 +01:00
Szilárd Dóró
001b3dccec chore: update codegen 2023-03-07 14:50:26 +01:00
Szilárd Dóró
6755dfb17b fix: improve line heights 2023-03-07 13:24:28 +01:00
Szilárd Dóró
2ac90dfdec chore: add changeset 2023-03-07 13:22:08 +01:00
Szilárd Dóró
093f3906a4 fix: additional responsive fixes 2023-03-07 13:21:26 +01:00
Szilárd Dóró
6fb81a27ba fix: additional responsive fixes 2023-03-07 13:07:51 +01:00
Szilárd Dóró
9be41bf594 fix: fixes for responsive issues 2023-03-07 12:58:51 +01:00
Szilárd Dóró
cbb1fc5bc8 chore: cleanup GraphQL operations 2023-03-07 11:23:55 +01:00
Szilárd Dóró
99fcc36250 Merge pull request #1695 from nhost/changeset-release/main
chore: update versions
2023-03-07 10:22:13 +01:00
github-actions[bot]
7e4a756cfe chore: update versions 2023-03-06 07:49:58 +00:00
Szilárd Dóró
5bf61583e0 Merge pull request #1699 from nhost/renovate/xstate-inspect-0.x
chore(deps): update dependency @xstate/inspect to ^0.8.0
2023-03-06 08:48:24 +01:00
Szilárd Dóró
7eac17a1cb chore: add changeset 2023-03-06 08:48:02 +01:00
Szilárd Dóró
a41aeeb9ef Merge pull request #1697 from nhost/fix/deployment-glitch
fix(dashboard): show correct deployment status on the main page
2023-03-03 15:37:22 +01:00
Johan Eliasson
e33df513ff Merge pull request #1698 from nhost/chatgpt-b9asdasd
tests: hasura-storage-js
2023-03-03 15:20:27 +01:00
Johan Eliasson
323fd5cbe3 Update packages/hasura-storage-js/src/utils/index.ts
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-03-03 13:30:36 +01:00
renovate[bot]
0ec3abf47c fix(deps): update dependency @graphiql/react to ^0.17.0 2023-03-03 00:19:25 +00:00
renovate[bot]
ffb3c426d3 chore(deps): update dependency @xstate/inspect to ^0.8.0 2023-03-03 00:17:57 +00:00
Johan Eliasson
889ee6589e added tests 2023-03-02 23:13:03 +01:00
Szilárd Dóró
b00d261916 fix: update error message 2023-03-02 14:39:08 +01:00
Szilárd Dóró
6e05ab4628 Merge remote-tracking branch 'origin/main' into fix/local-urls 2023-03-02 14:18:10 +01:00
Szilárd Dóró
5223ee9353 fix(dashboard): don't show weird deployment dates 2023-03-02 14:08:59 +01:00
Szilárd Dóró
c8c5ace7cc Merge pull request #1500 from nhost/renovate/turbo-1.x
chore(deps): update dependency turbo to v1.8.3
2023-03-02 13:05:29 +01:00
Johan Eliasson
c6a4c28579 Merge pull request #1694 from nhost/renovate-changesets
chore: create changesest from Renovate bumps
2023-03-02 12:36:10 +01:00
szilarddoro
850a049ca2 chore(deps): update docker/build-push-action action to v4 2023-03-02 11:36:03 +00:00
Szilárd Dóró
eff3f0aefd Merge pull request #1641 from nhost/renovate/docker-build-push-action-4.x
chore(deps): update docker/build-push-action action to v4
2023-03-02 12:34:35 +01:00
Szilárd Dóró
2b1338f716 chore(deps): bump turbo for the dashboard 2023-03-02 11:50:48 +01:00
Szilárd Dóró
2b58c60747 Merge remote-tracking branch 'origin/main' into renovate/turbo-1.x 2023-03-02 11:44:21 +01:00
Szilárd Dóró
1b2e3fbd1d Merge pull request #1689 from nhost/changeset-release/main
chore: update versions
2023-03-02 11:39:55 +01:00
github-actions[bot]
6f4fdcf73f chore: update versions 2023-03-02 09:58:26 +00:00
Szilárd Dóró
cb529dc60c Merge pull request #1557 from nhost/renovate/vitest-monorepo
chore(deps): update vitest monorepo to ^0.29.0
2023-03-02 10:56:23 +01:00
Szilárd Dóró
68a449dbfc Merge remote-tracking branch 'origin/main' into renovate/vitest-monorepo 2023-03-02 10:33:42 +01:00
Szilárd Dóró
7d0c6d083a Merge pull request #1680 from nhost/renovate/glob-9.x
fix(deps): update dependency glob to v9
2023-03-02 10:28:58 +01:00
Szilárd Dóró
1353477da1 fix: lint error 2023-03-02 10:22:49 +01:00
Szilárd Dóró
549c7cb7eb chore(deps): bump glob to v9 2023-03-02 10:17:39 +01:00
Szilárd Dóró
e131c12d5d Merge branch 'renovate/vitest-monorepo' of https://github.com/nhost/nhost into renovate/vitest-monorepo 2023-03-02 10:03:45 +01:00
Szilárd Dóró
8bb097c9a7 chore(deps): bump vitest 2023-03-02 10:01:27 +01:00
renovate[bot]
ea31e64a71 chore(deps): update vitest monorepo to ^0.29.0 2023-03-02 08:58:06 +00:00
renovate[bot]
369b931689 chore(deps): update dependency turbo to v1.8.3 2023-03-02 08:56:38 +00:00
Szilárd Dóró
e1ec5c1be2 Merge remote-tracking branch 'origin/main' into renovate/vitest-monorepo 2023-03-02 09:51:23 +01:00
Szilárd Dóró
9822a160d4 Merge remote-tracking branch 'origin/main' into renovate/glob-9.x 2023-03-02 09:50:28 +01:00
Szilárd Dóró
7c67a2c437 chore(sync-versions): bump glob to v9 2023-03-02 09:50:11 +01:00
Szilárd Dóró
8e8884f4e1 Merge pull request #1629 from nhost/renovate/typescript-4.x
chore(deps): update dependency typescript to v4.9.5
2023-03-02 09:49:23 +01:00
Szilárd Dóró
9923be41ce Merge pull request #1675 from nhost/fix/isomorphic-unfetch
chore(deps): replace `cross-fetch` with `isomorphic-unfetch`
2023-03-02 09:29:49 +01:00
Szilárd Dóró
3141ce5b68 Merge branch 'main' into fix/local-urls 2023-03-01 16:10:07 +01:00
Szilárd Dóró
9c22a616a7 Merge pull request #1687 from nhost/changeset-release/main
chore: update versions
2023-03-01 14:56:43 +01:00
github-actions[bot]
6bc67e95a5 chore: update versions 2023-03-01 13:56:24 +00:00
Szilárd Dóró
0f6074c16f Merge pull request #1686 from nhost/fix/docker-build
fix(dashboard): fix docker build
2023-03-01 14:55:05 +01:00
Szilárd Dóró
c96d7ccdf2 fix(dashboard): fix docker build 2023-03-01 14:40:39 +01:00
Szilárd Dóró
fde7ac7c1c Merge pull request #1684 from nhost/changeset-release/main
chore: update versions
2023-03-01 13:40:28 +01:00
github-actions[bot]
24ef6071cc chore: update versions 2023-03-01 12:31:54 +00:00
Szilárd Dóró
bb993b6b03 Merge pull request #1595 from nhost/feat/settings-from-mimir
feat(dashboard): Settings from Mimir
2023-03-01 13:30:21 +01:00
Szilárd Dóró
89ca34be9a fix(dashboard): add tests, improve readability 2023-03-01 13:18:25 +01:00
Szilárd Dóró
b66d095c95 fix(dashboard): fix review comments 2023-03-01 13:07:41 +01:00
Szilárd Dóró
0bad9ff4fa feat(dashboard): add option to bypass maintenance 2023-03-01 11:15:24 +01:00
Szilárd Dóró
9a761f4fec feat(dashboard): add maintenance alert 2023-03-01 11:03:26 +01:00
Szilárd Dóró
bd6b55868a Merge remote-tracking branch 'origin/main' into renovate/typescript-4.x 2023-02-28 17:04:20 +01:00
Szilárd Dóró
afb3fe490e Merge pull request #1646 from nhost/renovate/major-graphqlcodegenerator-monorepo
chore(deps): update graphqlcodegenerator monorepo to v3 (major)
2023-02-28 17:02:07 +01:00
Szilárd Dóró
eaebd2b028 fix(dashboard): fix build errors 2023-02-28 16:31:19 +01:00
Szilárd Dóró
f03ecd91a9 fix(dashboard): disable settings through env vars 2023-02-28 16:20:54 +01:00
Szilárd Dóró
96f17c39b1 fix(dashboard): improve error handling 2023-02-28 15:25:08 +01:00
renovate[bot]
cb7c8c6398 fix(deps): update dependency glob to v9 2023-02-28 11:06:48 +00:00
Szilárd Dóró
4bf40995b5 chore(deps): add changeset 2023-02-28 12:02:28 +01:00
Szilárd Dóró
ab5f704280 chore(deps): remove direct typescript dependency 2023-02-28 11:59:38 +01:00
Szilárd Dóró
f65e4de955 chore(dashboard): add changeset 2023-02-28 11:54:23 +01:00
Szilárd Dóró
7e0e4d05aa Merge remote-tracking branch 'origin/main' into renovate/typescript-4.x 2023-02-28 11:45:49 +01:00
Szilárd Dóró
0954a44f84 Merge remote-tracking branch 'origin/main' into fix/isomorphic-unfetch 2023-02-28 11:38:25 +01:00
Szilárd Dóró
700cbd9e47 fix(dashboard): fix secrets' dialog management 2023-02-28 11:14:38 +01:00
Szilárd Dóró
3238543b08 Merge remote-tracking branch 'origin/main' into feat/settings-from-mimir 2023-02-28 11:11:09 +01:00
Szilárd Dóró
3f8d68ffab fix(dashboard): fix build 2023-02-28 09:57:46 +01:00
Szilárd Dóró
f7e706724c chore(dashboard): update generated code 2023-02-28 09:52:51 +01:00
Szilárd Dóró
6b8acd35bd fix(nhost-js): fix tests 2023-02-27 14:23:37 +01:00
Szilárd Dóró
2832d7299f fix(dashboard): add adminSecret to local app 2023-02-27 14:03:39 +01:00
Szilárd Dóró
44c5b386c3 Merge branch 'main' into feat/settings-from-mimir 2023-02-27 13:52:26 +01:00
Szilárd Dóró
44ff6a059f Merge remote-tracking branch 'origin/main' into fix/local-urls 2023-02-27 13:50:34 +01:00
Szilárd Dóró
5a91c477f0 fix(dashboard): fix tests 2023-02-27 11:45:22 +01:00
Szilárd Dóró
66f73d06a8 fix(hasura-storage-js): fix build error 2023-02-27 11:17:53 +01:00
Szilárd Dóró
35d52aab87 chore(deps): replace cross-fetch with isomorphic-unfetch 2023-02-27 10:57:37 +01:00
Szilárd Dóró
ddd41aae99 chore(dashboard): migrate DB settings to Mimir 2023-02-27 10:32:05 +01:00
renovate[bot]
832210d8ad chore(deps): update vitest monorepo to ^0.29.0 2023-02-25 11:09:59 +00:00
Szilárd Dóró
a09dad060c fix(dashboard): migrate to new admin secret location 2023-02-24 17:48:46 +01:00
Szilárd Dóró
76b63debf0 Merge branch 'main' into feat/settings-from-mimir 2023-02-24 17:13:56 +01:00
Szilárd Dóró
e88684ff2a Merge branch 'main' into fix/local-urls 2023-02-24 15:41:03 +01:00
Szilárd Dóró
095d6e918c Merge branch 'main' into feat/settings-from-mimir 2023-02-24 12:41:56 +01:00
renovate[bot]
6593e8d3eb chore(deps): update graphqlcodegenerator monorepo to v3 2023-02-23 10:28:10 +00:00
Szilárd Dóró
9219838127 Merge remote-tracking branch 'origin/main' into feat/settings-from-mimir 2023-02-22 15:13:21 +01:00
Szilárd Dóró
43b68a79eb fix(dashboard): improve error handling 2023-02-22 14:43:21 +01:00
Szilárd Dóró
ac845c6d92 Merge remote-tracking branch 'origin/main' into feat/settings-from-mimir 2023-02-22 11:06:08 +01:00
Szilárd Dóró
892ad66ba1 Merge remote-tracking branch 'origin/main' into fix/local-urls 2023-02-21 19:08:20 +01:00
Szilárd Dóró
f4af81020b Merge branch 'main' into feat/settings-from-mimir 2023-02-21 18:51:11 +01:00
renovate[bot]
6999562b59 chore(deps): update dependency typescript to v4.9.5 2023-02-21 17:03:56 +00:00
Szilárd Dóró
d167121093 chore(dashboard): add changeset
hide the "Secrets" menu item on the Settings page
2023-02-21 17:02:08 +01:00
Szilárd Dóró
822e251b11 cleanup part 2 2023-02-21 15:37:12 +01:00
Szilárd Dóró
328c6bb486 chore(packages): cleanup 2023-02-21 15:36:04 +01:00
Szilárd Dóró
bef8198cbf fix(dashboard): provider validation and scope 2023-02-21 14:59:06 +01:00
Szilárd Dóró
179313d8a2 fix(dashboard): run codegen, fix validation 2023-02-21 13:56:26 +01:00
Szilárd Dóró
c3ce004f46 Merge remote-tracking branch 'origin/main' into feat/settings-from-mimir 2023-02-21 10:48:29 +01:00
renovate[bot]
7d577a68b7 chore(deps): update docker/build-push-action action to v4 2023-02-20 10:08:41 +00:00
Szilárd Dóró
982059e18e fix(dashboard): fix build error 2023-02-20 09:53:53 +01:00
Szilárd Dóró
02c0586467 Merge remote-tracking branch 'origin/main' into fix/local-urls 2023-02-20 09:37:51 +01:00
Szilárd Dóró
0753e6529c fix(nhost-js): update service URLs 2023-02-14 15:17:07 +01:00
Siarhei Lipchyk
e87a14a3fe Don't append "/console" to value from NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL 2023-02-14 11:20:33 +01:00
Szilárd Dóró
b45aa420d9 fix(dashboard): use scope defined by the schema 2023-02-13 13:53:49 +01:00
Szilárd Dóró
1d76de3f60 Merge branch 'main' into feat/settings-from-mimir 2023-02-13 11:59:40 +01:00
Szilárd Dóró
9e37ca4cbc fix(dashboard): duplicate input IDs 2023-02-10 10:37:04 +01:00
Szilárd Dóró
af57ccce0f fix lint error and a UI warning 2023-02-10 10:26:06 +01:00
Szilárd Dóró
5f44aefcc6 Merge branch 'main' into feat/settings-from-mimir 2023-02-10 09:58:56 +01:00
Szilárd Dóró
168616df38 Merge branch 'main' into fix/local-urls 2023-02-06 17:50:37 +01:00
Szilárd Dóró
96f9278c8f chore(dashboard): ID workaround for config 2023-01-31 18:52:39 +01:00
Szilárd Dóró
9fe2ecd317 Merge branch 'main' into feat/settings-from-mimir 2023-01-31 16:31:45 +01:00
Szilárd Dóró
ada5309b49 fix(dashboard): catch errors thrown by mutations 2023-01-31 15:59:49 +01:00
Szilárd Dóró
08698f8246 feat(dashboard): migrate system variables to mimir 2023-01-31 15:46:36 +01:00
Szilárd Dóró
0b56e31408 Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-31 13:32:37 +01:00
Szilárd Dóró
d8c45b452d Merge remote-tracking branch 'origin/main' into fix/local-urls 2023-01-31 09:22:40 +01:00
Szilárd Dóró
c4e3e3f91f Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-31 09:21:18 +01:00
Szilárd Dóró
483fd6c7f4 feat(dashboard): environment variables to use mimir 2023-01-30 17:19:52 +01:00
Szilárd Dóró
ac37d7bcae chore(dashboard): improve config caching in Apollo 2023-01-30 16:54:48 +01:00
Szilárd Dóró
9adf91ba87 fix(dashboard): infinite query loop 2023-01-30 16:43:26 +01:00
Szilárd Dóró
d11f6eebb0 feat(dashboard): migrate permission variables to mimir 2023-01-30 16:18:32 +01:00
Szilárd Dóró
8a678fbc87 Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-30 15:42:59 +01:00
Szilárd Dóró
6411ec3ec3 chore(dashboard): all sign-in methods to use mimir 2023-01-30 15:38:23 +01:00
Szilárd Dóró
5187fe76aa feat(dashboard): allowed roles to use mimir 2023-01-30 15:06:28 +01:00
Szilárd Dóró
859f457e4a Merge remote-tracking branch 'origin/main' into feat/settings-from-mimir 2023-01-30 14:50:02 +01:00
Szilárd Dóró
dc2b5b4429 chore(dashboard): migrate rest of the auth forms to mimir 2023-01-30 12:04:10 +01:00
Szilárd Dóró
b7645e7892 chore(dashboard): migrate auth forms to mimir 2023-01-30 11:37:54 +01:00
Szilárd Dóró
b1338246aa Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-30 11:07:54 +01:00
Szilárd Dóró
d04ccd600e feat(dashboard): add Mimir support for all providers 2023-01-28 12:18:27 +01:00
Szilárd Dóró
d483ad5602 feat(dashboard): Apple, Discord and Facebook to use Mimir 2023-01-28 11:37:23 +01:00
Szilárd Dóró
bcf3e6bc2c feat(dashboard): SMTP page to update Mimir 2023-01-27 17:00:13 +01:00
Szilárd Dóró
575ff4e9b5 Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-27 16:43:10 +01:00
Szilárd Dóró
2010638540 feat(dashboard): migrate settings / authentication to Mimir 2023-01-27 16:10:43 +01:00
Szilárd Dóró
0346495a79 feat(dashboard): migrate SMTP settings to Mimir 2023-01-27 14:31:54 +01:00
Szilárd Dóró
2babb0b6f3 feat(dashboard): migrate the rest of the providers 2023-01-27 14:24:25 +01:00
Szilárd Dóró
1f293d0f0c feat(dashboard): migrate Sign In Methods to Mimir 2023-01-27 14:01:57 +01:00
Szilárd Dóró
af4c886437 Merge branch 'feat/dark-mode' into feat/settings-from-mimir 2023-01-27 11:21:25 +01:00
Szilárd Dóró
c182b3ca4b feat(dashboard): finalize secrets functionality 2023-01-27 10:57:40 +01:00
Szilárd Dóró
d5344ed31f feat(dashboard): initial secrets page code 2023-01-26 12:15:07 +01:00
Siarhei Lipchyk
adeb2a6d90 Adjust docker-entrypoint.sh for dashboard 2023-01-25 12:47:23 +01:00
Szilárd Dóró
921243e4d9 fix(dashboard): intercept metadata query in tests 2023-01-25 12:37:40 +01:00
Szilárd Dóró
1c5178f5fb chore(dashboard): _SCHEMA_API -> _API 2023-01-25 12:31:14 +01:00
Szilárd Dóró
72ad9aa8ee Merge branch 'main' into fix/local-urls 2023-01-23 10:39:56 +01:00
Szilárd Dóró
1b45db8caf chore(dashboard): revert users page changes
These will be fixed in a separate PR
2023-01-23 09:35:57 +01:00
Szilárd Dóró
9ffb4d0295 fix(dashboard): use fallbacks for services 2023-01-19 08:51:05 +01:00
Szilárd Dóró
e56340b792 fix(dashboard): env vars in Dockerfile
`localhost` -> `local`
2023-01-19 08:33:23 +01:00
Szilárd Dóró
814c6d997a Merge branch 'main' into fix/local-urls 2023-01-19 08:20:04 +01:00
Szilárd Dóró
7d7a352c33 chore(dashboard): update README 2023-01-16 19:23:22 +01:00
Szilárd Dóró
53a704fc7d chore(nhost-js): add TODO comments 2023-01-16 19:20:37 +01:00
Szilárd Dóró
c23eddf33d chore(dashboard): update README, improve SDK 2023-01-16 19:15:46 +01:00
Szilárd Dóró
d4147f4713 chore(dashboard): cleanup tests, cleanup env vars 2023-01-16 18:22:20 +01:00
Szilárd Dóró
f375eaccf5 feat(dashboard): introduce service based env vars
fix `@nhost/nextjs` and `@nhost/react` constructors
2023-01-16 17:49:03 +01:00
Siarhei Lipchyk
47f79ba9f3 upd 2023-01-10 12:33:26 +01:00
Siarhei Lipchyk
2e010455cf Update docker-entrypoint.sh 2023-01-10 11:09:00 +01:00
Szilárd Dóró
7e63c822ec Update dashboard/README.md
Co-authored-by: Nuno Pato <nunopato@gmail.com>
2023-01-10 09:27:24 +01:00
Szilárd Dóró
276b7d48c3 fix(dashboard): fix typo 2023-01-09 17:44:05 +01:00
Szilárd Dóró
6925b0d510 Merge remote-tracking branch 'origin/main' into fix/local-urls 2023-01-09 17:33:47 +01:00
Szilárd Dóró
6ff306c4e4 fix(dashboard): correct changeset
changed patch bump to minor bump as this version introduces deprecations
2023-01-09 17:33:01 +01:00
Szilárd Dóró
aa440fefe6 fix(dashboard): fix Dockerfile variables 2023-01-09 17:32:07 +01:00
Szilárd Dóró
9fbafc6654 feat(dashboard): introduce new port for services 2023-01-09 17:29:51 +01:00
Szilárd Dóró
b086175045 fix(dashboard): prevent build error 2023-01-09 16:25:06 +01:00
Szilárd Dóró
36db12297b fix(dashboard): resolve linter error 2023-01-09 15:43:49 +01:00
Szilárd Dóró
e5885d9bad fix(dashboard): don't break Auth page in local mode 2023-01-09 15:43:12 +01:00
Szilárd Dóró
15c13f3bbe Merge remote-tracking branch 'origin/main' into fix/local-urls 2023-01-09 15:10:40 +01:00
Szilárd Dóró
8d47cafd86 fix(dashboard): use correct subdomain 2023-01-09 14:59:25 +01:00
Szilárd Dóró
408cb6d10c chore(dashboard): update README 2023-01-06 13:31:01 +01:00
Szilárd Dóró
4d882703f2 fix(dashboard): use localhost for Hasura services 2023-01-06 13:27:57 +01:00
Szilárd Dóró
437dacaa9e chore(nhost-js): refactor port default value 2023-01-04 19:00:58 +01:00
Szilárd Dóró
088584e79d feat: add support for custom local subdomains 2023-01-04 15:34:48 +01:00
294 changed files with 9367 additions and 7755 deletions

View File

@@ -98,7 +98,7 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push to Docker Hub - name: Build and push to Docker Hub
uses: docker/build-push-action@v3 uses: docker/build-push-action@v4
timeout-minutes: 60 timeout-minutes: 60
with: with:
context: . context: .

View File

@@ -1,89 +0,0 @@
name: Renovate
on:
pull_request:
branches: [main]
types: [closed]
paths-ignore:
- 'assets/**'
- '**.md'
- 'LICENSE'
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: nhost
jobs:
renovate-changeset:
name: Add changeset
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'renovate/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GH_PAT }}
# * Install Node and dependencies. Package downloads will be cached for the next jobs.
- name: Install Node and dependencies
uses: ./.github/actions/install-dependencies
with:
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
TURBO_TEAM: ${{ env.TURBO_TEAM }}
BUILD: 'none'
- name: Determine bumps
id: bumps
run: |
LAST_NON_PR_SHA=$(git log --no-merges main origin/${{ github.head_ref }} --format=format:%h -- | head -2 | tail -1)
echo "result<<EOF" >> $GITHUB_OUTPUT
pnpm recursive list --depth -1 --parseable \
--filter='!nhost-root' \
--filter=[$LAST_NON_PR_SHA] \
| xargs -I@ jq ".name" @/package.json \
| sort \
| uniq -u \
| awk '$0=$0": patch"' \
>> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
- name: Install dictionary
if: steps.bumps.outputs.result != ''
run: sudo apt-get install wbritish
- name: Generate changeset file name
id: file_name
if: steps.bumps.outputs.result != ''
run: |
FILE_NAME=$(shuf -n 3 /usr/share/dict/words | tr '\n' '-' | sed 's/-$//' | sed 's/'"'"'s//g' | tr '[:upper:]' '[:lower:]')
echo "result=./.changeset/${FILE_NAME}.md" >> $GITHUB_OUTPUT
- name: Create changeset file
if: steps.bumps.outputs.result != ''
run: |
cat <<EOF > ${{ steps.file_name.outputs.result }}
---
${{ steps.bumps.outputs.result }}
---
${{ github.event.pull_request.title }}
EOF
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v4
with:
token: ${{ secrets.GH_PAT }}
commit-message: ${{ github.event.pull_request.title }}
branch: renovate-changesets
delete-branch: true
title: 'chore: create changesest from Renovate bumps'
labels: |
dependencies
body: |
This PR creates the changesets from the Renovate dependencies that have been merged to main.
- name: Enable Pull Request Automerge
if: steps.cpr.outputs.pull-request-operation == 'created'
uses: peter-evans/enable-pull-request-automerge@v2
with:
token: ${{ secrets.GH_PAT }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
- name: Auto approve
if: steps.cpr.outputs.pull-request-operation == 'created'
uses: juliangruber/approve-pull-request-action@v2
with:
github-token: ${{ secrets.GH_PAT }}
number: ${{ steps.cpr.outputs.pull-request-number }}

View File

@@ -20,7 +20,9 @@ export default defineConfig({
exclude: ['**/*.spec.ts', '**/*.test.ts', '**/tests/**'], exclude: ['**/*.spec.ts', '**/*.test.ts', '**/tests/**'],
entryRoot: 'src', entryRoot: 'src',
// Was defaulting to true until version 1.7 // Was defaulting to true until version 1.7
skipDiagnostics: true skipDiagnostics: true,
// Was defaulting to true until version 2.0
copyDtsFiles: true
}) })
], ],
test: { test: {

View File

@@ -1,8 +1,17 @@
# General Environment Variables
NEXT_PUBLIC_ENV=dev NEXT_PUBLIC_ENV=dev
NEXT_PUBLIC_NHOST_HASURA_URL=http://localhost:9695
NEXT_PUBLIC_NHOST_MIGRATIONS_URL=http://localhost:9693
NEXT_PUBLIC_NHOST_BACKEND_URL=http://localhost:1337
NEXT_PUBLIC_NHOST_PLATFORM=false NEXT_PUBLIC_NHOST_PLATFORM=false
# Environment Variables for Self Hosting and Local Development
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
# Environment Variables when running the Nhost Dashboard against the Nhost Backend
NEXT_PUBLIC_STRIPE_PK=<nhost_stripe_public_key> NEXT_PUBLIC_STRIPE_PK=<nhost_stripe_public_key>
NEXT_PUBLIC_GITHUB_APP_INSTALL_URL=<github_app_install_url> NEXT_PUBLIC_GITHUB_APP_INSTALL_URL=<github_app_install_url>
NEXT_PUBLIC_ANALYTICS_WRITE_KEY=<analytics_write_key> NEXT_PUBLIC_ANALYTICS_WRITE_KEY=<analytics_write_key>

View File

@@ -30,6 +30,7 @@ module.exports = {
'error', 'error',
{ ignoreTypeReferences: true }, { ignoreTypeReferences: true },
], ],
'no-console': ['warn', { allow: ['error'] }],
'no-shadow': 'off', 'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error', '@typescript-eslint/no-shadow': 'error',
'no-unused-vars': 'off', 'no-unused-vars': 'off',

View File

@@ -1,5 +1,89 @@
# @nhost/dashboard # @nhost/dashboard
## 0.13.3
### Patch Changes
- bfb4c1a6: chore(dashboard): remove `useAxios` property
- d8d8394b: Dashboard: allow to override hasura admin secret in docker
- Updated dependencies [ce1ee40d]
- @nhost/nextjs@1.13.16
- @nhost/react-apollo@5.0.11
## 0.13.2
### Patch Changes
- beed2eba: Fix docker entrypoint for dashboard
- 2c8559a3: fix(dashboard): refresh project list after deleting a project
- 4329d048: chore(dashboard): bump `graphiql` dependencies
## 0.13.1
### Patch Changes
- cbb1fc5b: chore(dashboard): cleanup GraphQL operations
## 0.13.0
### Minor Changes
- 088584e7: feat(dashboard): add support for custom local subdomains
### Patch Changes
- 2ac90dfd: fix(dashboard): improve mobile responsive layout
- Updated dependencies [f375eacc]
- @nhost/nextjs@1.13.15
- @nhost/react-apollo@5.0.10
## 0.12.4
### Patch Changes
- @nhost/react-apollo@5.0.9
- @nhost/nextjs@1.13.14
## 0.12.3
### Patch Changes
- 2b1338f7: chore(dashboard): bump `turbo` to 1.8.3
- 5223ee93: fix(dashboard): show correct deployment status on the main page
- 850a049c: chore(deps): update docker/build-push-action action to v4
- Updated dependencies [850a049c]
- @nhost/nextjs@1.13.13
- @nhost/react-apollo@5.0.8
## 0.12.2
### Patch Changes
- 4bf40995: chore(deps): bump `typescript` to `4.9.5`
- 8bb097c9: chore(deps): bump `vitest`
- 35d52aab: chore(deps): replace `cross-fetch` with `isomorphic-unfetch`
- Updated dependencies [4bf40995]
- Updated dependencies [8bb097c9]
- Updated dependencies [35d52aab]
- @nhost/react-apollo@5.0.7
- @nhost/nextjs@1.13.12
## 0.12.1
### Patch Changes
- c96d7ccd: fix(dashboard): fix docker builds
## 0.12.0
### Minor Changes
- d1671210: feat(dashboard): use mimir to manage project configuration
### Patch Changes
- f65e4de9: chore(deps): bump @graphql-codegen monorepo to v3
## 0.11.20 ## 0.11.20
### Patch Changes ### Patch Changes

View File

@@ -3,7 +3,7 @@ RUN apk add --no-cache libc6-compat
RUN apk update RUN apk update
WORKDIR /app WORKDIR /app
RUN yarn global add turbo@1.6.3 RUN yarn global add turbo@1.8.3
COPY . . COPY . .
RUN turbo prune --scope="@nhost/dashboard" --docker RUN turbo prune --scope="@nhost/dashboard" --docker
@@ -11,7 +11,7 @@ FROM node:16-alpine AS builder
ARG TURBO_TOKEN ARG TURBO_TOKEN
ARG TURBO_TEAM ARG TURBO_TEAM
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat python3 make g++
RUN apk update RUN apk update
WORKDIR /app WORKDIR /app
@@ -19,10 +19,15 @@ ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_PUBLIC_ENV dev ENV NEXT_PUBLIC_ENV dev
ENV NEXT_PUBLIC_NHOST_PLATFORM false ENV NEXT_PUBLIC_NHOST_PLATFORM false
# placeholders for ports, will be replaced on runtime by entrypoint script # placeholders for URLs, will be replaced on runtime by entrypoint script
ENV NEXT_PUBLIC_NHOST_MIGRATIONS_PORT __NEXT_PUBLIC_NHOST_MIGRATIONS_PORT__ ENV NEXT_PUBLIC_NHOST_ADMIN_SECRET __NEXT_PUBLIC_NHOST_ADMIN_SECRET__
ENV NEXT_PUBLIC_NHOST_HASURA_PORT __NEXT_PUBLIC_NHOST_HASURA_PORT__ ENV NEXT_PUBLIC_NHOST_AUTH_URL __NEXT_PUBLIC_NHOST_AUTH_URL__
ENV NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT __NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT__ ENV NEXT_PUBLIC_NHOST_FUNCTIONS_URL __NEXT_PUBLIC_NHOST_FUNCTIONS_URL__
ENV NEXT_PUBLIC_NHOST_GRAPHQL_URL __NEXT_PUBLIC_NHOST_GRAPHQL_URL__
ENV NEXT_PUBLIC_NHOST_STORAGE_URL __NEXT_PUBLIC_NHOST_STORAGE_URL__
ENV NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL __NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL__
ENV NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL __NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__
ENV NEXT_PUBLIC_NHOST_HASURA_API_URL __NEXT_PUBLIC_NHOST_HASURA_API_URL__
RUN yarn global add pnpm@7.17.0 RUN yarn global add pnpm@7.17.0
COPY .gitignore .gitignore COPY .gitignore .gitignore

View File

@@ -35,8 +35,17 @@ You can connect the Nhost Dashboard to your locally running backend by setting t
```bash ```bash
NEXT_PUBLIC_ENV=dev NEXT_PUBLIC_ENV=dev
NEXT_PUBLIC_NHOST_PLATFORM=false NEXT_PUBLIC_NHOST_PLATFORM=false
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
``` ```
This will connect the Nhost Dashboard to your locally running Nhost backend.
### Storybook ### Storybook
Components are documented using [Storybook](https://storybook.js.org/). To run Storybook, run the following command: Components are documented using [Storybook](https://storybook.js.org/). To run Storybook, run the following command:
@@ -45,20 +54,39 @@ Components are documented using [Storybook](https://storybook.js.org/). To run S
pnpm storybook pnpm storybook
``` ```
### Full list of environment variables ### General Environment Variables
| Name | Description | | Name | Description |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_ENV` | `dev`, `staging` or `prod`. Should be set to `dev` in most cases. | | `NEXT_PUBLIC_ENV` | `dev`, `staging` or `prod`. This 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_ADMIN_SECRET` | Admin secret for Hasura. Default: `nhost-admin-secret` |
| `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_PLATFORM` | This should be set to `false` to connect the Nhost Dashboard to a locally running or a self-hosted Nhost backend. Setting this to `true` will connect the Nhost Dashboard to the cloud environment. Default: `false` |
| `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` | ### Environment Variables for Local Development and Self-Hosting
| `NEXT_PUBLIC_NHOST_BACKEND_URL` | Backend URL. Not necessary for local development. |
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. Not necessary for local development. | | Name | Description |
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. Not necessary for local development. | | ---- | ----------- |
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. Not necessary for local development. |
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. Not necessary for local development. | | `NEXT_PUBLIC_NHOST_AUTH_URL` | The URL of the Auth service. When working locally, point it to the Auth service started by the CLI. When self-hosting, point it to the self-hosted Auth service. |
| `NEXT_PUBLIC_NHOST_FUNCTIONS_URL` | The URL of the Functions service. When working locally, point it to the Functions service started by the CLI. When self-hosting, point it to the self-hosted Functions service. |
| `NEXT_PUBLIC_NHOST_GRAPHQL_URL` | The URL of the GraphQL service. When working locally, point it to the GraphQL service started by the CLI. When self-hosting, point it to the self-hosted GraphQL service. |
| `NEXT_PUBLIC_NHOST_STORAGE_URL` | The URL of the Storage service. When working locally, point it to the Storage service started by the CLI. When self-hosting, point it to the self-hosted Storage service. |
| `NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL` | The URL of the Hasura Console. When working locally, point it to the Hasura Console started by the CLI. When self-hosting, point it to the self-hosted Hasura Console. |
| `NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL` | The URL of Hasura's Migrations service. When working locally, point it to the Migrations service started by the CLI. |
| `NEXT_PUBLIC_NHOST_HASURA_API_URL` | The URL of Hasura's Schema and Metadata API. When working locally, point it to the Schema and Metadata API started by the CLI. When self-hosting, point it to the self-hosted Schema and Metadata API. |
### Other Environment Variables
| Name | Description |
| --------------------------------------- | ------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_NHOST_BACKEND_URL` | Backend URL. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
| `NEXT_PUBLIC_MAINTENANCE_ACTIVE` | Determines whether or not maintenance mode is active. |
| `NEXT_PUBLIC_MAINTENANCE_END_DATE` | Date when maintenance mode will end. |
| `NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET` | Secret that can be used to bypass maintenance mode. |
## ESLint Rules ## ESLint Rules

View File

@@ -1,15 +1,25 @@
#!/bin/sh #!/bin/sh
set -e set -euo pipefail
# read ports from env variables or use defaults # read URLs from env variables (with defaults)
NEXT_PUBLIC_NHOST_MIGRATIONS_PORT="${NEXT_PUBLIC_NHOST_MIGRATIONS_PORT:=9693}" NEXT_PUBLIC_NHOST_ADMIN_SECRET="${NEXT_PUBLIC_NHOST_ADMIN_SECRET:-nhost-admin-secret}"
NEXT_PUBLIC_NHOST_HASURA_PORT="${NEXT_PUBLIC_NHOST_HASURA_PORT:=9695}" NEXT_PUBLIC_NHOST_AUTH_URL="${NEXT_PUBLIC_NHOST_AUTH_URL:-http://localhost:1337/v1/auth}"
NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT="${NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT:=1337}" NEXT_PUBLIC_NHOST_FUNCTIONS_URL="${NEXT_PUBLIC_NHOST_FUNCTIONS_URL:-http://localhost:1337/v1/functions}"
NEXT_PUBLIC_NHOST_GRAPHQL_URL="${NEXT_PUBLIC_NHOST_GRAPHQL_URL:-http://localhost:1337/v1/graphql}"
NEXT_PUBLIC_NHOST_STORAGE_URL="${NEXT_PUBLIC_NHOST_STORAGE_URL:-http://localhost:1337/v1/storage}"
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL="${NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL:-http://localhost:9695}"
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL="${NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL:-http://localhost:9693}"
NEXT_PUBLIC_NHOST_HASURA_API_URL="${NEXT_PUBLIC_NHOST_HASURA_API_URL:-http://localhost:8080}"
# replace placeholders # 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_ADMIN_SECRET__~${NEXT_PUBLIC_NHOST_ADMIN_SECRET}~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_AUTH_URL__~${NEXT_PUBLIC_NHOST_AUTH_URL}~g" {} +
find dashboard -type f -exec sed -i "s/__NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT__/${NEXT_PUBLIC_NHOST_LOCAL_BACKEND_PORT}/g" {} + find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_FUNCTIONS_URL__~${NEXT_PUBLIC_NHOST_FUNCTIONS_URL}~g" {} +
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_GRAPHQL_URL__~${NEXT_PUBLIC_NHOST_GRAPHQL_URL}~g" {} +
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_STORAGE_URL__~${NEXT_PUBLIC_NHOST_STORAGE_URL}~g" {} +
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL__~${NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL}~g" {} +
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__~${NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL}~g" {} +
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_API_URL__~${NEXT_PUBLIC_NHOST_HASURA_API_URL}~g" {} +
exec "$@" exec "$@"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nhost/dashboard", "name": "@nhost/dashboard",
"version": "0.11.20", "version": "0.13.3",
"private": true, "private": true,
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
@@ -25,8 +25,8 @@
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.10.5",
"@fontsource/inter": "^4.5.14", "@fontsource/inter": "^4.5.14",
"@fontsource/roboto-mono": "^4.5.8", "@fontsource/roboto-mono": "^4.5.8",
"@graphiql/react": "^0.15.0", "@graphiql/react": "^0.17.0",
"@graphiql/toolkit": "^0.8.0", "@graphiql/toolkit": "^0.8.2",
"@headlessui/react": "^1.6.5", "@headlessui/react": "^1.6.5",
"@heroicons/react": "^1.0.6", "@heroicons/react": "^1.0.6",
"@hookform/resolvers": "^2.9.10", "@hookform/resolvers": "^2.9.10",
@@ -37,7 +37,7 @@
"@nhost/nextjs": "workspace:*", "@nhost/nextjs": "workspace:*",
"@nhost/react-apollo": "workspace:*", "@nhost/react-apollo": "workspace:*",
"@segment/snippet": "^4.15.3", "@segment/snippet": "^4.15.3",
"@stripe/react-stripe-js": "^1.10.0", "@stripe/react-stripe-js": "^2.0.0",
"@stripe/stripe-js": "^1.35.0", "@stripe/stripe-js": "^1.35.0",
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.3",
"@tanstack/react-query": "^4.16.1", "@tanstack/react-query": "^4.16.1",
@@ -46,10 +46,9 @@
"analytics-node": "^6.2.0", "analytics-node": "^6.2.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cross-fetch": "^3.1.5",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"generate-password": "^1.7.0", "generate-password": "^1.7.0",
"graphiql": "^2.2.0", "graphiql": "^2.4.0",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"graphql-request": "^4.3.0", "graphql-request": "^4.3.0",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
@@ -64,7 +63,7 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-error-boundary": "^3.1.4", "react-error-boundary": "^3.1.4",
"react-hook-form": "^7.39.5", "react-hook-form": "^7.42.1",
"react-hot-toast": "^2.4.0", "react-hot-toast": "^2.4.0",
"react-is": "18.2.0", "react-is": "18.2.0",
"react-loading-skeleton": "^2.2.0", "react-loading-skeleton": "^2.2.0",
@@ -82,10 +81,10 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.2", "@babel/core": "^7.20.2",
"@graphql-codegen/cli": "^2.8.0", "@graphql-codegen/cli": "^3.0.0",
"@graphql-codegen/typescript": "^2.7.1", "@graphql-codegen/typescript": "^3.0.0",
"@graphql-codegen/typescript-graphql-request": "^4.5.1", "@graphql-codegen/typescript-graphql-request": "^4.5.1",
"@graphql-codegen/typescript-operations": "^2.5.1", "@graphql-codegen/typescript-operations": "^3.0.0",
"@graphql-codegen/typescript-react-apollo": "^3.3.1", "@graphql-codegen/typescript-react-apollo": "^3.3.1",
"@next/bundle-analyzer": "^12.3.1", "@next/bundle-analyzer": "^12.3.1",
"@storybook/addon-actions": "^6.5.14", "@storybook/addon-actions": "^6.5.14",
@@ -112,11 +111,12 @@
"@typescript-eslint/eslint-plugin": "^5.43.0", "@typescript-eslint/eslint-plugin": "^5.43.0",
"@typescript-eslint/parser": "^5.43.0", "@typescript-eslint/parser": "^5.43.0",
"@vitejs/plugin-react": "^3.0.0", "@vitejs/plugin-react": "^3.0.0",
"@vitest/coverage-c8": "^0.27.0", "@vitest/coverage-c8": "^0.29.0",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"babel-loader": "^8.3.0", "babel-loader": "^8.3.0",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"csstype": "^3.0.10", "csstype": "^3.0.10",
"encoding": "^0.1.13",
"eslint": "^8.28.0", "eslint": "^8.28.0",
"eslint-config-airbnb": "19.0.4", "eslint-config-airbnb": "19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-airbnb-typescript": "^17.0.0",
@@ -130,6 +130,7 @@
"lint-staged": ">=13", "lint-staged": ">=13",
"msw": "^1.0.1", "msw": "^1.0.1",
"msw-storybook-addon": "^1.6.3", "msw-storybook-addon": "^1.6.3",
"node-fetch": "^3.3.0",
"postcss": "^8.4.19", "postcss": "^8.4.19",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"prettier-plugin-organize-imports": "^3.2.0", "prettier-plugin-organize-imports": "^3.2.0",
@@ -140,10 +141,9 @@
"tailwindcss": "^3.1.2", "tailwindcss": "^3.1.2",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsconfig-paths-webpack-plugin": "^4.0.0", "tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "^4.8.4",
"vite": "^4.0.2", "vite": "^4.0.2",
"vite-tsconfig-paths": "^4.0.3", "vite-tsconfig-paths": "^4.0.3",
"vitest": "^0.27.0", "vitest": "^0.29.0",
"webpack": "^5.75.0" "webpack": "^5.75.0"
}, },
"browserslist": { "browserslist": {

View File

@@ -1,4 +1,7 @@
import { useDeleteApplicationMutation } from '@/generated/graphql'; import {
GetOneUserDocument,
useDeleteApplicationMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import ArrowRightIcon from '@/ui/v2/icons/ArrowRightIcon'; import ArrowRightIcon from '@/ui/v2/icons/ArrowRightIcon';
@@ -12,7 +15,9 @@ import { useRouter } from 'next/router';
export default function ApplicationInfo() { export default function ApplicationInfo() {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [deleteApplication, { client }] = useDeleteApplicationMutation(); const [deleteApplication, { client }] = useDeleteApplicationMutation({
refetchQueries: [GetOneUserDocument],
});
const router = useRouter(); const router = useRouter();
async function handleClickRemove() { async function handleClickRemove() {

View File

@@ -1,3 +1,4 @@
import MaintenanceAlert from '@/components/common/MaintenanceAlert';
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary'; import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import Container from '@/components/layout/Container'; import Container from '@/components/layout/Container';
import { features } from '@/components/overview/features'; import { features } from '@/components/overview/features';
@@ -52,6 +53,7 @@ export default function ApplicationLive() {
return ( return (
<Container> <Container>
<MaintenanceAlert />
<OverviewTopBar /> <OverviewTopBar />
<div className="grid grid-cols-1 gap-12 pt-3 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-12 pt-3 lg:grid-cols-3">

View File

@@ -32,12 +32,12 @@ function Plan({
return ( return (
<button <button
type="button" type="button"
className="my-4 grid w-full grid-flow-col items-center justify-between px-1" className="my-4 grid w-full grid-flow-col items-center justify-between gap-2 px-1"
onClick={setPlan} onClick={setPlan}
tabIndex={-1} tabIndex={-1}
> >
<div className="grid grid-flow-row gap-y-0.5"> <div className="grid grid-flow-row gap-y-0.5">
<div className="flex flex-row items-center"> <div className="grid grid-flow-col items-center justify-start gap-2">
<Checkbox <Checkbox
onChange={setPlan} onChange={setPlan}
checked={selectedPlanId === planId} checked={selectedPlanId === planId}
@@ -47,12 +47,13 @@ function Plan({
<Text <Text
variant="h3" variant="h3"
component="p" component="p"
className="ml-2 self-center font-medium" className="self-center text-left font-medium"
> >
{currentPlan.price > price ? 'Downgrade' : 'Upgrade'} to {planName} {currentPlan.price > price ? 'Downgrade' : 'Upgrade'} to {planName}
</Text> </Text>
</div> </div>
<Text variant="subtitle2" className="w-64 text-start">
<Text variant="subtitle2" className="w-full max-w-[256px] text-start">
{planDescriptions[planName]} {planDescriptions[planName]}
</Text> </Text>
</div> </div>
@@ -142,7 +143,7 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
}; };
return ( return (
<Box className="w-welcome rounded-lg p-6 text-left"> <Box className="w-full max-w-xl rounded-lg p-6 text-left">
<Modal <Modal
showModal={paymentModal} showModal={paymentModal}
close={closePaymentModal} close={closePaymentModal}

View File

@@ -12,7 +12,7 @@ import generateAppServiceUrl, {
defaultRemoteBackendSlugs, defaultRemoteBackendSlugs,
} from '@/utils/common/generateAppServiceUrl'; } from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import { LOCAL_HASURA_URL } from '@/utils/env'; import { getHasuraConsoleServiceUrl } from '@/utils/env';
import Image from 'next/image'; import Image from 'next/image';
interface HasuraDataProps { interface HasuraDataProps {
@@ -22,17 +22,15 @@ interface HasuraDataProps {
export function HasuraData({ close }: HasuraDataProps) { export function HasuraData({ close }: HasuraDataProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const projectAdminSecret = currentApplication?.config?.hasura.adminSecret;
if ( if (!currentApplication?.subdomain || !projectAdminSecret) {
!currentApplication?.subdomain ||
!currentApplication?.hasuraGraphqlAdminSecret
) {
return <LoadingScreen />; return <LoadingScreen />;
} }
const hasuraUrl = const hasuraUrl =
process.env.NEXT_PUBLIC_ENV === 'dev' || !isPlatform process.env.NEXT_PUBLIC_ENV === 'dev' || !isPlatform
? `${LOCAL_HASURA_URL}/console` ? `${getHasuraConsoleServiceUrl()}`
: generateAppServiceUrl( : generateAppServiceUrl(
currentApplication?.subdomain, currentApplication?.subdomain,
currentApplication?.region.awsName, currentApplication?.region.awsName,
@@ -71,18 +69,11 @@ export function HasuraData({ close }: HasuraDataProps) {
<div className="col-span-1 grid grid-flow-col items-center justify-center gap-2 sm:col-span-2 sm:justify-end"> <div className="col-span-1 grid grid-flow-col items-center justify-center gap-2 sm:col-span-2 sm:justify-end">
<Text className="font-medium" variant="subtitle2"> <Text className="font-medium" variant="subtitle2">
{Array(currentApplication.hasuraGraphqlAdminSecret.length) {Array(projectAdminSecret.length).fill('•').join('')}
.fill('•')
.join('')}
</Text> </Text>
<IconButton <IconButton
onClick={() => onClick={() => copy(projectAdminSecret, 'Hasura admin secret')}
copy(
currentApplication.hasuraGraphqlAdminSecret,
'Hasura admin secret',
)
}
variant="borderless" variant="borderless"
color="secondary" color="secondary"
className="min-w-0 p-1" className="min-w-0 p-1"

View File

@@ -1,8 +1,8 @@
import DeploymentStatusMessage from '@/components/deployments/DeploymentStatusMessage';
import { FindOldApps } from '@/components/home'; import { FindOldApps } from '@/components/home';
import type { UserData } from '@/hooks/useGetAllUserWorkspacesAndApplications'; import type { UserData } from '@/hooks/useGetAllUserWorkspacesAndApplications';
import type { Application, ApplicationState } from '@/types/application'; import type { ApplicationState } from '@/types/application';
import { ApplicationStatus } from '@/types/application'; import { ApplicationStatus } from '@/types/application';
import { Avatar } from '@/ui/Avatar';
import StateBadge from '@/ui/StateBadge'; import StateBadge from '@/ui/StateBadge';
import type { DeploymentStatus } from '@/ui/StatusCircle'; import type { DeploymentStatus } from '@/ui/StatusCircle';
import { StatusCircle } from '@/ui/StatusCircle'; import { StatusCircle } from '@/ui/StatusCircle';
@@ -10,59 +10,11 @@ import Divider from '@/ui/v2/Divider';
import Link from '@/ui/v2/Link'; import Link from '@/ui/v2/Link';
import List from '@/ui/v2/List'; import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem'; import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import { getApplicationStatusString } from '@/utils/helpers'; import { getApplicationStatusString } from '@/utils/helpers';
import { formatDistance } from 'date-fns';
import Image from 'next/image'; import Image from 'next/image';
import NavLink from 'next/link'; import NavLink from 'next/link';
import { Fragment } from 'react'; import { Fragment } from 'react';
function ApplicationCreatedAt({ createdAt }: any) {
return (
<Text component="span" className="text-sm">
created{' '}
{formatDistance(new Date(createdAt), new Date(), {
addSuffix: true,
})}
</Text>
);
}
function LastSuccessfulDeployment({ deployment }: any) {
return (
<span className="flex flex-row">
<Avatar
component="span"
name={deployment.commitUserName}
avatarUrl={deployment.commitUserAvatarUrl}
className="mr-1 h-4 w-4 self-center"
/>
<Text component="span" className="self-center text-sm">
{deployment.commitUserName} deployed{' '}
{formatDistance(new Date(deployment.deploymentEndedAt), new Date(), {
addSuffix: true,
})}
</Text>
</span>
);
}
function CurrentDeployment({ deployment }: any) {
return (
<span className="flex flex-row">
<Avatar
component="span"
name={deployment.commitUserName}
avatarUrl={deployment.commitUserAvatarUrl}
className="mr-1 h-4 w-4 self-center"
/>
<Text className="self-center text-sm">
{deployment.commitUserName} updated just now
</Text>
</span>
);
}
export function checkStatusOfTheApplication( export function checkStatusOfTheApplication(
stateHistory: ApplicationState[] | [], stateHistory: ApplicationState[] | [],
) { ) {
@@ -103,7 +55,7 @@ export function RenderWorkspacesWithApps({
} }
const workspaceProjects = workspace.applications const workspaceProjects = workspace.applications
.filter((app: Application) => .filter((app) =>
app.name.toLowerCase().includes(query.toLowerCase()), app.name.toLowerCase().includes(query.toLowerCase()),
) )
.sort((appA, appB) => { .sort((appA, appB) => {
@@ -141,25 +93,23 @@ export function RenderWorkspacesWithApps({
</NavLink> </NavLink>
<List className="grid grid-flow-row border-y"> <List className="grid grid-flow-row border-y">
{workspaceProjects.map((app, index) => { {workspaceProjects.map((app, index) => {
const isDeployingToProduction = app.deployments[0] const [latestDeployment] = app.deployments;
? app.deployments[0].deploymentStatus === 'DEPLOYING'
: false;
return ( return (
<Fragment key={app.slug}> <Fragment key={app.slug}>
<ListItem.Root <ListItem.Root
secondaryAction={ secondaryAction={
<div className="grid grid-flow-col gap-px"> <div className="grid grid-flow-col gap-px">
{app.deployments[0] && ( {latestDeployment && (
<div className="mr-2 flex self-center align-middle"> <div className="mr-2 flex self-center align-middle">
<StatusCircle <StatusCircle
status={ status={
app.deployments[0] latestDeployment.deploymentStatus as DeploymentStatus
.deploymentStatus as DeploymentStatus
} }
/> />
</div> </div>
)} )}
<StateBadge <StateBadge
status={checkStatusOfTheApplication( status={checkStatusOfTheApplication(
app.appStates, app.appStates,
@@ -190,27 +140,10 @@ export function RenderWorkspacesWithApps({
<ListItem.Text <ListItem.Text
primary={app.name} primary={app.name}
secondary={ secondary={
<> <DeploymentStatusMessage
{isDeployingToProduction && ( appCreatedAt={app.createdAt}
<CurrentDeployment deployment={latestDeployment}
deployment={app.deployments[0]} />
/>
)}
{!isDeployingToProduction &&
app.deployments[0] && (
<LastSuccessfulDeployment
deployment={app.deployments[0]}
/>
)}
{!isDeployingToProduction &&
!app.deployments[0] && (
<ApplicationCreatedAt
createdAt={app.createdAt}
/>
)}
</>
} }
/> />
</ListItem.Button> </ListItem.Button>

View File

@@ -33,11 +33,10 @@ export function UnlockFeatureByUpgrading({
title: 'Upgrade your plan.', title: 'Upgrade your plan.',
payload: <ChangePlanModal />, payload: <ChangePlanModal />,
props: { props: {
PaperProps: { className: 'p-0' }, PaperProps: { className: 'p-0 max-w-xl w-full' },
hidePrimaryAction: true, hidePrimaryAction: true,
hideSecondaryAction: true, hideSecondaryAction: true,
hideTitle: true, hideTitle: true,
maxWidth: 'lg',
}, },
}); });
}} }}

View File

@@ -1,3 +1,4 @@
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient'; import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Option from '@/ui/v2/Option'; import Option from '@/ui/v2/Option';
@@ -18,10 +19,12 @@ export interface UserSelectProps {
} }
export function UserSelect({ onUserChange, ...props }: UserSelectProps) { export function UserSelect({ onUserChange, ...props }: UserSelectProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const userApplicationClient = useRemoteApplicationGQLClient(); const userApplicationClient = useRemoteApplicationGQLClient();
const { data, loading, error } = useRemoteAppGetUsersCustomQuery({ const { data, loading, error } = useRemoteAppGetUsersCustomQuery({
client: userApplicationClient, client: userApplicationClient,
variables: { where: {}, limit: 250, offset: 0 }, variables: { where: {}, limit: 250, offset: 0 },
skip: !currentApplication,
}); });
if (loading) { if (loading) {
@@ -36,8 +39,6 @@ export function UserSelect({ onUserChange, ...props }: UserSelectProps) {
throw error; throw error;
} }
const { users } = data;
return ( return (
<Select <Select
{...props} {...props}
@@ -57,7 +58,7 @@ export function UserSelect({ onUserChange, ...props }: UserSelectProps) {
return; return;
} }
const user: RemoteAppGetUsersCustomQuery['users'][0] = users.find( const user: RemoteAppGetUsersCustomQuery['users'][0] = data?.users.find(
({ id }) => id === userId, ({ id }) => id === userId,
); );
@@ -68,7 +69,7 @@ export function UserSelect({ onUserChange, ...props }: UserSelectProps) {
> >
<Option value="admin">Admin</Option> <Option value="admin">Admin</Option>
{users.map(({ id, displayName, email, phoneNumber }) => ( {data?.users.map(({ id, displayName, email, phoneNumber }) => (
<Option key={id} value={id}> <Option key={id} value={id}>
{displayName || email || phoneNumber || id} {displayName || email || phoneNumber || id}
</Option> </Option>

View File

@@ -22,7 +22,9 @@ import {
import { loadStripe } from '@stripe/stripe-js'; import { loadStripe } from '@stripe/stripe-js';
import React, { useState } from 'react'; import React, { useState } from 'react';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PK!); const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PK
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PK)
: null;
type AddPaymentMethodFormProps = { type AddPaymentMethodFormProps = {
close: () => void; close: () => void;

View File

@@ -205,7 +205,7 @@ export default function DataGridPreviewCell<TData extends object>({
} }
const { presignedUrl } = await appClient.storage const { presignedUrl } = await appClient.storage
.setAdminSecret(currentApplication.hasuraGraphqlAdminSecret) .setAdminSecret(currentApplication.config?.hasura.adminSecret)
.getPresignedUrl({ fileId: id }); .getPresignedUrl({ fileId: id });
if (!presignedUrl) { if (!presignedUrl) {

View File

@@ -1,6 +1,7 @@
import type { BoxProps } from '@/ui/v2/Box'; import type { BoxProps } from '@/ui/v2/Box';
import Box from '@/ui/v2/Box'; import Box from '@/ui/v2/Box';
import type { KeyboardEvent } from 'react'; import type { KeyboardEvent } from 'react';
import { useRef } from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
export interface FormProps extends BoxProps { export interface FormProps extends BoxProps {
@@ -11,6 +12,7 @@ export interface FormProps extends BoxProps {
} }
export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) { export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
const formRef = useRef<HTMLDivElement>();
const { const {
handleSubmit, handleSubmit,
formState: { isSubmitting }, formState: { isSubmitting },
@@ -25,6 +27,15 @@ export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
return; return;
} }
const submitButton = Array.from(
formRef.current.getElementsByTagName('button'),
).find((item) => item.type === 'submit');
// Disabling submit if the submit button is disabled
if (submitButton?.disabled) {
return;
}
event.preventDefault(); event.preventDefault();
handleSubmit(onSubmit)(event); handleSubmit(onSubmit)(event);
@@ -35,6 +46,7 @@ export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
// so keyboard events must be handled on the form element itself. // so keyboard events must be handled on the form element itself.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<Box <Box
ref={formRef}
component="form" component="form"
{...props} {...props}
onKeyDown={(event) => { onKeyDown={(event) => {

View File

@@ -23,6 +23,7 @@ export default function Header({ className, ...props }: HeaderProps) {
return ( return (
<Box <Box
component="header"
className={twMerge( className={twMerge(
'z-40 grid w-full transform-gpu grid-flow-col items-center justify-between gap-2 border-b-1 px-4 py-3', 'z-40 grid w-full transform-gpu grid-flow-col items-center justify-between gap-2 border-b-1 px-4 py-3',
className, className,

View File

@@ -38,6 +38,12 @@ function IconLink(
: [icon.props?.sx]), : [icon.props?.sx]),
{ {
color: (theme) => { color: (theme) => {
if (props.disabled) {
return theme.palette.mode === 'dark'
? 'text.secondary'
: 'text.primary';
}
if (active) { if (active) {
return 'primary.main'; return 'primary.main';
} }

View File

@@ -0,0 +1,46 @@
import { useUI } from '@/context/UIContext';
import { Alert } from '@/ui/Alert';
export default function MaintenanceAlert() {
const { maintenanceActive, maintenanceEndDate } = useUI();
if (!maintenanceActive) {
return null;
}
const dateTimeFormat = Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZoneName: 'short',
});
const parts = dateTimeFormat.formatToParts(maintenanceEndDate);
const year = parts.find((part) => part.type === 'year')?.value;
const month = parts.find((part) => part.type === 'month')?.value;
const day = parts.find((part) => part.type === 'day')?.value;
const hour = parts.find((part) => part.type === 'hour')?.value;
const minute = parts.find((part) => part.type === 'minute')?.value;
const timeZone = parts.find((part) => part.type === 'timeZoneName')?.value;
return (
<Alert severity="warning" className="mt-4">
<p>
We&apos;re currently doing maintenance on our infrastructure. Project
creation and project settings are temporarily disabled during the
maintenance period.
</p>
{maintenanceEndDate && (
<p>
Maintenance is expected to be completed at {year}-{month}-{day} {hour}
:{minute} {timeZone}.
</p>
)}
</Alert>
);
}

View File

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

View File

@@ -1,4 +1,4 @@
import customClaimsQuery from '@/utils/msw/mocks/graphql/customClaimsQuery'; import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery'; import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/utils/msw/mocks/rest/tableQuery'; import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
import { render, screen } from '@/utils/testUtils'; import { render, screen } from '@/utils/testUtils';
@@ -6,7 +6,11 @@ import { setupServer } from 'msw/node';
import { test, vi } from 'vitest'; import { test, vi } from 'vitest';
import ColumnAutocomplete from './ColumnAutocomplete'; import ColumnAutocomplete from './ColumnAutocomplete';
const server = setupServer(tableQuery, hasuraMetadataQuery, customClaimsQuery); const server = setupServer(
tableQuery,
hasuraMetadataQuery,
permissionVariablesQuery,
);
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' })); beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers()); afterEach(() => server.resetHandlers());

View File

@@ -547,7 +547,7 @@ export default function DataBrowserSidebar({
document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed); document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed);
}, []); }, []);
if (isPlatform && !currentApplication?.hasuraGraphqlAdminSecret) { if (isPlatform && !currentApplication?.config?.hasura.adminSecret) {
return null; return null;
} }

View File

@@ -14,6 +14,7 @@ import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import convertToHasuraPermissions from '@/utils/dataBrowser/convertToHasuraPermissions'; import convertToHasuraPermissions from '@/utils/dataBrowser/convertToHasuraPermissions';
import convertToRuleGroup from '@/utils/dataBrowser/convertToRuleGroup'; import convertToRuleGroup from '@/utils/dataBrowser/convertToRuleGroup';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@@ -258,7 +259,7 @@ export default function RolePermissionEditorForm({
{ {
loading: 'Saving permission...', loading: 'Saving permission...',
success: 'Permission has been saved successfully.', success: 'Permission has been saved successfully.',
error: 'An error occurred while saving the permission.', error: getServerError('An error occurred while saving the permission.'),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
@@ -297,7 +298,9 @@ export default function RolePermissionEditorForm({
{ {
loading: 'Deleting permission...', loading: 'Deleting permission...',
success: 'Permission has been deleted successfully.', success: 'Permission has been deleted successfully.',
error: 'An error occurred while deleting the permission.', error: getServerError(
'An error occurred while deleting the permission.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );

View File

@@ -11,8 +11,8 @@ import XIcon from '@/ui/v2/icons/XIcon';
import InputLabel from '@/ui/v2/InputLabel'; import InputLabel from '@/ui/v2/InputLabel';
import Option from '@/ui/v2/Option'; import Option from '@/ui/v2/Option';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray'; import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql'; import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
import { useTheme } from '@mui/material'; import { useTheme } from '@mui/material';
import clsx from 'clsx'; import clsx from 'clsx';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
@@ -51,8 +51,8 @@ export default function ColumnPresetsSection({
} = useTableQuery([`default.${schema}.${table}`], { schema, table }); } = useTableQuery([`default.${schema}.${table}`], { schema, table });
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data: customClaimsData } = useGetAppCustomClaimsQuery({ const { data: permissionVariablesData } = useGetRolesPermissionsQuery({
variables: { id: currentApplication?.id }, variables: { appId: currentApplication?.id },
skip: !currentApplication?.id, skip: !currentApplication?.id,
}); });
const { const {
@@ -74,8 +74,8 @@ export default function ColumnPresetsSection({
throw tableError; throw tableError;
} }
const permissionVariableOptions = getPermissionVariablesArray( const permissionVariableOptions = getAllPermissionVariables(
customClaimsData?.app?.authJwtCustomClaims, permissionVariablesData?.config?.auth?.session?.accessToken?.customClaims,
).map(({ key }) => ({ ).map(({ key }) => ({
label: `X-Hasura-${key}`, label: `X-Hasura-${key}`,
value: `X-Hasura-${key}`, value: `X-Hasura-${key}`,
@@ -136,7 +136,7 @@ export default function ColumnPresetsSection({
disableClearable={false} disableClearable={false}
clearIcon={ clearIcon={
<XIcon <XIcon
className="w-4 h-4 mt-px" className="mt-px h-4 w-4"
sx={{ color: theme.palette.text.primary }} sx={{ color: theme.palette.text.primary }}
/> />
} }
@@ -187,7 +187,7 @@ export default function ColumnPresetsSection({
disabled={disabled} disabled={disabled}
variant="outlined" variant="outlined"
color="secondary" color="secondary"
className="shrink-0 grow-0 flex-[40px]" className="flex-[40px] shrink-0 grow-0"
onClick={() => { onClick={() => {
if (fields.length === 1) { if (fields.length === 1) {
remove(index); remove(index);
@@ -199,7 +199,7 @@ export default function ColumnPresetsSection({
remove(index); remove(index);
}} }}
> >
<XIcon className="w-4 h-4" /> <XIcon className="h-4 w-4" />
</IconButton> </IconButton>
</div> </div>
))} ))}

View File

@@ -2,7 +2,7 @@ import Form from '@/components/common/Form';
import type { RuleGroup } from '@/types/dataBrowser'; import type { RuleGroup } from '@/types/dataBrowser';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import customClaimsQuery from '@/utils/msw/mocks/graphql/customClaimsQuery'; import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery'; import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
import tableQuery from '@/utils/msw/mocks/rest/tableQuery'; import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
import type { ComponentMeta, ComponentStory } from '@storybook/react'; import type { ComponentMeta, ComponentStory } from '@storybook/react';
@@ -36,7 +36,7 @@ const defaultParameters = {
}, },
}, },
msw: { msw: {
handlers: [tableQuery, hasuraMetadataQuery, customClaimsQuery], handlers: [tableQuery, hasuraMetadataQuery, permissionVariablesQuery],
}, },
}; };

View File

@@ -10,8 +10,8 @@ import type { AutocompleteOption } from '@/ui/v2/Autocomplete';
import type { InputProps } from '@/ui/v2/Input'; import type { InputProps } from '@/ui/v2/Input';
import { inputClasses } from '@/ui/v2/Input'; import { inputClasses } from '@/ui/v2/Input';
import Option from '@/ui/v2/Option'; import Option from '@/ui/v2/Option';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray'; import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql'; import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
import { useController, useFormContext, useWatch } from 'react-hook-form'; import { useController, useFormContext, useWatch } from 'react-hook-form';
import useRuleGroupEditor from './useRuleGroupEditor'; import useRuleGroupEditor from './useRuleGroupEditor';
@@ -117,8 +117,8 @@ export default function RuleValueInput({
data, data,
loading, loading,
error: customClaimsError, error: customClaimsError,
} = useGetAppCustomClaimsQuery({ } = useGetRolesPermissionsQuery({
variables: { id: currentApplication?.id }, variables: { appId: currentApplication?.id },
skip: !isHasuraInput || !currentApplication?.id, skip: !isHasuraInput || !currentApplication?.id,
}); });
@@ -200,8 +200,8 @@ export default function RuleValueInput({
); );
} }
const availableHasuraPermissionVariables = getPermissionVariablesArray( const availableHasuraPermissionVariables = getAllPermissionVariables(
data?.app?.authJwtCustomClaims, data?.config?.auth?.session?.accessToken?.customClaims,
).map(({ key }) => ({ ).map(({ key }) => ({
value: `X-Hasura-${key}`, value: `X-Hasura-${key}`,
label: `X-Hasura-${key}`, label: `X-Hasura-${key}`,

View File

@@ -10,6 +10,7 @@ import ArrowCounterclockwiseIcon from '@/ui/v2/icons/ArrowCounterclockwiseIcon';
import ChevronRightIcon from '@/ui/v2/icons/ChevronRightIcon'; import ChevronRightIcon from '@/ui/v2/icons/ChevronRightIcon';
import { ListItem } from '@/ui/v2/ListItem'; import { ListItem } from '@/ui/v2/ListItem';
import Tooltip from '@/ui/v2/Tooltip'; import Tooltip from '@/ui/v2/Tooltip';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql'; import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
import { useInsertDeploymentMutation } from '@/utils/__generated__/graphql'; import { useInsertDeploymentMutation } from '@/utils/__generated__/graphql';
@@ -58,12 +59,12 @@ export default function DeploymentListItem({
return ( return (
<ListItem.Root> <ListItem.Root>
<ListItem.Button <ListItem.Button
className="grid grid-flow-col items-center justify-between gap-2 rounded-none px-2 py-2" className="grid grid-flow-col items-center justify-between gap-2 rounded-none p-2"
component={NavLink} component={NavLink}
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`} href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
aria-label={commitMessage || 'No commit message'} aria-label={commitMessage || 'No commit message'}
> >
<div className="flex cursor-pointer flex-row items-center justify-center space-x-2 self-center"> <div className="grid grid-flow-col items-center justify-center gap-2 self-center">
<ListItem.Avatar> <ListItem.Avatar>
<Avatar <Avatar
name={deployment.commitUserName} name={deployment.commitUserName}
@@ -84,7 +85,7 @@ export default function DeploymentListItem({
/> />
</div> </div>
<div className="grid grid-flow-col items-center gap-2"> <div className="grid grid-flow-col items-center justify-end gap-2">
{showRedeploy && ( {showRedeploy && (
<Tooltip <Tooltip
title={ title={
@@ -122,7 +123,9 @@ export default function DeploymentListItem({
{ {
loading: 'Scheduling deployment...', loading: 'Scheduling deployment...',
success: 'Deployment has been scheduled successfully.', success: 'Deployment has been scheduled successfully.',
error: 'An error occurred when scheduling deployment.', error: getServerError(
'An error occurred when scheduling deployment.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
@@ -139,16 +142,16 @@ export default function DeploymentListItem({
)} )}
{isLive && ( {isLive && (
<div className="flex w-12 justify-end"> <div className="hidden w-12 justify-end sm:flex">
<Chip size="small" color="success" label="Live" /> <Chip size="small" color="success" label="Live" />
</div> </div>
)} )}
<div className="w-16 text-right font-mono text-sm- font-medium"> <div className="hidden w-16 text-right font-mono text-sm- font-medium sm:block">
{deployment.commitSHA.substring(0, 7)} {deployment.commitSHA.substring(0, 7)}
</div> </div>
<div className="w-[80px] text-right font-mono text-sm- font-medium"> <div className="text-right font-mono text-sm- font-medium sm:w-20">
<AppDeploymentDuration <AppDeploymentDuration
startedAt={deployment.deploymentStartedAt} startedAt={deployment.deploymentStartedAt}
endedAt={deployment.deploymentEndedAt} endedAt={deployment.deploymentEndedAt}

View File

@@ -0,0 +1,94 @@
import type { Deployment } from '@/types/application';
import { render, screen } from '@/utils/testUtils';
import { test, vi } from 'vitest';
import DeploymentStatusMessage from './DeploymentStatusMessage';
const defaultDeployment: Deployment = {
id: 'de305d54-75b4-431b-adb2-eb6b9e546013',
commitUserName: 'john.doe',
commitUserAvatarUrl: 'https://example.com/avatar.png',
deploymentStartedAt: '2023-02-24T12:00:00.000Z',
deploymentEndedAt: null,
deploymentStatus: 'SCHEDULED',
commitSHA: '1234567890',
commitMessage: 'Update README.md',
};
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
test('should render the avatar of the user who deployed the application', () => {
render(
<DeploymentStatusMessage
deployment={defaultDeployment}
appCreatedAt="2023-02-24"
/>,
);
expect(
screen.getByRole('img', {
name: `Avatar of ${defaultDeployment.commitUserName}`,
}),
).toHaveAttribute(
'style',
`background-image: url(${defaultDeployment.commitUserAvatarUrl});`,
);
});
test('should render "updated just now" when the deployment is in progress and has not ended', () => {
render(
<DeploymentStatusMessage
deployment={defaultDeployment}
appCreatedAt="2023-02-24"
/>,
);
expect(screen.getByText(/updated just now/i)).toBeInTheDocument();
});
test('should render "updated just now" when the deployment\'s status is DEPLOYED, but it doesn\'t have an end date for some reason', () => {
render(
<DeploymentStatusMessage
deployment={{
...defaultDeployment,
deploymentStatus: 'DEPLOYED',
deploymentEndedAt: null,
}}
appCreatedAt="2023-02-24"
/>,
);
expect(screen.getByText(/updated just now/i)).toBeInTheDocument();
});
test('should render "deployed 1 day ago" when the deployment has ended', () => {
vi.setSystemTime(new Date('2023-02-25T12:25:00.000Z'));
render(
<DeploymentStatusMessage
deployment={{
...defaultDeployment,
deploymentStatus: 'DEPLOYED',
deploymentEndedAt: '2023-02-24T12:15:00.000Z',
}}
appCreatedAt="2023-02-24"
/>,
);
expect(screen.getByText(/deployed 1 day ago/i)).toBeInTheDocument();
});
test('should render "created 1 day ago" if the application does not have a deployment', () => {
vi.setSystemTime(new Date('2023-02-25T12:25:00.000Z'));
render(
<DeploymentStatusMessage deployment={null} appCreatedAt="2023-02-24" />,
);
expect(screen.getByText(/created 1 day ago/i)).toBeInTheDocument();
});

View File

@@ -0,0 +1,73 @@
import type { Deployment } from '@/types/application';
import { Avatar } from '@/ui/Avatar';
import Text from '@/ui/v2/Text';
import formatDistance from 'date-fns/formatDistance';
export interface DeploymentStatusMessageProps {
/**
* The deployment to render the status message for.
*/
deployment: Partial<Deployment>;
/**
* The date the application was created.
*/
appCreatedAt: string;
}
export default function DeploymentStatusMessage({
deployment,
appCreatedAt,
}: DeploymentStatusMessageProps) {
const isDeployingToProduction = [
'SCHEDULED',
'PENDING',
'DEPLOYING',
].includes(deployment?.deploymentStatus);
if (
isDeployingToProduction ||
(deployment && !deployment.deploymentEndedAt)
) {
return (
<span className="flex flex-row">
<Avatar
component="span"
name={deployment.commitUserName}
avatarUrl={deployment.commitUserAvatarUrl}
className="mr-1 h-4 w-4 self-center"
/>
<Text component="span" className="self-center text-sm">
{deployment.commitUserName} updated just now
</Text>
</span>
);
}
if (!isDeployingToProduction && deployment?.deploymentEndedAt) {
return (
<span className="grid grid-flow-col">
<Avatar
component="span"
name={deployment.commitUserName}
avatarUrl={deployment.commitUserAvatarUrl}
className="mr-1 h-4 w-4 self-center"
/>
<Text component="span" className="self-center truncate text-sm">
{deployment.commitUserName} deployed{' '}
{formatDistance(new Date(deployment.deploymentEndedAt), new Date(), {
addSuffix: true,
})}
</Text>
</span>
);
}
return (
<Text component="span" className="text-sm">
created{' '}
{formatDistance(new Date(appCreatedAt), new Date(), {
addSuffix: true,
})}
</Text>
);
}

View File

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

View File

@@ -12,6 +12,7 @@ import useBuckets from '@/hooks/useBuckets';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import useFiles from '@/hooks/useFiles'; import useFiles from '@/hooks/useFiles';
import useFilesAggregate from '@/hooks/useFilesAggregate'; import useFilesAggregate from '@/hooks/useFilesAggregate';
import { getHasuraAdminSecret } from '@/utils/env';
import { showLoadingToast, triggerToast } from '@/utils/toast'; import { showLoadingToast, triggerToast } from '@/utils/toast';
import type { Files } from '@/utils/__generated__/graphql'; import type { Files } from '@/utils/__generated__/graphql';
import { Order_By as OrderBy } from '@/utils/__generated__/graphql'; import { Order_By as OrderBy } from '@/utils/__generated__/graphql';
@@ -261,8 +262,8 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
const { fileMetadata, error: fileError } = await appClient.storage const { fileMetadata, error: fileError } = await appClient.storage
.setAdminSecret( .setAdminSecret(
process.env.NEXT_PUBLIC_ENV === 'dev' process.env.NEXT_PUBLIC_ENV === 'dev'
? 'nhost-admin-secret' ? getHasuraAdminSecret()
: currentApplication.hasuraGraphqlAdminSecret, : currentApplication.config?.hasura.adminSecret,
) )
.upload({ .upload({
file, file,

View File

@@ -12,6 +12,7 @@ import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip'; import Chip from '@/ui/v2/Chip';
import type { InputProps } from '@/ui/v2/Input'; import type { InputProps } from '@/ui/v2/Input';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import { getHasuraAdminSecret } from '@/utils/env';
import { triggerToast } from '@/utils/toast'; import { triggerToast } from '@/utils/toast';
import type { Files } from '@/utils/__generated__/graphql'; import type { Files } from '@/utils/__generated__/graphql';
import type { PropsWithoutRef } from 'react'; import type { PropsWithoutRef } from 'react';
@@ -71,8 +72,8 @@ export default function FilesDataGridControls({
try { try {
const storageWithAdminSecret = appClient.storage.setAdminSecret( const storageWithAdminSecret = appClient.storage.setAdminSecret(
process.env.NEXT_PUBLIC_ENV === 'dev' process.env.NEXT_PUBLIC_ENV === 'dev'
? 'nhost-admin-secret' ? getHasuraAdminSecret()
: currentApplication.hasuraGraphqlAdminSecret, : currentApplication.config?.hasura.adminSecret,
); );
// note: this is not an optimal solution, but we don't have a better way // note: this is not an optimal solution, but we don't have a better way
@@ -120,7 +121,7 @@ export default function FilesDataGridControls({
{...props} {...props}
> >
{numberOfSelectedFiles > 0 ? ( {numberOfSelectedFiles > 0 ? (
<div className="mx-auto h-[40px] grid grid-flow-col justify-start items-center gap-2"> <div className="mx-auto grid h-[40px] grid-flow-col items-center justify-start gap-2">
<Chip <Chip
color="info" color="info"
size="small" size="small"

View File

@@ -4,6 +4,7 @@ import type { DialogFormProps } from '@/types/common';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import { slugifyString } from '@/utils/helpers'; import { slugifyString } from '@/utils/helpers';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
refetchGetOneUserQuery, refetchGetOneUserQuery,
@@ -141,7 +142,9 @@ export default function EditWorkspaceNameForm({
{ {
loading: 'Updating workspace name...', loading: 'Updating workspace name...',
success: 'Workspace name has been updated successfully.', success: 'Workspace name has been updated successfully.',
error: 'An error occurred while updating the workspace name.', error: getServerError(
'An error occurred while updating the workspace name.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
@@ -168,7 +171,9 @@ export default function EditWorkspaceNameForm({
{ {
loading: 'Creating new workspace...', loading: 'Creating new workspace...',
success: 'The new workspace has been created successfully.', success: 'The new workspace has been created successfully.',
error: 'An error occurred while creating the new workspace.', error: getServerError(
'An error occurred while creating the new workspace.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );

View File

@@ -1,3 +1,4 @@
import { useUI } from '@/context/UIContext';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import PlusCircleIcon from '@/ui/v2/icons/PlusCircleIcon'; import PlusCircleIcon from '@/ui/v2/icons/PlusCircleIcon';
import SearchIcon from '@/ui/v2/icons/SearchIcon'; import SearchIcon from '@/ui/v2/icons/SearchIcon';
@@ -11,6 +12,8 @@ interface IndexHeaderAppsProps {
} }
export function IndexHeaderApps({ query, setQuery }: IndexHeaderAppsProps) { export function IndexHeaderApps({ query, setQuery }: IndexHeaderAppsProps) {
const { maintenanceActive } = useUI();
return ( return (
<div className="mx-auto mb-6 grid w-full grid-flow-col place-content-between items-center py-2"> <div className="mx-auto mb-6 grid w-full grid-flow-col place-content-between items-center py-2">
<Text variant="h2" component="h1" className="hidden md:block"> <Text variant="h2" component="h1" className="hidden md:block">
@@ -36,6 +39,7 @@ export function IndexHeaderApps({ query, setQuery }: IndexHeaderAppsProps) {
variant="outlined" variant="outlined"
color="secondary" color="secondary"
startIcon={<PlusCircleIcon />} startIcon={<PlusCircleIcon />}
disabled={maintenanceActive}
> >
New Project New Project
</Button> </Button>

View File

@@ -99,7 +99,6 @@ export function InviteAnnounce() {
workspaceMemberInviteId: inviteId, workspaceMemberInviteId: inviteId,
isAccepted: false, isAccepted: false,
}, },
{ useAxios: false },
); );
if (ignoreError) { if (ignoreError) {

View File

@@ -1,5 +1,5 @@
import { UserDataProvider } from '@/context/workspace1-context'; import { UserDataProvider } from '@/context/workspace1-context';
import type { Application } from '@/types/application'; import type { Project } from '@/types/application';
import { ApplicationStatus } from '@/types/application'; import { ApplicationStatus } from '@/types/application';
import type { Workspace } from '@/types/workspace'; import type { Workspace } from '@/types/workspace';
import { render, screen, waitForElementToBeRemoved } from '@/utils/testUtils'; import { render, screen, waitForElementToBeRemoved } from '@/utils/testUtils';
@@ -36,12 +36,11 @@ vi.mock('next/router', () => ({
}), }),
})); }));
const mockApplication: Application = { const mockApplication: Project = {
id: '1', id: '1',
name: 'Test Application', name: 'Test Application',
slug: 'test-application', slug: 'test-application',
appStates: [], appStates: [],
hasuraGraphqlAdminSecret: 'nhost-admin-secret',
subdomain: '', subdomain: '',
isProvisioned: true, isProvisioned: true,
region: { region: {
@@ -56,6 +55,14 @@ const mockApplication: Application = {
featureFlags: [], featureFlags: [],
providersUpdated: true, providersUpdated: true,
githubRepository: { fullName: 'test/git-project' }, githubRepository: { fullName: 'test/git-project' },
repositoryProductionBranch: null,
nhostBaseFolder: null,
plan: null,
config: {
hasura: {
adminSecret: 'nhost-admin-secret',
},
},
}; };
const mockWorkspace: Workspace = { const mockWorkspace: Workspace = {

View File

@@ -1,6 +1,7 @@
import useGitHubModal from '@/components/applications/github/useGitHubModal'; import useGitHubModal from '@/components/applications/github/useGitHubModal';
import DeploymentListItem from '@/components/deployments/DeploymentListItem'; import DeploymentListItem from '@/components/deployments/DeploymentListItem';
import GithubIcon from '@/components/icons/GithubIcon'; import GithubIcon from '@/components/icons/GithubIcon';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Box from '@/ui/v2/Box'; import Box from '@/ui/v2/Box';
@@ -75,7 +76,7 @@ function OverviewDeploymentList() {
if (!deployments?.length) { if (!deployments?.length) {
return ( return (
<Box className="grid grid-flow-row items-center justify-items-center gap-5 overflow-hidden rounded-lg border-1 py-12 px-48 shadow-sm"> <Box className="grid grid-flow-row items-center justify-items-center gap-5 overflow-hidden rounded-lg border-1 py-12 px-4 shadow-sm">
<RocketIcon <RocketIcon
strokeWidth={1} strokeWidth={1}
className="h-10 w-10" className="h-10 w-10"
@@ -85,7 +86,7 @@ function OverviewDeploymentList() {
<Text className="text-center font-medium" variant="h3"> <Text className="text-center font-medium" variant="h3">
No Deployments No Deployments
</Text> </Text>
<Text variant="subtitle1" className="text-center"> <Text variant="subtitle1" className="max-w-md text-center">
We&apos;ll deploy changes automatically when you push to the We&apos;ll deploy changes automatically when you push to the
deployment branch in your connected GitHub repository deployment branch in your connected GitHub repository
</Text> </Text>
@@ -146,6 +147,7 @@ function OverviewDeploymentList() {
export default function OverviewDeployments() { export default function OverviewDeployments() {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openGitHubModal } = useGitHubModal(); const { openGitHubModal } = useGitHubModal();
const { maintenanceActive } = useUI();
const { githubRepository } = currentApplication || {}; const { githubRepository } = currentApplication || {};
@@ -164,14 +166,14 @@ export default function OverviewDeployments() {
<div className="flex flex-col"> <div className="flex flex-col">
<OverviewDeploymentsTopBar /> <OverviewDeploymentsTopBar />
<Box className="grid grid-flow-row items-center justify-items-center gap-5 rounded-lg border-1 py-12 px-48 shadow-sm"> <Box className="grid grid-flow-row items-center justify-items-center gap-5 rounded-lg border-1 py-12 px-4 shadow-sm">
<RocketIcon strokeWidth={1} className="h-10 w-10" /> <RocketIcon strokeWidth={1} className="h-10 w-10" />
<div className="grid grid-flow-row gap-1"> <div className="grid grid-flow-row gap-1">
<Text className="text-center font-medium" variant="h3"> <Text className="text-center font-medium" variant="h3">
No Deployments No Deployments
</Text> </Text>
<Text variant="subtitle1" className="text-center"> <Text variant="subtitle1" className="max-w-sm text-center">
Connect your project with a GitHub repository to create your first Connect your project with a GitHub repository to create your first
deployment deployment
</Text> </Text>
@@ -183,6 +185,7 @@ export default function OverviewDeployments() {
color="primary" color="primary"
className="w-full" className="w-full"
onClick={openGitHubModal} onClick={openGitHubModal}
disabled={maintenanceActive}
> >
<GithubIcon className="mr-1.5 h-4 w-4 self-center" /> <GithubIcon className="mr-1.5 h-4 w-4 self-center" />
Connect to GitHub Connect to GitHub

View File

@@ -1,4 +1,5 @@
import GithubIcon from '@/components/icons/GithubIcon'; import GithubIcon from '@/components/icons/GithubIcon';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import Box from '@/ui/v2/Box'; import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
@@ -8,6 +9,7 @@ import NavLink from 'next/link';
export default function OverviewRepository() { export default function OverviewRepository() {
const { currentWorkspace, currentApplication } = const { currentWorkspace, currentApplication } =
useCurrentWorkspaceAndApplication(); useCurrentWorkspaceAndApplication();
const { maintenanceActive } = useUI();
return ( return (
<div> <div>
@@ -28,6 +30,7 @@ export default function OverviewRepository() {
color="secondary" color="secondary"
className="w-full border-1 hover:border-1" className="w-full border-1 hover:border-1"
startIcon={<GithubIcon />} startIcon={<GithubIcon />}
disabled={maintenanceActive}
> >
Connect to GitHub Connect to GitHub
</Button> </Button>
@@ -39,7 +42,7 @@ export default function OverviewRepository() {
sx={{ backgroundColor: 'grey.200' }} sx={{ backgroundColor: 'grey.200' }}
> >
<Box <Box
className="grid grid-flow-col gap-1.5 ml-2" className="ml-2 grid grid-flow-col gap-1.5"
sx={{ backgroundColor: 'transparent' }} sx={{ backgroundColor: 'transparent' }}
> >
<GithubIcon className="h-4 w-4 self-center" /> <GithubIcon className="h-4 w-4 self-center" />
@@ -52,7 +55,11 @@ export default function OverviewRepository() {
href={`/${currentWorkspace.slug}/${currentApplication.slug}/settings/git`} href={`/${currentWorkspace.slug}/${currentApplication.slug}/settings/git`}
passHref passHref
> >
<Button variant="borderless" size="small"> <Button
variant="borderless"
size="small"
disabled={maintenanceActive}
>
Edit Edit
</Button> </Button>
</NavLink> </NavLink>

View File

@@ -1,8 +1,9 @@
import { ChangePlanModal } from '@/components/applications/ChangePlanModal'; import { ChangePlanModal } from '@/components/applications/ChangePlanModal';
import { useDialog } from '@/components/common/DialogProvider'; import { useDialog } from '@/components/common/DialogProvider';
import { useUI } from '@/context/UIContext';
import useIsPlatform from '@/hooks/common/useIsPlatform'; import useIsPlatform from '@/hooks/common/useIsPlatform';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip'; import Chip from '@/ui/v2/Chip';
import CogIcon from '@/ui/v2/icons/CogIcon'; import CogIcon from '@/ui/v2/icons/CogIcon';
@@ -16,6 +17,7 @@ export default function OverviewTopBar() {
useCurrentWorkspaceAndApplication(); useCurrentWorkspaceAndApplication();
const isPro = !currentApplication?.plan?.isFree; const isPro = !currentApplication?.plan?.isFree;
const { openAlertDialog } = useDialog(); const { openAlertDialog } = useDialog();
const { maintenanceActive } = useUI();
if (!isPlatform) { if (!isPlatform) {
return ( return (
@@ -41,9 +43,9 @@ export default function OverviewTopBar() {
} }
return ( return (
<div className="flex flex-row place-content-between items-center py-5"> <div className="grid items-center gap-4 pb-5 md:grid-flow-col md:place-content-between md:py-5">
<div className="flex flex-row items-center space-x-2"> <div className="grid items-center gap-2 md:grid-flow-col">
<div className="grid grid-flow-col items-center gap-2"> <div className="grid grid-flow-col items-center justify-start gap-2">
<div className="h-10 w-10 overflow-hidden rounded-lg"> <div className="h-10 w-10 overflow-hidden rounded-lg">
<Image <Image
src="/logos/new.svg" src="/logos/new.svg"
@@ -58,43 +60,44 @@ export default function OverviewTopBar() {
</Text> </Text>
</div> </div>
{isPro ? ( <Box className="grid grid-flow-col items-center justify-start gap-2">
<Chip {isPro ? (
className="self-center font-medium"
size="small"
label="Pro Plan"
color="primary"
/>
) : (
<>
<Chip <Chip
className="self-center font-medium" className="self-center font-medium"
size="small" size="small"
label="Free Plan" label="Pro Plan"
color="default" color="primary"
variant="filled"
/> />
<Button ) : (
variant="borderless" <>
className="mr-2" <Chip
onClick={() => { className="self-center font-medium"
openAlertDialog({ size="small"
title: 'Upgrade your plan.', label="Free Plan"
payload: <ChangePlanModal />, color="default"
props: { variant="filled"
PaperProps: { className: 'p-0' }, />
hidePrimaryAction: true, <Button
hideSecondaryAction: true, variant="borderless"
hideTitle: true, className="mr-2"
maxWidth: 'lg', onClick={() => {
}, openAlertDialog({
}); title: 'Upgrade your plan.',
}} payload: <ChangePlanModal />,
> props: {
Upgrade PaperProps: { className: 'p-0 max-w-xl w-full' },
</Button> hidePrimaryAction: true,
</> hideSecondaryAction: true,
)} hideTitle: true,
},
});
}}
>
Upgrade
</Button>
</>
)}
</Box>
</div> </div>
<Link <Link
href={`/${currentWorkspace.slug}/${currentApplication.slug}/settings/general`} href={`/${currentWorkspace.slug}/${currentApplication.slug}/settings/general`}
@@ -104,6 +107,7 @@ export default function OverviewTopBar() {
endIcon={<CogIcon className="h-4 w-4" />} endIcon={<CogIcon className="h-4 w-4" />}
variant="outlined" variant="outlined"
color="secondary" color="secondary"
disabled={maintenanceActive}
> >
Settings Settings
</Button> </Button>

View File

@@ -79,6 +79,7 @@ export function OverviewUsageMetrics() {
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const remoteAppClient = useRemoteApplicationGQLClient(); const remoteAppClient = useRemoteApplicationGQLClient();
const [metrics, setMetrics] = useState({ const [metrics, setMetrics] = useState({
functions: 0, functions: 0,
storage: 0, storage: 0,
@@ -98,6 +99,7 @@ export function OverviewUsageMetrics() {
const { data: remoteAppMetricsData } = useGetRemoteAppMetricsQuery({ const { data: remoteAppMetricsData } = useGetRemoteAppMetricsQuery({
client: remoteAppClient, client: remoteAppClient,
skip: !currentApplication,
}); });
useEffect(() => { useEffect(() => {

View File

@@ -1,5 +1,6 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { import {
useResetPostgresPasswordMutation, useResetPostgresPasswordMutation,
useUpdateApplicationMutation, useUpdateApplicationMutation,
@@ -28,6 +29,7 @@ export interface ResetDatabasePasswordFormValues {
export default function ResetDatabasePasswordSettings() { export default function ResetDatabasePasswordSettings() {
const [updateApplication] = useUpdateApplicationMutation(); const [updateApplication] = useUpdateApplicationMutation();
const { maintenanceActive } = useUI();
const form = useForm<ResetDatabasePasswordFormValues>({ const form = useForm<ResetDatabasePasswordFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
@@ -44,11 +46,10 @@ export default function ResetDatabasePasswordSettings() {
setValue, setValue,
getValues, getValues,
register, register,
formState: { errors }, formState: { errors, isDirty, isSubmitting },
} = form; } = form;
const [resetPostgresPasswordMutation, { loading }] = const [resetPostgresPasswordMutation] = useResetPostgresPasswordMutation();
useResetPostgresPasswordMutation();
const user = useUserData(); const user = useUserData();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
@@ -99,12 +100,16 @@ export default function ResetDatabasePasswordSettings() {
title="Reset Password" title="Reset Password"
description="This password is used for accessing your database." description="This password is used for accessing your database."
submitButtonText="Reset" submitButtonText="Reset"
rootClassName="border-[#F87171]" slotProps={{
primaryActionButtonProps={{ root: {
variant: 'contained', sx: { borderColor: (theme) => theme.palette.error.main },
color: 'error', },
disabled: Boolean(errors?.databasePassword), submitButton: {
loading, variant: 'contained',
color: 'error',
disabled: !isDirty || maintenanceActive,
loading: isSubmitting,
},
}} }}
className="grid grid-flow-row pb-4" className="grid grid-flow-row pb-4"
> >

View File

@@ -179,6 +179,15 @@ export default function SettingsSidebar({
> >
Environment Variables Environment Variables
</SettingsNavLink> </SettingsNavLink>
<SettingsNavLink
href="/secrets"
exact={false}
onClick={handleSelect}
className="hidden"
>
Secrets
</SettingsNavLink>
</List> </List>
</nav> </nav>
</Box> </Box>

View File

@@ -1,68 +1,61 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql'; import { useUI } from '@/context/UIContext';
import {
GetAuthenticationSettingsDocument,
useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useEffect } from 'react'; import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface AllowedEmailSettingsFormValues { const validationSchema = Yup.object({
/** enabled: Yup.boolean().label('Enabled'),
* Determines whether or not the allowed email settings are enabled. allowedEmails: Yup.string().label('Allowed Emails'),
*/ allowedEmailDomains: Yup.string().label('Allowed Email Domains'),
enabled: boolean; });
/**
* Set of email that are allowed to be used for project's users authentication. export type AllowedEmailSettingsFormValues = Yup.InferType<
*/ typeof validationSchema
authAccessControlAllowedEmails: string; >;
/**
* Set of email domains that are allowed to be used for project's users authentication.
* @example 'nhost.io'
*/
authAccessControlAllowedEmailDomains: string;
}
export default function AllowedEmailDomainsSettings() { export default function AllowedEmailDomainsSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
const { data, loading, error } = useGetAppQuery({
variables: {
id: currentApplication?.id,
},
}); });
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { email, emailDomains } = data?.config?.auth?.user || {};
const form = useForm<AllowedEmailSettingsFormValues>({ const form = useForm<AllowedEmailSettingsFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
enabled: enabled: email?.allowed?.length > 0 || emailDomains?.allowed?.length > 0,
Boolean(data?.app?.authAccessControlAllowedEmails) || allowedEmails: email?.allowed?.join(', ') || '',
Boolean(data?.app?.authAccessControlAllowedEmailDomains), allowedEmailDomains: emailDomains?.allowed?.join(', ') || '',
authAccessControlAllowedEmails: data?.app?.authAccessControlAllowedEmails,
authAccessControlAllowedEmailDomains:
data?.app?.authAccessControlAllowedEmailDomains,
}, },
resolver: yupResolver(validationSchema),
}); });
const { register, formState, setValue, watch } = form; const { register, formState, watch } = form;
const enabled = watch('enabled'); const enabled = watch('enabled');
const isDirty = Object.keys(formState.dirtyFields).length > 0; const isDirty = Object.keys(formState.dirtyFields).length > 0;
useEffect(() => {
if (
!data.app?.authAccessControlAllowedEmails &&
!data.app?.authAccessControlAllowedEmailDomains
) {
return;
}
setValue('enabled', true, { shouldDirty: false });
}, [data.app, setValue]);
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
@@ -80,29 +73,51 @@ export default function AllowedEmailDomainsSettings() {
const handleAllowedEmailDomainsChange = async ( const handleAllowedEmailDomainsChange = async (
values: AllowedEmailSettingsFormValues, values: AllowedEmailSettingsFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
authAccessControlAllowedEmails: values.enabled auth: {
? values.authAccessControlAllowedEmails user: {
: '', email: {
authAccessControlAllowedEmailDomains: values.enabled blocked: email.blocked,
? values.authAccessControlAllowedEmailDomains allowed:
: '', values.enabled && values.allowedEmails
? values.allowedEmails
.split(',')
.map((allowedEmail) => allowedEmail.trim())
: [],
},
emailDomains: {
blocked: emailDomains.blocked,
allowed:
values.enabled && values.allowedEmailDomains
? values.allowedEmailDomains
.split(',')
.map((allowedEmailDomain) => allowedEmailDomain.trim())
: [],
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Allowed email settings are being updated...`, {
success: `Allowed email settings have been updated successfully.`, loading: `Allowed email settings are being updated...`,
error: `An error occurred while trying to update the project's allowed email settings.`, success: `Allowed email settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's allowed email settings.`,
); ),
},
getToastStyleProps(),
);
} catch {
// Note: The toast will handle the error
}
form.reset(values); form.reset(values);
}; };
@@ -115,15 +130,12 @@ export default function AllowedEmailDomainsSettings() {
description="Allow specific email addresses and domains to sign up." description="Allow specific email addresses and domains to sign up."
slotProps={{ slotProps={{
submitButton: { submitButton: {
disabled: !formState.isValid || !isDirty, disabled: !isDirty || maintenanceActive,
loading: formState.isSubmitting, loading: formState.isSubmitting,
}, },
}} }}
docsLink="https://docs.nhost.io/authentication#allowed-emails-and-domains" docsLink="https://docs.nhost.io/authentication#allowed-emails-and-domains"
enabled={enabled} switchId="enabled"
onEnabledChange={(switchEnabled) =>
setValue('enabled', switchEnabled, { shouldDirty: true })
}
showSwitch showSwitch
className={twMerge( className={twMerge(
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3', 'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
@@ -131,9 +143,9 @@ export default function AllowedEmailDomainsSettings() {
)} )}
> >
<Input <Input
{...register('authAccessControlAllowedEmails')} {...register('allowedEmails')}
name="authAccessControlAllowedEmails" name="allowedEmails"
id="authAccessControlAllowedEmails" id="allowedEmails"
placeholder="These emails (separated by comma, e.g, david@ikea.com, lisa@mycompany.com)" placeholder="These emails (separated by comma, e.g, david@ikea.com, lisa@mycompany.com)"
className="col-span-2" className="col-span-2"
label="Allowed Emails (comma separated)" label="Allowed Emails (comma separated)"
@@ -141,9 +153,9 @@ export default function AllowedEmailDomainsSettings() {
hideEmptyHelperText hideEmptyHelperText
/> />
<Input <Input
{...register('authAccessControlAllowedEmailDomains')} {...register('allowedEmailDomains')}
name="authAccessControlAllowedEmailDomains" name="allowedEmailDomains"
id="authAccessControlAllowedEmailDomains" id="allowedEmailDomains"
label="Allowed Email Domains (comma sepated list)" label="Allowed Email Domains (comma sepated list)"
placeholder="These email domains (separated by comma, e.g, ikea.com, mycompany.com)" placeholder="These email domains (separated by comma, e.g, ikea.com, mycompany.com)"
className="col-span-2" className="col-span-2"

View File

@@ -1,36 +1,49 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql'; import { useUI } from '@/context/UIContext';
import {
GetAuthenticationSettingsDocument,
useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface AllowedRedirectURLFormValues { const validationSchema = Yup.object({
/** allowedUrls: Yup.string().label('Allowed Redirect URLs'),
* Set of URLs that are allowed to be redirected to after project's users authentication. });
*/
authAccessControlAllowedRedirectUrls: string; export type AllowedRedirectURLFormValues = Yup.InferType<
} typeof validationSchema
>;
export default function AllowedRedirectURLsSettings() { export default function AllowedRedirectURLsSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
const { data, loading, error } = useGetAppQuery({
variables: {
id: currentApplication?.id,
},
}); });
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { allowedUrls } = data?.config?.auth?.redirections || {};
const form = useForm<AllowedRedirectURLFormValues>({ const form = useForm<AllowedRedirectURLFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authAccessControlAllowedRedirectUrls: allowedUrls: allowedUrls?.join(', ') || '',
data?.app?.authAccessControlAllowedRedirectUrls,
}, },
resolver: yupResolver(validationSchema),
}); });
if (loading) { if (loading) {
@@ -52,26 +65,38 @@ export default function AllowedRedirectURLsSettings() {
const handleAllowedRedirectURLsChange = async ( const handleAllowedRedirectURLsChange = async (
values: AllowedRedirectURLFormValues, values: AllowedRedirectURLFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
...values, auth: {
redirections: {
allowedUrls: values.allowedUrls
? values.allowedUrls.split(',').map((url) => url.trim())
: [],
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Allowed redirect URL settings are being updated...`, {
success: `Allowed redirect URL settings have been updated successfully.`, loading: `Allowed redirect URL settings are being updated...`,
error: `An error occurred while trying to update the project's allowed redirect URL settings.`, success: `Allowed redirect URL settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's allowed redirect URL settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -80,17 +105,19 @@ export default function AllowedRedirectURLsSettings() {
<SettingsContainer <SettingsContainer
title="Allowed Redirect URLs" title="Allowed Redirect URLs"
description="Allowed URLs where users can be redirected to after authentication. Separate multiple redirect URLs with comma." description="Allowed URLs where users can be redirected to after authentication. Separate multiple redirect URLs with comma."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/authentication#allowed-redirect-urls" docsLink="https://docs.nhost.io/authentication#allowed-redirect-urls"
className="grid grid-flow-row px-4 lg:grid-cols-5" className="grid grid-flow-row px-4 lg:grid-cols-5"
> >
<Input <Input
{...register('authAccessControlAllowedRedirectUrls')} {...register('allowedUrls')}
name="authAccessControlAllowedRedirectUrls" name="allowedUrls"
id="authAccessControlAllowedRedirectUrls" id="allowedUrls"
placeholder="http://localhost:3000, http://localhost:4000" placeholder="http://localhost:3000, http://localhost:4000"
className="col-span-2" className="col-span-2"
fullWidth fullWidth

View File

@@ -1,67 +1,58 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql'; import { useUI } from '@/context/UIContext';
import {
GetAuthenticationSettingsDocument,
useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useEffect } from 'react'; import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface BlockedEmailFormValues { const validationSchema = Yup.object({
/** enabled: Yup.boolean().label('Enabled'),
* Determines whether or not the blocked email settings are enabled. blockedEmails: Yup.string().label('Blocked Emails'),
*/ blockedEmailDomains: Yup.string().label('Blocked Email Domains'),
enabled: boolean; });
/**
* Set of emails that are blocked from registering to the user's project. export type BlockedEmailFormValues = Yup.InferType<typeof validationSchema>;
*/
authAccessControlBlockedEmails: string;
/**
* Set of email domains that are blocked from registering to the user's project.
*/
authAccessControlBlockedEmailDomains: string;
}
export default function BlockedEmailSettings() { export default function BlockedEmailSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
const { data, loading, error } = useGetAppQuery({
variables: {
id: currentApplication?.id,
},
}); });
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { email, emailDomains } = data?.config?.auth?.user || {};
const form = useForm<BlockedEmailFormValues>({ const form = useForm<BlockedEmailFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
enabled: enabled: email?.blocked?.length > 0 || emailDomains?.blocked?.length > 0,
Boolean(data?.app?.authAccessControlBlockedEmails) || blockedEmails: email?.blocked?.join(', ') || '',
Boolean(data?.app?.authAccessControlBlockedEmailDomains), blockedEmailDomains: emailDomains?.blocked?.join(', ') || '',
authAccessControlBlockedEmails: data?.app?.authAccessControlBlockedEmails,
authAccessControlBlockedEmailDomains:
data?.app?.authAccessControlBlockedEmailDomains,
}, },
resolver: yupResolver(validationSchema),
}); });
const { register, formState, setValue, watch } = form; const { register, formState, watch } = form;
const enabled = watch('enabled'); const enabled = watch('enabled');
const isDirty = Object.keys(formState.dirtyFields).length > 0; const isDirty = Object.keys(formState.dirtyFields).length > 0;
useEffect(() => {
if (
!data.app?.authAccessControlBlockedEmails &&
!data.app?.authAccessControlBlockedEmailDomains
) {
return;
}
setValue('enabled', true, { shouldDirty: false });
}, [data.app, setValue]);
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
@@ -79,31 +70,63 @@ export default function BlockedEmailSettings() {
const handleAllowedEmailDomainsChange = async ( const handleAllowedEmailDomainsChange = async (
values: BlockedEmailFormValues, values: BlockedEmailFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
authAccessControlBlockedEmails: values.enabled auth: {
? values.authAccessControlBlockedEmails user: {
: '', email: {
authAccessControlBlockedEmailDomains: values.enabled allowed: email.allowed,
? values.authAccessControlBlockedEmailDomains blocked:
: '', values.enabled && values.blockedEmails
? [
...new Set(
values.blockedEmails
.split(',')
.map((blockedEmail) => blockedEmail.trim()),
),
]
: [],
},
emailDomains: {
allowed: emailDomains.allowed,
blocked:
values.enabled && values.blockedEmailDomains
? [
...new Set(
values.blockedEmailDomains
.split(',')
.map((blockedEmailDomain) =>
blockedEmailDomain.trim(),
),
),
]
: [],
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Blocked email and domain settings are being updated...`, {
success: `Blocked email and domain settings have been updated successfully.`, loading: `Blocked email and domain settings are being updated...`,
error: `An error occurred while trying to update the project's blocked email and domain settings.`, success: `Blocked email and domain settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's blocked email and domain settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -114,15 +137,12 @@ export default function BlockedEmailSettings() {
description="Block specific email addresses and domains to sign up." description="Block specific email addresses and domains to sign up."
slotProps={{ slotProps={{
submitButton: { submitButton: {
disabled: !formState.isValid || !isDirty, disabled: !isDirty || maintenanceActive,
loading: formState.isSubmitting, loading: formState.isSubmitting,
}, },
}} }}
docsLink="https://docs.nhost.io/authentication#blocked-emails-and-domains" docsLink="https://docs.nhost.io/authentication#blocked-emails-and-domains"
enabled={enabled} switchId="enabled"
onEnabledChange={(switchEnabled) =>
setValue('enabled', switchEnabled, { shouldDirty: true })
}
showSwitch showSwitch
className={twMerge( className={twMerge(
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3', 'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
@@ -130,9 +150,9 @@ export default function BlockedEmailSettings() {
)} )}
> >
<Input <Input
{...register('authAccessControlBlockedEmails')} {...register('blockedEmails')}
name="authAccessControlBlockedEmails" name="blockedEmails"
id="authAccessControlBlockedEmails" id="blockedEmails"
placeholder="These emails (separated by comma, e.g, david@ikea.com, lisa@mycompany.com)" placeholder="These emails (separated by comma, e.g, david@ikea.com, lisa@mycompany.com)"
className="col-span-2" className="col-span-2"
label="Blocked Emails (comma separated)" label="Blocked Emails (comma separated)"
@@ -140,9 +160,9 @@ export default function BlockedEmailSettings() {
hideEmptyHelperText hideEmptyHelperText
/> />
<Input <Input
{...register('authAccessControlBlockedEmailDomains')} {...register('blockedEmailDomains')}
name="authAccessControlBlockedEmailDomains" name="blockedEmailDomains"
id="authAccessControlBlockedEmailDomains" id="blockedEmailDomains"
label="Blocked Email Domains (comma sepated list)" label="Blocked Email Domains (comma sepated list)"
placeholder="These email domains (separated by comma, e.g, ikea.com, mycompany.com)" placeholder="These email domains (separated by comma, e.g, ikea.com, mycompany.com)"
className="col-span-2" className="col-span-2"

View File

@@ -1,36 +1,47 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useGetAppQuery, useUpdateAppMutation } from '@/generated/graphql'; import { useUI } from '@/context/UIContext';
import {
GetAuthenticationSettingsDocument,
useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface ClientURLFormValues { const validationSchema = Yup.object({
/** clientUrl: Yup.string().label('Client URL'),
* The URL of the frontend app of where users are redirected after authenticating. });
*/
authClientUrl: string; export type ClientURLFormValues = Yup.InferType<typeof validationSchema>;
}
export default function ClientURLSettings() { export default function ClientURLSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation({ refetchQueries: ['GetApp'] }); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
const { data, loading, error } = useGetAppQuery({
variables: {
id: currentApplication?.id,
},
fetchPolicy: 'cache-first',
}); });
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { clientUrl, allowedUrls } = data?.config?.auth?.redirections || {};
const form = useForm<ClientURLFormValues>({ const form = useForm<ClientURLFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authClientUrl: data?.app?.authClientUrl, clientUrl,
}, },
resolver: yupResolver(validationSchema),
}); });
if (loading) { if (loading) {
@@ -50,26 +61,37 @@ export default function ClientURLSettings() {
const { register, formState } = form; const { register, formState } = form;
const handleClientURLChange = async (values: ClientURLFormValues) => { const handleClientURLChange = async (values: ClientURLFormValues) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
...values, auth: {
redirections: {
...values,
allowedUrls,
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Client URL is being updated...`, {
success: `Client URL has been updated successfully.`, loading: `Client URL is being updated...`,
error: `An error occurred while trying to update the project's Client URL.`, success: `Client URL has been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's Client URL.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -78,22 +100,26 @@ export default function ClientURLSettings() {
<SettingsContainer <SettingsContainer
title="Client URL" title="Client URL"
description="This should be the URL of your frontend app where users are redirected after authenticating." description="This should be the URL of your frontend app where users are redirected after authenticating."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/authentication#client-url" docsLink="https://docs.nhost.io/authentication#client-url"
className="grid grid-flow-row lg:grid-cols-5" className="grid grid-flow-row lg:grid-cols-5"
> >
<Input <Input
{...register('authClientUrl')} {...register('clientUrl')}
name="authClientUrl" name="clientUrl"
id="authClientUrl" id="clientUrl"
placeholder="http://localhost:3000" placeholder="http://localhost:3000"
className="col-span-2" className="col-span-2"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
aria-label="Client URL" aria-label="Client URL"
error={!!formState.errors?.clientUrl}
helperText={formState.errors?.clientUrl?.message}
/> />
</SettingsContainer> </SettingsContainer>
</Form> </Form>

View File

@@ -1,46 +1,44 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
useGetAuthSettingsQuery, GetAuthenticationSettingsDocument,
useUpdateAppMutation, useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface DisableNewUsersFormValues { const validationSchema = Yup.object({
/** disabled: Yup.boolean(),
* Disable new users from signing up to this project });
*/
authDisableNewUsers: boolean; export type DisableNewUsersFormValues = Yup.InferType<typeof validationSchema>;
}
export default function DisableNewUsersSettings() { export default function DisableNewUsersSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
});
const { data, loading, error } = useGetAuthSettingsQuery({ const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id, fetchPolicy: 'cache-only',
},
}); });
const form = useForm<DisableNewUsersFormValues>({ const form = useForm<DisableNewUsersFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authDisableNewUsers: data?.app?.authDisableNewUsers, disabled: !!data?.config?.auth?.signUp?.enabled,
}, },
}); });
useEffect(() => {
form.reset(() => ({
authDisableNewUsers: data?.app?.authDisableNewUsers,
}));
}, [data?.app?.authDisableNewUsers, form, form.reset]);
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
@@ -55,32 +53,41 @@ export default function DisableNewUsersSettings() {
throw error; throw error;
} }
const { formState, watch } = form; const { formState } = form;
const authDisableNewUsers = watch('authDisableNewUsers');
const handleDisableNewUsersChange = async ( const handleDisableNewUsersChange = async (
values: DisableNewUsersFormValues, values: DisableNewUsersFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
...values, auth: {
signUp: {
enabled: !values.disabled,
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Disabling new user sign ups...`, {
success: `New user sign ups have been disabled successfully.`, loading: `Disabling new user sign ups...`,
error: `An error occurred while trying to disable new user sign ups.`, success: `New user sign ups have been disabled successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to disable new user sign ups.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -88,14 +95,15 @@ export default function DisableNewUsersSettings() {
<Form onSubmit={handleDisableNewUsersChange}> <Form onSubmit={handleDisableNewUsersChange}>
<SettingsContainer <SettingsContainer
title="Disable New Users" title="Disable New Users"
description="If set, newly registered users are disabled and wont be able to sign in." description="If set, newly registered users are disabled and won't be able to sign in."
docsLink="https://docs.nhost.io/authentication#disable-new-users" docsLink="https://docs.nhost.io/authentication#disable-new-users"
switchId="authDisableNewUsers" switchId="disabled"
showSwitch showSwitch
enabled={authDisableNewUsers} slotProps={{
primaryActionButtonProps={{ submitButton: {
disabled: !formState.isValid || !formState.isDirty, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting, loading: formState.isSubmitting,
},
}} }}
className="hidden" className="hidden"
/> />

View File

@@ -1,65 +1,63 @@
import ControlledSelect from '@/components/common/ControlledSelect'; import ControlledSelect from '@/components/common/ControlledSelect';
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { import {
useGetGravatarSettingsQuery, GetAuthenticationSettingsDocument,
useUpdateAppMutation, useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Option from '@/ui/v2/Option'; import Option from '@/ui/v2/Option';
import getServerError from '@/utils/settings/getServerError';
import { import {
AUTH_GRAVATAR_DEFAULT, AUTH_GRAVATAR_DEFAULT,
AUTH_GRAVATAR_RATING, AUTH_GRAVATAR_RATING,
getToastStyleProps, getToastStyleProps,
} from '@/utils/settings/settingsConstants'; } from '@/utils/settings/settingsConstants';
import { useEffect } from 'react'; import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface GravatarFormValues { const validationSchema = Yup.object({
/** enabled: Yup.boolean().label('Enabled'),
* Gravatar image to use as default. default: Yup.string().label('Default Gravatar'),
*/ rating: Yup.string().label('Gravatar Rating'),
authGravatarDefault: string; });
/**
* Gravatar image rating. export type GravatarFormValues = Yup.InferType<typeof validationSchema>;
*/
authGravatarRating: string;
/**
* Enable Gravatar for this project
*/
authGravatarEnabled: boolean;
}
export default function GravatarSettings() { export default function GravatarSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
const { data, loading, error } = useGetGravatarSettingsQuery({
variables: {
id: currentApplication?.id,
},
}); });
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const {
default: defaultGravatar,
rating,
enabled,
} = data?.config?.auth?.user?.gravatar || {};
const form = useForm<GravatarFormValues>({ const form = useForm<GravatarFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authGravatarDefault: data?.app?.authGravatarDefault || '', default: defaultGravatar || '',
authGravatarRating: data?.app?.authGravatarRating || '', rating: rating || '',
authGravatarEnabled: data?.app?.authGravatarEnabled || false, enabled: enabled || false,
}, },
resolver: yupResolver(validationSchema),
}); });
useEffect(() => {
form.reset(() => ({
authGravatarDefault: data?.app?.authGravatarDefault || '',
authGravatarRating: data?.app?.authGravatarRating || '',
authGravatarEnabled: data?.app?.authGravatarEnabled || false,
}));
}, [data?.app, form, form.reset]);
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
@@ -75,29 +73,39 @@ export default function GravatarSettings() {
} }
const { register, formState, watch } = form; const { register, formState, watch } = form;
const authGravatarEnabled = watch('authGravatarEnabled'); const gravatarEnabled = watch('enabled') ?? false;
const handleGravatarSettingsChange = async (values: GravatarFormValues) => { const handleGravatarSettingsChange = async (values: GravatarFormValues) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
...values, auth: {
user: {
gravatar: values,
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Gravatar settings are being updated...`, {
success: `Gravatar settings have been updated successfully.`, loading: `Gravatar settings are being updated...`,
error: `An error occurred while trying to update the project's Gravatar settings.`, success: `Gravatar settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's Gravatar settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -106,22 +114,23 @@ export default function GravatarSettings() {
<SettingsContainer <SettingsContainer
title="Gravatar" title="Gravatar"
description="Use Gravatars for avatar URLs for users." description="Use Gravatars for avatar URLs for users."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/authentication#gravatar" docsLink="https://docs.nhost.io/authentication#gravatar"
switchId="authGravatarEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authGravatarEnabled}
className={twMerge( className={twMerge(
'grid grid-flow-col grid-cols-5 grid-rows-2 gap-y-6', 'grid grid-flow-col grid-cols-5 grid-rows-2 gap-y-6',
!authGravatarEnabled && 'hidden', !gravatarEnabled && 'hidden',
)} )}
> >
<ControlledSelect <ControlledSelect
{...register('authGravatarDefault')} {...register('default')}
id="authGravatarDefault" id="default"
className="col-span-5 lg:col-span-2" className="col-span-5 lg:col-span-2"
placeholder="Default Gravatar" placeholder="Default Gravatar"
hideEmptyHelperText hideEmptyHelperText
@@ -135,8 +144,8 @@ export default function GravatarSettings() {
))} ))}
</ControlledSelect> </ControlledSelect>
<ControlledSelect <ControlledSelect
{...register('authGravatarRating')} {...register('rating')}
id="authGravatarRating" id="rating"
className="col-span-5 lg:col-span-2" className="col-span-5 lg:col-span-2"
placeholder="Gravatar Rating" placeholder="Gravatar Rating"
hideEmptyHelperText hideEmptyHelperText

View File

@@ -1,54 +1,52 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { import {
useGetAuthSettingsQuery, GetAuthenticationSettingsDocument,
useUpdateAppMutation, useGetAuthenticationSettingsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useEffect } from 'react'; import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface MFASettingsFormValues { const validationSchema = Yup.object({
/** enabled: Yup.boolean().label('Enabled'),
* One Time Password issuer issuer: Yup.string().label('OTP Issuer').nullable().required(),
*/ });
authMfaTotpIssuer: string;
/** export type MFASettingsFormValues = Yup.InferType<typeof validationSchema>;
* Enable Multi Factor Authentication for this project
*/
authMfaEnabled: boolean;
}
export default function MFASettings() { export default function MFASettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetAuthenticationSettingsDocument],
const { data, loading, error } = useGetAuthSettingsQuery({
variables: {
id: currentApplication?.id,
},
}); });
const { data, loading, error } = useGetAuthenticationSettingsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { enabled, issuer } = data?.config?.auth?.totp || {};
const form = useForm<MFASettingsFormValues>({ const form = useForm<MFASettingsFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authMfaTotpIssuer: data?.app?.authMfaTotpIssuer, issuer,
authMfaEnabled: data?.app?.authMfaEnabled, enabled,
}, },
resolver: yupResolver(validationSchema),
}); });
useEffect(() => {
form.reset(() => ({
authMfaTotpIssuer: data?.app?.authMfaTotpIssuer,
authMfaEnabled: data?.app?.authMfaEnabled,
}));
}, [data?.app, form, form.reset]);
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
@@ -64,29 +62,37 @@ export default function MFASettings() {
} }
const { register, formState, watch } = form; const { register, formState, watch } = form;
const authMfaEnabled = watch('authMfaEnabled'); const authMfaEnabled = watch('enabled');
const handleMFASettingsChange = async (values: MFASettingsFormValues) => { const handleMFASettingsChange = async (values: MFASettingsFormValues) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
...values, auth: {
totp: values,
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Multi-factor authentication settings are being updated...`, {
success: `Multi-factor authentication settings have been updated successfully.`, loading: `Multi-factor authentication settings are being updated...`,
error: `An error occurred while trying to update the project's multi-factor authentication settings.`, success: `Multi-factor authentication settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's multi-factor authentication settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -95,13 +101,14 @@ export default function MFASettings() {
<SettingsContainer <SettingsContainer
title="Multi-Factor Authentication" title="Multi-Factor Authentication"
description="Enable users to use MFA to sign in" description="Enable users to use MFA to sign in"
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/authentication#multi-factor-authentication" docsLink="https://docs.nhost.io/authentication#multi-factor-authentication"
switchId="authMfaEnabled" switchId="enabled"
enabled={authMfaEnabled}
showSwitch showSwitch
className={twMerge( className={twMerge(
'grid grid-flow-row lg:grid-cols-5', 'grid grid-flow-row lg:grid-cols-5',
@@ -109,14 +116,16 @@ export default function MFASettings() {
)} )}
> >
<Input <Input
{...register('authMfaTotpIssuer')} {...register('issuer')}
name="authMfaTotpIssuer" name="issuer"
id="authMfaTotpIssuer" id="issuer"
label="OTP Issuer" label="OTP Issuer"
placeholder="Name of the One Time Password (OTP) issuer" placeholder="Name of the One Time Password (OTP) issuer"
className="col-span-2" className="col-span-2"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
error={!!formState.errors?.issuer}
helperText={formState.errors?.issuer?.message}
/> />
</SettingsContainer> </SettingsContainer>
</Form> </Form>

View File

@@ -8,25 +8,6 @@ import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup'; import * as Yup from 'yup';
export interface BaseEnvironmentVariableFormValues {
/**
* Identifier of the environment variable.
*/
id: string;
/**
* The name of the role.
*/
name: string;
/**
* Development environment variable value.
*/
devValue: string;
/**
* Production environment variable value.
*/
prodValue: string;
}
export interface BaseEnvironmentVariableFormProps extends DialogFormProps { export interface BaseEnvironmentVariableFormProps extends DialogFormProps {
/** /**
* Determines the mode of the form. * Determines the mode of the form.
@@ -51,8 +32,11 @@ export interface BaseEnvironmentVariableFormProps extends DialogFormProps {
} }
export const baseEnvironmentVariableFormValidationSchema = Yup.object({ export const baseEnvironmentVariableFormValidationSchema = Yup.object({
id: Yup.string().label('ID'),
name: Yup.string() name: Yup.string()
.required('This field is required.') .label('Name')
.nullable()
.required()
.test( .test(
'isEnvVarPermitted', 'isEnvVarPermitted',
'This is a reserved name.', 'This is a reserved name.',
@@ -78,13 +62,18 @@ export const baseEnvironmentVariableFormValidationSchema = Yup.object({
(prefix) => !value.startsWith(prefix), (prefix) => !value.startsWith(prefix),
), ),
) )
.test('isEnvVarValid', `The name must start with a letter.`, (value) => .test(
/^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value), 'isEnvVarValid',
'A name must start with a letter and can only contain letters, numbers, and underscores.',
(value) => /^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
), ),
devValue: Yup.string().required('This field is required.'), value: Yup.string().label('Value').nullable().required(),
prodValue: Yup.string().required('This field is required.'),
}); });
export type BaseEnvironmentVariableFormValues = Yup.InferType<
typeof baseEnvironmentVariableFormValidationSchema
>;
export default function BaseEnvironmentVariableForm({ export default function BaseEnvironmentVariableForm({
mode = 'edit', mode = 'edit',
onSubmit, onSubmit,
@@ -117,21 +106,7 @@ export default function BaseEnvironmentVariableForm({
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4"> <Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input <Input
{...register('name', { {...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,
'',
);
}
},
})}
id="name" id="name"
label="Name" label="Name"
placeholder="EXAMPLE_NAME" placeholder="EXAMPLE_NAME"
@@ -145,30 +120,18 @@ export default function BaseEnvironmentVariableForm({
/> />
<Input <Input
{...register('prodValue')} {...register('value')}
id="prodValue" id="value"
label="Production Value" label="Value"
placeholder="Enter value" placeholder="Enter value"
hideEmptyHelperText hideEmptyHelperText
error={!!errors.prodValue} error={!!errors.value}
helperText={errors?.prodValue?.message} helperText={errors?.value?.message}
fullWidth fullWidth
autoComplete="off" autoComplete="off"
autoFocus={mode === 'edit'} autoFocus={mode === 'edit'}
/> />
<Input
{...register('devValue')}
id="devValue"
label="Development Value"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.devValue}
helperText={errors?.devValue?.message}
fullWidth
autoComplete="off"
/>
<div className="grid grid-flow-row gap-2"> <div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}> <Button type="submit" loading={isSubmitting}>
{submitButtonText} {submitButtonText}

View File

@@ -7,10 +7,12 @@ import BaseEnvironmentVariableForm, {
} from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm'; } from '@/components/settings/environmentVariables/BaseEnvironmentVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
GetEnvironmentVariablesDocument,
useGetEnvironmentVariablesQuery, useGetEnvironmentVariablesQuery,
useInsertEnvironmentVariablesMutation, useUpdateConfigMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
@@ -31,8 +33,7 @@ export default function CreateEnvironmentVariableForm({
const form = useForm<BaseEnvironmentVariableFormValues>({ const form = useForm<BaseEnvironmentVariableFormValues>({
defaultValues: { defaultValues: {
name: '', name: '',
devValue: '', value: '',
prodValue: '',
}, },
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema), resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
@@ -41,14 +42,14 @@ export default function CreateEnvironmentVariableForm({
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({ const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication?.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const [insertEnvironmentVariables] = useInsertEnvironmentVariablesMutation({ const availableEnvironmentVariables = data?.config?.global?.environment || [];
refetchQueries: ['getEnvironmentVariables'],
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetEnvironmentVariablesDocument],
}); });
if (loading) { if (loading) {
@@ -68,13 +69,10 @@ export default function CreateEnvironmentVariableForm({
async function handleSubmit({ async function handleSubmit({
name, name,
prodValue, value,
devValue,
}: BaseEnvironmentVariableFormValues) { }: BaseEnvironmentVariableFormValues) {
if ( if (
data?.environmentVariables?.some( availableEnvironmentVariables?.some((variable) => variable.name === name)
(environmentVariable) => environmentVariable.name === name,
)
) { ) {
setError('name', { setError('name', {
message: 'This environment variable already exists.', message: 'This environment variable already exists.',
@@ -83,20 +81,34 @@ export default function CreateEnvironmentVariableForm({
return; return;
} }
const insertEnvironmentVariablePromise = insertEnvironmentVariables({ const updateConfigPromise = updateConfig({
variables: { variables: {
environmentVariables: [ appId: currentApplication?.id,
{ appId: currentApplication.id, name, prodValue, devValue }, config: {
], global: {
environment: [
...(availableEnvironmentVariables?.map((variable) => ({
name: variable.name,
value: variable.value,
})) || []),
{
name,
value,
},
],
},
},
}, },
}); });
await toast.promise( await toast.promise(
insertEnvironmentVariablePromise, updateConfigPromise,
{ {
loading: 'Creating environment variable...', loading: 'Creating environment variable...',
success: 'Environment variable has been created successfully.', success: 'Environment variable has been created successfully.',
error: 'An error occurred while creating the environment variable.', error: getServerError(
'An error occurred while creating the environment variable.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );

View File

@@ -8,10 +8,12 @@ import BaseEnvironmentVariableForm, {
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { EnvironmentVariable } from '@/types/application'; import type { EnvironmentVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
GetEnvironmentVariablesDocument,
useGetEnvironmentVariablesQuery, useGetEnvironmentVariablesQuery,
useUpdateEnvironmentVariableMutation, useUpdateConfigMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
@@ -38,8 +40,7 @@ export default function EditEnvironmentVariableForm({
defaultValues: { defaultValues: {
id: originalEnvironmentVariable.id || '', id: originalEnvironmentVariable.id || '',
name: originalEnvironmentVariable.name || '', name: originalEnvironmentVariable.name || '',
devValue: originalEnvironmentVariable.devValue || '', value: originalEnvironmentVariable.value || '',
prodValue: originalEnvironmentVariable.prodValue || '',
}, },
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
resolver: yupResolver(baseEnvironmentVariableFormValidationSchema), resolver: yupResolver(baseEnvironmentVariableFormValidationSchema),
@@ -48,14 +49,14 @@ export default function EditEnvironmentVariableForm({
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({ const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication?.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const [updateEnvironmentVariable] = useUpdateEnvironmentVariableMutation({ const availableEnvironmentVariables = data?.config?.global?.environment || [];
refetchQueries: ['getEnvironmentVariables'],
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetEnvironmentVariablesDocument],
}); });
if (loading) { if (loading) {
@@ -76,14 +77,13 @@ export default function EditEnvironmentVariableForm({
async function handleSubmit({ async function handleSubmit({
id, id,
name, name,
prodValue, value,
devValue,
}: BaseEnvironmentVariableFormValues) { }: BaseEnvironmentVariableFormValues) {
if ( if (
data?.environmentVariables?.some( availableEnvironmentVariables.some(
(environmentVariable) => (variable) =>
environmentVariable.name === name && variable.name === name &&
environmentVariable.name !== originalEnvironmentVariable.name, variable.name !== originalEnvironmentVariable.name,
) )
) { ) {
setError('name', { setError('name', {
@@ -93,22 +93,36 @@ export default function EditEnvironmentVariableForm({
return; return;
} }
const updateEnvironmentVariablePromise = updateEnvironmentVariable({ const updateConfigPromise = updateConfig({
variables: { variables: {
id, appId: currentApplication?.id,
environmentVariable: { config: {
prodValue, global: {
devValue, environment: [
...availableEnvironmentVariables
.filter((variable) => variable.id !== id)
.map((variable) => ({
name: variable.name,
value: variable.value,
})),
{
name,
value,
},
],
},
}, },
}, },
}); });
await toast.promise( await toast.promise(
updateEnvironmentVariablePromise, updateConfigPromise,
{ {
loading: 'Updating environment variable...', loading: 'Updating environment variable...',
success: 'Environment variable has been updated successfully.', success: 'Environment variable has been updated successfully.',
error: 'An error occurred while updating the environment variable.', error: getServerError(
'An error occurred while updating the environment variable.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );

View File

@@ -6,8 +6,8 @@ import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
refetchGetAppInjectedVariablesQuery, GetEnvironmentVariablesDocument,
useUpdateApplicationMutation, useUpdateConfigMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -65,10 +65,8 @@ export default function EditJwtSecretForm({
location, location,
}: EditJwtSecretFormProps) { }: EditJwtSecretFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApplication] = useUpdateApplicationMutation({ const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [ refetchQueries: [GetEnvironmentVariablesDocument],
refetchGetAppInjectedVariablesQuery({ id: currentApplication?.id }),
],
}); });
const { onDirtyStateChange } = useDialog(); const { onDirtyStateChange } = useDialog();
@@ -90,26 +88,38 @@ export default function EditJwtSecretForm({
}, [isDirty, location, onDirtyStateChange]); }, [isDirty, location, onDirtyStateChange]);
async function handleSubmit(values: EditJwtSecretFormValues) { async function handleSubmit(values: EditJwtSecretFormValues) {
const updateAppPromise = updateApplication({ const parsedJwtSecret = JSON.parse(values.jwtSecret);
const isArray = Array.isArray(parsedJwtSecret);
const updateConfigPromise = updateConfig({
variables: { variables: {
appId: currentApplication?.id, appId: currentApplication?.id,
app: { config: {
hasuraGraphqlJwtSecret: values.jwtSecret, hasura: {
jwtSecrets: isArray ? parsedJwtSecret : [parsedJwtSecret],
},
}, },
}, },
}); });
await toast.promise( try {
updateAppPromise, await toast.promise(
{ updateConfigPromise,
loading: 'Updating JWT secret...', {
success: 'JWT secret has been updated successfully.', loading: 'Updating JWT secret...',
error: 'An error occurred while updating the JWT secret.', success: 'JWT secret has been updated successfully.',
}, error: (arg: Error) =>
getToastStyleProps(), arg?.message
); ? `Error: ${arg.message}`
: 'An error occurred while updating the JWT secret.',
},
getToastStyleProps(),
);
onSubmit?.(); onSubmit?.();
} catch {
// Note: error is handled above
}
} }
return ( return (

View File

@@ -2,6 +2,7 @@ import { useDialog } from '@/components/common/DialogProvider';
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm'; import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm'; import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { EnvironmentVariable } from '@/types/application'; import type { EnvironmentVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -15,12 +16,13 @@ import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List'; import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem'; import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
useDeleteEnvironmentVariableMutation, GetEnvironmentVariablesDocument,
useGetEnvironmentVariablesQuery, useGetEnvironmentVariablesQuery,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
import { Fragment } from 'react'; import { Fragment } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@@ -34,15 +36,29 @@ export interface EnvironmentVariableSettingsFormValues {
export default function EnvironmentVariableSettings() { export default function EnvironmentVariableSettings() {
const { openDialog, openAlertDialog } = useDialog(); const { openDialog, openAlertDialog } = useDialog();
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetEnvironmentVariablesQuery({ const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication?.id, fetchPolicy: 'cache-only',
},
}); });
const [deleteEnvironmentVariable] = useDeleteEnvironmentVariableMutation({ const availableEnvironmentVariables = [
refetchQueries: ['getEnvironmentVariables'], ...(data?.config?.global?.environment || []),
].sort((a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
});
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetEnvironmentVariablesDocument],
}); });
if (loading) { if (loading) {
@@ -59,21 +75,37 @@ export default function EnvironmentVariableSettings() {
} }
async function handleDeleteVariable({ id }: EnvironmentVariable) { async function handleDeleteVariable({ id }: EnvironmentVariable) {
const deleteEnvironmentVariablePromise = deleteEnvironmentVariable({ const updateConfigPromise = updateConfig({
variables: { variables: {
id, appId: currentApplication?.id,
config: {
global: {
environment: availableEnvironmentVariables
.filter((variable) => variable.id !== id)
.map((variable) => ({
name: variable.name,
value: variable.value,
})),
},
},
}, },
}); });
await toast.promise( try {
deleteEnvironmentVariablePromise, await toast.promise(
{ updateConfigPromise,
loading: 'Deleting environment variable...', {
success: 'Environment variable has been deleted successfully.', loading: 'Deleting environment variable...',
error: 'An error occurred while deleting the environment variable.', success: 'Environment variable has been deleted successfully.',
}, error: getServerError(
getToastStyleProps(), 'An error occurred while deleting the environment variable.',
); ),
},
getToastStyleProps(),
);
} catch {
// Note: The toast will handle the error.
}
} }
function handleOpenCreator() { function handleOpenCreator() {
@@ -120,12 +152,6 @@ export default function EnvironmentVariableSettings() {
}); });
} }
const availableEnvironmentVariables =
[...data.environmentVariables].sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
) || [];
return ( return (
<SettingsContainer <SettingsContainer
title="Project Environment Variables" title="Project Environment Variables"
@@ -141,95 +167,79 @@ export default function EnvironmentVariableSettings() {
> >
<Box className="grid grid-cols-2 gap-2 border-b-1 px-4 py-3 lg:grid-cols-3"> <Box className="grid grid-cols-2 gap-2 border-b-1 px-4 py-3 lg:grid-cols-3">
<Text className="font-medium">Variable Name</Text> <Text className="font-medium">Variable Name</Text>
<Text className="font-medium lg:col-span-2">Updated</Text>
</Box> </Box>
<div className="grid grid-flow-row gap-2"> <div className="grid grid-flow-row gap-2">
{availableEnvironmentVariables.length > 0 && ( {availableEnvironmentVariables.length > 0 && (
<List> <List>
{availableEnvironmentVariables.map((environmentVariable, index) => { {availableEnvironmentVariables.map((environmentVariable, index) => (
const timestamp = formatDistanceToNowStrict( <Fragment key={environmentVariable.id}>
parseISO(environmentVariable.updatedAt), <ListItem.Root
{ addSuffix: true, roundingMethod: 'floor' }, className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3"
); secondaryAction={
<Dropdown.Root>
return ( <Dropdown.Trigger
<Fragment key={environmentVariable.id}> asChild
<ListItem.Root hideChevron
className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3" className="absolute right-4 top-1/2 -translate-y-1/2"
secondaryAction={ >
<Dropdown.Root> <IconButton
<Dropdown.Trigger variant="borderless"
asChild color="secondary"
hideChevron disabled={maintenanceActive}
className="absolute right-4 top-1/2 -translate-y-1/2"
> >
<IconButton variant="borderless" color="secondary"> <DotsVerticalIcon />
<DotsVerticalIcon /> </IconButton>
</IconButton> </Dropdown.Trigger>
</Dropdown.Trigger>
<Dropdown.Content <Dropdown.Content
menu menu
PaperProps={{ className: 'w-32' }} PaperProps={{ className: 'w-32' }}
anchorOrigin={{ anchorOrigin={{
vertical: 'bottom', vertical: 'bottom',
horizontal: 'right', horizontal: 'right',
}} }}
transformOrigin={{ transformOrigin={{
vertical: 'top', vertical: 'top',
horizontal: 'right', horizontal: 'right',
}} }}
>
<Dropdown.Item
onClick={() => handleOpenEditor(environmentVariable)}
> >
<Dropdown.Item <Text className="font-medium">Edit</Text>
onClick={() => </Dropdown.Item>
handleOpenEditor(environmentVariable)
}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
<Divider component="li" /> <Divider component="li" />
<Dropdown.Item <Dropdown.Item
onClick={() => onClick={() =>
handleConfirmDelete(environmentVariable) handleConfirmDelete(environmentVariable)
} }
> >
<Text className="font-medium" color="error"> <Text className="font-medium" color="error">
Delete Delete
</Text> </Text>
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Content> </Dropdown.Content>
</Dropdown.Root> </Dropdown.Root>
} }
> >
<ListItem.Text className="truncate"> <ListItem.Text className="truncate">
{environmentVariable.name} {environmentVariable.name}
</ListItem.Text> </ListItem.Text>
</ListItem.Root>
<Text <Divider
variant="subtitle1" component="li"
className="truncate lg:col-span-2" className={twMerge(
> index === availableEnvironmentVariables.length - 1
{timestamp === '0 seconds ago' || ? '!mt-4'
timestamp === 'in 0 seconds' : '!my-4',
? 'Now' )}
: timestamp} />
</Text> </Fragment>
</ListItem.Root> ))}
<Divider
component="li"
className={twMerge(
index === availableEnvironmentVariables.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
);
})}
</List> </List>
)} )}
@@ -238,6 +248,7 @@ export default function EnvironmentVariableSettings() {
variant="borderless" variant="borderless"
startIcon={<PlusIcon />} startIcon={<PlusIcon />}
onClick={handleOpenCreator} onClick={handleOpenCreator}
disabled={maintenanceActive}
> >
Create Environment Variable Create Environment Variable
</Button> </Button>

View File

@@ -2,6 +2,7 @@ import { useDialog } from '@/components/common/DialogProvider';
import InlineCode from '@/components/common/InlineCode'; import InlineCode from '@/components/common/InlineCode';
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm'; import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import useIsPlatform from '@/hooks/common/useIsPlatform'; import useIsPlatform from '@/hooks/common/useIsPlatform';
import { useAppClient } from '@/hooks/useAppClient'; import { useAppClient } from '@/hooks/useAppClient';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
@@ -19,9 +20,10 @@ import generateAppServiceUrl, {
defaultLocalBackendSlugs, defaultLocalBackendSlugs,
defaultRemoteBackendSlugs, defaultRemoteBackendSlugs,
} from '@/utils/common/generateAppServiceUrl'; } from '@/utils/common/generateAppServiceUrl';
import { LOCAL_HASURA_URL } from '@/utils/env'; import { getHasuraConsoleServiceUrl } from '@/utils/env';
import { generateRemoteAppUrl } from '@/utils/helpers'; import { generateRemoteAppUrl } from '@/utils/helpers';
import { useGetAppInjectedVariablesQuery } from '@/utils/__generated__/graphql'; import getJwtSecretsWithoutFalsyValues from '@/utils/settings/getJwtSecretsWithoutFalsyValues';
import { useGetEnvironmentVariablesQuery } from '@/utils/__generated__/graphql';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
export default function SystemEnvironmentVariableSettings() { export default function SystemEnvironmentVariableSettings() {
@@ -29,10 +31,22 @@ export default function SystemEnvironmentVariableSettings() {
const [showWebhookSecret, setShowWebhookSecret] = useState(false); const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const { openDialog } = useDialog(); const { openDialog } = useDialog();
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetAppInjectedVariablesQuery({ const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: { id: currentApplication?.id }, variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
}); });
const { jwtSecrets, webhookSecret, adminSecret } = data?.config?.hasura || {};
const jwtSecretsWithoutFalsyValues = getJwtSecretsWithoutFalsyValues(
jwtSecrets || [],
);
const stringifiedJwtSecrets =
jwtSecretsWithoutFalsyValues.length === 1
? JSON.stringify(jwtSecretsWithoutFalsyValues[0], null, 2)
: JSON.stringify(jwtSecretsWithoutFalsyValues, null, 2);
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const appClient = useAppClient(); const appClient = useAppClient();
@@ -63,10 +77,7 @@ export default function SystemEnvironmentVariableSettings() {
</span> </span>
), ),
component: ( component: (
<EditJwtSecretForm <EditJwtSecretForm disabled jwtSecret={stringifiedJwtSecrets} />
disabled
jwtSecret={data?.app?.hasuraGraphqlJwtSecret}
/>
), ),
}); });
} }
@@ -83,9 +94,7 @@ export default function SystemEnvironmentVariableSettings() {
</Text> </Text>
</span> </span>
), ),
component: ( component: <EditJwtSecretForm jwtSecret={stringifiedJwtSecrets} />,
<EditJwtSecretForm jwtSecret={data?.app?.hasuraGraphqlJwtSecret} />
),
}); });
} }
@@ -100,7 +109,7 @@ export default function SystemEnvironmentVariableSettings() {
key: 'NHOST_HASURA_URL', key: 'NHOST_HASURA_URL',
value: value:
process.env.NEXT_PUBLIC_ENV === 'dev' || !isPlatform process.env.NEXT_PUBLIC_ENV === 'dev' || !isPlatform
? `${LOCAL_HASURA_URL}/console` ? `${getHasuraConsoleServiceUrl()}/console`
: generateAppServiceUrl( : generateAppServiceUrl(
currentApplication?.subdomain, currentApplication?.subdomain,
currentApplication?.region.awsName, currentApplication?.region.awsName,
@@ -137,7 +146,7 @@ export default function SystemEnvironmentVariableSettings() {
<Text className="truncate" color="secondary"> <Text className="truncate" color="secondary">
{showAdminSecret ? ( {showAdminSecret ? (
<InlineCode className="!text-sm font-medium"> <InlineCode className="!text-sm font-medium">
{currentApplication?.hasuraGraphqlAdminSecret} {adminSecret}
</InlineCode> </InlineCode>
) : ( ) : (
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●' '●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
@@ -170,7 +179,7 @@ export default function SystemEnvironmentVariableSettings() {
<Text className="truncate" color="secondary"> <Text className="truncate" color="secondary">
{showWebhookSecret ? ( {showWebhookSecret ? (
<InlineCode className="!text-sm font-medium"> <InlineCode className="!text-sm font-medium">
{data?.app?.webhookSecret} {webhookSecret}
</InlineCode> </InlineCode>
) : ( ) : (
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●' '●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
@@ -234,6 +243,7 @@ export default function SystemEnvironmentVariableSettings() {
variant="borderless" variant="borderless"
onClick={showEditJwtSecretModal} onClick={showEditJwtSecretModal}
size="small" size="small"
disabled={maintenanceActive}
> >
Edit JWT Secret Edit JWT Secret
</Button> </Button>

View File

@@ -1,11 +1,13 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import InlineCode from '@/components/common/InlineCode'; import InlineCode from '@/components/common/InlineCode';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useUpdateAppMutation } from '@/generated/graphql'; import { useUpdateAppMutation } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert'; import { Alert } from '@/ui/Alert';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import { discordAnnounce } from '@/utils/discordAnnounce'; import { discordAnnounce } from '@/utils/discordAnnounce';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from '@apollo/client';
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -20,6 +22,7 @@ export interface BaseDirectoryFormValues {
} }
export default function BaseDirectorySettings() { export default function BaseDirectorySettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateApp] = useUpdateAppMutation();
const client = useApolloClient(); const client = useApolloClient();
@@ -54,7 +57,9 @@ export default function BaseDirectorySettings() {
{ {
loading: `The base directory is being updated...`, loading: `The base directory is being updated...`,
success: `The base directory has been updated successfully.`, success: `The base directory has been updated successfully.`,
error: `An error occurred while trying to update the project's base directory.`, error: getServerError(
`An error occurred while trying to update the project's base directory.`,
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
@@ -84,9 +89,11 @@ export default function BaseDirectorySettings() {
<InlineCode className="text-xs">nhost</InlineCode> folder. <InlineCode className="text-xs">nhost</InlineCode> folder.
</> </>
} }
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/platform/github-integration#base-directory" docsLink="https://docs.nhost.io/platform/github-integration#base-directory"
className="grid grid-flow-row lg:grid-cols-5" className="grid grid-flow-row lg:grid-cols-5"

View File

@@ -1,10 +1,12 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useUpdateAppMutation } from '@/generated/graphql'; import { useUpdateAppMutation } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert'; import { Alert } from '@/ui/Alert';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import { discordAnnounce } from '@/utils/discordAnnounce'; import { discordAnnounce } from '@/utils/discordAnnounce';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from '@apollo/client';
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -19,6 +21,7 @@ export interface DeploymentBranchFormValues {
} }
export default function DeploymentBranchSettings() { export default function DeploymentBranchSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateApp] = useUpdateAppMutation();
const client = useApolloClient(); const client = useApolloClient();
@@ -57,7 +60,9 @@ export default function DeploymentBranchSettings() {
{ {
loading: `The deployment branch is being updated...`, loading: `The deployment branch is being updated...`,
success: `The deployment branch has been updated successfully.`, success: `The deployment branch has been updated successfully.`,
error: `An error occurred while trying to update the project's deployment branch.`, error: getServerError(
`An error occurred while trying to update the project's deployment branch.`,
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
@@ -79,9 +84,11 @@ export default function DeploymentBranchSettings() {
<SettingsContainer <SettingsContainer
title="Deployment Branch" title="Deployment Branch"
description="All commits pushed to this deployment branch will trigger a deployment. You can switch to a different branch here." description="All commits pushed to this deployment branch will trigger a deployment. You can switch to a different branch here."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/platform/github-integration#deployment-branch" docsLink="https://docs.nhost.io/platform/github-integration#deployment-branch"
className="grid grid-flow-row lg:grid-cols-5" className="grid grid-flow-row lg:grid-cols-5"

View File

@@ -26,8 +26,8 @@ export interface BasePermissionVariableFormProps extends DialogFormProps {
} }
export const basePermissionVariableValidationSchema = Yup.object({ export const basePermissionVariableValidationSchema = Yup.object({
key: Yup.string().required('This field is required.'), key: Yup.string().label('Field Name').nullable().required(),
value: Yup.string().required('This field is required.'), value: Yup.string().label('Path').nullable().required(),
}); });
export type BasePermissionVariableFormValues = Yup.InferType< export type BasePermissionVariableFormValues = Yup.InferType<

View File

@@ -7,12 +7,12 @@ import BasePermissionVariableForm, {
} from '@/components/settings/permissions/BasePermissionVariableForm'; } from '@/components/settings/permissions/BasePermissionVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray'; import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
useGetAppCustomClaimsQuery, GetRolesPermissionsDocument,
useUpdateAppMutation, useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
@@ -32,11 +32,14 @@ export default function CreatePermissionVariableForm({
}: CreatePermissionVariableFormProps) { }: CreatePermissionVariableFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, error, loading } = useGetAppCustomClaimsQuery({ const { data, error, loading } = useGetRolesPermissionsQuery({
variables: { id: currentApplication?.id }, variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { customClaims: permissionVariables } =
data?.config?.auth?.session?.accessToken || {};
const form = useForm<BasePermissionVariableFormValues>({ const form = useForm<BasePermissionVariableFormValues>({
defaultValues: { defaultValues: {
key: '', key: '',
@@ -46,8 +49,8 @@ export default function CreatePermissionVariableForm({
resolver: yupResolver(basePermissionVariableValidationSchema), resolver: yupResolver(basePermissionVariableValidationSchema),
}); });
const [updateApp] = useUpdateAppMutation({ const [updateConfig] = useUpdateConfigMutation({
refetchQueries: ['getAppCustomClaims'], refetchQueries: [GetRolesPermissionsDocument],
}); });
if (loading) { if (loading) {
@@ -61,9 +64,8 @@ export default function CreatePermissionVariableForm({
} }
const { setError } = form; const { setError } = form;
const availablePermissionVariables = getPermissionVariablesArray( const availablePermissionVariables =
data?.app?.authJwtCustomClaims, getAllPermissionVariables(permissionVariables);
);
async function handleSubmit({ async function handleSubmit({
key, key,
@@ -79,26 +81,29 @@ export default function CreatePermissionVariableForm({
return; return;
} }
const permissionVariablesObject = getPermissionVariablesObject( const existingPermissionVariables =
availablePermissionVariables.filter( permissionVariables?.map((permissionVariable) => ({
(permissionVariable) => !permissionVariable.isSystemClaim, key: permissionVariable.key,
), value: permissionVariable.value,
); })) || [];
const updateAppPromise = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication?.id, appId: currentApplication?.id,
app: { config: {
authJwtCustomClaims: { auth: {
...permissionVariablesObject, session: {
[key]: value, accessToken: {
customClaims: [...existingPermissionVariables, { key, value }],
},
},
}, },
}, },
}, },
}); });
await toast.promise( await toast.promise(
updateAppPromise, updateConfigPromise,
{ {
loading: 'Creating permission variable...', loading: 'Creating permission variable...',
success: 'Permission variable has been created successfully.', success: 'Permission variable has been created successfully.',

View File

@@ -6,14 +6,14 @@ import BasePermissionVariableForm, {
basePermissionVariableValidationSchema, basePermissionVariableValidationSchema,
} from '@/components/settings/permissions/BasePermissionVariableForm'; } from '@/components/settings/permissions/BasePermissionVariableForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { CustomClaim } from '@/types/application'; import type { PermissionVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getPermissionVariables from '@/utils/settings/getPermissionVariablesArray'; import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import getPermissionVariablesObject from '@/utils/settings/getPermissionVariablesObject';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
useGetAppCustomClaimsQuery, GetRolesPermissionsDocument,
useUpdateAppMutation, useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
@@ -24,7 +24,7 @@ export interface EditPermissionVariableFormProps
/** /**
* The permission variable to be edited. * The permission variable to be edited.
*/ */
originalVariable: CustomClaim; originalVariable: PermissionVariable;
/** /**
* Function to be called when the form is submitted. * Function to be called when the form is submitted.
*/ */
@@ -38,11 +38,14 @@ export default function EditPermissionVariableForm({
}: EditPermissionVariableFormProps) { }: EditPermissionVariableFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, error, loading } = useGetAppCustomClaimsQuery({ const { data, error, loading } = useGetRolesPermissionsQuery({
variables: { id: currentApplication?.id }, variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { customClaims: permissionVariables } =
data?.config?.auth?.session?.accessToken || {};
const form = useForm<BasePermissionVariableFormValues>({ const form = useForm<BasePermissionVariableFormValues>({
defaultValues: { defaultValues: {
key: originalVariable.key || '', key: originalVariable.key || '',
@@ -52,8 +55,8 @@ export default function EditPermissionVariableForm({
resolver: yupResolver(basePermissionVariableValidationSchema), resolver: yupResolver(basePermissionVariableValidationSchema),
}); });
const [updateApp] = useUpdateAppMutation({ const [updateConfig] = useUpdateConfigMutation({
refetchQueries: ['getAppCustomClaims'], refetchQueries: [GetRolesPermissionsDocument],
}); });
if (loading) { if (loading) {
@@ -67,9 +70,8 @@ export default function EditPermissionVariableForm({
} }
const { setError } = form; const { setError } = form;
const availablePermissionVariables = getPermissionVariables( const availablePermissionVariables =
data?.app?.authJwtCustomClaims, getAllPermissionVariables(permissionVariables);
);
async function handleSubmit({ async function handleSubmit({
key, key,
@@ -92,36 +94,43 @@ export default function EditPermissionVariableForm({
(permissionVariable) => permissionVariable.key === originalVariable.key, (permissionVariable) => permissionVariable.key === originalVariable.key,
); );
const updatedPermissionVariables = availablePermissionVariables.map( const updatedPermissionVariables = availablePermissionVariables
(permissionVariable, index) => { .map((permissionVariable, index) => {
if (index === originalPermissionVariableIndex) { if (permissionVariable.isSystemVariable) {
return { key, value }; return null;
} }
return permissionVariable; if (index === originalPermissionVariableIndex) {
}, return {
); key,
value,
};
}
const permissionVariablesObject = getPermissionVariablesObject( return {
updatedPermissionVariables.filter( key: permissionVariable.key,
(permissionVariable) => !permissionVariable.isSystemClaim, value: permissionVariable.value,
), };
); })
.filter(Boolean);
const updateAppPromise = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication?.id, appId: currentApplication?.id,
app: { config: {
authJwtCustomClaims: { auth: {
...permissionVariablesObject, session: {
[key]: value, accessToken: {
customClaims: updatedPermissionVariables,
},
},
}, },
}, },
}, },
}); });
await toast.promise( await toast.promise(
updateAppPromise, updateConfigPromise,
{ {
loading: 'Updating permission variable...', loading: 'Updating permission variable...',
success: 'Permission variable has been updated successfully.', success: 'Permission variable has been updated successfully.',

View File

@@ -2,8 +2,9 @@ import { useDialog } from '@/components/common/DialogProvider';
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm'; import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm'; import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { CustomClaim } from '@/types/application'; import type { PermissionVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Box from '@/ui/v2/Box'; import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
@@ -17,35 +18,33 @@ import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem'; import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import Tooltip from '@/ui/v2/Tooltip'; import Tooltip from '@/ui/v2/Tooltip';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray'; import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
useGetAppCustomClaimsQuery, GetRolesPermissionsDocument,
useUpdateAppMutation, useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { Fragment } from 'react'; import { Fragment } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export interface PermissionVariableSettingsFormValues {
/**
* Permission variables.
*/
authJwtCustomClaims: CustomClaim[];
}
export default function PermissionVariableSettings() { export default function PermissionVariableSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, openAlertDialog } = useDialog(); const { openDialog, openAlertDialog } = useDialog();
const { data, loading, error } = useGetAppCustomClaimsQuery({ const { data, loading, error } = useGetRolesPermissionsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication?.id, fetchPolicy: 'cache-only',
},
}); });
const [updateApp] = useUpdateAppMutation({ const { customClaims: permissionVariables } =
refetchQueries: ['getAppCustomClaims'], data?.config?.auth?.session?.accessToken || {};
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetRolesPermissionsDocument],
}); });
if (loading) { if (loading) {
@@ -58,32 +57,35 @@ export default function PermissionVariableSettings() {
throw error; throw error;
} }
async function handleDeleteVariable({ key }: CustomClaim) { async function handleDeleteVariable({ id }: PermissionVariable) {
const filteredCustomClaims = Object.keys( const updateConfigPromise = updateConfig({
data?.app?.authJwtCustomClaims,
).filter((customClaimKey) => customClaimKey !== key);
const updateAppPromise = updateApp({
variables: { variables: {
id: currentApplication?.id, appId: currentApplication?.id,
app: { config: {
authJwtCustomClaims: filteredCustomClaims.reduce( auth: {
(customClaims, currentKey) => ({ session: {
...customClaims, accessToken: {
[currentKey]: data?.app?.authJwtCustomClaims[currentKey], customClaims: permissionVariables
}), ?.filter((permissionVariable) => permissionVariable.id !== id)
{}, .map((permissionVariable) => ({
), key: permissionVariable.key,
value: permissionVariable.value,
})),
},
},
},
}, },
}, },
}); });
await toast.promise( await toast.promise(
updateAppPromise, updateConfigPromise,
{ {
loading: 'Deleting permission variable...', loading: 'Deleting permission variable...',
success: 'Permission variable has been deleted successfully.', success: 'Permission variable has been deleted successfully.',
error: 'An error occurred while trying to delete permission variable.', error: getServerError(
'An error occurred while trying to delete permission variable.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
@@ -100,7 +102,7 @@ export default function PermissionVariableSettings() {
}); });
} }
function handleOpenEditor(originalVariable: CustomClaim) { function handleOpenEditor(originalVariable: PermissionVariable) {
openDialog({ openDialog({
title: 'Edit Permission Variable', title: 'Edit Permission Variable',
component: ( component: (
@@ -113,7 +115,7 @@ export default function PermissionVariableSettings() {
}); });
} }
function handleConfirmDelete(originalVariable: CustomClaim) { function handleConfirmDelete(originalVariable: PermissionVariable) {
openAlertDialog({ openAlertDialog({
title: 'Delete Permission Variable', title: 'Delete Permission Variable',
payload: ( payload: (
@@ -131,9 +133,8 @@ export default function PermissionVariableSettings() {
}); });
} }
const availablePermissionVariables = getPermissionVariablesArray( const availablePermissionVariables =
data?.app?.authJwtCustomClaims, getAllPermissionVariables(permissionVariables);
);
return ( return (
<SettingsContainer <SettingsContainer
@@ -151,28 +152,33 @@ export default function PermissionVariableSettings() {
<div className="grid grid-flow-row gap-2"> <div className="grid grid-flow-row gap-2">
<List> <List>
{availablePermissionVariables.map((customClaim, index) => ( {availablePermissionVariables.map((permissionVariable, index) => (
<Fragment key={customClaim.key}> <Fragment key={permissionVariable.id}>
<ListItem.Root <ListItem.Root
className="grid grid-cols-2 px-4" className="grid grid-cols-2 px-4"
secondaryAction={ secondaryAction={
<Dropdown.Root> <Dropdown.Root>
<Tooltip <Tooltip
title={ title={
customClaim.isSystemClaim permissionVariable.isSystemVariable
? "You can't edit system permission variables" ? "You can't edit system permission variables"
: '' : ''
} }
placement="right" placement="right"
disableHoverListener={!customClaim.isSystemClaim} disableHoverListener={
hasDisabledChildren={customClaim.isSystemClaim} !permissionVariable.isSystemVariable
}
hasDisabledChildren={permissionVariable.isSystemVariable}
className="absolute right-4 top-1/2 -translate-y-1/2" className="absolute right-4 top-1/2 -translate-y-1/2"
> >
<Dropdown.Trigger asChild hideChevron> <Dropdown.Trigger asChild hideChevron>
<IconButton <IconButton
variant="borderless" variant="borderless"
color="secondary" color="secondary"
disabled={customClaim.isSystemClaim} disabled={
permissionVariable.isSystemVariable ||
maintenanceActive
}
> >
<DotsVerticalIcon /> <DotsVerticalIcon />
</IconButton> </IconButton>
@@ -192,7 +198,7 @@ export default function PermissionVariableSettings() {
}} }}
> >
<Dropdown.Item <Dropdown.Item
onClick={() => handleOpenEditor(customClaim)} onClick={() => handleOpenEditor(permissionVariable)}
> >
<Text className="font-medium">Edit</Text> <Text className="font-medium">Edit</Text>
</Dropdown.Item> </Dropdown.Item>
@@ -200,7 +206,7 @@ export default function PermissionVariableSettings() {
<Divider component="li" /> <Divider component="li" />
<Dropdown.Item <Dropdown.Item
onClick={() => handleConfirmDelete(customClaim)} onClick={() => handleConfirmDelete(permissionVariable)}
> >
<Text <Text
className="font-medium" className="font-medium"
@@ -218,15 +224,17 @@ export default function PermissionVariableSettings() {
<ListItem.Text <ListItem.Text
primary={ primary={
<> <>
X-Hasura-{customClaim.key}{' '} X-Hasura-{permissionVariable.key}{' '}
{customClaim.isSystemClaim && ( {permissionVariable.isSystemVariable && (
<LockIcon className="h-4 w-4" /> <LockIcon className="h-4 w-4" />
)} )}
</> </>
} }
/> />
<Text className="font-medium">user.{customClaim.value}</Text> <Text className="font-medium">
user.{permissionVariable.value}
</Text>
</ListItem.Root> </ListItem.Root>
<Divider <Divider
@@ -246,6 +254,7 @@ export default function PermissionVariableSettings() {
variant="borderless" variant="borderless"
startIcon={<PlusIcon />} startIcon={<PlusIcon />}
onClick={handleOpenCreator} onClick={handleOpenCreator}
disabled={maintenanceActive}
> >
Create Permission Variable Create Permission Variable
</Button> </Button>

View File

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

View File

@@ -7,11 +7,13 @@ import BaseRoleForm, {
} from '@/components/settings/roles/BaseRoleForm'; } from '@/components/settings/roles/BaseRoleForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import getUserRoles from '@/utils/settings/getUserRoles'; import getUserRoles from '@/utils/settings/getUserRoles';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
useGetRolesQuery, GetRolesPermissionsDocument,
useUpdateAppMutation, useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
@@ -30,10 +32,11 @@ export default function CreateRoleForm({
...props ...props
}: CreateRoleFormProps) { }: CreateRoleFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetRolesQuery({ const { data, loading, error } = useGetRolesPermissionsQuery({
variables: { id: currentApplication?.id }, variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { allowed: allowedRoles } = data?.config?.auth?.user?.roles || {};
const form = useForm<BaseRoleFormValues>({ const form = useForm<BaseRoleFormValues>({
defaultValues: {}, defaultValues: {},
@@ -41,7 +44,9 @@ export default function CreateRoleForm({
resolver: yupResolver(baseRoleFormValidationSchema), resolver: yupResolver(baseRoleFormValidationSchema),
}); });
const [updateApp] = useUpdateAppMutation({ refetchQueries: ['getRoles'] }); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetRolesPermissionsDocument],
});
if (loading) { if (loading) {
return <ActivityIndicator delay={1000} label="Loading roles..." />; return <ActivityIndicator delay={1000} label="Loading roles..." />;
@@ -52,7 +57,7 @@ export default function CreateRoleForm({
} }
const { setError } = form; const { setError } = form;
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles); const availableRoles = getUserRoles(allowedRoles);
async function handleSubmit({ name }: BaseRoleFormValues) { async function handleSubmit({ name }: BaseRoleFormValues) {
if (availableRoles.some((role) => role.name === name)) { if (availableRoles.some((role) => role.name === name)) {
@@ -61,26 +66,40 @@ export default function CreateRoleForm({
return; return;
} }
const updateAppPromise = updateApp({ const updatedAllowedRoles = allowedRoles ? [...allowedRoles, name] : [name];
const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication?.id, appId: currentApplication?.id,
app: { config: {
authUserDefaultAllowedRoles: `${data?.app?.authUserDefaultAllowedRoles},${name}`, auth: {
user: {
roles: {
allowed: updatedAllowedRoles,
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppPromise, await toast.promise(
{ updateConfigPromise,
loading: 'Creating role...', {
success: 'Role has been created successfully.', loading: 'Creating role...',
error: 'An error occurred while trying to create the role.', success: 'Role has been created successfully.',
}, error: getServerError(
getToastStyleProps(), 'An error occurred while trying to create the role.',
); ),
},
getToastStyleProps(),
);
await onSubmit?.(); onSubmit?.();
} catch (updateConfigError) {
console.error(updateConfigError);
}
} }
return ( return (

View File

@@ -8,11 +8,13 @@ import BaseRoleForm, {
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Role } from '@/types/application'; import type { Role } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import getUserRoles from '@/utils/settings/getUserRoles'; import getUserRoles from '@/utils/settings/getUserRoles';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
useGetRolesQuery, GetRolesPermissionsDocument,
useUpdateAppMutation, useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
@@ -36,11 +38,14 @@ export default function EditRoleForm({
...props ...props
}: EditRoleFormProps) { }: EditRoleFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetRolesQuery({ const { data, loading, error } = useGetRolesPermissionsQuery({
variables: { id: currentApplication?.id }, variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { allowed: allowedRoles, default: defaultRole } =
data?.config?.auth?.user?.roles || {};
const form = useForm<BaseRoleFormValues>({ const form = useForm<BaseRoleFormValues>({
defaultValues: { defaultValues: {
name: originalRole.name || '', name: originalRole.name || '',
@@ -49,8 +54,8 @@ export default function EditRoleForm({
resolver: yupResolver(baseRoleFormValidationSchema), resolver: yupResolver(baseRoleFormValidationSchema),
}); });
const [updateApp] = useUpdateAppMutation({ const [updateConfig] = useUpdateConfigMutation({
refetchQueries: ['getRoles'], refetchQueries: [GetRolesPermissionsDocument],
}); });
if (loading) { if (loading) {
@@ -62,7 +67,7 @@ export default function EditRoleForm({
} }
const { setError } = form; const { setError } = form;
const availableRoles = getUserRoles(data?.app?.authUserDefaultAllowedRoles); const availableRoles = getUserRoles(allowedRoles);
async function handleSubmit({ name }: BaseRoleFormValues) { async function handleSubmit({ name }: BaseRoleFormValues) {
if ( if (
@@ -75,47 +80,55 @@ export default function EditRoleForm({
return; return;
} }
const defaultAllowedRolesList = const defaultAllowedRolesList = allowedRoles || [];
data?.app?.authUserDefaultAllowedRoles.split(',') || [];
const originalRoleIndex = defaultAllowedRolesList.findIndex( const originalRoleIndex = defaultAllowedRolesList.findIndex(
(role) => role.trim() === originalRole.name, (role) => role.trim() === originalRole.name,
); );
const updatedDefaultAllowedRoles = defaultAllowedRolesList const updatedDefaultAllowedRoles = defaultAllowedRolesList.map(
.map((role, index) => { (role, index) => {
if (index === originalRoleIndex) { if (index === originalRoleIndex) {
return name; return name;
} }
return role; return role;
}) },
.join(','); );
const updateAppPromise = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication?.id, appId: currentApplication?.id,
app: { config: {
authUserDefaultRole: auth: {
data?.app?.authUserDefaultRole === originalRole.name user: {
? name roles: {
: data?.app?.authUserDefaultRole, default: defaultRole === originalRole.name ? name : defaultRole,
authUserDefaultAllowedRoles: updatedDefaultAllowedRoles, allowed: updatedDefaultAllowedRoles,
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppPromise, await toast.promise(
{ updateConfigPromise,
loading: 'Updating role...', {
success: 'Role has been updated successfully.', loading: 'Updating role...',
error: 'An error occurred while trying to update the role.', success: 'Role has been updated successfully.',
}, error: getServerError(
getToastStyleProps(), 'An error occurred while trying to update the role.',
); ),
},
getToastStyleProps(),
);
await onSubmit?.(); onSubmit?.();
} catch (updateConfigError) {
console.error(updateConfigError);
}
} }
return ( return (

View File

@@ -2,6 +2,7 @@ import { useDialog } from '@/components/common/DialogProvider';
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm'; import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
import EditRoleForm from '@/components/settings/roles/EditRoleForm'; import EditRoleForm from '@/components/settings/roles/EditRoleForm';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Role } from '@/types/application'; import type { Role } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -17,11 +18,13 @@ import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List'; import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem'; import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import getServerError from '@/utils/settings/getServerError';
import getUserRoles from '@/utils/settings/getUserRoles'; import getUserRoles from '@/utils/settings/getUserRoles';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
useGetRolesQuery, GetRolesPermissionsDocument,
useUpdateAppMutation, useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { Fragment } from 'react'; import { Fragment } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -39,15 +42,20 @@ export interface RoleSettingsFormValues {
} }
export default function RoleSettings() { export default function RoleSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, openAlertDialog } = useDialog(); const { openDialog, openAlertDialog } = useDialog();
const { data, loading, error } = useGetRolesQuery({ const { data, loading, error } = useGetRolesPermissionsQuery({
variables: { id: currentApplication?.id }, variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
}); });
const [updateApp] = useUpdateAppMutation({ const { allowed: allowedRoles, default: defaultRole } =
refetchQueries: ['getRoles'], data?.config?.auth?.user?.roles || {};
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetRolesPermissionsDocument],
}); });
if (loading) { if (loading) {
@@ -59,51 +67,60 @@ export default function RoleSettings() {
} }
async function handleSetAsDefault({ name }: Role) { async function handleSetAsDefault({ name }: Role) {
const updateAppPromise = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication?.id, appId: currentApplication?.id,
app: { config: {
authUserDefaultRole: name, auth: {
user: {
roles: {
allowed: allowedRoles,
default: name,
},
},
},
}, },
}, },
}); });
await toast.promise( await toast.promise(
updateAppPromise, updateConfigPromise,
{ {
loading: 'Updating default role...', loading: 'Updating default role...',
success: 'Default role has been updated successfully.', success: 'Default role has been updated successfully.',
error: 'An error occurred while trying to update the default role.', error: getServerError(
'An error occurred while trying to update the default role.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
} }
async function handleDeleteRole({ name }: Role) { async function handleDeleteRole({ name }: Role) {
const filteredRoles = data?.app?.authUserDefaultAllowedRoles const updateConfigPromise = updateConfig({
.split(',')
.filter((role) => role !== name)
.join(',');
const updateAppPromise = updateApp({
variables: { variables: {
id: currentApplication?.id, appId: currentApplication?.id,
app: { config: {
authUserDefaultAllowedRoles: filteredRoles, auth: {
authUserDefaultRole: user: {
name === data?.app?.authUserDefaultRole roles: {
? 'user' allowed: allowedRoles.filter((role) => role !== name),
: data?.app?.authUserDefaultRole, default: name === defaultRole ? 'user' : defaultRole,
},
},
},
}, },
}, },
}); });
await toast.promise( await toast.promise(
updateAppPromise, updateConfigPromise,
{ {
loading: 'Deleting allowed role...', loading: 'Deleting allowed role...',
success: 'Allowed Role has been deleted successfully.', success: 'Allowed Role has been deleted successfully.',
error: 'An error occurred while trying to delete the allowed role.', error: getServerError(
'An error occurred while trying to delete the allowed role.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
@@ -148,9 +165,7 @@ export default function RoleSettings() {
}); });
} }
const availableAllowedRoles = getUserRoles( const availableAllowedRoles = getUserRoles(allowedRoles);
data?.app?.authUserDefaultAllowedRoles,
);
return ( return (
<SettingsContainer <SettingsContainer
@@ -158,7 +173,10 @@ export default function RoleSettings() {
description="Allowed roles are roles users get automatically when they sign up." description="Allowed roles are roles users get automatically when they sign up."
docsLink="https://docs.nhost.io/authentication/users#allowed-roles" docsLink="https://docs.nhost.io/authentication/users#allowed-roles"
rootClassName="gap-0" rootClassName="gap-0"
className="my-2 px-0" className={twMerge(
'my-2 px-0',
availableAllowedRoles.length === 0 && 'gap-2',
)}
slotProps={{ submitButton: { className: 'invisible' } }} slotProps={{ submitButton: { className: 'invisible' } }}
> >
<Box className="border-b-1 px-4 py-3"> <Box className="border-b-1 px-4 py-3">
@@ -166,103 +184,110 @@ export default function RoleSettings() {
</Box> </Box>
<div className="grid grid-flow-row gap-2"> <div className="grid grid-flow-row gap-2">
<List> {availableAllowedRoles.length > 0 && (
{availableAllowedRoles.map((role, index) => ( <List>
<Fragment key={role.name}> {availableAllowedRoles.map((role, index) => (
<ListItem.Root <Fragment key={role.name}>
className="px-4" <ListItem.Root
secondaryAction={ className="px-4"
<Dropdown.Root> secondaryAction={
<Dropdown.Trigger <Dropdown.Root>
asChild <Dropdown.Trigger
hideChevron asChild
className="absolute right-4 top-1/2 -translate-y-1/2" 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> <IconButton
</Dropdown.Item> variant="borderless"
color="secondary"
disabled={maintenanceActive}
>
<DotsVerticalIcon />
</IconButton>
</Dropdown.Trigger>
<Divider component="li" /> <Dropdown.Content
menu
<Dropdown.Item PaperProps={{ className: 'w-32' }}
disabled={role.isSystemRole} anchorOrigin={{
onClick={() => handleConfirmDelete(role)} vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
> >
<Text className="font-medium" color="error"> <Dropdown.Item onClick={() => handleSetAsDefault(role)}>
Delete <Text className="font-medium">Set as Default</Text>
</Text> </Dropdown.Item>
</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="h-4 w-4" />} <Divider component="li" />
{data?.app?.authUserDefaultRole === role.name && ( <Dropdown.Item
<Chip disabled={role.isSystemRole}
component="span" onClick={() => handleOpenEditor(role)}
color="info" >
size="small" <Text className="font-medium">Edit</Text>
label="Default" </Dropdown.Item>
/>
)} <Divider component="li" />
</>
<Dropdown.Item
disabled={role.isSystemRole}
onClick={() => handleConfirmDelete(role)}
>
<Text className="font-medium" color="error">
Delete
</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
} }
/> >
</ListItem.Root> <ListItem.Text
primaryTypographyProps={{
className:
'inline-grid grid-flow-col gap-1 items-center h-6 font-medium',
}}
primary={
<>
{role.name}
<Divider {role.isSystemRole && <LockIcon className="h-4 w-4" />}
component="li"
className={twMerge( {defaultRole === role.name && (
index === availableAllowedRoles.length - 1 <Chip
? '!mt-4' component="span"
: '!my-4', color="info"
)} size="small"
/> label="Default"
</Fragment> />
))} )}
</List> </>
}
/>
</ListItem.Root>
<Divider
component="li"
className={twMerge(
index === availableAllowedRoles.length - 1
? '!mt-4'
: '!my-4',
)}
/>
</Fragment>
))}
</List>
)}
<Button <Button
className="mx-4 justify-self-start" className="mx-4 justify-self-start"
variant="borderless" variant="borderless"
startIcon={<PlusIcon />} startIcon={<PlusIcon />}
onClick={handleOpenCreator} onClick={handleOpenCreator}
disabled={maintenanceActive}
> >
Create Allowed Role Create Allowed Role
</Button> </Button>

View File

@@ -0,0 +1,113 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BaseSecretFormProps {
/**
* Determines the mode of the form.
*
* @default 'edit'
*/
mode?: 'edit' | 'create';
/**
* Function to be called when the form is submitted.
*/
onSubmit: (values: BaseSecretFormValues) => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Submit button text.
*
* @default 'Save'
*/
submitButtonText?: string;
}
export const baseSecretFormValidationSchema = Yup.object({
name: Yup.string()
.required('This field is required.')
.test(
'isSecretValid',
'A name must start with a letter and can only contain letters, numbers, and underscores.',
(value) => /^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
),
value: Yup.string().required('This field is required.'),
});
export type BaseSecretFormValues = Yup.InferType<
typeof baseSecretFormValidationSchema
>;
export default function BaseSecretForm({
mode = 'edit',
onSubmit,
onCancel,
submitButtonText = 'Save',
}: BaseSecretFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BaseSecretFormValues>();
const {
register,
formState: { errors, dirtyFields, isSubmitting },
} = form;
// react-hook-form's isDirty gets true even if an input field is focused, then
// immediately unfocused - we can't rely on that information
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-6 px-6 pb-6">
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input
{...register('name')}
id="name"
label="Name"
placeholder="EXAMPLE_NAME"
hideEmptyHelperText
error={!!errors.name}
helperText={errors?.name?.message}
fullWidth
autoComplete="off"
autoFocus={mode === 'create'}
disabled={mode === 'edit'}
/>
<Input
{...register('value')}
id="value"
label="Value"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.value}
helperText={errors?.value?.message}
fullWidth
multiline
rows={5}
autoComplete="off"
autoFocus={mode === 'edit'}
/>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
{submitButtonText}
</Button>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Form>
</div>
);
}

View File

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

View File

@@ -0,0 +1,85 @@
import type {
BaseSecretFormProps,
BaseSecretFormValues,
} from '@/components/settings/secrets/BaseSecretForm';
import BaseSecretForm, {
baseSecretFormValidationSchema,
} from '@/components/settings/secrets/BaseSecretForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
GetSecretsDocument,
useInsertSecretMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
export interface CreateSecretFormProps
extends Pick<BaseSecretFormProps, 'onCancel'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function CreateSecretForm({
onSubmit,
...props
}: CreateSecretFormProps) {
const form = useForm<BaseSecretFormValues>({
defaultValues: {
name: '',
value: '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseSecretFormValidationSchema),
});
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [insertSecret] = useInsertSecretMutation({
refetchQueries: [GetSecretsDocument],
});
async function handleSubmit({ name, value }: BaseSecretFormValues) {
const insertSecretPromise = insertSecret({
variables: {
appId: currentApplication?.id,
secret: {
name,
value,
},
},
});
try {
await toast.promise(
insertSecretPromise,
{
loading: 'Creating secret...',
success: 'Secret has been created successfully.',
error: (arg: Error) =>
arg?.message
? `Error: ${arg?.message}`
: 'An error occurred while creating the secret.',
},
getToastStyleProps(),
);
onSubmit?.();
} catch (error) {
console.error(error);
}
}
return (
<FormProvider {...form}>
<BaseSecretForm
mode="create"
submitButtonText="Create"
onSubmit={handleSubmit}
{...props}
/>
</FormProvider>
);
}

View File

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

View File

@@ -0,0 +1,91 @@
import type {
BaseSecretFormProps,
BaseSecretFormValues,
} from '@/components/settings/secrets/BaseSecretForm';
import BaseSecretForm, {
baseSecretFormValidationSchema,
} from '@/components/settings/secrets/BaseSecretForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Secret } from '@/types/application';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
GetSecretsDocument,
useUpdateSecretMutation,
} 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 EditSecretFormProps
extends Pick<BaseSecretFormProps, 'onCancel'> {
/**
* The secret to edit.
*/
originalSecret: Secret;
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
}
export default function EditSecretForm({
originalSecret,
onSubmit,
...props
}: EditSecretFormProps) {
const form = useForm<BaseSecretFormValues>({
defaultValues: {
name: originalSecret.name,
value: '',
},
reValidateMode: 'onSubmit',
resolver: yupResolver(baseSecretFormValidationSchema),
});
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateSecret] = useUpdateSecretMutation({
refetchQueries: [GetSecretsDocument],
});
async function handleSubmit({ name, value }: BaseSecretFormValues) {
const updateSecretPromise = updateSecret({
variables: {
appId: currentApplication?.id,
secret: {
name,
value,
},
},
});
try {
await toast.promise(
updateSecretPromise,
{
loading: 'Updating secret...',
success: 'Secret has been updated successfully.',
error: (arg: Error) =>
arg?.message
? `Error: ${arg?.message}`
: 'An error occurred while updating the secret.',
},
getToastStyleProps(),
);
onSubmit?.();
} catch (error) {
console.error(error);
}
}
return (
<FormProvider {...form}>
<BaseSecretForm
mode="edit"
submitButtonText="Save"
onSubmit={handleSubmit}
{...props}
/>
</FormProvider>
);
}

View File

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

View File

@@ -1,45 +1,53 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface AnonymousSignInFormValues { const validationSchema = Yup.object({
/** enabled: Yup.boolean(),
* Enables users to register as an anonymous user. });
*/
authAnonymousUsersEnabled: boolean; export type AnonymousSignInFormValues = Yup.InferType<typeof validationSchema>;
}
export default function AnonymousSignInSettings() { export default function AnonymousSignInSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { enabled } = data?.config?.auth?.method?.anonymous || {};
const form = useForm<AnonymousSignInFormValues>({ const form = useForm<AnonymousSignInFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authAnonymousUsersEnabled: data.app.authAnonymousUsersEnabled, enabled,
}, },
resolver: yupResolver(validationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading..." label="Loading anonymous sign-in settings..."
className="justify-center" className="justify-center"
/> />
); );
@@ -52,26 +60,36 @@ export default function AnonymousSignInSettings() {
const handlePasswordProtectionSettingsChange = async ( const handlePasswordProtectionSettingsChange = async (
values: AnonymousSignInFormValues, values: AnonymousSignInFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
...values, auth: {
method: {
anonymous: values,
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Anonymous sign-in settings are being updated...`, {
success: `Anonymous sign-in settings have been updated successfully.`, loading: `Anonymous sign-in settings are being updated...`,
error: `An error occurred while trying to update Anonymous sign-in settings.`, success: `Anonymous sign-in settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update Anonymous sign-in settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -80,14 +98,13 @@ export default function AnonymousSignInSettings() {
<SettingsContainer <SettingsContainer
title="Anonymous Users" title="Anonymous Users"
description="Allow users to sign in anonymously." description="Allow users to sign in anonymously."
primaryActionButtonProps={{ slotProps={{
disabled: submitButton: {
form.formState.isSubmitting || disabled: !form.formState.isDirty || maintenanceActive,
!form.formState.isValid || loading: form.formState.isSubmitting,
!form.formState.isDirty, },
}} }}
enabled={form.getValues('authAnonymousUsersEnabled')} switchId="enabled"
switchId="authAnonymousUsersEnabled"
showSwitch showSwitch
className="hidden" className="hidden"
/> />

View File

@@ -1,8 +1,10 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -12,60 +14,70 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useTheme } from '@mui/material'; import { useTheme } from '@mui/material';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface AppleProviderFormValues { const validationSchema = Yup.object({
authAppleEnabled: boolean; teamId: Yup.string().label('Team ID').when('enabled', {
authAppleTeamId: string; is: true,
authAppleKeyId: string; then: Yup.string().required(),
authAppleClientId: string; }),
authApplePrivateKey: string; keyId: Yup.string().label('Key ID').when('enabled', {
} is: true,
then: Yup.string().required(),
}),
clientId: Yup.string().label('Client ID').when('enabled', {
is: true,
then: Yup.string().required(),
}),
privateKey: Yup.string().label('Private Key').when('enabled', {
is: true,
then: Yup.string().required(),
}),
enabled: Yup.boolean(),
});
export type AppleProviderFormValues = Yup.InferType<typeof validationSchema>;
export default function AppleProviderSettings() { export default function AppleProviderSettings() {
const theme = useTheme(); const theme = useTheme();
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { const { data, loading, error } = useGetSignInMethodsQuery({
data: { variables: { appId: currentApplication?.id },
app: {
authAppleEnabled,
authAppleTeamId,
authAppleKeyId,
authAppleClientId,
authApplePrivateKey,
},
},
loading,
error,
} = useSignInMethodsQuery({
variables: {
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { clientId, enabled, keyId, privateKey, teamId } =
data?.config?.auth?.method?.oauth?.apple || {};
const form = useForm<AppleProviderFormValues>({ const form = useForm<AppleProviderFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authAppleTeamId, teamId: teamId || '',
authAppleKeyId, keyId: keyId || '',
authAppleClientId, clientId: clientId || '',
authApplePrivateKey, privateKey: privateKey || '',
authAppleEnabled, enabled: enabled || false,
}, },
resolver: yupResolver(validationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading Apple settings..." label="Loading settings for Apple..."
className="justify-center" className="justify-center"
/> />
); );
@@ -76,27 +88,44 @@ export default function AppleProviderSettings() {
} }
const { register, formState, watch } = form; const { register, formState, watch } = form;
const authEnabled = watch('authAppleEnabled'); const authEnabled = watch('enabled');
const handleProviderUpdate = async (values: AppleProviderFormValues) => { const handleProviderUpdate = async (values: AppleProviderFormValues) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: values, config: {
auth: {
method: {
oauth: {
apple: {
...values,
scope: [],
},
},
},
},
},
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Apple settings are being updated...`, {
success: `Apple settings have been updated successfully.`, loading: `Apple settings are being updated...`,
error: `An error occurred while trying to update the project's Apple settings.`, success: `Apple settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's Apple settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -107,7 +136,7 @@ export default function AppleProviderSettings() {
description="Allow users to sign in with Apple." description="Allow users to sign in with Apple."
slotProps={{ slotProps={{
submitButton: { submitButton: {
disabled: !formState.isValid || !formState.isDirty, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting, loading: formState.isSubmitting,
}, },
}} }}
@@ -118,55 +147,62 @@ export default function AppleProviderSettings() {
? '/assets/brands/light/apple.svg' ? '/assets/brands/light/apple.svg'
: '/assets/brands/apple.svg' : '/assets/brands/apple.svg'
} }
switchId="authAppleEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authEnabled}
className={twMerge( className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2', 'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden', !authEnabled && 'hidden',
)} )}
> >
<Input <Input
{...register(`authAppleTeamId`)} {...register('teamId')}
name="authAppleTeamId" name="teamId"
id="authAppleTeamId" id="teamId"
label="Team ID" label="Team ID"
placeholder="Apple Team ID" placeholder="Apple Team ID"
className="col-span-1" className="col-span-1"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
error={!!formState.errors?.teamId}
helperText={formState.errors?.teamId?.message}
/> />
<Input <Input
{...register('authAppleClientId')} {...register('clientId')}
name="authAppleClientId" name="clientId"
id="authAppleClientId" id="clientId"
label="Service ID" label="Service ID"
placeholder="Apple Service ID" placeholder="Apple Service ID"
className="col-span-1" className="col-span-1"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
error={!!formState.errors?.clientId}
helperText={formState.errors?.clientId?.message}
/> />
<Input <Input
{...register('authAppleKeyId')} {...register('keyId')}
name="authAppleKeyId" name="keyId"
id="authAppleKeyId" id="keyId"
label="Key ID" label="Key ID"
placeholder="Apple Key ID" placeholder="Apple Key ID"
className="col-span-2" className="col-span-2"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
error={!!formState.errors?.keyId}
helperText={formState.errors?.keyId?.message}
/> />
<Input <Input
{...register('authApplePrivateKey')} {...register('privateKey')}
multiline multiline
rows={4} rows={4}
name="authApplePrivateKey" name="privateKey"
id="authApplePrivateKey" id="privateKey"
label="Private Key" label="Private Key"
placeholder="Paste Private Key here" placeholder="Paste Private Key here"
className="col-span-2" className="col-span-2"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
error={!!formState.errors?.privateKey}
helperText={formState.errors?.privateKey?.message}
/> />
<Input <Input
name="redirectUrl" name="redirectUrl"
@@ -199,7 +235,7 @@ export default function AppleProviderSettings() {
); );
}} }}
> >
<CopyIcon className="w-4 h-4" /> <CopyIcon className="h-4 w-4" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
} }

View File

@@ -1,60 +1,59 @@
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BaseProviderSettingsFormValues { export const baseProviderValidationSchema = Yup.object({
authEnabled: boolean; clientId: Yup.string().label('Client ID').when('enabled', {
authClientId: string; is: true,
authClientSecret: string; then: Yup.string().required(),
}),
clientSecret: Yup.string().label('Client Secret').when('enabled', {
is: true,
then: Yup.string().required(),
}),
enabled: Yup.bool(),
});
export type BaseProviderSettingsFormValues = Yup.InferType<
typeof baseProviderValidationSchema
>;
export interface BaseProviderSettingsProps {
/**
* The name of the provider. Used to provide unique IDs to the inputs.
*/
providerName: string;
} }
/** export default function BaseProviderSettings({
* Third-party auth providers e.g. Google, GitHub. providerName,
* }: BaseProviderSettingsProps) {
* @remarks const { register, formState } =
* useFormContext<BaseProviderSettingsFormValues>();
* These providers follow the same API structure in our database and in our GraphQL API:
* In the case of adding a new provider to this list it should contain the configuration in the example below.
*
* ```
* auth<Provider>Enabled
* auth<Provider>ClientId
* auth<Provider>ClientSecret
* ```
*
* @example
*
* ```
* authGithubEnabled
* authGithubClientId
* authGithubClientSecret
* ```
*
* @remarks If the provider has a different configuration (more or less fields) it should be added as its own component
* @see {@link 'src\components\settings\sign-in-methods\ProviderTwitterSettings\ProviderTwitterSettings.tsx'}
*
*/
export default function BaseProviderSettings() {
const { register } = useFormContext<BaseProviderSettingsFormValues>();
return ( return (
<> <>
<Input <Input
{...register(`authClientId`)} {...register('clientId')}
id="authClientId" id={`${providerName}-clientId`}
label="Client ID" label="Client ID"
placeholder="Enter your Client ID" placeholder="Enter your Client ID"
className="col-span-1" className="col-span-1"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
error={!!formState.errors?.clientId}
helperText={formState.errors?.clientId?.message}
/> />
<Input <Input
{...register(`authClientSecret`)} {...register('clientSecret')}
id="authClientSecret" id={`${providerName}-clientSecret`}
label="Client Secret" label="Client Secret"
placeholder="Enter your Client Secret" placeholder="Enter your Client Secret"
className="col-span-1" className="col-span-1"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
error={!!formState.errors?.clientSecret}
helperText={formState.errors?.clientSecret?.message}
/> />
</> </>
); );

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings'; import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings'; import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,36 +18,43 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export default function DiscordProviderSettings() { export default function DiscordProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.discord || {};
const form = useForm<BaseProviderSettingsFormValues>({ const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authClientId: data?.app?.authDiscordClientId, clientId: clientId || '',
authClientSecret: data?.app?.authDiscordClientSecret, clientSecret: clientSecret || '',
authEnabled: data?.app?.authDiscordEnabled, enabled: enabled || false,
}, },
resolver: yupResolver(baseProviderValidationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading Discord settings..." label="Loading settings for Discord..."
className="justify-center" className="justify-center"
/> />
); );
@@ -54,33 +65,46 @@ export default function DiscordProviderSettings() {
} }
const { formState, watch } = form; const { formState, watch } = form;
const authEnabled = watch('authEnabled'); const authEnabled = watch('enabled');
const handleProviderUpdate = async ( const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues, values: BaseProviderSettingsFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication?.id,
app: { config: {
authDiscordClientId: values.authClientId, auth: {
authDiscordClientSecret: values.authClientSecret, method: {
authDiscordEnabled: values.authEnabled, oauth: {
discord: {
...values,
scope: [],
},
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Discord settings are being updated...`, {
success: `Discord settings have been updated successfully.`, loading: `Discord settings are being updated...`,
error: `An error occurred while trying to update the project's Discord settings.`, success: `Discord settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's Discrod settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -89,22 +113,23 @@ export default function DiscordProviderSettings() {
<SettingsContainer <SettingsContainer
title="Discord" title="Discord"
description="Allow users to sign in with Discord." description="Allow users to sign in with Discord."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-discord" docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-discord"
docsTitle="how to sign in users with Discord" docsTitle="how to sign in users with Discord"
icon="/assets/brands/discord.svg" icon="/assets/brands/discord.svg"
switchId="authEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authEnabled}
className={twMerge( className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2', 'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden', !authEnabled && 'hidden',
)} )}
> >
<BaseProviderSettings /> <BaseProviderSettings providerName="discord" />
<Input <Input
name="redirectUrl" name="redirectUrl"
id="redirectUrl" id="redirectUrl"
@@ -136,7 +161,7 @@ export default function DiscordProviderSettings() {
); );
}} }}
> >
<CopyIcon className="w-4 h-4" /> <CopyIcon className="h-4 w-4" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
} }

View File

@@ -1,46 +1,51 @@
import ControlledCheckbox from '@/components/common/ControlledCheckbox'; import ControlledCheckbox from '@/components/common/ControlledCheckbox';
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface EmailAndPasswordFormValues { const validationSchema = Yup.object({
/** emailVerificationRequired: Yup.boolean(),
* When enabled, users will need to verify their email by a link sent to their specified email. hibpEnabled: Yup.boolean(),
*/ });
authEmailSigninEmailVerifiedRequired: boolean;
/** export type EmailAndPasswordFormValues = Yup.InferType<typeof validationSchema>;
* If true, users' passwords will be checked against https://haveibeenpwned.com/Passwords
*/
authPasswordHibpEnabled: boolean;
}
export default function EmailAndPasswordSettings() { export default function EmailAndPasswordSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, error, loading } = useSignInMethodsQuery({ const { data, error, loading } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { hibpEnabled, emailVerificationRequired } =
data?.config?.auth?.method?.emailPassword || {};
const form = useForm<EmailAndPasswordFormValues>({ const form = useForm<EmailAndPasswordFormValues>({
reValidateMode: 'onChange', reValidateMode: 'onChange',
defaultValues: { defaultValues: {
authPasswordHibpEnabled: data?.app?.authPasswordHibpEnabled || false, hibpEnabled: hibpEnabled || false,
authEmailSigninEmailVerifiedRequired: emailVerificationRequired: emailVerificationRequired || false,
data?.app?.authEmailSigninEmailVerifiedRequired || false,
}, },
resolver: yupResolver(validationSchema),
}); });
if (loading) { if (loading) {
@@ -62,28 +67,36 @@ export default function EmailAndPasswordSettings() {
const handleEmailAndPasswordSettingsChange = async ( const handleEmailAndPasswordSettingsChange = async (
values: EmailAndPasswordFormValues, values: EmailAndPasswordFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
authPasswordHibpEnabled: values.authPasswordHibpEnabled, auth: {
authEmailSigninEmailVerifiedRequired: method: {
values.authEmailSigninEmailVerifiedRequired, emailPassword: values,
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Email and password sign-in settings are being updated...`, {
success: `Email and password sign-in settings have been updated successfully.`, loading: `Email and password sign-in settings are being updated...`,
error: `An error occurred while trying to update email sign-in settings.`, success: `Email and password sign-in settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update email sign-in settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -98,18 +111,16 @@ export default function EmailAndPasswordSettings() {
showSwitch showSwitch
enabled enabled
slotProps={{ slotProps={{
switch: { switch: { disabled: true },
disabled: true,
},
submitButton: { submitButton: {
disabled: !formState.isValid || !formState.isDirty, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting, loading: formState.isSubmitting,
}, },
}} }}
> >
<ControlledCheckbox <ControlledCheckbox
name="authEmailSigninEmailVerifiedRequired" name="emailVerificationRequired"
id="authEmailSigninEmailVerifiedRequired" id="emailVerificationRequired"
label={ label={
<span className="inline-grid grid-flow-row gap-y-0.5 text-sm+"> <span className="inline-grid grid-flow-row gap-y-0.5 text-sm+">
<Text component="span">Require Verified Emails</Text> <Text component="span">Require Verified Emails</Text>
@@ -121,8 +132,8 @@ export default function EmailAndPasswordSettings() {
/> />
<ControlledCheckbox <ControlledCheckbox
name="authPasswordHibpEnabled" name="hibpEnabled"
id="authPasswordHibpEnabled" id="hibpEnabled"
label={ label={
<span className="inline-grid grid-flow-row gap-y-0.5 text-sm+"> <span className="inline-grid grid-flow-row gap-y-0.5 text-sm+">
<Text component="span">Password Protection</Text> <Text component="span">Password Protection</Text>

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings'; import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings'; import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,36 +18,43 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export default function FacebookProviderSettings() { export default function FacebookProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.facebook || {};
const form = useForm<BaseProviderSettingsFormValues>({ const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authClientId: data?.app?.authFacebookClientId, clientId: clientId || '',
authClientSecret: data?.app?.authFacebookClientSecret, clientSecret: clientSecret || '',
authEnabled: data?.app?.authFacebookEnabled, enabled: enabled || false,
}, },
resolver: yupResolver(baseProviderValidationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading Facebook settings..." label="Loading settings for Facebook..."
className="justify-center" className="justify-center"
/> />
); );
@@ -54,33 +65,46 @@ export default function FacebookProviderSettings() {
} }
const { formState, watch } = form; const { formState, watch } = form;
const authEnabled = watch('authEnabled'); const authEnabled = watch('enabled');
const handleProviderUpdate = async ( const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues, values: BaseProviderSettingsFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
authFacebookClientId: values.authClientId, auth: {
authFacebookClientSecret: values.authClientSecret, method: {
authFacebookEnabled: values.authEnabled, oauth: {
facebook: {
...values,
scope: [],
},
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Facebook settings are being updated...`, {
success: `Facebook settings have been updated successfully.`, loading: `Facebook settings are being updated...`,
error: `An error occurred while trying to update the project's Facebook settings.`, success: `Facebook settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's Facebook settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -89,22 +113,23 @@ export default function FacebookProviderSettings() {
<SettingsContainer <SettingsContainer
title="Facebook" title="Facebook"
description="Allow users to sign in with Facebook." description="Allow users to sign in with Facebook."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-facebook" docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-facebook"
docsTitle="how to sign in users with Facebook" docsTitle="how to sign in users with Facebook"
icon="/assets/brands/facebook.svg" icon="/assets/brands/facebook.svg"
switchId="authEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authEnabled}
className={twMerge( className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2', 'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden', !authEnabled && 'hidden',
)} )}
> >
<BaseProviderSettings /> <BaseProviderSettings providerName="facebook" />
<Input <Input
name="redirectUrl" name="redirectUrl"
id="redirectUrl" id="redirectUrl"
@@ -136,7 +161,7 @@ export default function FacebookProviderSettings() {
); );
}} }}
> >
<CopyIcon className="w-4 h-4" /> <CopyIcon className="h-4 w-4" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
} }

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings'; import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings'; import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,7 +18,9 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useTheme } from '@mui/material'; import { useTheme } from '@mui/material';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@@ -22,30 +28,35 @@ import { twMerge } from 'tailwind-merge';
export default function GitHubProviderSettings() { export default function GitHubProviderSettings() {
const theme = useTheme(); const theme = useTheme();
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.github || {};
const form = useForm<BaseProviderSettingsFormValues>({ const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authClientId: data?.app?.authGithubClientId, clientId: clientId || '',
authClientSecret: data?.app?.authGithubClientSecret, clientSecret: clientSecret || '',
authEnabled: data?.app?.authGithubEnabled, enabled: enabled || false,
}, },
resolver: yupResolver(baseProviderValidationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading GitHub settings..." label="Loading settings for GitHub..."
className="justify-center" className="justify-center"
/> />
); );
@@ -56,33 +67,46 @@ export default function GitHubProviderSettings() {
} }
const { formState, watch } = form; const { formState, watch } = form;
const authEnabled = watch('authEnabled'); const authEnabled = watch('enabled');
const handleProviderUpdate = async ( const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues, values: BaseProviderSettingsFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
authGithubClientId: values.authClientId, auth: {
authGithubClientSecret: values.authClientSecret, method: {
authGithubEnabled: values.authEnabled, oauth: {
github: {
...values,
scope: [],
},
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `GitHub settings are being updated...`, {
success: `GitHub settings have been updated successfully.`, loading: `GitHub settings are being updated...`,
error: `An error occurred while trying to update the project's GitHub settings.`, success: `GitHub settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's GitHub settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -91,9 +115,11 @@ export default function GitHubProviderSettings() {
<SettingsContainer <SettingsContainer
title="GitHub" title="GitHub"
description="Allow users to sign in with GitHub." description="Allow users to sign in with GitHub."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-github" docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-github"
docsTitle="how to sign in users with GitHub" docsTitle="how to sign in users with GitHub"
@@ -102,15 +128,14 @@ export default function GitHubProviderSettings() {
? '/assets/brands/light/github.svg' ? '/assets/brands/light/github.svg'
: '/assets/brands/github.svg' : '/assets/brands/github.svg'
} }
switchId="authEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authEnabled}
className={twMerge( className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2', 'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden', !authEnabled && 'hidden',
)} )}
> >
<BaseProviderSettings /> <BaseProviderSettings providerName="github" />
<Input <Input
name="redirectUrl" name="redirectUrl"
id="redirectUrl" id="redirectUrl"
@@ -142,7 +167,7 @@ export default function GitHubProviderSettings() {
); );
}} }}
> >
<CopyIcon className="w-4 h-4" /> <CopyIcon className="h-4 w-4" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
} }

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings'; import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings'; import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,36 +18,43 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export default function GoogleProviderSettings() { export default function GoogleProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.google || {};
const form = useForm<BaseProviderSettingsFormValues>({ const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authClientId: data?.app?.authGoogleClientId, clientId: clientId || '',
authClientSecret: data?.app?.authGoogleClientSecret, clientSecret: clientSecret || '',
authEnabled: data?.app?.authGoogleEnabled, enabled: enabled || false,
}, },
resolver: yupResolver(baseProviderValidationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading Google settings..." label="Loading settings for Google..."
className="justify-center" className="justify-center"
/> />
); );
@@ -54,33 +65,46 @@ export default function GoogleProviderSettings() {
} }
const { formState, watch } = form; const { formState, watch } = form;
const authEnabled = watch('authEnabled'); const authEnabled = watch('enabled');
const handleProviderUpdate = async ( const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues, values: BaseProviderSettingsFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
authGoogleClientId: values.authClientId, auth: {
authGoogleClientSecret: values.authClientSecret, method: {
authGoogleEnabled: values.authEnabled, oauth: {
google: {
...values,
scope: [],
},
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Google settings are being updated...`, {
success: `Google settings have been updated successfully.`, loading: `Google settings are being updated...`,
error: `An error occurred while trying to update the project's Google settings.`, success: `Google settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's Google settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -89,22 +113,23 @@ export default function GoogleProviderSettings() {
<SettingsContainer <SettingsContainer
title="Google" title="Google"
description="Allow users to sign in with Google." description="Allow users to sign in with Google."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-google" docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-google"
docsTitle="how to sign in users with Google" docsTitle="how to sign in users with Google"
icon="/assets/brands/google.svg" icon="/assets/brands/google.svg"
switchId="authEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authEnabled}
className={twMerge( className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2', 'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden', !authEnabled && 'hidden',
)} )}
> >
<BaseProviderSettings /> <BaseProviderSettings providerName="google" />
<Input <Input
name="redirectUrl" name="redirectUrl"
id="redirectUrl" id="redirectUrl"
@@ -136,7 +161,7 @@ export default function GoogleProviderSettings() {
); );
}} }}
> >
<CopyIcon className="w-4 h-4" /> <CopyIcon className="h-4 w-4" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
} }

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings'; import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings'; import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,36 +18,43 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export default function LinkedInProviderSettings() { export default function LinkedInProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.linkedin || {};
const form = useForm<BaseProviderSettingsFormValues>({ const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authClientId: data?.app?.authLinkedinClientId, clientId: clientId || '',
authClientSecret: data?.app?.authLinkedinClientSecret, clientSecret: clientSecret || '',
authEnabled: data?.app?.authLinkedinEnabled, enabled: enabled || false,
}, },
resolver: yupResolver(baseProviderValidationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading..." label="Loading settings for LinkedIn..."
className="justify-center" className="justify-center"
/> />
); );
@@ -54,33 +65,46 @@ export default function LinkedInProviderSettings() {
} }
const { formState, watch } = form; const { formState, watch } = form;
const authEnabled = watch('authEnabled'); const authEnabled = watch('enabled');
const handleProviderUpdate = async ( const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues, values: BaseProviderSettingsFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
authLinkedinClientId: values.authClientId, auth: {
authLinkedinClientSecret: values.authClientSecret, method: {
authLinkedinEnabled: values.authEnabled, oauth: {
linkedin: {
...values,
scope: [],
},
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `LinkedIn settings are being updated...`, {
success: `LinkedIn settings have been updated successfully.`, loading: `LinkedIn settings are being updated...`,
error: `An error occurred while trying to update the project's LinkedIn settings.`, success: `LinkedIn settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's LinkedIn settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -89,22 +113,23 @@ export default function LinkedInProviderSettings() {
<SettingsContainer <SettingsContainer
title="LinkedIn" title="LinkedIn"
description="Allow users to sign in with LinkedIn." description="Allow users to sign in with LinkedIn."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-linkedin" docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-linkedin"
docsTitle="how to sign in users with LinkedIn" docsTitle="how to sign in users with LinkedIn"
icon="/assets/brands/linkedin.svg" icon="/assets/brands/linkedin.svg"
switchId="authEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authEnabled}
className={twMerge( className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2', 'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden', !authEnabled && 'hidden',
)} )}
> >
<BaseProviderSettings /> <BaseProviderSettings providerName="linkedin" />
<Input <Input
name="redirectUrl" name="redirectUrl"
id="redirectUrl" id="redirectUrl"
@@ -136,7 +161,7 @@ export default function LinkedInProviderSettings() {
); );
}} }}
> >
<CopyIcon className="w-4 h-4" /> <CopyIcon className="h-4 w-4" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
} }

View File

@@ -1,45 +1,53 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface MagicLinkFormValues { const validationSchema = Yup.object({
/** enabled: Yup.boolean(),
* Enables passwordless authentication by email. });
*/
authEmailPasswordlessEnabled: boolean; export type MagicLinkFormValues = Yup.InferType<typeof validationSchema>;
}
export default function MagicLinkSettings() { export default function MagicLinkSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { enabled } = data?.config?.auth?.method?.emailPasswordless || {};
const form = useForm<MagicLinkFormValues>({ const form = useForm<MagicLinkFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authEmailPasswordlessEnabled: data.app.authEmailPasswordlessEnabled, enabled,
}, },
resolver: yupResolver(validationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading Magic Link settings..." label="Loading settings for Magic Link..."
className="justify-center" className="justify-center"
/> />
); );
@@ -49,30 +57,39 @@ export default function MagicLinkSettings() {
throw error; throw error;
} }
const { formState, watch } = form; const { formState } = form;
const authEmailPasswordlessEnabled = watch('authEmailPasswordlessEnabled');
const handleMagicLinkSettingsUpdate = async (values: MagicLinkFormValues) => { const handleMagicLinkSettingsUpdate = async (values: MagicLinkFormValues) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
...values, auth: {
method: {
emailPasswordless: values,
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Magic Link settings are being updated...`, {
success: `Magic Link settings have been updated successfully.`, loading: `Magic Link settings are being updated...`,
error: `An error occurred while trying to update the project's Magic Link settings.`, success: `Magic Link settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's Magic Link settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -81,14 +98,15 @@ export default function MagicLinkSettings() {
<SettingsContainer <SettingsContainer
title="Magic Link" title="Magic Link"
description="Allow users to sign in with a Magic Link." description="Allow users to sign in with a Magic Link."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/authentication/sign-in-with-magic-link" docsLink="https://docs.nhost.io/authentication/sign-in-with-magic-link"
docsTitle="how to sign in users with Magic Link" docsTitle="how to sign in users with Magic Link"
enabled={authEmailPasswordlessEnabled} switchId="enabled"
switchId="authEmailPasswordlessEnabled"
showSwitch showSwitch
className="hidden" className="hidden"
/> />

View File

@@ -5,6 +5,7 @@ import Button from '@/ui/v2/Button';
import ArrowSquareOutIcon from '@/ui/v2/icons/ArrowSquareOutIcon'; import ArrowSquareOutIcon from '@/ui/v2/icons/ArrowSquareOutIcon';
import Link from '@/ui/v2/Link'; import Link from '@/ui/v2/Link';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { useConfirmProvidersUpdatedMutation } from '@/utils/__generated__/graphql'; import { useConfirmProvidersUpdatedMutation } from '@/utils/__generated__/graphql';
import { useState } from 'react'; import { useState } from 'react';
@@ -27,7 +28,9 @@ export default function ProvidersUpdatedAlert() {
{ {
loading: 'Confirming...', loading: 'Confirming...',
success: 'Your settings have been updated successfully.', success: 'Your settings have been updated successfully.',
error: 'An error occurred while trying to confirm the message.', error: getServerError(
'An error occurred while trying to confirm the message.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
@@ -55,7 +58,7 @@ export default function ProvidersUpdatedAlert() {
} }
return ( return (
<Alert className="grid items-center grid-flow-row gap-2 p-4 place-items-center lg:grid-flow-col lg:place-content-between bg-amber-500"> <Alert className="grid grid-flow-row place-items-center items-center gap-2 bg-amber-500 p-4 lg:grid-flow-col lg:place-content-between">
<div className="grid grid-flow-row gap-1 text-left"> <div className="grid grid-flow-row gap-1 text-left">
<Text className="font-semibold"> <Text className="font-semibold">
Please update the Redirect URL for all providers being used Please update the Redirect URL for all providers being used
@@ -74,7 +77,7 @@ export default function ProvidersUpdatedAlert() {
className="font-medium" className="font-medium"
> >
Read the discussion here. Read the discussion here.
<ArrowSquareOutIcon className="w-4 h-4 ml-1" /> <ArrowSquareOutIcon className="ml-1 h-4 w-4" />
</Link> </Link>
</Text> </Text>
</div> </div>

View File

@@ -1,9 +1,10 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { import {
GetSmsSettingsDocument, GetSignInMethodsDocument,
useSignInMethodsQuery, useGetSignInMethodsQuery,
useUpdateAppMutation, useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -11,79 +12,119 @@ import Input from '@/ui/v2/Input';
import Option from '@/ui/v2/Option'; import Option from '@/ui/v2/Option';
import Select from '@/ui/v2/Select'; import Select from '@/ui/v2/Select';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import Image from 'next/image'; import Image from 'next/image';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface SMSSettingsFormValues { const validationSchema = Yup.object({
authSmsTwilioAccountSid: string; accountSid: Yup.string().label('Account SID').when('enabled', {
authSmsTwilioAuthToken: string; is: true,
authSmsTwilioMessagingServiceId: string; then: Yup.string().required(),
authSmsPasswordlessEnabled: boolean; }),
} authToken: Yup.string().label('Auth Token').when('enabled', {
is: true,
then: Yup.string().required(),
}),
messagingServiceId: Yup.string()
.label('Messaging Service ID')
.when('enabled', {
is: true,
then: Yup.string().required(),
}),
enabled: Yup.boolean().label('Enabled'),
});
export type SMSSettingsFormValues = Yup.InferType<typeof validationSchema>;
export default function SMSSettings() { export default function SMSSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation({ const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSmsSettingsDocument], refetchQueries: [GetSignInMethodsDocument],
}); });
const { data, loading } = useSignInMethodsQuery({ const { data, error, loading } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
onError: (error) => {
throw error;
},
}); });
const { accountSid, authToken, messagingServiceId } =
data?.config?.provider?.sms || {};
const { enabled } = data?.config?.auth?.method?.smsPasswordless || {};
const form = useForm<SMSSettingsFormValues>({ const form = useForm<SMSSettingsFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authSmsTwilioAccountSid: data.app.authSmsTwilioAccountSid, accountSid: accountSid || '',
authSmsTwilioAuthToken: data.app.authSmsTwilioAuthToken, authToken: authToken || '',
authSmsTwilioMessagingServiceId: data.app.authSmsTwilioMessagingServiceId, messagingServiceId: messagingServiceId || '',
authSmsPasswordlessEnabled: data.app.authSmsPasswordlessEnabled, enabled: enabled || false,
}, },
resolver: yupResolver(validationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading SMS settings..." label="Loading settings for the SMS provider..."
className="justify-center" className="justify-center"
/> />
); );
} }
if (error) {
throw error;
}
const { register, formState, watch } = form; const { register, formState, watch } = form;
const authSmsPasswordlessEnabled = watch('authSmsPasswordlessEnabled'); const authSmsPasswordlessEnabled = watch('enabled');
const handleSMSSettingsChange = async (values: SMSSettingsFormValues) => { const handleSMSSettingsChange = async (values: SMSSettingsFormValues) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
...values, provider: {
sms: {
accountSid: values.accountSid,
authToken: values.authToken,
messagingServiceId: values.messagingServiceId,
},
},
auth: {
method: {
smsPasswordless: {
enabled: values.enabled,
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `SMS settings are being updated...`, {
success: `SMS settings have been updated successfully.`, loading: `SMS settings are being updated...`,
error: `An error occurred while trying to update SMS settings.`, success: `SMS settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update SMS settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -92,12 +133,13 @@ export default function SMSSettings() {
<SettingsContainer <SettingsContainer
title="Phone Number (SMS)" title="Phone Number (SMS)"
description="Allow users to sign in with Phone Number (SMS)." description="Allow users to sign in with Phone Number (SMS)."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
switchId="authSmsPasswordlessEnabled" switchId="enabled"
enabled={authSmsPasswordlessEnabled}
showSwitch showSwitch
docsLink="https://docs.nhost.io/authentication/sign-in-with-phone-number-sms" docsLink="https://docs.nhost.io/authentication/sign-in-with-phone-number-sms"
docsTitle="how to sign in users with a phone number (SMS)" docsTitle="how to sign in users with a phone number (SMS)"
@@ -136,34 +178,40 @@ export default function SMSSettings() {
</Option> </Option>
</Select> </Select>
<Input <Input
{...register('authSmsTwilioAccountSid')} {...register('accountSid')}
name="authSmsTwilioAccountSid" name="accountSid"
id="authSmsTwilioAccountSid" id="accountSid"
placeholder="Account SID" placeholder="Account SID"
className="col-span-2 lg:col-span-1" className="col-span-2 lg:col-span-1"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
label="Account SID" label="Account SID"
error={!!formState.errors?.accountSid}
helperText={formState.errors?.accountSid?.message}
/> />
<Input <Input
{...register('authSmsTwilioAuthToken')} {...register('authToken')}
name="authSmsTwilioAuthToken" name="authToken"
id="authSmsTwilioAuthToken" id="authToken"
placeholder="Auth Token" placeholder="Auth Token"
className="col-span-2 lg:col-span-1" className="col-span-2 lg:col-span-1"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
label="Auth Token" label="Auth Token"
error={!!formState.errors?.authToken}
helperText={formState.errors?.authToken?.message}
/> />
<Input <Input
{...register('authSmsTwilioMessagingServiceId')} {...register('messagingServiceId')}
name="authSmsTwilioMessagingServiceId" name="messagingServiceId"
id="authSmsTwilioMessagingServiceId" id="messagingServiceId"
placeholder="Messaging Service ID" placeholder="Messaging Service ID"
className="col-span-2 lg:col-span-1" className="col-span-2 lg:col-span-1"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
label="Messaging Service ID" label="Messaging Service ID"
error={!!formState.errors?.messagingServiceId}
helperText={formState.errors?.messagingServiceId?.message}
/> />
</SettingsContainer> </SettingsContainer>
</Form> </Form>

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings'; import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings'; import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,36 +18,43 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export default function SpotifyProviderSettings() { export default function SpotifyProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.spotify || {};
const form = useForm<BaseProviderSettingsFormValues>({ const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authClientId: data?.app?.authSpotifyClientId, clientId: clientId || '',
authClientSecret: data?.app?.authSpotifyClientSecret, clientSecret: clientSecret || '',
authEnabled: data?.app?.authSpotifyEnabled, enabled: enabled || false,
}, },
resolver: yupResolver(baseProviderValidationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading Spotify settings..." label="Loading settings for Spotify..."
className="justify-center" className="justify-center"
/> />
); );
@@ -54,33 +65,46 @@ export default function SpotifyProviderSettings() {
} }
const { formState, watch } = form; const { formState, watch } = form;
const authEnabled = watch('authEnabled'); const authEnabled = watch('enabled');
const handleProviderUpdate = async ( const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues, values: BaseProviderSettingsFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
authSpotifyClientId: values.authClientId, auth: {
authSpotifyClientSecret: values.authClientSecret, method: {
authSpotifyEnabled: values.authEnabled, oauth: {
spotify: {
...values,
scope: [],
},
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Spotify settings are being updated...`, {
success: `Spotify settings have been updated successfully.`, loading: `Spotify settings are being updated...`,
error: `An error occurred while trying to update the project's Spotify settings.`, success: `Spotify settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's Spotify settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -89,22 +113,23 @@ export default function SpotifyProviderSettings() {
<SettingsContainer <SettingsContainer
title="Spotify" title="Spotify"
description="Allow users to sign in with Spotify." description="Allow users to sign in with Spotify."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-spotify" docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-spotify"
docsTitle="how to sign in users with Spotify" docsTitle="how to sign in users with Spotify"
icon="/assets/brands/spotify.svg" icon="/assets/brands/spotify.svg"
switchId="authEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authEnabled}
className={twMerge( className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2', 'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden', !authEnabled && 'hidden',
)} )}
> >
<BaseProviderSettings /> <BaseProviderSettings providerName="spotify" />
<Input <Input
name="redirectUrl" name="redirectUrl"
id="redirectUrl" id="redirectUrl"
@@ -136,7 +161,7 @@ export default function SpotifyProviderSettings() {
); );
}} }}
> >
<CopyIcon className="w-4 h-4" /> <CopyIcon className="h-4 w-4" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
} }

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings'; import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings'; import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,7 +18,9 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useTheme } from '@mui/material'; import { useTheme } from '@mui/material';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@@ -22,30 +28,35 @@ import { twMerge } from 'tailwind-merge';
export default function TwitchProviderSettings() { export default function TwitchProviderSettings() {
const theme = useTheme(); const theme = useTheme();
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.twitch || {};
const form = useForm<BaseProviderSettingsFormValues>({ const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authClientId: data?.app?.authTwitchClientId, clientId: clientId || '',
authClientSecret: data?.app?.authTwitchClientSecret, clientSecret: clientSecret || '',
authEnabled: data?.app?.authTwitchEnabled, enabled: enabled || false,
}, },
resolver: yupResolver(baseProviderValidationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading Twitch Settings..." label="Loading settings for Twitch..."
className="justify-center" className="justify-center"
/> />
); );
@@ -56,33 +67,46 @@ export default function TwitchProviderSettings() {
} }
const { formState, watch } = form; const { formState, watch } = form;
const authEnabled = watch('authEnabled'); const authEnabled = watch('enabled');
const handleProviderUpdate = async ( const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues, values: BaseProviderSettingsFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
authTwitchClientId: values.authClientId, auth: {
authTwitchClientSecret: values.authClientSecret, method: {
authTwitchEnabled: values.authEnabled, oauth: {
twitch: {
...values,
scope: [],
},
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Twitch settings are being updated...`, {
success: `Twitch settings have been updated successfully.`, loading: `Twitch settings are being updated...`,
error: `An error occurred while trying to update the project's Twitch settings.`, success: `Twitch settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's Twitch settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -91,9 +115,11 @@ export default function TwitchProviderSettings() {
<SettingsContainer <SettingsContainer
title="Twitch" title="Twitch"
description="Allow users to sign in with Twitch." description="Allow users to sign in with Twitch."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-twitch" docsLink="https://docs.nhost.io/platform/authentication/sign-in-with-twitch"
docsTitle="how to sign in users with Twitch" docsTitle="how to sign in users with Twitch"
@@ -102,15 +128,14 @@ export default function TwitchProviderSettings() {
? '/assets/brands/light/twitch.svg' ? '/assets/brands/light/twitch.svg'
: '/assets/brands/twitch.svg' : '/assets/brands/twitch.svg'
} }
switchId="authEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authEnabled}
className={twMerge( className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2', 'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden', !authEnabled && 'hidden',
)} )}
> >
<BaseProviderSettings /> <BaseProviderSettings providerName="twitch" />
<Input <Input
name="redirectUrl" name="redirectUrl"
id="redirectUrl" id="redirectUrl"
@@ -142,7 +167,7 @@ export default function TwitchProviderSettings() {
); );
}} }}
> >
<CopyIcon className="w-4 h-4" /> <CopyIcon className="h-4 w-4" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
} }

View File

@@ -1,8 +1,10 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -12,42 +14,58 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface TwitterProviderFormValues { const validationSchema = Yup.object({
authTwitterConsumerSecret: string; consumerSecret: Yup.string().label('Consumer Secret').when('enabled', {
authTwitterConsumerKey: string; is: true,
authTwitterEnabled: boolean; then: Yup.string().required(),
} }),
consumerKey: Yup.string().label('Consumer Key').when('enabled', {
is: true,
then: Yup.string().required(),
}),
enabled: Yup.boolean(),
});
export type TwitterProviderFormValues = Yup.InferType<typeof validationSchema>;
export default function TwitterProviderSettings() { export default function TwitterProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication?.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { consumerKey, consumerSecret, enabled } =
data?.config?.auth?.method?.oauth?.twitter || {};
const form = useForm<TwitterProviderFormValues>({ const form = useForm<TwitterProviderFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authTwitterConsumerSecret: data?.app?.authTwitterConsumerSecret, consumerSecret: consumerSecret || '',
authTwitterConsumerKey: data?.app?.authTwitterConsumerKey, consumerKey: consumerKey || '',
authTwitterEnabled: data?.app?.authTwitterEnabled, enabled: enabled || false,
}, },
resolver: yupResolver(validationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading Twitter settings..." label="Loading settings for Twitter..."
className="justify-center" className="justify-center"
/> />
); );
@@ -58,29 +76,41 @@ export default function TwitterProviderSettings() {
} }
const { register, formState, watch } = form; const { register, formState, watch } = form;
const authEnabled = watch('authTwitterEnabled'); const authEnabled = watch('enabled');
const handleProviderUpdate = async (values: TwitterProviderFormValues) => { const handleProviderUpdate = async (values: TwitterProviderFormValues) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
...values, auth: {
method: {
oauth: {
twitter: values,
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Twitter settings are being updated...`, {
success: `Twitter settings have been updated successfully.`, loading: `Twitter settings are being updated...`,
error: `An error occurred while trying to update the project's Twitter settings.`, success: `Twitter settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's Twitter settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -89,39 +119,44 @@ export default function TwitterProviderSettings() {
<SettingsContainer <SettingsContainer
title="Twitter" title="Twitter"
description="Allow users to sign in with Twitter." description="Allow users to sign in with Twitter."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsTitle="how to sign in users with Twitter" docsTitle="how to sign in users with Twitter"
icon="/assets/brands/twitter.svg" icon="/assets/brands/twitter.svg"
switchId="authTwitterEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authEnabled}
className={twMerge( className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2', 'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden', !authEnabled && 'hidden',
)} )}
> >
<Input <Input
{...register(`authTwitterConsumerKey`)} {...register(`consumerKey`)}
name="authTwitterConsumerKey" name="consumerKey"
id="authTwitterConsumerKey" id="consumerKey"
label="Twitter Consumer Key" label="Twitter Consumer Key"
placeholder="Twitter Consumer Key" placeholder="Twitter Consumer Key"
className="col-span-1" className="col-span-1"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
error={!!formState.errors?.consumerKey}
helperText={formState.errors?.consumerKey?.message}
/> />
<Input <Input
{...register('authTwitterConsumerSecret')} {...register('consumerSecret')}
name="authTwitterConsumerSecret" name="consumerSecret"
id="authTwitterConsumerSecret" id="consumerSecret"
label="Twitter Consumer Secret" label="Twitter Consumer Secret"
placeholder="Twitter Consumer Secret" placeholder="Twitter Consumer Secret"
className="col-span-1" className="col-span-1"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
error={!!formState.errors?.consumerSecret}
helperText={formState.errors?.consumerSecret?.message}
/> />
<Input <Input
name="redirectUrl" name="redirectUrl"
@@ -154,7 +189,7 @@ export default function TwitterProviderSettings() {
); );
}} }}
> >
<CopyIcon className="w-4 h-4" /> <CopyIcon className="h-4 w-4" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
} }

View File

@@ -1,39 +1,46 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface WebAuthnFormValues { const validationSchema = Yup.object({
/** enabled: Yup.boolean(),
* When enabled, passwordless Webauthn authentication can be done });
* via device supported strong authenticators like fingerprint, Face ID, etc.
*/ export type WebAuthnFormValues = Yup.InferType<typeof validationSchema>;
authWebAuthnEnabled: boolean;
}
export default function WebAuthnSettings() { export default function WebAuthnSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { enabled } = data?.config?.auth?.method?.webauthn || {};
const form = useForm<WebAuthnFormValues>({ const form = useForm<WebAuthnFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authWebAuthnEnabled: data.app.authWebAuthnEnabled, enabled,
}, },
resolver: yupResolver(validationSchema),
}); });
if (loading) { if (loading) {
@@ -50,30 +57,39 @@ export default function WebAuthnSettings() {
throw error; throw error;
} }
const { formState, watch } = form; const { formState } = form;
const authWebAuthnEnabled = watch('authWebAuthnEnabled');
const handleWebAuthnSettingsUpdate = async (values: WebAuthnFormValues) => { const handleWebAuthnSettingsUpdate = async (values: WebAuthnFormValues) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
authWebAuthnEnabled: values.authWebAuthnEnabled, auth: {
method: {
webauthn: values,
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `WebAuthn settings are being updated...`, {
success: `WebAuthn settings have been updated successfully.`, loading: `WebAuthn settings are being updated...`,
error: `An error occurred while trying to update the project's WebAuthn settings.`, success: `WebAuthn settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's WebAuthn settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -82,14 +98,15 @@ export default function WebAuthnSettings() {
<SettingsContainer <SettingsContainer
title="Security Keys" title="Security Keys"
description="Allow users to sign in with security keys using WebAuthn." description="Allow users to sign in with security keys using WebAuthn."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/authentication/sign-in-with-security-keys" docsLink="https://docs.nhost.io/authentication/sign-in-with-security-keys"
docsTitle="how to sign in users with security keys" docsTitle="how to sign in users with security keys"
enabled={authWebAuthnEnabled} switchId="enabled"
switchId="authWebAuthnEnabled"
showSwitch showSwitch
className="hidden" className="hidden"
/> />

View File

@@ -1,10 +1,14 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings'; import type { BaseProviderSettingsFormValues } from '@/components/settings/signInMethods/BaseProviderSettings';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings'; import BaseProviderSettings, {
baseProviderValidationSchema,
} from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -14,36 +18,43 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export default function WindowsLiveProviderSettings() { export default function WindowsLiveProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { clientId, clientSecret, enabled } =
data?.config?.auth?.method?.oauth?.windowslive || {};
const form = useForm<BaseProviderSettingsFormValues>({ const form = useForm<BaseProviderSettingsFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authClientId: data?.app?.authWindowsLiveClientId, clientId: clientId || '',
authClientSecret: data?.app?.authWindowsLiveClientSecret, clientSecret: clientSecret || '',
authEnabled: data?.app?.authWindowsLiveEnabled, enabled: enabled || false,
}, },
resolver: yupResolver(baseProviderValidationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading Windows Live Settings..." label="Loading settings for Windows Live..."
className="justify-center" className="justify-center"
/> />
); );
@@ -54,33 +65,46 @@ export default function WindowsLiveProviderSettings() {
} }
const { formState, watch } = form; const { formState, watch } = form;
const authEnabled = watch('authEnabled'); const authEnabled = watch('enabled');
const handleProviderUpdate = async ( const handleProviderUpdate = async (
values: BaseProviderSettingsFormValues, values: BaseProviderSettingsFormValues,
) => { ) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
authWindowsLiveClientId: values.authClientId, auth: {
authWindowsLiveClientSecret: values.authClientSecret, method: {
authWindowsLiveEnabled: values.authEnabled, oauth: {
windowslive: {
...values,
scope: [],
},
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `Windows Live settings are being updated...`, {
success: `Windows Live settings have been updated successfully.`, loading: `Windows Live settings are being updated...`,
error: `An error occurred while trying to update the project's Windows Live settings.`, success: `Windows Live settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's Windows Live settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -89,21 +113,22 @@ export default function WindowsLiveProviderSettings() {
<SettingsContainer <SettingsContainer
title="Windows Live" title="Windows Live"
description="Allow users to sign in with Windows Live." description="Allow users to sign in with Windows Live."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsTitle="how to sign in users with Windows Live" docsTitle="how to sign in users with Windows Live"
icon="/assets/brands/windowslive.svg" icon="/assets/brands/windowslive.svg"
switchId="authEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authEnabled}
className={twMerge( className={twMerge(
'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2', 'grid-flow-rows grid grid-cols-2 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden', !authEnabled && 'hidden',
)} )}
> >
<BaseProviderSettings /> <BaseProviderSettings providerName="windowslive" />
<Input <Input
name="redirectUrl" name="redirectUrl"
id="redirectUrl" id="redirectUrl"
@@ -135,7 +160,7 @@ export default function WindowsLiveProviderSettings() {
); );
}} }}
> >
<CopyIcon className="w-4 h-4" /> <CopyIcon className="h-4 w-4" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
} }

View File

@@ -1,8 +1,11 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import { import {
useSignInMethodsQuery, GetSignInMethodsDocument,
useUpdateAppMutation, useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
@@ -12,48 +15,68 @@ import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
export interface WorkOsProviderFormValues { const validationSchema = Yup.object({
authWorkOsEnabled: boolean; clientId: Yup.string().label('Client ID').when('enabled', {
authWorkOsClientId: string; is: true,
authWorkOsClientSecret: string; then: Yup.string().required(),
authWorkOsDefaultDomain: string; }),
authWorkOsDefaultOrganization: string; clientSecret: Yup.string().label('Client Secret').when('enabled', {
authWorkOsDefaultConnection: string; is: true,
} then: Yup.string().required(),
}),
organization: Yup.string().label('Organization').when('enabled', {
is: true,
then: Yup.string().required(),
}),
connection: Yup.string().label('Connection').when('enabled', {
is: true,
then: Yup.string().required(),
}),
enabled: Yup.boolean(),
});
export type WorkOsProviderFormValues = Yup.InferType<typeof validationSchema>;
export default function WorkOsProviderSettings() { export default function WorkOsProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useSignInMethodsQuery({ const { data, loading, error } = useGetSignInMethodsQuery({
variables: { variables: { appId: currentApplication?.id },
id: currentApplication.id,
},
fetchPolicy: 'cache-only', fetchPolicy: 'cache-only',
}); });
const { clientId, clientSecret, organization, connection, enabled } =
data?.config?.auth?.method?.oauth?.workos || {};
const form = useForm<WorkOsProviderFormValues>({ const form = useForm<WorkOsProviderFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
authWorkOsClientId: data?.app?.authWorkOsClientId, clientId: clientId || '',
authWorkOsClientSecret: data?.app?.authWorkOsClientSecret, clientSecret: clientSecret || '',
authWorkOsDefaultDomain: data?.app?.authWorkOsDefaultDomain, organization: organization || '',
authWorkOsDefaultOrganization: data?.app?.authWorkOsDefaultOrganization, connection: connection || '',
authWorkOsDefaultConnection: data?.app?.authWorkOsDefaultConnection, enabled: enabled || false,
authWorkOsEnabled: data?.app?.authWorkOsEnabled,
}, },
resolver: yupResolver(validationSchema),
}); });
if (loading) { if (loading) {
return ( return (
<ActivityIndicator <ActivityIndicator
delay={1000} delay={1000}
label="Loading WorkOS settings..." label="Loading settings for WorkOS..."
className="justify-center" className="justify-center"
/> />
); );
@@ -64,29 +87,41 @@ export default function WorkOsProviderSettings() {
} }
const { register, formState, watch } = form; const { register, formState, watch } = form;
const authEnabled = watch('authWorkOsEnabled'); const authEnabled = watch('enabled');
const handleProviderUpdate = async (values: WorkOsProviderFormValues) => { const handleProviderUpdate = async (values: WorkOsProviderFormValues) => {
const updateAppMutation = updateApp({ const updateConfigPromise = updateConfig({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { config: {
...values, auth: {
method: {
oauth: {
workos: values,
},
},
},
}, },
}, },
}); });
await toast.promise( try {
updateAppMutation, await toast.promise(
{ updateConfigPromise,
loading: `WorkOS settings are being updated...`, {
success: `WorkOS settings have been updated successfully.`, loading: `WorkOS settings are being updated...`,
error: `An error occurred while trying to update the project's WorkOS settings.`, success: `WorkOS settings have been updated successfully.`,
}, error: getServerError(
getToastStyleProps(), `An error occurred while trying to update the project's WorkOS settings.`,
); ),
},
getToastStyleProps(),
);
form.reset(values); form.reset(values);
} catch {
// Note: The toast will handle the error.
}
}; };
return ( return (
@@ -95,70 +130,46 @@ export default function WorkOsProviderSettings() {
<SettingsContainer <SettingsContainer
title="WorkOS" title="WorkOS"
description="Allow users to sign in with WorkOS." description="Allow users to sign in with WorkOS."
primaryActionButtonProps={{ slotProps={{
disabled: !formState.isValid || !formState.isDirty, submitButton: {
loading: formState.isSubmitting, disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}} }}
docsLink="https://docs.nhost.io/authentication/sign-in-with-workos" docsLink="https://docs.nhost.io/authentication/sign-in-with-workos"
docsTitle="how to sign in users with WorkOS" docsTitle="how to sign in users with WorkOS"
icon="/assets/brands/workos.svg" icon="/assets/brands/workos.svg"
switchId="authWorkOsEnabled" switchId="enabled"
showSwitch showSwitch
enabled={authEnabled}
className={twMerge( className={twMerge(
'grid-flow-rows grid grid-cols-6 grid-rows-2 gap-y-4 gap-x-3 px-4 py-2', 'grid grid-flow-row grid-cols-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden', !authEnabled && 'hidden',
)} )}
> >
<BaseProviderSettings providerName="workos" />
<Input <Input
{...register(`authWorkOsClientId`)} {...register('organization')}
name="authWorkOsClientId" name="organization"
id="authWorkOsClientId" id="organization"
label="Client ID"
placeholder="Enter your Client ID"
className="col-span-3"
fullWidth
hideEmptyHelperText
/>
<Input
{...register('authWorkOsClientSecret')}
name="authWorkOsClientSecret"
id="authWorkOsClientSecret"
label="Client Secret"
placeholder="Enter your Client Secret"
className="col-span-3"
fullWidth
hideEmptyHelperText
/>
<Input
{...register('authWorkOsDefaultOrganization')}
name="authWorkOsDefaultOrganization"
id="authWorkOsDefaultOrganization"
label="Default Organization ID (optional)" label="Default Organization ID (optional)"
placeholder="Default Organization ID" placeholder="Default Organization ID"
className="col-span-2" className="col-span-1"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
error={!!formState.errors?.organization}
helperText={formState.errors?.organization?.message}
/> />
<Input <Input
{...register('authWorkOsDefaultDomain')} {...register('connection')}
name="authWorkOsDefaultDomain" name="connection"
id="authWorkOsDefaultDomain" id="connection"
label="Default Domain (optional)"
placeholder="Default Domain"
className="col-span-2"
fullWidth
hideEmptyHelperText
/>
<Input
{...register('authWorkOsDefaultConnection')}
name="authWorkOsDefaultConnection"
id="authWorkOsDefaultConnection"
label="Default Connection (optional)" label="Default Connection (optional)"
placeholder="Default Connection" placeholder="Default Connection"
className="col-span-2" className="col-span-1"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
error={!!formState.errors?.connection}
helperText={formState.errors?.connection?.message}
/> />
<Input <Input
name="redirectUrl" name="redirectUrl"
@@ -168,7 +179,7 @@ export default function WorkOsProviderSettings() {
currentApplication.region.awsName, currentApplication.region.awsName,
'auth', 'auth',
)}/signin/provider/workos/callback`} )}/signin/provider/workos/callback`}
className="col-span-6" className="col-span-2"
fullWidth fullWidth
hideEmptyHelperText hideEmptyHelperText
label="Redirect URL" label="Redirect URL"
@@ -191,7 +202,7 @@ export default function WorkOsProviderSettings() {
); );
}} }}
> >
<CopyIcon className="w-4 h-4" /> <CopyIcon className="h-4 w-4" />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
} }

View File

@@ -32,7 +32,7 @@ export function Alert({
}, },
severity === 'warning' && { severity === 'warning' && {
backgroundColor: 'warning.light', backgroundColor: 'warning.light',
color: 'warning.main', color: 'warning.dark',
}, },
severity === 'success' && { severity === 'success' && {
backgroundColor: 'success.light', backgroundColor: 'success.light',

View File

@@ -59,7 +59,8 @@ export function Avatar({
<Box <Box
style={Object.assign(style, { backgroundImage: `url(${avatarUrl})` })} style={Object.assign(style, { backgroundImage: `url(${avatarUrl})` })}
className={classes} className={classes}
aria-label="Avatar" aria-label={name ? `Avatar of ${name}` : 'Avatar'}
role="img"
{...rest} {...rest}
/> />
); );

View File

@@ -21,14 +21,17 @@ export default function createTheme(mode: PaletteMode) {
}, },
h2: { h2: {
fontSize: '1.625rem', fontSize: '1.625rem',
lineHeight: '2.375rem',
fontWeight: 500, fontWeight: 500,
}, },
h3: { h3: {
fontSize: '1.125rem', fontSize: '1.125rem',
lineHeight: '1.5rem',
fontWeight: 500, fontWeight: 500,
}, },
h4: { h4: {
fontSize: '1rem', fontSize: '1rem',
lineHeight: '1.375rem',
fontWeight: 500, fontWeight: 500,
}, },
subtitle1: { subtitle1: {

View File

@@ -6,9 +6,9 @@ import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl'; import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import fetch from 'cross-fetch';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
@@ -100,10 +100,9 @@ export default function CreateUserForm({
{ {
loading: 'Creating user...', loading: 'Creating user...',
success: 'User created successfully.', success: 'User created successfully.',
error: (arg) => error: getServerError(
arg?.message 'An error occurred while trying to create the user.',
? `Error: ${arg.message}` ),
: 'An error occurred while trying to create the user.',
}, },
getToastStyleProps(), getToastStyleProps(),
); );

View File

@@ -23,7 +23,7 @@ import getUserRoles from '@/utils/settings/getUserRoles';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
RemoteAppGetUsersDocument, RemoteAppGetUsersDocument,
useGetRolesQuery, useGetRolesPermissionsQuery,
useUpdateRemoteAppUserMutation, useUpdateRemoteAppUserMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
@@ -137,12 +137,12 @@ export default function EditUserForm({
}); });
} }
const { data: dataRoles } = useGetRolesQuery({ const { data: dataRoles } = useGetRolesPermissionsQuery({
variables: { id: currentApplication?.id }, variables: { appId: currentApplication?.id },
}); });
const allAvailableProjectRoles = getUserRoles( const allAvailableProjectRoles = getUserRoles(
dataRoles?.app?.authUserDefaultAllowedRoles, dataRoles?.config?.auth?.user?.roles?.allowed,
); );
/** /**

View File

@@ -5,6 +5,7 @@ import type { DialogFormProps } from '@/types/common';
import { Alert } from '@/ui/Alert'; import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql'; import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
import { useUpdateRemoteAppUserMutation } from '@/utils/__generated__/graphql'; import { useUpdateRemoteAppUserMutation } from '@/utils/__generated__/graphql';
@@ -78,7 +79,7 @@ export default function EditUserPasswordForm({
{ {
loading: 'Updating user password...', loading: 'Updating user password...',
success: 'User password updated successfully.', success: 'User password updated successfully.',
error: 'Failed to update user password.', error: getServerError('Failed to update user password.'),
}, },
getToastStyleProps(), getToastStyleProps(),
); );

View File

@@ -16,11 +16,12 @@ import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem'; import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import getReadableProviderName from '@/utils/common/getReadableProviderName'; import getReadableProviderName from '@/utils/common/getReadableProviderName';
import getServerError from '@/utils/settings/getServerError';
import getUserRoles from '@/utils/settings/getUserRoles'; import getUserRoles from '@/utils/settings/getUserRoles';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
useDeleteRemoteAppUserRolesMutation, useDeleteRemoteAppUserRolesMutation,
useGetRolesQuery, useGetRolesPermissionsQuery,
useInsertRemoteAppUserRolesMutation, useInsertRemoteAppUserRolesMutation,
useRemoteAppDeleteUserMutation, useRemoteAppDeleteUserMutation,
useUpdateRemoteAppUserMutation, useUpdateRemoteAppUserMutation,
@@ -81,13 +82,15 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
* going to use once the user selects a user of their application; we use it * going to use once the user selects a user of their application; we use it
* in the drawer form. * in the drawer form.
*/ */
const { data: dataRoles } = useGetRolesQuery({ const { data: dataRoles } = useGetRolesPermissionsQuery({
variables: { id: currentApplication?.id }, variables: { appId: currentApplication?.id },
}); });
const { allowed: allowedRoles } = dataRoles?.config?.auth?.user?.roles || {};
const allAvailableProjectRoles = useMemo( const allAvailableProjectRoles = useMemo(
() => getUserRoles(dataRoles?.app?.authUserDefaultAllowedRoles), () => getUserRoles(allowedRoles),
[dataRoles], [allowedRoles],
); );
async function handleEditUser( async function handleEditUser(
@@ -149,7 +152,9 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
{ {
loading: `Updating user's settings...`, loading: `Updating user's settings...`,
success: 'User settings updated successfully.', success: 'User settings updated successfully.',
error: `An error occurred while trying to update this user's settings.`, error: getServerError(
`An error occurred while trying to update this user's settings.`,
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
@@ -179,7 +184,9 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
{ {
loading: 'Deleting user...', loading: 'Deleting user...',
success: 'User deleted successfully.', success: 'User deleted successfully.',
error: 'An error occurred while trying to delete this user.', error: getServerError(
'An error occurred while trying to delete this user.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );

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