Compare commits

..

268 Commits

Author SHA1 Message Date
Szilárd Dóró
43705b992d Merge pull request #1509 from nhost/changeset-release/main
chore: update versions
2023-01-12 12:41:41 +01:00
github-actions[bot]
2e999e8715 chore: update versions 2023-01-12 10:14:41 +00:00
Pilou
0370696d5c Merge pull request #1511 from nhost/chore/unlink-packages
chore(changeset): stop linking packages
2023-01-12 11:12:44 +01:00
Pierre-Louis Mercereau
f62131d55a chore(changeset): stop linking packages 2023-01-12 10:59:57 +01:00
Szilárd Dóró
86d077ac00 Merge pull request #1508 from nhost/renovate-changesets
chore: create changesest from Renovate bumps
2023-01-12 10:10:35 +01:00
szilarddoro
200e9f774c chore(deps): update dependency @types/react-dom to v18.0.10 2023-01-12 08:49:58 +00:00
Szilárd Dóró
bc1235de3b Merge pull request #1433 from nhost/renovate/react-dom-18.x
chore(deps): update dependency @types/react-dom to v18.0.10
2023-01-12 09:48:10 +01:00
Szilárd Dóró
fce58ebaea remove changeset, CI generates it 2023-01-12 09:47:53 +01:00
Szilárd Dóró
452e281120 chore(dashboard): add changeset 2023-01-12 09:47:04 +01:00
Szilárd Dóró
9a338e54c9 Merge pull request #1492 from nhost/renovate/vitest-monorepo
chore(deps): update vitest monorepo to ^0.27.0
2023-01-12 09:45:14 +01:00
Szilárd Dóró
baeebf980d Merge pull request #1507 from nhost/changeset-release/main
chore: update versions
2023-01-12 09:27:25 +01:00
github-actions[bot]
ac92c6ee61 chore: update versions 2023-01-12 01:38:20 +00:00
Guido Curcio
1ddaf680c0 Merge pull request #1471 from nhost/fix(dashboard)/workspace-creation-redirection-delete 2023-01-11 22:36:50 -03:00
Guido Curcio
c6e6194d8e mutating -> updating for signaling changes in course. 2023-01-11 09:05:43 -03:00
Pilou
83deea8b45 Merge pull request #1501 from nhost/chore/exclude-functions-from-workspace
chore: exclude functions from workspace
2023-01-11 11:42:07 +01:00
Pierre-Louis Mercereau
acbaabcf85 chore: update lockfile 2023-01-11 10:44:36 +01:00
Pierre-Louis Mercereau
3534501f37 chore: force using turborepo v1 2023-01-11 10:31:10 +01:00
Pierre-Louis Mercereau
27bc23cbbc chore: exclude functions from workspace 2023-01-11 10:15:47 +01:00
Szilárd Dóró
6450223558 Merge pull request #1498 from nhost/changeset-release/main
chore: update versions
2023-01-11 08:48:36 +01:00
Guido Curcio
a62a85a777 add comments to effects and router changes. 2023-01-11 02:07:15 -03:00
Guido Curcio
ae24f83953 fix changing application name redirect to 404, fix 404 flash when changing workspace name. 2023-01-11 01:33:28 -03:00
Guido Curcio
fc60d7a782 2023-01-10 19:14:00 -03:00
Guido Curcio
6be8a998df 2023-01-10 19:13:09 -03:00
Guido Curcio
ea091f6251 2023-01-10 19:11:02 -03:00
Guido Curcio
8175c052f7 2023-01-10 18:50:45 -03:00
github-actions[bot]
e6605a6ed0 chore: update versions 2023-01-10 16:43:20 +00:00
Szilárd Dóró
1cba0e6492 Merge pull request #1497 from nhost/fix/database-ui-hasura-metadata
fix(dashboard): don't break the table creation process
2023-01-10 17:41:38 +01:00
Szilárd Dóró
179c90fcdb fix(dashboard): update inline snapshot 2023-01-10 17:13:29 +01:00
Szilárd Dóró
85f0f943a1 chore(dashboard): add changeset 2023-01-10 16:04:15 +01:00
Szilárd Dóró
c4c23fde31 fix(dashboard): don't break table creation
don't break table creation when referencing a table that is not in the `public` schema
2023-01-10 15:39:05 +01:00
Szilárd Dóró
e0b94c3e90 Merge pull request #1493 from nhost/changeset-release/main
chore: update versions
2023-01-10 09:33:58 +01:00
github-actions[bot]
113d638532 chore: update versions 2023-01-09 17:20:13 +00:00
Pilou
d87448916f Merge pull request #1486 from nhost/chore/bump-start-server-and-test
chore(deps): bump start-server-and-test
2023-01-09 18:17:47 +01:00
Pilou
af4292658c Merge pull request #1495 from nhost/fix/export-types
Export commonly used types
2023-01-09 18:17:01 +01:00
Pilou
f735bcd2ea Merge pull request #1485 from nhost/fix/explicit-types
fix: 🐛 add explicit types to React hooks and Vue composables
2023-01-09 18:00:48 +01:00
Pierre-Louis Mercereau
66fb74af86 Merge branch 'main' into fix/explicit-types 2023-01-09 16:30:20 +01:00
Pierre-Louis Mercereau
791eac30bb Merge branch 'main' into chore/bump-start-server-and-test 2023-01-09 16:29:57 +01:00
Pierre-Louis Mercereau
da4ad889d7 Merge branch 'main' into fix/export-types 2023-01-09 16:27:31 +01:00
Pilou
9ef111760c Merge pull request #1490 from nhost/test/correct-forgot-password
test: correct forgot-password test
2023-01-09 16:26:57 +01:00
Pierre-Louis Mercereau
c2706c7d97 chore: export commonly used types 2023-01-09 16:26:01 +01:00
Pilou
683b8768c4 Merge pull request #1482 from nhost/chore/access-token-cookie
chore: store the session in a cookie to avoid refetching the jwt on every ssr call
2023-01-09 16:11:39 +01:00
renovate[bot]
6d9df237a8 chore(deps): update vitest monorepo to ^0.27.0 2023-01-09 13:34:33 +00:00
Szilárd Dóró
220ae37aa7 Merge pull request #1491 from nhost/changeset-release/main
chore: update versions
2023-01-09 14:26:56 +01:00
Pierre-Louis Mercereau
d0d94d9239 chore: use email+password sign-up 2023-01-09 13:27:28 +01:00
Pierre-Louis Mercereau
aed3d1f147 chore: wrap 2023-01-09 11:59:49 +01:00
github-actions[bot]
d07bf08e45 chore: update versions 2023-01-09 10:46:27 +00:00
Szilárd Dóró
f2183250d2 Merge pull request #1470 from nhost/fix(dashboard)/sign-out
fix(dashboard): Resetting the cache when signing out.
2023-01-09 11:44:48 +01:00
Pierre-Louis Mercereau
d2bb5ecfae refactor: unnest code blocks 2023-01-09 11:39:33 +01:00
Pierre-Louis Mercereau
02d0db0cf0 revert: remove line 2023-01-09 11:20:28 +01:00
Pierre-Louis Mercereau
441005d5c3 chore: another attempt 2023-01-09 10:59:38 +01:00
Pierre-Louis Mercereau
eea8708549 chore: visit 2023-01-09 10:31:44 +01:00
Szilárd Dóró
5f3f9390aa chore(dashboard): updated changeset 2023-01-09 09:42:36 +01:00
Pierre-Louis Mercereau
1c5b0560ed chore: 10 attempts 2023-01-09 09:38:19 +01:00
Pierre-Louis Mercereau
1bfdf21b99 test: correct forgot-password test 2023-01-09 09:33:40 +01:00
Pierre-Louis Mercereau
efd522a38a chore: update changesets 2023-01-06 16:53:21 +01:00
Pierre-Louis Mercereau
55c35fa9c5 chore(deps): bump start-server-and-test 2023-01-06 16:45:06 +01:00
Pierre-Louis Mercereau
d42c27ae99 fix: 🐛 add explicit types to React hooks and Vue composables 2023-01-06 13:56:19 +01:00
Pierre-Louis Mercereau
927be4a2c9 chore: store the session in a cookie 2023-01-06 10:47:09 +01:00
Guido Curcio
e44352abbd typo in CreateWorkspaceFormProps Save -> Create 2023-01-05 23:48:35 -03:00
Guido Curcio
f9289f3c32 Merge branch 'fix(dashboard)/workspace-creation-redirection-delete' of https://github.com/nhost/nhost into fix(dashboard)/workspace-creation-redirection-delete 2023-01-05 23:47:01 -03:00
Guido Curcio
8ff06e5637 disable create workspace button if error on input. 2023-01-05 23:45:32 -03:00
Guido Curcio
49e4633bca handle when workspace name is already taken. 2023-01-05 23:41:23 -03:00
Guido Curcio
7ae7a7206c Update dashboard/src/components/home/CreateWorkspaceForm/CreateWorkspaceForm.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-05 23:31:02 -03:00
Guido Curcio
43d7e7babf Update dashboard/src/components/workspace/WorkspaceSection.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-05 23:30:54 -03:00
Guido Curcio
463a51ce7c Update dashboard/src/components/home/CreateWorkspaceForm/CreateWorkspaceForm.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-05 23:30:47 -03:00
Guido Curcio
86e9d9d47f Update dashboard/src/components/home/CreateWorkspaceForm/CreateWorkspaceForm.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-05 23:30:42 -03:00
Guido Curcio
f99b72cd7c useUserData instead of nhost.auth.getUser() 2023-01-05 23:29:42 -03:00
Guido Curcio
0dc2f3ff29 remove unused file 2023-01-05 23:24:11 -03:00
Guido Curcio
d0f8081101 new changeset 2023-01-05 23:16:09 -03:00
Guido Curcio
84ebfb79d0 reorder calls when signing out 2023-01-05 23:14:35 -03:00
Pilou
3c78d0ef46 Merge pull request #1476 from nhost/test/reset-password
test: add forgot password test
2023-01-05 17:07:47 +01:00
Szilárd Dóró
e9a26fc995 Merge pull request #1477 from nhost/fix/versions
fix(deps): revert major bumps
2023-01-05 13:46:39 +01:00
Szilárd Dóró
b0794507f5 fix(deps): revert major bumps 2023-01-05 13:43:00 +01:00
Szilárd Dóró
824e222e9d Merge pull request #1472 from nhost/changeset-release/main
chore: update versions
2023-01-05 13:39:35 +01:00
github-actions[bot]
16a99d7d0f chore: update versions 2023-01-05 11:58:13 +00:00
Johan Eliasson
cda5c3d274 Merge pull request #1475 from nhost/dashboard-ib8ga98sid
fix(dashboard): create new user
2023-01-05 12:56:53 +01:00
Johan Eliasson
3d3791286d changeset 2023-01-05 12:08:34 +01:00
Pierre-Louis Mercereau
ad28bf2166 test: add forgot password test 2023-01-05 10:59:52 +01:00
Szilárd Dóró
17bfa83204 Merge pull request #1414 from nhost/feat/permission-editor
feat(dashboard): Permission Editor
2023-01-05 10:38:09 +01:00
Johan Eliasson
6cd64e76ff Merge pull request #1450 from nhost/chatgpt-iyv8asd
[chatgpt] added tests
2023-01-05 10:26:16 +01:00
Pilou
a4bf50cf23 Merge pull request #1461 from nhost/feat/image-transformation
feat: image transformation parameters
2023-01-05 09:47:19 +01:00
Pierre-Louis Mercereau
113baafd84 Merge branch 'main' into feat/image-transformation 2023-01-05 09:10:56 +01:00
Johan Eliasson
87c2b31821 fix 2023-01-05 08:50:12 +01:00
Johan Eliasson
8a6bc3625c fix 2023-01-05 08:42:42 +01:00
Pilou
bdfda8aced Merge pull request #1444 from nhost/fix/non-iso-8859-1-names
Fix: convert non ISO-8859-1 file names
2023-01-05 08:24:52 +01:00
Szilárd Dóró
ca090436af Merge pull request #1469 from nhost/changeset-release/main 2023-01-04 22:51:49 +01:00
Johan Eliasson
55f85a04ea fix 2023-01-04 22:10:41 +01:00
Johan Eliasson
73f95cfa3b jsdom tests 2023-01-04 22:07:47 +01:00
Guido Curcio
dbd3ded515 add patch changeset for dashboard. 2023-01-04 17:41:20 -03:00
Guido Curcio
5399fac211 remove AddWorkspace.tsx file and imports. 2023-01-04 17:39:01 -03:00
Guido Curcio
52e3127a34 fix(dashboard): workspaces creation, new form, correct redirects. 2023-01-04 17:34:35 -03:00
github-actions[bot]
3fb12c189b chore: update versions 2023-01-04 19:22:01 +00:00
Guido Curcio
c4d5366b22 Merge pull request #1468 from nhost/fix/twitter 2023-01-04 14:20:19 -05:00
Szilárd Dóró
bd68e916cf chore(dashboard): cleanup unused GQL file 2023-01-04 20:04:52 +01:00
Szilárd Dóró
7cadd9447b fix(dashboard): display Twitter provider settings 2023-01-04 19:52:21 +01:00
Szilárd Dóró
b649f178e0 Merge pull request #1454 from nhost/changeset-release/main
chore: update versions
2023-01-04 14:43:52 +01:00
Szilárd Dóró
7432c6477c fix(dashboard): change truncation type 2023-01-04 10:33:13 +01:00
Szilárd Dóró
c3aa6126fe chore(dashboard): improve autocomplete equality check 2023-01-04 09:34:58 +01:00
Szilárd Dóró
0f3cf887c1 fix(dashboard): improve boolean transformation 2023-01-04 09:31:55 +01:00
Szilárd Dóró
5cd311b69a feat(dashboard): add support for manual relationships 2023-01-04 09:25:30 +01:00
Szilárd Dóró
057fda178f fix(dashboard): get rid of MUI warning, add preview label 2023-01-03 20:56:10 +01:00
Szilárd Dóró
241b14a004 feat(dashboard): add read-only permissions support 2023-01-03 20:52:04 +01:00
github-actions[bot]
1f5e1e3d42 chore: update versions 2023-01-03 18:20:01 +00:00
Nuno Pato
5727b0b0fe Merge pull request #1426 from nhost/feat/add-functions-to-logs-dashboard
feat(dashboard): add functions to logs
2023-01-03 17:18:24 -01:00
Szilárd Dóró
10b56089fa feat(dashboard): initial read-only mode implementation 2023-01-03 16:35:57 +01:00
Szilárd Dóró
973df1ed5a Merge remote-tracking branch 'origin/main' into feat/permission-editor 2023-01-03 16:09:26 +01:00
Pierre-Louis Mercereau
8f681b83e8 chore: re-enable quality, as it is implemented 2023-01-03 13:50:29 +01:00
Pilou
2f38ed56f5 Merge pull request #1459 from nhost/fix/reuse-file-upload
fix: 🐛 allow useFileUpload to be reused
2023-01-03 13:41:20 +01:00
Pierre-Louis Mercereau
21501624e6 chore: update changeset 2023-01-03 13:31:16 +01:00
Pierre-Louis Mercereau
464530dacb chore: deactivate unavailable image transformation parameters 2023-01-03 13:28:24 +01:00
Pierre-Louis Mercereau
0f2fc3dfec docs: image transformation parameters 2023-01-03 13:18:18 +01:00
Pierre-Louis Mercereau
5cb71f1dc8 chore: correct changeset 2023-01-03 13:14:32 +01:00
Pierre-Louis Mercereau
83e0a4d33e feat: image transformation parameters 2023-01-03 13:11:04 +01:00
Guido Curcio
16502ea175 Merge pull request #1382 from nhost/feat/auth-management 2023-01-03 06:16:57 -05:00
Guido Curcio
beee0407df Merge branch 'main' into feat/auth-management 2023-01-03 05:13:46 -05:00
Guido Curcio
3990b1ffbb Update dashboard/src/components/users/CreateUserForm/CreateUserForm.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-03 06:12:22 -03:00
Guido Curcio
1fb03708e3 Update dashboard/src/components/users/EditUserPasswordForm/EditUserPasswordForm.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-03 06:12:15 -03:00
Szilárd Dóró
e9ef254c6d Merge branch 'main' into feat/permission-editor 2023-01-03 09:45:44 +01:00
Szilárd Dóró
d42719ee65 Merge pull request #1453 from nhost/feat/custom-jwt-secret
feat(dashboard): add JWT secret editor modal
2023-01-03 09:30:09 +01:00
Guido Curcio
72ff489ea8 fix inconsistent padding in modals. 2023-01-03 03:43:11 -03:00
Pilou
c9bf2dde0e Merge pull request #1458 from nhost/refactor/do-not-over-export
Only export what is required by the user or `@nhost/nextjs`
2023-01-02 22:04:52 +01:00
Pierre-Louis Mercereau
613533d377 chore: do not cache documentation build 2023-01-02 21:22:54 +01:00
Pierre-Louis Mercereau
8568354718 fix: 🐛 allow useFileUpload to be reused 2023-01-02 21:03:58 +01:00
Pierre-Louis Mercereau
1be6d32455 Only export what is required by the user or @nhost/nextjs 2023-01-02 16:19:00 +01:00
Pilou
812a6e5005 Merge pull request #1452 from nhost/fix/improve-missing-react-provider-error
fix: improve missing React provider error
2023-01-02 15:54:48 +01:00
Szilárd Dóró
34cc230b61 fix(dashboard): improve responsive layout 2023-01-02 15:29:02 +01:00
Szilárd Dóró
898a7c835f chore(dashboard): improve JWT secret validation 2023-01-02 14:41:28 +01:00
Szilárd Dóró
7766624bc5 feat(dashboard): add JWT secret editor modal 2023-01-02 14:34:36 +01:00
Pierre-Louis Mercereau
2e8f73df38 chore: changeset 2023-01-02 14:25:29 +01:00
Pierre-Louis Mercereau
6a419e060e fix: improve missing React provider error 2023-01-02 14:23:49 +01:00
Guido Curcio
43480ca735 spacing on action buttons 2023-01-02 09:07:21 -03:00
Guido Curcio
efc42d77fd fix loading state on EditUserForm (drawer) 2023-01-02 08:42:45 -03:00
Guido Curcio
31e2523eca spacing on create user modal 2023-01-02 08:19:54 -03:00
Guido Curcio
fbf4f40ab7 Merge branch 'feat/auth-management' of https://github.com/nhost/nhost into feat/auth-management 2023-01-02 08:15:31 -03:00
Guido Curcio
cbe203e720 fix alt props and spacing on users table 2023-01-02 08:14:49 -03:00
Szilárd Dóró
09af118452 fix(dashboard): use booleans when operator is _is_null 2023-01-02 12:01:31 +01:00
Szilárd Dóró
20d0c3d09b fix(dashboard): select appearance in rule group editor 2023-01-02 11:47:35 +01:00
Szilárd Dóró
378a6684b0 Merge pull request #1451 from nhost/changeset-release/main
chore: update versions
2023-01-02 11:33:20 +01:00
Szilárd Dóró
d92891b223 chore(dashboard): add changeset 2023-01-02 10:48:25 +01:00
github-actions[bot]
1999ae09e6 chore: update versions 2023-01-02 09:47:28 +00:00
Szilárd Dóró
aef86dc822 Merge remote-tracking branch 'origin/main' into feat/permission-editor 2023-01-02 10:46:25 +01:00
Szilárd Dóró
0fe48a0833 Merge pull request #1425 from nhost/fix/refresh-after-provisioning
fix(dashboard): provisioning status polling
2023-01-02 10:46:12 +01:00
Szilárd Dóró
a3499c4628 fix(dashboard): improve rule group editor scrollability 2023-01-02 10:43:47 +01:00
Szilárd Dóró
52ccfdec89 Merge branch 'main' into fix/refresh-after-provisioning 2023-01-02 10:05:46 +01:00
Szilárd Dóró
3cac6f69bd fix(dashboard): improve warning message 2023-01-02 09:43:19 +01:00
Szilárd Dóró
71ff71ccd2 Merge branch 'main' into feat/permission-editor 2023-01-02 09:36:43 +01:00
Szilárd Dóró
da575ca262 feat(dashboard): add unsupported rules to request 2023-01-02 09:36:27 +01:00
Johan Eliasson
5020566725 update 2022-12-31 19:32:18 +01:00
Johan Eliasson
eb5915aa03 revert code 2022-12-31 19:17:20 +01:00
Johan Eliasson
458ee7fe6c remove jsdom 2022-12-31 14:40:10 +01:00
Johan Eliasson
ea7eb18f36 test update 2022-12-31 14:39:37 +01:00
Johan Eliasson
18f5414411 missing imports 2022-12-31 14:04:52 +01:00
Johan Eliasson
a7ce6d85f4 missing imports 2022-12-31 12:56:04 +01:00
Johan Eliasson
2f348c660a tests and refactoring 2022-12-31 12:36:57 +01:00
Pierre-Louis Mercereau
7c07d09ea4 test: add test from user 2022-12-27 14:35:17 +01:00
Pierre-Louis Mercereau
13876ed523 fix: Allow uploading files with non ISO 8859-1 names 2022-12-27 14:11:08 +01:00
renovate[bot]
a529b654bc chore(deps): update dependency @types/react-dom to v18.0.10 2022-12-26 17:31:33 +00:00
Szilárd Dóró
b112ba0af4 feat(dashboard): extend "unsupported" functionality 2022-12-23 17:24:23 +01:00
Guido Curcio
70cfeb1fcf Update dashboard/src/components/users/UsersBody/UsersBody.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2022-12-23 12:38:12 -03:00
Szilárd Dóró
e6d990faa7 chore(dashboard): unsupported objects in permissions 2022-12-23 16:28:14 +01:00
Szilárd Dóró
b45da7e360 fix(dashboard): correct _is_null negation 2022-12-23 16:14:27 +01:00
Guido Curcio
3116562b58 fix service urls; pagination props 2022-12-23 11:31:43 -03:00
Guido Curcio
693e40d385 Merge branch 'main' into feat/auth-management 2022-12-23 11:28:53 -03:00
Szilárd Dóró
ff186a8d09 feat(dashboard): add support for the _not operator 2022-12-23 14:48:03 +01:00
Szilárd Dóró
3061771908 fix(dashboard): improve validation 2022-12-23 14:14:58 +01:00
Szilárd Dóró
c681cc9bef chore(dashboard): remove code blocking submission 2022-12-23 11:22:15 +01:00
Szilárd Dóró
3a80504427 feat(dashboard): add form validation to permission editor 2022-12-23 11:21:53 +01:00
Nuno Pato
9a1aa7bb2e changeset 2022-12-22 20:08:28 -01:00
Nuno Pato
98345f2e78 dashboard: add functions to the logs dashboard 2022-12-22 20:05:39 -01:00
Guido Curcio
f29abe6238 add changeset 2022-12-22 16:51:19 -03:00
Szilárd Dóró
8956d47bce fix(dashboard): start polling manually
reference issue: https://github.com/apollographql/apollo-client/issues/9819
2022-12-22 18:22:00 +01:00
Szilárd Dóró
dd0738d5f7 chore(dashboard): add changeset 2022-12-22 17:57:36 +01:00
Szilárd Dóró
11d77d6011 fix(dashboard): provisioning status polling 2022-12-22 17:53:07 +01:00
Szilárd Dóró
e025c5857f chore(dashboard): simplify section components 2022-12-22 16:54:23 +01:00
Szilárd Dóró
c3c95053dc fix(dashboard): improve permission editor UX 2022-12-22 15:30:12 +01:00
Szilárd Dóró
b27e94c712 chore(dashboard): add subtitle to column presets 2022-12-22 15:04:30 +01:00
Szilárd Dóró
279cf78aa5 fix(dashboard): clear dirty state when cancelling 2022-12-22 14:20:35 +01:00
Szilárd Dóró
8817adddf6 Merge remote-tracking branch 'origin/main' into feat/permission-editor 2022-12-22 14:12:03 +01:00
Guido Curcio
229c47cf16 set up redirect from /users/<userId> -> users?userId=<userId> 2022-12-22 10:04:56 -03:00
Szilárd Dóró
1388f11508 feat(dashboard): backend only permissions
- improved the conversion from Nhost's data structure to Hasura's
2022-12-22 13:58:53 +01:00
Guido Curcio
e5e705350d fix: style inconsistencies in users page 2022-12-22 09:56:37 -03:00
Guido Curcio
4f81b0695d add responsive breakpoints to users's table, user's drawer 2022-12-22 09:51:47 -03:00
Szilárd Dóró
c2bfed6e1f feat(dashboard): add support for local migrations 2022-12-22 13:38:59 +01:00
Szilárd Dóró
97dc261fcd feat(dashboard): persist permissions 2022-12-22 13:04:28 +01:00
Szilárd Dóró
4ca93c2773 feat(dashboard): delete permissions 2022-12-22 12:27:14 +01:00
Szilárd Dóró
f8b32584b4 Merge branch 'main' into feat/permission-editor 2022-12-22 10:55:09 +01:00
Guido Curcio
800db1b300 remove page query param from url if currentPage is equal to 1. 2022-12-22 03:02:53 -03:00
Guido Curcio
a40baa8c63 show up to 3 providers in users table, render a chip with remainder length 2022-12-22 02:23:17 -03:00
Guido Curcio
5cc06609c2 fix(EditUserForm): copy button. 2022-12-22 01:51:17 -03:00
Guido Curcio
efa68aab83 fix(roles): only show alert when editing role. 2022-12-21 14:15:48 -03:00
Guido Curcio
3a696d366a clicking on auth button of sidebar goes to page 1 2022-12-21 13:55:15 -03:00
Guido Curcio
e3e21b6164 upper bound on number of pages 2022-12-21 13:44:50 -03:00
Guido Curcio
9259663c76 missing key prop on providers 2022-12-21 13:43:28 -03:00
Guido Curcio
26dd7faf05 retrigger effect when nr of pages changes 2022-12-21 13:31:30 -03:00
Guido Curcio
1b5cb93761 include alert when editing roles 2022-12-21 12:18:51 -03:00
Szilárd Dóró
8de1be4910 feat(dashboard); add custom claims to column presets 2022-12-21 15:42:46 +01:00
Szilárd Dóró
d0f9ffba73 feat(dashboard): initial column preset section 2022-12-21 14:56:55 +01:00
Guido Curcio
b6df9e2e8c fix: correct onPageChange prop comments. 2022-12-21 08:21:39 -03:00
Guido Curcio
48f15eb849 remove unhelpful comments 2022-12-21 08:16:19 -03:00
Guido Curcio
141642d40d show only two providers in users table 2022-12-21 08:07:23 -03:00
Guido Curcio
def4a3a2ea fix: password change error handling, refetch, and closing dialog; createdAt in users body; use internal ban state for unbanning user; implement error handling for odd pages from URL & side-effect for user id queries; add comments to side-effects. 2022-12-21 08:02:50 -03:00
Szilárd Dóró
fcb4d167e7 feat(dashboard): added aggregation queries and root field customization 2022-12-21 10:45:44 +01:00
Szilárd Dóró
c5137c6c45 fix(dashboard): fix async initialization 2022-12-20 16:26:57 +01:00
Szilárd Dóró
297c2a965d fix(dashboard): fix async initialization issues 2022-12-20 16:06:28 +01:00
Guido Curcio
b876a4ada1 handle pagination between pages (parse & mutate url) 2022-12-20 11:02:35 -03:00
Guido Curcio
7e064355ba show banned users on users table 2022-12-20 10:47:27 -03:00
Guido Curcio
0f6ece6b8c remove pagination when there are no users from filter. 2022-12-20 10:28:46 -03:00
Guido Curcio
793e7392da error handling for creating users 2022-12-20 10:24:53 -03:00
Guido Curcio
a9bbd1303e close drawer after deleting user; track disabled state of an user. 2022-12-20 09:30:41 -03:00
Guido Curcio
b0ed2b6f14 button instead of IconButton for password change 2022-12-20 09:03:33 -03:00
Szilárd Dóró
9194be4816 feat(dashboard): fetch table permissions 2022-12-20 12:55:47 +01:00
Guido Curcio
3e951eab4f fix: don't hide the OAuth methods section; remove the active chip. 2022-12-20 08:42:13 -03:00
Guido Curcio
cd7a198715 fix: fix showing minutes instead of month; correct format. 2022-12-20 08:36:36 -03:00
Guido Curcio
7c4d05a25e fix: loading states, remove sign-in methods, fix default role, move handlers to specific component. 2022-12-20 07:15:43 -03:00
Szilárd Dóró
199fd0d491 fix(dashboard): correct relationship discovery 2022-12-20 10:36:48 +01:00
Szilárd Dóró
cd4b58674a feat(dashboard): integrate RuleGroupEditor 2022-12-20 10:15:14 +01:00
Szilárd Dóró
4f20d8640d feat(dashboard): added Radio and RadioGroup 2022-12-19 17:04:57 +01:00
Szilárd Dóró
5e8ae336a2 feat(dashboard): added form for role editing 2022-12-19 15:54:35 +01:00
Szilárd Dóró
1ff73f4f00 chore(dashboard): improved table appearance 2022-12-19 14:29:41 +01:00
Szilárd Dóró
7ce44ae1b1 feat(dashboard): improve table customizability 2022-12-19 14:16:51 +01:00
Szilárd Dóró
1b847617b6 feat(dashboard): added default role table 2022-12-19 13:59:58 +01:00
Szilárd Dóró
df53ec2954 feat(dashboard): started working on permissions layout 2022-12-19 12:49:31 +01:00
Szilárd Dóró
7b4c32816e feat(dashboard): started working on permission editor 2022-12-19 11:54:12 +01:00
Guido Curcio
357e0933ff pass roles with payload; handle adding and removing roles from user 2022-12-17 03:18:53 -03:00
Guido Curcio
7d8f82b99d user.lastSeen fix 2022-12-16 09:36:22 -03:00
Guido Curcio
e10480b761 missing refetch when deleting user. 2022-12-15 15:02:05 -03:00
Guido Curcio
1343abbe50 comments & types 2022-12-15 12:33:04 -03:00
Guido Curcio
fa37546139 handle banning users 2022-12-15 11:22:55 -03:00
Guido Curcio
112526a984 render email & password only if email and no social oauth. 2022-12-15 11:04:40 -03:00
Guido Curcio
0c74806245 fix showing limit in pagination rather than total users. 2022-12-14 13:31:47 -03:00
Guido Curcio
5df84d7f50 fix remoteAppUser type 2022-12-14 13:08:39 -03:00
Guido Curcio
d58de8fcf8 join totalUsers query with main users query 2022-12-14 12:55:49 -03:00
Guido Curcio
85674c4d90 handle users providers other than email (e.g. github); handle removed providers from a user. 2022-12-14 11:32:28 -03:00
Guido Curcio
5a1d3b9bfc handle anonymous users 2022-12-14 11:06:35 -03:00
Guido Curcio
942570ed29 show table header on not found search strings 2022-12-14 10:33:29 -03:00
Guido Curcio
3058eee48f 25 limit, loading screen 2022-12-14 09:21:14 -03:00
Guido Curcio
bacb1b9720 fix number of pages going to 0 on nextPageClick 2022-12-13 11:53:42 -03:00
Guido Curcio
e119e4fc18 styles on userBody 2022-12-13 11:19:23 -03:00
Guido Curcio
b1fe2be963 users body loader, pagination elemnts; input fix for pages > 9 2022-12-13 09:07:36 -03:00
Guido Curcio
9b6e8ab3bc slotProps for pagination component 2022-12-13 07:50:43 -03:00
Guido Curcio
d2c4b7cad1 custom styles for pagination in users tab 2022-12-13 02:26:32 -03:00
Guido Curcio
59d737696a onChangePage handler for Pagination 2022-12-13 02:13:57 -03:00
Guido Curcio
35cd76e562 UsersBodyProps types 2022-12-12 11:16:56 -03:00
Guido Curcio
266bbe837d type users, add comments UsersBodyProps, memo limit & offset. 2022-12-12 11:15:31 -03:00
Guido Curcio
caf785a938 feat: Pagination common component. 2022-12-12 10:48:06 -03:00
Guido Curcio
96f9c1a55d handle pagination when searching for users 2022-12-12 06:08:35 -03:00
Guido Curcio
731460b20d handle pagination in users' table 2022-12-12 06:05:22 -03:00
Guido Curcio
1537d46b1d handle states of search: case insensitive query, preserve search query. 2022-12-12 04:29:12 -03:00
Guido Curcio
632def158d render activated providers from users. 2022-12-12 02:38:53 -03:00
Guido Curcio
39271a67e2 render all available roles from the app, check roles form user. 2022-12-12 02:11:43 -03:00
Guido Curcio
9e25c4f386 render verified checkbox as helpertext except when errors; disable prop on phoneNumberVerified when no phoneNumber. 2022-12-12 01:26:48 -03:00
Guido Curcio
dd58a4ac7f handle mutation of displayName, email, and avatarUrl; render avatar url if not default=blank. 2022-12-12 01:14:04 -03:00
Guido Curcio
b9c3567baa update gql query with locale, lastSeen, emailVerified, and phoneNumberVerified 2022-12-12 00:56:00 -03:00
Guido Curcio
108937789a EditUserForm: roles & sign-in methods styles. 2022-12-12 00:20:59 -03:00
Guido Curcio
e651745a7e no users found; default & allowed roles 2022-12-11 23:15:29 -03:00
Guido Curcio
6091b4a8e8 handle delete users & refetch main users query 2022-12-09 06:46:01 -03:00
Guido Curcio
82ddcbd180 handle search strings, static UsersBodyHeader, nullish render on no users 2022-12-09 03:48:34 -03:00
Guido Curcio
8aa7aafa3b add form footer to EditUserForm 2022-12-09 03:10:23 -03:00
Guido Curcio
183cb4b26a EditUserPasswordForm: handle change password edits, remote gql client, toast on edit. 2022-12-09 03:00:20 -03:00
Guido Curcio
3a7377c6e2 EditUserForm: add avatar with letters, change password button & modal. 2022-12-08 23:15:05 -03:00
Guido Curcio
1529f58c33 fetch remote project users on page load 2022-12-08 23:00:26 -03:00
Guido Curcio
95af5421d1 UsersBody: render first two letters of displayname if no avatar 2022-12-08 22:51:11 -03:00
Guido Curcio
feb39404db first section of user's drawer 2022-12-08 22:26:54 -03:00
Guido Curcio
15b3100c63 pass on data from previously fetched users table 2022-12-08 12:17:22 -03:00
Guido Curcio
f7ef7d106d open drawer on edit user 2022-12-08 11:18:11 -03:00
Guido Curcio
72c31622cd render user's table with dropdown trigger 2022-12-08 09:25:07 -03:00
Guido Curcio
6959461e3f handle creating users (CreateUserForm) 2022-12-08 08:40:13 -03:00
Guido Curcio
103472ac77 empty state for new users page 2022-12-08 07:57:42 -03:00
257 changed files with 7919 additions and 2122 deletions

View File

@@ -2,20 +2,7 @@
"$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json", "$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json",
"changelog": "@changesets/cli/changelog", "changelog": "@changesets/cli/changelog",
"commit": false, "commit": false,
"linked": [ "linked": [],
[
"@nhost/nextjs",
"@nhost/react",
"@nhost/vue",
"@nhost/nhost-js",
"@nhost/hasura-auth-js",
"@nhost/hasura-storage-js"
],
[
"@nhost/react-apollo",
"@nhost/apollo"
]
],
"access": "restricted", "access": "restricted",
"baseBranch": "main", "baseBranch": "main",
"updateInternalDependencies": "patch", "updateInternalDependencies": "patch",

View File

@@ -38,7 +38,7 @@ runs:
uses: nick-fields/retry@v2 uses: nick-fields/retry@v2
with: with:
timeout_minutes: 3 timeout_minutes: 3
max_attempts: 3 max_attempts: 10
command: bash <(curl --silent -L https://raw.githubusercontent.com/nhost/cli/main/get.sh) ${{ inputs.version }} command: bash <(curl --silent -L https://raw.githubusercontent.com/nhost/cli/main/get.sh) ${{ inputs.version }}
- name: Set custom configuration - name: Set custom configuration
if: ${{ inputs.config }} if: ${{ inputs.config }}

View File

@@ -1,5 +1,78 @@
# @nhost/dashboard # @nhost/dashboard
## 0.9.5
### Patch Changes
- 200e9f77: chore(deps): update dependency @types/react-dom to v18.0.10
- Updated dependencies [200e9f77]
- @nhost/nextjs@1.13.2
- @nhost/react-apollo@4.13.2
## 0.9.4
### Patch Changes
- dbd3ded5: fix(dashboard): workspaces creation, new form, correct redirects.
## 0.9.3
### Patch Changes
- 85f0f943: fix(dashboard): don't break the table creation process
## 0.9.2
### Patch Changes
- Updated dependencies [d42c27ae]
- Updated dependencies [927be4a2]
- @nhost/nextjs@1.13.1
- @nhost/react-apollo@4.13.1
## 0.9.1
### Patch Changes
- d0f80811: fix(dashboard): don't show error when signing out the user
## 0.9.0
### Minor Changes
- d92891b2: feat(dashboard): add Permission Editor to the Database section
### Patch Changes
- 3d379128: fix(dashboard): create new user
- @nhost/react-apollo@4.13.0
- @nhost/nextjs@1.13.0
## 0.8.1
### Patch Changes
- 7cadd944: fix(dashboard): display Twitter provider settings
## 0.8.0
### Minor Changes
- 9a1aa7bb: add functions to the log dashboard
- f29abe62: feat(dashboard): Users Management v2
### Patch Changes
- 7766624b: feat(dashboard): add JWT secret editor modal
- @nhost/react-apollo@4.12.1
- @nhost/nextjs@1.12.1
## 0.7.13
### Patch Changes
- dd0738d5: fix(dashboard): provisioning status polling
## 0.7.12 ## 0.7.12
### Patch Changes ### Patch Changes

View File

@@ -3,7 +3,7 @@ RUN apk add --no-cache libc6-compat
RUN apk update RUN apk update
WORKDIR /app WORKDIR /app
RUN yarn global add turbo RUN yarn global add turbo@1
COPY . . COPY . .
RUN turbo prune --scope="@nhost/dashboard" --docker RUN turbo prune --scope="@nhost/dashboard" --docker

View File

@@ -66,6 +66,11 @@ module.exports = withBundleAnalyzer({
destination: '/:workspaceSlug/:appSlug/settings/environment-variables', destination: '/:workspaceSlug/:appSlug/settings/environment-variables',
permanent: true, permanent: true,
}, },
{
source: '/:workspaceSlug/:appSlug/users/:userId',
destination: '/:workspaceSlug/:appSlug/users?userId=:userId',
permanent: true,
},
]; ];
}, },
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nhost/dashboard", "name": "@nhost/dashboard",
"version": "0.7.12", "version": "0.9.5",
"private": true, "private": true,
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
@@ -17,7 +17,7 @@
"build-storybook": "build-storybook" "build-storybook": "build-storybook"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.6.2", "@apollo/client": "^3.7.3",
"@codemirror/language": "^6.3.0", "@codemirror/language": "^6.3.0",
"@emotion/cache": "^11.10.5", "@emotion/cache": "^11.10.5",
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
@@ -105,14 +105,14 @@
"@types/node": "^16.11.7", "@types/node": "^16.11.7",
"@types/pluralize": "^0.0.29", "@types/pluralize": "^0.0.29",
"@types/react": "18.0.25", "@types/react": "18.0.25",
"@types/react-dom": "18.0.9", "@types/react-dom": "18.0.10",
"@types/react-table": "^7.7.12", "@types/react-table": "^7.7.12",
"@types/testing-library__jest-dom": "^5.14.5", "@types/testing-library__jest-dom": "^5.14.5",
"@types/validator": "^13.7.10", "@types/validator": "^13.7.10",
"@typescript-eslint/eslint-plugin": "^5.43.0", "@typescript-eslint/eslint-plugin": "^5.43.0",
"@typescript-eslint/parser": "^5.43.0", "@typescript-eslint/parser": "^5.43.0",
"@vitejs/plugin-react": "^3.0.0", "@vitejs/plugin-react": "^3.0.0",
"@vitest/coverage-c8": "^0.26.0", "@vitest/coverage-c8": "^0.27.0",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"babel-loader": "^8.3.0", "babel-loader": "^8.3.0",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
@@ -143,7 +143,7 @@
"typescript": "^4.8.4", "typescript": "^4.8.4",
"vite": "^4.0.2", "vite": "^4.0.2",
"vite-tsconfig-paths": "^4.0.3", "vite-tsconfig-paths": "^4.0.3",
"vitest": "^0.26.2", "vitest": "^0.27.0",
"webpack": "^5.75.0" "webpack": "^5.75.0"
}, },
"browserslist": { "browserslist": {

View File

@@ -1,119 +0,0 @@
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { discordAnnounce } from '@/utils/discordAnnounce';
import { inputErrorMessages } from '@/utils/getErrorMessage';
import { slugifyString } from '@/utils/helpers';
import { triggerToast } from '@/utils/toast';
import { useUpdateWorkspaceMutation } from '@/utils/__generated__/graphql';
import router from 'next/router';
import type { ChangeEvent } from 'react';
import React, { useState } from 'react';
type ChangeWorkspaceNameProps = {
close: VoidFunction;
};
export default function ChangeWorkspaceName({
close,
}: ChangeWorkspaceNameProps) {
const { currentWorkspace } = useCurrentWorkspaceAndApplication();
const [newWorkspaceName, setNewWorkspaceName] = useState(
currentWorkspace.name,
);
const [workspaceError, setWorkspaceError] = useState<string>('');
const [updateWorkspace, { loading: mutationLoading, error: mutationError }] =
useUpdateWorkspaceMutation({
refetchQueries: [],
});
function handleChange(event: ChangeEvent<HTMLInputElement>) {
inputErrorMessages(
event.target.value,
setNewWorkspaceName,
setWorkspaceError,
'Workspace',
);
}
async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
e.preventDefault();
const name = newWorkspaceName;
const slug = slugifyString(name);
if (slug.length < 4 || slug.length > 32) {
setWorkspaceError('Slug should be within 4 and 32 characters.');
return;
}
try {
await updateWorkspace({
variables: {
id: currentWorkspace.id,
workspace: {
name,
slug,
},
},
});
close();
triggerToast('Workspace name changed');
} catch (error) {
await discordAnnounce(
`Error trying to remove workspace: ${currentWorkspace.id} - ${error.message}`,
);
}
await router.push(slug);
}
return (
<div className="w-modal px-6 py-6 text-left">
<div className="flex flex-col">
<Text variant="h3" component="h2">
Change Workspace Name
</Text>
<form onSubmit={handleSubmit}>
<div className="mt-4 grid grid-flow-row gap-2">
<Input
id="workspaceName"
label="New Workspace Name"
onChange={handleChange}
value={newWorkspaceName}
placeholder="New workspace name"
fullWidth
autoFocus
autoComplete="off"
helperText={`https://app.nhost.io/${slugifyString(
newWorkspaceName || '',
)}`}
/>
{workspaceError && <Alert severity="error">{workspaceError}</Alert>}
{mutationError && (
<Alert severity="error">{mutationError.toString()}</Alert>
)}
</div>
<div className="mt-6 grid grid-flow-row gap-2">
<Button
type="submit"
disabled={mutationLoading || !!workspaceError}
>
Save Changes
</Button>
<Button variant="outlined" color="secondary" onClick={close}>
Close
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -32,9 +32,12 @@ export default function ConnectGithubModal({ close }: ConnectGithubModalProps) {
useState<ConnectGithubModalState>('CONNECTING'); useState<ConnectGithubModalState>('CONNECTING');
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null); const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null);
const { data, loading, error } = useGetGithubRepositoriesQuery({ const { data, loading, error, startPolling } =
pollInterval: 2000, useGetGithubRepositoriesQuery();
});
useEffect(() => {
startPolling(2000);
}, [startPolling]);
const handleSelectAnotherRepository = () => { const handleSelectAnotherRepository = () => {
setSelectedRepoId(null); setSelectedRepoId(null);

View File

@@ -13,14 +13,17 @@ export function FunctionsLogsTerminalPage({ functionName }: any) {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [normalizedFunctionData, setNormalizedFunctionData] = useState(null); const [normalizedFunctionData, setNormalizedFunctionData] = useState(null);
const { data } = useGetFunctionLogQuery({ const { data, startPolling } = useGetFunctionLogQuery({
variables: { variables: {
subdomain: currentApplication.subdomain, subdomain: currentApplication.subdomain,
functionPaths: [functionName?.split('/').slice(1, 3).join('/')], functionPaths: [functionName?.split('/').slice(1, 3).join('/')],
}, },
pollInterval: 3000,
}); });
useEffect(() => {
startPolling(3000);
}, [startPolling]);
useEffect(() => { useEffect(() => {
if (!data || data.getFunctionLogs.length === 0) { if (!data || data.getFunctionLogs.length === 0) {
return; return;

View File

@@ -2,7 +2,7 @@ import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLCl
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Option from '@/ui/v2/Option'; import Option from '@/ui/v2/Option';
import Select from '@/ui/v2/Select'; import Select from '@/ui/v2/Select';
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql'; import type { RemoteAppGetUsersCustomQuery } from '@/utils/__generated__/graphql';
import { useRemoteAppGetUsersCustomQuery } from '@/utils/__generated__/graphql'; import { useRemoteAppGetUsersCustomQuery } from '@/utils/__generated__/graphql';
import { DEFAULT_ROLES } from './utils'; import { DEFAULT_ROLES } from './utils';
@@ -57,7 +57,7 @@ export function UserSelect({ onUserChange, ...props }: UserSelectProps) {
return; return;
} }
const user: RemoteAppGetUsersQuery['users'][number] = users.find( const user: RemoteAppGetUsersCustomQuery['users'][0] = users.find(
({ id }) => id === userId, ({ id }) => id === userId,
); );

View File

@@ -52,12 +52,14 @@ function ControlledAutocomplete(
return ( return (
<Autocomplete <Autocomplete
inputValue={typeof field.value === 'string' ? field.value : undefined}
{...props} {...props}
{...field} {...field}
inputValue={typeof field.value === 'string' ? field.value : undefined}
ref={mergeRefs([field.ref, ref])} ref={mergeRefs([field.ref, ref])}
onChange={(event, options, reason, details) => { onChange={(event, options, reason, details) => {
setValue?.(controllerProps?.name || name, options); setValue?.(controllerProps?.name || name, options, {
shouldDirty: true,
});
if (props.onChange) { if (props.onChange) {
props.onChange(event, options, reason, details); props.onChange(event, options, reason, details);

View File

@@ -42,13 +42,16 @@ function ControlledSwitch(
{...props} {...props}
{...field} {...field}
ref={mergeRefs([field.ref, ref])} ref={mergeRefs([field.ref, ref])}
onChange={(e) => { onChange={(event) => {
setValue(controllerProps?.name || name, e.target.checked, { setValue(controllerProps?.name || name, event.target.checked, {
shouldDirty: true, shouldDirty: true,
}); });
if (props.onChange) {
props.onChange(event);
}
}} }}
checked={field.value || false} checked={field.value || false}
{...props}
/> />
); );
} }

View File

@@ -35,15 +35,15 @@ function InsertPlaceholderTableRow({
}: InsertPlaceholderTableRowProps) { }: InsertPlaceholderTableRowProps) {
return ( return (
<div <div
className="h-12 border-r-1 border-b-1 border-gray-200 bg-white" className="h-12 bg-white border-gray-200 border-r-1 border-b-1"
{...props} {...props}
> >
<Button <Button
onClick={onInsertRow} onClick={onInsertRow}
variant="borderless" variant="borderless"
color="secondary" color="secondary"
className="h-full w-full justify-start rounded-none px-2 py-3 text-xs font-normal hover:shadow-none focus:shadow-none focus:outline-none" className="justify-start w-full h-full px-2 py-3 text-xs font-normal rounded-none hover:shadow-none focus:shadow-none focus:outline-none"
startIcon={<PlusIcon className="h-4 w-4 text-greyscaleGrey" />} startIcon={<PlusIcon className="w-4 h-4 text-greyscaleGrey" />}
> >
Insert New Row Insert New Row
</Button> </Button>
@@ -181,7 +181,7 @@ export default function DataGridBody<T extends object>({
return ( return (
<div {...getTableBodyProps()} ref={bodyRef} {...props}> <div {...getTableBodyProps()} ref={bodyRef} {...props}>
{rows.length === 0 && !loading && ( {rows.length === 0 && !loading && (
<div className="flex flex-nowrap pr-5"> <div className="flex pr-5 flex-nowrap">
{onInsertRow ? ( {onInsertRow ? (
<InsertPlaceholderTableRow <InsertPlaceholderTableRow
style={{ style={{
@@ -279,7 +279,7 @@ export default function DataGridBody<T extends object>({
})} })}
{allowInsertColumn && ( {allowInsertColumn && (
<div className="h-12 w-25 border-r-1 border-b-1 border-gray-200 bg-white" /> <div className="h-12 bg-white border-gray-200 w-25 border-r-1 border-b-1" />
)} )}
</div> </div>

View File

@@ -6,19 +6,25 @@ import { createContext } from 'react';
* Available dialog types. * Available dialog types.
*/ */
export type DialogType = export type DialogType =
| 'EDIT_WORKSPACE_NAME'
| 'CREATE_RECORD' | 'CREATE_RECORD'
| 'CREATE_COLUMN' | 'CREATE_COLUMN'
| 'EDIT_COLUMN' | 'EDIT_COLUMN'
| 'CREATE_TABLE' | 'CREATE_TABLE'
| 'EDIT_TABLE' | 'EDIT_TABLE'
| 'EDIT_PERMISSIONS'
| 'CREATE_FOREIGN_KEY' | 'CREATE_FOREIGN_KEY'
| 'EDIT_FOREIGN_KEY' | 'EDIT_FOREIGN_KEY'
| 'CREATE_ROLE' | 'CREATE_ROLE'
| 'EDIT_ROLE' | 'EDIT_ROLE'
| 'CREATE_USER'
| 'CREATE_PERMISSION_VARIABLE' | 'CREATE_PERMISSION_VARIABLE'
| 'EDIT_PERMISSION_VARIABLE' | 'EDIT_PERMISSION_VARIABLE'
| 'CREATE_ENVIRONMENT_VARIABLE' | 'CREATE_ENVIRONMENT_VARIABLE'
| 'EDIT_ENVIRONMENT_VARIABLE'; | 'EDIT_ENVIRONMENT_VARIABLE'
| 'EDIT_USER'
| 'EDIT_USER_PASSWORD'
| 'EDIT_JWT_SECRET';
export interface DialogConfig<TPayload = unknown> { export interface DialogConfig<TPayload = unknown> {
/** /**
@@ -62,6 +68,16 @@ export interface DialogContextProps {
* Call this function to close the active drawer. * Call this function to close the active drawer.
*/ */
closeDrawer: VoidFunction; closeDrawer: VoidFunction;
/**
* Call this function to check if the form is dirty and close the active dialog
* if the form is pristine.
*/
closeDialogWithDirtyGuard: VoidFunction;
/**
* Call this function to check if the form is dirty and close the active drawer
* if the form is pristine.
*/
closeDrawerWithDirtyGuard: VoidFunction;
/** /**
* Call this function to close the active alert dialog. * Call this function to close the active alert dialog.
*/ */
@@ -73,6 +89,10 @@ export interface DialogContextProps {
isDirty: boolean, isDirty: boolean,
location?: 'drawer' | 'dialog', location?: 'drawer' | 'dialog',
) => void; ) => void;
/**
* Call this function to open a dirty confirmation dialog.
*/
openDirtyConfirmation: (config?: Partial<DialogConfig<string>>) => void;
} }
export default createContext<DialogContextProps>({ export default createContext<DialogContextProps>({
@@ -81,6 +101,9 @@ export default createContext<DialogContextProps>({
openAlertDialog: () => {}, openAlertDialog: () => {},
closeDialog: () => {}, closeDialog: () => {},
closeDrawer: () => {}, closeDrawer: () => {},
closeDialogWithDirtyGuard: () => {},
closeDrawerWithDirtyGuard: () => {},
closeAlertDialog: () => {}, closeAlertDialog: () => {},
onDirtyStateChange: () => {}, onDirtyStateChange: () => {},
openDirtyConfirmation: () => {},
}); });

View File

@@ -1,12 +1,17 @@
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary'; import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm'; import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm'; import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
import EditWorkspaceNameForm from '@/components/home/EditWorkspaceNameForm';
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm'; import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm'; import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm'; import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm'; import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm'; import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
import EditRoleForm from '@/components/settings/roles/EditRoleForm'; import EditRoleForm from '@/components/settings/roles/EditRoleForm';
import CreateUserForm from '@/components/users/CreateUserForm';
import EditUserForm from '@/components/users/EditUserForm';
import EditUserPasswordForm from '@/components/users/EditUserPasswordForm';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import AlertDialog from '@/ui/v2/AlertDialog'; import AlertDialog from '@/ui/v2/AlertDialog';
import { BaseDialog } from '@/ui/v2/Dialog'; import { BaseDialog } from '@/ui/v2/Dialog';
@@ -81,6 +86,11 @@ const EditTableForm = dynamic(
{ ssr: false, loading: () => LoadingComponent() }, { ssr: false, loading: () => LoadingComponent() },
); );
const EditPermissionsForm = dynamic(
() => import('@/components/dataBrowser/EditPermissionsForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
function DialogProvider({ children }: PropsWithChildren<unknown>) { function DialogProvider({ children }: PropsWithChildren<unknown>) {
const router = useRouter(); const router = useRouter();
@@ -192,23 +202,31 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
[], [],
); );
function closeDrawerWithDirtyGuard(event?: BaseSyntheticEvent) { const closeDrawerWithDirtyGuard = useCallback(
if (isDrawerDirty.current && event?.type !== 'submit') { (event?: BaseSyntheticEvent) => {
openDirtyConfirmation({ props: { onPrimaryAction: closeDrawer } }); if (isDrawerDirty.current && event?.type !== 'submit') {
return; setShowDirtyConfirmation(true);
} openDirtyConfirmation({ props: { onPrimaryAction: closeDrawer } });
return;
}
closeDrawer(); closeDrawer();
} },
[closeDrawer, openDirtyConfirmation],
);
function closeDialogWithDirtyGuard(event?: BaseSyntheticEvent) { const closeDialogWithDirtyGuard = useCallback(
if (isDialogDirty.current && event?.type !== 'submit') { (event?: BaseSyntheticEvent) => {
openDirtyConfirmation({ props: { onPrimaryAction: closeDialog } }); if (isDialogDirty.current && event?.type !== 'submit') {
return; setShowDirtyConfirmation(true);
} openDirtyConfirmation({ props: { onPrimaryAction: closeDialog } });
return;
}
closeDialog(); closeDialog();
} },
[closeDialog, openDirtyConfirmation],
);
// We are coupling this logic with the location of the dialog content which is // 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 // not ideal. We shoule figure out a better logic for tracking the dirty
@@ -235,10 +253,22 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
openAlertDialog, openAlertDialog,
closeDialog, closeDialog,
closeDrawer, closeDrawer,
closeDialogWithDirtyGuard,
closeDrawerWithDirtyGuard,
closeAlertDialog, closeAlertDialog,
onDirtyStateChange, onDirtyStateChange,
openDirtyConfirmation,
}), }),
[closeDialog, closeDrawer, onDirtyStateChange, openDialog, openDrawer], [
closeDialog,
closeDialogWithDirtyGuard,
closeDrawer,
closeDrawerWithDirtyGuard,
onDirtyStateChange,
openDialog,
openDirtyConfirmation,
openDrawer,
],
); );
const sharedDialogProps = { const sharedDialogProps = {
@@ -337,6 +367,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<RetryableErrorBoundary <RetryableErrorBoundary
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }} errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
> >
{activeDialogType === 'EDIT_WORKSPACE_NAME' && (
<EditWorkspaceNameForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_FOREIGN_KEY' && ( {activeDialogType === 'CREATE_FOREIGN_KEY' && (
<CreateForeignKeyForm {...sharedDialogProps} /> <CreateForeignKeyForm {...sharedDialogProps} />
)} )}
@@ -353,6 +387,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<EditRoleForm {...sharedDialogProps} /> <EditRoleForm {...sharedDialogProps} />
)} )}
{activeDialogType === 'CREATE_USER' && (
<CreateUserForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_PERMISSION_VARIABLE' && ( {activeDialogType === 'CREATE_PERMISSION_VARIABLE' && (
<CreatePermissionVariableForm {...sharedDialogProps} /> <CreatePermissionVariableForm {...sharedDialogProps} />
)} )}
@@ -368,17 +406,34 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
{activeDialogType === 'EDIT_ENVIRONMENT_VARIABLE' && ( {activeDialogType === 'EDIT_ENVIRONMENT_VARIABLE' && (
<EditEnvironmentVariableForm {...sharedDialogProps} /> <EditEnvironmentVariableForm {...sharedDialogProps} />
)} )}
{activeDialogType === 'EDIT_USER_PASSWORD' && (
<EditUserPasswordForm
{...sharedDialogProps}
user={sharedDialogProps?.user}
/>
)}
{activeDialogType === 'EDIT_JWT_SECRET' && (
<EditJwtSecretForm {...sharedDialogProps} />
)}
</RetryableErrorBoundary> </RetryableErrorBoundary>
</BaseDialog> </BaseDialog>
<Drawer <Drawer
anchor="right"
{...drawerProps} {...drawerProps}
title={drawerTitle} title={drawerTitle}
open={drawerOpen} open={drawerOpen}
onClose={closeDrawerWithDirtyGuard} onClose={closeDrawerWithDirtyGuard}
SlideProps={{ onExited: clearDrawerContent, unmountOnExit: false }} SlideProps={{ onExited: clearDrawerContent, unmountOnExit: false }}
anchor="right" PaperProps={{
PaperProps={{ className: 'max-w-2.5xl w-full' }} ...drawerProps?.PaperProps,
className: twMerge(
'max-w-2.5xl w-full',
drawerProps?.PaperProps?.className,
),
}}
> >
<RetryableErrorBoundary> <RetryableErrorBoundary>
{activeDrawerType === 'CREATE_RECORD' && ( {activeDrawerType === 'CREATE_RECORD' && (
@@ -413,6 +468,19 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
schema={drawerPayload?.schema} schema={drawerPayload?.schema}
/> />
)} )}
{activeDrawerType === 'EDIT_PERMISSIONS' && (
<EditPermissionsForm
{...sharedDrawerProps}
disabled={drawerPayload?.disabled}
schema={drawerPayload?.schema}
table={drawerPayload?.table}
/>
)}
{activeDrawerType === 'EDIT_USER' && (
<EditUserForm {...sharedDrawerProps} {...drawerPayload} />
)}
</RetryableErrorBoundary> </RetryableErrorBoundary>
</Drawer> </Drawer>

View File

@@ -0,0 +1,12 @@
import InlineCode from '@/components/common/InlineCode';
import type { PropsWithChildren } from 'react';
export default function HighlightedText({
children,
}: PropsWithChildren<unknown>) {
return (
<InlineCode className="text-greyscaleDark bg-primary-light font-display text-sm">
{children}
</InlineCode>
);
}

View File

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

View File

@@ -8,7 +8,7 @@ function InlineCode({ className, children, ...props }: InlineCodeProps) {
return ( return (
<code <code
className={twMerge( className={twMerge(
'inline-grid h-full max-h-[18px] max-w-xs items-center truncate rounded-sm bg-gray-100 px-1 font-mono text-[11px] text-gray-600', 'inline-grid max-w-xs items-center truncate rounded-sm bg-gray-100 px-1 font-mono text-[11px] text-greyscaleMedium',
className, className,
)} )}
{...props} {...props}

View File

@@ -0,0 +1,138 @@
import type { ButtonProps } from '@/ui/v2/Button';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import ChevronLeftIcon from '@/ui/v2/icons/ChevronLeftIcon';
import ChevronRightIcon from '@/ui/v2/icons/ChevronRightIcon';
import type { DetailedHTMLProps, HTMLProps } from 'react';
import { twMerge } from 'tailwind-merge';
export type PaginationProps = DetailedHTMLProps<
HTMLProps<HTMLDivElement>,
HTMLDivElement
> & {
/**
* Total number of pages.
*/
totalNrOfPages: number;
/**
* Number of total elements per page.
*/
elementsPerPage?: number;
/**
* Total number of elements.
*/
totalNrOfElements: number;
/**
* Current page number.
*/
currentPageNumber: number;
/**
* Function to be called when navigating to the previous page.
*/
onPrevPageClick: VoidFunction;
/**
* Function to be called when navigating to the next page.
*/
onNextPageClick: VoidFunction;
/**
* Function to be called when a new page number is submitted.
*/
onPageChange: (page: number) => void;
/**
* Props for component slots.
*/
slotProps?: {
/**
* Props to be passed to the next button component.
*/
nextButton?: Partial<ButtonProps>;
/**
* Props to be passed to the previous button component.
*/
prevButton?: Partial<ButtonProps>;
};
};
export default function Pagination({
className,
totalNrOfPages,
currentPageNumber,
onPrevPageClick,
onNextPageClick,
slotProps,
elementsPerPage,
onPageChange,
totalNrOfElements,
...props
}: PaginationProps) {
return (
<div
className={twMerge('grid grid-flow-col items-center gap-2', className)}
{...props}
>
<div className="grid justify-start grid-flow-col gap-2">
<Button
variant="outlined"
color="secondary"
className="block text-xs"
disabled={currentPageNumber === 1}
aria-label="Previous page"
onClick={onPrevPageClick}
>
<ChevronLeftIcon className="w-4 h-4" />
Back
</Button>
<div className="grid items-center grid-cols-3 gap-1 text-center grid-col !text-greyscaleGreyDark">
<Text className="text-xs align-middle ">Page</Text>
<Input
value={currentPageNumber}
onChange={(e) => {
const page = parseInt(e.target.value, 10);
if (page > 0 && page <= totalNrOfPages) {
onPageChange(page);
}
}}
disabled={totalNrOfPages === 1}
color="secondary"
slotProps={{
inputRoot: {
className: 'w-4 h-2.5 text-center !text-[11.5px]',
},
}}
/>
<Text className="self-center text-xs align-middle text-greyscaleGreyDark">
of {totalNrOfPages}
</Text>
</div>
<Button
variant="outlined"
color="secondary"
className="text-xs"
aria-label="Next page"
disabled={currentPageNumber === totalNrOfPages}
onClick={onNextPageClick}
{...slotProps?.nextButton}
>
Next
<ChevronRightIcon className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-row items-center justify-end text-center gap-x-1">
<Text className="text-xs text-greyscaleGreyDark">
{currentPageNumber === 1 && currentPageNumber}
{currentPageNumber === 2 && elementsPerPage + currentPageNumber - 1}
{currentPageNumber > 2 &&
(currentPageNumber - 1) * elementsPerPage + 1}{' '}
-{' '}
{totalNrOfElements < currentPageNumber * elementsPerPage
? totalNrOfElements
: currentPageNumber * elementsPerPage}{' '}
of {totalNrOfElements} users
</Text>
</div>
</div>
);
}

View File

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

View File

@@ -1,13 +1,11 @@
import { ChangePasswordModal } from '@/components/applications/ChangePasswordModal'; import { ChangePasswordModal } from '@/components/applications/ChangePasswordModal';
import { useWorkspaceContext } from '@/context/workspace-context';
import { useUserDataContext } from '@/context/workspace1-context';
import { Avatar } from '@/ui/Avatar'; import { Avatar } from '@/ui/Avatar';
import { Modal } from '@/ui/Modal'; import { Modal } from '@/ui/Modal';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import { Dropdown, useDropdown } from '@/ui/v2/Dropdown'; import { Dropdown, useDropdown } from '@/ui/v2/Dropdown';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import { emptyWorkspace } from '@/utils/helpers';
import { nhost } from '@/utils/nhost'; import { nhost } from '@/utils/nhost';
import { useApolloClient } from '@apollo/client';
import { useUserData } from '@nhost/nextjs'; import { useUserData } from '@nhost/nextjs';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
@@ -22,9 +20,8 @@ function AccountMenuContent({
}: AccountMenuContentProps) { }: AccountMenuContentProps) {
const user = useUserData(); const user = useUserData();
const router = useRouter(); const router = useRouter();
const client = useApolloClient();
const [clicked, setClicked] = useState(false); const [clicked, setClicked] = useState(false);
const { setWorkspaceContext } = useWorkspaceContext();
const { setUserContext } = useUserDataContext();
const { handleClose } = useDropdown(); const { handleClose } = useDropdown();
return ( return (
@@ -34,10 +31,9 @@ function AccountMenuContent({
color="secondary" color="secondary"
className="absolute top-6 right-4 grid grid-flow-col items-center gap-1 self-start font-medium" className="absolute top-6 right-4 grid grid-flow-col items-center gap-1 self-start font-medium"
onClick={async () => { onClick={async () => {
setWorkspaceContext(emptyWorkspace());
setUserContext({ workspaces: [] });
nhost.auth.signOut();
router.push('/signin'); router.push('/signin');
await nhost.auth.signOut();
await client.resetStore();
}} }}
aria-label="Sign Out" aria-label="Sign Out"
> >

View File

@@ -118,6 +118,7 @@ export default function BaseColumnForm({
variant="inline" variant="inline"
className="col-span-8 py-3" className="col-span-8 py-3"
autoFocus autoFocus
autoComplete="off"
/> />
<ControlledAutocomplete <ControlledAutocomplete
@@ -272,6 +273,7 @@ export default function BaseColumnForm({
error={Boolean(errors.comment)} error={Boolean(errors.comment)}
variant="inline" variant="inline"
className="col-span-8 py-3" className="col-span-8 py-3"
autoComplete="off"
/> />
</section> </section>
</div> </div>

View File

@@ -88,6 +88,7 @@ function NameInput() {
error={Boolean(errors.name)} error={Boolean(errors.name)}
variant="inline" variant="inline"
className="col-span-8 py-3" className="col-span-8 py-3"
autoComplete="off"
autoFocus autoFocus
/> />
); );

View File

@@ -70,6 +70,7 @@ function NameInput({ index }: FieldArrayInputProps) {
} }
}, },
})} })}
autoComplete="off"
aria-label="Name" aria-label="Name"
placeholder="Enter name" placeholder="Enter name"
hideEmptyHelperText hideEmptyHelperText

View File

@@ -82,7 +82,7 @@ export default function ColumnEditorTable() {
startIcon={<PlusIcon />} startIcon={<PlusIcon />}
size="small" size="small"
> >
Add column Add Column
</Button> </Button>
</div> </div>
</> </>

View File

@@ -25,8 +25,8 @@ import type {
} from 'react'; } from 'react';
import { forwardRef, useEffect, useState } from 'react'; import { forwardRef, useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import type { UseAsyncInitialValueOptions } from './useAsyncInitialValue'; import type { UseAsyncValueOptions } from './useAsyncValue';
import useAsyncInitialValue from './useAsyncInitialValue'; import useAsyncValue from './useAsyncValue';
import type { UseColumnGroupsOptions } from './useColumnGroups'; import type { UseColumnGroupsOptions } from './useColumnGroups';
import useColumnGroups from './useColumnGroups'; import useColumnGroups from './useColumnGroups';
@@ -54,7 +54,7 @@ export interface ColumnAutocompleteProps
/** /**
* Function to be called when the input is asynchronously initialized. * Function to be called when the input is asynchronously initialized.
*/ */
onInitialized?: UseAsyncInitialValueOptions['onInitialized']; onInitialized?: UseAsyncValueOptions['onInitialized'];
/** /**
* Class name to be applied to the root element. * Class name to be applied to the root element.
*/ */
@@ -107,7 +107,11 @@ function ColumnAutocomplete(
ref: ForwardedRef<HTMLInputElement>, ref: ForwardedRef<HTMLInputElement>,
) { ) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeRelationship, setActiveRelationship] = useState<any>(); const [activeRelationship, setActiveRelationship] = useState<{
schema: string;
table: string;
name: string;
}>();
const selectedSchema = activeRelationship?.schema || defaultSchema; const selectedSchema = activeRelationship?.schema || defaultSchema;
const selectedTable = activeRelationship?.table || defaultTable; const selectedTable = activeRelationship?.table || defaultTable;
@@ -119,6 +123,7 @@ function ColumnAutocomplete(
} = useTableQuery([`default.${selectedSchema}.${selectedTable}`], { } = useTableQuery([`default.${selectedSchema}.${selectedTable}`], {
schema: selectedSchema, schema: selectedSchema,
table: selectedTable, table: selectedTable,
preventRowFetching: true,
queryOptions: { refetchOnWindowFocus: false }, queryOptions: { refetchOnWindowFocus: false },
}); });
@@ -141,7 +146,7 @@ function ColumnAutocomplete(
setSelectedRelationships, setSelectedRelationships,
relationshipDotNotation, relationshipDotNotation,
activeRelationship: asyncActiveRelationship, activeRelationship: asyncActiveRelationship,
} = useAsyncInitialValue({ } = useAsyncValue({
selectedSchema, selectedSchema,
selectedTable, selectedTable,
initialValue: externalValue as string, initialValue: externalValue as string,
@@ -226,7 +231,7 @@ function ColumnAutocomplete(
inputValue, inputValue,
options, options,
id: props?.name, id: props?.name,
openOnFocus: true, openOnFocus: !props.disabled,
disableCloseOnSelect: true, disableCloseOnSelect: true,
value: selectedColumn, value: selectedColumn,
onClose: () => setOpen(false), onClose: () => setOpen(false),
@@ -256,24 +261,43 @@ function ColumnAutocomplete(
), ),
}, },
}} }}
onFocus={() => setOpen(true)} onFocus={() => {
onClick={() => setOpen(true)} if (props.disabled) {
error={Boolean(tableError || metadataError)} return;
helperText={String(tableError || metadataError || '')} }
setOpen(true);
}}
onClick={() => {
if (props.disabled) {
return;
}
setOpen(true);
}}
error={Boolean(tableError || metadataError) || props.error}
helperText={
String(tableError || metadataError || '') || props.helperText
}
onChange={(event) => setInputValue(event.target.value)} onChange={(event) => setInputValue(event.target.value)}
value={inputValue} value={inputValue}
startAdornment={ startAdornment={
selectedColumn || relationshipDotNotation ? ( selectedColumn || relationshipDotNotation ? (
<Text className="!ml-2 lg:max-w-[200px] flex-shrink-0 truncate"> <Text
className={twMerge(
'!ml-2 lg:max-w-[200px] flex-shrink-0 truncate',
props.disabled && 'text-greyscaleGrey',
)}
>
<span className="text-greyscaleGrey">{defaultTable}</span>. <span className="text-greyscaleGrey">{defaultTable}</span>.
{relationshipDotNotation && ( {relationshipDotNotation && (
<> <>
<span className="hidden lg:inline"> <span className="hidden lg:inline">
{getTruncatedText(relationshipDotNotation, 15, 'start')}. {getTruncatedText(relationshipDotNotation, 15, 'end')}.
</span> </span>
<span className="inline lg:hidden"> <span className="inline lg:hidden">
{relationshipDotNotation}. {getTruncatedText(relationshipDotNotation, 35, 'end')}.
</span> </span>
</> </>
)} )}

View File

@@ -4,7 +4,7 @@ import type { HasuraMetadataTable } from '@/types/dataBrowser';
import type { AutocompleteOption } from '@/ui/v2/Autocomplete'; import type { AutocompleteOption } from '@/ui/v2/Autocomplete';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export interface UseAsyncInitialValueOptions { export interface UseAsyncValueOptions {
/** /**
* Selected schema to be used to determine the initial value. * Selected schema to be used to determine the initial value.
*/ */
@@ -42,7 +42,7 @@ export interface UseAsyncInitialValueOptions {
}) => void; }) => void;
} }
export default function useAsyncInitialValue({ export default function useAsyncValue({
selectedSchema, selectedSchema,
selectedTable, selectedTable,
initialValue, initialValue,
@@ -51,7 +51,7 @@ export default function useAsyncInitialValue({
tableData, tableData,
metadata, metadata,
onInitialized, onInitialized,
}: UseAsyncInitialValueOptions) { }: UseAsyncValueOptions) {
const currentTablePath = `${selectedSchema}.${selectedTable}`; const currentTablePath = `${selectedSchema}.${selectedTable}`;
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
@@ -65,10 +65,9 @@ export default function useAsyncInitialValue({
const [selectedRelationships, setSelectedRelationships] = useState< const [selectedRelationships, setSelectedRelationships] = useState<
{ schema: string; table: string; name: string }[] { schema: string; table: string; name: string }[]
>([]); >([]);
const relationshipDotNotation = const relationshipDotNotation = selectedRelationships
initialized && selectedRelationships?.length > 0 .map((relationship) => relationship.name)
? selectedRelationships.map((relationship) => relationship.name).join('.') .join('.');
: '';
const [selectedColumn, setSelectedColumn] = const [selectedColumn, setSelectedColumn] =
useState<AutocompleteOption>(null); useState<AutocompleteOption>(null);
const activeRelationship = const activeRelationship =
@@ -146,7 +145,8 @@ export default function useAsyncInitialValue({
remainingColumnPath.length < 2 || remainingColumnPath.length < 2 ||
isTableLoading || isTableLoading ||
isMetadataLoading || isMetadataLoading ||
!tableData?.columns !tableData?.columns ||
asyncTablePath !== currentTablePath
) { ) {
return; return;
} }
@@ -167,8 +167,8 @@ export default function useAsyncInitialValue({
const tableMetadata = metadataMap.get(`${selectedSchema}.${selectedTable}`); const tableMetadata = metadataMap.get(`${selectedSchema}.${selectedTable}`);
const currentRelationship = [ const currentRelationship = [
...(tableMetadata.object_relationships || []), ...(tableMetadata?.object_relationships || []),
...(tableMetadata.array_relationships || []), ...(tableMetadata?.array_relationships || []),
].find(({ name }) => name === nextPath); ].find(({ name }) => name === nextPath);
if (!currentRelationship) { if (!currentRelationship) {
@@ -176,11 +176,32 @@ export default function useAsyncInitialValue({
return; return;
} }
const { foreign_key_constraint_on: metadataConstraint } = const {
currentRelationship.using || {}; foreign_key_constraint_on: metadataConstraint,
manual_configuration: metadataManualConfiguration,
} = currentRelationship.using || {};
if (metadataManualConfiguration) {
setAsyncTablePath(
`${metadataManualConfiguration.remote_table.schema}.${metadataManualConfiguration.remote_table.name}`,
);
setSelectedRelationships((currentRelationships) => [
...currentRelationships,
{
schema: metadataManualConfiguration.remote_table.schema || 'public',
table: metadataManualConfiguration.remote_table.name,
name: nextPath,
},
]);
setRemainingColumnPath((columnPath) => columnPath.slice(1));
return;
}
// In some cases the metadata already contains the schema and table name // In some cases the metadata already contains the schema and table name
if (typeof metadataConstraint !== 'string') { if (metadataConstraint && typeof metadataConstraint !== 'string') {
setAsyncTablePath( setAsyncTablePath(
`${metadataConstraint.table.schema || 'public'}.${ `${metadataConstraint.table.schema || 'public'}.${
metadataConstraint.table.name metadataConstraint.table.name
@@ -203,17 +224,25 @@ export default function useAsyncInitialValue({
const foreignKeyRelation = tableData?.foreignKeyRelations?.find( const foreignKeyRelation = tableData?.foreignKeyRelations?.find(
({ columnName }) => { ({ columnName }) => {
const { foreign_key_constraint_on } = currentRelationship.using || {}; const normalizedColumnName = columnName.replace(/"/g, '');
const { foreign_key_constraint_on, manual_configuration } =
currentRelationship.using || {};
if (!foreign_key_constraint_on) { if (!foreign_key_constraint_on && !manual_configuration) {
return false; return false;
} }
if (typeof foreign_key_constraint_on === 'string') { if (manual_configuration) {
return foreign_key_constraint_on === columnName; return Object.keys(manual_configuration.column_mapping).includes(
normalizedColumnName,
);
} }
return foreign_key_constraint_on.column === columnName; if (typeof foreign_key_constraint_on === 'string') {
return foreign_key_constraint_on === normalizedColumnName;
}
return foreign_key_constraint_on.column === normalizedColumnName;
}, },
); );
@@ -222,23 +251,30 @@ export default function useAsyncInitialValue({
return; return;
} }
setAsyncTablePath( const normalizedSchema = foreignKeyRelation.referencedSchema?.replace(
`${foreignKeyRelation.referencedSchema || 'public'}.${ /(\\"|")/g,
foreignKeyRelation.referencedTable '',
}`,
); );
const normalizedTable = foreignKeyRelation.referencedTable?.replace(
/(\\"|")/g,
'',
);
setAsyncTablePath(`${normalizedSchema || 'public'}.${normalizedTable}`);
setSelectedRelationships((currentRelationships) => [ setSelectedRelationships((currentRelationships) => [
...currentRelationships, ...currentRelationships,
{ {
schema: foreignKeyRelation.referencedSchema || 'public', schema: normalizedSchema || 'public',
table: foreignKeyRelation.referencedTable, table: normalizedTable,
name: nextPath, name: nextPath,
}, },
]); ]);
setRemainingColumnPath((columnPath) => columnPath.slice(1)); setRemainingColumnPath((columnPath) => columnPath.slice(1));
}, [ }, [
currentTablePath,
asyncTablePath,
selectedSchema, selectedSchema,
selectedTable, selectedTable,
metadata?.tables, metadata?.tables,
@@ -258,6 +294,9 @@ export default function useAsyncInitialValue({
selectedColumn: initialized ? selectedColumn : null, selectedColumn: initialized ? selectedColumn : null,
setSelectedRelationships, setSelectedRelationships,
setSelectedColumn, setSelectedColumn,
relationshipDotNotation, relationshipDotNotation:
initialized && selectedRelationships?.length > 0
? relationshipDotNotation
: '',
}; };
} }

View File

@@ -65,25 +65,44 @@ export default function useColumnGroups({
const objectAndArrayRelationships = [ const objectAndArrayRelationships = [
...(object_relationships || []), ...(object_relationships || []),
...(array_relationships || []), ...(array_relationships || []),
].map((relationship) => { ].reduce((relationships, currentRelationship) => {
const { foreign_key_constraint_on } = relationship?.using || {}; const { foreign_key_constraint_on, manual_configuration } =
currentRelationship?.using || {};
if (typeof foreign_key_constraint_on === 'string') { if (manual_configuration) {
return { return [
schema: selectedSchema, ...relationships,
table: selectedTable, ...Object.keys(manual_configuration.column_mapping).map((column) => ({
column: foreign_key_constraint_on, schema: manual_configuration.remote_table?.schema || 'public',
name: relationship.name, table: manual_configuration.remote_table?.name,
}; column,
name: currentRelationship.name,
})),
];
} }
return { if (typeof foreign_key_constraint_on === 'string') {
schema: foreign_key_constraint_on.table.schema, return [
table: foreign_key_constraint_on.table.name, ...relationships,
column: foreign_key_constraint_on.column, {
name: relationship.name, schema: selectedSchema,
}; table: selectedTable,
}); column: foreign_key_constraint_on,
name: currentRelationship.name,
},
];
}
return [
...relationships,
{
schema: foreign_key_constraint_on.table.schema,
table: foreign_key_constraint_on.table.name,
column: foreign_key_constraint_on.column,
name: currentRelationship.name,
},
];
}, [] as { schema: string; table: string; column: string; name: string }[]);
return [ return [
...columnOptions, ...columnOptions,
@@ -93,6 +112,9 @@ export default function useColumnGroups({
group: 'relationships', group: 'relationships',
metadata: { metadata: {
target: { target: {
schema: relationship.schema,
table: relationship.table,
column: relationship.column,
...(columnTargetMap?.get(relationship.column) || {}), ...(columnTargetMap?.get(relationship.column) || {}),
name: relationship.name, name: relationship.name,
}, },

View File

@@ -348,7 +348,7 @@ export default function DataBrowserGrid({
description={ description={
<span> <span>
Schema{' '} Schema{' '}
<InlineCode className="max-h-[32px] bg-gray-200 bg-opacity-80 px-1.5 text-sm"> <InlineCode className="bg-gray-200 bg-opacity-80 px-1.5 text-sm">
{metadata.schema || schemaSlug} {metadata.schema || schemaSlug}
</InlineCode>{' '} </InlineCode>{' '}
does not exist. does not exist.
@@ -365,7 +365,7 @@ export default function DataBrowserGrid({
description={ description={
<span> <span>
Table{' '} Table{' '}
<InlineCode className="max-h-[32px] bg-gray-200 bg-opacity-80 px-1.5 text-sm"> <InlineCode className="bg-gray-200 bg-opacity-80 px-1.5 text-sm">
{metadata.schema || schemaSlug}.{metadata.table || tableSlug} {metadata.schema || schemaSlug}.{metadata.table || tableSlug}
</InlineCode>{' '} </InlineCode>{' '}
does not exist. does not exist.

View File

@@ -1,4 +1,5 @@
import { useDialog } from '@/components/common/DialogProvider'; import { useDialog } from '@/components/common/DialogProvider';
import InlineCode from '@/components/common/InlineCode';
import NavLink from '@/components/common/NavLink'; import NavLink from '@/components/common/NavLink';
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary'; import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import useIsPlatform from '@/hooks/common/useIsPlatform'; import useIsPlatform from '@/hooks/common/useIsPlatform';
@@ -8,6 +9,7 @@ import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAn
import FloatingActionButton from '@/ui/FloatingActionButton'; import FloatingActionButton from '@/ui/FloatingActionButton';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip';
import Divider from '@/ui/v2/Divider'; import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown'; import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton'; import IconButton from '@/ui/v2/IconButton';
@@ -17,6 +19,7 @@ import LockIcon from '@/ui/v2/icons/LockIcon';
import PencilIcon from '@/ui/v2/icons/PencilIcon'; import PencilIcon from '@/ui/v2/icons/PencilIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon'; import PlusIcon from '@/ui/v2/icons/PlusIcon';
import TrashIcon from '@/ui/v2/icons/TrashIcon'; import TrashIcon from '@/ui/v2/icons/TrashIcon';
import UsersIcon from '@/ui/v2/icons/UsersIcon';
import Link from '@/ui/v2/Link'; import Link from '@/ui/v2/Link';
import List from '@/ui/v2/List'; import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem'; import { ListItem } from '@/ui/v2/ListItem';
@@ -194,6 +197,40 @@ function DataBrowserSidebarContent({
}); });
} }
function handleEditPermissionClick(
schema: string,
table: string,
disabled?: boolean,
) {
openDrawer('EDIT_PERMISSIONS', {
title: (
<span className="inline-grid grid-flow-col gap-2 items-center">
Permissions
<InlineCode className="!text-sm+ font-normal text-greyscaleMedium">
{table}
</InlineCode>
<Chip label="Preview" size="small" color="info" component="span" />
</span>
),
props: {
PaperProps: {
className: 'lg:w-[65%] lg:max-w-7xl',
},
},
payload: {
onSubmit: async () => {
await queryClient.refetchQueries([
`${dataSourceSlug}.${schema}.${table}`,
]);
await refetch();
},
disabled,
schema,
table,
},
});
}
return ( return (
<div className="grid gap-1"> <div className="grid gap-1">
{schemas && schemas.length > 0 && ( {schemas && schemas.length > 0 && (
@@ -318,9 +355,7 @@ function DataBrowserSidebarContent({
<Dropdown.Trigger <Dropdown.Trigger
asChild asChild
hideChevron hideChevron
disabled={ disabled={tablePath === removableTable}
tablePath === removableTable || isGitHubConnected
}
> >
<IconButton <IconButton
variant="borderless" variant="borderless"
@@ -329,7 +364,6 @@ function DataBrowserSidebarContent({
!isSelected && !isSelected &&
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100', 'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
)} )}
disabled={isGitHubConnected}
> >
<DotsHorizontalIcon /> <DotsHorizontalIcon />
</IconButton> </IconButton>
@@ -339,44 +373,84 @@ function DataBrowserSidebarContent({
menu menu
PaperProps={{ className: 'w-52' }} PaperProps={{ className: 'w-52' }}
> >
<Dropdown.Item {isGitHubConnected ? (
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium" <Dropdown.Item
onClick={() => className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
openDrawer('EDIT_TABLE', { onClick={() =>
title: 'Edit Table', handleEditPermissionClick(
payload: { table.table_schema,
onSubmit: async () => { table.table_name,
await queryClient.refetchQueries([ true,
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`, )
]); }
await refetch(); >
}, <UsersIcon className="h-4 w-4 text-gray-700" />
schema: table.table_schema,
table,
},
})
}
>
<PencilIcon className="h-4 w-4 text-gray-700" />
<span>Edit Table</span> <span>View Permissions</span>
</Dropdown.Item> </Dropdown.Item>
) : (
[
<Dropdown.Item
key="edit-table"
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
openDrawer('EDIT_TABLE', {
title: 'Edit Table',
payload: {
onSubmit: async () => {
await queryClient.refetchQueries([
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
]);
await refetch();
},
schema: table.table_schema,
table,
},
})
}
>
<PencilIcon className="h-4 w-4 text-gray-700" />
<Divider component="li" /> <span>Edit Table</span>
</Dropdown.Item>,
<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 text-gray-700" />
<Dropdown.Item <span>Edit Permissions</span>
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium text-red" </Dropdown.Item>,
onClick={() => <Divider
handleDeleteTableClick( key="edit-permissions-separator"
table.table_schema, component="li"
table.table_name, />,
) <Dropdown.Item
} key="delete-table"
> className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium text-red"
<TrashIcon className="h-4 w-4 text-red" /> onClick={() =>
handleDeleteTableClick(
table.table_schema,
table.table_name,
)
}
>
<TrashIcon className="h-4 w-4 text-red" />
<span>Delete Table</span> <span>Delete Table</span>
</Dropdown.Item> </Dropdown.Item>,
]
)}
</Dropdown.Content> </Dropdown.Content>
</Dropdown.Root> </Dropdown.Root>
) )

View File

@@ -127,7 +127,7 @@ export default function DatabaseRecordInputGroup({
<span>{columnId}</span> <span>{columnId}</span>
</span> </span>
<InlineCode> <InlineCode className="h-[18px]">
{specificType} {specificType}
{maxLength ? `(${maxLength})` : null} {maxLength ? `(${maxLength})` : null}
</InlineCode> </InlineCode>

View File

@@ -0,0 +1,348 @@
import { useDialog } from '@/components/common/DialogProvider';
import useMetadataQuery from '@/hooks/dataBrowser/useMetadataQuery';
import useTableQuery from '@/hooks/dataBrowser/useTableQuery';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
import type {
DatabaseAccessLevel,
DatabaseAction,
HasuraMetadataPermission,
} from '@/types/dataBrowser';
import { Alert } from '@/ui/Alert';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import FullPermissionIcon from '@/ui/v2/icons/FullPermissionIcon';
import NoPermissionIcon from '@/ui/v2/icons/NoPermissionIcon';
import PartialPermissionIcon from '@/ui/v2/icons/PartialPermissionIcon';
import Link from '@/ui/v2/Link';
import Table from '@/ui/v2/Table';
import TableBody from '@/ui/v2/TableBody';
import TableCell from '@/ui/v2/TableCell';
import TableContainer from '@/ui/v2/TableContainer';
import TableHead from '@/ui/v2/TableHead';
import TableRow from '@/ui/v2/TableRow';
import Text from '@/ui/v2/Text';
import { useGetRemoteAppRolesQuery } from '@/utils/__generated__/graphql';
import NavLink from 'next/link';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import RolePermissionEditorForm from './RolePermissionEditorForm';
import RolePermissionsRow from './RolePermissionsRow';
export interface EditPermissionsFormProps {
/**
* Determines whether the form is disabled or not.
*/
disabled?: boolean;
/**
* The schema that is being edited.
*/
schema: string;
/**
* The table that is being edited.
*/
table: string;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
}
export default function EditPermissionsForm({
disabled,
schema,
table,
onCancel,
}: EditPermissionsFormProps) {
const [role, setRole] = useState<string>();
const [action, setAction] = useState<DatabaseAction>();
const { closeDrawerWithDirtyGuard } = useDialog();
const { currentWorkspace, currentApplication } =
useCurrentWorkspaceAndApplication();
const client = useRemoteApplicationGQLClient();
const {
data: rolesData,
loading: rolesLoading,
error: rolesError,
} = useGetRemoteAppRolesQuery({ client });
const {
data: tableData,
status: tableStatus,
error: tableError,
} = useTableQuery([`default.${schema}.${table}`], { schema, table });
const {
data: metadata,
status: metadataStatus,
error: metadataError,
} = useMetadataQuery([`default.metadata`]);
if (tableStatus === 'loading') {
return (
<div className="p-6">
<ActivityIndicator label="Loading table..." />
</div>
);
}
if (tableError) {
throw tableError;
}
if (metadataStatus === 'loading') {
return (
<div className="p-6">
<ActivityIndicator label="Loading table metadata..." />
</div>
);
}
if (metadataError) {
throw metadataError;
}
if (rolesLoading) {
return (
<div className="p-6">
<ActivityIndicator label="Loading available roles..." />
</div>
);
}
if (rolesError) {
throw rolesError;
}
const availableRoles = [
'public',
...(rolesData?.authRoles?.map(({ role: authRole }) => authRole) || []),
];
const metadataForTable = metadata?.tables?.find(
({ table: currentTable }) =>
currentTable.name === table && currentTable.schema === schema,
);
const availableColumns =
tableData?.columns.map((column) => column.column_name) || [];
function handleSubmit() {
setRole(undefined);
setAction(undefined);
}
function handleCancel() {
setRole(undefined);
setAction(undefined);
}
function getAccessLevel(
permission?: HasuraMetadataPermission['permission'],
): DatabaseAccessLevel {
if (
!permission ||
(!permission?.check && !permission && permission?.columns?.length === 0)
) {
return 'none';
}
const sortedTableColumns = [...availableColumns].sort();
const isAllColumnSelected =
sortedTableColumns.length === permission?.columns?.length &&
[...(permission?.columns || [])]
.sort()
.every(
(permissionColumn, index) =>
permissionColumn === sortedTableColumns[index],
);
if (
Object.keys(permission?.check || {}).length === 0 &&
Object.keys(permission?.filter || {}).length === 0 &&
isAllColumnSelected
) {
return 'full';
}
return 'partial';
}
if (role && action) {
const permissionsForAction = {
insert: metadataForTable?.insert_permissions,
select: metadataForTable?.select_permissions,
update: metadataForTable?.update_permissions,
delete: metadataForTable?.delete_permissions,
};
return (
<RolePermissionEditorForm
disabled={disabled}
schema={schema}
table={table}
role={role}
action={action}
onSubmit={handleSubmit}
onCancel={handleCancel}
permission={
permissionsForAction[action]?.find(
({ role: currentRole }) => currentRole === role,
)?.permission
}
/>
);
}
return (
<div className="flex flex-auto flex-col content-between overflow-hidden border-t-1 border-gray-200 bg-[#fafafa]">
<div className="flex-auto">
<section className="grid grid-flow-row gap-6 content-start overflow-y-auto p-6 bg-white border-b-1 border-gray-200">
<div className="grid grid-flow-row gap-2">
<Text component="h2" className="!font-bold">
Roles & Actions overview
</Text>
<Text>
Rules for each role and action can be set by clicking on the
corresponding cell.
</Text>
</div>
<div className="grid grid-flow-col gap-4 items-center justify-start">
<Text
variant="subtitle2"
className="!text-greyscaleDark grid items-center grid-flow-col gap-1"
>
full access <FullPermissionIcon />
</Text>
<Text
variant="subtitle2"
className="!text-greyscaleDark grid items-center grid-flow-col gap-1"
>
partial access <PartialPermissionIcon />
</Text>
<Text
variant="subtitle2"
className="!text-greyscaleDark grid items-center grid-flow-col gap-1"
>
no access <NoPermissionIcon />
</Text>
</div>
<TableContainer>
<Table>
<TableHead className="block">
<TableRow className="grid grid-cols-5 items-center">
<TableCell className="border-b-0 p-2">Role</TableCell>
<TableCell className="border-b-0 p-2 text-center">
Insert
</TableCell>
<TableCell className="border-b-0 p-2 text-center">
Select
</TableCell>
<TableCell className="border-b-0 p-2 text-center">
Update
</TableCell>
<TableCell className="border-b-0 p-2 text-center">
Delete
</TableCell>
</TableRow>
</TableHead>
<TableBody className="rounded-sm+ block border-1">
<RolePermissionsRow
name="admin"
disabled
accessLevels={{
insert: 'full',
select: 'full',
update: 'full',
delete: 'full',
}}
/>
{availableRoles.map((currentRole, index) => {
const insertPermissions =
metadataForTable?.insert_permissions?.find(
({ role: permissionRole }) =>
permissionRole === currentRole,
);
const selectPermissions =
metadataForTable?.select_permissions?.find(
({ role: permissionRole }) =>
permissionRole === currentRole,
);
const updatePermissions =
metadataForTable?.update_permissions?.find(
({ role: permissionRole }) =>
permissionRole === currentRole,
);
const deletePermissions =
metadataForTable?.delete_permissions?.find(
({ role: permissionRole }) =>
permissionRole === currentRole,
);
return (
<RolePermissionsRow
name={currentRole}
key={currentRole}
className={twMerge(
index === availableRoles.length - 1 && 'border-b-0',
)}
onActionSelect={(selectedAction) => {
setRole(currentRole);
setAction(selectedAction);
}}
accessLevels={{
insert: getAccessLevel(insertPermissions?.permission),
select: getAccessLevel(selectPermissions?.permission),
update: getAccessLevel(updatePermissions?.permission),
delete: getAccessLevel(deletePermissions?.permission),
}}
/>
);
})}
</TableBody>
</Table>
</TableContainer>
<Alert className="text-left">
Please go to the{' '}
<NavLink
href={`/${currentWorkspace.slug}/${currentApplication.slug}/settings/roles-and-permissions`}
passHref
>
<Link
href="settings/roles-and-permissions"
underline="hover"
onClick={closeDrawerWithDirtyGuard}
>
Settings page
</Link>
</NavLink>{' '}
to add and delete roles.
</Alert>
</section>
</div>
<div className="grid flex-shrink-0 grid-flow-col justify-between gap-3 border-t-1 border-gray-200 p-2 bg-white">
<Button variant="borderless" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,425 @@
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 {
DatabaseAction,
HasuraMetadataPermission,
RuleGroup,
} from '@/types/dataBrowser';
import { Alert } from '@/ui/Alert';
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 { toastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import AggregationQuerySection from './sections/AggregationQuerySection';
import BackendOnlySection from './sections/BackendOnlySection';
import ColumnPermissionsSection from './sections/ColumnPermissionsSection';
import type { ColumnPreset } from './sections/ColumnPresetsSection';
import ColumnPresetsSection from './sections/ColumnPresetsSection';
import PermissionSettingsSection from './sections/PermissionSettingsSection';
import RootFieldPermissionsSection from './sections/RootFieldPermissionsSection';
import RowPermissionsSection from './sections/RowPermissionsSection';
import validationSchemas from './validationSchemas';
export interface RolePermissionEditorFormValues {
/**
* The permission filter to be applied for the role.
*/
filter: Record<string, any> | {};
/**
* The allowed columns to CRUD for the role.
*/
columns?: string[];
/**
* The number of rows to be returned for the role.
*/
limit?: number;
/**
* Whether the role is allowed to perform aggregations.
*/
allowAggregations?: boolean;
/**
* Whether the role is allowed to have access to special fields.
*/
enableRootFieldCustomization?: boolean;
/**
* The allowed root fields in queries and mutations for the role.
*/
queryRootFields?: string[];
/**
* The allowed root fields in subscriptions for the role.
*/
subscriptionRootFields?: string[];
/**
* Column presets for the role.
*/
columnPresets?: ColumnPreset[];
/**
* Whether the mutation should be restricted to trusted backends.
*/
backendOnly?: boolean;
}
export interface RolePermissionEditorFormProps {
/**
* Determines whether or not the form is disabled.
*/
disabled?: boolean;
/**
* The schema that is being edited.
*/
schema: string;
/**
* The table that is being edited.
*/
table: string;
/**
* The role that is being edited.
*/
role: string;
/**
* The action that is being edited.
*/
action: DatabaseAction;
/**
* Function to be called when the form is submitted.
*/
onSubmit: VoidFunction;
/**
* Function to be called when the editing is cancelled.
*/
onCancel: VoidFunction;
/**
* The existing permissions for the role and action.
*/
permission?: HasuraMetadataPermission['permission'];
}
function getDefaultRuleGroup(
action: DatabaseAction,
permission: HasuraMetadataPermission['permission'],
): RuleGroup | {} {
if (!permission) {
return null;
}
if (action === 'insert') {
return convertToRuleGroup(permission.check);
}
return convertToRuleGroup(permission.filter);
}
function getColumnPresets(data: Record<string, any>): ColumnPreset[] {
if (!data || Object.keys(data).length === 0) {
return [{ column: '', value: '' }];
}
return Object.keys(data).map((key) => ({
column: key,
value: data[key],
}));
}
function convertToColumnPresetObject(
columnPresets: ColumnPreset[],
): Record<string, any> {
if (columnPresets?.length === 0) {
return null;
}
const returnValue = columnPresets.reduce((data, { column, value }) => {
if (column) {
return { ...data, [column]: value };
}
return data;
}, {});
if (Object.keys(returnValue).length === 0) {
return null;
}
return returnValue;
}
export default function RolePermissionEditorForm({
schema,
table,
role,
action,
onSubmit,
onCancel,
permission,
disabled,
}: RolePermissionEditorFormProps) {
const queryClient = useQueryClient();
const {
mutateAsync: managePermission,
error,
reset: resetError,
isLoading,
} = useManagePermissionMutation({
schema,
table,
mutationOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['default.metadata'] });
},
},
});
const form = useForm<RolePermissionEditorFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
filter: getDefaultRuleGroup(action, permission),
columns: permission?.columns || [],
limit: permission?.limit || null,
allowAggregations: permission?.allow_aggregations || false,
enableRootFieldCustomization:
permission?.query_root_fields?.length > 0 ||
permission?.subscription_root_fields?.length > 0,
queryRootFields: permission?.query_root_fields || [],
subscriptionRootFields: permission?.subscription_root_fields || [],
columnPresets: getColumnPresets(permission?.set || {}),
backendOnly: permission?.backend_only || false,
},
resolver: yupResolver(validationSchemas[action]),
});
const {
formState: { dirtyFields, isSubmitting },
} = form;
const { onDirtyStateChange, openDirtyConfirmation, openAlertDialog } =
useDialog();
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'drawer');
}, [isDirty, onDirtyStateChange]);
async function handleSubmit(values: RolePermissionEditorFormValues) {
const managePermissionPromise = managePermission({
role,
action,
mode: permission ? 'update' : 'insert',
originalPermission: permission,
permission: {
set: convertToColumnPresetObject(values.columnPresets),
columns: values.columns,
limit: values.limit,
allow_aggregations: values.allowAggregations,
query_root_fields: values.queryRootFields,
subscription_root_fields: values.subscriptionRootFields,
filter:
action !== 'insert'
? convertToHasuraPermissions(values.filter as RuleGroup)
: permission?.filter,
check:
action === 'insert'
? convertToHasuraPermissions(values.filter as RuleGroup)
: permission?.check,
backend_only: values.backendOnly,
},
});
await toast.promise(
managePermissionPromise,
{
loading: 'Saving permission...',
success: 'Permission has been saved successfully.',
error: 'An error occurred while saving the permission.',
},
toastStyleProps,
);
onDirtyStateChange(false, 'drawer');
onSubmit?.();
}
function handleCancelClick() {
if (isDirty) {
openDirtyConfirmation({
props: {
onPrimaryAction: () => {
onDirtyStateChange(false, 'drawer');
onCancel?.();
},
},
});
return;
}
onCancel?.();
}
async function handleDelete() {
const deletePermissionPromise = managePermission({
role,
action,
originalPermission: permission,
mode: 'delete',
});
await toast.promise(
deletePermissionPromise,
{
loading: 'Deleting permission...',
success: 'Permission has been deleted successfully.',
error: 'An error occurred while deleting the permission.',
},
toastStyleProps,
);
onDirtyStateChange(false, 'drawer');
onSubmit?.();
}
function handleDeleteClick() {
openAlertDialog({
title: 'Delete permissions',
payload: (
<span>
Are you sure you want to delete the{' '}
<HighlightedText>{action}</HighlightedText> permissions of{' '}
<HighlightedText>{role}</HighlightedText>?
</span>
),
props: {
primaryButtonText: 'Delete',
primaryButtonColor: 'error',
onPrimaryAction: handleDelete,
},
});
}
return (
<FormProvider {...form}>
{error && error instanceof Error && (
<div className="-mt-3 mb-4 px-6">
<Alert
severity="error"
className="grid grid-flow-col items-center justify-between px-4 py-3"
>
<span className="text-left">
<strong>Error:</strong> {error.message}
</span>
<Button
variant="borderless"
color="secondary"
className="p-1"
onClick={resetError}
>
Clear
</Button>
</Alert>
</div>
)}
<Form
onSubmit={handleSubmit}
className="flex flex-auto flex-col content-between overflow-hidden border-t-1 border-gray-200 bg-[#fafafa]"
>
<div className="grid grid-flow-row gap-6 content-start flex-auto py-4 overflow-auto">
<PermissionSettingsSection
title="Selected role & action"
className="justify-between grid-flow-col"
>
<div className="grid grid-flow-col gap-4">
<Text>
Role: <HighlightedText>{role}</HighlightedText>
</Text>
<Text>
Action: <HighlightedText>{action}</HighlightedText>
</Text>
</div>
<Button variant="borderless" onClick={handleCancelClick}>
Change
</Button>
</PermissionSettingsSection>
<RowPermissionsSection
disabled={disabled}
role={role}
action={action}
schema={schema}
table={table}
/>
{action !== 'delete' && (
<ColumnPermissionsSection
disabled={disabled}
role={role}
action={action}
schema={schema}
table={table}
/>
)}
{action === 'select' && (
<>
<AggregationQuerySection role={role} disabled={disabled} />
<RootFieldPermissionsSection disabled={disabled} />
</>
)}
{(action === 'insert' || action === 'update') && (
<ColumnPresetsSection
schema={schema}
table={table}
disabled={disabled}
/>
)}
{action !== 'select' && <BackendOnlySection disabled={disabled} />}
</div>
<div className="grid flex-shrink-0 sm:grid-flow-col sm:justify-between gap-2 border-t-1 border-gray-200 p-2 bg-white">
<Button
variant="borderless"
color="secondary"
onClick={handleCancelClick}
tabIndex={isDirty ? -1 : 0}
>
Cancel
</Button>
{!disabled && (
<div className="grid grid-flow-row sm:grid-flow-col gap-2">
{Boolean(permission) && (
<Button
variant="outlined"
color="error"
onClick={handleDeleteClick}
disabled={isLoading}
>
Delete Permissions
</Button>
)}
<Button
loading={isSubmitting}
disabled={isSubmitting}
type="submit"
className="justify-self-end"
>
Save
</Button>
</div>
)}
</div>
</Form>
</FormProvider>
);
}

View File

@@ -0,0 +1,176 @@
import type { DatabaseAccessLevel, DatabaseAction } from '@/types/dataBrowser';
import IconButton from '@/ui/v2/IconButton';
import FullPermissionIcon from '@/ui/v2/icons/FullPermissionIcon';
import NoPermissionIcon from '@/ui/v2/icons/NoPermissionIcon';
import PartialPermissionIcon from '@/ui/v2/icons/PartialPermissionIcon';
import type { TableCellProps } from '@/ui/v2/TableCell';
import TableCell from '@/ui/v2/TableCell';
import type { TableRowProps } from '@/ui/v2/TableRow';
import TableRow from '@/ui/v2/TableRow';
import { twMerge } from 'tailwind-merge';
export interface RolePermissionsProps extends TableRowProps {
/**
* Role name.
*/
name: string;
/**
* Determines whether or not the actions are disabled.
*/
disabled?: boolean;
/**
* Access types for specific operations.
*/
accessLevels?: Record<DatabaseAction, DatabaseAccessLevel>;
/**
* Function to be called when the user wants to open the settings for an
* operation.
*/
onActionSelect?: (action: DatabaseAction) => void;
/**
* Props passed to individual component slots.
*/
slotProps?: {
/**
* Props passed to every cell in the table row.
*/
cell?: Partial<TableCellProps>;
};
}
function AccessLevelIcon({ level }: { level: DatabaseAccessLevel }) {
if (level === 'none') {
return <NoPermissionIcon />;
}
if (level === 'partial') {
return <PartialPermissionIcon />;
}
return <FullPermissionIcon />;
}
export default function RolePermissions({
name,
disabled,
accessLevels = {
insert: 'none',
select: 'none',
update: 'none',
delete: 'none',
},
onActionSelect,
slotProps,
className,
...props
}: RolePermissionsProps) {
const cellProps = slotProps?.cell || {};
return (
<TableRow
className={twMerge(
'grid grid-cols-5 items-center justify-items-stretch border-b-1',
className,
)}
{...props}
>
<TableCell
{...cellProps}
className={twMerge(
'block p-2 border-0 truncate border-r-1',
cellProps.className,
)}
>
{name}
</TableCell>
<TableCell
{...cellProps}
className={twMerge(
'inline-grid items-center p-0 border-0 text-center w-full h-full border-r-1',
disabled && 'justify-center',
cellProps.className,
)}
>
{disabled ? (
<AccessLevelIcon level={accessLevels.insert} />
) : (
<IconButton
variant="borderless"
color="secondary"
className="w-full h-full rounded-none"
onClick={() => onActionSelect('insert')}
>
<AccessLevelIcon level={accessLevels.insert} />
</IconButton>
)}
</TableCell>
<TableCell
{...cellProps}
className={twMerge(
'inline-grid items-center p-0 border-0 text-center w-full h-full border-r-1',
disabled && 'justify-center',
cellProps.className,
)}
>
{disabled ? (
<AccessLevelIcon level={accessLevels.select} />
) : (
<IconButton
variant="borderless"
color="secondary"
className="w-full h-full rounded-none"
onClick={() => onActionSelect('select')}
>
<AccessLevelIcon level={accessLevels.select} />
</IconButton>
)}
</TableCell>
<TableCell
{...cellProps}
className={twMerge(
'inline-grid items-center p-0 border-0 text-center w-full h-full border-r-1',
disabled && 'justify-center',
cellProps.className,
)}
>
{disabled ? (
<AccessLevelIcon level={accessLevels.update} />
) : (
<IconButton
variant="borderless"
color="secondary"
className="w-full h-full rounded-none"
onClick={() => onActionSelect('update')}
>
<AccessLevelIcon level={accessLevels.update} />
</IconButton>
)}
</TableCell>
<TableCell
{...cellProps}
className={twMerge(
'inline-grid items-center p-0 border-0 text-center w-full h-full',
disabled && 'justify-center',
cellProps.className,
)}
>
{disabled ? (
<AccessLevelIcon level={accessLevels.delete} />
) : (
<IconButton
variant="borderless"
color="secondary"
className="w-full h-full rounded-none"
onClick={() => onActionSelect('delete')}
>
<AccessLevelIcon level={accessLevels.delete} />
</IconButton>
)}
</TableCell>
</TableRow>
);
}

View File

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

View File

@@ -0,0 +1,64 @@
import ControlledSwitch from '@/components/common/ControlledSwitch';
import HighlightedText from '@/components/common/HighlightedText';
import type { RolePermissionEditorFormValues } from '@/components/dataBrowser/EditPermissionsForm/RolePermissionEditorForm';
import Text from '@/ui/v2/Text';
import { useFormContext } from 'react-hook-form';
import PermissionSettingsSection from './PermissionSettingsSection';
export interface AggregationQuerySectionProps {
/**
* The role that is being edited.
*/
role: string;
/**
* Determines whether or not the section is disabled.
*/
disabled?: boolean;
}
export default function AggregationQuerySection({
role,
disabled,
}: AggregationQuerySectionProps) {
const { setValue, getValues } =
useFormContext<RolePermissionEditorFormValues>();
return (
<PermissionSettingsSection title="Aggregation queries permissions">
<Text variant="subtitle1">
Allow queries with aggregate functions like sum, count, avg, max, min,
etc.
</Text>
<ControlledSwitch
disabled={disabled}
name="allowAggregations"
label={
<Text variant="subtitle1" component="span">
Allow <HighlightedText>{role}</HighlightedText> to make aggregation
queries
</Text>
}
onChange={(event) => {
if (event.target.checked) {
return;
}
setValue(
'queryRootFields',
getValues('queryRootFields')?.filter(
(field) => field !== 'select_aggregate',
) || [],
);
setValue(
'subscriptionRootFields',
getValues('subscriptionRootFields')?.filter(
(field) => field !== 'select_aggregate',
) || [],
);
}}
/>
</PermissionSettingsSection>
);
}

View File

@@ -0,0 +1,33 @@
import ControlledSwitch from '@/components/common/ControlledSwitch';
import Text from '@/ui/v2/Text';
import PermissionSettingsSection from './PermissionSettingsSection';
export interface BackendOnlySectionProps {
/**
* Determines whether or not the section is disabled.
*/
disabled?: boolean;
}
export default function BackendOnlySection({
disabled,
}: BackendOnlySectionProps) {
return (
<PermissionSettingsSection title="Backend only">
<Text variant="subtitle1">
When enabled, this mutation is accessible only via &apos;trusted
backends&apos;.
</Text>
<ControlledSwitch
disabled={disabled}
name="backendOnly"
label={
<Text variant="subtitle1" component="span">
Allow from backends only
</Text>
}
/>
</PermissionSettingsSection>
);
}

View File

@@ -0,0 +1,113 @@
import HighlightedText from '@/components/common/HighlightedText';
import type { RolePermissionEditorFormValues } from '@/components/dataBrowser/EditPermissionsForm/RolePermissionEditorForm';
import useTableQuery from '@/hooks/dataBrowser/useTableQuery';
import type { DatabaseAction } from '@/types/dataBrowser';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import Checkbox from '@/ui/v2/Checkbox';
import Text from '@/ui/v2/Text';
import { useFormContext, useWatch } from 'react-hook-form';
import PermissionSettingsSection from './PermissionSettingsSection';
export interface ColumnPermissionsSectionProps {
/**
* The role that is being edited.
*/
role: string;
/**
* The action that is being edited.
*/
action: DatabaseAction;
/**
* The schema that is being edited.
*/
schema: string;
/**
* The table that is being edited.
*/
table: string;
/**
* Determines whether or not the section is disabled.
*/
disabled?: boolean;
}
export default function ColumnPermissionsSection({
role,
action,
schema,
table,
disabled,
}: ColumnPermissionsSectionProps) {
const { register, setValue } =
useFormContext<RolePermissionEditorFormValues>();
const selectedColumns = useWatch({ name: 'columns' }) as string[];
const {
data: tableData,
status: tableStatus,
error: tableError,
} = useTableQuery([`default.${schema}.${table}`], { schema, table });
if (tableError) {
throw tableError;
}
const isAllSelected = selectedColumns?.length === tableData?.columns?.length;
return (
<PermissionSettingsSection title={`Column ${action} permissions`}>
<div className="grid grid-flow-col justify-between gap-2 items-center">
<Text>
Allow role <HighlightedText>{role}</HighlightedText> to{' '}
<HighlightedText>{action}</HighlightedText> columns:
</Text>
<Button
variant="borderless"
size="small"
disabled={disabled}
onClick={() => {
if (isAllSelected) {
setValue('columns', []);
return;
}
setValue(
'columns',
tableData?.columns?.map((column) => column.column_name),
);
}}
>
{isAllSelected ? 'Deselect All' : 'Select All'}
</Button>
</div>
{tableStatus === 'loading' && (
<ActivityIndicator label="Loading columns..." />
)}
{tableStatus === 'success' && (
<div className="flex flex-row gap-6 justify-start flex-wrap items-center">
{tableData?.columns?.map((column) => (
<Checkbox
disabled={disabled}
name="columns"
value={column.column_name}
label={column.column_name}
key={column.column_name}
checked={selectedColumns.includes(column.column_name)}
{...register('columns')}
/>
))}
</div>
)}
<Text variant="subtitle1">
For <strong>relationships</strong>, set permissions for the
corresponding tables/views.
</Text>
</PermissionSettingsSection>
);
}

View File

@@ -0,0 +1,215 @@
import ControlledSelect from '@/components/common/ControlledSelect';
import type { RolePermissionEditorFormValues } from '@/components/dataBrowser/EditPermissionsForm/RolePermissionEditorForm';
import useTableQuery from '@/hooks/dataBrowser/useTableQuery';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Autocomplete from '@/ui/v2/Autocomplete';
import Button from '@/ui/v2/Button';
import IconButton from '@/ui/v2/IconButton';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
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 { useTheme } from '@mui/material';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import PermissionSettingsSection from './PermissionSettingsSection';
export interface ColumnPreset {
column: string;
value: string;
}
export interface ColumnPresetSectionProps {
/**
* Schema to use for fetching available columns.
*/
schema: string;
/**
* Table to use for fetching available columns.
*/
table: string;
/**
* Determines whether or not the section is disabled.
*/
disabled?: boolean;
}
export default function ColumnPresetsSection({
schema,
table,
disabled,
}: ColumnPresetSectionProps) {
const theme = useTheme();
const {
data: tableData,
status: tableStatus,
error: tableError,
} = useTableQuery([`default.${schema}.${table}`], { schema, table });
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data: customClaimsData } = useGetAppCustomClaimsQuery({
variables: { id: currentApplication?.id },
skip: !currentApplication?.id,
});
const {
setValue,
formState: { errors },
} = useFormContext<RolePermissionEditorFormValues>();
const { fields, append, remove } = useFieldArray({ name: 'columnPresets' });
const columnPresets = useWatch({ name: 'columnPresets' }) as ColumnPreset[];
const allColumnNames: string[] =
tableData?.columns.map((column) => column.column_name) || [];
const selectedColumns = fields as (ColumnPreset & { id: string })[];
const selectedColumnsMap = columnPresets.reduce(
(map, { column }) => map.set(column, true),
new Map<string, boolean>(),
);
if (tableError) {
throw tableError;
}
const permissionVariableOptions = getPermissionVariablesArray(
customClaimsData?.app?.authJwtCustomClaims,
).map(({ key }) => ({
label: `X-Hasura-${key}`,
value: `X-Hasura-${key}`,
group: 'Permission variables',
}));
return (
<PermissionSettingsSection title="Column presets" className="gap-6">
<Text variant="subtitle1">
Set static values or session variables as pre-determined values for
columns while inserting.
</Text>
<div className="grid grid-flow-row gap-2">
<div className="grid grid-cols-[1fr_1fr_40px] gap-2">
<InputLabel as="span">Column Name</InputLabel>
<InputLabel as="span">Column Value</InputLabel>
</div>
{tableStatus === 'loading' && (
<ActivityIndicator label="Loading columns..." />
)}
<div className="grid grid-flow-row gap-4">
{tableStatus === 'success' &&
selectedColumns.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-[1fr_1fr_40px] gap-2"
>
<ControlledSelect
disabled={disabled}
name={`columnPresets.${index}.column`}
error={Boolean(
errors?.columnPresets?.at(index).column?.message,
)}
>
{allColumnNames.map((column) => (
<Option
value={column}
disabled={selectedColumnsMap.has(column)}
key={column}
>
{column}
</Option>
))}
</ControlledSelect>
<Autocomplete
disabled={disabled}
options={permissionVariableOptions}
groupBy={(option) => option.group}
name={`columnPresets.${index}.value`}
inputValue={field.value}
value={field.value}
freeSolo
fullWidth
disableClearable={false}
clearIcon={
<XIcon
className="w-4 h-4 mt-px"
sx={{ color: theme.palette.text.primary }}
/>
}
autoSelect
autoHighlight={false}
error={Boolean(
errors?.columnPresets?.at(index).value?.message,
)}
isOptionEqualToValue={(option, value) => {
if (typeof value === 'string') {
return (
option.value.toLowerCase() ===
(value as string).toLowerCase()
);
}
return (
option.value.toLowerCase() === value.value.toLowerCase()
);
}}
onChange={(_event, _value, reason, details) => {
if (reason === 'clear') {
setValue(`columnPresets.${index}.value`, null, {
shouldDirty: true,
});
return;
}
setValue(
`columnPresets.${index}.value`,
typeof details.option === 'string'
? details.option
: details.option.value,
{ shouldDirty: true },
);
}}
/>
<IconButton
disabled={disabled}
variant="outlined"
color="secondary"
className="shrink-0 grow-0 flex-[40px]"
onClick={() => {
if (fields.length === 1) {
remove(index);
append({ column: '', value: '' });
return;
}
remove(index);
}}
>
<XIcon className="w-4 h-4" />
</IconButton>
</div>
))}
</div>
<Button
variant="borderless"
startIcon={<PlusIcon />}
size="small"
onClick={() => append({ column: '', value: '' })}
disabled={
selectedColumns.length === allColumnNames.length || disabled
}
className="justify-self-start"
>
Add Column
</Button>
</div>
</PermissionSettingsSection>
);
}

View File

@@ -0,0 +1,67 @@
import type { TextProps } from '@/ui/v2/Text';
import Text from '@/ui/v2/Text';
import type { DetailedHTMLProps, HTMLProps, ReactNode } from 'react';
import { twMerge } from 'tailwind-merge';
export interface PermissionSettingsSectionProps
extends Omit<
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
'title'
> {
/**
* Title of the section.
*/
title: ReactNode;
/**
* Props to be passed to individual slots.
*/
slotProps?: {
/**
* Props to be passed to the root slot.
*/
root?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
/**
* Props to be passed to the title slot.
*/
title?: TextProps;
};
}
export default function PermissionSettingsSection({
children,
className,
title,
slotProps,
...props
}: PermissionSettingsSectionProps) {
return (
<section
{...(slotProps?.root || {})}
className={twMerge(
'bg-white border-y-1 border-gray-200',
slotProps?.root?.className,
)}
>
<Text
component="h2"
{...(slotProps?.title || {})}
className={twMerge(
'px-6 py-3 font-bold border-b-1 border-gray-200',
slotProps?.title?.className,
)}
>
{title}
</Text>
<div
{...props}
className={twMerge(
'grid grid-flow-row gap-4 items-center px-6 py-4',
className,
)}
>
{children}
</div>
</section>
);
}

View File

@@ -0,0 +1,241 @@
import ControlledSwitch from '@/components/common/ControlledSwitch';
import type { RolePermissionEditorFormValues } from '@/components/dataBrowser/EditPermissionsForm/RolePermissionEditorForm';
import Button from '@/ui/v2/Button';
import Checkbox from '@/ui/v2/Checkbox';
import Text from '@/ui/v2/Text';
import { useFormContext, useWatch } from 'react-hook-form';
import PermissionSettingsSection from './PermissionSettingsSection';
export interface RootFieldPermissionsSectionProps {
/**
* Determines whether or not the section is disabled.
*/
disabled?: boolean;
}
export default function RootFieldPermissionsSection({
disabled,
}: RootFieldPermissionsSectionProps) {
const { register, setValue } =
useFormContext<RolePermissionEditorFormValues>();
const allowAggregations = useWatch({
name: 'allowAggregations',
}) as boolean;
const enableRootFieldCustomization = useWatch({
name: 'enableRootFieldCustomization',
}) as boolean;
const checkedQueryRootFields = useWatch({
name: 'queryRootFields',
}) as string[];
const checkedSubscriptionRootFields = useWatch({
name: 'subscriptionRootFields',
}) as string[];
const numberOfAvailableQueryRootFields = allowAggregations ? 3 : 2;
const availableQueryRootFields = allowAggregations
? checkedQueryRootFields
: checkedQueryRootFields.filter((field) => field !== 'select_aggregate');
const numberOfAvailableSubscriptionRootFields = allowAggregations ? 3 : 2;
const availableSubscriptionRootFields = allowAggregations
? checkedSubscriptionRootFields
: checkedSubscriptionRootFields.filter(
(field) => field !== 'select_aggregate',
);
function toggleQueryRootFields() {
if (availableQueryRootFields.length === numberOfAvailableQueryRootFields) {
setValue('queryRootFields', []);
return;
}
if (!allowAggregations) {
setValue('queryRootFields', ['select', 'select_by_pk']);
return;
}
setValue('queryRootFields', ['select', 'select_by_pk', 'select_aggregate']);
}
function toggleSubscriptionRootFields() {
if (
availableSubscriptionRootFields.length ===
numberOfAvailableSubscriptionRootFields
) {
setValue('subscriptionRootFields', []);
return;
}
if (!allowAggregations) {
setValue('subscriptionRootFields', ['select', 'select_by_pk']);
return;
}
setValue('subscriptionRootFields', [
'select',
'select_by_pk',
'select_aggregate',
]);
}
return (
<PermissionSettingsSection title="Root fields permissions">
<Text variant="subtitle1">
By enabling this you can customize the root field permissions. When this
switch is turned off, all values are enabled by default.
</Text>
<ControlledSwitch
disabled={disabled}
name="enableRootFieldCustomization"
label={
<Text variant="subtitle1" component="span">
Enable GraphQL root field visibility customization
</Text>
}
onChange={(event) => {
if (!event.target.checked) {
setValue('queryRootFields', []);
setValue('subscriptionRootFields', []);
return;
}
if (!allowAggregations) {
setValue('queryRootFields', ['select', 'select_by_pk']);
setValue('subscriptionRootFields', ['select', 'select_by_pk']);
return;
}
setValue('queryRootFields', [
'select',
'select_by_pk',
'select_aggregate',
]);
setValue('subscriptionRootFields', [
'select',
'select_by_pk',
'select_aggregate',
]);
}}
/>
{enableRootFieldCustomization && (
<div className="grid grid-flow-row gap-4">
<div className="grid grid-flow-row gap-2">
<div className="grid grid-flow-row items-center sm:grid-flow-col gap-2 justify-center sm:justify-between">
<Text>
Allow the following root fields under the{' '}
<strong>query root field</strong>:
</Text>
<Button
disabled={disabled}
variant="borderless"
size="small"
onClick={toggleQueryRootFields}
>
{availableQueryRootFields.length ===
numberOfAvailableQueryRootFields
? 'Deselect All'
: 'Select All'}
</Button>
</div>
<div className="flex flex-row flex-wrap gap-6 justify-start">
<Checkbox
disabled={disabled}
name="queryRootFields"
value="select"
label="select"
checked={availableQueryRootFields.includes('select')}
{...register('queryRootFields')}
/>
<Checkbox
disabled={disabled}
name="queryRootFields"
value="select_by_pk"
label="select_by_pk"
checked={availableQueryRootFields.includes('select_by_pk')}
{...register('queryRootFields')}
/>
<Checkbox
disabled={!allowAggregations || disabled}
name="queryRootFields"
value="select_aggregate"
label="select_aggregate"
checked={
allowAggregations
? availableQueryRootFields.includes('select_aggregate')
: false
}
{...register('queryRootFields')}
/>
</div>
</div>
<div className="grid grid-flow-row gap-2">
<div className="grid grid-flow-row items-center sm:grid-flow-col gap-2 justify-center sm:justify-between">
<Text>
Allow the following root fields under the{' '}
<strong>subscription root field</strong>:
</Text>
<Button
disabled={disabled}
variant="borderless"
size="small"
onClick={toggleSubscriptionRootFields}
>
{availableSubscriptionRootFields.length ===
numberOfAvailableSubscriptionRootFields
? 'Deselect All'
: 'Select All'}
</Button>
</div>
<div className="flex flex-row flex-wrap gap-6 justify-start">
<Checkbox
disabled={disabled}
name="subscriptionRootFields"
value="select"
label="select"
checked={availableSubscriptionRootFields.includes('select')}
{...register('subscriptionRootFields')}
/>
<Checkbox
disabled={disabled}
name="subscriptionRootFields"
value="select_by_pk"
label="select_by_pk"
checked={availableSubscriptionRootFields.includes(
'select_by_pk',
)}
{...register('subscriptionRootFields')}
/>
<Checkbox
disabled={!allowAggregations || disabled}
name="subscriptionRootFields"
value="select_aggregate"
label="select_aggregate"
checked={
allowAggregations
? availableSubscriptionRootFields.includes(
'select_aggregate',
)
: false
}
{...register('subscriptionRootFields')}
/>
</div>
</div>
</div>
)}
</PermissionSettingsSection>
);
}

View File

@@ -0,0 +1,147 @@
import HighlightedText from '@/components/common/HighlightedText';
import type { RolePermissionEditorFormValues } from '@/components/dataBrowser/EditPermissionsForm/RolePermissionEditorForm';
import RuleGroupEditor from '@/components/dataBrowser/RuleGroupEditor';
import type { DatabaseAction, RuleGroup } from '@/types/dataBrowser';
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 { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import PermissionSettingsSection from './PermissionSettingsSection';
export interface RowPermissionsSectionProps {
/**
* Determines whether or not the section is disabled.
*/
disabled?: boolean;
/**
* The role that is being edited.
*/
role: string;
/**
* The action that is being edited.
*/
action: DatabaseAction;
/**
* The schema that is being edited.
*/
schema: string;
/**
* The table that is being edited.
*/
table: string;
}
export default function RowPermissionsSection({
role,
action,
schema,
table,
disabled,
}: RowPermissionsSectionProps) {
const {
register,
setValue,
getValues,
formState: { errors },
} = useFormContext<RolePermissionEditorFormValues>();
const { filter } = getValues();
const defaultRowCheckType =
filter &&
'rules' in filter &&
'groups' in filter &&
(filter.rules.length > 0 ||
filter.groups.length > 0 ||
filter.unsupported?.length > 0)
? 'custom'
: 'none';
const [temporaryPermissions, setTemporaryPermissions] = useState<
RuleGroup | {}
>(null);
const [rowCheckType, setRowCheckType] = useState<'none' | 'custom'>(
filter ? defaultRowCheckType : null,
);
function handleCheckTypeChange(value: typeof rowCheckType) {
setRowCheckType(value);
if (value === 'none') {
setTemporaryPermissions(getValues().filter);
// Note: https://github.com/react-hook-form/react-hook-form/issues/4055#issuecomment-950145092
// @ts-ignore
setValue('filter', {});
return;
}
setRowCheckType(value);
setValue(
'filter',
temporaryPermissions || {
operator: '_and',
rules: [{ column: '', operator: '_eq', value: '' }],
groups: [],
},
);
}
return (
<PermissionSettingsSection title={`Row ${action} permissions`}>
<Text>
Allow role <HighlightedText>{role}</HighlightedText> to{' '}
<HighlightedText>{action}</HighlightedText> rows:
</Text>
<RadioGroup
value={rowCheckType}
className="grid grid-flow-col justify-start gap-4"
onChange={(_event, value) =>
handleCheckTypeChange(value as typeof rowCheckType)
}
>
<Radio value="none" label="Without any checks" disabled={disabled} />
<Radio value="custom" label="With custom check" disabled={disabled} />
</RadioGroup>
{errors?.filter?.message && (
<Text variant="subtitle2" className="font-normal !text-red">
{errors.filter.message}
</Text>
)}
{rowCheckType === 'custom' && (
<RuleGroupEditor
name="filter"
schema={schema}
table={table}
className="w-full overflow-x-auto"
disabled={disabled}
/>
)}
{action === 'select' && (
<Input
{...register('limit')}
disabled={disabled}
id="limit"
type="number"
label="Limit number of rows"
slotProps={{
input: { className: 'max-w-xs w-full' },
inputRoot: { min: 0 },
}}
helperText={
errors?.limit?.message ||
'Set limit on number of rows fetched per request.'
}
error={Boolean(errors?.limit)}
/>
)}
</PermissionSettingsSection>
);
}

View File

@@ -0,0 +1,98 @@
import type { DatabaseAction } from '@/types/dataBrowser';
import * as Yup from 'yup';
const ruleSchema = Yup.object().shape({
column: Yup.string().nullable().required('Please select a column.'),
operator: Yup.string().nullable().required('Please select an operator.'),
value: Yup.string().nullable().required('Please enter a value.'),
});
const ruleGroupSchema = Yup.object().shape({
operator: Yup.string().test(
'operator',
'Please select an operator.',
(selectedOperator, ctx) => {
// `from` is part of the Yup API, but it's not typed.
// @ts-ignore
const [, { value }] = ctx.from;
if (Object.keys(value.filter).length > 0 && !selectedOperator) {
return false;
}
return true;
},
),
rules: Yup.array().of(ruleSchema),
groups: Yup.array().of(Yup.lazy(() => ruleGroupSchema) as any),
});
const baseValidationSchema = Yup.object().shape({
filter: ruleGroupSchema.nullable().required('Please select a filter type.'),
columns: Yup.array().of(Yup.string()).nullable(true),
});
const selectValidationSchema = baseValidationSchema.shape({
limit: Yup.number().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),
});
const columnPresetSchema = Yup.object().shape({
column: Yup.string()
.nullable()
.test('column', 'Please select a column.', (selectedColumn, ctx) => {
// `from` is part of the Yup API, but it's not typed.
// @ts-ignore
const [, { value }] = ctx.from;
if (
(value.columnPresets.length > 1 && !selectedColumn) ||
(!!ctx.parent.value && !selectedColumn)
) {
return false;
}
return true;
}),
value: Yup.string()
.nullable()
.test('value', 'Please enter a value.', (selectedValue, ctx) => {
// `from` is part of the Yup API, but it's not typed.
// @ts-ignore
const [, { value }] = ctx.from;
if (
(value.columnPresets.length > 1 && !selectedValue) ||
(!!ctx.parent.column && !selectedValue)
) {
return false;
}
return true;
}),
});
const insertValidationSchema = baseValidationSchema.shape({
backendOnly: Yup.boolean().nullable(true),
columnPresets: Yup.array().of(columnPresetSchema).nullable(true),
});
const updateValidationSchema = baseValidationSchema.shape({
backendOnly: Yup.boolean().nullable(true),
columnPresets: Yup.array().of(columnPresetSchema).nullable(true),
});
const deleteValidationSchema = baseValidationSchema.shape({
columnPresets: Yup.array().of(columnPresetSchema).nullable(true),
});
const validationSchemas: Record<DatabaseAction, Yup.ObjectSchema<any>> = {
select: selectValidationSchema,
insert: insertValidationSchema,
update: updateValidationSchema,
delete: deleteValidationSchema,
};
export default validationSchemas;

View File

@@ -3,13 +3,13 @@ import ColumnAutocomplete from '@/components/dataBrowser/ColumnAutocomplete';
import type { HasuraOperator } from '@/types/dataBrowser'; import type { HasuraOperator } from '@/types/dataBrowser';
import Option from '@/ui/v2/Option'; import Option from '@/ui/v2/Option';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import { useRouter } from 'next/router';
import type { DetailedHTMLProps, HTMLProps } from 'react'; import type { DetailedHTMLProps, HTMLProps } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { useController, useFormContext } from 'react-hook-form'; import { useController, useFormContext } from 'react-hook-form';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import RuleRemoveButton from './RuleRemoveButton'; import RuleRemoveButton from './RuleRemoveButton';
import RuleValueInput from './RuleValueInput'; import RuleValueInput from './RuleValueInput';
import useRuleGroupEditor from './useRuleGroupEditor';
export interface RuleEditorRowProps export interface RuleEditorRowProps
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> { extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
@@ -98,12 +98,14 @@ export default function RuleEditorRow({
disabledOperators = [], disabledOperators = [],
...props ...props
}: RuleEditorRowProps) { }: RuleEditorRowProps) {
const { const { schema, table, disabled } = useRuleGroupEditor();
query: { schemaSlug, tableSlug }, const { control, setValue, getFieldState } = useFormContext();
} = useRouter();
const { control, setValue } = useFormContext();
const rowName = `${name}.rules.${index}`; const rowName = `${name}.rules.${index}`;
const columnState = getFieldState(`${rowName}.column`);
const operatorState = getFieldState(`${rowName}.operator`);
const valueState = getFieldState(`${rowName}.value`);
const [selectedTablePath, setSelectedTablePath] = useState<string>(''); const [selectedTablePath, setSelectedTablePath] = useState<string>('');
const [selectedColumnType, setSelectedColumnType] = useState<string>(''); const [selectedColumnType, setSelectedColumnType] = useState<string>('');
const { field: autocompleteField } = useController({ const { field: autocompleteField } = useController({
@@ -128,30 +130,36 @@ export default function RuleEditorRow({
return ( return (
<div <div
className={twMerge( className={twMerge(
'flex lg:flex-row flex-col items-stretch lg:max-h-10 flex-1 space-y-1 lg:space-y-0', 'grid lg:grid-cols-[320px_140px_minmax(100px,_1fr)_40px] grid-flow-row lg:max-h-10 space-y-1 lg:space-y-0',
className, className,
)} )}
{...props} {...props}
> >
<ColumnAutocomplete <ColumnAutocomplete
{...autocompleteField} {...autocompleteField}
schema={schemaSlug as string} disabled={disabled}
table={tableSlug as string} schema={schema}
rootClassName="lg:flex-grow-0 lg:flex-shrink-0 lg:flex-[320px] h-10" table={table}
rootClassName="h-10"
slotProps={{ input: { className: 'bg-white lg:!rounded-r-none' } }} slotProps={{ input: { className: 'bg-white lg:!rounded-r-none' } }}
fullWidth fullWidth
error={Boolean(columnState?.error?.message)}
onChange={(_event, { value, columnMetadata, disableReset }) => { onChange={(_event, { value, columnMetadata, disableReset }) => {
setSelectedTablePath( setSelectedTablePath(
`${columnMetadata.table_schema}.${columnMetadata.table_name}`, `${columnMetadata.table_schema}.${columnMetadata.table_name}`,
); );
setSelectedColumnType(columnMetadata?.udt_name); setSelectedColumnType(columnMetadata?.udt_name);
setValue(`${rowName}.column`, value, { shouldDirty: true }); setValue(`${rowName}.column`, value, {
shouldDirty: true,
});
if (disableReset) { if (disableReset) {
return; return;
} }
setValue(`${rowName}.operator`, '_eq', { shouldDirty: true }); setValue(`${rowName}.operator`, '_eq', {
shouldDirty: true,
});
setValue(`${rowName}.value`, '', { shouldDirty: true }); setValue(`${rowName}.value`, '', { shouldDirty: true });
}} }}
onInitialized={({ value, columnMetadata }) => { onInitialized={({ value, columnMetadata }) => {
@@ -159,22 +167,32 @@ export default function RuleEditorRow({
`${columnMetadata.table_schema}.${columnMetadata.table_name}`, `${columnMetadata.table_schema}.${columnMetadata.table_name}`,
); );
setSelectedColumnType(columnMetadata?.udt_name); setSelectedColumnType(columnMetadata?.udt_name);
setValue(`${rowName}.column`, value, { shouldDirty: true }); setValue(`${rowName}.column`, value, {
shouldDirty: true,
});
}} }}
/> />
<ControlledSelect <ControlledSelect
disabled={disabled}
name={`${rowName}.operator`} name={`${rowName}.operator`}
className="lg:flex-grow-0 lg:flex-shrink-0 lg:flex-[140px] h-10" className="h-10"
slotProps={{ root: { className: 'bg-white lg:!rounded-none' } }} slotProps={{
root: { className: 'bg-white lg:!rounded-none' },
listbox: { className: 'max-h-[300px]' },
popper: { disablePortal: false, className: 'z-[10000]' },
}}
fullWidth fullWidth
error={Boolean(operatorState?.error?.message)}
onChange={(_event, value: HasuraOperator) => { onChange={(_event, value: HasuraOperator) => {
if (!['_in', '_nin', '_in_hasura', '_nin_hasura'].includes(value)) { if (!['_in', '_nin', '_in_hasura', '_nin_hasura'].includes(value)) {
return; return;
} }
if (value === '_in_hasura' || value === '_nin_hasura') { if (value === '_in_hasura' || value === '_nin_hasura') {
setValue(`${rowName}.value`, null, { shouldDirty: true }); setValue(`${rowName}.value`, null, {
shouldDirty: true,
});
return; return;
} }
@@ -200,9 +218,13 @@ export default function RuleEditorRow({
{availableOperators.map(renderOption)} {availableOperators.map(renderOption)}
</ControlledSelect> </ControlledSelect>
<RuleValueInput selectedTablePath={selectedTablePath} name={rowName} /> <RuleValueInput
selectedTablePath={selectedTablePath}
name={rowName}
error={Boolean(valueState?.error?.message)}
/>
<RuleRemoveButton onRemove={onRemove} name={name} /> <RuleRemoveButton onRemove={onRemove} name={name} disabled={disabled} />
</div> </div>
); );
} }

View File

@@ -5,6 +5,7 @@ import Text from '@/ui/v2/Text';
import type { DetailedHTMLProps, HTMLProps } from 'react'; import type { DetailedHTMLProps, HTMLProps } from 'react';
import { useWatch } from 'react-hook-form'; import { useWatch } from 'react-hook-form';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import useRuleGroupEditor from './useRuleGroupEditor';
export interface RuleGroupControlsProps export interface RuleGroupControlsProps
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> { extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
@@ -30,6 +31,7 @@ export default function RuleGroupControls({
className, className,
...props ...props
}: RuleGroupControlsProps) { }: RuleGroupControlsProps) {
const { disabled } = useRuleGroupEditor();
const currentOperator: RuleGroup['operator'] = useWatch({ const currentOperator: RuleGroup['operator'] = useWatch({
name: `${name}.operator`, name: `${name}.operator`,
}); });
@@ -41,6 +43,7 @@ export default function RuleGroupControls({
> >
{showSelect ? ( {showSelect ? (
<ControlledSelect <ControlledSelect
disabled={disabled}
name={`${name}.operator`} name={`${name}.operator`}
slotProps={{ root: { className: 'bg-white' } }} slotProps={{ root: { className: 'bg-white' } }}
fullWidth fullWidth

View File

@@ -65,7 +65,13 @@ const Template: ComponentStory<typeof RuleGroupEditor> = function Template(
<div className="grid grid-flow-row gap-2"> <div className="grid grid-flow-row gap-2">
<FormProvider {...form}> <FormProvider {...form}>
<Form onSubmit={handleSubmit} className="grid grid-flow-row gap-2"> <Form onSubmit={handleSubmit} className="grid grid-flow-row gap-2">
<RuleGroupEditor {...args} name="ruleGroupEditor" onRemove={null} /> <RuleGroupEditor
schema="public"
table="books"
{...args}
name="ruleGroupEditor"
onRemove={null}
/>
<Button type="submit" className="justify-self-start"> <Button type="submit" className="justify-self-start">
Submit Submit

View File

@@ -1,17 +1,35 @@
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Rule, RuleGroup } from '@/types/dataBrowser'; import type { Rule, RuleGroup } from '@/types/dataBrowser';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import PlusIcon from '@/ui/v2/icons/PlusIcon'; import PlusIcon from '@/ui/v2/icons/PlusIcon';
import Link from '@/ui/v2/Link';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import type { DetailedHTMLProps, HTMLProps } from 'react'; import type { DetailedHTMLProps, HTMLProps } from 'react';
import { useMemo } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form'; import { useFieldArray, useFormContext } from 'react-hook-form';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import type { RuleEditorRowProps } from './RuleEditorRow'; import type { RuleEditorRowProps } from './RuleEditorRow';
import RuleEditorRow from './RuleEditorRow'; import RuleEditorRow from './RuleEditorRow';
import RuleGroupControls from './RuleGroupControls'; import RuleGroupControls from './RuleGroupControls';
import { RuleGroupEditorContext } from './useRuleGroupEditor';
export interface RuleGroupEditorProps export interface RuleGroupEditorProps
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>, extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
Pick<RuleEditorRowProps, 'disabledOperators'> { Pick<RuleEditorRowProps, 'disabledOperators'> {
/**
* Determines whether or not the rule group editor is disabled.
*/
disabled?: boolean;
/**
* Schema for the column autocomplete.
*/
schema: string;
/**
* Table for the column autocomplete.
*/
table: string;
/** /**
* Name of the group editor. * Name of the group editor.
*/ */
@@ -46,14 +64,15 @@ export default function RuleGroupEditor({
disabledOperators = [], disabledOperators = [],
depth = 0, depth = 0,
maxDepth = 7, maxDepth = 7,
schema,
table,
disabled,
...props ...props
}: RuleGroupEditorProps) { }: RuleGroupEditorProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const form = useFormContext(); const form = useFormContext();
const { control } = form; const { control, getValues } = form;
// Note: Reason for the type cast to `never`
// https://github.com/react-hook-form/react-hook-form/issues/4055#issuecomment-950145092
const { const {
fields: rules, fields: rules,
append: appendRule, append: appendRule,
@@ -61,10 +80,11 @@ export default function RuleGroupEditor({
} = useFieldArray({ } = useFieldArray({
control, control,
name: `${name}.rules`, name: `${name}.rules`,
} as never); });
const unsupportedValues: Record<string, any>[] =
getValues(`${name}.unsupported`) || [];
// Note: Reason for the type cast to `never`
// https://github.com/react-hook-form/react-hook-form/issues/4055#issuecomment-950145092
const { const {
fields: groups, fields: groups,
append: appendGroup, append: appendGroup,
@@ -72,122 +92,164 @@ export default function RuleGroupEditor({
} = useFieldArray({ } = useFieldArray({
control, control,
name: `${name}.groups`, name: `${name}.groups`,
} as never); });
if (!form) { if (!form) {
throw new Error('RuleGroupEditor must be used in a FormContext.'); throw new Error('RuleGroupEditor must be used in a FormContext.');
} }
const contextValue = useMemo(
() => ({
disabled,
schema,
table,
}),
[disabled, schema, table],
);
return ( return (
<div <RuleGroupEditorContext.Provider value={contextValue}>
className={twMerge( <div
'rounded-lg px-2', className={twMerge(
depth === 0 && 'bg-greyscale-50', 'rounded-lg border border-r-8 border-transparent pl-2',
depth === 1 && 'bg-greyscale-100', depth === 0 && 'bg-greyscale-50',
depth === 2 && 'bg-greyscale-200', depth === 1 && 'bg-greyscale-100',
depth === 3 && 'bg-greyscale-300', depth === 2 && 'bg-greyscale-200',
depth === 4 && 'bg-greyscale-400', depth === 3 && 'bg-greyscale-300',
depth === 5 && 'bg-greyscale-500', depth === 4 && 'bg-greyscale-400',
depth >= 6 && 'bg-greyscale-600', depth === 5 && 'bg-greyscale-500',
className, depth >= 6 && 'bg-greyscale-600',
)} className,
{...props} )}
> {...props}
<div className="flex flex-col flex-auto space-y-4 lg:space-y-2 py-4"> >
{(rules as (Rule & { id: string })[]).map((rule, ruleIndex) => ( <div className="grid grid-flow-row gap-4 lg:gap-2 py-4">
<div className="flex flex-row flex-auto" key={rule.id}> {(rules as (Rule & { id: string })[]).map((rule, ruleIndex) => (
<div className="flex-[70px] flex-grow-0 flex-shrink-0 mr-2"> <div className="grid grid-cols-[70px_1fr] gap-2" key={rule.id}>
{ruleIndex === 0 && ( <div>
<Text className="p-2 !font-medium">Where</Text> {ruleIndex === 0 && (
)}
{ruleIndex > 0 && (
<RuleGroupControls name={name} showSelect={ruleIndex === 1} />
)}
</div>
<RuleEditorRow
name={name}
index={ruleIndex}
onRemove={() => removeRule(ruleIndex)}
className="flex-auto"
disabledOperators={disabledOperators}
/>
</div>
))}
{(groups as (RuleGroup & { id: string })[]).map(
(ruleGroup, ruleGroupIndex) => (
<div
className="flex flex-row flex-auto items-start mt-2"
key={ruleGroup.id}
>
<div className="flex-[70px] flex-grow-0 flex-shrink-0 mr-2">
{rules.length === 0 && ruleGroupIndex === 0 && (
<Text className="p-2 !font-medium">Where</Text> <Text className="p-2 !font-medium">Where</Text>
)} )}
<RuleGroupControls {ruleIndex > 0 && (
name={name} <RuleGroupControls name={name} showSelect={ruleIndex === 1} />
showSelect={ )}
(rules.length === 0 && ruleGroupIndex === 1) ||
(rules.length === 1 && ruleGroupIndex === 0)
}
/>
</div> </div>
<RuleGroupEditor <RuleEditorRow
onRemove={() => removeGroup(ruleGroupIndex)} name={name}
disableRemove={rules.length === 0 && groups.length === 1} index={ruleIndex}
onRemove={() => removeRule(ruleIndex)}
disabledOperators={disabledOperators} disabledOperators={disabledOperators}
name={`${name}.groups.${ruleGroupIndex}`}
className="flex-auto"
depth={depth + 1}
/> />
</div> </div>
), ))}
)}
</div>
<div className="grid grid-flow-row lg:grid-flow-col lg:justify-between gap-2 pb-2"> {(groups as (RuleGroup & { id: string })[]).map(
<div className="grid grid-flow-row lg:grid-flow-col gap-2 lg:justify-start"> (ruleGroup, ruleGroupIndex) => (
<Button <div
startIcon={<PlusIcon />} className="grid grid-cols-[70px_1fr] gap-2"
variant="borderless" key={ruleGroup.id}
onClick={() => >
appendRule({ column: '', operator: '_eq', value: '' }) <div>
} {rules.length === 0 && ruleGroupIndex === 0 && (
> <Text className="p-2 !font-medium">Where</Text>
New Rule )}
</Button>
<Button <RuleGroupControls
startIcon={<PlusIcon />} name={name}
variant="borderless" showSelect={
onClick={() => (rules.length === 0 && ruleGroupIndex === 1) ||
appendGroup({ (rules.length === 1 && ruleGroupIndex === 0)
operator: '_and', }
rules: [{ column: '', operator: '_eq', value: '' }], />
groups: [], </div>
})
} <RuleGroupEditor
disabled={depth >= maxDepth - 1} schema={schema}
> table={table}
New Group onRemove={() => removeGroup(ruleGroupIndex)}
</Button> disableRemove={rules.length === 0 && groups.length === 1}
disabledOperators={disabledOperators}
name={`${name}.groups.${ruleGroupIndex}`}
depth={depth + 1}
disabled={disabled}
/>
</div>
),
)}
{unsupportedValues?.length > 0 && (
<Alert severity="warning" className="text-left">
<Text>
This rule group contains one or more objects (e.g: _exists) that
are not supported by our dashboard yet.{' '}
{currentApplication && (
<span>
Please{' '}
<Link
href={`${generateAppServiceUrl(
currentApplication.subdomain,
currentApplication.region?.awsName,
'hasura',
)}/console/data/default/schema/${schema}/tables/${table}/permissions`}
underline="hover"
target="_blank"
rel="noopener noreferrer"
>
visit Hasura
</Link>{' '}
to edit them.
</span>
)}
</Text>
</Alert>
)}
</div> </div>
{onRemove && ( {!disabled && (
<Button <div className="grid grid-flow-row lg:grid-flow-col lg:justify-between gap-2 pb-2">
variant="borderless" <div className="grid grid-flow-row lg:grid-flow-col gap-2 lg:justify-start">
color="secondary" <Button
onClick={onRemove} startIcon={<PlusIcon />}
disabled={disableRemove} variant="borderless"
> onClick={() =>
Delete Group appendRule({ column: '', operator: '_eq', value: '' })
</Button> }
>
New Rule
</Button>
<Button
startIcon={<PlusIcon />}
variant="borderless"
onClick={() =>
appendGroup({
operator: '_and',
rules: [{ column: '', operator: '_eq', value: '' }],
groups: [],
unsupported: [],
})
}
disabled={depth >= maxDepth - 1}
>
New Group
</Button>
</div>
{onRemove && (
<Button
variant="borderless"
color="secondary"
onClick={onRemove}
disabled={disableRemove}
>
Delete Group
</Button>
)}
</div>
)} )}
</div> </div>
</div> </RuleGroupEditorContext.Provider>
); );
} }

View File

@@ -24,16 +24,19 @@ function RuleRemoveButton({
}: RuleRemoveButtonProps) { }: RuleRemoveButtonProps) {
const rules: Rule[] = useWatch({ name: `${name}.rules` }); const rules: Rule[] = useWatch({ name: `${name}.rules` });
const groups: RuleGroup[] = useWatch({ name: `${name}.groups` }); const groups: RuleGroup[] = useWatch({ name: `${name}.groups` });
const unsupported: Record<string, any>[] = useWatch({
name: `${name}.unsupported`,
});
return ( return (
<Button <Button
variant="outlined" variant="outlined"
color="secondary" color="secondary"
className={twMerge( className={twMerge(
'!bg-white lg:!rounded-l-none lg:flex-grow-0 lg:flex-shrink-0 lg:flex-[40px] !min-w-0 h-10', '!bg-white lg:!rounded-l-none !min-w-0 h-10',
className, className,
)} )}
disabled={rules.length === 1 && groups.length === 0} disabled={rules.length === 1 && !groups?.length && !unsupported?.length}
aria-label="Remove Rule" aria-label="Remove Rule"
{...props} {...props}
onClick={onRemove} onClick={onRemove}

View File

@@ -6,22 +6,12 @@ import ColumnAutocomplete from '@/components/dataBrowser/ColumnAutocomplete';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { HasuraOperator } from '@/types/dataBrowser'; import type { HasuraOperator } from '@/types/dataBrowser';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import type { InputProps } from '@/ui/v2/Input';
import Option from '@/ui/v2/Option'; import Option from '@/ui/v2/Option';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray'; import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql'; import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
import { useRouter } from 'next/router';
import { useController, useFormContext, useWatch } from 'react-hook-form'; import { useController, useFormContext, useWatch } from 'react-hook-form';
import useRuleGroupEditor from './useRuleGroupEditor';
export interface RuleValueInputProps {
/**
* Name of the parent group editor.
*/
name: string;
/**
* Path of the table selected through the column input.
*/
selectedTablePath?: string;
}
function ColumnSelectorInput({ function ColumnSelectorInput({
name, name,
@@ -47,7 +37,6 @@ function ColumnSelectorInput({
schema={schema} schema={schema}
table={table} table={table}
disableRelationships disableRelationships
rootClassName="flex-auto"
slotProps={{ slotProps={{
input: { className: 'lg:!rounded-none !bg-white !z-10' }, input: { className: 'lg:!rounded-none !bg-white !z-10' },
}} }}
@@ -64,31 +53,59 @@ function ColumnSelectorInput({
); );
} }
export interface RuleValueInputProps {
/**
* Name of the parent group editor.
*/
name: string;
/**
* Path of the table selected through the column input.
*/
selectedTablePath?: string;
/**
* Whether the input should be marked as invalid.
*/
error?: InputProps['error'];
/**
* Helper text to display below the input.
*/
helperText?: InputProps['helperText'];
}
export default function RuleValueInput({ export default function RuleValueInput({
name, name,
selectedTablePath, selectedTablePath,
error,
helperText,
}: RuleValueInputProps) { }: RuleValueInputProps) {
const { schema, table, disabled } = useRuleGroupEditor();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { setValue } = useFormContext(); const { setValue } = useFormContext();
const inputName = `${name}.value`; const inputName = `${name}.value`;
const operator: HasuraOperator = useWatch({ name: `${name}.operator` }); const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
const isHasuraInput = operator === '_in_hasura' || operator === '_nin_hasura'; const isHasuraInput = operator === '_in_hasura' || operator === '_nin_hasura';
const {
query: { schemaSlug, tableSlug },
} = useRouter();
const { data, loading, error } = useGetAppCustomClaimsQuery({ const {
data,
loading,
error: customClaimsError,
} = useGetAppCustomClaimsQuery({
variables: { id: currentApplication?.id }, variables: { id: currentApplication?.id },
skip: !isHasuraInput, skip: !isHasuraInput || !currentApplication?.id,
}); });
if (operator === '_is_null') { if (operator === '_is_null') {
return ( return (
<ControlledSelect <ControlledSelect
disabled={disabled}
name={inputName} name={inputName}
className="flex-auto"
fullWidth fullWidth
slotProps={{ root: { className: 'bg-white lg:!rounded-none h-10' } }} slotProps={{
root: { className: 'bg-white lg:!rounded-none h-10' },
popper: { disablePortal: false, className: 'z-[10000]' },
}}
error={error}
helperText={helperText}
> >
<Option value="true"> <Option value="true">
<ReadOnlyToggle <ReadOnlyToggle
@@ -110,15 +127,17 @@ export default function RuleValueInput({
if (operator === '_in' || operator === '_nin') { if (operator === '_in' || operator === '_nin') {
return ( return (
<ControlledAutocomplete <ControlledAutocomplete
disabled={disabled}
name={inputName} name={inputName}
multiple multiple
freeSolo freeSolo
limitTags={5} limitTags={3}
className="flex-auto"
slotProps={{ input: { className: 'lg:!rounded-none !bg-white !z-10' } }} slotProps={{ input: { className: 'lg:!rounded-none !bg-white !z-10' } }}
options={[]} options={[]}
fullWidth fullWidth
filterSelectedOptions filterSelectedOptions
error={error}
helperText={helperText}
/> />
); );
} }
@@ -126,28 +145,41 @@ export default function RuleValueInput({
if (['_ceq', '_cne', '_cgt', '_clt', '_cgte', '_clte'].includes(operator)) { if (['_ceq', '_cne', '_cgt', '_clt', '_cgte', '_clte'].includes(operator)) {
return ( return (
<ColumnSelectorInput <ColumnSelectorInput
disabled={disabled}
selectedTablePath={selectedTablePath} selectedTablePath={selectedTablePath}
schema={schemaSlug as string} schema={schema}
table={tableSlug as string} table={table}
name={inputName} name={inputName}
error={error}
helperText={helperText}
/> />
); );
} }
const availableHasuraPermissionVariables = !loading const availableHasuraPermissionVariables = getPermissionVariablesArray(
? getPermissionVariablesArray(data?.app?.authJwtCustomClaims).map( data?.app?.authJwtCustomClaims,
({ key }) => ({ ).map(({ key }) => ({
value: `X-Hasura-${key}`, value: `X-Hasura-${key}`,
label: `X-Hasura-${key}`, label: `X-Hasura-${key}`,
}), group: 'Frequently used',
) }));
: [];
return ( return (
<ControlledAutocomplete <ControlledAutocomplete
disabled={disabled}
freeSolo={!isHasuraInput} freeSolo={!isHasuraInput}
autoSelect={!isHasuraInput}
autoHighlight={isHasuraInput}
filterSelectedOptions
isOptionEqualToValue={(option, value) => {
if (typeof value === 'string') {
return option.value.toLowerCase() === (value as string).toLowerCase();
}
return option.value.toLowerCase() === value.value.toLowerCase();
}}
name={inputName} name={inputName}
className="flex-auto" groupBy={(option) => option.group}
slotProps={{ slotProps={{
input: { className: 'lg:!rounded-none !bg-white' }, input: { className: 'lg:!rounded-none !bg-white' },
formControl: { className: '!bg-transparent' }, formControl: { className: '!bg-transparent' },
@@ -155,12 +187,18 @@ export default function RuleValueInput({
fullWidth fullWidth
loading={loading} loading={loading}
loadingText={<ActivityIndicator label="Loading..." />} loadingText={<ActivityIndicator label="Loading..." />}
error={!!error} error={Boolean(customClaimsError) || error}
helperText={error?.message} helperText={customClaimsError?.message || helperText}
options={ options={
isHasuraInput isHasuraInput
? availableHasuraPermissionVariables ? availableHasuraPermissionVariables
: [{ value: 'X-Hasura-User-Id', label: 'X-Hasura-User-Id' }] : [
{
value: 'X-Hasura-User-Id',
label: 'X-Hasura-User-Id',
group: 'Frequently used',
},
]
} }
onChange={(_event, _value, reason, details) => { onChange={(_event, _value, reason, details) => {
if ( if (

View File

@@ -0,0 +1,21 @@
import { createContext, useContext } from 'react';
export const RuleGroupEditorContext = createContext<{
schema: string;
table: string;
disabled: boolean;
}>({
schema: '',
table: '',
disabled: false,
});
export default function useRuleGroupEditor() {
const context = useContext(RuleGroupEditorContext);
if (!context) {
throw new Error('useRuleGroupEditor must be used within a RuleGroupEditor');
}
return context;
}

View File

@@ -0,0 +1,239 @@
import Form from '@/components/common/Form';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import {
refetchGetOneUserQuery,
useInsertWorkspaceMutation,
useUpdateWorkspaceMutation,
} from '@/utils/__generated__/graphql';
import { slugifyString } from '@/utils/helpers';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useUserData } from '@nhost/nextjs';
import { useRouter } from 'next/router';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface EditWorkspaceNameFormProps {
/**
* The current workspace name if this is an edit operation.
*/
currentWorkspaceName?: string;
/**
* The current workspace name id if this is an edit operation.
*/
currentWorkspaceId?: string;
/**
* Determines whether the form is disabled.
*/
disabled?: boolean;
/**
* Submit button text.
*
* @default 'Create'
*/
submitButtonText?: string;
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
}
export interface EditWorkspaceNameFormValues {
/**
* New workspace name.
*/
newWorkspaceName: string;
}
const validationSchema = Yup.object().shape({
newWorkspaceName: Yup.string()
.required('Workspace name is required.')
.min(4, 'The new Workspace name must be at least 4 characters.')
.max(32, "The new Workspace name can't be longer than 32 characters.")
.test(
'canBeSlugified',
`This field should be at least 4 characters and can't be longer than 32 characters.`,
(value) => {
const slug = slugifyString(value);
if (slug.length < 4 || slug.length > 32) {
return false;
}
return true;
},
),
});
export default function EditWorkspaceName({
disabled,
onSubmit,
onCancel,
currentWorkspaceName,
currentWorkspaceId,
submitButtonText = 'Create',
}: EditWorkspaceNameFormProps) {
const currentUser = useUserData();
const [insertWorkspace, { client }] = useInsertWorkspaceMutation();
const [updateWorkspaceName] = useUpdateWorkspaceMutation({
refetchQueries: [
refetchGetOneUserQuery({
userId: currentUser.id,
}),
],
awaitRefetchQueries: true,
ignoreResults: true,
});
const router = useRouter();
const form = useForm<EditWorkspaceNameFormValues>({
defaultValues: {
newWorkspaceName: currentWorkspaceName || '',
},
resolver: yupResolver(validationSchema),
});
const {
register,
formState: { dirtyFields, isSubmitting, errors },
} = form;
const isDirty = Object.keys(dirtyFields).length > 0;
async function handleSubmit({
newWorkspaceName,
}: EditWorkspaceNameFormValues) {
const slug = slugifyString(newWorkspaceName);
try {
if (currentWorkspaceId) {
// 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.
await router.replace({
pathname: router.pathname,
query: { ...router.query, updating: true },
});
await toast.promise(
updateWorkspaceName({
variables: {
id: currentWorkspaceId,
workspace: {
name: newWorkspaceName,
slug,
},
},
}),
{
loading: 'Updating workspace name...',
success: 'Workspace name has been updated successfully.',
error: 'An error occurred while updating the workspace name.',
},
toastStyleProps,
);
} else {
await toast.promise(
insertWorkspace({
variables: {
workspace: {
name: newWorkspaceName,
companyName: newWorkspaceName,
email: currentUser.email,
slug,
workspaceMembers: {
data: [
{
userId: currentUser.id,
type: 'owner',
},
],
},
},
},
}),
{
loading: 'Creating new workspace...',
success: 'The new workspace has been created successfully.',
error: 'An error occurred while creating the new workspace.',
},
toastStyleProps,
);
}
} catch (error) {
if (error.message?.includes('duplicate key value')) {
form.setError(
'newWorkspaceName',
{
type: 'manual',
message: 'This workspace name is already taken.',
},
{
shouldFocus: false,
},
);
}
return;
}
await client.refetchQueries({
include: ['getOneUser'],
});
await router.push(slug);
onSubmit?.();
}
return (
<FormProvider {...form}>
<Form
onSubmit={handleSubmit}
className="flex flex-col content-between flex-auto pt-2 pb-6 overflow-hidden"
>
<div className="flex-auto px-6 overflow-y-auto">
<Input
{...register('newWorkspaceName')}
error={Boolean(errors.newWorkspaceName?.message)}
label="Name"
helperText={errors.newWorkspaceName?.message}
autoFocus={!disabled}
disabled={disabled}
fullWidth
hideEmptyHelperText
placeholder='e.g. "My Workspace"'
/>
</div>
<div className="grid flex-shrink-0 grid-flow-row gap-2 px-6 pt-4">
{!disabled && (
<Button
loading={isSubmitting}
disabled={
isSubmitting || Boolean(errors.newWorkspaceName?.message)
}
type="submit"
>
{currentWorkspaceName ? 'Save' : submitButtonText}
</Button>
)}
<Button
variant="outlined"
color="secondary"
onClick={onCancel}
tabIndex={isDirty ? -1 : 0}
autoFocus={disabled}
>
{disabled ? 'Close' : 'Cancel'}
</Button>
</div>
</Form>
</FormProvider>
);
}

View File

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

View File

@@ -9,6 +9,7 @@ import { updateOwnCache } from '@/utils/updateOwnCache';
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from '@apollo/client';
import { useUserData } from '@nhost/nextjs'; import { useUserData } from '@nhost/nextjs';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect } from 'react';
export function InviteAnnounce() { export function InviteAnnounce() {
const user = useUserData(); const user = useUserData();
@@ -21,15 +22,18 @@ export function InviteAnnounce() {
useSubmitState(); useSubmitState();
// @FIX: We probably don't want to poll every ten seconds for possible invites. (We can change later depending on how it works in production.) Maybe just on the workspace page? // @FIX: We probably don't want to poll every ten seconds for possible invites. (We can change later depending on how it works in production.) Maybe just on the workspace page?
const { data, loading, error, refetch } = const { data, loading, error, refetch, startPolling } =
useGetWorkspaceMemberInvitesToManageQuery({ useGetWorkspaceMemberInvitesToManageQuery({
variables: { variables: {
userId: user?.id, userId: user?.id,
}, },
pollInterval: 15000,
skip: !isPlatform, skip: !isPlatform,
}); });
useEffect(() => {
startPolling(15000);
}, [startPolling]);
if (loading) { if (loading) {
return null; return null;
} }

View File

@@ -4,11 +4,8 @@ import { InviteAnnounce } from '@/components/home/InviteAnnounce';
import type { BaseLayoutProps } from '@/components/layout/BaseLayout'; import type { BaseLayoutProps } from '@/components/layout/BaseLayout';
import BaseLayout from '@/components/layout/BaseLayout'; import BaseLayout from '@/components/layout/BaseLayout';
import Container from '@/components/layout/Container'; import Container from '@/components/layout/Container';
import AddWorkspace from '@/components/workspace/AddWorkspace';
import { useUI } from '@/context/UIContext';
import useIsHealthy from '@/hooks/common/useIsHealthy'; import useIsHealthy from '@/hooks/common/useIsHealthy';
import useIsPlatform from '@/hooks/common/useIsPlatform'; import useIsPlatform from '@/hooks/common/useIsPlatform';
import { Modal } from '@/ui';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Link from '@/ui/v2/Link'; import Link from '@/ui/v2/Link';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
@@ -39,7 +36,6 @@ export default function AuthenticatedLayout({
}: AuthenticatedLayoutProps) { }: AuthenticatedLayoutProps) {
const router = useRouter(); const router = useRouter();
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const { newWorkspace, closeSection } = useUI();
const { isAuthenticated, isLoading } = useAuthenticationStatus(); const { isAuthenticated, isLoading } = useAuthenticationStatus();
const isHealthy = useIsHealthy(); const isHealthy = useIsHealthy();
@@ -85,7 +81,7 @@ export default function AuthenticatedLayout({
<BaseLayout {...props}> <BaseLayout {...props}>
<Header className="flex max-h-[59px] flex-auto" /> <Header className="flex max-h-[59px] flex-auto" />
<Container className="my-12 grid max-w-md grid-flow-row justify-center gap-2 text-center"> <Container className="grid justify-center max-w-md grid-flow-row gap-2 my-12 text-center">
<div className="mx-auto"> <div className="mx-auto">
<Image <Image
src="/terminal-text.svg" src="/terminal-text.svg"
@@ -123,13 +119,7 @@ export default function AuthenticatedLayout({
} }
return ( return (
<BaseLayout className="flex h-full flex-col" {...props}> <BaseLayout className="flex flex-col h-full" {...props}>
<Modal
showModal={newWorkspace}
close={closeSection}
Component={AddWorkspace}
/>
<Header className="flex max-h-[59px] flex-auto" /> <Header className="flex max-h-[59px] flex-auto" />
<InviteAnnounce /> <InviteAnnounce />

View File

@@ -0,0 +1,164 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
refetchGetAppInjectedVariablesQuery,
useUpdateApplicationMutation,
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface EditJwtSecretFormProps {
/**
* Initial JWT secret.
*/
jwtSecret: string;
/**
* Determines whether the form is disabled.
*/
disabled?: boolean;
/**
* Submit button text.
*
* @default 'Save'
*/
submitButtonText?: string;
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
}
export interface EditJwtSecretFormValues {
/**
* JWT secret.
*/
jwtSecret: string;
}
const validationSchema = Yup.object().shape({
jwtSecret: Yup.string()
.nullable()
.required('This field is required.')
.test('isJson', 'This is not a valid JSON.', (value) => {
try {
JSON.parse(value);
return true;
} catch (error) {
return false;
}
}),
});
export default function EditJwtSecretForm({
disabled,
jwtSecret,
onSubmit,
onCancel,
submitButtonText = 'Save',
}: EditJwtSecretFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApplication] = useUpdateApplicationMutation({
refetchQueries: [
refetchGetAppInjectedVariablesQuery({ id: currentApplication?.id }),
],
});
const { onDirtyStateChange } = useDialog();
const form = useForm<EditJwtSecretFormValues>({
defaultValues: {
jwtSecret,
},
resolver: yupResolver(validationSchema),
});
const {
register,
formState: { dirtyFields, isSubmitting, errors },
} = form;
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'dialog');
}, [isDirty, onDirtyStateChange]);
async function handleSubmit(values: EditJwtSecretFormValues) {
const updateAppPromise = updateApplication({
variables: {
appId: currentApplication?.id,
app: {
hasuraGraphqlJwtSecret: values.jwtSecret,
},
},
});
await toast.promise(
updateAppPromise,
{
loading: 'Updating JWT secret...',
success: 'JWT secret has been updated successfully.',
error: 'An error occurred while updating the JWT secret.',
},
toastStyleProps,
);
onSubmit?.();
}
return (
<FormProvider {...form}>
<Form
onSubmit={handleSubmit}
className="flex flex-auto flex-col content-between overflow-hidden pb-4"
>
<div className="px-6 overflow-y-auto flex-auto">
<Input
{...register('jwtSecret')}
error={Boolean(errors.jwtSecret?.message)}
helperText={errors.jwtSecret?.message}
autoFocus={!disabled}
disabled={disabled}
aria-label="JWT Secret"
multiline
minRows={4}
fullWidth
hideEmptyHelperText
slotProps={{ inputRoot: { className: 'font-mono !text-sm' } }}
/>
</div>
<div className="grid flex-shrink-0 grid-flow-row gap-2 px-6 pt-4">
{!disabled && (
<Button
loading={isSubmitting}
disabled={isSubmitting}
type="submit"
>
{submitButtonText}
</Button>
)}
<Button
variant="outlined"
color="secondary"
onClick={onCancel}
tabIndex={isDirty ? -1 : 0}
autoFocus={disabled}
>
{disabled ? 'Close' : 'Cancel'}
</Button>
</div>
</Form>
</FormProvider>
);
}

View File

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

View File

@@ -10,7 +10,6 @@ import Divider from '@/ui/v2/Divider';
import IconButton from '@/ui/v2/IconButton'; import IconButton from '@/ui/v2/IconButton';
import EyeIcon from '@/ui/v2/icons/EyeIcon'; import EyeIcon from '@/ui/v2/icons/EyeIcon';
import EyeOffIcon from '@/ui/v2/icons/EyeOffIcon'; import EyeOffIcon from '@/ui/v2/icons/EyeOffIcon';
import Input from '@/ui/v2/Input';
import List from '@/ui/v2/List'; import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem'; import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
@@ -27,7 +26,7 @@ export default function SystemEnvironmentVariableSettings() {
const [showAdminSecret, setShowAdminSecret] = useState(false); const [showAdminSecret, setShowAdminSecret] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false); const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const { openAlertDialog } = useDialog(); const { openDialog } = useDialog();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetAppInjectedVariablesQuery({ const { data, loading, error } = useGetAppInjectedVariablesQuery({
variables: { id: currentApplication?.id }, variables: { id: currentApplication?.id },
@@ -49,30 +48,39 @@ export default function SystemEnvironmentVariableSettings() {
throw error; throw error;
} }
function showJwtSecret() { function showViewJwtSecretModal() {
openAlertDialog({ openDialog('EDIT_JWT_SECRET', {
title: 'Auth JWT Secret', title: (
payload: ( <span className="grid grid-flow-row">
<div className="grid grid-flow-row gap-2"> <span>Auth JWT Secret</span>
<Text variant="subtitle2">
<Text variant="subtitle1" component="span">
This is the key used for generating JWTs. It&apos;s HMAC-SHA-based This is the key used for generating JWTs. It&apos;s HMAC-SHA-based
and the same as configured in Hasura. and the same as configured in Hasura.
</Text> </Text>
</span>
<Input
defaultValue={data?.app?.hasuraGraphqlJwtSecret}
disabled
fullWidth
multiline
minRows={5}
hideEmptyHelperText
inputProps={{ className: 'font-mono' }}
/>
</div>
), ),
props: { payload: {
hidePrimaryAction: true, disabled: true,
secondaryButtonText: 'Close', jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
},
});
}
function showEditJwtSecretModal() {
openDialog('EDIT_JWT_SECRET', {
title: (
<span className="grid grid-flow-row">
<span>Edit JWT Secret</span>
<Text variant="subtitle1" component="span">
You can add your custom JWT secret here. Hasura will use it to
validate the identity of your users.
</Text>
</span>
),
payload: {
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
}, },
}); });
} }
@@ -109,22 +117,22 @@ export default function SystemEnvironmentVariableSettings() {
description="Environment Variables are key-value pairs configured outside your source code. They are used to store environment-specific values such as API keys." description="Environment Variables are key-value pairs configured outside your source code. They are used to store environment-specific values such as API keys."
docsLink="https://docs.nhost.io/platform/environment-variables#system-environment-variables" docsLink="https://docs.nhost.io/platform/environment-variables#system-environment-variables"
rootClassName="gap-0" rootClassName="gap-0"
className="px-0 mt-2 mb-2.5" className="mt-2 mb-2.5 px-0"
slotProps={{ submitButton: { className: 'invisible' } }} slotProps={{ submitButton: { className: 'invisible' } }}
> >
<div className="grid grid-cols-3 border-b-1 gap-2 border-gray-200 px-4 py-3"> <div className="grid grid-cols-3 gap-2 border-b-1 border-gray-200 px-4 py-3">
<Text className="font-medium">Variable Name</Text> <Text className="font-medium">Variable Name</Text>
<Text className="font-medium lg:col-span-2">Value</Text> <Text className="font-medium lg:col-span-2">Value</Text>
</div> </div>
<List> <List>
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2"> <ListItem.Root className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3">
<ListItem.Text>NHOST_ADMIN_SECRET</ListItem.Text> <ListItem.Text>NHOST_ADMIN_SECRET</ListItem.Text>
<div className="grid grid-flow-col lg:col-span-2 gap-2 items-center justify-start"> <div className="grid grid-flow-col items-center justify-start gap-2 lg:col-span-2">
<Text className="text-greyscaleGreyDark truncate"> <Text className="truncate text-greyscaleGreyDark">
{showAdminSecret ? ( {showAdminSecret ? (
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]"> <InlineCode className="!text-sm font-medium">
{currentApplication?.hasuraGraphqlAdminSecret} {currentApplication?.hasuraGraphqlAdminSecret}
</InlineCode> </InlineCode>
) : ( ) : (
@@ -141,9 +149,9 @@ export default function SystemEnvironmentVariableSettings() {
onClick={() => setShowAdminSecret((show) => !show)} onClick={() => setShowAdminSecret((show) => !show)}
> >
{showAdminSecret ? ( {showAdminSecret ? (
<EyeOffIcon className="w-5 h-5" /> <EyeOffIcon className="h-5 w-5" />
) : ( ) : (
<EyeIcon className="w-5 h-5" /> <EyeIcon className="h-5 w-5" />
)} )}
</IconButton> </IconButton>
</div> </div>
@@ -151,13 +159,13 @@ export default function SystemEnvironmentVariableSettings() {
<Divider component="li" className="!my-4" /> <Divider component="li" className="!my-4" />
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2"> <ListItem.Root className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3">
<ListItem.Text>NHOST_WEBHOOK_SECRET</ListItem.Text> <ListItem.Text>NHOST_WEBHOOK_SECRET</ListItem.Text>
<div className="grid grid-flow-col gap-2 lg:col-span-2 items-center justify-start"> <div className="grid grid-flow-col items-center justify-start gap-2 lg:col-span-2">
<Text className="text-greyscaleGreyDark truncate"> <Text className="truncate text-greyscaleGreyDark">
{showWebhookSecret ? ( {showWebhookSecret ? (
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]"> <InlineCode className="!text-sm font-medium">
{data?.app?.webhookSecret} {data?.app?.webhookSecret}
</InlineCode> </InlineCode>
) : ( ) : (
@@ -176,9 +184,9 @@ export default function SystemEnvironmentVariableSettings() {
onClick={() => setShowWebhookSecret((show) => !show)} onClick={() => setShowWebhookSecret((show) => !show)}
> >
{showWebhookSecret ? ( {showWebhookSecret ? (
<EyeOffIcon className="w-5 h-5" /> <EyeOffIcon className="h-5 w-5" />
) : ( ) : (
<EyeIcon className="w-5 h-5" /> <EyeIcon className="h-5 w-5" />
)} )}
</IconButton> </IconButton>
</div> </div>
@@ -188,7 +196,7 @@ export default function SystemEnvironmentVariableSettings() {
{systemEnvironmentVariables.map((environmentVariable, index) => ( {systemEnvironmentVariables.map((environmentVariable, index) => (
<Fragment key={environmentVariable.key}> <Fragment key={environmentVariable.key}>
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2"> <ListItem.Root className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3">
<ListItem.Text>{environmentVariable.key}</ListItem.Text> <ListItem.Text>{environmentVariable.key}</ListItem.Text>
<Text className="truncate lg:col-span-2"> <Text className="truncate lg:col-span-2">
@@ -204,17 +212,28 @@ export default function SystemEnvironmentVariableSettings() {
<Divider component="li" className="!mt-4 !mb-2.5" /> <Divider component="li" className="!mt-4 !mb-2.5" />
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 justify-start"> <ListItem.Root className="grid grid-cols-2 justify-start px-4 lg:grid-cols-3">
<ListItem.Text>NHOST_JWT_SECRET</ListItem.Text> <ListItem.Text>NHOST_JWT_SECRET</ListItem.Text>
<Button <div className="grid grid-flow-row md:grid-flow-col gap-1.5 justify-center text-center lg:text-left lg:justify-start items-center lg:col-span-2">
variant="borderless" <Button
onClick={showJwtSecret} variant="borderless"
size="small" onClick={showViewJwtSecretModal}
className="justify-self-start" size="small"
> >
Show JWT Secret Show JWT Secret
</Button> </Button>
<Text component="span">or</Text>
<Button
variant="borderless"
onClick={showEditJwtSecretModal}
size="small"
>
Edit JWT Secret
</Button>
</div>
</ListItem.Root> </ListItem.Root>
</List> </List>
</SettingsContainer> </SettingsContainer>

View File

@@ -1,5 +1,6 @@
import { useDialog } from '@/components/common/DialogProvider'; import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
@@ -55,11 +56,20 @@ export default function BaseRoleForm({
}, [isDirty, onDirtyStateChange]); }, [isDirty, onDirtyStateChange]);
return ( return (
<div className="grid grid-flow-row gap-2 px-6 pb-6"> <div className="grid grid-flow-row gap-3 px-6 pb-6">
<Text variant="subtitle1" component="span"> <Text variant="subtitle1" component="span">
Enter the name for the role below. Enter the name for the role below.
</Text> </Text>
{submitButtonText !== 'Create' && (
<Alert severity="warning" className="text-left">
<span className="text-left">
<strong>Note:</strong> Changing the name of the role will lose the
associated permissions with that role.
</span>
</Alert>
)}
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4"> <Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
<Input <Input
{...register('name')} {...register('name')}

View File

@@ -8,18 +8,18 @@ import Chip from '@/ui/v2/Chip';
import Divider from '@/ui/v2/Divider'; import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown'; import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton'; import IconButton from '@/ui/v2/IconButton';
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import LockIcon from '@/ui/v2/icons/LockIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List'; import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem'; import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import getUserRoles from '@/utils/settings/getUserRoles'; import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import { toastStyleProps } from '@/utils/settings/settingsConstants'; import LockIcon from '@/ui/v2/icons/LockIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import { import {
useGetRolesQuery, useGetRolesQuery,
useUpdateAppMutation, useUpdateAppMutation
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import { Fragment } from 'react'; import { Fragment } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@@ -156,7 +156,7 @@ export default function RoleSettings() {
className="px-0 my-2" className="px-0 my-2"
slotProps={{ submitButton: { className: 'invisible' } }} slotProps={{ submitButton: { className: 'invisible' } }}
> >
<div className="border-b-1 border-gray-200 px-4 py-3"> <div className="px-4 py-3 border-gray-200 border-b-1">
<Text className="font-medium">Name</Text> <Text className="font-medium">Name</Text>
</div> </div>
@@ -171,7 +171,7 @@ export default function RoleSettings() {
<Dropdown.Trigger <Dropdown.Trigger
asChild asChild
hideChevron hideChevron
className="absolute right-4 top-1/2 -translate-y-1/2" className="absolute -translate-y-1/2 right-4 top-1/2"
> >
<IconButton variant="borderless" color="secondary"> <IconButton variant="borderless" color="secondary">
<DotsVerticalIcon /> <DotsVerticalIcon />
@@ -257,7 +257,7 @@ export default function RoleSettings() {
</List> </List>
<Button <Button
className="justify-self-start mx-4" className="mx-4 justify-self-start"
variant="borderless" variant="borderless"
startIcon={<PlusIcon />} startIcon={<PlusIcon />}
onClick={handleOpenCreator} onClick={handleOpenCreator}

View File

@@ -2,7 +2,6 @@ import ControlledCheckbox from '@/components/common/ControlledCheckbox';
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { import {
GetAppLoginDataDocument,
useSignInMethodsQuery, useSignInMethodsQuery,
useUpdateAppMutation, useUpdateAppMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
@@ -25,9 +24,7 @@ export interface EmailAndPasswordFormValues {
export default function EmailAndPasswordSettings() { export default function EmailAndPasswordSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation({ const [updateApp] = useUpdateAppMutation();
refetchQueries: [GetAppLoginDataDocument],
});
const { data, error, loading } = useSignInMethodsQuery({ const { data, error, loading } = useSignInMethodsQuery({
variables: { variables: {

View File

@@ -1,7 +1,7 @@
import Form from '@/components/common/Form'; import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer'; import SettingsContainer from '@/components/settings/SettingsContainer';
import { import {
useGetAppLoginDataQuery, useSignInMethodsQuery,
useUpdateAppMutation, useUpdateAppMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
@@ -27,10 +27,11 @@ export default function TwitterProviderSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation(); const [updateApp] = useUpdateAppMutation();
const { data, loading, error } = useGetAppLoginDataQuery({ const { data, loading, error } = useSignInMethodsQuery({
variables: { variables: {
id: currentApplication?.id, id: currentApplication?.id,
}, },
fetchPolicy: 'cache-only',
}); });
const form = useForm<TwitterProviderFormValues>({ const form = useForm<TwitterProviderFormValues>({

View File

@@ -3,21 +3,21 @@ import type { FormControlProps } from '@/ui/v2/FormControl';
import ChevronDownIcon from '@/ui/v2/icons/ChevronDownIcon'; import ChevronDownIcon from '@/ui/v2/icons/ChevronDownIcon';
import XIcon from '@/ui/v2/icons/XIcon'; import XIcon from '@/ui/v2/icons/XIcon';
import type { InputProps } from '@/ui/v2/Input'; import type { InputProps } from '@/ui/v2/Input';
import Input from '@/ui/v2/Input'; import Input, { inputClasses } from '@/ui/v2/Input';
import { OptionBase } from '@/ui/v2/Option'; import { OptionBase } from '@/ui/v2/Option';
import { OptionGroupBase } from '@/ui/v2/OptionGroup'; import { OptionGroupBase } from '@/ui/v2/OptionGroup';
import type { StyledComponent } from '@emotion/styled'; import type { StyledComponent } from '@emotion/styled';
import type { UseAutocompleteProps } from '@mui/base/AutocompleteUnstyled'; import type { UseAutocompleteProps } from '@mui/base/AutocompleteUnstyled';
import { createFilterOptions } from '@mui/base/AutocompleteUnstyled'; import { createFilterOptions } from '@mui/base/AutocompleteUnstyled';
import PopperUnstyled from '@mui/base/PopperUnstyled'; import PopperUnstyled from '@mui/base/PopperUnstyled';
import { darken, inputBaseClasses, styled } from '@mui/material'; import { darken, styled } from '@mui/material';
import type { AutocompleteProps as MaterialAutocompleteProps } from '@mui/material/Autocomplete'; import type { AutocompleteProps as MaterialAutocompleteProps } from '@mui/material/Autocomplete';
import MaterialAutocomplete, { import MaterialAutocomplete, {
autocompleteClasses as materialAutocompleteClasses, autocompleteClasses as materialAutocompleteClasses,
} from '@mui/material/Autocomplete'; } from '@mui/material/Autocomplete';
import clsx from 'clsx'; import clsx from 'clsx';
import type { ForwardedRef } from 'react'; import type { ForwardedRef } from 'react';
import { forwardRef, useEffect, useState } from 'react'; import { forwardRef, useEffect, useRef, useState } from 'react';
export interface AutocompleteOption<TValue = string> { export interface AutocompleteOption<TValue = string> {
/** /**
@@ -112,6 +112,12 @@ const StyledTag = styled(Chip)(({ theme }) => ({
})); }));
const StyledAutocomplete = styled(MaterialAutocomplete)(({ theme }) => ({ const StyledAutocomplete = styled(MaterialAutocomplete)(({ theme }) => ({
[`&:not(.${materialAutocompleteClasses.focused})`]: {
[`& .${inputClasses.root}`]: {
maxHeight: 40,
overflow: 'auto',
},
},
[`.${materialAutocompleteClasses.endAdornment}`]: { [`.${materialAutocompleteClasses.endAdornment}`]: {
right: theme.spacing(1.5), right: theme.spacing(1.5),
}, },
@@ -127,7 +133,7 @@ const StyledAutocomplete = styled(MaterialAutocomplete)(({ theme }) => ({
>; >;
export const AutocompletePopper = styled(PopperUnstyled)(({ theme }) => ({ export const AutocompletePopper = styled(PopperUnstyled)(({ theme }) => ({
zIndex: 1, zIndex: theme.zIndex.modal + 1,
boxShadow: 'none', boxShadow: 'none',
minWidth: 320, minWidth: 320,
maxWidth: 600, maxWidth: 600,
@@ -196,6 +202,7 @@ function Autocomplete(
}: AutocompleteProps<AutocompleteOption>, }: AutocompleteProps<AutocompleteOption>,
ref: ForwardedRef<HTMLInputElement>, ref: ForwardedRef<HTMLInputElement>,
) { ) {
const inputRef = useRef<HTMLInputElement>();
const { formControl: formControlSlotProps, ...defaultComponentsProps } = const { formControl: formControlSlotProps, ...defaultComponentsProps } =
slotProps || {}; slotProps || {};
@@ -262,6 +269,17 @@ function Autocomplete(
onInputChange(event, value, reason); onInputChange(event, value, reason);
} }
}} }}
onKeyDown={(event) => {
if (event.key !== 'Escape') {
return;
}
event.stopPropagation();
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}}
PopperComponent={AutocompletePopper} PopperComponent={AutocompletePopper}
popupIcon={<ChevronDownIcon sx={{ width: 12, height: 12 }} />} popupIcon={<ChevronDownIcon sx={{ width: 12, height: 12 }} />}
getOptionLabel={(option) => { getOptionLabel={(option) => {
@@ -344,13 +362,14 @@ function Autocomplete(
...params ...params
}) => ( }) => (
<Input <Input
ref={inputRef}
slotProps={{ slotProps={{
input: { input: {
className: slotProps?.input?.className, className: slotProps?.input?.className,
sx: props.multiple sx: props.multiple
? { ? {
flexWrap: 'wrap', flexWrap: 'wrap',
[`& .${inputBaseClasses.input}`]: { [`& .${inputClasses.input}`]: {
minWidth: 30, minWidth: 30,
width: 0, width: 0,
}, },

View File

@@ -29,4 +29,5 @@ function FormControlLabel(
FormControlLabel.displayName = 'NhostFormControlLabel'; FormControlLabel.displayName = 'NhostFormControlLabel';
export { formControlLabelClasses } from '@mui/material/FormControlLabel';
export default forwardRef(FormControlLabel); export default forwardRef(FormControlLabel);

View File

@@ -71,9 +71,12 @@ const StyledInputBase = styled(MaterialInputBase)(({ theme }) => ({
outline: 'none', outline: 'none',
}, },
[`&.${inputBaseClasses.disabled}`]: { [`&.${inputBaseClasses.disabled}`]: {
color: theme.palette.grey[600], color: `${theme.palette.grey[600]} !important`,
borderColor: darken(theme.palette.grey[300], 0.1), borderColor: `${darken(theme.palette.grey[300], 0.1)} !important`,
backgroundColor: lighten(theme.palette.action.disabled, 0.75), backgroundColor: `${lighten(
theme.palette.action.disabled,
0.75,
)} !important`,
}, },
[`&:not(.${inputBaseClasses.disabled}):hover`]: { [`&:not(.${inputBaseClasses.disabled}):hover`]: {
borderColor: theme.palette.grey[600], borderColor: theme.palette.grey[600],
@@ -165,4 +168,6 @@ function Input(
Input.displayName = 'NhostInput'; Input.displayName = 'NhostInput';
export { inputBaseClasses as inputClasses } from '@mui/material/InputBase';
export default forwardRef(Input); export default forwardRef(Input);

View File

@@ -1,12 +1,21 @@
import type { LinkProps as MaterialLinkProps } from '@mui/material/Link'; import type { LinkProps as MaterialLinkProps } from '@mui/material/Link';
import MaterialLink from '@mui/material/Link'; import MaterialLink from '@mui/material/Link';
import type { ForwardedRef } from 'react';
import { forwardRef } from 'react';
export interface LinkProps extends MaterialLinkProps {} export interface LinkProps extends MaterialLinkProps {}
function Link({ children, ...props }: LinkProps) { function Link(
return <MaterialLink {...props}>{children}</MaterialLink>; { children, ...props }: LinkProps,
ref: ForwardedRef<HTMLAnchorElement>,
) {
return (
<MaterialLink ref={ref} {...props}>
{children}
</MaterialLink>
);
} }
Link.displayName = 'NhostLink'; Link.displayName = 'NhostLink';
export default Link; export default forwardRef(Link);

View File

@@ -11,16 +11,21 @@ export interface OptionProps<TValue extends {}>
extends OptionUnstyledProps<TValue> {} extends OptionUnstyledProps<TValue> {}
const StyledOption = styled(OptionUnstyled)(({ theme }) => ({ const StyledOption = styled(OptionUnstyled)(({ theme }) => ({
[`&.${optionUnstyledClasses.disabled}:not(.${optionUnstyledClasses.highlighted}):hover`]:
{
backgroundColor: 'transparent',
},
[`&.${optionUnstyledClasses.highlighted}`]: { [`&.${optionUnstyledClasses.highlighted}`]: {
backgroundColor: darken(theme.palette.action.active, 0.025), backgroundColor: darken(theme.palette.action.active, 0.025),
}, },
[`&.${optionUnstyledClasses.highlighted}:hover`]: { [`&.${optionUnstyledClasses.highlighted}:not(.${optionUnstyledClasses.disabled}):hover`]:
backgroundColor: darken(theme.palette.action.hover, 0.1), {
}, backgroundColor: darken(theme.palette.action.hover, 0.1),
},
[`&.${optionUnstyledClasses.disabled}`]: { [`&.${optionUnstyledClasses.disabled}`]: {
color: theme.palette.text.disabled, color: theme.palette.text.disabled,
}, },
[`&:hover:not(.${optionUnstyledClasses.disabled}):not(.${optionUnstyledClasses.highlighted})`]: [`&:not(.${optionUnstyledClasses.disabled}):not(.${optionUnstyledClasses.highlighted}):hover`]:
{ {
backgroundColor: theme.palette.action.hover, backgroundColor: theme.palette.action.hover,
}, },

View File

@@ -0,0 +1,119 @@
import type { FormControlLabelProps } from '@/ui/v2/FormControlLabel';
import FormControlLabel, {
formControlLabelClasses,
} from '@/ui/v2/FormControlLabel';
import { styled } from '@mui/material';
import type { RadioProps as MaterialRadioProps } from '@mui/material/Radio';
import MaterialRadio from '@mui/material/Radio';
import SvgIcon from '@mui/material/SvgIcon';
import type { ForwardedRef, PropsWithoutRef } from 'react';
import { forwardRef } from 'react';
export interface RadioProps extends MaterialRadioProps {
/**
* Value of the radio button.
*/
value?: string;
/**
* Label to be displayed next to the radio button.
*/
label?: string;
/**
* Props to be passed to individual component slots.
*/
slotProps?: {
/**
* Props to be passed to the radio button.
*/
radio?: Partial<MaterialRadioProps>;
/**
* Props to be passed to the form control label.
*/
formControl?: Partial<PropsWithoutRef<FormControlLabelProps>>;
};
}
const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
[`& .${formControlLabelClasses.label}`]: {
display: 'inline-block',
marginLeft: theme.spacing(1),
fontSize: theme.typography.pxToRem(15),
fontWeight: 500,
lineHeight: theme.typography.pxToRem(22),
},
}));
const StyledRadio = styled(MaterialRadio)(({ theme }) => ({
padding: 0,
width: 18,
height: 18,
color: theme.palette.action.disabled,
[`& > svg`]: {
width: 18,
height: 18,
},
}));
function Radio(
{ label, value, slotProps, ...props }: RadioProps,
ref: ForwardedRef<HTMLInputElement>,
) {
return (
<StyledFormControlLabel
{...(slotProps?.formControl || {})}
label={label}
value={value}
control={
<StyledRadio
checkedIcon={
<SvgIcon
width={18}
height={18}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 18 18"
>
<circle cx="9" cy="9" r="4" fill="currentColor" />
<rect
x=".75"
y=".75"
width="16.5"
height="16.5"
rx="8.25"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
/>
</SvgIcon>
}
icon={
<SvgIcon
width={18}
height={18}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 18 18"
>
<rect
x=".75"
y=".75"
width="16.5"
height="16.5"
rx="8.25"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
/>
</SvgIcon>
}
disableRipple
ref={ref}
{...slotProps?.radio}
{...props}
/>
}
/>
);
}
Radio.displayName = 'NhostRadio';
export default forwardRef(Radio);

View File

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

View File

@@ -0,0 +1,14 @@
import type { RadioGroupProps as MaterialRadioGroupProps } from '@mui/material/RadioGroup';
import MaterialRadioGroup from '@mui/material/RadioGroup';
import type { ForwardedRef } from 'react';
import { forwardRef } from 'react';
export interface RadioGroupProps extends MaterialRadioGroupProps {}
function RadioGroup(props: RadioGroupProps, ref: ForwardedRef<HTMLDivElement>) {
return <MaterialRadioGroup ref={ref} {...props} />;
}
RadioGroup.displayName = 'NhostRadioGroup';
export default forwardRef(RadioGroup);

View File

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

View File

@@ -46,9 +46,12 @@ const StyledButton = styled(ButtonUnstyled)(({ theme }) => ({
borderColor: theme.palette.grey[600], borderColor: theme.palette.grey[600],
}, },
[`&.${selectUnstyledClasses.disabled}`]: { [`&.${selectUnstyledClasses.disabled}`]: {
color: theme.palette.grey[600], color: `${theme.palette.grey[600]} !important`,
borderColor: darken(theme.palette.grey[300], 0.1), borderColor: `${darken(theme.palette.grey[300], 0.1)} !important`,
backgroundColor: lighten(theme.palette.grey[200], 0.5), backgroundColor: `${lighten(
theme.palette.action.disabled,
0.75,
)} !important`,
}, },
[`&.${selectUnstyledClasses.focusVisible}, &.${selectUnstyledClasses.expanded}`]: [`&.${selectUnstyledClasses.focusVisible}, &.${selectUnstyledClasses.expanded}`]:
{ {

View File

@@ -11,7 +11,7 @@ export default {
const Template: ComponentStory<typeof Switch> = function Template( const Template: ComponentStory<typeof Switch> = function Template(
args: SwitchProps, args: SwitchProps,
) { ) {
return <Switch {...args} />; return <Switch label="Accept Rules" {...args} />;
}; };
export const Default = Template.bind({}); export const Default = Template.bind({});

View File

@@ -1,13 +1,39 @@
import type { FormControlLabelProps } from '@/ui/v2/FormControlLabel';
import FormControlLabel from '@/ui/v2/FormControlLabel';
import SwitchUnstyled, { import SwitchUnstyled, {
switchUnstyledClasses, switchUnstyledClasses,
} from '@mui/base/SwitchUnstyled'; } from '@mui/base/SwitchUnstyled';
import type { SwitchUnstyledProps } from '@mui/base/SwitchUnstyled/SwitchUnstyled.types'; import type { SwitchUnstyledProps } from '@mui/base/SwitchUnstyled/SwitchUnstyled.types';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import type { ForwardedRef } from 'react'; import type { ForwardedRef, PropsWithoutRef } from 'react';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
export interface SwitchProps extends SwitchUnstyledProps {} export interface SwitchProps extends SwitchUnstyledProps {
/**
* Label to be displayed next to the checkbox.
*/
label?: FormControlLabelProps['label'];
/**
* Props to be passed to the internal components.
*/
slotProps?: SwitchUnstyledProps['slotProps'] & {
/**
* Props to be passed to the `Switch` component.
*/
root?: Partial<SwitchUnstyledProps>;
/**
* Props to be passed to the `FormControlLabel` component.
*/
formControlLabel?: Partial<PropsWithoutRef<FormControlLabelProps>>;
};
}
const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
display: 'grid',
gridAutoFlow: 'column',
gap: theme.spacing(1.25),
justifyContent: 'start',
}));
const StyledSwitch = styled(SwitchUnstyled)(({ theme }) => ({ const StyledSwitch = styled(SwitchUnstyled)(({ theme }) => ({
position: 'relative', position: 'relative',
@@ -86,13 +112,21 @@ const StyledSwitch = styled(SwitchUnstyled)(({ theme }) => ({
})); }));
function Switch( function Switch(
{ children, ...props }: SwitchProps, { label, slotProps, ...props }: SwitchProps,
ref: ForwardedRef<HTMLSpanElement>, ref: ForwardedRef<HTMLSpanElement>,
) { ) {
if (!label) {
return <StyledSwitch {...(slotProps?.root || {})} {...props} ref={ref} />;
}
return ( return (
<StyledSwitch {...props} ref={ref}> <StyledFormControlLabel
{children} {...(slotProps?.formControlLabel || {})}
</StyledSwitch> control={
<StyledSwitch {...(slotProps?.root || {})} {...props} ref={ref} />
}
label={label}
/>
); );
} }

View File

@@ -1,10 +1,19 @@
import { styled } from '@mui/material';
import type { TableCellProps as MaterialTableCellProps } from '@mui/material/TableCell'; import type { TableCellProps as MaterialTableCellProps } from '@mui/material/TableCell';
import MaterialTableCell from '@mui/material/TableCell'; import MaterialTableCell, { tableCellClasses } from '@mui/material/TableCell';
export interface TableCellProps extends MaterialTableCellProps {} export interface TableCellProps extends MaterialTableCellProps {}
const StyledTableCell = styled(MaterialTableCell)(({ theme }) => ({
borderColor: theme.palette.grey[400],
[`&.${tableCellClasses.head}`]: {
fontSize: theme.typography.pxToRem(12),
lineHeight: theme.typography.pxToRem(16),
},
}));
function TableCell({ children, ...props }: TableCellProps) { function TableCell({ children, ...props }: TableCellProps) {
return <MaterialTableCell {...props}>{children}</MaterialTableCell>; return <StyledTableCell {...props}>{children}</StyledTableCell>;
} }
TableCell.displayName = 'NhostTableCell'; TableCell.displayName = 'NhostTableCell';

View File

@@ -0,0 +1,24 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@mui/material/SvgIcon';
function FullPermissionIcon({ sx, ...props }: IconProps) {
return (
<SvgIcon
width="20"
height="20"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
aria-label="Three filled horizontal lines"
{...props}
sx={{ width: 20, height: 20, ...sx }}
>
<path fill="currentColor" d="M5 15h10v2.5H5z" />
<path fill="currentColor" d="M5 8.75h10v2.5H5z" />
<path fill="currentColor" d="M5 2.5h10V5H5z" />
</SvgIcon>
);
}
FullPermissionIcon.displayName = 'NhostFullPermissionIcon';
export default FullPermissionIcon;

View File

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

View File

@@ -0,0 +1,24 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@mui/material/SvgIcon';
function NoPermissionIcon({ sx, ...props }: IconProps) {
return (
<SvgIcon
width="20"
height="20"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
aria-label="Three horizontal lines"
{...props}
sx={{ width: 20, height: 20, ...sx }}
>
<path fill="currentColor" d="M5 15h10v2.5H5z" fillOpacity={0.3} />
<path fill="currentColor" d="M5 8.75h10v2.5H5z" fillOpacity={0.3} />
<path fill="currentColor" d="M5 2.5h10V5H5z" fillOpacity={0.3} />
</SvgIcon>
);
}
NoPermissionIcon.displayName = 'NhostNoPermissionIcon';
export default NoPermissionIcon;

View File

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

View File

@@ -0,0 +1,24 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@mui/material/SvgIcon';
function PartialPermissionIcon({ sx, ...props }: IconProps) {
return (
<SvgIcon
width="20"
height="20"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
aria-label="Three horizontal lines, the one in the middle filles"
{...props}
sx={{ width: 20, height: 20, ...sx }}
>
<path fill="currentColor" d="M5 15h10v2.5H5z" fillOpacity={0.3} />
<path fill="currentColor" d="M5 8.75h10v2.5H5z" />
<path fill="currentColor" d="M5 2.5h10V5H5z" fillOpacity={0.3} />
</SvgIcon>
);
}
PartialPermissionIcon.displayName = 'NhostPartialPermissionIcon';
export default PartialPermissionIcon;

View File

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

View File

@@ -0,0 +1,34 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@mui/material/SvgIcon';
function UsersIcon(props: IconProps) {
return (
<SvgIcon
width="16"
height="16"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-label="Users"
{...props}
>
<path
d="M5.5 10a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5Z"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeMiterlimit="10"
/>
<path
d="M9.713 3.621A3.25 3.25 0 1 1 10.595 10M1 12.337a5.501 5.501 0 0 1 9 0M10.595 10a5.493 5.493 0 0 1 4.5 2.337"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</SvgIcon>
);
}
UsersIcon.displayName = 'NhostUsersIcon';
export default UsersIcon;

View File

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

View File

@@ -0,0 +1,171 @@
import Form from '@/components/common/Form';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import axios from 'axios';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface CreateUserFormValues {
/**
* Email of the user to add to this project.
*/
email: string;
/**
* Password for the user.
*/
password: string;
}
export interface CreateUserFormProps {
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Function to be called when the submit is successful.
*/
onSuccess?: VoidFunction;
}
export const CreateUserFormValidationSchema = Yup.object({
email: Yup.string()
.min(5, 'Email must be at least 5 characters long.')
.email('Invalid email address')
.required('This field is required.'),
password: Yup.string()
.label('Users Password')
.min(8, 'Password must be at least 8 characters long.')
.required('This field is required.'),
});
export default function CreateUserForm({
onSuccess,
onCancel,
}: CreateUserFormProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [createUserFormError, setCreateUserFormError] = useState<Error | null>(
null,
);
const form = useForm<CreateUserFormValues>({
defaultValues: {},
reValidateMode: 'onSubmit',
resolver: yupResolver(CreateUserFormValidationSchema),
});
const {
register,
formState: { errors, isSubmitting },
setError,
} = form;
const baseAuthUrl = generateAppServiceUrl(
currentApplication.subdomain,
currentApplication.region.awsName,
'auth',
);
const signUpUrl = `${baseAuthUrl}/signup/email-password`;
async function handleCreateUser({ email, password }: CreateUserFormValues) {
setCreateUserFormError(null);
try {
await toast.promise(
axios.post(signUpUrl, {
email,
password,
}),
{
loading: 'Creating user...',
success: 'User created successfully.',
error: 'An error occurred while trying to create the user.',
},
toastStyleProps,
);
onSuccess?.();
} catch (error) {
if (error.response?.status === 409) {
setError('email', {
message: error.response.data.message,
});
return;
}
setCreateUserFormError(
new Error(error.response.data.message || 'Something went wrong.'),
);
}
}
return (
<FormProvider {...form}>
<Form
onSubmit={handleCreateUser}
className="grid grid-flow-row gap-6 px-6 pb-6"
autoComplete="off"
>
<Input
{...register('email')}
id="email"
label="Email"
placeholder="Enter Email"
hideEmptyHelperText
error={!!errors.email}
helperText={errors?.email?.message}
fullWidth
autoComplete="off"
autoFocus
/>
<Input
{...register('password')}
id="password"
label="Password"
placeholder="Enter Password"
hideEmptyHelperText
error={!!errors.password}
helperText={errors?.password?.message}
fullWidth
autoComplete="off"
type="password"
/>
{createUserFormError && (
<Alert
severity="error"
className="grid items-center justify-between grid-flow-col px-4 py-3"
>
<span className="text-left">
<strong>Error:</strong> {createUserFormError.message}
</span>
<Button
variant="borderless"
color="error"
className="p-1 text-greyscaleDark hover:text-greyscaleDark"
onClick={() => {
setCreateUserFormError(null);
}}
>
Clear
</Button>
</Alert>
)}
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting} disabled={isSubmitting}>
Create
</Button>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Form>
</FormProvider>
);
}

View File

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

View File

@@ -0,0 +1,475 @@
import ControlledCheckbox from '@/components/common/ControlledCheckbox';
import ControlledSelect from '@/components/common/ControlledSelect';
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import Input from '@/ui/v2/Input';
import InputLabel from '@/ui/v2/InputLabel';
import Option from '@/ui/v2/Option';
import Text from '@/ui/v2/Text';
import CopyIcon from '@/ui/v2/icons/CopyIcon';
import {
useGetRolesQuery,
useUpdateRemoteAppUserMutation,
} from '@/utils/__generated__/graphql';
import { copy } from '@/utils/copy';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { Avatar } from '@mui/material';
import { format } from 'date-fns';
import Image from 'next/image';
import type { RemoteAppUser } from 'pages/[workspaceSlug]/[appSlug]/users';
import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface EditUserFormProps {
/**
* This is the selected user from the user's table.
*/
user: RemoteAppUser;
/**
* Function to be called when the form is submitted.
*/
onEditUser?: (
values: EditUserFormValues,
user: RemoteAppUser,
) => Promise<void>;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* Function to be called when banning the user.
*/
onBanUser?: (user: RemoteAppUser) => Promise<void>;
/**
* Function to be called when deleting the user.
*/
onDeleteUser: (user: RemoteAppUser) => Promise<void>;
/**
* User roles
*/
roles: { [key: string]: boolean }[];
/**
* Function to be called after a successful action.
*/
onSuccessfulAction?: () => Promise<void> | void;
}
export const EditUserFormValidationSchema = Yup.object({
displayName: Yup.string(),
avatarURL: Yup.string(),
email: Yup.string()
.email('Invalid email address')
.required('This field is required.'),
emailVerified: Yup.boolean().optional(),
phoneNumber: Yup.string().nullable(),
phoneNumberVerified: Yup.boolean().optional(),
locale: Yup.string(),
defaultRole: Yup.string(),
roles: Yup.array().of(Yup.boolean()),
});
export type EditUserFormValues = Yup.InferType<
typeof EditUserFormValidationSchema
>;
export default function EditUserForm({
user,
onEditUser,
onCancel,
onDeleteUser,
roles,
onSuccessfulAction,
}: EditUserFormProps) {
const { onDirtyStateChange, openDialog } = useDialog();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const isAnonymous = user.roles.some((role) => role.role === 'anonymous');
const [isUserBanned, setIsUserBanned] = useState(user.disabled);
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
const [updateUser] = useUpdateRemoteAppUserMutation({
client: remoteProjectGQLClient,
});
const form = useForm<EditUserFormValues>({
reValidateMode: 'onSubmit',
resolver: yupResolver(EditUserFormValidationSchema),
defaultValues: {
avatarURL: user.avatarUrl,
displayName: user.displayName,
email: user.email,
emailVerified: user.emailVerified,
phoneNumber: user.phoneNumber,
phoneNumberVerified: user.phoneNumberVerified,
locale: user.locale,
defaultRole: user.defaultRole,
roles: roles.map((role) => Object.values(role)[0]),
},
});
const {
register,
handleSubmit,
formState: { errors, dirtyFields, isSubmitting, isValidating },
} = form;
const isDirty = Object.keys(dirtyFields).length > 0;
useEffect(() => {
onDirtyStateChange(isDirty, 'drawer');
}, [isDirty, onDirtyStateChange]);
function handleChangeUserPassword() {
openDialog('EDIT_USER_PASSWORD', {
title: 'Change Password',
payload: { user },
});
}
const { data: dataRoles } = useGetRolesQuery({
variables: { id: currentApplication.id },
});
const allAvailableProjectRoles = getUserRoles(
dataRoles?.app?.authUserDefaultAllowedRoles,
);
/**
* This will change the `disabled` field in the user to its opposite.
* If the user is disabled, it will be enabled and vice versa.
* We are tracking the `disabled` field as a react state variable in order to avoid
* both having to refetch this single user from the database again or causing a re-render of the drawer.
*/
async function handleUserDisabledStatus() {
const banUser = updateUser({
variables: {
id: user.id,
user: {
disabled: !isUserBanned,
},
},
});
await toast.promise(
banUser,
{
loading: user.disabled ? 'Unbanning user...' : 'Banning user...',
success: user.disabled
? 'User unbanned successfully.'
: 'User banned successfully',
error: user.disabled
? 'An error occurred while trying to unban the user.'
: 'An error occurred while trying to ban the user.',
},
{ ...toastStyleProps },
);
await onSuccessfulAction();
}
return (
<FormProvider {...form}>
<Form
className="flex flex-col border-gray-200 lg:content-between lg:flex-auto border-t-1"
onSubmit={handleSubmit(async (values) => {
await onEditUser(values, user);
})}
>
<div className="flex-auto divide-y">
<section className="grid grid-flow-col p-6 lg:grid-cols-7">
<div className="grid items-center grid-flow-col col-span-6 gap-4 place-content-start">
<Avatar className="w-12 h-12 border" src={user.avatarUrl} />
<div className="grid items-center grid-flow-row">
<Text className="text-lg font-medium">{user.displayName}</Text>
<Text className="font-normal text-sm+ text-greyscaleGreyDark">
{user.email}
</Text>
</div>
{isUserBanned && (
<Chip
component="span"
color="error"
size="small"
label="Banned"
className="self-center align-middle"
/>
)}
</div>
<div>
<Dropdown.Root>
<Dropdown.Trigger
autoFocus={false}
asChild
className="gap-2"
>
<Button variant="outlined" color="secondary">
Actions
</Button>
</Dropdown.Trigger>
<Dropdown.Content menu disablePortal className="w-full h-full">
<Dropdown.Item
className="font-medium text-red"
onClick={() => {
handleUserDisabledStatus();
setIsUserBanned((s) => !s);
}}
>
{isUserBanned ? 'Unban User' : 'Ban User'}
</Dropdown.Item>
<Dropdown.Item
className="font-medium text-red"
onClick={() => {
onDeleteUser(user);
}}
>
Delete User
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
</div>
</section>
<section className="grid grid-flow-row grid-cols-4 gap-8 p-6">
<InputLabel as="h3" className="self-center col-span-1">
User ID
</InputLabel>
<div className="grid items-center justify-start grid-flow-col col-span-3 gap-2">
<Text className="font-medium truncate">{user.id}</Text>
<IconButton
variant="borderless"
color="secondary"
aria-label="Copy User ID"
onClick={(e) => {
e.stopPropagation();
copy(user.id, 'User ID');
}}
>
<CopyIcon className="w-4 h-4" />
</IconButton>
</div>
<InputLabel as="h3" className="self-center col-span-1 ">
Created At
</InputLabel>
<Text className="col-span-3 font-medium">
{format(new Date(user.createdAt), 'yyyy-MM-dd hh:mm:ss')}
</Text>
<InputLabel as="h3" className="self-center col-span-1 ">
Last Seen
</InputLabel>
<Text className="col-span-3 font-medium">
{user.lastSeen
? `${format(new Date(user.lastSeen), 'yyyy-mm-dd hh:mm:ss')}`
: '-'}
</Text>
</section>
<section className="grid grid-flow-row gap-8 p-6">
<Input
{...register('displayName')}
id="Display Name"
label="Display Name"
variant="inline"
placeholder="Enter Display Name"
hideEmptyHelperText
error={!!errors.displayName}
helperText={errors?.displayName?.message}
fullWidth
autoComplete="off"
/>
<Input
{...register('avatarURL')}
id="Avatar URL"
label="Avatar URL"
variant="inline"
placeholder="Enter Avatar URL"
hideEmptyHelperText
error={!!errors.avatarURL}
helperText={errors?.avatarURL?.message}
fullWidth
autoComplete="off"
/>
<Input
{...register('email')}
id="email"
label="Email"
variant="inline"
placeholder="Enter Email"
hideEmptyHelperText
error={!!errors.email}
helperText={
errors.email ? (
errors?.email?.message
) : (
<ControlledCheckbox
id="emailVerified"
name="emailVerified"
label="Verified"
/>
)
}
fullWidth
autoComplete="off"
/>
<div className="grid items-center grid-flow-col grid-cols-8 col-span-1 my-1">
<div className="col-span-2 ">
<InputLabel as="h3">Password</InputLabel>
</div>
<Button
color="primary"
variant="borderless"
className="col-span-6 px-2 place-self-start"
onClick={handleChangeUserPassword}
>
Change
</Button>
</div>
<Input
{...register('phoneNumber')}
id="phoneNumber"
label="Phone Number"
variant="inline"
placeholder="Enter Phone Number"
error={!!errors.phoneNumber}
fullWidth
autoComplete="off"
helperText={
errors.phoneNumber ? (
errors?.phoneNumber?.message
) : (
<ControlledCheckbox
id="phoneNumberVerified"
name="phoneNumberVerified"
label="Verified"
disabled={!form.watch('phoneNumber')}
/>
)
}
/>
<ControlledSelect
{...register('locale')}
id="locale"
variant="inline"
label="Locale"
slotProps={{ root: { className: 'truncate' } }}
fullWidth
error={!!errors.locale}
helperText={errors?.locale?.message}
>
<Option key="en" value="en">
en
</Option>
<Option key="fr" value="fr">
fr
</Option>
</ControlledSelect>
</section>
<section className="grid gap-4 p-6 lg:grid-cols-4 place-content-start">
<div className="items-center self-center col-span-1 align-middle">
<InputLabel as="h3">OAuth Providers</InputLabel>
</div>
<div className="grid w-full grid-flow-row col-span-3 gap-y-6">
{user.userProviders.length === 0 && (
<div className="grid grid-flow-col gap-x-1 place-content-between">
<Text className="font-normal text-greyscaleGrey">
This user has no OAuth providers connected.
</Text>
</div>
)}
{user.userProviders.map((provider) => (
<div
className="grid grid-flow-col gap-3 place-content-between"
key={provider.id}
>
<div className="grid grid-flow-col gap-3 span-cols-1">
<Image
src={`/logos/${
provider.providerId[0].toUpperCase() +
provider.providerId.slice(1)
}.svg`}
width={25}
height={25}
/>
<Text className="font-medium capitalize">
{provider.providerId === 'github'
? 'GitHub'
: provider.providerId}
</Text>
</div>
</div>
))}
</div>
</section>
{!isAnonymous && (
<section className="grid grid-flow-row p-6 gap-y-10">
<ControlledSelect
{...register('defaultRole')}
id="defaultRole"
name="defaultRole"
variant="inline"
label="Default Role"
slotProps={{ root: { className: 'truncate' } }}
hideEmptyHelperText
fullWidth
error={!!errors.defaultRole}
helperText={errors?.defaultRole?.message}
>
{allAvailableProjectRoles.map((role) => (
<Option key={role.name} value={role.name}>
{role.name}
</Option>
))}
</ControlledSelect>
<div className="grid grid-flow-row gap-6 lg:grid-cols-8 lg:grid-flow-col place-content-start">
<InputLabel as="h3" className="col-span-2">
Allowed Roles
</InputLabel>
<div className="grid grid-flow-row col-span-3 gap-6">
{roles.map((role, i) => (
<ControlledCheckbox
id={`roles.${i}`}
label={Object.keys(role)[0]}
defaultChecked={Object.values(role)[0]}
name={`roles.${i}`}
/>
))}
</div>
</div>
</section>
)}
</div>
<div className="grid justify-between flex-shrink-0 w-full grid-flow-col gap-3 p-2 border-gray-200 place-self-end border-t-1 snap-end">
<Button
variant="outlined"
color="secondary"
tabIndex={isDirty ? -1 : 0}
onClick={onCancel}
>
Cancel
</Button>
<Button
type="submit"
className="justify-self-end"
disabled={!isDirty}
loading={isSubmitting || isValidating}
>
Save
</Button>
</div>
</Form>
</FormProvider>
);
}

View File

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

View File

@@ -0,0 +1,157 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
import { useUpdateRemoteAppUserMutation } from '@/utils/__generated__/graphql';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import bcrypt from 'bcryptjs';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface EditUserPasswordFormValues {
/**
* Password for the user.
*/
password: string;
/**
* Confirm Password for the user.
*/
cpassword: string;
}
export interface EditUserPasswordFormProps {
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
/**
* The selected user.
*/
user: RemoteAppGetUsersQuery['users'][0];
}
export const EditUserPasswordFormValidationSchema = Yup.object().shape({
password: Yup.string()
.label('Users Password')
.min(8, 'Password must be at least 8 characters long.')
.required('This field is required.'),
cpassword: Yup.string()
.required('Confirm Password is required')
.min(8, 'Password must be at least 8 characters long.')
.oneOf([Yup.ref('password')], 'Passwords do not match'),
});
export default function EditUserPasswordForm({
onCancel,
user,
}: EditUserPasswordFormProps) {
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
const [updateUser] = useUpdateRemoteAppUserMutation({
client: remoteProjectGQLClient,
});
const { closeDialog } = useDialog();
const [editUserPasswordFormError, setEditUserPasswordFormError] =
useState<Error | null>(null);
const form = useForm<EditUserPasswordFormValues>({
defaultValues: {},
reValidateMode: 'onSubmit',
resolver: yupResolver(EditUserPasswordFormValidationSchema),
});
const handleSubmit = async ({ password }: EditUserPasswordFormValues) => {
setEditUserPasswordFormError(null);
const passwordHash = await bcrypt.hash(password, 10);
const updateUserPasswordPromise = updateUser({
variables: {
id: user.id,
user: {
passwordHash,
},
},
client: remoteProjectGQLClient,
});
try {
await toast.promise(
updateUserPasswordPromise,
{
loading: 'Updating user password...',
success: 'User password updated successfully.',
error: 'Failed to update user password.',
},
toastStyleProps,
);
} catch (error) {
setEditUserPasswordFormError(
new Error(error.message || 'Something went wrong.'),
);
} finally {
closeDialog();
}
};
const {
register,
formState: { errors, isSubmitting },
} = form;
return (
<FormProvider {...form}>
<Form
onSubmit={handleSubmit}
className="grid grid-flow-row gap-6 px-6 pb-6"
>
<Input
{...register('password')}
id="password"
type="password"
label="Password"
placeholder="Enter Password"
hideEmptyHelperText
error={!!errors.password}
helperText={errors?.password?.message}
fullWidth
autoComplete="off"
autoFocus
/>
<Input
{...register('cpassword')}
id="confirm-password"
type="password"
label="Confirm Password"
placeholder="Enter Password"
hideEmptyHelperText
error={!!errors.cpassword}
helperText={errors?.cpassword?.message}
fullWidth
autoComplete="off"
/>
{editUserPasswordFormError && (
<Alert severity="error">
<span className="text-left">
<strong>Error:</strong> {editUserPasswordFormError.message}
</span>
</Alert>
)}
<div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting}>
Save
</Button>
<Button variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</div>
</Form>
</FormProvider>
);
}

View File

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

View File

@@ -0,0 +1,376 @@
import { useDialog } from '@/components/common/DialogProvider';
import type { EditUserFormValues } from '@/components/users/EditUserForm';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Chip from '@/ui/v2/Chip';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import DotsHorizontalIcon from '@/ui/v2/icons/DotsHorizontalIcon';
import TrashIcon from '@/ui/v2/icons/TrashIcon';
import UserIcon from '@/ui/v2/icons/UserIcon';
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
import {
useDeleteRemoteAppUserRolesMutation,
useGetRolesQuery,
useInsertRemoteAppUserRolesMutation,
useRemoteAppDeleteUserMutation,
useUpdateRemoteAppUserMutation,
} from '@/utils/__generated__/graphql';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import type { ApolloQueryResult } from '@apollo/client';
import { Avatar } from '@mui/material';
import { formatDistance } from 'date-fns';
import Image from 'next/image';
import type { RemoteAppUser } from 'pages/[workspaceSlug]/[appSlug]/users';
import { Fragment, useMemo } from 'react';
import toast from 'react-hot-toast';
export interface UsersBodyProps<T = {}> {
/**
* The users fetched from entering the users page given a limit and offset.
* @remark users will be an empty array if there are no users.
*/
users?: RemoteAppUser[];
/**
* Function to be called after a successful action.
*
* @example onSuccessfulAction={() => refetch()}
* @example onSuccessfulAction={() => router.reload()}
*/
onSuccessfulAction?: () => Promise<void> | void | Promise<T>;
}
export default function UsersBody({
users,
onSuccessfulAction,
}: UsersBodyProps<ApolloQueryResult<RemoteAppGetUsersQuery>>) {
const { openAlertDialog, openDrawer, closeDrawer } = useDialog();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
const [deleteUser] = useRemoteAppDeleteUserMutation({
client: remoteProjectGQLClient,
});
const [updateUser] = useUpdateRemoteAppUserMutation({
client: remoteProjectGQLClient,
});
const [insertUserRoles] = useInsertRemoteAppUserRolesMutation({
client: remoteProjectGQLClient,
});
const [deleteUserRoles] = useDeleteRemoteAppUserRolesMutation({
client: remoteProjectGQLClient,
});
/**
* We want to fetch the queries of the application on this page since we're
* going to use once the user selects a user of their application; we use it
* in the drawer form.
*/
const { data: dataRoles } = useGetRolesQuery({
variables: { id: currentApplication.id },
});
const allAvailableProjectRoles = useMemo(
() => getUserRoles(dataRoles?.app?.authUserDefaultAllowedRoles),
[dataRoles],
);
async function handleEditUser(
values: EditUserFormValues,
user: RemoteAppUser,
) {
const updateUserMutationPromise = updateUser({
variables: {
id: user.id,
user: {
displayName: values.displayName,
email: values.email,
avatarUrl: values.avatarURL,
emailVerified: values.emailVerified,
defaultRole: values.defaultRole,
phoneNumber: values.phoneNumber,
phoneNumberVerified: values.phoneNumberVerified,
locale: values.locale,
},
},
});
const newRoles = allAvailableProjectRoles
.filter((role, i) => values.roles[i] === true)
.map((role) => role.name);
const userHasRoles = user.roles.map((role) => role.role);
const rolesToAdd = newRoles.filter(
(value) => !userHasRoles.includes(value),
);
const rolesToRemove = userHasRoles.filter(
(value: string) => !newRoles.includes(value),
);
if (rolesToAdd.length !== 0) {
await insertUserRoles({
variables: {
roles: rolesToAdd.map((role) => ({
userId: user.id,
role,
})),
},
});
}
if (rolesToRemove.length !== 0) {
await deleteUserRoles({
variables: {
userId: user.id,
roles: rolesToRemove,
},
});
}
await toast.promise(
updateUserMutationPromise,
{
loading: `Updating user's settings...`,
success: 'User settings updated successfully.',
error: `An error occurred while trying to update this user's settings.`,
},
{ ...toastStyleProps },
);
await onSuccessfulAction?.();
closeDrawer();
}
function handleDeleteUser(user: RemoteAppUser) {
openAlertDialog({
title: 'Delete User',
payload: (
<Text>
Are you sure you want to delete the &quot;
<strong>{user.displayName}</strong>&quot; user? This cannot be undone.
</Text>
),
props: {
onPrimaryAction: async () => {
await toast.promise(
deleteUser({
variables: {
id: user.id,
},
}),
{
loading: 'Deleting user...',
success: 'User deleted successfully.',
error: 'An error occurred while trying to delete this user.',
},
toastStyleProps,
);
await onSuccessfulAction();
closeDrawer();
},
primaryButtonColor: 'error',
primaryButtonText: 'Delete',
},
});
}
function handleViewUser(user: RemoteAppUser) {
openDrawer('EDIT_USER', {
title: 'User Details',
payload: {
user,
onEditUser: handleEditUser,
onDeleteUser: handleDeleteUser,
onSuccessfulAction,
roles: allAvailableProjectRoles.map((role) => ({
[role.name]: user.roles.some(
(userRole) => userRole.role === role.name,
),
})),
},
});
}
return (
<>
{!users && (
<div className="w-screen h-screen overflow-hidden">
<div className="absolute top-0 left-0 z-50 block w-full h-full">
<span className="relative block mx-auto my-0 top50percent top-1/2">
<ActivityIndicator
label="Loading users..."
className="flex items-center justify-center my-auto"
/>
</span>
</div>
</div>
)}
<List>
{users.map((user) => (
<Fragment key={user.id}>
<ListItem.Root
className="w-full h-[64px]"
secondaryAction={
<Dropdown.Root>
<Dropdown.Trigger asChild hideChevron>
<IconButton variant="borderless" color="secondary">
<DotsHorizontalIcon />
</IconButton>
</Dropdown.Trigger>
<Dropdown.Content
menu
PaperProps={{ className: 'w-32' }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Dropdown.Item
onClick={() => {
handleViewUser(user);
}}
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
>
<UserIcon className="w-4 h-4" />
<Text className="font-medium">View User</Text>
</Dropdown.Item>
<Divider component="li" />
<Dropdown.Item
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium text-red"
onClick={() => handleDeleteUser(user)}
>
<TrashIcon className="w-4 h-4" />
<Text className="font-medium text-red">Delete</Text>
</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
}
>
<ListItem.Button
className="grid lg:grid-cols-6 grid-cols-1 cursor-pointer py-2.5 h-full w-full hover:bg-gray-100 focus:bg-gray-100 focus:outline-none motion-safe:transition-colors"
onClick={() => handleViewUser(user)}
>
<div className="grid grid-flow-col col-span-2 gap-4 place-content-start">
<Avatar
src={user.avatarUrl}
className="border"
alt="User's Avatar"
/>
<div className="grid items-center grid-flow-row">
<div className="grid items-center grid-flow-col gap-2">
<Text className="font-medium leading-5 truncate">
{user.displayName}
</Text>
{user.disabled && (
<Chip
component="span"
color="error"
size="small"
label="Banned"
className="self-center align-middle"
/>
)}
</div>
<Text className="font-normal truncate text-greyscaleGreyDark">
{user.email}
</Text>
</div>
</div>
<Text
color="greyscaleDark"
className="hidden px-2 font-normal md:block"
size="normal"
>
{user.createdAt
? `${formatDistance(
new Date(user.createdAt),
new Date(),
)} ago`
: '-'}
</Text>
<Text
color="greyscaleDark"
className="hidden px-4 font-normal md:block"
size="normal"
>
{user.lastSeen
? `${formatDistance(
new Date(user.lastSeen),
new Date(),
)} ago`
: '-'}
</Text>
<div className="hidden grid-flow-col col-span-2 gap-3 px-4 lg:grid place-content-start">
{user.userProviders.length === 0 && (
<Text className="col-span-3 font-medium">-</Text>
)}
{user.userProviders.slice(0, 4).map((provider) => (
<Chip
component="span"
color="default"
size="small"
key={provider.id}
label={
provider.providerId === 'github'
? 'GitHub'
: provider.providerId
}
className="capitalize"
sx={{
paddingLeft: '0.55rem',
}}
icon={
<Image
src={`/logos/${provider.providerId}.svg`}
width={16}
height={16}
/>
}
/>
))}
{user.userProviders.length > 3 && (
<Chip
component="span"
color="default"
size="small"
label={`+${user.userProviders.length - 3}`}
className="font-medium"
/>
)}
</div>
</ListItem.Button>
</ListItem.Root>
<Divider component="li" />
</Fragment>
))}
</List>
</>
);
}

View File

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

View File

@@ -1,159 +0,0 @@
import { useUI } from '@/context/UIContext';
import { useInsertWorkspaceMutation } from '@/generated/graphql';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { getErrorMessage, inputErrorMessages } from '@/utils/getErrorMessage';
import { slugifyString } from '@/utils/helpers';
import { nhost } from '@/utils/nhost';
import { triggerToast } from '@/utils/toast';
import router from 'next/router';
import React, { useState } from 'react';
import slugify from 'slugify';
function AddNewWorkspaceForm({
closeSection: externalCloseSection,
}: {
closeSection: VoidFunction;
}) {
const [workspace, setWorkspace] = useState('');
const { closeSection } = useUI();
const [workspaceError, setWorkspaceError] = useState<string>('');
const [loadingAddWorkspace, setLoadingAddWorkspace] = useState(false);
const [insertWorkspace, { client }] = useInsertWorkspaceMutation();
const slug = slugify(workspace, { lower: true, strict: true });
const user = nhost.auth.getUser();
if (!user) {
return <div>No user..</div>;
}
const userId = user.id;
async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
e.preventDefault();
setWorkspaceError('');
setLoadingAddWorkspace(true);
if (
!inputErrorMessages(
workspace,
setWorkspace,
setWorkspaceError,
'Workspace',
)
) {
return;
}
if (slug.length < 4 || slug.length > 32) {
setWorkspaceError('Slug should be within 4 and 32 characters.');
setLoadingAddWorkspace(false);
return;
}
const currentUser = nhost.auth.getUser();
if (!currentUser) {
triggerToast('User is not signed in');
setLoadingAddWorkspace(false);
return;
}
try {
await insertWorkspace({
variables: {
workspace: {
name: workspace,
companyName: workspace,
email: user.email,
slug,
workspaceMembers: {
data: [
{
userId,
type: 'owner',
},
],
},
},
},
});
await client.refetchQueries({ include: ['getOneUser'] });
router.push(`/${slug}`);
setLoadingAddWorkspace(false);
closeSection();
} catch (error: any) {
setWorkspaceError(getErrorMessage(error, 'workspace'));
setLoadingAddWorkspace(false);
}
}
return (
<form onSubmit={handleSubmit} className="grid grid-flow-row gap-4">
<Input
type="text"
placeholder="Your new workspace"
name="workspace"
id="workspace"
label="Workspace"
fullWidth
autoFocus
helperText={`https://app.nhost.io/${slugifyString(workspace)}`}
onChange={(event) => {
setWorkspace(event.target.value);
setWorkspaceError('');
}}
/>
{workspaceError && <Alert severity="error">{workspaceError}</Alert>}
<div className="grid grid-flow-col justify-between gap-2">
<Button
variant="outlined"
color="secondary"
onClick={(e) => {
e.preventDefault();
externalCloseSection();
}}
>
Cancel
</Button>
<Button
type="submit"
disabled={!!workspaceError}
loading={loadingAddWorkspace}
>
Create Workspace
</Button>
</div>
</form>
);
}
export default function AddWorkspace() {
const { closeSection } = useUI();
const user = nhost.auth.getUser();
if (!user) {
return <div>No user..</div>;
}
return (
<div className="grid w-modal grid-flow-row gap-2 px-6 py-6 text-left">
<div className="grid w-full grid-flow-row gap-1">
<Text variant="h3" component="h2">
New Workspace
</Text>
<Text variant="subtitle2">
Invite team members to workspaces to work collaboratively.
</Text>
</div>
<AddNewWorkspaceForm closeSection={closeSection} />
</div>
);
}

View File

@@ -1,17 +1,21 @@
import { Avatar } from '@/ui/Avatar'; import { Avatar } from '@/ui/Avatar';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import { nhost } from '@/utils/nhost';
import { useGetWorkspacesQuery } from '@/utils/__generated__/graphql'; import { useGetWorkspacesQuery } from '@/utils/__generated__/graphql';
import { nhost } from '@/utils/nhost';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect } from 'react'; import { useEffect } from 'react';
export default function SidebarWorkspaces() { export default function SidebarWorkspaces() {
const user = nhost.auth.getUser(); const user = nhost.auth.getUser();
const { data, loading, stopPolling } = useGetWorkspacesQuery({ const { data, loading, startPolling, stopPolling } = useGetWorkspacesQuery({
pollInterval: 1000, fetchPolicy: 'cache-and-network',
}); });
useEffect(() => {
startPolling(1000);
}, [startPolling]);
// keep polling for workspaces until there is a workspace available. // keep polling for workspaces until there is a workspace available.
// We do this because when a user signs up a workspace is created automatically // We do this because when a user signs up a workspace is created automatically
// and the serverless function can take some time to complete. // and the serverless function can take some time to complete.
@@ -26,7 +30,7 @@ export default function SidebarWorkspaces() {
<div className="mt-3 mb-4 space-y-2"> <div className="mt-3 mb-4 space-y-2">
<div className="flex flex-row"> <div className="flex flex-row">
<svg <svg
className="ml-1 h-4 w-4 animate-spin self-center text-dark" className="self-center w-4 h-4 ml-1 animate-spin text-dark"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -45,7 +49,7 @@ export default function SidebarWorkspaces() {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/> />
</svg> </svg>
<Text size="tiny" className="ml-2 self-center" color="greyscaleGrey"> <Text size="tiny" className="self-center ml-2" color="greyscaleGrey">
Creating first workspace... Creating first workspace...
</Text> </Text>
</div> </div>
@@ -64,12 +68,12 @@ export default function SidebarWorkspaces() {
> >
{name === 'Default Workspace' && creatorUserId === user.id ? ( {name === 'Default Workspace' && creatorUserId === user.id ? (
<Avatar <Avatar
className="h-8 w-8 self-center rounded-full" className="self-center w-8 h-8 rounded-full"
name={user?.displayName} name={user?.displayName}
avatarUrl={user?.avatarUrl} avatarUrl={user?.avatarUrl}
/> />
) : ( ) : (
<div className="inline-block h-8 w-8 overflow-hidden rounded-lg"> <div className="inline-block w-8 h-8 overflow-hidden rounded-lg">
<Image <Image
src="/logos/new.svg" src="/logos/new.svg"
alt="Nhost Logo" alt="Nhost Logo"

View File

@@ -1,4 +1,4 @@
import ChangeWorkspaceName from '@/components/applications/ChangeWorkspaceName'; import { useDialog } from '@/components/common/DialogProvider';
import RemoveWorkspaceModal from '@/components/workspace/RemoveWorkspaceModal'; import RemoveWorkspaceModal from '@/components/workspace/RemoveWorkspaceModal';
import { useUI } from '@/context/UIContext'; import { useUI } from '@/context/UIContext';
import { useGetWorkspace } from '@/hooks/use-GetWorkspace'; import { useGetWorkspace } from '@/hooks/use-GetWorkspace';
@@ -13,7 +13,6 @@ import { copy } from '@/utils/copy';
import { nhost } from '@/utils/nhost'; import { nhost } from '@/utils/nhost';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react';
export default function WorkspaceHeader() { export default function WorkspaceHeader() {
const { currentWorkspace } = useCurrentWorkspaceAndApplication(); const { currentWorkspace } = useCurrentWorkspaceAndApplication();
@@ -21,14 +20,14 @@ export default function WorkspaceHeader() {
query: { workspaceSlug }, query: { workspaceSlug },
} = useRouter(); } = useRouter();
const [changeWorkspaceNameModal, setChangeWorkspaceNameModal] =
useState(false);
const { const {
openDeleteWorkspaceModal, openDeleteWorkspaceModal,
closeDeleteWorkspaceModal, closeDeleteWorkspaceModal,
deleteWorkspaceModal, deleteWorkspaceModal,
} = useUI(); } = useUI();
const { openDialog } = useDialog();
const { data } = useGetWorkspace(workspaceSlug); const { data } = useGetWorkspace(workspaceSlug);
const workspace = data?.workspaces[0]; const workspace = data?.workspaces[0];
@@ -45,11 +44,6 @@ export default function WorkspaceHeader() {
return ( return (
<div className="mx-auto flex max-w-3xl flex-col"> <div className="mx-auto flex max-w-3xl flex-col">
<Modal
showModal={changeWorkspaceNameModal}
close={() => setChangeWorkspaceNameModal(!changeWorkspaceNameModal)}
Component={ChangeWorkspaceName}
/>
<Modal <Modal
showModal={deleteWorkspaceModal} showModal={deleteWorkspaceModal}
close={closeDeleteWorkspaceModal} close={closeDeleteWorkspaceModal}
@@ -112,9 +106,23 @@ export default function WorkspaceHeader() {
> >
<Dropdown.Item <Dropdown.Item
className="py-2" className="py-2"
onClick={() => onClick={() => {
setChangeWorkspaceNameModal(!changeWorkspaceNameModal) openDialog('EDIT_WORKSPACE_NAME', {
} title: (
<span className="grid grid-flow-row">
<span>Change Workspace Name</span>
<Text variant="subtitle1" component="span">
Changing the workspace name will also affect the URL
of the workspace.
</Text>
</span>
),
payload: {
currentWorkspaceName: currentWorkspace.name,
currentWorkspaceId: currentWorkspace.id,
},
});
}}
> >
Change workspace name Change workspace name
</Dropdown.Item> </Dropdown.Item>

View File

@@ -1,11 +1,12 @@
import { useDialog } from '@/components/common/DialogProvider';
import { SidebarTitle } from '@/components/home/SidebarTitle'; import { SidebarTitle } from '@/components/home/SidebarTitle';
import { useUI } from '@/context/UIContext';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text';
import PlusCircleIcon from '@/ui/v2/icons/PlusCircleIcon'; import PlusCircleIcon from '@/ui/v2/icons/PlusCircleIcon';
import SidebarWorkspaces from './SidebarWorkspaces'; import SidebarWorkspaces from './SidebarWorkspaces';
export function WorkspaceSection() { export function WorkspaceSection() {
const { openSection } = useUI(); const { openDialog } = useDialog();
return ( return (
<> <>
@@ -15,7 +16,19 @@ export function WorkspaceSection() {
<Button <Button
variant="borderless" variant="borderless"
color="secondary" color="secondary"
onClick={openSection} onClick={() => {
openDialog('EDIT_WORKSPACE_NAME', {
title: (
<span className="grid grid-flow-row">
<span>New Workspace</span>
<Text variant="subtitle1" component="span">
Invite team members to workspaces to work collaboratively.
</Text>
</span>
),
});
}}
startIcon={<PlusCircleIcon />} startIcon={<PlusCircleIcon />}
> >
New Workspace New Workspace

View File

@@ -1,84 +0,0 @@
fragment GetAppLoginData on apps {
id
slug
subdomain
name
createdAt
authEmailSigninEmailVerifiedRequired
authPasswordHibpEnabled
authEmailPasswordlessEnabled
authSmsPasswordlessEnabled
authWebAuthnEnabled
authClientUrl
authAccessControlAllowedRedirectUrls
authAccessControlAllowedEmails
authAccessControlAllowedEmailDomains
authAccessControlBlockedEmails
authAccessControlBlockedEmailDomains
authGithubEnabled
authGithubClientId
authGithubClientSecret
authGoogleEnabled
authGoogleClientId
authGoogleClientSecret
authFacebookEnabled
authFacebookClientId
authFacebookClientSecret
authLinkedinEnabled
authLinkedinClientId
authLinkedinClientSecret
# Twitter
authTwitterEnabled
authTwitterConsumerKey
authTwitterConsumerSecret
# Apple
authAppleEnabled
authAppleTeamId
authAppleKeyId
authAppleClientId
authApplePrivateKey
authAppleScope
# Windows Live
authWindowsLiveEnabled
authWindowsLiveClientId
authWindowsLiveClientSecret
# Spotify
authSpotifyEnabled
authSpotifyClientId
authSpotifyClientSecret
# WorkOs
authWorkOsEnabled
authWorkOsClientId
authWorkOsClientSecret
authWorkOsDefaultDomain
authWorkOsDefaultOrganization
authWorkOsDefaultConnection
# Discord
authDiscordEnabled
authDiscordClientId
authDiscordClientSecret
# Twitch
authTwitchEnabled
authTwitchClientId
authTwitchClientSecret
}
query getAppLoginData($id: uuid!) {
app(id: $id) {
...GetAppLoginData
}
}

View File

@@ -4,20 +4,39 @@ fragment RemoteAppGetUsers on users {
displayName displayName
avatarUrl avatarUrl
email email
emailVerified
phoneNumber phoneNumber
phoneNumberVerified
disabled disabled
defaultRole defaultRole
lastSeen
locale
roles { roles {
id
role role
} }
userProviders {
id
providerId
}
disabled
} }
query remoteAppGetUsers($where: users_bool_exp!, $limit: Int!, $offset: Int!) { query remoteAppGetUsers($where: users_bool_exp!, $limit: Int!, $offset: Int!) {
users(where: $where, limit: $limit, offset: $offset) { users(
where: $where
limit: $limit
offset: $offset
order_by: { createdAt: desc }
) {
...RemoteAppGetUsers ...RemoteAppGetUsers
} }
usersAggregate(where: $where) { filteredUsersAggreggate: usersAggregate(where: $where) {
# make same where query here aggregate {
count
}
}
usersAggregate {
aggregate { aggregate {
count count
} }

View File

@@ -18,12 +18,15 @@ export default function useProjectRedirectWhenReady(
options: UseProjectRedirectWhenReadyOptions = {}, options: UseProjectRedirectWhenReadyOptions = {},
) { ) {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, client, ...rest } = useGetApplicationStateQuery({ const { data, client, startPolling, ...rest } = useGetApplicationStateQuery({
pollInterval: 2000,
...options, ...options,
variables: { ...options.variables, appId: currentApplication.id }, variables: { ...options.variables, appId: currentApplication.id },
}); });
useEffect(() => {
startPolling(options.pollInterval || 2000);
}, [options.pollInterval, startPolling]);
useEffect(() => { useEffect(() => {
async function updateOwnCache() { async function updateOwnCache() {
await client.refetchQueries({ await client.refetchQueries({

View File

@@ -0,0 +1,4 @@
export * from './managePermission';
export * from './managePermissionMigration';
export * from './useManagePermissionMutation';
export { default } from './useManagePermissionMutation';

View File

@@ -0,0 +1,40 @@
import fetch from 'cross-fetch';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import type { ManagePermissionOptions } from './managePermission';
import managePermission from './managePermission';
global.fetch = fetch;
const defaultParameters: ManagePermissionOptions = {
dataSource: 'default',
schema: 'public',
table: 'users',
appUrl: 'http://localhost:1337',
adminSecret: 'x-hasura-admin-secret',
};
const server = setupServer(
rest.post('http://localhost:1337/v1/metadata', (_req, res, ctx) =>
res(ctx.json({})),
),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('should throw an error if permission object is incorrectly provided', async () => {
await expect(
managePermission({
...defaultParameters,
action: 'select',
role: 'user',
mode: 'update',
}),
).rejects.toThrowError(
new Error(
'A permission must be provided when creating or updating a permission.',
),
);
});

View File

@@ -0,0 +1,105 @@
import type {
AffectedRowsResult,
DatabaseAction,
HasuraMetadataPermission,
MetadataError,
MutationOrQueryBaseOptions,
QueryResult,
} from '@/types/dataBrowser';
import normalizeMetadataError from '@/utils/dataBrowser/normalizeMetadataError/normalizeMetadataError';
export interface ManagePermissionVariables {
/**
* The role to manage the permission for.
*/
role: string;
/**
* The action to manage the permission for.
*/
action: DatabaseAction;
/**
* Permission to insert or update.
*/
permission?: HasuraMetadataPermission['permission'];
/**
* The mode to use when managing the permission.
*
* Available modes:
* - `create`: Creates the permission using the provided object.
* - `update`: Drops the permission and creates it again using the provided object.
* - `delete`: Drops the permission.
*
* @default 'update'
*/
mode?: 'insert' | 'update' | 'delete';
}
export interface ManagePermissionOptions extends MutationOrQueryBaseOptions {}
export default async function managePermission({
dataSource,
schema,
appUrl,
adminSecret,
table,
permission,
role,
action,
mode = 'update',
}: ManagePermissionOptions & ManagePermissionVariables) {
if (mode !== 'delete' && !permission) {
throw new Error(
'A permission must be provided when creating or updating a permission.',
);
}
const deleteArgs = {
type: `pg_drop_${action}_permission`,
args: { table: { schema, name: table }, source: dataSource, role },
};
const insertArgs = {
type: `pg_create_${action}_permission`,
args: {
source: dataSource,
table: { schema, name: table },
role,
permission,
},
};
let args = [];
if (mode === 'delete') {
args = [deleteArgs];
} else if (mode === 'insert') {
args = [insertArgs];
} else {
args = [deleteArgs, insertArgs];
}
const response = await fetch(`${appUrl}/v1/metadata`, {
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
body: JSON.stringify({
args,
type: 'bulk',
source: dataSource,
version: 1,
}),
});
const responseData:
| [AffectedRowsResult, QueryResult<string[]>]
| MetadataError = await response.json();
if (response.ok) {
return;
}
const normalizedError = normalizeMetadataError(responseData);
throw new Error(normalizedError);
}

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