Compare commits

...

108 Commits

Author SHA1 Message Date
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
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
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
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ó
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ó
378a6684b0 Merge pull request #1451 from nhost/changeset-release/main
chore: update versions
2023-01-02 11:33:20 +01:00
github-actions[bot]
1999ae09e6 chore: update versions 2023-01-02 09:47:28 +00:00
Szilárd Dóró
0fe48a0833 Merge pull request #1425 from nhost/fix/refresh-after-provisioning
fix(dashboard): provisioning status polling
2023-01-02 10:46:12 +01:00
Szilárd Dóró
52ccfdec89 Merge branch 'main' into fix/refresh-after-provisioning 2023-01-02 10:05:46 +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
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
Nuno Pato
9a1aa7bb2e changeset 2022-12-22 20:08:28 -01:00
Nuno Pato
98345f2e78 dashboard: add functions to the logs dashboard 2022-12-22 20:05:39 -01:00
Guido Curcio
f29abe6238 add changeset 2022-12-22 16:51:19 -03:00
Szilárd Dóró
8956d47bce fix(dashboard): start polling manually
reference issue: https://github.com/apollographql/apollo-client/issues/9819
2022-12-22 18:22:00 +01:00
Szilárd Dóró
dd0738d5f7 chore(dashboard): add changeset 2022-12-22 17:57:36 +01:00
Szilárd Dóró
11d77d6011 fix(dashboard): provisioning status polling 2022-12-22 17:53:07 +01:00
Guido Curcio
229c47cf16 set up redirect from /users/<userId> -> users?userId=<userId> 2022-12-22 10:04:56 -03: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
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
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
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
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
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
67 changed files with 3556 additions and 795 deletions

View File

@@ -1,5 +1,24 @@
# @nhost/dashboard
## 0.8.0
### Minor Changes
- 9a1aa7bb: add functions to the log dashboard
- f29abe62: feat(dashboard): Users Management v2
### Patch Changes
- 7766624b: feat(dashboard): add JWT secret editor modal
- @nhost/react-apollo@4.12.1
- @nhost/nextjs@1.12.1
## 0.7.13
### Patch Changes
- dd0738d5: fix(dashboard): provisioning status polling
## 0.7.12
### 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.12",
"version": "0.8.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -17,7 +17,7 @@
"build-storybook": "build-storybook"
},
"dependencies": {
"@apollo/client": "^3.6.2",
"@apollo/client": "^3.7.3",
"@codemirror/language": "^6.3.0",
"@emotion/cache": "^11.10.5",
"@emotion/react": "^11.10.5",

View File

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

View File

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

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

@@ -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

@@ -15,10 +15,14 @@ export type DialogType =
| '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> {
/**

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';
@@ -353,6 +357,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<EditRoleForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_USER' && (
<CreateUserForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_PERMISSION_VARIABLE' && (
<CreatePermissionVariableForm {...sharedDialogProps} />
)}
@@ -368,6 +376,17 @@ 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>
@@ -413,6 +432,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
schema={drawerPayload?.schema}
/>
)}
{activeDrawerType === 'EDIT_USER' && (
<EditUserForm {...sharedDrawerProps} {...drawerPayload} />
)}
</RetryableErrorBoundary>
</Drawer>

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

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

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,
},
});
}
@@ -207,14 +215,25 @@ export default function SystemEnvironmentVariableSettings() {
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 justify-start">
<ListItem.Text>NHOST_JWT_SECRET</ListItem.Text>
<Button
variant="borderless"
onClick={showJwtSecret}
size="small"
className="justify-self-start"
>
Show JWT Secret
</Button>
<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

@@ -0,0 +1,169 @@
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 signUpUrl = generateAppServiceUrl(
currentApplication?.subdomain,
currentApplication?.region.awsName,
'auth',
);
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

@@ -8,9 +8,11 @@ import { useEffect } from 'react';
export default function SidebarWorkspaces() {
const user = nhost.auth.getUser();
const { data, loading, stopPolling } = useGetWorkspacesQuery({
pollInterval: 1000,
});
const { data, loading, startPolling, stopPolling } = useGetWorkspacesQuery();
useEffect(() => {
startPolling(1000);
}, [startPolling]);
// keep polling for workspaces until there is a workspace available.
// We do this because when a user signs up a workspace is created automatically

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

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

View File

@@ -21,11 +21,12 @@ export function useCheckProvisioning() {
const [currentApplicationState, setCurrentApplicationState] =
useState<ApplicationStateMetadata>({ state: ApplicationStatus.Empty });
const isPlatform = useIsPlatform();
const { data, stopPolling, client } = useGetApplicationStateQuery({
variables: { appId: currentApplication.id },
pollInterval: 2000,
skip: !isPlatform,
});
const { data, startPolling, stopPolling, client } =
useGetApplicationStateQuery({
variables: { appId: currentApplication?.id },
skip: !isPlatform || !currentApplication?.id,
});
async function updateOwnCache() {
await client.refetchQueries({
@@ -35,6 +36,10 @@ export function useCheckProvisioning() {
const memoizedUpdateCache = useCallback(updateOwnCache, [client]);
useEffect(() => {
startPolling(2000);
}, [startPolling]);
useEffect(() => {
if (!data) {
return;
@@ -81,7 +86,13 @@ export function useCheckProvisioning() {
stopPolling();
memoizedUpdateCache();
}
}, [data, stopPolling, memoizedUpdateCache, currentApplication.id]);
}, [
data,
stopPolling,
memoizedUpdateCache,
currentApplication.id,
currentApplicationState.state,
]);
return currentApplicationState;
}

View File

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

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 Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import PlusIcon from '@/ui/v2/icons/PlusIcon';
import UserIcon from '@/ui/v2/icons/UserIcon';
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 handleViewUser() {
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={handleViewUser}
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={handleViewUser}
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={handleViewUser}
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,6 +49,10 @@ export const theme = createTheme({
main: '#f13154',
dark: '#c91737',
},
success: {
main: 'rgba(170, 240, 204, 0.3)',
contrastText: '#3BB174',
},
action: {
hover: '#f3f4f6',
active: '#f3f4f6',

View File

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

File diff suppressed because it is too large Load Diff

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

@@ -25,6 +25,10 @@ export const availableServices: {
label: 'Hasura',
value: AvailableLogsServices.HASURA,
},
{
label: 'Functions',
value: AvailableLogsServices.FUNCTIONS,
},
];
export const logsCustomIntervals: LogsCustomInterval[] = [

View File

@@ -1,22 +1,30 @@
import { useWorkspaceContext } from '@/context/workspace-context';
import { useGetUserAllWorkspacesQuery } from '@/generated/graphql';
import { useWithin } from '@/hooks/useWithin';
import { useEffect } from 'react';
export const useUserFirstWorkspace = () => {
const { workspaceContext } = useWorkspaceContext();
const { within } = useWithin();
const fetch = !!workspaceContext.slug || !within;
const { loading, error, data, stopPolling, client } =
const { loading, error, data, startPolling, stopPolling, client } =
useGetUserAllWorkspacesQuery({
pollInterval: 1000,
skip: fetch,
fetchPolicy: 'cache-first',
});
if (!!workspaceContext.slug || !within) {
useEffect(() => {
startPolling(1000);
}, [startPolling]);
useEffect(() => {
if (!workspaceContext.slug && within) {
return;
}
stopPolling();
}
}, [workspaceContext.slug, within, stopPolling]);
return {
loading,

View File

@@ -1,5 +1,18 @@
# @nhost-examples/react-apollo
## 0.1.5
### Patch Changes
- 85683547: Allow `useFileUpload` to be reused
Once a file were uploaded with `useFileUpload`, it was not possible to reuse it as the returned file id were kept in memory and sent again to hasura-storage, leading to a conflict error.
File upload now makes sure to clear the metadata information from the first file before uploading the second file.
- Updated dependencies [1be6d324]
- Updated dependencies [2e8f73df]
- Updated dependencies [85683547]
- @nhost/react@1.12.1
- @nhost/react-apollo@4.12.1
## 0.1.4
### Patch Changes

View File

@@ -17,6 +17,33 @@ context('File uploads', () => {
.contains('Successfully uploaded')
})
it('should upload two files using the same single file uploader', () => {
cy.signUpAndConfirmEmail()
cy.findByRole('button', { name: /Storage/i }).click()
cy.findByRole('button', { name: /Drag a file here or click to select/i })
.children('input[type=file]')
.selectFile(
{
contents: Cypress.Buffer.from('file contents'),
fileName: 'file.txt',
mimeType: 'text/plain',
lastModified: Date.now()
},
{ force: true }
)
.selectFile(
{
contents: Cypress.Buffer.from('file contents'),
fileName: 'file.txt',
mimeType: 'text/plain',
lastModified: Date.now()
},
{ force: true }
)
.parent()
.contains('Successfully uploaded')
})
it('should upload multiple files', () => {
const files: Required<Cypress.FileReferenceObject>[] = [
{

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost-examples/react-apollo",
"version": "0.1.4",
"version": "0.1.5",
"private": true,
"dependencies": {
"@apollo/client": "^3.6.9",

View File

@@ -1,5 +1,11 @@
# @nhost/apollo
## 4.12.1
### Patch Changes
- @nhost/nhost-js@1.12.1
## 0.6.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/apollo",
"version": "0.6.0",
"version": "4.12.1",
"description": "Nhost Apollo Client library",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,15 @@
# @nhost/react-apollo
## 4.12.1
### Patch Changes
- Updated dependencies [1be6d324]
- Updated dependencies [2e8f73df]
- Updated dependencies [85683547]
- @nhost/react@1.12.1
- @nhost/apollo@4.12.1
## 4.12.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/react-apollo",
"version": "4.12.0",
"version": "4.12.1",
"description": "Nhost React Apollo client",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,14 @@
# @nhost/react-urql
## 0.1.1
### Patch Changes
- Updated dependencies [1be6d324]
- Updated dependencies [2e8f73df]
- Updated dependencies [85683547]
- @nhost/react@1.12.1
## 0.1.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/react-urql",
"version": "0.1.0",
"version": "0.1.1",
"description": "Nhost React URQL client",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,13 @@
# @nhost/hasura-storage-js
## 1.12.1
### Patch Changes
- 85683547: Allow `useFileUpload` to be reused
Once a file were uploaded with `useFileUpload`, it was not possible to reuse it as the returned file id were kept in memory and sent again to hasura-storage, leading to a conflict error.
File upload now makes sure to clear the metadata information from the first file before uploading the second file.
## 1.12.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/hasura-storage-js",
"version": "1.12.0",
"version": "1.12.1",
"description": "Hasura-storage client",
"license": "MIT",
"keywords": [

View File

@@ -28,7 +28,14 @@ export type FileUploadEvents =
| { type: 'CANCEL' }
| { type: 'DESTROY' }
export const INITIAL_FILE_CONTEXT: FileUploadContext = { progress: null, loaded: 0, error: null }
export const INITIAL_FILE_CONTEXT: FileUploadContext = {
progress: null,
loaded: 0,
error: null,
bucketId: undefined,
file: undefined,
id: undefined
}
export type FileUploadMachine = ReturnType<typeof createFileUploadMachine>
export const createFileUploadMachine = () =>
@@ -62,8 +69,20 @@ export const createFileUploadMachine = () =>
},
invoke: { src: 'uploadFile' }
},
uploaded: { entry: ['setFileMetadata', 'sendDone'] },
error: { entry: ['setError', 'sendError'] },
uploaded: {
entry: ['setFileMetadata', 'sendDone'],
on: {
ADD: { actions: 'addFile', target: 'idle' },
UPLOAD: { actions: 'resetContext', target: 'uploading' }
}
},
error: {
entry: ['setError', 'sendError'],
on: {
ADD: { actions: 'addFile', target: 'idle' },
UPLOAD: { actions: 'resetContext', target: 'uploading' }
}
},
stopped: { type: 'final' }
}
},
@@ -88,6 +107,7 @@ export const createFileUploadMachine = () =>
sendDestroy: () => {},
sendDone: () => {},
resetProgress: assign({ progress: (_) => null, loaded: (_) => 0 }),
resetContext: assign((_) => INITIAL_FILE_CONTEXT),
addFile: assign({
file: (_, { file }) => file,
bucketId: (_, { bucketId }) => bucketId,

View File

@@ -10,13 +10,14 @@ export interface Typegen0 {
}
missingImplementations: {
actions: never
services: never
guards: never
delays: never
guards: never
services: never
}
eventsCausingActions: {
addFile: 'ADD'
incrementProgress: 'UPLOAD_PROGRESS'
resetContext: 'UPLOAD'
resetProgress: 'UPLOAD'
sendDestroy: 'DESTROY'
sendDone: 'UPLOAD_DONE'
@@ -25,13 +26,13 @@ export interface Typegen0 {
setError: 'UPLOAD_ERROR'
setFileMetadata: 'UPLOAD_DONE'
}
eventsCausingServices: {
uploadFile: 'UPLOAD'
}
eventsCausingDelays: {}
eventsCausingGuards: {
hasFile: 'UPLOAD'
}
eventsCausingDelays: {}
eventsCausingServices: {
uploadFile: 'UPLOAD'
}
matchesStates: 'error' | 'idle' | 'stopped' | 'uploaded' | 'uploading'
tags: never
}

View File

@@ -1,5 +1,14 @@
# @nhost/nextjs
## 1.12.1
### Patch Changes
- Updated dependencies [1be6d324]
- Updated dependencies [2e8f73df]
- Updated dependencies [85683547]
- @nhost/react@1.12.1
## 1.12.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/nextjs",
"version": "1.12.0",
"version": "1.12.1",
"description": "Nhost NextJS library",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,12 @@
# @nhost/nhost-js
## 1.12.1
### Patch Changes
- Updated dependencies [85683547]
- @nhost/hasura-storage-js@1.12.1
## 1.12.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/nhost-js",
"version": "1.12.0",
"version": "1.12.1",
"description": "Nhost JavaScript SDK",
"license": "MIT",
"keywords": [

View File

@@ -1,5 +1,16 @@
# @nhost/react
## 1.12.1
### Patch Changes
- 1be6d324: Only export what is required by the user or `@nhost/nextjs`
- 2e8f73df: Improve the error message when the application is not wrapped in `<NhostProvider></NhostProvider>`
- 85683547: Allow `useFileUpload` to be reused
Once a file were uploaded with `useFileUpload`, it was not possible to reuse it as the returned file id were kept in memory and sent again to hasura-storage, leading to a conflict error.
File upload now makes sure to clear the metadata information from the first file before uploading the second file.
- @nhost/nhost-js@1.12.1
## 1.12.0
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/react",
"version": "1.12.0",
"version": "1.12.1",
"description": "Nhost React library",
"license": "MIT",
"keywords": [

View File

@@ -1,12 +1,18 @@
import {
AuthMachine,
BackendUrl,
NhostAuthConstructorParams,
NhostClient as VanillaNhostClient,
NhostClient as _VanillaNhostClient,
NhostSession,
NHOST_REFRESH_TOKEN_KEY,
Subdomain
} from '@nhost/nhost-js'
export * from '@nhost/nhost-js'
export { VanillaNhostClient }
export type { NhostSession, NhostAuthConstructorParams, AuthMachine, Subdomain, BackendUrl }
export { NHOST_REFRESH_TOKEN_KEY }
/** @internal */
export const VanillaNhostClient = _VanillaNhostClient
export interface NhostReactClientConstructorParams
extends Partial<BackendUrl>,

View File

@@ -7,6 +7,7 @@ import { NhostReactContext } from './provider'
export const useAuthInterpreter = (): InterpreterFrom<AuthMachine> => {
const nhost = useContext(NhostReactContext)
const interpreter = nhost.auth?.client.interpreter
if (!interpreter) throw Error('No interpreter')
if (!interpreter)
throw Error('Could not find the Nhost auth client. Did you wrap your app in <NhostProvider />?')
return interpreter
}

View File

@@ -75,10 +75,7 @@ export const useFileUploadItem = (
url: nhost.storage.url,
accessToken: nhost.auth.getAccessToken(),
adminSecret: nhost.adminSecret,
file: params.file,
bucketId: params.bucketId || bucketId,
id,
name
...params
},
ref
)

View File

@@ -1,5 +1,14 @@
# @nhost/vue
## 1.12.1
### Patch Changes
- 85683547: Allow `useFileUpload` to be reused
Once a file were uploaded with `useFileUpload`, it was not possible to reuse it as the returned file id were kept in memory and sent again to hasura-storage, leading to a conflict error.
File upload now makes sure to clear the metadata information from the first file before uploading the second file.
- @nhost/nhost-js@1.12.1
## 1.12.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/vue",
"version": "1.12.0",
"version": "1.12.1",
"description": "Nhost Vue library",
"license": "MIT",
"keywords": [

View File

@@ -75,10 +75,7 @@ export const useFileUploadItem = (
url: nhost.storage.url,
accessToken: nhost.auth.getAccessToken(),
adminSecret: nhost.adminSecret,
file: params.file,
bucketId: params.bucketId || bucketId,
id,
name
...params
},
ref
)

2
pnpm-lock.yaml generated
View File

@@ -77,7 +77,7 @@ importers:
dashboard:
specifiers:
'@apollo/client': ^3.6.2
'@apollo/client': ^3.7.3
'@babel/core': ^7.20.2
'@codemirror/language': ^6.3.0
'@emotion/cache': ^11.10.5

View File

@@ -16,6 +16,9 @@
"outputs": ["dist/**"],
"env": ["VITE_NHOST_SUBDOMAIN", "VITE_NHOST_REGION"]
},
"@nhost/docs#build": {
"cache": false
},
"@nhost/dashboard#build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"],