Compare commits

..

299 Commits

Author SHA1 Message Date
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
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ó
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ó
decb0b057c Merge pull request #1677 from nhost/changeset-release/main
chore: update versions
2023-02-28 11:39:26 +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
github-actions[bot]
fc79b890df chore: update versions 2023-02-28 10:06:25 +00:00
Szilárd Dóró
211eb42af5 Merge pull request #1622 from nhost/chore/improved-dialogs
chore(dashboard): improve Dialog and Drawer API
2023-02-28 11:05:03 +01:00
Szilárd Dóró
a7398451e3 fix(dashboard): add dirty state checking to user form 2023-02-28 10:47:42 +01:00
Szilárd Dóró
4b4f0d0150 chore(dashboard): add changeset 2023-02-28 10:31:47 +01:00
Szilárd Dóró
f37e2a23e2 Merge remote-tracking branch 'origin/main' into chore/improved-dialogs 2023-02-28 10:31:10 +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ó
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
Johan Eliasson
1a4a061284 Merge pull request #1674 from nhost/changeset-release/main
chore: update versions
2023-02-27 11:55:28 +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
github-actions[bot]
78555c7e85 chore: update versions 2023-02-27 08:22:19 +00:00
Johan Eliasson
01ded8ffff Merge pull request #1670 from nhost/functions-tests
Functions fix + tests
2023-02-27 09:21:05 +01:00
Johan Eliasson
3c7cf92edf Create .changeset/eighty-mugs-flash.md 2023-02-27 09:20:49 +01:00
Johan Eliasson
bb4301fd34 more tests 2023-02-26 17:49:19 +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ó
c8c8948755 Merge pull request #1667 from nhost/changeset-release/main
chore: update versions
2023-02-24 12:45:06 +01:00
github-actions[bot]
17e9e5899e chore: update versions 2023-02-24 11:44:31 +00:00
Szilárd Dóró
bd22c48131 Merge pull request #1666 from nhost/fix/workspace-invitation
fix(nhost-js): use correct URL for functions requests
2023-02-24 12:43:02 +01:00
Szilárd Dóró
095d6e918c Merge branch 'main' into feat/settings-from-mimir 2023-02-24 12:41:56 +01:00
Szilárd Dóró
89a239ff3a fix(nhost-js): improve code readability 2023-02-24 12:22:54 +01:00
Szilárd Dóró
0131886011 fix(nhost-js): use correct URL for functions requests 2023-02-24 12:06:30 +01:00
Szilárd Dóró
340c014fe8 Merge pull request #1664 from nhost/update-codeowners
chore: update codeowners
2023-02-24 11:06:07 +01:00
Szilárd Dóró
bc9c8b9456 chore: update codeowners 2023-02-24 11:05:35 +01:00
Nuno Pato
c22b2621ba Merge pull request #1661 from nhost/changeset-release/main
chore: update versions
2023-02-23 20:33:34 -01:00
github-actions[bot]
726746c4d3 chore: update versions 2023-02-23 19:19:03 +00:00
Nuno Pato
c431570783 Merge pull request #1662 from nhost/fix/file-upload
fix(hasura-storage-js): fix forbidden error
2023-02-23 18:17:47 -01:00
Szilárd Dóró
445d8ef449 fix(hasura-storage-js): fix forbidden error 2023-02-23 18:16:22 +01:00
Szilárd Dóró
0f4ea18e42 Merge pull request #1655 from nhost/feat/auth-storage-permissions
fix(dashboard): allow permission editing for auth and storage schemas
2023-02-23 15:22:05 +01:00
Szilárd Dóró
dae7c5d517 Merge pull request #1660 from nhost/fix/user-creation-content-type
fix(dashboard): set correct Content-Type for user creation
2023-02-23 14:38:16 +01:00
Szilárd Dóró
f673adea00 fix(dashboard): set correct Content-Type for user creation 2023-02-23 12:50:00 +01:00
Szilárd Dóró
1c6f1e3b33 Merge pull request #1656 from nhost/changeset-release/main
chore: update versions
2023-02-23 11:56:07 +01:00
renovate[bot]
6593e8d3eb chore(deps): update graphqlcodegenerator monorepo to v3 2023-02-23 10:28:10 +00:00
github-actions[bot]
d1365ea516 chore: update versions 2023-02-23 10:20:53 +00:00
Szilárd Dóró
72dbba7881 Merge pull request #1659 from nhost/chore/revert-graphql-client
chore(graphql-js): revert GraphQL Client
2023-02-23 11:19:26 +01:00
Szilárd Dóró
a3f3991d5a Merge pull request #1658 from nhost/fix/user-creation
fix(dashboard): false positive message on user creation
2023-02-23 11:01:44 +01:00
Szilárd Dóró
c71fe2cf72 Revert "chore(graphql-js): revert GraphQL client for now"
This reverts commit 9a0ab5b887.
2023-02-23 10:15:02 +01:00
Szilárd Dóró
24c5ed3ea4 chore(graphql-js): cleanup 2023-02-23 10:14:43 +01:00
Szilárd Dóró
2d9145f918 chore(graphql-js): revert GraphQL client for now 2023-02-23 10:10:14 +01:00
Szilárd Dóró
9a0ab5b887 chore(graphql-js): revert GraphQL client for now 2023-02-23 10:06:58 +01:00
Szilárd Dóró
1ddf704c5b fix(dashboard): false positive message on user creation 2023-02-23 09:39:32 +01:00
Szilárd Dóró
6f4ee845c6 Merge pull request #1643 from nhost/fix/auth-last-seen
fix(dashboard): use correct date for last seen
2023-02-22 20:01:18 +01:00
Szilárd Dóró
0368663dea fix(dashboard): allow permission editing for auth and storage schemas
fixes #1555
2023-02-22 19:59:20 +01:00
Pilou
76ce7d7b6e Merge pull request #1653 from nhost/changeset-release/main
chore: update versions
2023-02-22 15:43:41 +01:00
github-actions[bot]
538bfbcb3e chore: update versions 2023-02-22 14:23:55 +00:00
Pilou
07b35d1e5f Merge pull request #1650 from nhost/docs/graphql-client-readme
GraphQL client: fix snake_case bug and improve readme
2023-02-22 15:22:34 +01:00
Pierre-Louis Mercereau
2200a0ed07 fix: correct types on snake case operations 2023-02-22 15:13:43 +01: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ó
df23d97126 Merge pull request #1652 from nhost/changeset-release/main
chore: update versions
2023-02-22 14:44:46 +01:00
Szilárd Dóró
43b68a79eb fix(dashboard): improve error handling 2023-02-22 14:43:21 +01:00
github-actions[bot]
104f149369 chore: update versions 2023-02-22 13:26:08 +00:00
Szilárd Dóró
01228583a0 Merge pull request #1651 from nhost/fix/permission-editor-dropdown
fix(dashboard): prevent permission editor dropdown from being always open
2023-02-22 14:24:51 +01:00
Pierre-Louis Mercereau
93309dd851 docs: strictNullChecks 2023-02-22 14:06:10 +01:00
Szilárd Dóró
2cc18dcb51 fix(dashboard): prevent permission editor dropdown from being always open 2023-02-22 13:54:31 +01:00
Pierre-Louis Mercereau
3b48a62790 docs: ✏️ improve readme instructions 2023-02-22 13:44:15 +01:00
Pierre-Louis Mercereau
8897dec056 docs: add package.json script instructions 2023-02-22 12:23:54 +01:00
Pierre-Louis Mercereau
324dda8309 docs: streamline readme instructions 2023-02-22 11:58:51 +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
Pilou
95f62bed07 Merge pull request #1644 from nhost/changeset-release/main
chore: update versions
2023-02-22 08:50:38 +01:00
Szilárd Dóró
f4af81020b Merge branch 'main' into feat/settings-from-mimir 2023-02-21 18:51:11 +01:00
Pierre-Louis Mercereau
0e4d8ff118 chore: manually bump @nhost/react-apollo 2023-02-21 18:20:41 +01:00
renovate[bot]
6999562b59 chore(deps): update dependency typescript to v4.9.5 2023-02-21 17:03:56 +00:00
github-actions[bot]
baec5bada7 chore: update versions 2023-02-21 17:03:03 +00:00
Pilou
4e56cfc628 Merge pull request #1348 from nhost/sdk-next-major
Sdk next major version
2023-02-21 18:01:13 +01: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
Pierre-Louis Mercereau
54bc91923f docs: update changeset 2023-02-21 12:40:52 +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
Szilárd Dóró
77b12feb95 Merge pull request #1639 from nhost/renovate/testing-library-react-14.x
chore(deps): update dependency @testing-library/react to v14
2023-02-20 16:30:11 +01:00
Pierre-Louis Mercereau
32d4670bbb Merge branch 'main' into sdk-next-major 2023-02-20 16:05:23 +01:00
Pilou
1dc09942d2 Merge pull request #1587 from nhost/next-client
Next GraphQL client
2023-02-20 16:01:49 +01:00
Szilárd Dóró
3343a36358 chore(deps): bump @testing-library 2023-02-20 15:52:18 +01:00
Szilárd Dóró
b755e9086c fix(dashboard): use correct date for last seen 2023-02-20 14:19:56 +01:00
Pierre-Louis Mercereau
48866d0ee1 chore: fail on runtime when passing an invalid field 2023-02-20 12:45:52 +01:00
renovate[bot]
5b3b76bd41 chore(deps): update dependency @testing-library/react to v14 2023-02-20 10:08:36 +00:00
Szilárd Dóró
7f7e7ea7d4 Merge pull request #1638 from nhost/changeset-release/main
chore: update versions
2023-02-20 11:05:47 +01:00
github-actions[bot]
aaaf2dc9c5 chore: update versions 2023-02-20 10:02:33 +00:00
Szilárd Dóró
fa9c1ea28c Merge pull request #1626 from nhost/renovate/react-18.x
chore(deps): update dependency @types/react to v18.0.28
2023-02-20 11:01:06 +01:00
Szilárd Dóró
87eda76e2b chore(dashboard): bump @types/react-dom to v18.0.11 2023-02-20 10:17:41 +01:00
Szilárd Dóró
8a596f2a9e Merge pull request #1621 from nhost/feat/show-version
feat(dashboard): show dashboard version
2023-02-20 09:13:40 +01:00
renovate[bot]
d6d2381598 chore(deps): update dependency @types/react to v18.0.28 2023-02-17 11:24:40 +00:00
Pierre-Louis Mercereau
284ef7e7f2 test: multiple where 2023-02-17 08:57:02 +01:00
Pierre-Louis Mercereau
6d5c202da9 changes 2023-02-17 08:44:48 +01:00
Szilárd Dóró
962563d6a0 chore(dashboard): cleanup 2023-02-16 16:51:40 +01:00
Szilárd Dóró
8bf58ba26b chore(dashboard): migrate remaining dialogs 2023-02-16 16:37:44 +01:00
Szilárd Dóró
0c175e7a11 chore(dashboard): migrate additional dialogs to the new API 2023-02-16 16:13:47 +01:00
Szilárd Dóró
70f2fbcfc2 chore(dashboard): partially migrate dialogs to new API 2023-02-16 15:59:35 +01:00
Szilárd Dóró
d2c4ad3260 chore(dashboard): cleanup dialog provider 2023-02-16 13:32:21 +01:00
Szilárd Dóró
a9ca2c2946 chore(dashboard): migrate drawers to use new API 2023-02-16 13:25:23 +01:00
Szilárd Dóró
d854dd74b1 chore(dashboard): improve dialog management 2023-02-16 12:54:21 +01:00
Szilárd Dóró
6f0ac5706c feat(dashboard): show dashboard version 2023-02-15 19:19:15 +01:00
Pierre-Louis Mercereau
9342937440 readme 2023-02-15 13:39:24 +01:00
Pierre-Louis Mercereau
e89cd4e262 simple mutation example 2023-02-15 13:30:16 +01:00
Pierre-Louis Mercereau
a05438352b inline documentation 2023-02-15 13:28:04 +01:00
Pierre-Louis Mercereau
78437959bb dew 2023-02-15 13:08:20 +01:00
Pierre-Louis Mercereau
e1a7433adb docs: static doc for nhost.functions 2023-02-15 12:41:54 +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
Pierre-Louis Mercereau
e23cf74975 fix tests 2023-02-10 22:11:53 +01:00
Pierre-Louis Mercereau
a3d01c4fad fix lint config 2023-02-10 21:33:26 +01:00
Pierre-Louis Mercereau
4cdcef9ef5 fix broken links 2023-02-10 19:12:29 +01:00
Pierre-Louis Mercereau
df894ef7e2 upgrade typescript version 2023-02-10 15:51:59 +01:00
Pierre-Louis Mercereau
f7dd6a9fc6 deactivate functions doc 2023-02-10 14:42:56 +01:00
Pierre-Louis Mercereau
2949ff0f62 fix docgen 2023-02-10 14:33:56 +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
Pierre-Louis Mercereau
1527b0a455 remove type-fest dependency 2023-02-06 14:18:13 +01:00
Pierre-Louis Mercereau
375e53a3f0 update ts 2023-02-06 13:35:33 +01:00
Pierre-Louis Mercereau
96e3ca5a32 basic readme 2023-02-06 12:53:11 +01:00
Pierre-Louis Mercereau
0e570df9c5 missing dependency 2023-02-06 12:11:02 +01:00
Pierre-Louis Mercereau
1f4c67283e lockfile 2023-02-06 10:49:47 +01:00
Pierre-Louis Mercereau
fc1c4861a3 cleanup 2023-02-06 10:49:02 +01:00
Pierre-Louis Mercereau
74feaf6add unions 2023-02-02 11:47:29 +01:00
Pierre-Louis Mercereau
8cd97206cc Merge branch 'next-client' of https://github.com/nhost/nhost into next-client 2023-01-31 21:06:17 +01:00
Pierre-Louis Mercereau
02197639f2 dew 2023-01-31 20:59:14 +01:00
Pilou
38b594aef9 Update README.md 2023-01-31 19:40:31 +01:00
Pierre-Louis Mercereau
f3a8886cd0 dew 2023-01-31 19:08:19 +01:00
Szilárd Dóró
96f9278c8f chore(dashboard): ID workaround for config 2023-01-31 18:52:39 +01:00
Pierre-Louis Mercereau
8d76cf8d40 simplify 2023-01-31 17:37:44 +01:00
Pierre-Louis Mercereau
3e1fb974e4 dew 2023-01-31 16:37:40 +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
Pierre-Louis Mercereau
f74871d872 dew 2023-01-31 15:01:54 +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ó
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
Pierre-Louis Mercereau
3f26056688 Merge branch 'main' into sdk-next-major 2023-01-30 11:11:51 +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
Pierre-Louis Mercereau
6a7801be93 chore: update changeset 2023-01-10 09:10:33 +01:00
Pierre-Louis Mercereau
7bc5bb857c chore: add the deprecated useAxios option 2023-01-10 09:02:45 +01:00
Pierre-Louis Mercereau
c957039d75 chore: update lock file 2023-01-09 20:17:12 +01:00
Pierre-Louis Mercereau
96c4032424 Merge branch 'main' into sdk-next-major 2023-01-09 20:13:13 +01:00
Pierre-Louis Mercereau
ec70126b56 fix: iso 8859-1 2023-01-09 19:25:02 +01:00
Pierre-Louis Mercereau
86b9f9040c Merge branch 'main' into sdk-next-major 2023-01-09 16:31:04 +01:00
Pierre-Louis Mercereau
222f03725b fix(dashboard): use fetch instead of axios 2023-01-06 16:42:40 +01:00
Pierre-Louis Mercereau
10b786e5c6 chore: fix lockfile 2023-01-06 16:30:22 +01:00
Pierre-Louis Mercereau
aa8ae88d12 Merge branch 'main' into sdk-next-major 2023-01-06 16:25:12 +01:00
Pilou
0f2c86b41a Merge pull request #1351 from nhost/refactor/cross-fetch
refactor: replace axios by cross-fetch
2023-01-06 16:17:03 +01:00
Pilou
a4c76892dd Update packages/hasura-storage-js/src/hasura-storage-api.ts
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-03 10:36:33 +01:00
Pierre-Louis Mercereau
00d278b2cc refactor: adjustments from review 2023-01-02 17:32:34 +01:00
Pilou
cb6b5faeb9 Update packages/hasura-storage-js/src/hasura-storage-client.ts
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-02 17:29:21 +01:00
Pierre-Louis Mercereau
7c4c847b91 Merge branch 'sdk-next-major' into refactor/cross-fetch 2022-12-26 12:54:40 +01:00
Pierre-Louis Mercereau
908887d8c5 Merge branch 'main' into sdk-next-major 2022-12-26 12:54:19 +01:00
Pierre-Louis Mercereau
a2d67bc2db fix(dashboard): js error from error payload 2022-12-26 12:45:33 +01:00
Pierre-Louis Mercereau
1a6cd78254 Merge branch 'sdk-next-major' into refactor/cross-fetch 2022-12-21 16:42:58 +01:00
Pierre-Louis Mercereau
6500629c4b Merge branch 'main' into sdk-next-major 2022-12-21 16:22:28 +01:00
Pierre-Louis Mercereau
add3c2c10e Merge branch 'sdk-next-major' into refactor/cross-fetch 2022-12-21 14:29:00 +01:00
Pierre-Louis Mercereau
dd29b06260 Merge branch 'main' into sdk-next-major 2022-12-21 14:27:48 +01:00
Pierre-Louis Mercereau
490cb25a0f Merge branch 'sdk-next-major' into refactor/cross-fetch 2022-12-20 10:13:18 +01:00
Pierre-Louis Mercereau
0df0dd741e Merge branch 'main' into sdk-next-major 2022-12-20 10:12:56 +01:00
Pierre-Louis Mercereau
2172946879 Merge branch 'sdk-next-major' into refactor/cross-fetch 2022-12-20 09:17:50 +01:00
Pierre-Louis Mercereau
40e50f0e75 Merge branch 'main' into sdk-next-major 2022-12-20 09:15:30 +01:00
Pierre-Louis Mercereau
65cf0888b5 chore: extract patch changes 2022-12-16 12:14:38 +01:00
Pierre-Louis Mercereau
21833019ca chore: correct error from several mergers 2022-12-14 21:14:48 +01:00
Pierre-Louis Mercereau
b3171ba3e9 chore: remove lockfile 2022-12-14 20:40:16 +01:00
Pierre-Louis Mercereau
6f01f19d02 refactor: remove docgen 2022-12-14 20:33:03 +01:00
Pierre-Louis Mercereau
ce92b01eac Merge branch 'main' into sdk-next-major 2022-12-14 19:25:15 +01:00
Pierre-Louis Mercereau
e24a177434 Merge branch 'main' into sdk-next-major 2022-12-14 19:06:50 +01:00
Pierre-Louis Mercereau
56a52b6d48 chore: remove duplicate changesets 2022-12-12 16:45:42 +01:00
Pierre-Louis Mercereau
92bfa8c723 chore: phrasing 2022-12-12 16:03:07 +01:00
Pierre-Louis Mercereau
2a52aaa4a6 Merge branch 'sdk-next-major' into refactor/cross-fetch 2022-12-12 14:46:39 +01:00
Pierre-Louis Mercereau
8280a3e9d8 chore: use explicit changeset links 2022-12-12 14:30:38 +01:00
Pierre-Louis Mercereau
523f60bf68 Merge branch 'sdk-next-major' into refactor/cross-fetch 2022-12-12 14:20:28 +01:00
Pierre-Louis Mercereau
19b11d4084 chore: details on removed deprecations 2022-12-12 14:15:08 +01:00
Pierre-Louis Mercereau
805bae1507 chore: bump nextjs as major as React removed deprecated methods are reexported 2022-12-12 13:36:25 +01:00
Pierre-Louis Mercereau
f6c014c06f chore: correct changeset 2022-12-12 13:34:19 +01:00
Pierre-Louis Mercereau
c5794f4596 chore: improve changesets 2022-12-12 13:32:09 +01:00
Pierre-Louis Mercereau
fc28817380 chore: explain xhr in changeset 2022-12-12 10:06:56 +01:00
Pierre-Louis Mercereau
80bbd3a165 chore: changesets 2022-12-12 10:02:21 +01:00
Pierre-Louis Mercereau
7a10617a72 test: simplify email verification test 2022-12-12 09:26:02 +01:00
Pierre-Louis Mercereau
f0b6dca1a5 Merge branch 'sdk-next-major' into refactor/cross-fetch 2022-12-08 14:28:03 +01:00
Szilárd Dóró
5db20adc34 fix(react): eliminate error when testing the project 2022-12-08 12:01:05 +01:00
Pierre-Louis Mercereau
12dc41a517 test: TypedDocumentNode 2022-12-08 09:07:46 +01:00
Pierre-Louis Mercereau
768fd56891 feat: graphql-typed-document-node 2022-12-07 21:53:21 +01:00
Pierre-Louis Mercereau
8a508cb1cc chore: run CI on evey pull request, not only the ones to main 2022-12-07 20:47:26 +01:00
Pierre-Louis Mercereau
34f6a8eef4 refactor: upload progress with XHR 2022-12-07 19:48:34 +01:00
Pierre-Louis Mercereau
c9d2d31a9b feat: nhost.graphql.httpUrl and nhost.graphql.wsUrl 2022-12-07 18:53:58 +01:00
Pierre-Louis Mercereau
68fb23a361 refactor: remove last pieces of axios 2022-12-07 17:16:42 +01:00
Pierre-Louis Mercereau
476139e528 refactor: remove axios from hasura-storage-js, with breaking change 2022-12-07 13:27:34 +01:00
Pierre-Louis Mercereau
6a850818a0 Merge branch 'sdk-next-major' into refactor/cross-fetch 2022-12-07 11:41:55 +01:00
Pilou
3970dbba0d Merge pull request #1342 from nhost/chore/improve-internal-dependencies
chore: improve internal dependencies
2022-12-07 11:40:02 +01:00
Pierre-Louis Mercereau
8ee2166f0d fix: correct nhost clients 2022-12-07 11:08:54 +01:00
Szilárd Dóró
e13500a185 fix(docgen): correct class references when used as interface members 2022-12-07 10:47:04 +01:00
Pierre-Louis Mercereau
411f574a51 refactor: re-export @nhost/react in @nhost/nextjs 2022-12-07 09:47:45 +01:00
Szilárd Dóró
7fc91b992e chore: cleanup dependencies, fix references 2022-12-07 08:59:06 +01:00
Pierre-Louis Mercereau
b840012be0 fix: correct fetch errors 2022-12-07 08:56:11 +01:00
Pierre-Louis Mercereau
645c51a9dc refactor: 💡 replace axios by cross-fetch, step 1
Changes not done yet in `nhost.auth.graphql` (will soon use
graphql-request instead) and the upload machines (depends on prrogress)
2022-12-06 22:08:46 +01:00
Pierre-Louis Mercereau
0ce6f05539 Merge branch 'sdk-next-major' into chore/improve-internal-dependencies 2022-12-06 14:10:19 +01:00
Pierre-Louis Mercereau
8b1188af53 Merge branch 'main' into sdk-next-major 2022-12-06 14:08:55 +01:00
Pilou
12b01f8dee Merge pull request #1298 from nhost/fix/react-strict-mode
fix: react strict mode
2022-12-06 14:06:07 +01:00
Pierre-Louis Mercereau
60f4faf409 chore: remove @nhost/core from changeset 2022-12-06 13:05:45 +01:00
Pierre-Louis Mercereau
528dff3f1b Merge branch 'sdk-next-major' into fix/react-strict-mode 2022-12-06 12:52:33 +01:00
Pierre-Louis Mercereau
d429fb4a3e Merge branch 'sdk-next-major' into chore/improve-internal-dependencies 2022-12-06 12:41:25 +01:00
Pilou
816c916709 Merge pull request #1233 from nhost/docs/hide-old-deprecations
chore: remove deprecated methods
2022-12-06 12:40:42 +01:00
Pierre-Louis Mercereau
b7a2b8b537 docs: update installation instructions 2022-12-06 10:50:40 +01:00
Pierre-Louis Mercereau
261d8cf434 merge 2022-12-06 10:10:13 +01:00
Pierre-Louis Mercereau
41f49bde76 Merge branch 'sdk-next-major' into chore/improve-internal-dependencies 2022-12-06 10:06:25 +01:00
Pilou
65f685bdb2 Update packages/core/src/client.ts
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2022-12-06 09:57:29 +01:00
Pilou
f52a7f4aac Update packages/core/src/machines/index.ts
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2022-12-06 09:57:01 +01:00
Pierre-Louis Mercereau
e71b9903d9 Merge branch 'sdk-next-major' into docs/hide-old-deprecations 2022-12-06 09:50:40 +01:00
Pilou
325fd08aef Merge pull request #1246 from nhost/refactor/merge-core
refactor: decommission `@nhost/core`
2022-12-06 09:46:50 +01:00
Pierre-Louis Mercereau
3888704464 chore: remove comment 2022-12-06 09:23:31 +01:00
Pierre-Louis Mercereau
38e8a10a29 chore: 🤖 remove lockfile 2022-12-06 09:19:02 +01:00
Pierre-Louis Mercereau
d8545eae12 chore: 🤖 add changeset 2022-12-06 09:18:16 +01:00
Pierre-Louis Mercereau
3d5bfd87d2 refactor: simplify 2022-12-06 08:59:11 +01:00
Pierre-Louis Mercereau
e66c5626bd fix: don't fetch session when present in initial state 2022-12-05 23:14:17 +01:00
Pierre-Louis Mercereau
a227c6561e refactor: work with nextjs 2022-12-05 23:09:26 +01:00
Pierre-Louis Mercereau
e885c159df refactor: simplify onStart 2022-12-05 14:53:10 +01:00
Pierre-Louis Mercereau
09fcb74bef refactor: use Johan's function name 2022-12-05 14:31:03 +01:00
Pierre-Louis Mercereau
a089197197 Merge branch 'main' into fix/react-strict-mode 2022-12-05 14:29:27 +01:00
Pierre-Louis Mercereau
34f843875b Merge branch 'main' into fix/react-strict-mode 2022-12-05 12:15:01 +01:00
Pierre-Louis Mercereau
ca278a8c39 Merge branch 'main' into fix/react-strict-mode 2022-12-05 11:54:01 +01:00
Pierre-Louis Mercereau
75603786e0 refactor: 💡 Remove unused immer dependency 2022-12-05 11:43:47 +01:00
Pierre-Louis Mercereau
4e4e699b94 refactor: adapt to nextjs 2022-12-05 11:39:36 +01:00
Pierre-Louis Mercereau
da31fa9fba ci: explicit pnpm run 2022-12-02 20:46:25 +01:00
Pierre-Louis Mercereau
95e2afaf47 Merge branch 'main' into fix/react-strict-mode 2022-12-02 15:46:13 +01:00
Pierre-Louis Mercereau
958a56dde9 fix: adapt to nextjs 2022-12-02 15:11:08 +01:00
Pierre-Louis Mercereau
74cb15930e Merge branch 'main' into fix/react-strict-mode 2022-12-02 13:35:53 +01:00
Pierre-Louis Mercereau
aa37a98424 Merge branch 'main' into fix/react-strict-mode 2022-12-02 13:27:37 +01:00
Pierre-Louis Mercereau
11cbdda3a5 Merge branch 'main' into fix/react-strict-mode 2022-12-02 13:20:55 +01:00
Pierre-Louis Mercereau
6d1f4adf10 chore: update changeset 2022-12-02 13:16:31 +01:00
Pierre-Louis Mercereau
ddbc50c15e refactor: avoid ambiguous interpreter getter/setter 2022-12-02 13:15:04 +01:00
Pierre-Louis Mercereau
b2cbf570a3 fix: 🐛 don't throw error when re-setting the interpreter 2022-12-02 12:08:52 +01:00
Pierre-Louis Mercereau
22b8e65031 fix: 🐛 onstart only when interpreter is initialised 2022-12-02 11:26:14 +01:00
Pierre-Louis Mercereau
63c94d2036 chore: fine-tune (peer) dependencies 2022-11-30 12:56:46 +01:00
Pierre-Louis Mercereau
010df48c1e chore: remove deprecations 2022-11-28 20:44:24 +01:00
Pierre-Louis Mercereau
fdc11db93d Merge branch 'main' into docs/hide-old-deprecations 2022-11-28 20:18:26 +01:00
Pierre-Louis Mercereau
cb4749f168 chore: fix lint 2022-11-28 15:53:54 +01:00
Pierre-Louis Mercereau
46a8fcf471 docs: hide > 6m.o. deprecations in the generated documentation 2022-11-28 15:34:09 +01:00
397 changed files with 11011 additions and 6567 deletions

18
.github/CODEOWNERS vendored
View File

@@ -1,14 +1,14 @@
# Documentation
# https://help.github.com/en/articles/about-code-owners
/packages @plmercereau @szilarddoro
/packages @szilarddoro
/packages/docgen @szilarddoro
/integrations/stripe-graphql-js @elitan
/.github @plmercereau
/dashboard/ @szilarddoro @guicurcio
/docs/ @guicurcio @elitan
/config/ @plmercereau @szilarddoro
/examples/ @plmercereau
/examples/codegen-react-apollo @elitan @plmercereau
/examples/codegen-react-query @elitan @plmercereau
/examples/react-apollo-crm @elitan @plmercereau
/.github @szilarddoro
/dashboard/ @szilarddoro
/docs/ @elitan
/config/ @szilarddoro
/examples/ @szilarddoro
/examples/codegen-react-apollo @elitan @szilarddoro
/examples/codegen-react-query @elitan @szilarddoro
/examples/react-apollo-crm @elitan @szilarddoro

View File

@@ -40,14 +40,14 @@ runs:
- shell: bash
name: Build packages
if: ${{ inputs.BUILD == 'all' }}
run: pnpm build:all
run: pnpm run build:all
env:
TURBO_TOKEN: ${{ inputs.TURBO_TOKEN }}
TURBO_TEAM: ${{ inputs.TURBO_TEAM }}
- shell: bash
name: Build everything in the monorepo
if: ${{ inputs.BUILD == 'default' }}
run: pnpm build
run: pnpm run build
env:
TURBO_TOKEN: ${{ inputs.TURBO_TOKEN }}
TURBO_TEAM: ${{ inputs.TURBO_TEAM }}

View File

@@ -8,7 +8,6 @@ on:
- '**.md'
- 'LICENSE'
pull_request:
branches: [main]
types: [opened, synchronize]
paths-ignore:
- 'assets/**'
@@ -56,7 +55,7 @@ jobs:
| xargs -I@ realpath --relative-to=$PWD @ \
| xargs -I@ jq "if (.scripts.e2e | length) != 0 then {name: .name, path: \"@\"} else null end" @/package.json \
| awk "!/null/" \
| jq -c --slurp)
| jq -c --slurp 'map(select(length > 0))')
echo "matrix=$PACKAGES" >> $GITHUB_OUTPUT
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}

View File

@@ -45,6 +45,9 @@
"@nhost/docgen": [
"../packages/docgen/src/index.ts"
],
"@nhost/graphql-js": [
"../packages/graphql-js/src/index.ts"
],
"@nhost/hasura-auth-js": [
"../packages/hasura-auth-js/src/index.ts"
],

View File

@@ -1,7 +1,6 @@
import replace from '@rollup/plugin-replace'
import fs from 'fs'
import path from 'path'
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'
import tsconfigPaths from 'vite-tsconfig-paths'
@@ -21,7 +20,9 @@ export default defineConfig({
exclude: ['**/*.spec.ts', '**/*.test.ts', '**/tests/**'],
entryRoot: 'src',
// Was defaulting to true until version 1.7
skipDiagnostics: true
skipDiagnostics: true,
// Was defaulting to true until version 2.0
copyDtsFiles: true
})
],
test: {
@@ -61,7 +62,6 @@ export default defineConfig({
'@apollo/client/utilities': '@apollo/client/utilities',
'graphql-ws': 'graphql-ws',
xstate: 'xstate',
axios: 'axios',
'js-cookie': 'Cookies',
react: 'React',
'react-dom': 'ReactDOM',

View File

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

View File

@@ -1,5 +1,107 @@
# @nhost/dashboard
## 0.12.2
### Patch Changes
- 4bf40995: chore(deps): bump `typescript` to `4.9.5`
- 8bb097c9: chore(deps): bump `vitest`
- 35d52aab: chore(deps): replace `cross-fetch` with `isomorphic-unfetch`
- Updated dependencies [4bf40995]
- Updated dependencies [8bb097c9]
- Updated dependencies [35d52aab]
- @nhost/react-apollo@5.0.7
- @nhost/nextjs@1.13.12
## 0.12.1
### Patch Changes
- c96d7ccd: fix(dashboard): fix docker builds
## 0.12.0
### Minor Changes
- d1671210: feat(dashboard): use mimir to manage project configuration
### Patch Changes
- f65e4de9: chore(deps): bump @graphql-codegen monorepo to v3
## 0.11.20
### Patch Changes
- 4b4f0d01: chore(dashboard): improve dialog management
## 0.11.19
### Patch Changes
- @nhost/react-apollo@5.0.6
- @nhost/nextjs@1.13.11
## 0.11.18
### Patch Changes
- 01318860: fix(nhost-js): use correct URL for functions requests
- Updated dependencies [01318860]
- @nhost/react-apollo@5.0.5
- @nhost/nextjs@1.13.10
## 0.11.17
### Patch Changes
- f673adea: fix(dashboard): set correct Content-Type for user creation
- 445d8ef4: chore(deps): bump `@nhost/react-apollo` to 5.0.4
- 445d8ef4: chore(deps): bump `@nhost/nextjs` to 1.13.9
- 0368663d: fix(dashboard): allow permission editing for auth and storage schemas
- Updated dependencies [445d8ef4]
- Updated dependencies [445d8ef4]
- @nhost/react-apollo@5.0.4
- @nhost/nextjs@1.13.9
## 0.11.16
### Patch Changes
- b755e908: fix(dashboard): use correct date for last seen
- 2d9145f9: chore(deps): revert GraphQL client
- 1ddf704c: fix(dashboard): don't show false positive message for failed user creation
- @nhost/react-apollo@5.0.3
- @nhost/nextjs@1.13.8
## 0.11.15
### Patch Changes
- @nhost/react-apollo@5.0.2
- @nhost/nextjs@1.13.7
## 0.11.14
### Patch Changes
- 2cc18dcb: fix(dashboard): prevent permission editor dropdown from being always open
## 0.11.13
### Patch Changes
- 3343a363: chore(dashboard): bump `@testing-library/react` to v14 and `@testing-library/dom` to v9
- @nhost/react-apollo@5.0.1
- @nhost/nextjs@1.13.6
## 0.11.12
### Patch Changes
- 87eda76e: chore(dashboard): bump `@types/react` to v18.0.28 and `@types/react-dom` to v18.0.11
- 6f0ac570: feat(dashboard): show dashboard version in account menu
## 0.11.11
### Patch Changes

View File

@@ -11,7 +11,7 @@ FROM node:16-alpine AS builder
ARG TURBO_TOKEN
ARG TURBO_TEAM
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache libc6-compat python3 make g++
RUN apk update
WORKDIR /app

View File

@@ -59,6 +59,9 @@ pnpm storybook
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. Not necessary for local development. |
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. Not necessary for local development. |
| `NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET` | URL of the Bragi websocket. Not necessary for local development. |
| `NEXT_PUBLIC_MAINTENANCE_ACTIVE` | Determines whether or not maintenance mode is active. |
| `NEXT_PUBLIC_MAINTENANCE_END_DATE` | Date when maintenance mode will end. |
| `NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET` | Secret that can be used to bypass maintenance mode. |
## ESLint Rules

View File

@@ -2,6 +2,7 @@ const path = require('path');
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
const { version } = require('./package.json');
module.exports = withBundleAnalyzer({
reactStrictMode: true,
@@ -10,6 +11,9 @@ module.exports = withBundleAnalyzer({
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'),
},
publicRuntimeConfig: {
version,
},
eslint: {
dirs: ['src'],
},

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.11.11",
"version": "0.12.2",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -44,10 +44,8 @@
"@tanstack/react-table": "^8.5.30",
"@tanstack/react-virtual": "^3.0.0-beta.23",
"analytics-node": "^6.2.0",
"axios": "^0.27.2",
"bcryptjs": "^2.4.3",
"clsx": "^1.2.1",
"cross-fetch": "^3.1.5",
"date-fns": "^2.29.3",
"generate-password": "^1.7.0",
"graphiql": "^2.2.0",
@@ -65,7 +63,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-error-boundary": "^3.1.4",
"react-hook-form": "^7.39.5",
"react-hook-form": "^7.42.1",
"react-hot-toast": "^2.4.0",
"react-is": "18.2.0",
"react-loading-skeleton": "^2.2.0",
@@ -83,10 +81,10 @@
},
"devDependencies": {
"@babel/core": "^7.20.2",
"@graphql-codegen/cli": "^2.8.0",
"@graphql-codegen/typescript": "^2.7.1",
"@graphql-codegen/cli": "^3.0.0",
"@graphql-codegen/typescript": "^3.0.0",
"@graphql-codegen/typescript-graphql-request": "^4.5.1",
"@graphql-codegen/typescript-operations": "^2.5.1",
"@graphql-codegen/typescript-operations": "^3.0.0",
"@graphql-codegen/typescript-react-apollo": "^3.3.1",
"@next/bundle-analyzer": "^12.3.1",
"@storybook/addon-actions": "^6.5.14",
@@ -98,22 +96,22 @@
"@storybook/manager-webpack5": "^6.5.14",
"@storybook/react": "^6.5.14",
"@storybook/testing-library": "^0.0.13",
"@testing-library/dom": "^8.19.0",
"@testing-library/dom": "^9.0.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "^16.11.7",
"@types/pluralize": "^0.0.29",
"@types/react": "18.0.25",
"@types/react-dom": "18.0.10",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/react-table": "^7.7.12",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/validator": "^13.7.10",
"@typescript-eslint/eslint-plugin": "^5.43.0",
"@typescript-eslint/parser": "^5.43.0",
"@vitejs/plugin-react": "^3.0.0",
"@vitest/coverage-c8": "^0.27.0",
"@vitest/coverage-c8": "^0.29.0",
"autoprefixer": "^10.4.13",
"babel-loader": "^8.3.0",
"babel-plugin-transform-remove-console": "^6.9.4",
@@ -131,6 +129,7 @@
"lint-staged": ">=13",
"msw": "^1.0.1",
"msw-storybook-addon": "^1.6.3",
"node-fetch": "^3.3.0",
"postcss": "^8.4.19",
"prettier": "^2.7.1",
"prettier-plugin-organize-imports": "^3.2.0",
@@ -141,10 +140,9 @@
"tailwindcss": "^3.1.2",
"ts-node": "^10.9.1",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "^4.8.4",
"vite": "^4.0.2",
"vite-tsconfig-paths": "^4.0.3",
"vitest": "^0.27.0",
"vitest": "^0.29.0",
"webpack": "^5.75.0"
},
"browserslist": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -1,6 +0,0 @@
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="white" fill-opacity="0.15"/>
<rect width="200" height="200" fill="#263245" fill-opacity="0.08"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M71 84C71 67.9837 83.9837 55 100 55C116.016 55 129 67.9837 129 84C129 100.016 116.016 113 100 113C83.9837 113 71 100.016 71 84ZM100 49C80.67 49 65 64.67 65 84C65 97.6014 72.7585 109.391 84.0914 115.184C79.3584 116.509 74.7892 118.425 70.496 120.903C61.5257 126.08 54.0757 133.527 48.8946 142.495C48.0657 143.929 48.5568 145.764 49.9914 146.593C51.4261 147.422 53.261 146.931 54.0898 145.496C58.7443 137.44 65.4368 130.75 73.4952 126.099C81.5536 121.448 90.694 119 99.9982 119C109.302 119 118.443 121.449 126.501 126.1C134.559 130.751 141.252 137.441 145.906 145.497C146.735 146.932 148.57 147.423 150.004 146.594C151.439 145.765 151.93 143.93 151.101 142.496C145.92 133.527 138.471 126.081 129.5 120.903C125.208 118.426 120.639 116.509 115.907 115.185C127.241 109.392 135 97.6021 135 84C135 64.67 119.33 49 100 49Z" fill="white" fill-opacity="0.15"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M71 84C71 67.9837 83.9837 55 100 55C116.016 55 129 67.9837 129 84C129 100.016 116.016 113 100 113C83.9837 113 71 100.016 71 84ZM100 49C80.67 49 65 64.67 65 84C65 97.6014 72.7585 109.391 84.0914 115.184C79.3584 116.509 74.7892 118.425 70.496 120.903C61.5257 126.08 54.0757 133.527 48.8946 142.495C48.0657 143.929 48.5568 145.764 49.9914 146.593C51.4261 147.422 53.261 146.931 54.0898 145.496C58.7443 137.44 65.4368 130.75 73.4952 126.099C81.5536 121.448 90.694 119 99.9982 119C109.302 119 118.443 121.449 126.501 126.1C134.559 130.751 141.252 137.441 145.906 145.497C146.735 146.932 148.57 147.423 150.004 146.594C151.439 145.765 151.93 143.93 151.101 142.496C145.92 133.527 138.471 126.081 129.5 120.903C125.208 118.426 120.639 116.509 115.907 115.185C127.241 109.392 135 97.6021 135 84C135 64.67 119.33 49 100 49Z" fill="#263245" fill-opacity="0.25"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

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

View File

@@ -22,11 +22,9 @@ interface HasuraDataProps {
export function HasuraData({ close }: HasuraDataProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const isPlatform = useIsPlatform();
const projectAdminSecret = currentApplication?.config?.hasura.adminSecret;
if (
!currentApplication?.subdomain ||
!currentApplication?.hasuraGraphqlAdminSecret
) {
if (!currentApplication?.subdomain || !projectAdminSecret) {
return <LoadingScreen />;
}
@@ -71,18 +69,11 @@ export function HasuraData({ close }: HasuraDataProps) {
<div className="col-span-1 grid grid-flow-col items-center justify-center gap-2 sm:col-span-2 sm:justify-end">
<Text className="font-medium" variant="subtitle2">
{Array(currentApplication.hasuraGraphqlAdminSecret.length)
.fill('•')
.join('')}
{Array(projectAdminSecret.length).fill('•').join('')}
</Text>
<IconButton
onClick={() =>
copy(
currentApplication.hasuraGraphqlAdminSecret,
'Hasura admin secret',
)
}
onClick={() => copy(projectAdminSecret, 'Hasura admin secret')}
variant="borderless"
color="secondary"
className="min-w-0 p-1"

View File

@@ -1,6 +1,6 @@
import { FindOldApps } from '@/components/home';
import type { UserData } from '@/hooks/useGetAllUserWorkspacesAndApplications';
import type { Application, ApplicationState } from '@/types/application';
import type { ApplicationState } from '@/types/application';
import { ApplicationStatus } from '@/types/application';
import { Avatar } from '@/ui/Avatar';
import StateBadge from '@/ui/StateBadge';
@@ -56,7 +56,7 @@ function CurrentDeployment({ deployment }: any) {
avatarUrl={deployment.commitUserAvatarUrl}
className="mr-1 h-4 w-4 self-center"
/>
<Text className="self-center text-sm">
<Text component="span" className="self-center text-sm">
{deployment.commitUserName} updated just now
</Text>
</span>
@@ -103,7 +103,7 @@ export function RenderWorkspacesWithApps({
}
const workspaceProjects = workspace.applications
.filter((app: Application) =>
.filter((app) =>
app.name.toLowerCase().includes(query.toLowerCase()),
)
.sort((appA, appB) => {
@@ -142,7 +142,9 @@ export function RenderWorkspacesWithApps({
<List className="grid grid-flow-row border-y">
{workspaceProjects.map((app, index) => {
const isDeployingToProduction = app.deployments[0]
? app.deployments[0].deploymentStatus === 'DEPLOYING'
? ['SCHEDULED', 'PENDING', 'DEPLOYING'].includes(
app.deployments[0].deploymentStatus,
)
: false;
return (

View File

@@ -52,7 +52,9 @@ function ControlledAutocomplete(
return (
<Autocomplete
inputValue={typeof field.value === 'string' ? field.value : undefined}
inputValue={
typeof field.value !== 'object' ? field.value.toString() : undefined
}
{...props}
{...field}
ref={mergeRefs([field.ref, ref])}

View File

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

View File

@@ -1,31 +1,8 @@
import type { DialogFormProps } from '@/types/common';
import type { CommonDialogProps } from '@/ui/v2/Dialog';
import type { ReactNode } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { createContext } from 'react';
/**
* Available dialog types.
*/
export type DialogType =
| 'EDIT_WORKSPACE_NAME'
| 'CREATE_RECORD'
| 'CREATE_COLUMN'
| 'EDIT_COLUMN'
| 'CREATE_TABLE'
| 'EDIT_TABLE'
| 'EDIT_PERMISSIONS'
| 'CREATE_FOREIGN_KEY'
| 'EDIT_FOREIGN_KEY'
| 'CREATE_ROLE'
| 'EDIT_ROLE'
| 'CREATE_USER'
| 'CREATE_PERMISSION_VARIABLE'
| 'EDIT_PERMISSION_VARIABLE'
| 'CREATE_ENVIRONMENT_VARIABLE'
| 'EDIT_ENVIRONMENT_VARIABLE'
| 'EDIT_USER'
| 'EDIT_USER_PASSWORD'
| 'EDIT_JWT_SECRET';
export interface DialogConfig<TPayload = unknown> {
/**
* Title of the dialog.
@@ -41,21 +18,36 @@ export interface DialogConfig<TPayload = unknown> {
payload?: TPayload;
}
export interface OpenDialogOptions {
/**
* Title of the dialog.
*/
title: ReactNode;
/**
* Component to render inside the dialog skeleton.
*/
component: ReactElement<{
location?: 'drawer' | 'dialog';
onCancel?: () => void;
onSubmit?: (args?: any) => Promise<any> | void;
}>;
/**
* Props to pass to the root dialog component.
*/
props?: Partial<CommonDialogProps>;
}
export interface DialogContextProps {
/**
* Call this function to open a dialog.
* Call this function to open a dialog. It will automatically apply the
* necessary functionality to the dialog.
*/
openDialog: <TPayload = unknown>(
type: DialogType,
config?: DialogConfig<TPayload>,
) => void;
openDialog: (options: OpenDialogOptions) => void;
/**
* Call this function to open a drawer.
* Call this function to open a drawer. It will automatically apply the
* necessary functionality to the drawer.
*/
openDrawer: <TPayload = unknown>(
type: DialogType,
config?: DialogConfig<TPayload>,
) => void;
openDrawer: (options: OpenDialogOptions) => void;
/**
* Call this function to open an alert dialog.
*/
@@ -87,7 +79,7 @@ export interface DialogContextProps {
*/
onDirtyStateChange: (
isDirty: boolean,
location?: 'drawer' | 'dialog',
location?: DialogFormProps['location'],
) => void;
/**
* Call this function to open a dirty confirmation dialog.

View File

@@ -1,30 +1,12 @@
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
import EditWorkspaceNameForm from '@/components/home/EditWorkspaceNameForm';
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
import CreateUserForm from '@/components/users/CreateUserForm';
import EditUserForm from '@/components/users/EditUserForm';
import EditUserPasswordForm from '@/components/users/EditUserPasswordForm';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import AlertDialog from '@/ui/v2/AlertDialog';
import { BaseDialog } from '@/ui/v2/Dialog';
import Drawer from '@/ui/v2/Drawer';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import type {
BaseSyntheticEvent,
DetailedHTMLProps,
HTMLProps,
PropsWithChildren,
} from 'react';
import type { BaseSyntheticEvent, PropsWithChildren } from 'react';
import {
cloneElement,
isValidElement,
useCallback,
useEffect,
useMemo,
@@ -33,7 +15,7 @@ import {
useState,
} from 'react';
import { twMerge } from 'tailwind-merge';
import type { DialogConfig, DialogType } from './DialogContext';
import type { DialogConfig, OpenDialogOptions } from './DialogContext';
import DialogContext from './DialogContext';
import {
alertDialogReducer,
@@ -41,67 +23,11 @@ import {
drawerReducer,
} from './dialogReducers';
function LoadingComponent({
className,
...props
}: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> = {}) {
return (
<div
{...props}
className={twMerge(
'grid items-center justify-center px-6 py-4',
className,
)}
>
<ActivityIndicator
circularProgressProps={{ className: 'w-5 h-5' }}
label="Loading form..."
/>
</div>
);
}
const CreateRecordForm = dynamic(
() => import('@/components/dataBrowser/CreateRecordForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
const CreateColumnForm = dynamic(
() => import('@/components/dataBrowser/CreateColumnForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
const EditColumnForm = dynamic(
() => import('@/components/dataBrowser/EditColumnForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
const CreateTableForm = dynamic(
() => import('@/components/dataBrowser/CreateTableForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
const EditTableForm = dynamic(
() => import('@/components/dataBrowser/EditTableForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
const EditPermissionsForm = dynamic(
() => import('@/components/dataBrowser/EditPermissionsForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
function DialogProvider({ children }: PropsWithChildren<unknown>) {
const router = useRouter();
const [
{
open: dialogOpen,
activeDialogType,
dialogProps,
title: dialogTitle,
payload: dialogPayload,
},
{ open: dialogOpen, title: dialogTitle, activeDialog, dialogProps },
dialogDispatch,
] = useReducer(dialogReducer, {
open: false,
@@ -110,10 +36,9 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
const [
{
open: drawerOpen,
activeDialogType: activeDrawerType,
dialogProps: drawerProps,
title: drawerTitle,
payload: drawerPayload,
activeDialog: activeDrawer,
dialogProps: drawerProps,
},
drawerDispatch,
] = useReducer(drawerReducer, {
@@ -136,12 +61,9 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
const isDialogDirty = useRef(false);
const [showDirtyConfirmation, setShowDirtyConfirmation] = useState(false);
const openDialog = useCallback(
<TConfig,>(type: DialogType, config?: DialogConfig<TConfig>) => {
dialogDispatch({ type: 'OPEN_DIALOG', payload: { type, config } });
},
[],
);
const openDialog = useCallback((options: OpenDialogOptions) => {
dialogDispatch({ type: 'OPEN_DIALOG', payload: options });
}, []);
const closeDialog = useCallback(() => {
dialogDispatch({ type: 'HIDE_DIALOG' });
@@ -152,12 +74,9 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
dialogDispatch({ type: 'CLEAR_DIALOG_CONTENT' });
}, []);
const openDrawer = useCallback(
<TConfig,>(type: DialogType, config?: DialogConfig<TConfig>) => {
drawerDispatch({ type: 'OPEN_DRAWER', payload: { type, config } });
},
[],
);
const openDrawer = useCallback((options: OpenDialogOptions) => {
drawerDispatch({ type: 'OPEN_DRAWER', payload: options });
}, []);
const closeDrawer = useCallback(() => {
drawerDispatch({ type: 'HIDE_DRAWER' });
@@ -228,9 +147,6 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
[closeDialog, openDirtyConfirmation],
);
// We are coupling this logic with the location of the dialog content which is
// not ideal. We shoule figure out a better logic for tracking the dirty
// state in the future.
const onDirtyStateChange = useCallback(
(dirty: boolean, location: 'drawer' | 'dialog' = 'drawer') => {
if (location === 'dialog') {
@@ -271,25 +187,6 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
],
);
const sharedDialogProps = {
...dialogPayload,
onSubmit: async (values: any) => {
await dialogPayload?.onSubmit?.(values);
closeDialog();
},
onCancel: closeDialogWithDirtyGuard,
};
const sharedDrawerProps = {
onSubmit: async () => {
await drawerPayload?.onSubmit();
closeDrawer();
},
onCancel: closeDrawerWithDirtyGuard,
};
useEffect(() => {
function handleCloseDrawerAndDialog() {
if (isDrawerDirty.current || isDialogDirty.current) {
@@ -367,56 +264,20 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<RetryableErrorBoundary
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
>
{activeDialogType === 'EDIT_WORKSPACE_NAME' && (
<EditWorkspaceNameForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_FOREIGN_KEY' && (
<CreateForeignKeyForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_FOREIGN_KEY' && (
<EditForeignKeyForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_ROLE' && (
<CreateRoleForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_ROLE' && (
<EditRoleForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_USER' && (
<CreateUserForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_PERMISSION_VARIABLE' && (
<CreatePermissionVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_PERMISSION_VARIABLE' && (
<EditPermissionVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_ENVIRONMENT_VARIABLE' && (
<CreateEnvironmentVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_ENVIRONMENT_VARIABLE' && (
<EditEnvironmentVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_USER_PASSWORD' && (
<EditUserPasswordForm
{...sharedDialogProps}
user={sharedDialogProps?.user}
/>
)}
{activeDialogType === 'EDIT_JWT_SECRET' && (
<EditJwtSecretForm {...sharedDialogProps} />
)}
{isValidElement(activeDialog)
? cloneElement(activeDialog, {
...activeDialog.props,
location: 'dialog',
onSubmit: async (values?: any) => {
await activeDialog?.props?.onSubmit?.(values);
closeDialog();
},
onCancel: () => {
activeDialog?.props?.onCancel?.();
closeDialogWithDirtyGuard();
},
})
: null}
</RetryableErrorBoundary>
</BaseDialog>
@@ -436,51 +297,20 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
}}
>
<RetryableErrorBoundary>
{activeDrawerType === 'CREATE_RECORD' && (
<CreateRecordForm
{...sharedDrawerProps}
columns={drawerPayload?.columns}
/>
)}
{activeDrawerType === 'CREATE_COLUMN' && (
<CreateColumnForm {...sharedDrawerProps} />
)}
{activeDrawerType === 'EDIT_COLUMN' && (
<EditColumnForm
{...sharedDrawerProps}
column={drawerPayload?.column}
/>
)}
{activeDrawerType === 'CREATE_TABLE' && (
<CreateTableForm
{...sharedDrawerProps}
schema={drawerPayload?.schema}
/>
)}
{activeDrawerType === 'EDIT_TABLE' && (
<EditTableForm
{...sharedDrawerProps}
table={drawerPayload?.table}
schema={drawerPayload?.schema}
/>
)}
{activeDrawerType === 'EDIT_PERMISSIONS' && (
<EditPermissionsForm
{...sharedDrawerProps}
disabled={drawerPayload?.disabled}
schema={drawerPayload?.schema}
table={drawerPayload?.table}
/>
)}
{activeDrawerType === 'EDIT_USER' && (
<EditUserForm {...sharedDrawerProps} {...drawerPayload} />
)}
{isValidElement(activeDrawer)
? cloneElement(activeDrawer, {
...activeDrawer.props,
location: 'drawer',
onSubmit: async (values?: any) => {
await activeDrawer?.props?.onSubmit?.(values);
closeDrawer();
},
onCancel: () => {
activeDrawer?.props?.onCancel?.();
closeDrawerWithDirtyGuard();
},
})
: null}
</RetryableErrorBoundary>
</Drawer>

View File

@@ -1,6 +1,6 @@
import type { CommonDialogProps } from '@/ui/v2/Dialog';
import type { ReactNode } from 'react';
import type { DialogConfig, DialogType } from './DialogContext';
import type { ReactElement, ReactNode } from 'react';
import type { DialogConfig, OpenDialogOptions } from './DialogContext';
export interface DialogState {
/**
@@ -12,9 +12,13 @@ export interface DialogState {
*/
open?: boolean;
/**
* Type of the currently active dialog.
* Component to render inside the dialog skeleton.
*/
activeDialogType?: DialogType;
activeDialog?: ReactElement<{
location?: 'drawer' | 'dialog';
onCancel?: () => void;
onSubmit?: (args?: any) => Promise<any> | void;
}>;
/**
* Props passed to the currently active dialog.
*/
@@ -27,10 +31,7 @@ export interface DialogState {
}
export type DialogAction =
| {
type: 'OPEN_DIALOG';
payload: { type: DialogType; config?: DialogConfig };
}
| { type: 'OPEN_DIALOG'; payload: OpenDialogOptions }
| { type: 'HIDE_DIALOG' }
| { type: 'CLEAR_DIALOG_CONTENT' };
@@ -50,10 +51,9 @@ export function dialogReducer(
return {
...state,
open: true,
activeDialogType: action.payload?.type,
dialogProps: action.payload.config?.props,
title: action.payload.config?.title,
payload: action.payload.config?.payload,
title: action.payload.title,
activeDialog: action.payload.component,
dialogProps: action.payload.props,
};
case 'HIDE_DIALOG':
return {
@@ -64,8 +64,7 @@ export function dialogReducer(
return {
...state,
title: undefined,
payload: undefined,
activeDialogType: undefined,
activeDialog: undefined,
dialogProps: undefined,
};
default:
@@ -74,10 +73,7 @@ export function dialogReducer(
}
export type DrawerAction =
| {
type: 'OPEN_DRAWER';
payload: { type: DialogType; config?: DialogConfig };
}
| { type: 'OPEN_DRAWER'; payload: OpenDialogOptions }
| { type: 'HIDE_DRAWER' }
| { type: 'CLEAR_DRAWER_CONTENT' };
@@ -97,10 +93,9 @@ export function drawerReducer(
return {
...state,
open: true,
activeDialogType: action.payload?.type,
dialogProps: action.payload.config?.props,
title: action.payload.config?.title,
payload: action.payload.config?.payload,
title: action.payload.title,
activeDialog: action.payload.component,
dialogProps: action.payload.props,
};
case 'HIDE_DRAWER':
return {
@@ -111,8 +106,7 @@ export function drawerReducer(
return {
...state,
title: undefined,
payload: undefined,
activeDialogType: undefined,
activeDialog: undefined,
dialogProps: undefined,
};
default:

View File

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

View File

@@ -0,0 +1,26 @@
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import type { BoxProps } from '@/ui/v2/Box';
import Box from '@/ui/v2/Box';
import { twMerge } from 'tailwind-merge';
export interface FormActivityIndicatorProps extends BoxProps {}
export default function FormActivityIndicator({
className,
...props
}: FormActivityIndicatorProps) {
return (
<Box
{...props}
className={twMerge(
'grid items-center justify-center px-6 py-4',
className,
)}
>
<ActivityIndicator
circularProgressProps={{ className: 'w-5 h-5' }}
label="Loading form..."
/>
</Box>
);
}

View File

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

View File

@@ -1,9 +1,9 @@
import Breadcrumbs from '@/components/common/Breadcrumbs';
import FeedbackForm from '@/components/common/FeedbackForm';
import LocalAccountMenu from '@/components/common/LocalAccountMenu';
import Logo from '@/components/common/Logo';
import MobileNav from '@/components/common/MobileNav';
import NavLink from '@/components/common/NavLink';
import ThemeSwitcher from '@/components/common/ThemeSwitcher';
import { AccountMenu } from '@/components/dashboard/AccountMenu';
import useIsPlatform from '@/hooks/common/useIsPlatform';
import Box from '@/ui/v2/Box';
@@ -23,6 +23,7 @@ export default function Header({ className, ...props }: HeaderProps) {
return (
<Box
component="header"
className={twMerge(
'z-40 grid w-full transform-gpu grid-flow-col items-center justify-between gap-2 border-b-1 px-4 py-3',
className,
@@ -73,7 +74,7 @@ export default function Header({ className, ...props }: HeaderProps) {
Docs
</NavLink>
{isPlatform ? <AccountMenu /> : <ThemeSwitcher className="w-52" />}
{isPlatform ? <AccountMenu /> : <LocalAccountMenu />}
</div>
<MobileNav className="sm:hidden" />

View File

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

View File

@@ -0,0 +1,39 @@
import ThemeSwitcher from '@/components/common/ThemeSwitcher';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import UserIcon from '@/ui/v2/icons/UserIcon';
import Text from '@/ui/v2/Text';
import getConfig from 'next/config';
export default function LocalAccountMenu() {
const { publicRuntimeConfig } = getConfig();
return (
<Dropdown.Root className="justify-self-center">
<Dropdown.Trigger hideChevron asChild>
<IconButton
variant="borderless"
color="secondary"
className="h-7 w-7 rounded-full"
sx={{
backgroundColor: (theme) => `${theme.palette.grey[300]} !important`,
}}
>
<UserIcon className="h-4 w-4" />
</IconButton>
</Dropdown.Trigger>
<Dropdown.Content
PaperProps={{
className: 'mt-1 p-6 grid grid-flow-row gap-4 w-full max-w-xs',
}}
>
<ThemeSwitcher label="Theme" />
<Text className="text-center text-xs" color="disabled">
Dashboard Version: {publicRuntimeConfig?.version || 'n/a'}
</Text>
</Dropdown.Content>
</Dropdown.Root>
);
}

View File

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

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

@@ -20,6 +20,7 @@ import type { ListItemButtonProps } from '@/ui/v2/ListItem';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import { useSignOut } from '@nhost/nextjs';
import getConfig from 'next/config';
import { useRouter } from 'next/router';
import type { ReactNode } from 'react';
import { cloneElement, Fragment, isValidElement, useState } from 'react';
@@ -89,6 +90,7 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
const { signOut } = useSignOut();
const { setUserContext } = useUserDataContext();
const router = useRouter();
const { publicRuntimeConfig } = getConfig();
return (
<>
@@ -256,6 +258,10 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
</ListItem.Button>
</ListItem.Root>
</List>
<Text className="text-center text-xs" color="secondary">
Dashboard Version: {publicRuntimeConfig?.version || 'n/a'}
</Text>
</section>
)}
</Drawer>

View File

@@ -21,6 +21,13 @@ export default function ThemeSwitcher({
onChange?.(event, value);
}}
slotProps={{
listbox: { className: 'min-w-0 w-full' },
popper: {
disablePortal: false,
className: 'z-[10000] w-[270px] w-full',
},
}}
>
<Option value="light">Light</Option>
<Option value="dark">Dark</Option>

View File

@@ -10,6 +10,7 @@ import Text from '@/ui/v2/Text';
import { nhost } from '@/utils/nhost';
import { useApolloClient } from '@apollo/client';
import { useUserData } from '@nhost/nextjs';
import getConfig from 'next/config';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
@@ -24,23 +25,10 @@ function AccountMenuContent({
const router = useRouter();
const client = useApolloClient();
const { handleClose } = useDropdown();
const { publicRuntimeConfig } = getConfig();
return (
<Box className="relative grid w-account grid-flow-row gap-5 p-6">
<Button
variant="borderless"
color="secondary"
className="absolute top-6 right-4 grid grid-flow-col items-center gap-px self-start font-medium"
onClick={async () => {
await nhost.auth.signOut();
router.push('/signin');
await client.resetStore();
}}
endIcon={<PowerIcon className="mr-1 h-4 w-4" />}
>
Sign Out
</Button>
<Box className="relative grid w-full grid-flow-row gap-5 p-6">
<div className="grid grid-flow-row justify-center">
<Avatar
className="mx-auto mb-2 h-16 w-16 rounded-full"
@@ -72,9 +60,26 @@ function AccountMenuContent({
<Button color="error" disabled>
Remove Account
</Button>
<Button
variant="outlined"
color="secondary"
onClick={async () => {
await nhost.auth.signOut();
router.push('/signin');
await client.resetStore();
}}
endIcon={<PowerIcon className="mr-1 h-4 w-4" />}
>
Sign Out
</Button>
</div>
<ThemeSwitcher label="Theme" fullWidth />
<ThemeSwitcher label="Theme" />
<Text className="text-center text-xs" color="disabled">
Dashboard Version: {publicRuntimeConfig?.version || 'n/a'}
</Text>
</Box>
);
}
@@ -107,7 +112,7 @@ export function AccountMenu() {
/>
</Dropdown.Trigger>
<Dropdown.Content PaperProps={{ className: 'mt-1' }}>
<Dropdown.Content PaperProps={{ className: 'mt-1 max-w-xs w-full' }}>
<AccountMenuContent
onChangePasswordClick={() => setChangePasswordModal(true)}
/>

View File

@@ -3,6 +3,7 @@ import ControlledCheckbox from '@/components/common/ControlledCheckbox';
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import InlineCode from '@/components/common/InlineCode';
import type { DialogFormProps } from '@/types/common';
import type { ColumnType, DatabaseColumn } from '@/types/dataBrowser';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
@@ -22,7 +23,7 @@ import ForeignKeyEditor from './ForeignKeyEditor';
export type BaseColumnFormValues = DatabaseColumn;
export interface BaseColumnFormProps {
export interface BaseColumnFormProps extends DialogFormProps {
/**
* Function to be called when the form is submitted.
*/
@@ -60,6 +61,7 @@ export default function BaseColumnForm({
onSubmit: handleExternalSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BaseColumnFormProps) {
const { onDirtyStateChange } = useDialog();
@@ -91,8 +93,8 @@ export default function BaseColumnForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'drawer');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<Form

View File

@@ -1,5 +1,6 @@
import { useDialog } from '@/components/common/DialogProvider';
import type { BaseForeignKeyFormValues } from '@/components/dataBrowser/BaseForeignKeyForm';
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
import type { DatabaseColumn } from '@/types/dataBrowser';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
@@ -29,7 +30,7 @@ const ForeignKeyEditorInput = forwardRef(
) => {
const { openDialog } = useDialog();
const { setValue } = useFormContext();
const column = useWatch<Partial<DatabaseColumn>>();
const column = useWatch() as DatabaseColumn;
const { foreignKeyRelation } = column;
if (!column.foreignKeyRelation) {
@@ -39,8 +40,8 @@ const ForeignKeyEditorInput = forwardRef(
className="py-1"
disabled={!column.name || !column.type}
ref={ref}
onClick={() =>
openDialog('CREATE_FOREIGN_KEY', {
onClick={() => {
openDialog({
title: (
<span className="grid grid-flow-row">
<span>Add a Foreign Key Relation</span>
@@ -51,16 +52,18 @@ const ForeignKeyEditorInput = forwardRef(
</Text>
</span>
),
payload: {
selectedColumn: column.name,
availableColumns: [column],
onSubmit: (values: BaseForeignKeyFormValues) => {
setValue('foreignKeyRelation', values);
onCreateSubmit();
},
},
})
}
component: (
<CreateForeignKeyForm
selectedColumn={column.name}
availableColumns={[column]}
onSubmit={(values) => {
setValue('foreignKeyRelation', values);
onCreateSubmit();
}}
/>
),
});
}}
>
Add Foreign Key
</Button>
@@ -86,20 +89,22 @@ const ForeignKeyEditorInput = forwardRef(
<div className="grid grid-flow-col">
<Button
ref={ref}
onClick={() =>
openDialog('EDIT_FOREIGN_KEY', {
onClick={() => {
openDialog({
title: 'Edit Foreign Key Relation',
payload: {
foreignKeyRelation,
availableColumns: [column],
selectedColumn: column.name,
onSubmit: (values: BaseForeignKeyFormValues) => {
setValue('foreignKeyRelation', values);
onEditSubmit();
},
},
})
}
component: (
<EditForeignKeyForm
foreignKeyRelation={foreignKeyRelation}
selectedColumn={column.name}
availableColumns={[column]}
onSubmit={(values) => {
setValue('foreignKeyRelation', values);
onEditSubmit();
}}
/>
),
});
}}
variant="borderless"
className="min-w-[initial] py-1 px-2"
>

View File

@@ -2,6 +2,7 @@ import ControlledSelect from '@/components/common/ControlledSelect';
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import useDatabaseQuery from '@/hooks/dataBrowser/useDatabaseQuery';
import type { DialogFormProps } from '@/types/common';
import type { DatabaseColumn, ForeignKeyRelation } from '@/types/dataBrowser';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
@@ -23,7 +24,7 @@ export interface BaseForeignKeyFormValues extends ForeignKeyRelation {
disableOriginColumn?: boolean;
}
export interface BaseForeignKeyFormProps {
export interface BaseForeignKeyFormProps extends DialogFormProps {
/**
* Available columns in the table.
*/
@@ -64,6 +65,7 @@ export function BaseForeignKeyForm({
onSubmit: handleExternalSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BaseForeignKeyFormProps) {
const { onDirtyStateChange } = useDialog();
@@ -86,8 +88,8 @@ export function BaseForeignKeyForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<Form

View File

@@ -1,6 +1,7 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import DatabaseRecordInputGroup from '@/components/dataBrowser/DatabaseRecordInputGroup';
import type { DialogFormProps } from '@/types/common';
import type {
ColumnInsertOptions,
DataBrowserGridColumn,
@@ -10,7 +11,7 @@ import Button from '@/ui/v2/Button';
import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
export interface BaseRecordFormProps {
export interface BaseRecordFormProps extends DialogFormProps {
/**
* The columns of the table.
*/
@@ -36,6 +37,7 @@ export default function BaseRecordForm({
onSubmit: handleExternalSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BaseRecordFormProps) {
const { onDirtyStateChange } = useDialog();
const { requiredColumns, optionalColumns } = columns.reduce(
@@ -70,8 +72,8 @@ export default function BaseRecordForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'drawer');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
// Stores columns in a map to have constant time lookup. This is necessary
// for tables with many columns.

View File

@@ -1,6 +1,7 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import { baseColumnValidationSchema } from '@/components/dataBrowser/BaseColumnForm';
import type { DialogFormProps } from '@/types/common';
import type { DatabaseTable, ForeignKeyRelation } from '@/types/dataBrowser';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
@@ -30,7 +31,7 @@ export interface BaseTableFormValues
foreignKeyRelations?: ForeignKeyRelation[];
}
export interface BaseTableFormProps {
export interface BaseTableFormProps extends DialogFormProps {
/**
* Function to be called when the form is submitted.
*/
@@ -99,7 +100,9 @@ function NameInput() {
function FormFooter({
onCancel,
submitButtonText,
}: Pick<BaseTableFormProps, 'onCancel' | 'submitButtonText'>) {
location,
}: Pick<BaseTableFormProps, 'onCancel' | 'submitButtonText'> &
Pick<DialogFormProps, 'location'>) {
const { onDirtyStateChange } = useDialog();
const { isSubmitting, dirtyFields } = useFormState();
@@ -108,8 +111,8 @@ function FormFooter({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'drawer');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<Box className="grid flex-shrink-0 grid-flow-col justify-between gap-3 border-t-1 p-2">
@@ -135,6 +138,7 @@ function FormFooter({
}
export default function BaseTableForm({
location,
onSubmit: handleExternalSubmit,
onCancel,
submitButtonText = 'Save',
@@ -168,7 +172,11 @@ export default function BaseTableForm({
<ForeignKeyEditorSection />
</div>
<FormFooter onCancel={onCancel} submitButtonText={submitButtonText} />
<FormFooter
onCancel={onCancel}
submitButtonText={submitButtonText}
location={location}
/>
</Form>
);
}

View File

@@ -1,5 +1,7 @@
import { useDialog } from '@/components/common/DialogProvider';
import type { BaseForeignKeyFormValues } from '@/components/dataBrowser/BaseForeignKeyForm';
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
import type { DatabaseColumn, ForeignKeyRelation } from '@/types/dataBrowser';
import Button from '@/ui/v2/Button';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
@@ -68,18 +70,19 @@ export default function ForeignKeyEditorSection() {
onEdit={() => {
const primaryKeyIndex = getValues('primaryKeyIndex');
openDialog('EDIT_FOREIGN_KEY', {
openDialog({
title: 'Edit Foreign Key Relation',
payload: {
foreignKeyRelation: fields[index],
availableColumns: columns.map((column, columnIndex) =>
columnIndex === primaryKeyIndex
? { ...column, isPrimary: true }
: column,
),
onSubmit: (values: BaseForeignKeyFormValues) =>
handleEdit(values, index),
},
component: (
<EditForeignKeyForm
foreignKeyRelation={fields[index] as ForeignKeyRelation}
availableColumns={columns.map((column, columnIndex) =>
columnIndex === primaryKeyIndex
? { ...column, isPrimary: true }
: column,
)}
onSubmit={(values) => handleEdit(values, index)}
/>
),
});
}}
onDelete={() => remove(index)}
@@ -105,7 +108,7 @@ export default function ForeignKeyEditorSection() {
onClick={() => {
const primaryKeyIndex = getValues('primaryKeyIndex');
openDialog('CREATE_FOREIGN_KEY', {
openDialog({
title: (
<span className="grid grid-flow-row">
<span>Add a Foreign Key Relation</span>
@@ -116,14 +119,16 @@ export default function ForeignKeyEditorSection() {
</Text>
</span>
),
payload: {
availableColumns: columns.map((column, index) =>
index === primaryKeyIndex
? { ...column, isPrimary: true }
: column,
),
onSubmit: handleCreate,
},
component: (
<CreateForeignKeyForm
availableColumns={columns.map((column, index) =>
index === primaryKeyIndex
? { ...column, isPrimary: true }
: column,
)}
onSubmit={handleCreate}
/>
),
});
}}
>

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

View File

@@ -15,11 +15,11 @@ import { useRouter } from 'next/router';
import { FormProvider, useForm } from 'react-hook-form';
export interface CreateColumnFormProps
extends Pick<BaseColumnFormProps, 'onCancel'> {
extends Pick<BaseColumnFormProps, 'onCancel' | 'location'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
onSubmit?: (args?: any) => Promise<any>;
}
export default function CreateColumnForm({

View File

@@ -13,7 +13,10 @@ import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
export interface CreateForeignKeyFormProps
extends Pick<BaseForeignKeyFormProps, 'onCancel' | 'availableColumns'> {
extends Pick<
BaseForeignKeyFormProps,
'onCancel' | 'availableColumns' | 'location'
> {
/**
* Column selected by default.
*/
@@ -21,7 +24,7 @@ export interface CreateForeignKeyFormProps
/**
* Function to be called when the form is submitted.
*/
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void> | void;
}
export default function CreateForeignKeyForm({
@@ -51,9 +54,7 @@ export default function CreateForeignKeyForm({
setError(undefined);
try {
if (onSubmit) {
await onSubmit(values);
}
await onSubmit?.(values);
} catch (submitError) {
if (submitError && submitError instanceof Error) {
setError(submitError);

View File

@@ -10,11 +10,11 @@ import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
export interface CreateRecordFormProps
extends Pick<BaseRecordFormProps, 'columns' | 'onCancel'> {
extends Pick<BaseRecordFormProps, 'columns' | 'onCancel' | 'location'> {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
onSubmit?: (args?: any) => Promise<any>;
}
export default function CreateRecordForm({

View File

@@ -17,7 +17,7 @@ import { useRouter } from 'next/router';
import { FormProvider, useForm } from 'react-hook-form';
export interface CreateTableFormProps
extends Pick<BaseTableFormProps, 'onCancel'> {
extends Pick<BaseTableFormProps, 'onCancel' | 'location'> {
/**
* Schema where the table should be created.
*/
@@ -25,7 +25,7 @@ export interface CreateTableFormProps
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => Promise<void>;
onSubmit?: (args?: any) => Promise<any>;
}
export default function CreateTableForm({

View File

@@ -5,6 +5,7 @@ import DataGridDateCell from '@/components/common/DataGridDateCell';
import DataGridNumericCell from '@/components/common/DataGridNumericCell';
import DataGridTextCell from '@/components/common/DataGridTextCell';
import { useDialog } from '@/components/common/DialogProvider';
import FormActivityIndicator from '@/components/common/FormActivityIndicator';
import InlineCode from '@/components/common/InlineCode';
import DataBrowserEmptyState from '@/components/dataBrowser/DataBrowserEmptyState';
import DataBrowserGridControls from '@/components/dataBrowser/DataBrowserGridControls';
@@ -28,9 +29,25 @@ import {
} from '@/utils/dataBrowser/postgresqlConstants';
import { isSchemaLocked } from '@/utils/dataBrowser/schemaHelpers';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useRef, useState } from 'react';
const CreateColumnForm = dynamic(
() => import('@/components/dataBrowser/CreateColumnForm'),
{ ssr: false, loading: () => <FormActivityIndicator /> },
);
const EditColumnForm = dynamic(
() => import('@/components/dataBrowser/EditColumnForm'),
{ ssr: false, loading: () => <FormActivityIndicator /> },
);
const CreateRecordForm = dynamic(
() => import('@/components/dataBrowser/CreateRecordForm'),
{ ssr: false, loading: () => <FormActivityIndicator /> },
);
export interface DataBrowserGridProps extends Partial<DataGridProps<any>> {}
export function createDataGridColumn(
@@ -273,33 +290,36 @@ export default function DataBrowserGrid({
const memoizedData = useMemo(() => rows, [rows]);
async function handleInsertRowClick() {
openDrawer('CREATE_RECORD', {
openDrawer({
title: 'Insert a New Row',
payload: {
columns: memoizedColumns,
onSubmit: refetch,
},
component: (
<CreateRecordForm
// TODO: Create proper typings for data browser columns
columns={memoizedColumns as unknown as DataBrowserGridColumn[]}
onSubmit={refetch}
/>
),
});
}
async function handleInsertColumnClick() {
openDrawer('CREATE_COLUMN', {
openDrawer({
title: 'Insert a New Column',
payload: {
onSubmit: refetch,
},
component: <CreateColumnForm onSubmit={refetch} />,
});
}
async function handleEditColumnClick(
column: DataBrowserGridColumn<NormalizedQueryDataRow>,
) {
openDrawer('EDIT_COLUMN', {
openDrawer({
title: 'Edit Column',
payload: {
column,
onSubmit: () => queryClient.refetchQueries([currentTablePath]),
},
component: (
<EditColumnForm
column={column}
onSubmit={() => queryClient.refetchQueries([currentTablePath])}
/>
),
});
}

View File

@@ -1,4 +1,5 @@
import { useDialog } from '@/components/common/DialogProvider';
import FormActivityIndicator from '@/components/common/FormActivityIndicator';
import InlineCode from '@/components/common/InlineCode';
import NavLink from '@/components/common/NavLink';
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
@@ -31,11 +32,36 @@ import Select from '@/ui/v2/Select';
import Text from '@/ui/v2/Text';
import { isSchemaLocked } from '@/utils/dataBrowser/schemaHelpers';
import { useQueryClient } from '@tanstack/react-query';
import dynamic from 'next/dynamic';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
const CreateTableForm = dynamic(
() => import('@/components/dataBrowser/CreateTableForm'),
{
ssr: false,
loading: () => <FormActivityIndicator />,
},
);
const EditTableForm = dynamic(
() => import('@/components/dataBrowser/EditTableForm'),
{
ssr: false,
loading: () => <FormActivityIndicator />,
},
);
const EditPermissionsForm = dynamic(
() => import('@/components/dataBrowser/EditPermissionsForm'),
{
ssr: false,
loading: () => <FormActivityIndicator />,
},
);
export interface DataBrowserSidebarProps extends Omit<BoxProps, 'children'> {
/**
* Function to be called when a sidebar item is clicked.
@@ -200,7 +226,7 @@ function DataBrowserSidebarContent({
table: string,
disabled?: boolean,
) {
openDrawer('EDIT_PERMISSIONS', {
openDrawer({
title: (
<span className="inline-grid grid-flow-col items-center gap-2">
Permissions
@@ -208,22 +234,18 @@ function DataBrowserSidebarContent({
<Chip label="Preview" size="small" color="info" component="span" />
</span>
),
component: (
<EditPermissionsForm
disabled={disabled}
schema={schema}
table={table}
/>
),
props: {
PaperProps: {
className: 'lg:w-[65%] lg:max-w-7xl',
},
},
payload: {
onSubmit: async () => {
await queryClient.refetchQueries([
`${dataSourceSlug}.${schema}.${table}`,
]);
await refetch();
},
disabled,
schema,
table,
},
});
}
@@ -296,9 +318,11 @@ function DataBrowserSidebarContent({
endIcon={<PlusIcon />}
className="mt-1 w-full justify-between px-2"
onClick={() => {
openDrawer('CREATE_TABLE', {
openDrawer({
title: 'Create a New Table',
payload: { onSubmit: refetch, schema: selectedSchema },
component: (
<CreateTableForm onSubmit={refetch} schema={selectedSchema} />
),
});
onSidebarItemClick();
@@ -328,69 +352,68 @@ function DataBrowserSidebarContent({
className="group"
key={tablePath}
secondaryAction={
!isSelectedSchemaLocked && (
<Dropdown.Root
id="table-management-menu"
onOpen={() => setSidebarMenuTable(tablePath)}
onClose={() => setSidebarMenuTable(undefined)}
<Dropdown.Root
id="table-management-menu"
onOpen={() => setSidebarMenuTable(tablePath)}
onClose={() => setSidebarMenuTable(undefined)}
>
<Dropdown.Trigger
asChild
hideChevron
disabled={tablePath === removableTable}
>
<Dropdown.Trigger
asChild
hideChevron
disabled={tablePath === removableTable}
<IconButton
variant="borderless"
color={isSelected ? 'primary' : 'secondary'}
className={twMerge(
!isSelected &&
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
)}
>
<IconButton
variant="borderless"
color={isSelected ? 'primary' : 'secondary'}
className={twMerge(
!isSelected &&
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
)}
<DotsHorizontalIcon />
</IconButton>
</Dropdown.Trigger>
<Dropdown.Content menu PaperProps={{ className: 'w-52' }}>
{isGitHubConnected ? (
<Dropdown.Item
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
handleEditPermissionClick(
table.table_schema,
table.table_name,
true,
)
}
>
<DotsHorizontalIcon />
</IconButton>
</Dropdown.Trigger>
<UsersIcon
className="h-4 w-4"
sx={{ color: 'text.secondary' }}
/>
<Dropdown.Content
menu
PaperProps={{ className: 'w-52' }}
>
{isGitHubConnected ? (
<Dropdown.Item
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
handleEditPermissionClick(
table.table_schema,
table.table_name,
true,
)
}
>
<UsersIcon
className="h-4 w-4"
sx={{ color: 'text.secondary' }}
/>
<span>View Permissions</span>
</Dropdown.Item>
) : (
[
<span>View Permissions</span>
</Dropdown.Item>
) : (
[
!isSelectedSchemaLocked && (
<Dropdown.Item
key="edit-table"
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
openDrawer('EDIT_TABLE', {
openDrawer({
title: 'Edit Table',
payload: {
onSubmit: async () => {
await queryClient.refetchQueries([
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
]);
await refetch();
},
schema: table.table_schema,
table,
},
component: (
<EditTableForm
onSubmit={async () => {
await queryClient.refetchQueries([
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
]);
await refetch();
}}
schema={table.table_schema}
table={table}
/>
),
})
}
>
@@ -400,32 +423,38 @@ function DataBrowserSidebarContent({
/>
<span>Edit Table</span>
</Dropdown.Item>,
</Dropdown.Item>
),
!isSelectedSchemaLocked && (
<Divider
key="edit-table-separator"
component="li"
/>,
<Dropdown.Item
key="edit-permissions"
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
handleEditPermissionClick(
table.table_schema,
table.table_name,
)
}
>
<UsersIcon
className="h-4 w-4"
sx={{ color: 'text.secondary' }}
/>
/>
),
<Dropdown.Item
key="edit-permissions"
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
handleEditPermissionClick(
table.table_schema,
table.table_name,
)
}
>
<UsersIcon
className="h-4 w-4"
sx={{ color: 'text.secondary' }}
/>
<span>Edit Permissions</span>
</Dropdown.Item>,
<span>Edit Permissions</span>
</Dropdown.Item>,
!isSelectedSchemaLocked && (
<Divider
key="edit-permissions-separator"
component="li"
/>,
/>
),
!isSelectedSchemaLocked && (
<Dropdown.Item
key="delete-table"
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
@@ -443,12 +472,12 @@ function DataBrowserSidebarContent({
/>
<span>Delete Table</span>
</Dropdown.Item>,
]
)}
</Dropdown.Content>
</Dropdown.Root>
)
</Dropdown.Item>
),
]
)}
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Button
@@ -518,7 +547,7 @@ export default function DataBrowserSidebar({
document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed);
}, []);
if (isPlatform && !currentApplication?.hasuraGraphqlAdminSecret) {
if (isPlatform && !currentApplication?.config?.hasura.adminSecret) {
return null;
}

View File

@@ -18,7 +18,7 @@ import { useRouter } from 'next/router';
import { FormProvider, useForm } from 'react-hook-form';
export interface EditColumnFormProps
extends Pick<BaseColumnFormProps, 'onCancel'> {
extends Pick<BaseColumnFormProps, 'onCancel' | 'location'> {
/**
* Column to be edited.
*/

View File

@@ -14,7 +14,10 @@ import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
export interface EditForeignKeyFormProps
extends Pick<BaseForeignKeyFormProps, 'onCancel' | 'availableColumns'> {
extends Pick<
BaseForeignKeyFormProps,
'onCancel' | 'availableColumns' | 'location'
> {
/**
* Foreign key relation to be edited.
*/
@@ -26,7 +29,7 @@ export interface EditForeignKeyFormProps
/**
* Function to be called when the form is submitted.
*/
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void>;
onSubmit?: (values: BaseForeignKeyFormValues) => Promise<void> | void;
}
export default function EditForeignKeyForm({
@@ -57,9 +60,7 @@ export default function EditForeignKeyForm({
setError(undefined);
try {
if (onSubmit) {
await onSubmit(values);
}
await onSubmit?.(values);
} catch (submitError) {
if (submitError && submitError instanceof Error) {
setError(submitError);

View File

@@ -3,6 +3,7 @@ import useMetadataQuery from '@/hooks/dataBrowser/useMetadataQuery';
import useTableQuery from '@/hooks/dataBrowser/useTableQuery';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
import type { DialogFormProps } from '@/types/common';
import type {
DatabaseAccessLevel,
DatabaseAction,
@@ -30,7 +31,7 @@ import { twMerge } from 'tailwind-merge';
import RolePermissionEditorForm from './RolePermissionEditorForm';
import RolePermissionsRow from './RolePermissionsRow';
export interface EditPermissionsFormProps {
export interface EditPermissionsFormProps extends DialogFormProps {
/**
* Determines whether the form is disabled or not.
*/
@@ -54,6 +55,7 @@ export default function EditPermissionsForm({
schema,
table,
onCancel,
location,
}: EditPermissionsFormProps) {
const [role, setRole] = useState<string>();
const [action, setAction] = useState<DatabaseAction>();
@@ -181,6 +183,7 @@ export default function EditPermissionsForm({
return (
<RolePermissionEditorForm
location={location}
resourceVersion={metadata?.resourceVersion}
disabled={disabled}
schema={schema}

View File

@@ -2,6 +2,7 @@ import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import HighlightedText from '@/components/common/HighlightedText';
import useManagePermissionMutation from '@/hooks/dataBrowser/useManagePermissionMutation';
import type { DialogFormProps } from '@/types/common';
import type {
DatabaseAction,
HasuraMetadataPermission,
@@ -13,6 +14,7 @@ import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text';
import convertToHasuraPermissions from '@/utils/dataBrowser/convertToHasuraPermissions';
import convertToRuleGroup from '@/utils/dataBrowser/convertToRuleGroup';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useQueryClient } from '@tanstack/react-query';
@@ -72,7 +74,7 @@ export interface RolePermissionEditorFormValues {
computedFields?: string[];
}
export interface RolePermissionEditorFormProps {
export interface RolePermissionEditorFormProps extends DialogFormProps {
/**
* Determines whether or not the form is disabled.
*/
@@ -169,6 +171,7 @@ export default function RolePermissionEditorForm({
onCancel,
permission,
disabled,
location,
}: RolePermissionEditorFormProps) {
const queryClient = useQueryClient();
const {
@@ -214,8 +217,8 @@ export default function RolePermissionEditorForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'drawer');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
async function handleSubmit(values: RolePermissionEditorFormValues) {
const managePermissionPromise = managePermission({
@@ -245,7 +248,7 @@ export default function RolePermissionEditorForm({
: permission?.check,
backend_only: values.backendOnly,
computed_fields:
permission?.computed_fields.length > 0
permission?.computed_fields?.length > 0
? permission?.computed_fields
: null,
},
@@ -256,12 +259,12 @@ export default function RolePermissionEditorForm({
{
loading: 'Saving permission...',
success: 'Permission has been saved successfully.',
error: 'An error occurred while saving the permission.',
error: getServerError('An error occurred while saving the permission.'),
},
getToastStyleProps(),
);
onDirtyStateChange(false, 'drawer');
onDirtyStateChange(false, location);
onSubmit?.();
}
@@ -270,7 +273,7 @@ export default function RolePermissionEditorForm({
openDirtyConfirmation({
props: {
onPrimaryAction: () => {
onDirtyStateChange(false, 'drawer');
onDirtyStateChange(false, location);
onCancel?.();
},
},
@@ -295,12 +298,14 @@ export default function RolePermissionEditorForm({
{
loading: 'Deleting permission...',
success: 'Permission has been deleted successfully.',
error: 'An error occurred while deleting the permission.',
error: getServerError(
'An error occurred while deleting the permission.',
),
},
getToastStyleProps(),
);
onDirtyStateChange(false, 'drawer');
onDirtyStateChange(false, location);
onSubmit?.();
}

View File

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

View File

@@ -6,6 +6,7 @@ import Input from '@/ui/v2/Input';
import Radio from '@/ui/v2/Radio';
import RadioGroup from '@/ui/v2/RadioGroup';
import Text from '@/ui/v2/Text';
import type { FocusEvent } from 'react';
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import PermissionSettingsSection from './PermissionSettingsSection';
@@ -130,7 +131,13 @@ export default function RowPermissionsSection({
{action === 'select' && (
<Input
{...register('limit')}
{...register('limit', {
onBlur: (event: FocusEvent<HTMLInputElement>) => {
if (!event.target.value) {
setValue('limit', null);
}
},
})}
disabled={disabled}
id="limit"
type="number"

View File

@@ -43,7 +43,10 @@ const baseValidationSchema = Yup.object().shape({
});
const selectValidationSchema = baseValidationSchema.shape({
limit: Yup.number().min(0, 'Limit must not be negative.').nullable(true),
limit: Yup.number()
.label('Limit')
.min(0, 'Limit must not be negative.')
.nullable(true),
allowAggregations: Yup.boolean().nullable(true),
queryRootFields: Yup.array().of(Yup.string()).nullable(true),
subscriptionRootFields: Yup.array().of(Yup.string()).nullable(true),

View File

@@ -23,7 +23,7 @@ import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
export interface EditTableFormProps
extends Pick<BaseTableFormProps, 'onCancel'> {
extends Pick<BaseTableFormProps, 'onCancel' | 'location'> {
/**
* Schema where the table is located.
*/

View File

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

View File

@@ -6,11 +6,12 @@ import ColumnAutocomplete from '@/components/dataBrowser/ColumnAutocomplete';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { HasuraOperator } from '@/types/dataBrowser';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import type { AutocompleteOption } from '@/ui/v2/Autocomplete';
import type { InputProps } from '@/ui/v2/Input';
import { inputClasses } from '@/ui/v2/Input';
import Option from '@/ui/v2/Option';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
import { useController, useFormContext, useWatch } from 'react-hook-form';
import useRuleGroupEditor from './useRuleGroupEditor';
@@ -116,8 +117,8 @@ export default function RuleValueInput({
data,
loading,
error: customClaimsError,
} = useGetAppCustomClaimsQuery({
variables: { id: currentApplication?.id },
} = useGetRolesPermissionsQuery({
variables: { appId: currentApplication?.id },
skip: !isHasuraInput || !currentApplication?.id,
});
@@ -199,8 +200,8 @@ export default function RuleValueInput({
);
}
const availableHasuraPermissionVariables = getPermissionVariablesArray(
data?.app?.authJwtCustomClaims,
const availableHasuraPermissionVariables = getAllPermissionVariables(
data?.config?.auth?.session?.accessToken?.customClaims,
).map(({ key }) => ({
value: `X-Hasura-${key}`,
label: `X-Hasura-${key}`,
@@ -211,12 +212,13 @@ export default function RuleValueInput({
<ControlledAutocomplete
disabled={disabled}
freeSolo={!isHasuraInput}
autoSelect={!isHasuraInput}
autoHighlight={isHasuraInput}
open
isOptionEqualToValue={(option, value) => {
if (typeof value === 'string') {
return option.value.toLowerCase() === (value as string).toLowerCase();
isOptionEqualToValue={(
option,
value: string | number | AutocompleteOption<string>,
) => {
if (typeof value !== 'object') {
return option.value.toLowerCase() === value?.toString().toLowerCase();
}
return option.value.toLowerCase() === value.value.toLowerCase();

View File

@@ -10,6 +10,7 @@ import ArrowCounterclockwiseIcon from '@/ui/v2/icons/ArrowCounterclockwiseIcon';
import ChevronRightIcon from '@/ui/v2/icons/ChevronRightIcon';
import { ListItem } from '@/ui/v2/ListItem';
import Tooltip from '@/ui/v2/Tooltip';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
import { useInsertDeploymentMutation } from '@/utils/__generated__/graphql';
@@ -122,7 +123,9 @@ export default function DeploymentListItem({
{
loading: 'Scheduling deployment...',
success: 'Deployment has been scheduled successfully.',
error: 'An error occurred when scheduling deployment.',
error: getServerError(
'An error occurred when scheduling deployment.',
),
},
getToastStyleProps(),
);

View File

@@ -262,7 +262,7 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
.setAdminSecret(
process.env.NEXT_PUBLIC_ENV === 'dev'
? 'nhost-admin-secret'
: currentApplication.hasuraGraphqlAdminSecret,
: currentApplication.config?.hasura.adminSecret,
)
.upload({
file,
@@ -277,7 +277,7 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
}
if (fileError) {
throw fileError;
throw new Error(fileError.message);
}
triggerToast(`File has been uploaded successfully (${fileMetadata?.id})`);

View File

@@ -72,7 +72,7 @@ export default function FilesDataGridControls({
const storageWithAdminSecret = appClient.storage.setAdminSecret(
process.env.NEXT_PUBLIC_ENV === 'dev'
? 'nhost-admin-secret'
: currentApplication.hasuraGraphqlAdminSecret,
: currentApplication.config?.hasura.adminSecret,
);
// note: this is not an optimal solution, but we don't have a better way
@@ -120,7 +120,7 @@ export default function FilesDataGridControls({
{...props}
>
{numberOfSelectedFiles > 0 ? (
<div className="mx-auto h-[40px] grid grid-flow-col justify-start items-center gap-2">
<div className="mx-auto grid h-[40px] grid-flow-col items-center justify-start gap-2">
<Chip
color="info"
size="small"

View File

@@ -1,7 +1,10 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { DialogFormProps } from '@/types/common';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import { slugifyString } from '@/utils/helpers';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
refetchGetOneUserQuery,
@@ -11,11 +14,12 @@ import {
import { yupResolver } from '@hookform/resolvers/yup';
import { useUserData } from '@nhost/nextjs';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface EditWorkspaceNameFormProps {
export interface EditWorkspaceNameFormProps extends DialogFormProps {
/**
* The current workspace name if this is an edit operation.
*/
@@ -44,14 +48,7 @@ export interface EditWorkspaceNameFormProps {
onCancel?: VoidFunction;
}
export interface EditWorkspaceNameFormValues {
/**
* New workspace name.
*/
newWorkspaceName: string;
}
const validationSchema = Yup.object().shape({
const validationSchema = Yup.object({
newWorkspaceName: Yup.string()
.required('Workspace name is required.')
.min(4, 'The new Workspace name must be at least 4 characters.')
@@ -71,14 +68,20 @@ const validationSchema = Yup.object().shape({
),
});
export default function EditWorkspaceName({
export type EditWorkspaceNameFormValues = Yup.InferType<
typeof validationSchema
>;
export default function EditWorkspaceNameForm({
disabled,
onSubmit,
onCancel,
currentWorkspaceName,
currentWorkspaceId,
submitButtonText = 'Create',
location,
}: EditWorkspaceNameFormProps) {
const { onDirtyStateChange } = useDialog();
const currentUser = useUserData();
const [insertWorkspace, { client }] = useInsertWorkspaceMutation();
const [updateWorkspaceName] = useUpdateWorkspaceMutation({
@@ -105,6 +108,10 @@ export default function EditWorkspaceName({
} = form;
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
async function handleSubmit({
newWorkspaceName,
}: EditWorkspaceNameFormValues) {
@@ -112,6 +119,8 @@ export default function EditWorkspaceName({
try {
if (currentWorkspaceId) {
onDirtyStateChange(false, location);
// In this bit of code we spread the props of the current path (e.g. /workspace/...) and add one key-value pair: `mutating: true`.
// We want to indicate that the currently we're in the process of running a mutation state that will affect the routing behaviour of the website
// i.e. redirecting to 404 if there's no workspace/project with that slug.
@@ -133,7 +142,9 @@ export default function EditWorkspaceName({
{
loading: 'Updating workspace name...',
success: 'Workspace name has been updated successfully.',
error: 'An error occurred while updating the workspace name.',
error: getServerError(
'An error occurred while updating the workspace name.',
),
},
getToastStyleProps(),
);
@@ -160,7 +171,9 @@ export default function EditWorkspaceName({
{
loading: 'Creating new workspace...',
success: 'The new workspace has been created successfully.',
error: 'An error occurred while creating the new workspace.',
error: getServerError(
'An error occurred while creating the new workspace.',
),
},
getToastStyleProps(),
);
@@ -186,6 +199,9 @@ export default function EditWorkspaceName({
include: ['getOneUser'],
});
// The form has been submitted, it's not dirty anymore
onDirtyStateChange(false, location);
await router.push(slug);
onSubmit?.();
}
@@ -194,9 +210,9 @@ export default function EditWorkspaceName({
<FormProvider {...form}>
<Form
onSubmit={handleSubmit}
className="flex flex-col content-between flex-auto pt-2 pb-6 overflow-hidden"
className="flex flex-auto flex-col content-between overflow-hidden pt-2 pb-6"
>
<div className="flex-auto px-6 overflow-y-auto">
<div className="flex-auto overflow-y-auto px-6">
<Input
{...register('newWorkspaceName')}
error={Boolean(errors.newWorkspaceName?.message)}

View File

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

View File

@@ -58,16 +58,19 @@ export function InviteAnnounce() {
error: null,
loading: true,
});
const res = await nhost.functions.call('/accept-workspace-invite', {
workspaceMemberInviteId: invite.id,
isAccepted: true,
});
const { res, error: acceptError } = await nhost.functions.call(
'/accept-workspace-invite',
{
workspaceMemberInviteId: invite.id,
isAccepted: true,
},
);
if (res?.res?.status !== 200) {
if (res?.status !== 200) {
triggerToast('An error occurred when trying to accept the invitation.');
return setSubmitState({
error: res.error,
error: new Error(acceptError.message),
loading: false,
});
}
@@ -90,7 +93,7 @@ export function InviteAnnounce() {
error: null,
});
const res = await nhost.functions.call(
const { error: ignoreError } = await nhost.functions.call(
'/accept-workspace-invite',
{
workspaceMemberInviteId: inviteId,
@@ -99,12 +102,12 @@ export function InviteAnnounce() {
{ useAxios: false },
);
if (res?.error) {
if (ignoreError) {
triggerToast('An error occurred when trying to ignore the invitation.');
setIgnoreState({
loading: false,
error: new Error(res.error.message),
error: new Error(ignoreError.message),
});
return;

View File

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

View File

@@ -1,6 +1,7 @@
import useGitHubModal from '@/components/applications/github/useGitHubModal';
import DeploymentListItem from '@/components/deployments/DeploymentListItem';
import GithubIcon from '@/components/icons/GithubIcon';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Box from '@/ui/v2/Box';
@@ -146,6 +147,7 @@ function OverviewDeploymentList() {
export default function OverviewDeployments() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openGitHubModal } = useGitHubModal();
const { maintenanceActive } = useUI();
const { githubRepository } = currentApplication || {};
@@ -183,6 +185,7 @@ export default function OverviewDeployments() {
color="primary"
className="w-full"
onClick={openGitHubModal}
disabled={maintenanceActive}
>
<GithubIcon className="mr-1.5 h-4 w-4 self-center" />
Connect to GitHub

View File

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

View File

@@ -1,6 +1,7 @@
import { ChangePlanModal } from '@/components/applications/ChangePlanModal';
import { useDialog } from '@/components/common/DialogProvider';
import { useUI } from '@/context/UIContext';
import useIsPlatform from '@/hooks/common/useIsPlatform';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import Button from '@/ui/v2/Button';
@@ -16,6 +17,7 @@ export default function OverviewTopBar() {
useCurrentWorkspaceAndApplication();
const isPro = !currentApplication?.plan?.isFree;
const { openAlertDialog } = useDialog();
const { maintenanceActive } = useUI();
if (!isPlatform) {
return (
@@ -104,6 +106,7 @@ export default function OverviewTopBar() {
endIcon={<CogIcon className="h-4 w-4" />}
variant="outlined"
color="secondary"
disabled={maintenanceActive}
>
Settings
</Button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { DialogFormProps } from '@/types/common';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
@@ -7,26 +8,7 @@ import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BaseEnvironmentVariableFormValues {
/**
* Identifier of the environment variable.
*/
id: string;
/**
* The name of the role.
*/
name: string;
/**
* Development environment variable value.
*/
devValue: string;
/**
* Production environment variable value.
*/
prodValue: string;
}
export interface BaseEnvironmentVariableFormProps {
export interface BaseEnvironmentVariableFormProps extends DialogFormProps {
/**
* Determines the mode of the form.
*
@@ -50,8 +32,11 @@ export interface BaseEnvironmentVariableFormProps {
}
export const baseEnvironmentVariableFormValidationSchema = Yup.object({
id: Yup.string().label('ID'),
name: Yup.string()
.required('This field is required.')
.label('Name')
.nullable()
.required()
.test(
'isEnvVarPermitted',
'This is a reserved name.',
@@ -77,18 +62,24 @@ export const baseEnvironmentVariableFormValidationSchema = Yup.object({
(prefix) => !value.startsWith(prefix),
),
)
.test('isEnvVarValid', `The name must start with a letter.`, (value) =>
/^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
.test(
'isEnvVarValid',
'A name must start with a letter and can only contain letters, numbers, and underscores.',
(value) => /^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/i.test(value),
),
devValue: Yup.string().required('This field is required.'),
prodValue: Yup.string().required('This field is required.'),
value: Yup.string().label('Value').nullable().required(),
});
export type BaseEnvironmentVariableFormValues = Yup.InferType<
typeof baseEnvironmentVariableFormValidationSchema
>;
export default function BaseEnvironmentVariableForm({
mode = 'edit',
onSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BaseEnvironmentVariableFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BaseEnvironmentVariableFormValues>();
@@ -103,8 +94,8 @@ export default function BaseEnvironmentVariableForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-6 px-6 pb-6">
@@ -115,21 +106,7 @@ export default function BaseEnvironmentVariableForm({
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input
{...register('name', {
onChange: (event) => {
if (
event.target.value &&
!/^[a-zA-Z]{1,}[a-zA-Z0-9_]*$/g.test(event.target.value)
) {
// we need to prevent invalid characters from being entered
// eslint-disable-next-line no-param-reassign
event.target.value = event.target.value.replace(
/[^a-zA-Z0-9_]/g,
'',
);
}
},
})}
{...register('name')}
id="name"
label="Name"
placeholder="EXAMPLE_NAME"
@@ -143,30 +120,18 @@ export default function BaseEnvironmentVariableForm({
/>
<Input
{...register('prodValue')}
id="prodValue"
label="Production Value"
{...register('value')}
id="value"
label="Value"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.prodValue}
helperText={errors?.prodValue?.message}
error={!!errors.value}
helperText={errors?.value?.message}
fullWidth
autoComplete="off"
autoFocus={mode === 'edit'}
/>
<Input
{...register('devValue')}
id="devValue"
label="Development Value"
placeholder="Enter value"
hideEmptyHelperText
error={!!errors.devValue}
helperText={errors?.devValue?.message}
fullWidth
autoComplete="off"
/>
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
{submitButtonText}

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { DialogFormProps } from '@/types/common';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
refetchGetAppInjectedVariablesQuery,
useUpdateApplicationMutation,
GetEnvironmentVariablesDocument,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { useEffect } from 'react';
@@ -14,7 +15,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface EditJwtSecretFormProps {
export interface EditJwtSecretFormProps extends DialogFormProps {
/**
* Initial JWT secret.
*/
@@ -39,14 +40,7 @@ export interface EditJwtSecretFormProps {
onCancel?: VoidFunction;
}
export interface EditJwtSecretFormValues {
/**
* JWT secret.
*/
jwtSecret: string;
}
const validationSchema = Yup.object().shape({
const validationSchema = Yup.object({
jwtSecret: Yup.string()
.nullable()
.required('This field is required.')
@@ -60,18 +54,19 @@ const validationSchema = Yup.object().shape({
}),
});
export type EditJwtSecretFormValues = Yup.InferType<typeof validationSchema>;
export default function EditJwtSecretForm({
disabled,
jwtSecret,
onSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: EditJwtSecretFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApplication] = useUpdateApplicationMutation({
refetchQueries: [
refetchGetAppInjectedVariablesQuery({ id: currentApplication?.id }),
],
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetEnvironmentVariablesDocument],
});
const { onDirtyStateChange } = useDialog();
@@ -89,30 +84,42 @@ export default function EditJwtSecretForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
async function handleSubmit(values: EditJwtSecretFormValues) {
const updateAppPromise = updateApplication({
const parsedJwtSecret = JSON.parse(values.jwtSecret);
const isArray = Array.isArray(parsedJwtSecret);
const updateConfigPromise = updateConfig({
variables: {
appId: currentApplication?.id,
app: {
hasuraGraphqlJwtSecret: values.jwtSecret,
config: {
hasura: {
jwtSecrets: isArray ? parsedJwtSecret : [parsedJwtSecret],
},
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating JWT secret...',
success: 'JWT secret has been updated successfully.',
error: 'An error occurred while updating the JWT secret.',
},
getToastStyleProps(),
);
try {
await toast.promise(
updateConfigPromise,
{
loading: 'Updating JWT secret...',
success: 'JWT secret has been updated successfully.',
error: (arg: Error) =>
arg?.message
? `Error: ${arg.message}`
: 'An error occurred while updating the JWT secret.',
},
getToastStyleProps(),
);
onSubmit?.();
onSubmit?.();
} catch {
// Note: error is handled above
}
}
return (
@@ -121,7 +128,7 @@ export default function EditJwtSecretForm({
onSubmit={handleSubmit}
className="flex flex-auto flex-col content-between overflow-hidden pb-4"
>
<div className="px-6 overflow-y-auto flex-auto">
<div className="flex-auto overflow-y-auto px-6">
<Input
{...register('jwtSecret')}
error={Boolean(errors.jwtSecret?.message)}

View File

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

View File

@@ -1,6 +1,8 @@
import { useDialog } from '@/components/common/DialogProvider';
import InlineCode from '@/components/common/InlineCode';
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import useIsPlatform from '@/hooks/common/useIsPlatform';
import { useAppClient } from '@/hooks/useAppClient';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
@@ -20,7 +22,8 @@ import generateAppServiceUrl, {
} from '@/utils/common/generateAppServiceUrl';
import { LOCAL_HASURA_URL } from '@/utils/env';
import { generateRemoteAppUrl } from '@/utils/helpers';
import { useGetAppInjectedVariablesQuery } from '@/utils/__generated__/graphql';
import getJwtSecretsWithoutFalsyValues from '@/utils/settings/getJwtSecretsWithoutFalsyValues';
import { useGetEnvironmentVariablesQuery } from '@/utils/__generated__/graphql';
import { Fragment, useState } from 'react';
export default function SystemEnvironmentVariableSettings() {
@@ -28,10 +31,22 @@ export default function SystemEnvironmentVariableSettings() {
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const { openDialog } = useDialog();
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetAppInjectedVariablesQuery({
variables: { id: currentApplication?.id },
const { data, loading, error } = useGetEnvironmentVariablesQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { jwtSecrets, webhookSecret, adminSecret } = data?.config?.hasura || {};
const jwtSecretsWithoutFalsyValues = getJwtSecretsWithoutFalsyValues(
jwtSecrets || [],
);
const stringifiedJwtSecrets =
jwtSecretsWithoutFalsyValues.length === 1
? JSON.stringify(jwtSecretsWithoutFalsyValues[0], null, 2)
: JSON.stringify(jwtSecretsWithoutFalsyValues, null, 2);
const isPlatform = useIsPlatform();
const appClient = useAppClient();
@@ -50,7 +65,7 @@ export default function SystemEnvironmentVariableSettings() {
}
function showViewJwtSecretModal() {
openDialog('EDIT_JWT_SECRET', {
openDialog({
title: (
<span className="grid grid-flow-row">
<span>Auth JWT Secret</span>
@@ -61,15 +76,14 @@ export default function SystemEnvironmentVariableSettings() {
</Text>
</span>
),
payload: {
disabled: true,
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
},
component: (
<EditJwtSecretForm disabled jwtSecret={stringifiedJwtSecrets} />
),
});
}
function showEditJwtSecretModal() {
openDialog('EDIT_JWT_SECRET', {
openDialog({
title: (
<span className="grid grid-flow-row">
<span>Edit JWT Secret</span>
@@ -80,9 +94,7 @@ export default function SystemEnvironmentVariableSettings() {
</Text>
</span>
),
payload: {
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
},
component: <EditJwtSecretForm jwtSecret={stringifiedJwtSecrets} />,
});
}
@@ -107,7 +119,7 @@ export default function SystemEnvironmentVariableSettings() {
),
},
{ key: 'NHOST_AUTH_URL', value: appClient.auth.url },
{ key: 'NHOST_GRAPHQL_URL', value: appClient.graphql.url },
{ key: 'NHOST_GRAPHQL_URL', value: appClient.graphql.httpUrl },
{ key: 'NHOST_STORAGE_URL', value: appClient.storage.url },
{ key: 'NHOST_FUNCTIONS_URL', value: appClient.functions.url },
];
@@ -134,7 +146,7 @@ export default function SystemEnvironmentVariableSettings() {
<Text className="truncate" color="secondary">
{showAdminSecret ? (
<InlineCode className="!text-sm font-medium">
{currentApplication?.hasuraGraphqlAdminSecret}
{adminSecret}
</InlineCode>
) : (
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
@@ -167,7 +179,7 @@ export default function SystemEnvironmentVariableSettings() {
<Text className="truncate" color="secondary">
{showWebhookSecret ? (
<InlineCode className="!text-sm font-medium">
{data?.app?.webhookSecret}
{webhookSecret}
</InlineCode>
) : (
'●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●'
@@ -231,6 +243,7 @@ export default function SystemEnvironmentVariableSettings() {
variant="borderless"
onClick={showEditJwtSecretModal}
size="small"
disabled={maintenanceActive}
>
Edit JWT Secret
</Button>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { DialogFormProps } from '@/types/common';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
@@ -7,18 +8,7 @@ import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BasePermissionVariableFormValues {
/**
* Permission variable key.
*/
key: string;
/**
* Permission variable value.
*/
value: string;
}
export interface BasePermissionVariableFormProps {
export interface BasePermissionVariableFormProps extends DialogFormProps {
/**
* Function to be called when the form is submitted.
*/
@@ -36,14 +26,19 @@ export interface BasePermissionVariableFormProps {
}
export const basePermissionVariableValidationSchema = Yup.object({
key: Yup.string().required('This field is required.'),
value: Yup.string().required('This field is required.'),
key: Yup.string().label('Field Name').nullable().required(),
value: Yup.string().label('Path').nullable().required(),
});
export type BasePermissionVariableFormValues = Yup.InferType<
typeof basePermissionVariableValidationSchema
>;
export default function BasePermissionVariableForm({
onSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BasePermissionVariableFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BasePermissionVariableFormValues>();
@@ -56,8 +51,8 @@ export default function BasePermissionVariableForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-2 px-6 pb-6">

View File

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

View File

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

View File

@@ -1,7 +1,10 @@
import { useDialog } from '@/components/common/DialogProvider';
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
import SettingsContainer from '@/components/settings/SettingsContainer';
import { useUI } from '@/context/UIContext';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { CustomClaim } from '@/types/application';
import type { PermissionVariable } from '@/types/application';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button';
@@ -15,35 +18,33 @@ import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import Tooltip from '@/ui/v2/Tooltip';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
import getAllPermissionVariables from '@/utils/settings/getAllPermissionVariables';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetAppCustomClaimsQuery,
useUpdateAppMutation,
GetRolesPermissionsDocument,
useGetRolesPermissionsQuery,
useUpdateConfigMutation,
} from '@/utils/__generated__/graphql';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface PermissionVariableSettingsFormValues {
/**
* Permission variables.
*/
authJwtCustomClaims: CustomClaim[];
}
export default function PermissionVariableSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, openAlertDialog } = useDialog();
const { data, loading, error } = useGetAppCustomClaimsQuery({
variables: {
id: currentApplication?.id,
},
const { data, loading, error } = useGetRolesPermissionsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const [updateApp] = useUpdateAppMutation({
refetchQueries: ['getAppCustomClaims'],
const { customClaims: permissionVariables } =
data?.config?.auth?.session?.accessToken || {};
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetRolesPermissionsDocument],
});
if (loading) {
@@ -56,40 +57,44 @@ export default function PermissionVariableSettings() {
throw error;
}
async function handleDeleteVariable({ key }: CustomClaim) {
const filteredCustomClaims = Object.keys(
data?.app?.authJwtCustomClaims,
).filter((customClaimKey) => customClaimKey !== key);
const updateAppPromise = updateApp({
async function handleDeleteVariable({ id }: PermissionVariable) {
const updateConfigPromise = updateConfig({
variables: {
id: currentApplication?.id,
app: {
authJwtCustomClaims: filteredCustomClaims.reduce(
(customClaims, currentKey) => ({
...customClaims,
[currentKey]: data?.app?.authJwtCustomClaims[currentKey],
}),
{},
),
appId: currentApplication?.id,
config: {
auth: {
session: {
accessToken: {
customClaims: permissionVariables
?.filter((permissionVariable) => permissionVariable.id !== id)
.map((permissionVariable) => ({
key: permissionVariable.key,
value: permissionVariable.value,
})),
},
},
},
},
},
});
await toast.promise(
updateAppPromise,
updateConfigPromise,
{
loading: 'Deleting permission variable...',
success: 'Permission variable has been deleted successfully.',
error: 'An error occurred while trying to delete permission variable.',
error: getServerError(
'An error occurred while trying to delete permission variable.',
),
},
getToastStyleProps(),
);
}
function handleOpenCreator() {
openDialog('CREATE_PERMISSION_VARIABLE', {
openDialog({
title: 'Create Permission Variable',
component: <CreatePermissionVariableForm />,
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
@@ -97,10 +102,12 @@ export default function PermissionVariableSettings() {
});
}
function handleOpenEditor(originalVariable: CustomClaim) {
openDialog('EDIT_PERMISSION_VARIABLE', {
function handleOpenEditor(originalVariable: PermissionVariable) {
openDialog({
title: 'Edit Permission Variable',
payload: { originalVariable },
component: (
<EditPermissionVariableForm originalVariable={originalVariable} />
),
props: {
titleProps: { className: '!pb-0' },
PaperProps: { className: 'max-w-sm' },
@@ -108,7 +115,7 @@ export default function PermissionVariableSettings() {
});
}
function handleConfirmDelete(originalVariable: CustomClaim) {
function handleConfirmDelete(originalVariable: PermissionVariable) {
openAlertDialog({
title: 'Delete Permission Variable',
payload: (
@@ -126,9 +133,8 @@ export default function PermissionVariableSettings() {
});
}
const availablePermissionVariables = getPermissionVariablesArray(
data?.app?.authJwtCustomClaims,
);
const availablePermissionVariables =
getAllPermissionVariables(permissionVariables);
return (
<SettingsContainer
@@ -136,7 +142,7 @@ export default function PermissionVariableSettings() {
description="Permission variables are used to define permission rules in the GraphQL API."
docsLink="https://docs.nhost.io/graphql/permissions"
rootClassName="gap-0"
className="px-0 my-2"
className="my-2 px-0"
slotProps={{ submitButton: { className: 'invisible' } }}
>
<Box className="grid grid-cols-2 border-b-1 px-4 py-3">
@@ -146,28 +152,33 @@ export default function PermissionVariableSettings() {
<div className="grid grid-flow-row gap-2">
<List>
{availablePermissionVariables.map((customClaim, index) => (
<Fragment key={customClaim.key}>
{availablePermissionVariables.map((permissionVariable, index) => (
<Fragment key={permissionVariable.id}>
<ListItem.Root
className="px-4 grid grid-cols-2"
className="grid grid-cols-2 px-4"
secondaryAction={
<Dropdown.Root>
<Tooltip
title={
customClaim.isSystemClaim
permissionVariable.isSystemVariable
? "You can't edit system permission variables"
: ''
}
placement="right"
disableHoverListener={!customClaim.isSystemClaim}
hasDisabledChildren={customClaim.isSystemClaim}
disableHoverListener={
!permissionVariable.isSystemVariable
}
hasDisabledChildren={permissionVariable.isSystemVariable}
className="absolute right-4 top-1/2 -translate-y-1/2"
>
<Dropdown.Trigger asChild hideChevron>
<IconButton
variant="borderless"
color="secondary"
disabled={customClaim.isSystemClaim}
disabled={
permissionVariable.isSystemVariable ||
maintenanceActive
}
>
<DotsVerticalIcon />
</IconButton>
@@ -187,7 +198,7 @@ export default function PermissionVariableSettings() {
}}
>
<Dropdown.Item
onClick={() => handleOpenEditor(customClaim)}
onClick={() => handleOpenEditor(permissionVariable)}
>
<Text className="font-medium">Edit</Text>
</Dropdown.Item>
@@ -195,7 +206,7 @@ export default function PermissionVariableSettings() {
<Divider component="li" />
<Dropdown.Item
onClick={() => handleConfirmDelete(customClaim)}
onClick={() => handleConfirmDelete(permissionVariable)}
>
<Text
className="font-medium"
@@ -213,15 +224,17 @@ export default function PermissionVariableSettings() {
<ListItem.Text
primary={
<>
X-Hasura-{customClaim.key}{' '}
{customClaim.isSystemClaim && (
<LockIcon className="w-4 h-4" />
X-Hasura-{permissionVariable.key}{' '}
{permissionVariable.isSystemVariable && (
<LockIcon className="h-4 w-4" />
)}
</>
}
/>
<Text className="font-medium">user.{customClaim.value}</Text>
<Text className="font-medium">
user.{permissionVariable.value}
</Text>
</ListItem.Root>
<Divider
@@ -237,10 +250,11 @@ export default function PermissionVariableSettings() {
</List>
<Button
className="justify-self-start mx-4"
className="mx-4 justify-self-start"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}
disabled={maintenanceActive}
>
Create Permission Variable
</Button>

View File

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

View File

@@ -1,5 +1,6 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import type { DialogFormProps } from '@/types/common';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
@@ -8,14 +9,7 @@ import { useEffect } from 'react';
import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup';
export interface BaseRoleFormValues {
/**
* The name of the role.
*/
name: string;
}
export interface BaseRoleFormProps {
export interface BaseRoleFormProps extends DialogFormProps {
/**
* Function to be called when the form is submitted.
*/
@@ -36,10 +30,15 @@ export const baseRoleFormValidationSchema = Yup.object({
name: Yup.string().required('This field is required.'),
});
export type BaseRoleFormValues = Yup.InferType<
typeof baseRoleFormValidationSchema
>;
export default function BaseRoleForm({
onSubmit,
onCancel,
submitButtonText = 'Save',
location,
}: BaseRoleFormProps) {
const { onDirtyStateChange } = useDialog();
const form = useFormContext<BaseRoleFormValues>();
@@ -52,8 +51,8 @@ export default function BaseRoleForm({
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
return (
<div className="grid grid-flow-row gap-3 px-6 pb-6">

View File

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

View File

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

View File

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

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';

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