Compare commits

..

193 Commits

Author SHA1 Message Date
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
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
Szilárd Dóró
5f3f9390aa chore(dashboard): updated changeset 2023-01-09 09:42:36 +01: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
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ó
d92891b223 chore(dashboard): add changeset 2023-01-02 10:48:25 +01: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ó
a3499c4628 fix(dashboard): improve rule group editor scrollability 2023-01-02 10:43:47 +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
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ó
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
159 changed files with 6933 additions and 1449 deletions

View File

@@ -1,5 +1,42 @@
# @nhost/dashboard
## 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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.7.13",
"version": "0.9.1",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",

View File

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

View File

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

View File

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

View File

@@ -35,15 +35,15 @@ function InsertPlaceholderTableRow({
}: InsertPlaceholderTableRowProps) {
return (
<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}
>
<Button
onClick={onInsertRow}
variant="borderless"
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"
startIcon={<PlusIcon className="h-4 w-4 text-greyscaleGrey" />}
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="w-4 h-4 text-greyscaleGrey" />}
>
Insert New Row
</Button>
@@ -181,7 +181,7 @@ export default function DataGridBody<T extends object>({
return (
<div {...getTableBodyProps()} ref={bodyRef} {...props}>
{rows.length === 0 && !loading && (
<div className="flex flex-nowrap pr-5">
<div className="flex pr-5 flex-nowrap">
{onInsertRow ? (
<InsertPlaceholderTableRow
style={{
@@ -279,7 +279,7 @@ export default function DataGridBody<T extends object>({
})}
{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>

View File

@@ -11,14 +11,19 @@ export type DialogType =
| 'EDIT_COLUMN'
| 'CREATE_TABLE'
| 'EDIT_TABLE'
| 'EDIT_PERMISSIONS'
| 'CREATE_FOREIGN_KEY'
| 'EDIT_FOREIGN_KEY'
| 'CREATE_ROLE'
| 'EDIT_ROLE'
| 'CREATE_USER'
| 'CREATE_PERMISSION_VARIABLE'
| 'EDIT_PERMISSION_VARIABLE'
| 'CREATE_ENVIRONMENT_VARIABLE'
| 'EDIT_ENVIRONMENT_VARIABLE';
| 'EDIT_ENVIRONMENT_VARIABLE'
| 'EDIT_USER'
| 'EDIT_USER_PASSWORD'
| 'EDIT_JWT_SECRET';
export interface DialogConfig<TPayload = unknown> {
/**
@@ -62,6 +67,16 @@ export interface DialogContextProps {
* Call this function to close the active drawer.
*/
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.
*/
@@ -73,6 +88,10 @@ export interface DialogContextProps {
isDirty: boolean,
location?: 'drawer' | 'dialog',
) => void;
/**
* Call this function to open a dirty confirmation dialog.
*/
openDirtyConfirmation: (config?: Partial<DialogConfig<string>>) => void;
}
export default createContext<DialogContextProps>({
@@ -81,6 +100,9 @@ export default createContext<DialogContextProps>({
openAlertDialog: () => {},
closeDialog: () => {},
closeDrawer: () => {},
closeDialogWithDirtyGuard: () => {},
closeDrawerWithDirtyGuard: () => {},
closeAlertDialog: () => {},
onDirtyStateChange: () => {},
openDirtyConfirmation: () => {},
});

View File

@@ -3,10 +3,14 @@ import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm'
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
import CreateUserForm from '@/components/users/CreateUserForm';
import EditUserForm from '@/components/users/EditUserForm';
import EditUserPasswordForm from '@/components/users/EditUserPasswordForm';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import AlertDialog from '@/ui/v2/AlertDialog';
import { BaseDialog } from '@/ui/v2/Dialog';
@@ -81,6 +85,11 @@ const EditTableForm = dynamic(
{ ssr: false, loading: () => LoadingComponent() },
);
const EditPermissionsForm = dynamic(
() => import('@/components/dataBrowser/EditPermissionsForm'),
{ ssr: false, loading: () => LoadingComponent() },
);
function DialogProvider({ children }: PropsWithChildren<unknown>) {
const router = useRouter();
@@ -192,23 +201,31 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
[],
);
function closeDrawerWithDirtyGuard(event?: BaseSyntheticEvent) {
if (isDrawerDirty.current && event?.type !== 'submit') {
openDirtyConfirmation({ props: { onPrimaryAction: closeDrawer } });
return;
}
const closeDrawerWithDirtyGuard = useCallback(
(event?: BaseSyntheticEvent) => {
if (isDrawerDirty.current && event?.type !== 'submit') {
setShowDirtyConfirmation(true);
openDirtyConfirmation({ props: { onPrimaryAction: closeDrawer } });
return;
}
closeDrawer();
}
closeDrawer();
},
[closeDrawer, openDirtyConfirmation],
);
function closeDialogWithDirtyGuard(event?: BaseSyntheticEvent) {
if (isDialogDirty.current && event?.type !== 'submit') {
openDirtyConfirmation({ props: { onPrimaryAction: closeDialog } });
return;
}
const closeDialogWithDirtyGuard = useCallback(
(event?: BaseSyntheticEvent) => {
if (isDialogDirty.current && event?.type !== 'submit') {
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
// not ideal. We shoule figure out a better logic for tracking the dirty
@@ -235,10 +252,22 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
openAlertDialog,
closeDialog,
closeDrawer,
closeDialogWithDirtyGuard,
closeDrawerWithDirtyGuard,
closeAlertDialog,
onDirtyStateChange,
openDirtyConfirmation,
}),
[closeDialog, closeDrawer, onDirtyStateChange, openDialog, openDrawer],
[
closeDialog,
closeDialogWithDirtyGuard,
closeDrawer,
closeDrawerWithDirtyGuard,
onDirtyStateChange,
openDialog,
openDirtyConfirmation,
openDrawer,
],
);
const sharedDialogProps = {
@@ -353,6 +382,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<EditRoleForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_USER' && (
<CreateUserForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_PERMISSION_VARIABLE' && (
<CreatePermissionVariableForm {...sharedDialogProps} />
)}
@@ -368,17 +401,34 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
{activeDialogType === 'EDIT_ENVIRONMENT_VARIABLE' && (
<EditEnvironmentVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_USER_PASSWORD' && (
<EditUserPasswordForm
{...sharedDialogProps}
user={sharedDialogProps?.user}
/>
)}
{activeDialogType === 'EDIT_JWT_SECRET' && (
<EditJwtSecretForm {...sharedDialogProps} />
)}
</RetryableErrorBoundary>
</BaseDialog>
<Drawer
anchor="right"
{...drawerProps}
title={drawerTitle}
open={drawerOpen}
onClose={closeDrawerWithDirtyGuard}
SlideProps={{ onExited: clearDrawerContent, unmountOnExit: false }}
anchor="right"
PaperProps={{ className: 'max-w-2.5xl w-full' }}
PaperProps={{
...drawerProps?.PaperProps,
className: twMerge(
'max-w-2.5xl w-full',
drawerProps?.PaperProps?.className,
),
}}
>
<RetryableErrorBoundary>
{activeDrawerType === 'CREATE_RECORD' && (
@@ -413,6 +463,19 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
schema={drawerPayload?.schema}
/>
)}
{activeDrawerType === 'EDIT_PERMISSIONS' && (
<EditPermissionsForm
{...sharedDrawerProps}
disabled={drawerPayload?.disabled}
schema={drawerPayload?.schema}
table={drawerPayload?.table}
/>
)}
{activeDrawerType === 'EDIT_USER' && (
<EditUserForm {...sharedDrawerProps} {...drawerPayload} />
)}
</RetryableErrorBoundary>
</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 (
<code
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,
)}
{...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 { useWorkspaceContext } from '@/context/workspace-context';
import { useUserDataContext } from '@/context/workspace1-context';
import { Avatar } from '@/ui/Avatar';
import { Modal } from '@/ui/Modal';
import Button from '@/ui/v2/Button';
import { Dropdown, useDropdown } from '@/ui/v2/Dropdown';
import Text from '@/ui/v2/Text';
import { emptyWorkspace } from '@/utils/helpers';
import { nhost } from '@/utils/nhost';
import { useApolloClient } from '@apollo/client';
import { useUserData } from '@nhost/nextjs';
import Image from 'next/image';
import { useRouter } from 'next/router';
@@ -22,9 +20,8 @@ function AccountMenuContent({
}: AccountMenuContentProps) {
const user = useUserData();
const router = useRouter();
const client = useApolloClient();
const [clicked, setClicked] = useState(false);
const { setWorkspaceContext } = useWorkspaceContext();
const { setUserContext } = useUserDataContext();
const { handleClose } = useDropdown();
return (
@@ -34,10 +31,9 @@ function AccountMenuContent({
color="secondary"
className="absolute top-6 right-4 grid grid-flow-col items-center gap-1 self-start font-medium"
onClick={async () => {
setWorkspaceContext(emptyWorkspace());
setUserContext({ workspaces: [] });
nhost.auth.signOut();
router.push('/signin');
await nhost.auth.signOut();
await client.resetStore();
}}
aria-label="Sign Out"
>

View File

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

View File

@@ -25,8 +25,8 @@ import type {
} from 'react';
import { forwardRef, useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import type { UseAsyncInitialValueOptions } from './useAsyncInitialValue';
import useAsyncInitialValue from './useAsyncInitialValue';
import type { UseAsyncValueOptions } from './useAsyncValue';
import useAsyncValue from './useAsyncValue';
import type { UseColumnGroupsOptions } from './useColumnGroups';
import useColumnGroups from './useColumnGroups';
@@ -54,7 +54,7 @@ export interface ColumnAutocompleteProps
/**
* 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.
*/
@@ -107,7 +107,11 @@ function ColumnAutocomplete(
ref: ForwardedRef<HTMLInputElement>,
) {
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 selectedTable = activeRelationship?.table || defaultTable;
@@ -119,6 +123,7 @@ function ColumnAutocomplete(
} = useTableQuery([`default.${selectedSchema}.${selectedTable}`], {
schema: selectedSchema,
table: selectedTable,
preventRowFetching: true,
queryOptions: { refetchOnWindowFocus: false },
});
@@ -141,7 +146,7 @@ function ColumnAutocomplete(
setSelectedRelationships,
relationshipDotNotation,
activeRelationship: asyncActiveRelationship,
} = useAsyncInitialValue({
} = useAsyncValue({
selectedSchema,
selectedTable,
initialValue: externalValue as string,
@@ -226,7 +231,7 @@ function ColumnAutocomplete(
inputValue,
options,
id: props?.name,
openOnFocus: true,
openOnFocus: !props.disabled,
disableCloseOnSelect: true,
value: selectedColumn,
onClose: () => setOpen(false),
@@ -256,24 +261,43 @@ function ColumnAutocomplete(
),
},
}}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
error={Boolean(tableError || metadataError)}
helperText={String(tableError || metadataError || '')}
onFocus={() => {
if (props.disabled) {
return;
}
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)}
value={inputValue}
startAdornment={
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>.
{relationshipDotNotation && (
<>
<span className="hidden lg:inline">
{getTruncatedText(relationshipDotNotation, 15, 'start')}.
{getTruncatedText(relationshipDotNotation, 15, 'end')}.
</span>
<span className="inline lg:hidden">
{relationshipDotNotation}.
{getTruncatedText(relationshipDotNotation, 35, 'end')}.
</span>
</>
)}

View File

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

View File

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

View File

@@ -348,7 +348,7 @@ export default function DataBrowserGrid({
description={
<span>
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}
</InlineCode>{' '}
does not exist.
@@ -365,7 +365,7 @@ export default function DataBrowserGrid({
description={
<span>
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}
</InlineCode>{' '}
does not exist.

View File

@@ -1,4 +1,5 @@
import { useDialog } from '@/components/common/DialogProvider';
import InlineCode from '@/components/common/InlineCode';
import NavLink from '@/components/common/NavLink';
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import useIsPlatform from '@/hooks/common/useIsPlatform';
@@ -8,6 +9,7 @@ import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAn
import FloatingActionButton from '@/ui/FloatingActionButton';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
@@ -17,6 +19,7 @@ import LockIcon from '@/ui/v2/icons/LockIcon';
import PencilIcon from '@/ui/v2/icons/PencilIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import TrashIcon from '@/ui/v2/icons/TrashIcon';
import UsersIcon from '@/ui/v2/icons/UsersIcon';
import Link from '@/ui/v2/Link';
import List from '@/ui/v2/List';
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 (
<div className="grid gap-1">
{schemas && schemas.length > 0 && (
@@ -318,9 +355,7 @@ function DataBrowserSidebarContent({
<Dropdown.Trigger
asChild
hideChevron
disabled={
tablePath === removableTable || isGitHubConnected
}
disabled={tablePath === removableTable}
>
<IconButton
variant="borderless"
@@ -329,7 +364,6 @@ function DataBrowserSidebarContent({
!isSelected &&
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
)}
disabled={isGitHubConnected}
>
<DotsHorizontalIcon />
</IconButton>
@@ -339,44 +373,84 @@ function DataBrowserSidebarContent({
menu
PaperProps={{ className: 'w-52' }}
>
<Dropdown.Item
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" />
{isGitHubConnected ? (
<Dropdown.Item
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
onClick={() =>
handleEditPermissionClick(
table.table_schema,
table.table_name,
true,
)
}
>
<UsersIcon className="h-4 w-4 text-gray-700" />
<span>Edit Table</span>
</Dropdown.Item>
<span>View Permissions</span>
</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
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium text-red"
onClick={() =>
handleDeleteTableClick(
table.table_schema,
table.table_name,
)
}
>
<TrashIcon className="h-4 w-4 text-red" />
<span>Edit Permissions</span>
</Dropdown.Item>,
<Divider
key="edit-permissions-separator"
component="li"
/>,
<Dropdown.Item
key="delete-table"
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium text-red"
onClick={() =>
handleDeleteTableClick(
table.table_schema,
table.table_name,
)
}
>
<TrashIcon className="h-4 w-4 text-red" />
<span>Delete Table</span>
</Dropdown.Item>
<span>Delete Table</span>
</Dropdown.Item>,
]
)}
</Dropdown.Content>
</Dropdown.Root>
)

View File

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

View File

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

View File

@@ -65,7 +65,13 @@ const Template: ComponentStory<typeof RuleGroupEditor> = function Template(
<div className="grid grid-flow-row gap-2">
<FormProvider {...form}>
<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">
Submit

View File

@@ -1,17 +1,35 @@
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { Rule, RuleGroup } from '@/types/dataBrowser';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import Link from '@/ui/v2/Link';
import Text from '@/ui/v2/Text';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import type { DetailedHTMLProps, HTMLProps } from 'react';
import { useMemo } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { twMerge } from 'tailwind-merge';
import type { RuleEditorRowProps } from './RuleEditorRow';
import RuleEditorRow from './RuleEditorRow';
import RuleGroupControls from './RuleGroupControls';
import { RuleGroupEditorContext } from './useRuleGroupEditor';
export interface RuleGroupEditorProps
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
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.
*/
@@ -46,14 +64,15 @@ export default function RuleGroupEditor({
disabledOperators = [],
depth = 0,
maxDepth = 7,
schema,
table,
disabled,
...props
}: RuleGroupEditorProps) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const form = useFormContext();
const { control } = form;
// Note: Reason for the type cast to `never`
// https://github.com/react-hook-form/react-hook-form/issues/4055#issuecomment-950145092
const { control, getValues } = form;
const {
fields: rules,
append: appendRule,
@@ -61,10 +80,11 @@ export default function RuleGroupEditor({
} = useFieldArray({
control,
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 {
fields: groups,
append: appendGroup,
@@ -72,122 +92,164 @@ export default function RuleGroupEditor({
} = useFieldArray({
control,
name: `${name}.groups`,
} as never);
});
if (!form) {
throw new Error('RuleGroupEditor must be used in a FormContext.');
}
const contextValue = useMemo(
() => ({
disabled,
schema,
table,
}),
[disabled, schema, table],
);
return (
<div
className={twMerge(
'rounded-lg px-2',
depth === 0 && 'bg-greyscale-50',
depth === 1 && 'bg-greyscale-100',
depth === 2 && 'bg-greyscale-200',
depth === 3 && 'bg-greyscale-300',
depth === 4 && 'bg-greyscale-400',
depth === 5 && 'bg-greyscale-500',
depth >= 6 && 'bg-greyscale-600',
className,
)}
{...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="flex flex-row flex-auto" key={rule.id}>
<div className="flex-[70px] flex-grow-0 flex-shrink-0 mr-2">
{ruleIndex === 0 && (
<Text className="p-2 !font-medium">Where</Text>
)}
{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 && (
<RuleGroupEditorContext.Provider value={contextValue}>
<div
className={twMerge(
'rounded-lg border border-r-8 border-transparent pl-2',
depth === 0 && 'bg-greyscale-50',
depth === 1 && 'bg-greyscale-100',
depth === 2 && 'bg-greyscale-200',
depth === 3 && 'bg-greyscale-300',
depth === 4 && 'bg-greyscale-400',
depth === 5 && 'bg-greyscale-500',
depth >= 6 && 'bg-greyscale-600',
className,
)}
{...props}
>
<div className="grid grid-flow-row gap-4 lg:gap-2 py-4">
{(rules as (Rule & { id: string })[]).map((rule, ruleIndex) => (
<div className="grid grid-cols-[70px_1fr] gap-2" key={rule.id}>
<div>
{ruleIndex === 0 && (
<Text className="p-2 !font-medium">Where</Text>
)}
<RuleGroupControls
name={name}
showSelect={
(rules.length === 0 && ruleGroupIndex === 1) ||
(rules.length === 1 && ruleGroupIndex === 0)
}
/>
{ruleIndex > 0 && (
<RuleGroupControls name={name} showSelect={ruleIndex === 1} />
)}
</div>
<RuleGroupEditor
onRemove={() => removeGroup(ruleGroupIndex)}
disableRemove={rules.length === 0 && groups.length === 1}
<RuleEditorRow
name={name}
index={ruleIndex}
onRemove={() => removeRule(ruleIndex)}
disabledOperators={disabledOperators}
name={`${name}.groups.${ruleGroupIndex}`}
className="flex-auto"
depth={depth + 1}
/>
</div>
),
)}
</div>
))}
<div className="grid grid-flow-row lg:grid-flow-col lg:justify-between gap-2 pb-2">
<div className="grid grid-flow-row lg:grid-flow-col gap-2 lg:justify-start">
<Button
startIcon={<PlusIcon />}
variant="borderless"
onClick={() =>
appendRule({ column: '', operator: '_eq', value: '' })
}
>
New Rule
</Button>
{(groups as (RuleGroup & { id: string })[]).map(
(ruleGroup, ruleGroupIndex) => (
<div
className="grid grid-cols-[70px_1fr] gap-2"
key={ruleGroup.id}
>
<div>
{rules.length === 0 && ruleGroupIndex === 0 && (
<Text className="p-2 !font-medium">Where</Text>
)}
<Button
startIcon={<PlusIcon />}
variant="borderless"
onClick={() =>
appendGroup({
operator: '_and',
rules: [{ column: '', operator: '_eq', value: '' }],
groups: [],
})
}
disabled={depth >= maxDepth - 1}
>
New Group
</Button>
<RuleGroupControls
name={name}
showSelect={
(rules.length === 0 && ruleGroupIndex === 1) ||
(rules.length === 1 && ruleGroupIndex === 0)
}
/>
</div>
<RuleGroupEditor
schema={schema}
table={table}
onRemove={() => removeGroup(ruleGroupIndex)}
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>
{onRemove && (
<Button
variant="borderless"
color="secondary"
onClick={onRemove}
disabled={disableRemove}
>
Delete Group
</Button>
{!disabled && (
<div className="grid grid-flow-row lg:grid-flow-col lg:justify-between gap-2 pb-2">
<div className="grid grid-flow-row lg:grid-flow-col gap-2 lg:justify-start">
<Button
startIcon={<PlusIcon />}
variant="borderless"
onClick={() =>
appendRule({ column: '', operator: '_eq', value: '' })
}
>
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>
</RuleGroupEditorContext.Provider>
);
}

View File

@@ -24,16 +24,19 @@ function RuleRemoveButton({
}: RuleRemoveButtonProps) {
const rules: Rule[] = useWatch({ name: `${name}.rules` });
const groups: RuleGroup[] = useWatch({ name: `${name}.groups` });
const unsupported: Record<string, any>[] = useWatch({
name: `${name}.unsupported`,
});
return (
<Button
variant="outlined"
color="secondary"
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,
)}
disabled={rules.length === 1 && groups.length === 0}
disabled={rules.length === 1 && !groups?.length && !unsupported?.length}
aria-label="Remove Rule"
{...props}
onClick={onRemove}

View File

@@ -6,22 +6,12 @@ import ColumnAutocomplete from '@/components/dataBrowser/ColumnAutocomplete';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import type { HasuraOperator } from '@/types/dataBrowser';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import type { InputProps } from '@/ui/v2/Input';
import Option from '@/ui/v2/Option';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
import { useRouter } from 'next/router';
import { useController, useFormContext, useWatch } from 'react-hook-form';
export interface RuleValueInputProps {
/**
* Name of the parent group editor.
*/
name: string;
/**
* Path of the table selected through the column input.
*/
selectedTablePath?: string;
}
import useRuleGroupEditor from './useRuleGroupEditor';
function ColumnSelectorInput({
name,
@@ -47,7 +37,6 @@ function ColumnSelectorInput({
schema={schema}
table={table}
disableRelationships
rootClassName="flex-auto"
slotProps={{
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({
name,
selectedTablePath,
error,
helperText,
}: RuleValueInputProps) {
const { schema, table, disabled } = useRuleGroupEditor();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { setValue } = useFormContext();
const inputName = `${name}.value`;
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
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 },
skip: !isHasuraInput,
skip: !isHasuraInput || !currentApplication?.id,
});
if (operator === '_is_null') {
return (
<ControlledSelect
disabled={disabled}
name={inputName}
className="flex-auto"
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">
<ReadOnlyToggle
@@ -110,15 +127,17 @@ export default function RuleValueInput({
if (operator === '_in' || operator === '_nin') {
return (
<ControlledAutocomplete
disabled={disabled}
name={inputName}
multiple
freeSolo
limitTags={5}
className="flex-auto"
limitTags={3}
slotProps={{ input: { className: 'lg:!rounded-none !bg-white !z-10' } }}
options={[]}
fullWidth
filterSelectedOptions
error={error}
helperText={helperText}
/>
);
}
@@ -126,28 +145,41 @@ export default function RuleValueInput({
if (['_ceq', '_cne', '_cgt', '_clt', '_cgte', '_clte'].includes(operator)) {
return (
<ColumnSelectorInput
disabled={disabled}
selectedTablePath={selectedTablePath}
schema={schemaSlug as string}
table={tableSlug as string}
schema={schema}
table={table}
name={inputName}
error={error}
helperText={helperText}
/>
);
}
const availableHasuraPermissionVariables = !loading
? getPermissionVariablesArray(data?.app?.authJwtCustomClaims).map(
({ key }) => ({
value: `X-Hasura-${key}`,
label: `X-Hasura-${key}`,
}),
)
: [];
const availableHasuraPermissionVariables = getPermissionVariablesArray(
data?.app?.authJwtCustomClaims,
).map(({ key }) => ({
value: `X-Hasura-${key}`,
label: `X-Hasura-${key}`,
group: 'Frequently used',
}));
return (
<ControlledAutocomplete
disabled={disabled}
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}
className="flex-auto"
groupBy={(option) => option.group}
slotProps={{
input: { className: 'lg:!rounded-none !bg-white' },
formControl: { className: '!bg-transparent' },
@@ -155,12 +187,18 @@ export default function RuleValueInput({
fullWidth
loading={loading}
loadingText={<ActivityIndicator label="Loading..." />}
error={!!error}
helperText={error?.message}
error={Boolean(customClaimsError) || error}
helperText={customClaimsError?.message || helperText}
options={
isHasuraInput
? 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) => {
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,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 EyeIcon from '@/ui/v2/icons/EyeIcon';
import EyeOffIcon from '@/ui/v2/icons/EyeOffIcon';
import Input from '@/ui/v2/Input';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
@@ -27,7 +26,7 @@ export default function SystemEnvironmentVariableSettings() {
const [showAdminSecret, setShowAdminSecret] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const { openAlertDialog } = useDialog();
const { openDialog } = useDialog();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetAppInjectedVariablesQuery({
variables: { id: currentApplication?.id },
@@ -49,30 +48,39 @@ export default function SystemEnvironmentVariableSettings() {
throw error;
}
function showJwtSecret() {
openAlertDialog({
title: 'Auth JWT Secret',
payload: (
<div className="grid grid-flow-row gap-2">
<Text variant="subtitle2">
function showViewJwtSecretModal() {
openDialog('EDIT_JWT_SECRET', {
title: (
<span className="grid grid-flow-row">
<span>Auth JWT Secret</span>
<Text variant="subtitle1" component="span">
This is the key used for generating JWTs. It&apos;s HMAC-SHA-based
and the same as configured in Hasura.
</Text>
<Input
defaultValue={data?.app?.hasuraGraphqlJwtSecret}
disabled
fullWidth
multiline
minRows={5}
hideEmptyHelperText
inputProps={{ className: 'font-mono' }}
/>
</div>
</span>
),
props: {
hidePrimaryAction: true,
secondaryButtonText: 'Close',
payload: {
disabled: true,
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."
docsLink="https://docs.nhost.io/platform/environment-variables#system-environment-variables"
rootClassName="gap-0"
className="px-0 mt-2 mb-2.5"
className="mt-2 mb-2.5 px-0"
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 lg:col-span-2">Value</Text>
</div>
<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>
<div className="grid grid-flow-col lg:col-span-2 gap-2 items-center justify-start">
<Text className="text-greyscaleGreyDark truncate">
<div className="grid grid-flow-col items-center justify-start gap-2 lg:col-span-2">
<Text className="truncate text-greyscaleGreyDark">
{showAdminSecret ? (
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]">
<InlineCode className="!text-sm font-medium">
{currentApplication?.hasuraGraphqlAdminSecret}
</InlineCode>
) : (
@@ -141,9 +149,9 @@ export default function SystemEnvironmentVariableSettings() {
onClick={() => setShowAdminSecret((show) => !show)}
>
{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>
</div>
@@ -151,13 +159,13 @@ export default function SystemEnvironmentVariableSettings() {
<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>
<div className="grid grid-flow-col gap-2 lg:col-span-2 items-center justify-start">
<Text className="text-greyscaleGreyDark truncate">
<div className="grid grid-flow-col items-center justify-start gap-2 lg:col-span-2">
<Text className="truncate text-greyscaleGreyDark">
{showWebhookSecret ? (
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]">
<InlineCode className="!text-sm font-medium">
{data?.app?.webhookSecret}
</InlineCode>
) : (
@@ -176,9 +184,9 @@ export default function SystemEnvironmentVariableSettings() {
onClick={() => setShowWebhookSecret((show) => !show)}
>
{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>
</div>
@@ -188,7 +196,7 @@ export default function SystemEnvironmentVariableSettings() {
{systemEnvironmentVariables.map((environmentVariable, index) => (
<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>
<Text className="truncate lg:col-span-2">
@@ -204,17 +212,28 @@ export default function SystemEnvironmentVariableSettings() {
<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>
<Button
variant="borderless"
onClick={showJwtSecret}
size="small"
className="justify-self-start"
>
Show JWT Secret
</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">
<Button
variant="borderless"
onClick={showViewJwtSecretModal}
size="small"
>
Show JWT Secret
</Button>
<Text component="span">or</Text>
<Button
variant="borderless"
onClick={showEditJwtSecretModal}
size="small"
>
Edit JWT Secret
</Button>
</div>
</ListItem.Root>
</List>
</SettingsContainer>

View File

@@ -1,5 +1,6 @@
import { useDialog } from '@/components/common/DialogProvider';
import Form from '@/components/common/Form';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
@@ -55,11 +56,20 @@ export default function BaseRoleForm({
}, [isDirty, onDirtyStateChange]);
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">
Enter the name for the role below.
</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">
<Input
{...register('name')}

View File

@@ -8,18 +8,18 @@ import Chip from '@/ui/v2/Chip';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import LockIcon from '@/ui/v2/icons/LockIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
import LockIcon from '@/ui/v2/icons/LockIcon';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import {
useGetRolesQuery,
useUpdateAppMutation,
useUpdateAppMutation
} from '@/utils/__generated__/graphql';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
@@ -156,7 +156,7 @@ export default function RoleSettings() {
className="px-0 my-2"
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>
</div>
@@ -171,7 +171,7 @@ export default function RoleSettings() {
<Dropdown.Trigger
asChild
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">
<DotsVerticalIcon />
@@ -257,7 +257,7 @@ export default function RoleSettings() {
</List>
<Button
className="justify-self-start mx-4"
className="mx-4 justify-self-start"
variant="borderless"
startIcon={<PlusIcon />}
onClick={handleOpenCreator}

View File

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

View File

@@ -1,7 +1,7 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import {
useGetAppLoginDataQuery,
useSignInMethodsQuery,
useUpdateAppMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
@@ -27,10 +27,11 @@ export default function TwitterProviderSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const { data, loading, error } = useGetAppLoginDataQuery({
const { data, loading, error } = useSignInMethodsQuery({
variables: {
id: currentApplication?.id,
},
fetchPolicy: 'cache-only',
});
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 XIcon from '@/ui/v2/icons/XIcon';
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 { OptionGroupBase } from '@/ui/v2/OptionGroup';
import type { StyledComponent } from '@emotion/styled';
import type { UseAutocompleteProps } from '@mui/base/AutocompleteUnstyled';
import { createFilterOptions } from '@mui/base/AutocompleteUnstyled';
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 MaterialAutocomplete, {
autocompleteClasses as materialAutocompleteClasses,
} from '@mui/material/Autocomplete';
import clsx from 'clsx';
import type { ForwardedRef } from 'react';
import { forwardRef, useEffect, useState } from 'react';
import { forwardRef, useEffect, useRef, useState } from 'react';
export interface AutocompleteOption<TValue = string> {
/**
@@ -112,6 +112,12 @@ const StyledTag = styled(Chip)(({ theme }) => ({
}));
const StyledAutocomplete = styled(MaterialAutocomplete)(({ theme }) => ({
[`&:not(.${materialAutocompleteClasses.focused})`]: {
[`& .${inputClasses.root}`]: {
maxHeight: 40,
overflow: 'auto',
},
},
[`.${materialAutocompleteClasses.endAdornment}`]: {
right: theme.spacing(1.5),
},
@@ -127,7 +133,7 @@ const StyledAutocomplete = styled(MaterialAutocomplete)(({ theme }) => ({
>;
export const AutocompletePopper = styled(PopperUnstyled)(({ theme }) => ({
zIndex: 1,
zIndex: theme.zIndex.modal + 1,
boxShadow: 'none',
minWidth: 320,
maxWidth: 600,
@@ -196,6 +202,7 @@ function Autocomplete(
}: AutocompleteProps<AutocompleteOption>,
ref: ForwardedRef<HTMLInputElement>,
) {
const inputRef = useRef<HTMLInputElement>();
const { formControl: formControlSlotProps, ...defaultComponentsProps } =
slotProps || {};
@@ -262,6 +269,17 @@ function Autocomplete(
onInputChange(event, value, reason);
}
}}
onKeyDown={(event) => {
if (event.key !== 'Escape') {
return;
}
event.stopPropagation();
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}}
PopperComponent={AutocompletePopper}
popupIcon={<ChevronDownIcon sx={{ width: 12, height: 12 }} />}
getOptionLabel={(option) => {
@@ -344,13 +362,14 @@ function Autocomplete(
...params
}) => (
<Input
ref={inputRef}
slotProps={{
input: {
className: slotProps?.input?.className,
sx: props.multiple
? {
flexWrap: 'wrap',
[`& .${inputBaseClasses.input}`]: {
[`& .${inputClasses.input}`]: {
minWidth: 30,
width: 0,
},

View File

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

View File

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

View File

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

View File

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

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],
},
[`&.${selectUnstyledClasses.disabled}`]: {
color: theme.palette.grey[600],
borderColor: darken(theme.palette.grey[300], 0.1),
backgroundColor: lighten(theme.palette.grey[200], 0.5),
color: `${theme.palette.grey[600]} !important`,
borderColor: `${darken(theme.palette.grey[300], 0.1)} !important`,
backgroundColor: `${lighten(
theme.palette.action.disabled,
0.75,
)} !important`,
},
[`&.${selectUnstyledClasses.focusVisible}, &.${selectUnstyledClasses.expanded}`]:
{

View File

@@ -11,7 +11,7 @@ export default {
const Template: ComponentStory<typeof Switch> = function Template(
args: SwitchProps,
) {
return <Switch {...args} />;
return <Switch label="Accept Rules" {...args} />;
};
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, {
switchUnstyledClasses,
} from '@mui/base/SwitchUnstyled';
import type { SwitchUnstyledProps } from '@mui/base/SwitchUnstyled/SwitchUnstyled.types';
import { styled } from '@mui/material';
import type { ForwardedRef } from 'react';
import type { ForwardedRef, PropsWithoutRef } 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 }) => ({
position: 'relative',
@@ -86,13 +112,21 @@ const StyledSwitch = styled(SwitchUnstyled)(({ theme }) => ({
}));
function Switch(
{ children, ...props }: SwitchProps,
{ label, slotProps, ...props }: SwitchProps,
ref: ForwardedRef<HTMLSpanElement>,
) {
if (!label) {
return <StyledSwitch {...(slotProps?.root || {})} {...props} ref={ref} />;
}
return (
<StyledSwitch {...props} ref={ref}>
{children}
</StyledSwitch>
<StyledFormControlLabel
{...(slotProps?.formControlLabel || {})}
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 MaterialTableCell from '@mui/material/TableCell';
import MaterialTableCell, { tableCellClasses } from '@mui/material/TableCell';
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) {
return <MaterialTableCell {...props}>{children}</MaterialTableCell>;
return <StyledTableCell {...props}>{children}</StyledTableCell>;
}
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,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
avatarUrl
email
emailVerified
phoneNumber
phoneNumberVerified
disabled
defaultRole
lastSeen
locale
roles {
id
role
}
userProviders {
id
providerId
}
disabled
}
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
}
usersAggregate(where: $where) {
# make same where query here
filteredUsersAggreggate: usersAggregate(where: $where) {
aggregate {
count
}
}
usersAggregate {
aggregate {
count
}

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);
}

View File

@@ -0,0 +1,136 @@
import type {
AffectedRowsResult,
DatabaseAction,
HasuraMetadataPermission,
MutationOrQueryBaseOptions,
QueryError,
QueryResult,
} from '@/types/dataBrowser';
import normalizeQueryError from '@/utils/dataBrowser/normalizeQueryError';
import { LOCAL_MIGRATIONS_URL } from '@/utils/env';
export interface ManagePermissionMigrationVariables {
/**
* 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 original permission to use for the down migration when updating and the
* up migration when deleting.
*/
originalPermission?: 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 ManagePermissionMigrationOptions
extends MutationOrQueryBaseOptions {}
export default async function managePermissionMigration({
dataSource,
schema,
adminSecret,
table,
permission,
originalPermission,
role,
action,
mode = 'update',
}: ManagePermissionMigrationOptions & ManagePermissionMigrationVariables) {
if (mode !== 'delete' && !permission) {
throw new Error('A permission object must be provided.');
}
if (mode === 'delete' && !originalPermission) {
throw new Error(
'An original permission object must be provided when mode is "delete".',
);
}
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: { up: any[]; down: any[] } = {
up: [],
down: [],
};
if (mode === 'delete') {
args = {
down: [
{
...insertArgs,
args: { ...insertArgs.args, permission: originalPermission },
},
],
up: [deleteArgs],
};
} else if (mode === 'insert') {
args = { down: [deleteArgs], up: [insertArgs] };
} else {
args = {
down: [
{
...insertArgs,
args: { ...insertArgs.args, permission: originalPermission },
},
deleteArgs,
],
up: [deleteArgs, insertArgs],
};
}
const response = await fetch(`${LOCAL_MIGRATIONS_URL}/apis/migrate`, {
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
body: JSON.stringify({
dataSource,
skip_execution: false,
name: `change_${action}_permission_${role}_${schema}_${table}`,
down: args.down,
up: args.up,
}),
});
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
await response.json();
if (response.ok) {
return;
}
const normalizedError = normalizeQueryError(responseData);
throw new Error(normalizedError);
}

View File

@@ -0,0 +1,69 @@
import useIsPlatform from '@/hooks/common/useIsPlatform';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import type { MutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import type { ManagePermissionOptions } from './managePermission';
import managePermission from './managePermission';
import type { ManagePermissionMigrationVariables } from './managePermissionMigration';
import managePermissionMigration from './managePermissionMigration';
export interface UseManagePermissionMutationOptions
extends Partial<ManagePermissionOptions> {
/**
* Props passed to the underlying mutation hook.
*/
mutationOptions?: MutationOptions<
void,
unknown,
ManagePermissionMigrationVariables
>;
}
/**
* This hook is a wrapper around a fetch call that manages a permission for a
* specific role on a specific table.
*
* @param options - Options to use for the mutation.
* @returns The result of the mutation.
*/
export default function useManagePermissionMutation({
dataSource: customDataSource,
schema: customSchema,
table: customTable,
appUrl: customAppUrl,
adminSecret: customAdminSecret,
mutationOptions,
}: UseManagePermissionMutationOptions = {}) {
const isPlatform = useIsPlatform();
const {
query: { dataSourceSlug, schemaSlug },
} = useRouter();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const appUrl = generateAppServiceUrl(
currentApplication?.subdomain,
currentApplication?.region.awsName,
'hasura',
);
const mutationFn = isPlatform ? managePermission : managePermissionMigration;
const mutation = useMutation(
(variables) =>
mutationFn({
...variables,
appUrl: customAppUrl || appUrl,
adminSecret:
process.env.NEXT_PUBLIC_ENV === 'dev'
? 'nhost-admin-secret'
: customAdminSecret || currentApplication?.hasuraGraphqlAdminSecret,
dataSource: customDataSource || (dataSourceSlug as string),
schema: customSchema || (schemaSlug as string),
table: customTable || (dataSourceSlug as string),
}),
mutationOptions,
);
return mutation;
}

View File

@@ -27,6 +27,10 @@ export interface FetchTableOptions extends MutationOrQueryBaseOptions {
* @default []
*/
orderBy?: OrderBy[];
/**
* Determines whether the query should fetch the rows or not.
*/
preventRowFetching?: boolean;
}
export interface FetchTableReturnType {
@@ -68,10 +72,13 @@ export default async function fetchTable({
limit,
offset,
orderBy,
preventRowFetching,
}: FetchTableOptions): Promise<FetchTableReturnType> {
let limitAndOffsetClause = '';
if (limit && offset) {
if (preventRowFetching) {
limitAndOffsetClause = `LIMIT 0`;
} else if (limit && offset) {
limitAndOffsetClause = `LIMIT ${limit} OFFSET ${offset}`;
} else if (limit) {
limitAndOffsetClause = `LIMIT ${limit}`;

View File

@@ -5,6 +5,7 @@ import type {
MutationOrQueryBaseOptions,
QueryResult,
} from '@/types/dataBrowser';
import normalizeMetadataError from '@/utils/dataBrowser/normalizeMetadataError/normalizeMetadataError';
import prepareTrackForeignKeyRelationsMetadata from './prepareTrackForeignKeyRelationsMetadata';
export interface TrackForeignKeyRelationsVariables {
@@ -60,15 +61,11 @@ export default async function trackForeignKeyRelations({
| [AffectedRowsResult, QueryResult<string[]>]
| MetadataError = await response.json();
if (!response.ok || 'error' in responseData) {
if ('internal' in responseData) {
const metadataError = responseData as MetadataError;
throw new Error(metadataError.internal[0]?.reason);
}
if ('error' in responseData) {
const metadataError = responseData as MetadataError;
throw new Error(metadataError.error);
}
if (response.ok) {
return;
}
const normalizedError = normalizeMetadataError(responseData);
throw new Error(normalizedError);
}

View File

@@ -5,6 +5,7 @@ import type {
MutationOrQueryBaseOptions,
QueryResult,
} from '@/types/dataBrowser';
import normalizeMetadataError from '@/utils/dataBrowser/normalizeMetadataError/normalizeMetadataError';
export interface TrackTableVariables {
/**
@@ -45,18 +46,11 @@ export default async function trackTable({
| [AffectedRowsResult, QueryResult<string[]>]
| MetadataError = await response.json();
if (!response.ok || 'error' in responseData) {
if ('internal' in responseData) {
const metadataError = responseData as MetadataError;
throw new Error(
metadataError.internal[0]?.reason ||
'Unknown error occurred. Please try again later.',
);
}
if ('error' in responseData) {
const metadataError = responseData as MetadataError;
throw new Error(metadataError.error);
}
if (response.ok) {
return;
}
const normalizedError = normalizeMetadataError(responseData);
throw new Error(normalizedError);
}

View File

@@ -17,8 +17,7 @@ export interface UseTrackTableMutationOptions
}
/**
* This hook is a wrapper around a fetch call that inserts a record into the
* table.
* This hook is a wrapper around a fetch call that tracks a table.
*
* @param options - Options to use for the mutation.
* @returns The result of the mutation.

View File

@@ -27,6 +27,7 @@ export function useRemoteApplicationGQLClient() {
: currentApplication?.hasuraGraphqlAdminSecret,
},
}),
}),
[
currentApplication?.subdomain,

View File

@@ -16,7 +16,7 @@ export default function DataBrowserDatabaseDetailsPage() {
description={
<span>
Database{' '}
<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">
{dataSourceSlug}
</InlineCode>{' '}
does not exist.

View File

@@ -109,7 +109,7 @@ export default function LogsPage() {
}, [subscribeToMoreLogs, toDate, client]);
return (
<div className="flex h-full w-full flex-col">
<div className="flex flex-col w-full h-full">
<RetryableErrorBoundary>
<LogsHeader
fromDate={fromDate}

View File

@@ -1,4 +1,5 @@
import Container from '@/components/layout/Container';
import SettingsLayout from '@/components/settings/SettingsLayout';
import AllowedEmailDomainsSettings from '@/components/settings/authentication/AllowedEmailSettings';
import AllowedRedirectURLsSettings from '@/components/settings/authentication/AllowedRedirectURLsSettings';
import BlockedEmailSettings from '@/components/settings/authentication/BlockedEmailSettings';
@@ -6,7 +7,6 @@ import ClientURLSettings from '@/components/settings/authentication/ClientURLSet
import DisableNewUsersSettings from '@/components/settings/authentication/DisableNewUsersSettings';
import GravatarSettings from '@/components/settings/authentication/GravatarSettings';
import MFASettings from '@/components/settings/authentication/MFASettings';
import SettingsLayout from '@/components/settings/SettingsLayout';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import { useGetAppQuery } from '@/utils/__generated__/graphql';
@@ -37,7 +37,7 @@ export default function SettingsAuthenticationPage() {
return (
<Container
className="grid max-w-5xl grid-flow-row gap-y-6 bg-transparent"
className="grid max-w-5xl grid-flow-row bg-transparent gap-y-6"
rootClassName="bg-transparent"
>
<ClientURLSettings />

View File

@@ -1,652 +0,0 @@
import ErrorMessage from '@/components/common/ErrorMessage';
import { LoadingScreen } from '@/components/common/LoadingScreen';
import Container from '@/components/layout/Container';
import ProjectLayout from '@/components/layout/ProjectLayout';
import RemoveUserFromAppModal from '@/components/workspace/RemoveUserFromAppModal';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { useFormSaver } from '@/hooks/useFormSaver';
import { Avatar } from '@/ui/Avatar';
import { FormSaver } from '@/ui/FormSaver';
import { Modal } from '@/ui/Modal';
import Status, { StatusEnum } from '@/ui/Status';
import Button from '@/ui/v2/Button';
import Checkbox from '@/ui/v2/Checkbox';
import IconButton from '@/ui/v2/IconButton';
import ChevronDownIcon from '@/ui/v2/icons/ChevronDownIcon';
import ChevronUpIcon from '@/ui/v2/icons/ChevronUpIcon';
import CopyIcon from '@/ui/v2/icons/CopyIcon';
import Input from '@/ui/v2/Input';
import Option from '@/ui/v2/Option';
import Select from '@/ui/v2/Select';
import Text from '@/ui/v2/Text';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy';
import { triggerToast } from '@/utils/toast';
import type {
GetRemoteAppUserAuthRolesFragment,
GetRemoteAppUserFragment,
} from '@/utils/__generated__/graphql';
import {
useDeleteRemoteAppUserRolesMutation,
useGetRemoteAppUserQuery,
useInsertRemoteAppUserRolesMutation,
useRemoteAppDeleteUserMutation,
useUpdateRemoteAppUserMutation,
} from '@/utils/__generated__/graphql';
import { NhostApolloProvider } from '@nhost/react-apollo';
import bcrypt from 'bcryptjs';
import { format } from 'date-fns';
import router, { useRouter } from 'next/router';
import type { PropsWithChildren, ReactElement } from 'react';
import React, { useState } from 'react';
function UserSectionContainer({ title, children }: any) {
return (
<div className="mt-16 space-y-6">
<Text variant="h3">{title}</Text>
<div className="divide divide-y-1 border-t-1 border-b-1">{children}</div>
</div>
);
}
function UserDetailsFromAppElement({ title, children }: any) {
return (
<div className="grid grid-cols-8 items-center justify-between gap-4 px-2 py-3">
<Text className="col-span-3 font-medium">{title}</Text>
<div className="col-span-5">{children}</div>
</div>
);
}
type UserDetailsFromAppProps = {
user: GetRemoteAppUserFragment;
};
function UserDetailsFromApp({ user }: UserDetailsFromAppProps) {
return (
<div className="divide-y-1 divide-divide">
<UserDetailsFromAppElement title="User ID">
<div className="grid grid-flow-col items-center justify-start gap-2">
<Text className="font-medium">{user.id}</Text>
<IconButton
variant="borderless"
color="secondary"
onClick={() => copy(user.id, 'User ID')}
aria-label="Copy user ID"
className="p-1"
>
<CopyIcon className="h-4 w-4" />
</IconButton>
</div>
</UserDetailsFromAppElement>
<UserDetailsFromAppElement title="Status">
<div className="flex flex-row space-x-2 self-center">
{user.disabled && (
<Status status={StatusEnum.Closed}>Disabled</Status>
)}
{user.emailVerified || user.phoneNumberVerified ? (
<Status status={StatusEnum.Live}>Verified</Status>
) : (
<Status status={StatusEnum.Medium}>Unverified</Status>
)}
</div>
</UserDetailsFromAppElement>
<UserDetailsFromAppElement title="Created">
<Text className="font-medium">
{format(new Date(user.createdAt), 'dd MMM yyyy')}
</Text>
</UserDetailsFromAppElement>
</div>
);
}
function UserDetailsSection({ children }: PropsWithChildren<unknown>) {
return (
<UserSectionContainer title="Details">{children}</UserSectionContainer>
);
}
type UserDetailsPasswordProps = {
userId: string;
userHasPasswordSet?: boolean;
};
function UserDetailsPassword({
userId,
userHasPasswordSet,
}: UserDetailsPasswordProps) {
const [password, setPassword] = useState('');
const [editPassword, setEditPassword] = useState(false);
const [updateUser, { loading }] = useUpdateRemoteAppUserMutation();
const [error, setError] = useState('');
const handleOnSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
try {
const passwordHash = await bcrypt.hash(password, 10);
await updateUser({
variables: {
id: userId,
user: {
passwordHash,
},
},
});
} catch (hashError) {
if (hashError instanceof Error) {
setError(hashError.message);
}
setError(hashError);
}
triggerToast('The password was successfully changed');
setEditPassword(false);
setPassword('');
};
if (editPassword) {
return (
<form
onSubmit={handleOnSubmit}
className="flex w-full flex-row items-start gap-2 py-3 px-2"
>
<Input
id="password"
label="Password"
name="Password"
placeholder={userHasPasswordSet ? `••••••••••••` : 'Password'}
value={password}
onChange={(event) => setPassword(event.target.value)}
type="password"
fullWidth
hideEmptyHelperText
helperText={error || ''}
error={!!error}
variant="inline"
inlineInputProportion="66%"
slotProps={{
label: { className: 'text-sm+ font-medium' },
inputWrapper: { className: 'max-w-[370px] justify-self-end' },
}}
className="justify-self-stretch"
/>
<Button
type="submit"
loading={loading}
className="justify-self-end py-2"
>
Save
</Button>
</form>
);
}
return (
<div className="grid grid-cols-8 items-center gap-4 py-3 px-2">
<Text className="col-span-3 text-sm+ font-medium">Password</Text>
<div className="col-span-5 grid w-full grid-flow-col place-content-between items-center gap-2">
<Text variant="subtitle2">
{userHasPasswordSet ? `••••••••••••` : 'No password set'}
</Text>
<Button variant="borderless" onClick={() => setEditPassword(true)}>
Change
</Button>
</div>
</div>
);
}
type UserDetailsProps = {
user: GetRemoteAppUserFragment;
authRoles: GetRemoteAppUserAuthRolesFragment[];
};
function UserDetails({ user: externalUser, authRoles }: UserDetailsProps) {
const {
query: { workspaceSlug, appSlug },
} = useRouter();
const { showFormSaver, setShowFormSaver, submitState, setSubmitState } =
useFormSaver();
const [originalUser, setOriginalUser] = useState(externalUser);
const [user, setUser] = useState(externalUser);
const stateUserRoles = originalUser.roles.map((role) => role.role);
const [roles, setRoles] = useState(stateUserRoles);
const defaultRoleOptions = authRoles.map((role) => ({
id: role.role,
name: role.role,
disabled: false,
}));
const [updateUser] = useUpdateRemoteAppUserMutation();
const [insertUserRoles] = useInsertRemoteAppUserRolesMutation();
const [deleteUserRoles] = useDeleteRemoteAppUserRolesMutation();
const [deleteUser, { loading: deleteUserLoading }] =
useRemoteAppDeleteUserMutation();
const handleFormSubmit = async () => {
setSubmitState({
loading: true,
error: null,
});
try {
await updateUser({
variables: {
id: user.id,
user: {
displayName: user.displayName,
email: user.email,
defaultRole: user.defaultRole,
},
},
});
} catch (error) {
setSubmitState({
loading: false,
error,
});
return;
}
// role di
const addedAllowedRoles = roles.filter(
(role) => !stateUserRoles.includes(role),
);
const deletedAllowedRoles = stateUserRoles.filter(
(role) => !roles.includes(role),
);
try {
await insertUserRoles({
variables: {
roles: addedAllowedRoles.map((role) => ({
userId: user.id,
role,
})),
},
});
await deleteUserRoles({
variables: {
userId: user.id,
roles: deletedAllowedRoles,
},
});
} catch (error) {
setSubmitState({
loading: false,
error,
});
return;
}
setOriginalUser(user);
setSubmitState({
loading: false,
error: null,
});
triggerToast('Settings saved');
setShowFormSaver(false);
};
const [removeUserFromAppModal, setRemoveUserFromAppModal] = useState(false);
const handleDeleteUser = async () => {
await deleteUser({
variables: {
id: user.id,
},
});
triggerToast(`${user.displayName} deleted`);
router.push(`/${workspaceSlug}/${appSlug}/users`);
};
const [toggleShowRoles, setToggleShowRoles] = useState(false);
return (
<Container className="max-w-3xl">
{showFormSaver && (
<FormSaver
show={showFormSaver}
onCancel={() => {
setUser(originalUser);
setShowFormSaver(false);
}}
onSave={() => {
handleFormSubmit();
}}
loading={submitState.loading}
/>
)}
<Modal
showModal={removeUserFromAppModal}
close={() => setRemoveUserFromAppModal(false)}
>
<RemoveUserFromAppModal
onClick={handleDeleteUser}
close={() => setRemoveUserFromAppModal(!removeUserFromAppModal)}
/>
</Modal>
<div className="flex flex-row">
<Avatar
className="h-14 w-14 rounded-lg"
avatarUrl={user.avatarUrl}
name={user.displayName}
/>
<div className="ml-4 flex flex-col self-center">
<Text variant="h3" component="h1">
{user.displayName || user.phoneNumber}
</Text>
<Text variant="subtitle2" className="!text-greyscaleGrey">
{user.id}
</Text>
</div>
</div>
<div className="mt-8 space-y-3">
<Input
value={user.displayName}
id="displayName"
label="Display Name"
fullWidth
placeholder="Display Name"
variant="inline"
inlineInputProportion="66%"
hideEmptyHelperText
onChange={(event) => {
setShowFormSaver(true);
setUser({
...user,
displayName: event.target.value,
});
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
handleFormSubmit();
}}
slotProps={{
label: { className: 'text-sm+ font-medium' },
}}
/>
<Input
value={user.email}
id="email"
label="Email"
placeholder="Email"
variant="inline"
inlineInputProportion="66%"
hideEmptyHelperText
fullWidth
autoComplete="off"
onChange={(event) => {
setShowFormSaver(true);
setUser({
...user,
email: event.target.value,
});
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
handleFormSubmit();
}}
slotProps={{
label: { className: 'text-sm+ font-medium' },
}}
/>
<Select
value="EN"
id="locale"
label="Locale"
variant="inline"
inlineInputProportion="66%"
fullWidth
hideEmptyHelperText
slotProps={{
label: { className: 'text-sm+ font-medium' },
}}
>
<Option value="EN">English</Option>
</Select>
</div>
<UserDetailsSection>
<UserDetailsFromApp user={originalUser} />
</UserDetailsSection>
<UserSectionContainer title="Sign-In Methods">
<UserDetailsPassword
userId={user.id}
userHasPasswordSet={!!user.passwordHash}
/>
<UserDetailsFromAppElement title="Authentication">
<div className="flex w-full flex-col space-y-3">
<div className="flex place-content-between items-center">
<div className="flex">
<Text className="font-medium">Email + Password</Text>
</div>
<div className="flex">
<Status
status={
user.email && user.passwordHash
? StatusEnum.Live
: StatusEnum.Closed
}
>
{user.email && user.passwordHash ? 'Active' : 'Inactive'}
</Status>
</div>
</div>
<div className="flex place-content-between items-center">
<div className="flex">
<Text className="font-medium">Magic Link</Text>
</div>
<div className="flex">
<Status
status={user.email ? StatusEnum.Live : StatusEnum.Closed}
>
{user.email && user.passwordHash ? 'Active' : 'Inactive'}
</Status>
</div>
</div>
<div className="flex place-content-between items-center">
<div className="flex">
<Text className="font-medium">SMS</Text>
</div>
<div className="flex">
<Status
status={
user.phoneNumber ? StatusEnum.Live : StatusEnum.Closed
}
>
{user.phoneNumber ? 'Active' : 'Inactive'}
</Status>
</div>
</div>
</div>
</UserDetailsFromAppElement>
</UserSectionContainer>
{toggleShowRoles && (
<UserSectionContainer title="Roles">
<div className="px-2 py-3">
<Select
label="Default role in API requests"
id="defaultRole"
fullWidth
value={user.defaultRole}
variant="inline"
inlineInputProportion="66%"
aria-label="Default role"
hideEmptyHelperText
onChange={(_event, value) => {
setShowFormSaver(true);
setUser({
...user,
defaultRole: value as string,
});
}}
slotProps={{
label: { className: 'text-sm+ font-medium' },
}}
>
{defaultRoleOptions.map((role) => (
<Option value={role.id} key={role.id}>
{role.name}
</Option>
))}
</Select>
</div>
<UserDetailsFromAppElement title="Roles">
<div className="grid w-full grid-flow-row gap-4">
{authRoles.map((role) => {
const checked = roles.includes(role.role);
return (
<Checkbox
label={role.role}
componentsProps={{
formControlLabel: {
componentsProps: {
typography: { className: 'text-sm+' },
},
},
}}
checked={checked}
onChange={(_event, isChecked) => {
setShowFormSaver(true);
if (!isChecked) {
const index = roles.indexOf(role.role);
if (index === -1) {
return;
}
roles.splice(index, 1);
setRoles([...roles]);
return;
}
setRoles([...roles, role.role]);
}}
key={role.role}
/>
);
})}
</div>
</UserDetailsFromAppElement>
</UserSectionContainer>
)}
<div className="mt-3 flex flex-row place-content-between px-1">
<Button
startIcon={toggleShowRoles ? <ChevronUpIcon /> : <ChevronDownIcon />}
variant="borderless"
onClick={() => setToggleShowRoles(!toggleShowRoles)}
>
{toggleShowRoles ? 'Hide Roles' : 'Show Roles'}
</Button>
<Button
variant="borderless"
color="error"
onClick={() => setRemoveUserFromAppModal(true)}
loading={deleteUserLoading}
>
Delete User
</Button>
</div>
</Container>
);
}
function UserDetailsPreloadData() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const {
query: { userId },
} = useRouter();
const { data, loading } = useGetRemoteAppUserQuery({
variables: {
id: userId,
},
skip:
!currentApplication?.subdomain &&
!currentApplication?.hasuraGraphqlAdminSecret,
});
if (loading) {
return <LoadingScreen />;
}
if (!data?.user) {
return (
<Container>
<ErrorMessage>
User data is not available. Please try again later.
</ErrorMessage>
</Container>
);
}
const { user, authRoles } = data;
return <UserDetails user={user} authRoles={authRoles} />;
}
export default function UserDetailsByIdPage() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
if (
!currentApplication?.subdomain ||
!currentApplication?.hasuraGraphqlAdminSecret
) {
return <LoadingScreen />;
}
return (
<NhostApolloProvider
graphqlUrl={generateAppServiceUrl(
currentApplication.subdomain,
currentApplication.region.awsName,
'graphql',
)}
fetchPolicy="cache-first"
headers={{
'x-hasura-admin-secret':
process.env.NEXT_PUBLIC_ENV === 'dev'
? 'nhost-admin-secret'
: currentApplication.hasuraGraphqlAdminSecret,
}}
>
<UserDetailsPreloadData />
</NhostApolloProvider>
);
}
UserDetailsByIdPage.getLayout = function getLayout(page: ReactElement) {
return <ProjectLayout>{page}</ProjectLayout>;
};

View File

@@ -1,38 +1,389 @@
import { LoadingScreen } from '@/components/common/LoadingScreen';
import { useDialog } from '@/components/common/DialogProvider';
import Pagination from '@/components/common/Pagination';
import Container from '@/components/layout/Container';
import ProjectLayout from '@/components/layout/ProjectLayout';
import UsersList from '@/components/users/UsersList';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { NhostApolloProvider } from '@nhost/react-apollo';
import type { ReactElement } from 'react';
import UsersBody from '@/components/users/UsersBody';
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import UserIcon from '@/ui/v2/icons/UserIcon';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
import { useRemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
import { SearchIcon } from '@heroicons/react/solid';
import debounce from 'lodash.debounce';
import Router, { useRouter } from 'next/router';
import type { ChangeEvent, ReactElement } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export type RemoteAppUser = Exclude<
RemoteAppGetUsersQuery['users'][0],
'__typename'
>;
export default function UsersPage() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, closeDialog } = useDialog();
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
const [searchString, setSearchString] = useState<string>('');
if (!currentApplication) {
return <LoadingScreen />;
const limit = useRef(25);
const router = useRouter();
const [nrOfPages, setNrOfPages] = useState(
parseInt(router.query.page as string, 10) || 1,
);
const [currentPage, setCurrentPage] = useState(
parseInt(router.query.page as string, 10) || 1,
);
const offset = useMemo(() => currentPage - 1, [currentPage]);
const remoteAppGetUserVariables = useMemo(
() => ({
where:
router.query.userId !== undefined
? {
id: {
_eq: searchString,
},
}
: {
_or: [
{
displayName: {
_ilike: `%${searchString}%`,
},
},
{
email: {
_ilike: `%${searchString}%`,
},
},
],
},
limit: limit.current,
offset: offset * limit.current,
}),
[router.query.userId, searchString, offset],
);
const {
data: dataRemoteAppUsers,
refetch: refetchProjectUsers,
loading: loadingRemoteAppUsersQuery,
} = useRemoteAppGetUsersQuery({
variables: remoteAppGetUserVariables,
client: remoteProjectGQLClient,
});
/**
* This function will remove query params from the URL.
*
* @remarks This function is used when we want to update the URL query
* params without refreshing the page. For example if we want to remove
* the page query param from the URL when we are on the first page.
*
* @param removeList - List of query params we want to remove from the URL
*/
const removeQueryParamsFromRouter = useCallback(
(removeList: string[] = []) => {
if (removeList.length > 0) {
removeList.forEach(
(param: string | number) => delete Router.query[param],
);
} else {
// Remove all
Object.keys(Router.query).forEach(
(param) => delete Router.query[param],
);
}
Router.replace(
{
pathname: Router.pathname,
query: Router.query,
},
undefined,
/**
* Do not refresh the page
*/
{ shallow: true },
);
},
[],
);
/**
* If a user of the app enters the users tab with a page query param of the following structure:
* `users?page=2` this useEffect will update the current page to 2.
* which in turn will update the offset and trigger fetching the data with the new variables.
* If the user enters a page number that is greater than the number of pages we will redirect
* the user to the first page and update the URL.
*
* @remarks If the user navigates the page back and forth we handle the URL change through
* props passed to the Pagination component.
* @see {@link Pagination}
*
*/
useEffect(() => {
if (router.query.page === undefined) {
setCurrentPage(1);
return;
}
if (router.query.page && typeof router.query.page === 'string') {
const pageNumber = parseInt(router.query.page, 10);
if (nrOfPages >= pageNumber) {
setCurrentPage(pageNumber);
} else {
setCurrentPage(1);
}
}
}, [nrOfPages, router.query.page]);
/**
* If the user is on the first page, we want to remove the page query param from the URL.
* e.g. `users?page=1` -> `users`
*/
useEffect(() => {
if (currentPage === 1) {
removeQueryParamsFromRouter(['page']);
}
}, [currentPage, removeQueryParamsFromRouter]);
/**
* If the users enters the page with a page query param with the following structure:
* `users?userId=<id>` this useEffect will update the search string to the id.
* which in turn will trigger fetching the data with the new variables.
*
*/
useEffect(() => {
if (router.query.userId && typeof router.query.userId === 'string') {
setSearchString(router.query.userId);
}
}, [router.query.userId]);
/**
* We want to update the number of pages when the data changes
* (either fetch for the first time or making a search).
*/
useEffect(() => {
if (loadingRemoteAppUsersQuery) {
return;
}
const userCount = searchString
? dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count
: dataRemoteAppUsers?.usersAggregate?.aggregate?.count;
setNrOfPages(Math.ceil(userCount / limit.current));
}, [
dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count,
dataRemoteAppUsers?.usersAggregate.aggregate.count,
loadingRemoteAppUsersQuery,
searchString,
]);
const handleSearchStringChange = useMemo(
() =>
debounce((event: ChangeEvent<HTMLInputElement>) => {
setCurrentPage(1);
setSearchString(event.target.value);
}, 1000),
[],
);
useEffect(
() => () => handleSearchStringChange.cancel(),
[handleSearchStringChange],
);
function openCreateUserDialog() {
openDialog('CREATE_USER', {
title: 'Create User',
payload: {
onSuccess: async () => {
await refetchProjectUsers();
closeDialog();
},
},
});
}
const users = useMemo(
() => dataRemoteAppUsers?.users.map((user) => user) ?? [],
[dataRemoteAppUsers],
);
const usersCount = useMemo(
() => dataRemoteAppUsers?.usersAggregate?.aggregate?.count ?? -1,
[dataRemoteAppUsers],
);
const thereAreUsers =
dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count ||
usersCount <= 0;
if (loadingRemoteAppUsersQuery) {
return (
<Container className="mx-auto overflow-x-hidden max-w-9xl">
<div className="flex flex-row place-content-between">
<Input
className="rounded-sm"
placeholder="Search users"
startAdornment={
<SearchIcon className="w-4 h-4 ml-2 -mr-1 text-greyscaleGrey shrink-0" />
}
onChange={handleSearchStringChange}
/>
<Button
onClick={openCreateUserDialog}
startIcon={<PlusIcon className="w-4 h-4" />}
className="grid h-full grid-flow-col gap-1 p-2 place-items-center"
size="small"
>
Create User
</Button>
</div>
<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>
</Container>
);
}
return (
<NhostApolloProvider
graphqlUrl={generateAppServiceUrl(
currentApplication.subdomain,
currentApplication.region.awsName,
'graphql',
<Container className="mx-auto space-y-5 overflow-x-hidden max-w-9xl">
<div className="flex flex-row place-content-between">
<Input
className="rounded-sm"
placeholder="Search users"
startAdornment={
<SearchIcon className="w-4 h-4 ml-2 -mr-1 text-greyscaleGrey shrink-0" />
}
onChange={handleSearchStringChange}
/>
<Button
onClick={openCreateUserDialog}
startIcon={<PlusIcon className="w-4 h-4" />}
className="grid h-full grid-flow-col gap-1 p-2 place-items-center"
size="small"
>
Create User
</Button>
</div>
{usersCount === 0 ? (
<div className="flex flex-col items-center justify-center px-48 py-12 space-y-5 border rounded-lg shadow-sm border-veryLightGray">
<UserIcon strokeWidth={1} className="w-10 h-10 text-greyscaleGrey" />
<div className="flex flex-col space-y-1">
<Text className="font-medium text-center" variant="h3">
There are no users yet
</Text>
<Text variant="subtitle1" className="text-center">
All users for your project will be listed here.
</Text>
</div>
<div className="flex flex-row place-content-between rounded-lg lg:w-[230px]">
<Button
variant="contained"
color="primary"
className="w-full"
aria-label="Create User"
onClick={openCreateUserDialog}
startIcon={<PlusIcon className="w-4 h-4" />}
>
Create User
</Button>
</div>
</div>
) : (
<div className="grid grid-flow-row gap-2 lg:w-9xl">
<div className="grid w-full h-full grid-flow-row pb-4 overflow-hidden">
<div className="grid w-full p-2 border-b md:grid-cols-6">
<Text className="font-medium md:col-span-2">Name</Text>
<Text className="hidden font-medium md:block">Signed up at</Text>
<Text className="hidden font-medium md:block">Last Seen</Text>
<Text className="hidden col-span-2 font-medium md:block">
OAuth Providers
</Text>
</div>
{dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count ===
0 &&
usersCount !== 0 && (
<div className="flex flex-col items-center justify-center px-48 py-12 space-y-5 border-b border-l border-r border-veryLightGray">
<UserIcon
strokeWidth={1}
className="w-10 h-10 text-greyscaleGrey"
/>
<div className="flex flex-col space-y-1">
<Text className="font-medium text-center" variant="h3">
No results for &quot;{searchString}&quot;
</Text>
<Text variant="subtitle1" className="text-center">
Try a different search
</Text>
</div>
</div>
)}
{thereAreUsers && (
<div className="grid grid-flow-row gap-4">
<UsersBody
users={users}
onSuccessfulAction={refetchProjectUsers}
/>
<Pagination
className="px-2"
totalNrOfPages={nrOfPages}
currentPageNumber={currentPage}
totalNrOfElements={
searchString
? dataRemoteAppUsers?.filteredUsersAggreggate.aggregate
.count
: dataRemoteAppUsers?.usersAggregate?.aggregate?.count
}
elementsPerPage={
searchString
? dataRemoteAppUsers?.filteredUsersAggreggate.aggregate
.count
: limit.current
}
onPrevPageClick={async () => {
setCurrentPage((page) => page - 1);
if (currentPage - 1 !== 1) {
await router.push({
pathname: router.pathname,
query: { ...router.query, page: currentPage - 1 },
});
}
}}
onNextPageClick={async () => {
setCurrentPage((page) => page + 1);
await router.push({
pathname: router.pathname,
query: { ...router.query, page: currentPage + 1 },
});
}}
onPageChange={async (page) => {
setCurrentPage(page);
await router.push({
pathname: router.pathname,
query: { ...router.query, page },
});
}}
/>
</div>
)}
</div>
</div>
)}
fetchPolicy="cache-first"
headers={{
'x-hasura-admin-secret':
process.env.NEXT_PUBLIC_ENV === 'dev'
? 'nhost-admin-secret'
: currentApplication.hasuraGraphqlAdminSecret,
}}
>
<Container>
<UsersList />
</Container>
</NhostApolloProvider>
</Container>
);
}

View File

@@ -49,17 +49,21 @@ export const theme = createTheme({
main: '#f13154',
dark: '#c91737',
},
success: {
main: 'rgba(170, 240, 204, 0.3)',
contrastText: '#3BB174',
},
action: {
hover: '#f3f4f6',
active: '#f3f4f6',
focus: '#f3f4f6',
disabled: '#C2CAD6',
disabled: '#c2cad6',
},
grey: {
100: '#ffffff',
200: '#f4f7f9',
300: '#eaedf0',
400: '#c2cad6',
400: '#e0e0e0',
500: '#9ca7b7',
600: '#556378',
700: '#21324b',

View File

@@ -33,6 +33,13 @@ export interface MutationOrQueryBaseOptions {
export interface HasuraMetadataRelationship {
name: string;
using: {
manual_configuration?: {
column_mapping: Record<string, string>;
remote_table: {
name: string;
schema: string;
};
};
foreign_key_constraint_on?:
| string
| {
@@ -45,6 +52,21 @@ export interface HasuraMetadataRelationship {
};
}
export interface HasuraMetadataPermission {
role: string;
permission: Partial<{
columns: string[];
filter: Record<string, any>;
check: Record<string, any>;
limit: number;
allow_aggregations: boolean;
query_root_fields: string[];
subscription_root_fields: string[];
set: Record<string, any>;
backend_only: boolean;
}>;
}
/**
* Represents a table from Hasura metadata.
*/
@@ -54,8 +76,12 @@ export interface HasuraMetadataTable {
schema: string;
};
configuration: Record<string, Record<string, any>>;
array_relationships: HasuraMetadataRelationship[];
object_relationships: HasuraMetadataRelationship[];
array_relationships?: HasuraMetadataRelationship[];
object_relationships?: HasuraMetadataRelationship[];
insert_permissions?: HasuraMetadataPermission[];
select_permissions?: HasuraMetadataPermission[];
update_permissions?: HasuraMetadataPermission[];
delete_permissions?: HasuraMetadataPermission[];
}
/**
@@ -132,7 +158,7 @@ export interface MetadataError {
schema: string;
name: string;
};
using: {
using?: {
foreign_key_constraint_on: string;
};
}[];
@@ -385,10 +411,25 @@ export interface DatabaseColumn {
* Represents a database table.
*/
export interface DatabaseTable {
/**
* Name of the table.
*/
name: string;
/**
* Columns of the table.
*/
columns: DatabaseColumn[];
/**
* Primary key of the table.
*/
primaryKey: string;
/**
* Identity column of the table.
*/
identityColumn?: string;
/**
* Foreign key relations of the table.
*/
foreignKeyRelations?: ForeignKeyRelation[];
}
@@ -485,6 +526,16 @@ export interface DataBrowserGridCellProps<
row: DataBrowserGridRow<TData>;
}
/**
* Represents an available database action.
*/
export type DatabaseAction = 'insert' | 'select' | 'update' | 'delete';
/**
* Represents the database access level.
*/
export type DatabaseAccessLevel = 'full' | 'partial' | 'none';
/**
* Represents a Hasura operator.
*/
@@ -534,4 +585,5 @@ export interface RuleGroup {
operator: '_and' | '_or';
rules: Rule[];
groups: RuleGroup[];
unsupported?: Record<string, any>[];
}

View File

@@ -9,6 +9,7 @@ export enum AvailableLogsServices {
AUTH = 'hasura-auth',
STORAGE = 'hasura-storage',
HASURA = 'hasura',
FUNCTIONS = 'functions',
}
export type LogsCustomInterval = {

View File

@@ -17080,15 +17080,6 @@ export type GetAppInjectedVariablesQueryVariables = Exact<{
export type GetAppInjectedVariablesQuery = { __typename?: 'query_root', app?: { __typename?: 'apps', id: any, webhookSecret: string, hasuraGraphqlJwtSecret: string } | null };
export type GetAppLoginDataFragment = { __typename?: 'apps', id: any, slug: string, subdomain: string, name: string, createdAt: any, authEmailSigninEmailVerifiedRequired: boolean, authPasswordHibpEnabled: boolean, authEmailPasswordlessEnabled: boolean, authSmsPasswordlessEnabled: boolean, authWebAuthnEnabled: boolean, authClientUrl: string, authAccessControlAllowedRedirectUrls: string, authAccessControlAllowedEmails: string, authAccessControlAllowedEmailDomains: string, authAccessControlBlockedEmails: string, authAccessControlBlockedEmailDomains: string, authGithubEnabled: boolean, authGithubClientId: string, authGithubClientSecret: string, authGoogleEnabled: boolean, authGoogleClientId: string, authGoogleClientSecret: string, authFacebookEnabled: boolean, authFacebookClientId: string, authFacebookClientSecret: string, authLinkedinEnabled: boolean, authLinkedinClientId: string, authLinkedinClientSecret: string, authTwitterEnabled: boolean, authTwitterConsumerKey: string, authTwitterConsumerSecret: string, authAppleEnabled: boolean, authAppleTeamId: string, authAppleKeyId: string, authAppleClientId: string, authApplePrivateKey: string, authAppleScope: string, authWindowsLiveEnabled: boolean, authWindowsLiveClientId: string, authWindowsLiveClientSecret: string, authSpotifyEnabled: boolean, authSpotifyClientId: string, authSpotifyClientSecret: string, authWorkOsEnabled: boolean, authWorkOsClientId: string, authWorkOsClientSecret: string, authWorkOsDefaultDomain: string, authWorkOsDefaultOrganization: string, authWorkOsDefaultConnection: string, authDiscordEnabled: boolean, authDiscordClientId: string, authDiscordClientSecret: string, authTwitchEnabled: boolean, authTwitchClientId: string, authTwitchClientSecret: string };
export type GetAppLoginDataQueryVariables = Exact<{
id: Scalars['uuid'];
}>;
export type GetAppLoginDataQuery = { __typename?: 'query_root', app?: { __typename?: 'apps', id: any, slug: string, subdomain: string, name: string, createdAt: any, authEmailSigninEmailVerifiedRequired: boolean, authPasswordHibpEnabled: boolean, authEmailPasswordlessEnabled: boolean, authSmsPasswordlessEnabled: boolean, authWebAuthnEnabled: boolean, authClientUrl: string, authAccessControlAllowedRedirectUrls: string, authAccessControlAllowedEmails: string, authAccessControlAllowedEmailDomains: string, authAccessControlBlockedEmails: string, authAccessControlBlockedEmailDomains: string, authGithubEnabled: boolean, authGithubClientId: string, authGithubClientSecret: string, authGoogleEnabled: boolean, authGoogleClientId: string, authGoogleClientSecret: string, authFacebookEnabled: boolean, authFacebookClientId: string, authFacebookClientSecret: string, authLinkedinEnabled: boolean, authLinkedinClientId: string, authLinkedinClientSecret: string, authTwitterEnabled: boolean, authTwitterConsumerKey: string, authTwitterConsumerSecret: string, authAppleEnabled: boolean, authAppleTeamId: string, authAppleKeyId: string, authAppleClientId: string, authApplePrivateKey: string, authAppleScope: string, authWindowsLiveEnabled: boolean, authWindowsLiveClientId: string, authWindowsLiveClientSecret: string, authSpotifyEnabled: boolean, authSpotifyClientId: string, authSpotifyClientSecret: string, authWorkOsEnabled: boolean, authWorkOsClientId: string, authWorkOsClientSecret: string, authWorkOsDefaultDomain: string, authWorkOsDefaultOrganization: string, authWorkOsDefaultConnection: string, authDiscordEnabled: boolean, authDiscordClientId: string, authDiscordClientSecret: string, authTwitchEnabled: boolean, authTwitchClientId: string, authTwitchClientSecret: string } | null };
export type GetAppRolesFragment = { __typename?: 'apps', id: any, slug: string, subdomain: string, name: string, authUserDefaultAllowedRoles: string, authUserDefaultRole: string };
export type GetAppRolesAndPermissionsQueryVariables = Exact<{
@@ -17433,7 +17424,7 @@ export type GetRemoteAppByIdQueryVariables = Exact<{
export type GetRemoteAppByIdQuery = { __typename?: 'query_root', user?: { __typename?: 'users', id: any, displayName: string, email?: any | null } | null };
export type RemoteAppGetUsersFragment = { __typename?: 'users', id: any, createdAt: any, displayName: string, avatarUrl: string, email?: any | null, phoneNumber?: string | null, disabled: boolean, defaultRole: string, roles: Array<{ __typename?: 'authUserRoles', role: string }> };
export type RemoteAppGetUsersFragment = { __typename?: 'users', id: any, createdAt: any, displayName: string, avatarUrl: string, email?: any | null, emailVerified: boolean, phoneNumber?: string | null, phoneNumberVerified: boolean, disabled: boolean, defaultRole: string, lastSeen?: any | null, locale: string, roles: Array<{ __typename?: 'authUserRoles', id: any, role: string }>, userProviders: Array<{ __typename?: 'authUserProviders', id: any, providerId: string }> };
export type RemoteAppGetUsersQueryVariables = Exact<{
where: Users_Bool_Exp;
@@ -17442,7 +17433,7 @@ export type RemoteAppGetUsersQueryVariables = Exact<{
}>;
export type RemoteAppGetUsersQuery = { __typename?: 'query_root', users: Array<{ __typename?: 'users', id: any, createdAt: any, displayName: string, avatarUrl: string, email?: any | null, phoneNumber?: string | null, disabled: boolean, defaultRole: string, roles: Array<{ __typename?: 'authUserRoles', role: string }> }>, usersAggregate: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null } };
export type RemoteAppGetUsersQuery = { __typename?: 'query_root', users: Array<{ __typename?: 'users', id: any, createdAt: any, displayName: string, avatarUrl: string, email?: any | null, emailVerified: boolean, phoneNumber?: string | null, phoneNumberVerified: boolean, disabled: boolean, defaultRole: string, lastSeen?: any | null, locale: string, roles: Array<{ __typename?: 'authUserRoles', id: any, role: string }>, userProviders: Array<{ __typename?: 'authUserProviders', id: any, providerId: string }> }>, filteredUsersAggreggate: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null }, usersAggregate: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null } };
export type RemoteAppGetUsersCustomQueryVariables = Exact<{
where: Users_Bool_Exp;
@@ -17459,7 +17450,7 @@ export type RemoteAppGetUsersWholeQueryVariables = Exact<{
}>;
export type RemoteAppGetUsersWholeQuery = { __typename?: 'query_root', users: Array<{ __typename?: 'users', id: any, createdAt: any, displayName: string, avatarUrl: string, email?: any | null, phoneNumber?: string | null, disabled: boolean, defaultRole: string, roles: Array<{ __typename?: 'authUserRoles', role: string }> }>, usersAggregate: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null } };
export type RemoteAppGetUsersWholeQuery = { __typename?: 'query_root', users: Array<{ __typename?: 'users', id: any, createdAt: any, displayName: string, avatarUrl: string, email?: any | null, emailVerified: boolean, phoneNumber?: string | null, phoneNumberVerified: boolean, disabled: boolean, defaultRole: string, lastSeen?: any | null, locale: string, roles: Array<{ __typename?: 'authUserRoles', id: any, role: string }>, userProviders: Array<{ __typename?: 'authUserProviders', id: any, providerId: string }> }>, usersAggregate: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null } };
export type TotalUsersQueryVariables = Exact<{ [key: string]: never; }>;
@@ -17802,65 +17793,6 @@ export const GetAppByWorkspaceAndNameFragmentDoc = gql`
workspaceId
}
`;
export const GetAppLoginDataFragmentDoc = gql`
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
authTwitterEnabled
authTwitterConsumerKey
authTwitterConsumerSecret
authAppleEnabled
authAppleTeamId
authAppleKeyId
authAppleClientId
authApplePrivateKey
authAppleScope
authWindowsLiveEnabled
authWindowsLiveClientId
authWindowsLiveClientSecret
authSpotifyEnabled
authSpotifyClientId
authSpotifyClientSecret
authWorkOsEnabled
authWorkOsClientId
authWorkOsClientSecret
authWorkOsDefaultDomain
authWorkOsDefaultOrganization
authWorkOsDefaultConnection
authDiscordEnabled
authDiscordClientId
authDiscordClientSecret
authTwitchEnabled
authTwitchClientId
authTwitchClientSecret
}
`;
export const GetAppRolesFragmentDoc = gql`
fragment GetAppRoles on apps {
id
@@ -17988,12 +17920,22 @@ export const RemoteAppGetUsersFragmentDoc = gql`
displayName
avatarUrl
email
emailVerified
phoneNumber
phoneNumberVerified
disabled
defaultRole
lastSeen
locale
roles {
id
role
}
userProviders {
id
providerId
}
disabled
}
`;
export const GetWorkspaceMembersWorkspaceMemberFragmentDoc = gql`
@@ -18474,44 +18416,6 @@ export type GetAppInjectedVariablesQueryResult = Apollo.QueryResult<GetAppInject
export function refetchGetAppInjectedVariablesQuery(variables: GetAppInjectedVariablesQueryVariables) {
return { query: GetAppInjectedVariablesDocument, variables: variables }
}
export const GetAppLoginDataDocument = gql`
query getAppLoginData($id: uuid!) {
app(id: $id) {
...GetAppLoginData
}
}
${GetAppLoginDataFragmentDoc}`;
/**
* __useGetAppLoginDataQuery__
*
* To run a query within a React component, call `useGetAppLoginDataQuery` and pass it any options that fit your needs.
* When your component renders, `useGetAppLoginDataQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetAppLoginDataQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useGetAppLoginDataQuery(baseOptions: Apollo.QueryHookOptions<GetAppLoginDataQuery, GetAppLoginDataQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetAppLoginDataQuery, GetAppLoginDataQueryVariables>(GetAppLoginDataDocument, options);
}
export function useGetAppLoginDataLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetAppLoginDataQuery, GetAppLoginDataQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetAppLoginDataQuery, GetAppLoginDataQueryVariables>(GetAppLoginDataDocument, options);
}
export type GetAppLoginDataQueryHookResult = ReturnType<typeof useGetAppLoginDataQuery>;
export type GetAppLoginDataLazyQueryHookResult = ReturnType<typeof useGetAppLoginDataLazyQuery>;
export type GetAppLoginDataQueryResult = Apollo.QueryResult<GetAppLoginDataQuery, GetAppLoginDataQueryVariables>;
export function refetchGetAppLoginDataQuery(variables: GetAppLoginDataQueryVariables) {
return { query: GetAppLoginDataDocument, variables: variables }
}
export const GetAppRolesAndPermissionsDocument = gql`
query getAppRolesAndPermissions($id: uuid!) {
app(id: $id) {
@@ -20381,10 +20285,20 @@ export function refetchGetRemoteAppByIdQuery(variables: GetRemoteAppByIdQueryVar
}
export const RemoteAppGetUsersDocument = gql`
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
}
usersAggregate(where: $where) {
filteredUsersAggreggate: usersAggregate(where: $where) {
aggregate {
count
}
}
usersAggregate {
aggregate {
count
}

View File

@@ -5,7 +5,7 @@ export function copy(toCopy: string, name: string) {
triggerToast('Error while copying');
});
triggerToast(`${name} copied to clipboard.`);
triggerToast(`${name} copied to clipboard`);
}
export default copy;

View File

@@ -1,5 +1,37 @@
import convertToHasuraPermissions from './convertToHasuraPermissions';
test('should return null if there are no rules or groups', () => {
expect(convertToHasuraPermissions()).toBeNull();
expect(convertToHasuraPermissions(null)).toBeNull();
expect(convertToHasuraPermissions(undefined)).toBeNull();
});
test('should return an empty object if the input is an empty object', () => {
expect(convertToHasuraPermissions({})).toMatchObject({});
});
test(`should return an empty object if the input doesn't have any rules or groups`, () => {
expect(
convertToHasuraPermissions({ operator: '_and', rules: [], groups: [] }),
).toMatchObject({});
});
test(`should remove a nesting level if it only contains a single group and no rules`, () => {
expect(
convertToHasuraPermissions({
operator: '_and',
rules: [],
groups: [
{
operator: '_and',
rules: [{ column: 'id', operator: '_eq', value: 'X-Hasura-User-Id' }],
groups: [],
},
],
}),
).toMatchObject({ id: { _eq: 'X-Hasura-User-Id' } });
});
test('should not return any operators if there is only one rule in a group', () => {
expect(
convertToHasuraPermissions({
@@ -179,3 +211,87 @@ test('should return nested groups', () => {
],
});
});
test('should merge unsupported rules into the object', () => {
expect(
convertToHasuraPermissions({
operator: '_or',
rules: [
{
column: 'title',
operator: '_eq',
value: 'test',
},
{
column: 'title',
operator: '_eq',
value: 'test2',
},
],
groups: [
{
operator: '_and',
rules: [],
groups: [],
unsupported: [
{
_exists: {
_table: { schema: 'public', name: 'authors' },
_where: { name: { _eq: 'test3' } },
},
},
],
},
],
unsupported: [
{
_exists: {
_table: { schema: 'public', name: 'authors' },
_where: { name: { _eq: 'test3' } },
},
},
],
}),
).toMatchObject({
_or: [
{ title: { _eq: 'test' } },
{ title: { _eq: 'test2' } },
{
_and: [
{
_exists: {
_table: { schema: 'public', name: 'authors' },
_where: { name: { _eq: 'test3' } },
},
},
],
},
{
_exists: {
_table: { schema: 'public', name: 'authors' },
_where: { name: { _eq: 'test3' } },
},
},
],
});
});
test('should convert value to boolean if the operator is _is_null', () => {
expect(
convertToHasuraPermissions({
operator: '_and',
rules: [
{
column: 'title',
operator: '_is_null',
value: 'true',
},
],
groups: [],
}),
).toMatchObject({
title: {
_is_null: true,
},
});
});

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