Compare commits
108 Commits
@nhost/rea
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b649f178e0 | ||
|
|
1f5e1e3d42 | ||
|
|
5727b0b0fe | ||
|
|
2f38ed56f5 | ||
|
|
16502ea175 | ||
|
|
beee0407df | ||
|
|
3990b1ffbb | ||
|
|
1fb03708e3 | ||
|
|
d42719ee65 | ||
|
|
72ff489ea8 | ||
|
|
c9bf2dde0e | ||
|
|
613533d377 | ||
|
|
8568354718 | ||
|
|
1be6d32455 | ||
|
|
812a6e5005 | ||
|
|
34cc230b61 | ||
|
|
898a7c835f | ||
|
|
7766624bc5 | ||
|
|
2e8f73df38 | ||
|
|
6a419e060e | ||
|
|
43480ca735 | ||
|
|
efc42d77fd | ||
|
|
31e2523eca | ||
|
|
fbf4f40ab7 | ||
|
|
cbe203e720 | ||
|
|
378a6684b0 | ||
|
|
1999ae09e6 | ||
|
|
0fe48a0833 | ||
|
|
52ccfdec89 | ||
|
|
70cfeb1fcf | ||
|
|
3116562b58 | ||
|
|
693e40d385 | ||
|
|
9a1aa7bb2e | ||
|
|
98345f2e78 | ||
|
|
f29abe6238 | ||
|
|
8956d47bce | ||
|
|
dd0738d5f7 | ||
|
|
11d77d6011 | ||
|
|
229c47cf16 | ||
|
|
e5e705350d | ||
|
|
4f81b0695d | ||
|
|
800db1b300 | ||
|
|
a40baa8c63 | ||
|
|
5cc06609c2 | ||
|
|
efa68aab83 | ||
|
|
3a696d366a | ||
|
|
e3e21b6164 | ||
|
|
9259663c76 | ||
|
|
26dd7faf05 | ||
|
|
1b5cb93761 | ||
|
|
b6df9e2e8c | ||
|
|
48f15eb849 | ||
|
|
141642d40d | ||
|
|
def4a3a2ea | ||
|
|
b876a4ada1 | ||
|
|
7e064355ba | ||
|
|
0f6ece6b8c | ||
|
|
793e7392da | ||
|
|
a9bbd1303e | ||
|
|
b0ed2b6f14 | ||
|
|
3e951eab4f | ||
|
|
cd7a198715 | ||
|
|
7c4d05a25e | ||
|
|
357e0933ff | ||
|
|
7d8f82b99d | ||
|
|
e10480b761 | ||
|
|
1343abbe50 | ||
|
|
fa37546139 | ||
|
|
112526a984 | ||
|
|
0c74806245 | ||
|
|
5df84d7f50 | ||
|
|
d58de8fcf8 | ||
|
|
85674c4d90 | ||
|
|
5a1d3b9bfc | ||
|
|
942570ed29 | ||
|
|
3058eee48f | ||
|
|
bacb1b9720 | ||
|
|
e119e4fc18 | ||
|
|
b1fe2be963 | ||
|
|
9b6e8ab3bc | ||
|
|
d2c4b7cad1 | ||
|
|
59d737696a | ||
|
|
35cd76e562 | ||
|
|
266bbe837d | ||
|
|
caf785a938 | ||
|
|
96f9c1a55d | ||
|
|
731460b20d | ||
|
|
1537d46b1d | ||
|
|
632def158d | ||
|
|
39271a67e2 | ||
|
|
9e25c4f386 | ||
|
|
dd58a4ac7f | ||
|
|
b9c3567baa | ||
|
|
108937789a | ||
|
|
e651745a7e | ||
|
|
6091b4a8e8 | ||
|
|
82ddcbd180 | ||
|
|
8aa7aafa3b | ||
|
|
183cb4b26a | ||
|
|
3a7377c6e2 | ||
|
|
1529f58c33 | ||
|
|
95af5421d1 | ||
|
|
feb39404db | ||
|
|
15b3100c63 | ||
|
|
f7ef7d106d | ||
|
|
72c31622cd | ||
|
|
6959461e3f | ||
|
|
103472ac77 |
@@ -1,5 +1,24 @@
|
|||||||
# @nhost/dashboard
|
# @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
|
## 0.7.12
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ module.exports = withBundleAnalyzer({
|
|||||||
destination: '/:workspaceSlug/:appSlug/settings/environment-variables',
|
destination: '/:workspaceSlug/:appSlug/settings/environment-variables',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: '/:workspaceSlug/:appSlug/users/:userId',
|
||||||
|
destination: '/:workspaceSlug/:appSlug/users?userId=:userId',
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/dashboard",
|
"name": "@nhost/dashboard",
|
||||||
"version": "0.7.12",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"build-storybook": "build-storybook"
|
"build-storybook": "build-storybook"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.6.2",
|
"@apollo/client": "^3.7.3",
|
||||||
"@codemirror/language": "^6.3.0",
|
"@codemirror/language": "^6.3.0",
|
||||||
"@emotion/cache": "^11.10.5",
|
"@emotion/cache": "^11.10.5",
|
||||||
"@emotion/react": "^11.10.5",
|
"@emotion/react": "^11.10.5",
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ export default function ConnectGithubModal({ close }: ConnectGithubModalProps) {
|
|||||||
useState<ConnectGithubModalState>('CONNECTING');
|
useState<ConnectGithubModalState>('CONNECTING');
|
||||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null);
|
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data, loading, error } = useGetGithubRepositoriesQuery({
|
const { data, loading, error, startPolling } =
|
||||||
pollInterval: 2000,
|
useGetGithubRepositoriesQuery();
|
||||||
});
|
|
||||||
|
useEffect(() => {
|
||||||
|
startPolling(2000);
|
||||||
|
}, [startPolling]);
|
||||||
|
|
||||||
const handleSelectAnotherRepository = () => {
|
const handleSelectAnotherRepository = () => {
|
||||||
setSelectedRepoId(null);
|
setSelectedRepoId(null);
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ export function FunctionsLogsTerminalPage({ functionName }: any) {
|
|||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
const [normalizedFunctionData, setNormalizedFunctionData] = useState(null);
|
const [normalizedFunctionData, setNormalizedFunctionData] = useState(null);
|
||||||
|
|
||||||
const { data } = useGetFunctionLogQuery({
|
const { data, startPolling } = useGetFunctionLogQuery({
|
||||||
variables: {
|
variables: {
|
||||||
subdomain: currentApplication.subdomain,
|
subdomain: currentApplication.subdomain,
|
||||||
functionPaths: [functionName?.split('/').slice(1, 3).join('/')],
|
functionPaths: [functionName?.split('/').slice(1, 3).join('/')],
|
||||||
},
|
},
|
||||||
pollInterval: 3000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startPolling(3000);
|
||||||
|
}, [startPolling]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || data.getFunctionLogs.length === 0) {
|
if (!data || data.getFunctionLogs.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLCl
|
|||||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
import Option from '@/ui/v2/Option';
|
import Option from '@/ui/v2/Option';
|
||||||
import Select from '@/ui/v2/Select';
|
import Select from '@/ui/v2/Select';
|
||||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
import type { RemoteAppGetUsersCustomQuery } from '@/utils/__generated__/graphql';
|
||||||
import { useRemoteAppGetUsersCustomQuery } from '@/utils/__generated__/graphql';
|
import { useRemoteAppGetUsersCustomQuery } from '@/utils/__generated__/graphql';
|
||||||
import { DEFAULT_ROLES } from './utils';
|
import { DEFAULT_ROLES } from './utils';
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ export function UserSelect({ onUserChange, ...props }: UserSelectProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user: RemoteAppGetUsersQuery['users'][number] = users.find(
|
const user: RemoteAppGetUsersCustomQuery['users'][0] = users.find(
|
||||||
({ id }) => id === userId,
|
({ id }) => id === userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -35,15 +35,15 @@ function InsertPlaceholderTableRow({
|
|||||||
}: InsertPlaceholderTableRowProps) {
|
}: InsertPlaceholderTableRowProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-12 border-r-1 border-b-1 border-gray-200 bg-white"
|
className="h-12 bg-white border-gray-200 border-r-1 border-b-1"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
onClick={onInsertRow}
|
onClick={onInsertRow}
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
className="h-full w-full justify-start rounded-none px-2 py-3 text-xs font-normal hover:shadow-none focus:shadow-none focus:outline-none"
|
className="justify-start w-full h-full px-2 py-3 text-xs font-normal rounded-none hover:shadow-none focus:shadow-none focus:outline-none"
|
||||||
startIcon={<PlusIcon className="h-4 w-4 text-greyscaleGrey" />}
|
startIcon={<PlusIcon className="w-4 h-4 text-greyscaleGrey" />}
|
||||||
>
|
>
|
||||||
Insert New Row
|
Insert New Row
|
||||||
</Button>
|
</Button>
|
||||||
@@ -181,7 +181,7 @@ export default function DataGridBody<T extends object>({
|
|||||||
return (
|
return (
|
||||||
<div {...getTableBodyProps()} ref={bodyRef} {...props}>
|
<div {...getTableBodyProps()} ref={bodyRef} {...props}>
|
||||||
{rows.length === 0 && !loading && (
|
{rows.length === 0 && !loading && (
|
||||||
<div className="flex flex-nowrap pr-5">
|
<div className="flex pr-5 flex-nowrap">
|
||||||
{onInsertRow ? (
|
{onInsertRow ? (
|
||||||
<InsertPlaceholderTableRow
|
<InsertPlaceholderTableRow
|
||||||
style={{
|
style={{
|
||||||
@@ -279,7 +279,7 @@ export default function DataGridBody<T extends object>({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{allowInsertColumn && (
|
{allowInsertColumn && (
|
||||||
<div className="h-12 w-25 border-r-1 border-b-1 border-gray-200 bg-white" />
|
<div className="h-12 bg-white border-gray-200 w-25 border-r-1 border-b-1" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ export type DialogType =
|
|||||||
| 'EDIT_FOREIGN_KEY'
|
| 'EDIT_FOREIGN_KEY'
|
||||||
| 'CREATE_ROLE'
|
| 'CREATE_ROLE'
|
||||||
| 'EDIT_ROLE'
|
| 'EDIT_ROLE'
|
||||||
|
| 'CREATE_USER'
|
||||||
| 'CREATE_PERMISSION_VARIABLE'
|
| 'CREATE_PERMISSION_VARIABLE'
|
||||||
| 'EDIT_PERMISSION_VARIABLE'
|
| 'EDIT_PERMISSION_VARIABLE'
|
||||||
| 'CREATE_ENVIRONMENT_VARIABLE'
|
| 'CREATE_ENVIRONMENT_VARIABLE'
|
||||||
| 'EDIT_ENVIRONMENT_VARIABLE';
|
| 'EDIT_ENVIRONMENT_VARIABLE'
|
||||||
|
| 'EDIT_USER'
|
||||||
|
| 'EDIT_USER_PASSWORD'
|
||||||
|
| 'EDIT_JWT_SECRET';
|
||||||
|
|
||||||
export interface DialogConfig<TPayload = unknown> {
|
export interface DialogConfig<TPayload = unknown> {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm'
|
|||||||
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
|
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
|
||||||
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
|
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
|
||||||
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
|
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
|
||||||
|
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
|
||||||
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
|
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
|
||||||
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
|
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
|
||||||
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
|
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
|
||||||
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
|
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
|
||||||
|
import CreateUserForm from '@/components/users/CreateUserForm';
|
||||||
|
import EditUserForm from '@/components/users/EditUserForm';
|
||||||
|
import EditUserPasswordForm from '@/components/users/EditUserPasswordForm';
|
||||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
import AlertDialog from '@/ui/v2/AlertDialog';
|
import AlertDialog from '@/ui/v2/AlertDialog';
|
||||||
import { BaseDialog } from '@/ui/v2/Dialog';
|
import { BaseDialog } from '@/ui/v2/Dialog';
|
||||||
@@ -353,6 +357,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
|||||||
<EditRoleForm {...sharedDialogProps} />
|
<EditRoleForm {...sharedDialogProps} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeDialogType === 'CREATE_USER' && (
|
||||||
|
<CreateUserForm {...sharedDialogProps} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeDialogType === 'CREATE_PERMISSION_VARIABLE' && (
|
{activeDialogType === 'CREATE_PERMISSION_VARIABLE' && (
|
||||||
<CreatePermissionVariableForm {...sharedDialogProps} />
|
<CreatePermissionVariableForm {...sharedDialogProps} />
|
||||||
)}
|
)}
|
||||||
@@ -368,6 +376,17 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
|||||||
{activeDialogType === 'EDIT_ENVIRONMENT_VARIABLE' && (
|
{activeDialogType === 'EDIT_ENVIRONMENT_VARIABLE' && (
|
||||||
<EditEnvironmentVariableForm {...sharedDialogProps} />
|
<EditEnvironmentVariableForm {...sharedDialogProps} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeDialogType === 'EDIT_USER_PASSWORD' && (
|
||||||
|
<EditUserPasswordForm
|
||||||
|
{...sharedDialogProps}
|
||||||
|
user={sharedDialogProps?.user}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeDialogType === 'EDIT_JWT_SECRET' && (
|
||||||
|
<EditJwtSecretForm {...sharedDialogProps} />
|
||||||
|
)}
|
||||||
</RetryableErrorBoundary>
|
</RetryableErrorBoundary>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
@@ -413,6 +432,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
|||||||
schema={drawerPayload?.schema}
|
schema={drawerPayload?.schema}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeDrawerType === 'EDIT_USER' && (
|
||||||
|
<EditUserForm {...sharedDrawerProps} {...drawerPayload} />
|
||||||
|
)}
|
||||||
</RetryableErrorBoundary>
|
</RetryableErrorBoundary>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
|||||||
138
dashboard/src/components/common/Pagination/Pagination.tsx
Normal file
138
dashboard/src/components/common/Pagination/Pagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
dashboard/src/components/common/Pagination/index.ts
Normal file
3
dashboard/src/components/common/Pagination/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './Pagination';
|
||||||
|
export { default } from './Pagination';
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import { updateOwnCache } from '@/utils/updateOwnCache';
|
|||||||
import { useApolloClient } from '@apollo/client';
|
import { useApolloClient } from '@apollo/client';
|
||||||
import { useUserData } from '@nhost/nextjs';
|
import { useUserData } from '@nhost/nextjs';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export function InviteAnnounce() {
|
export function InviteAnnounce() {
|
||||||
const user = useUserData();
|
const user = useUserData();
|
||||||
@@ -21,15 +22,18 @@ export function InviteAnnounce() {
|
|||||||
useSubmitState();
|
useSubmitState();
|
||||||
|
|
||||||
// @FIX: We probably don't want to poll every ten seconds for possible invites. (We can change later depending on how it works in production.) Maybe just on the workspace page?
|
// @FIX: We probably don't want to poll every ten seconds for possible invites. (We can change later depending on how it works in production.) Maybe just on the workspace page?
|
||||||
const { data, loading, error, refetch } =
|
const { data, loading, error, refetch, startPolling } =
|
||||||
useGetWorkspaceMemberInvitesToManageQuery({
|
useGetWorkspaceMemberInvitesToManageQuery({
|
||||||
variables: {
|
variables: {
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
},
|
},
|
||||||
pollInterval: 15000,
|
|
||||||
skip: !isPlatform,
|
skip: !isPlatform,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startPolling(15000);
|
||||||
|
}, [startPolling]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './EditJwtSecretForm';
|
||||||
|
export { default } from './EditJwtSecretForm';
|
||||||
@@ -10,7 +10,6 @@ import Divider from '@/ui/v2/Divider';
|
|||||||
import IconButton from '@/ui/v2/IconButton';
|
import IconButton from '@/ui/v2/IconButton';
|
||||||
import EyeIcon from '@/ui/v2/icons/EyeIcon';
|
import EyeIcon from '@/ui/v2/icons/EyeIcon';
|
||||||
import EyeOffIcon from '@/ui/v2/icons/EyeOffIcon';
|
import EyeOffIcon from '@/ui/v2/icons/EyeOffIcon';
|
||||||
import Input from '@/ui/v2/Input';
|
|
||||||
import List from '@/ui/v2/List';
|
import List from '@/ui/v2/List';
|
||||||
import { ListItem } from '@/ui/v2/ListItem';
|
import { ListItem } from '@/ui/v2/ListItem';
|
||||||
import Text from '@/ui/v2/Text';
|
import Text from '@/ui/v2/Text';
|
||||||
@@ -27,7 +26,7 @@ export default function SystemEnvironmentVariableSettings() {
|
|||||||
const [showAdminSecret, setShowAdminSecret] = useState(false);
|
const [showAdminSecret, setShowAdminSecret] = useState(false);
|
||||||
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||||
|
|
||||||
const { openAlertDialog } = useDialog();
|
const { openDialog } = useDialog();
|
||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
const { data, loading, error } = useGetAppInjectedVariablesQuery({
|
const { data, loading, error } = useGetAppInjectedVariablesQuery({
|
||||||
variables: { id: currentApplication?.id },
|
variables: { id: currentApplication?.id },
|
||||||
@@ -49,30 +48,39 @@ export default function SystemEnvironmentVariableSettings() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showJwtSecret() {
|
function showViewJwtSecretModal() {
|
||||||
openAlertDialog({
|
openDialog('EDIT_JWT_SECRET', {
|
||||||
title: 'Auth JWT Secret',
|
title: (
|
||||||
payload: (
|
<span className="grid grid-flow-row">
|
||||||
<div className="grid grid-flow-row gap-2">
|
<span>Auth JWT Secret</span>
|
||||||
<Text variant="subtitle2">
|
|
||||||
|
<Text variant="subtitle1" component="span">
|
||||||
This is the key used for generating JWTs. It's HMAC-SHA-based
|
This is the key used for generating JWTs. It's HMAC-SHA-based
|
||||||
and the same as configured in Hasura.
|
and the same as configured in Hasura.
|
||||||
</Text>
|
</Text>
|
||||||
|
</span>
|
||||||
<Input
|
|
||||||
defaultValue={data?.app?.hasuraGraphqlJwtSecret}
|
|
||||||
disabled
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
minRows={5}
|
|
||||||
hideEmptyHelperText
|
|
||||||
inputProps={{ className: 'font-mono' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
props: {
|
payload: {
|
||||||
hidePrimaryAction: true,
|
disabled: true,
|
||||||
secondaryButtonText: 'Close',
|
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEditJwtSecretModal() {
|
||||||
|
openDialog('EDIT_JWT_SECRET', {
|
||||||
|
title: (
|
||||||
|
<span className="grid grid-flow-row">
|
||||||
|
<span>Edit JWT Secret</span>
|
||||||
|
|
||||||
|
<Text variant="subtitle1" component="span">
|
||||||
|
You can add your custom JWT secret here. Hasura will use it to
|
||||||
|
validate the identity of your users.
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
payload: {
|
||||||
|
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -207,14 +215,25 @@ export default function SystemEnvironmentVariableSettings() {
|
|||||||
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 justify-start">
|
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 justify-start">
|
||||||
<ListItem.Text>NHOST_JWT_SECRET</ListItem.Text>
|
<ListItem.Text>NHOST_JWT_SECRET</ListItem.Text>
|
||||||
|
|
||||||
<Button
|
<div className="grid grid-flow-row md:grid-flow-col gap-1.5 justify-center text-center lg:text-left lg:justify-start items-center lg:col-span-2">
|
||||||
variant="borderless"
|
<Button
|
||||||
onClick={showJwtSecret}
|
variant="borderless"
|
||||||
size="small"
|
onClick={showViewJwtSecretModal}
|
||||||
className="justify-self-start"
|
size="small"
|
||||||
>
|
>
|
||||||
Show JWT Secret
|
Show JWT Secret
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Text component="span">or</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
onClick={showEditJwtSecretModal}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Edit JWT Secret
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</ListItem.Root>
|
</ListItem.Root>
|
||||||
</List>
|
</List>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useDialog } from '@/components/common/DialogProvider';
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
import Form from '@/components/common/Form';
|
import Form from '@/components/common/Form';
|
||||||
|
import { Alert } from '@/ui/Alert';
|
||||||
import Button from '@/ui/v2/Button';
|
import Button from '@/ui/v2/Button';
|
||||||
import Input from '@/ui/v2/Input';
|
import Input from '@/ui/v2/Input';
|
||||||
import Text from '@/ui/v2/Text';
|
import Text from '@/ui/v2/Text';
|
||||||
@@ -55,11 +56,20 @@ export default function BaseRoleForm({
|
|||||||
}, [isDirty, onDirtyStateChange]);
|
}, [isDirty, onDirtyStateChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-flow-row gap-2 px-6 pb-6">
|
<div className="grid grid-flow-row gap-3 px-6 pb-6">
|
||||||
<Text variant="subtitle1" component="span">
|
<Text variant="subtitle1" component="span">
|
||||||
Enter the name for the role below.
|
Enter the name for the role below.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{submitButtonText !== 'Create' && (
|
||||||
|
<Alert severity="warning" className="text-left">
|
||||||
|
<span className="text-left">
|
||||||
|
<strong>Note:</strong> Changing the name of the role will lose the
|
||||||
|
associated permissions with that role.
|
||||||
|
</span>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
|
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
|
||||||
<Input
|
<Input
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
|
|||||||
@@ -8,18 +8,18 @@ import Chip from '@/ui/v2/Chip';
|
|||||||
import Divider from '@/ui/v2/Divider';
|
import Divider from '@/ui/v2/Divider';
|
||||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||||
import IconButton from '@/ui/v2/IconButton';
|
import IconButton from '@/ui/v2/IconButton';
|
||||||
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
|
|
||||||
import LockIcon from '@/ui/v2/icons/LockIcon';
|
|
||||||
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
|
||||||
import List from '@/ui/v2/List';
|
import List from '@/ui/v2/List';
|
||||||
import { ListItem } from '@/ui/v2/ListItem';
|
import { ListItem } from '@/ui/v2/ListItem';
|
||||||
import Text from '@/ui/v2/Text';
|
import Text from '@/ui/v2/Text';
|
||||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
|
||||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
import LockIcon from '@/ui/v2/icons/LockIcon';
|
||||||
|
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||||
import {
|
import {
|
||||||
useGetRolesQuery,
|
useGetRolesQuery,
|
||||||
useUpdateAppMutation,
|
useUpdateAppMutation
|
||||||
} from '@/utils/__generated__/graphql';
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||||
|
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
@@ -156,7 +156,7 @@ export default function RoleSettings() {
|
|||||||
className="px-0 my-2"
|
className="px-0 my-2"
|
||||||
slotProps={{ submitButton: { className: 'invisible' } }}
|
slotProps={{ submitButton: { className: 'invisible' } }}
|
||||||
>
|
>
|
||||||
<div className="border-b-1 border-gray-200 px-4 py-3">
|
<div className="px-4 py-3 border-gray-200 border-b-1">
|
||||||
<Text className="font-medium">Name</Text>
|
<Text className="font-medium">Name</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ export default function RoleSettings() {
|
|||||||
<Dropdown.Trigger
|
<Dropdown.Trigger
|
||||||
asChild
|
asChild
|
||||||
hideChevron
|
hideChevron
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
className="absolute -translate-y-1/2 right-4 top-1/2"
|
||||||
>
|
>
|
||||||
<IconButton variant="borderless" color="secondary">
|
<IconButton variant="borderless" color="secondary">
|
||||||
<DotsVerticalIcon />
|
<DotsVerticalIcon />
|
||||||
@@ -257,7 +257,7 @@ export default function RoleSettings() {
|
|||||||
</List>
|
</List>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="justify-self-start mx-4"
|
className="mx-4 justify-self-start"
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
startIcon={<PlusIcon />}
|
startIcon={<PlusIcon />}
|
||||||
onClick={handleOpenCreator}
|
onClick={handleOpenCreator}
|
||||||
|
|||||||
169
dashboard/src/components/users/CreateUserForm/CreateUserForm.tsx
Normal file
169
dashboard/src/components/users/CreateUserForm/CreateUserForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
dashboard/src/components/users/CreateUserForm/index.ts
Normal file
3
dashboard/src/components/users/CreateUserForm/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './CreateUserForm';
|
||||||
|
export { default } from './CreateUserForm';
|
||||||
|
|
||||||
475
dashboard/src/components/users/EditUserForm/EditUserForm.tsx
Normal file
475
dashboard/src/components/users/EditUserForm/EditUserForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
dashboard/src/components/users/EditUserForm/index.ts
Normal file
3
dashboard/src/components/users/EditUserForm/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './EditUserForm';
|
||||||
|
export { default } from './EditUserForm';
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './EditUserPasswordForm';
|
||||||
|
export { default } from './EditUserPasswordForm';
|
||||||
|
|
||||||
376
dashboard/src/components/users/UsersBody/UsersBody.tsx
Normal file
376
dashboard/src/components/users/UsersBody/UsersBody.tsx
Normal 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 "
|
||||||
|
<strong>{user.displayName}</strong>" 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
dashboard/src/components/users/UsersBody/index.ts
Normal file
3
dashboard/src/components/users/UsersBody/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './UsersBody';
|
||||||
|
export { default } from './UsersBody';
|
||||||
|
|
||||||
@@ -8,9 +8,11 @@ import { useEffect } from 'react';
|
|||||||
|
|
||||||
export default function SidebarWorkspaces() {
|
export default function SidebarWorkspaces() {
|
||||||
const user = nhost.auth.getUser();
|
const user = nhost.auth.getUser();
|
||||||
const { data, loading, stopPolling } = useGetWorkspacesQuery({
|
const { data, loading, startPolling, stopPolling } = useGetWorkspacesQuery();
|
||||||
pollInterval: 1000,
|
|
||||||
});
|
useEffect(() => {
|
||||||
|
startPolling(1000);
|
||||||
|
}, [startPolling]);
|
||||||
|
|
||||||
// keep polling for workspaces until there is a workspace available.
|
// keep polling for workspaces until there is a workspace available.
|
||||||
// We do this because when a user signs up a workspace is created automatically
|
// We do this because when a user signs up a workspace is created automatically
|
||||||
|
|||||||
@@ -4,20 +4,39 @@ fragment RemoteAppGetUsers on users {
|
|||||||
displayName
|
displayName
|
||||||
avatarUrl
|
avatarUrl
|
||||||
email
|
email
|
||||||
|
emailVerified
|
||||||
phoneNumber
|
phoneNumber
|
||||||
|
phoneNumberVerified
|
||||||
disabled
|
disabled
|
||||||
defaultRole
|
defaultRole
|
||||||
|
lastSeen
|
||||||
|
locale
|
||||||
roles {
|
roles {
|
||||||
|
id
|
||||||
role
|
role
|
||||||
}
|
}
|
||||||
|
userProviders {
|
||||||
|
id
|
||||||
|
providerId
|
||||||
|
}
|
||||||
|
disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
query remoteAppGetUsers($where: users_bool_exp!, $limit: Int!, $offset: Int!) {
|
query remoteAppGetUsers($where: users_bool_exp!, $limit: Int!, $offset: Int!) {
|
||||||
users(where: $where, limit: $limit, offset: $offset) {
|
users(
|
||||||
|
where: $where
|
||||||
|
limit: $limit
|
||||||
|
offset: $offset
|
||||||
|
order_by: { createdAt: desc }
|
||||||
|
) {
|
||||||
...RemoteAppGetUsers
|
...RemoteAppGetUsers
|
||||||
}
|
}
|
||||||
usersAggregate(where: $where) {
|
filteredUsersAggreggate: usersAggregate(where: $where) {
|
||||||
# make same where query here
|
aggregate {
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usersAggregate {
|
||||||
aggregate {
|
aggregate {
|
||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ export default function useProjectRedirectWhenReady(
|
|||||||
options: UseProjectRedirectWhenReadyOptions = {},
|
options: UseProjectRedirectWhenReadyOptions = {},
|
||||||
) {
|
) {
|
||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
const { data, client, ...rest } = useGetApplicationStateQuery({
|
const { data, client, startPolling, ...rest } = useGetApplicationStateQuery({
|
||||||
pollInterval: 2000,
|
|
||||||
...options,
|
...options,
|
||||||
variables: { ...options.variables, appId: currentApplication.id },
|
variables: { ...options.variables, appId: currentApplication.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startPolling(options.pollInterval || 2000);
|
||||||
|
}, [options.pollInterval, startPolling]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function updateOwnCache() {
|
async function updateOwnCache() {
|
||||||
await client.refetchQueries({
|
await client.refetchQueries({
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ export function useCheckProvisioning() {
|
|||||||
const [currentApplicationState, setCurrentApplicationState] =
|
const [currentApplicationState, setCurrentApplicationState] =
|
||||||
useState<ApplicationStateMetadata>({ state: ApplicationStatus.Empty });
|
useState<ApplicationStateMetadata>({ state: ApplicationStatus.Empty });
|
||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const { data, stopPolling, client } = useGetApplicationStateQuery({
|
|
||||||
variables: { appId: currentApplication.id },
|
const { data, startPolling, stopPolling, client } =
|
||||||
pollInterval: 2000,
|
useGetApplicationStateQuery({
|
||||||
skip: !isPlatform,
|
variables: { appId: currentApplication?.id },
|
||||||
});
|
skip: !isPlatform || !currentApplication?.id,
|
||||||
|
});
|
||||||
|
|
||||||
async function updateOwnCache() {
|
async function updateOwnCache() {
|
||||||
await client.refetchQueries({
|
await client.refetchQueries({
|
||||||
@@ -35,6 +36,10 @@ export function useCheckProvisioning() {
|
|||||||
|
|
||||||
const memoizedUpdateCache = useCallback(updateOwnCache, [client]);
|
const memoizedUpdateCache = useCallback(updateOwnCache, [client]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startPolling(2000);
|
||||||
|
}, [startPolling]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return;
|
return;
|
||||||
@@ -81,7 +86,13 @@ export function useCheckProvisioning() {
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
memoizedUpdateCache();
|
memoizedUpdateCache();
|
||||||
}
|
}
|
||||||
}, [data, stopPolling, memoizedUpdateCache, currentApplication.id]);
|
}, [
|
||||||
|
data,
|
||||||
|
stopPolling,
|
||||||
|
memoizedUpdateCache,
|
||||||
|
currentApplication.id,
|
||||||
|
currentApplicationState.state,
|
||||||
|
]);
|
||||||
|
|
||||||
return currentApplicationState;
|
return currentApplicationState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function useRemoteApplicationGQLClient() {
|
|||||||
: currentApplication?.hasuraGraphqlAdminSecret,
|
: currentApplication?.hasuraGraphqlAdminSecret,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
currentApplication?.subdomain,
|
currentApplication?.subdomain,
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export default function LogsPage() {
|
|||||||
}, [subscribeToMoreLogs, toDate, client]);
|
}, [subscribeToMoreLogs, toDate, client]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="flex flex-col w-full h-full">
|
||||||
<RetryableErrorBoundary>
|
<RetryableErrorBoundary>
|
||||||
<LogsHeader
|
<LogsHeader
|
||||||
fromDate={fromDate}
|
fromDate={fromDate}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Container from '@/components/layout/Container';
|
import Container from '@/components/layout/Container';
|
||||||
|
import SettingsLayout from '@/components/settings/SettingsLayout';
|
||||||
import AllowedEmailDomainsSettings from '@/components/settings/authentication/AllowedEmailSettings';
|
import AllowedEmailDomainsSettings from '@/components/settings/authentication/AllowedEmailSettings';
|
||||||
import AllowedRedirectURLsSettings from '@/components/settings/authentication/AllowedRedirectURLsSettings';
|
import AllowedRedirectURLsSettings from '@/components/settings/authentication/AllowedRedirectURLsSettings';
|
||||||
import BlockedEmailSettings from '@/components/settings/authentication/BlockedEmailSettings';
|
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 DisableNewUsersSettings from '@/components/settings/authentication/DisableNewUsersSettings';
|
||||||
import GravatarSettings from '@/components/settings/authentication/GravatarSettings';
|
import GravatarSettings from '@/components/settings/authentication/GravatarSettings';
|
||||||
import MFASettings from '@/components/settings/authentication/MFASettings';
|
import MFASettings from '@/components/settings/authentication/MFASettings';
|
||||||
import SettingsLayout from '@/components/settings/SettingsLayout';
|
|
||||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
import { useGetAppQuery } from '@/utils/__generated__/graphql';
|
import { useGetAppQuery } from '@/utils/__generated__/graphql';
|
||||||
@@ -37,7 +37,7 @@ export default function SettingsAuthenticationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<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"
|
rootClassName="bg-transparent"
|
||||||
>
|
>
|
||||||
<ClientURLSettings />
|
<ClientURLSettings />
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
};
|
|
||||||
@@ -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 Container from '@/components/layout/Container';
|
||||||
import ProjectLayout from '@/components/layout/ProjectLayout';
|
import ProjectLayout from '@/components/layout/ProjectLayout';
|
||||||
import UsersList from '@/components/users/UsersList';
|
import UsersBody from '@/components/users/UsersBody';
|
||||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||||
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
import { NhostApolloProvider } from '@nhost/react-apollo';
|
import Button from '@/ui/v2/Button';
|
||||||
import type { ReactElement } from 'react';
|
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() {
|
export default function UsersPage() {
|
||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
const { openDialog, closeDialog } = useDialog();
|
||||||
|
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||||
|
const [searchString, setSearchString] = useState<string>('');
|
||||||
|
|
||||||
if (!currentApplication) {
|
const limit = useRef(25);
|
||||||
return <LoadingScreen />;
|
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 (
|
return (
|
||||||
<NhostApolloProvider
|
<Container className="mx-auto space-y-5 overflow-x-hidden max-w-9xl">
|
||||||
graphqlUrl={generateAppServiceUrl(
|
<div className="flex flex-row place-content-between">
|
||||||
currentApplication.subdomain,
|
<Input
|
||||||
currentApplication.region.awsName,
|
className="rounded-sm"
|
||||||
'graphql',
|
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 "{searchString}"
|
||||||
|
</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"
|
</Container>
|
||||||
headers={{
|
|
||||||
'x-hasura-admin-secret':
|
|
||||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
|
||||||
? 'nhost-admin-secret'
|
|
||||||
: currentApplication.hasuraGraphqlAdminSecret,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Container>
|
|
||||||
<UsersList />
|
|
||||||
</Container>
|
|
||||||
</NhostApolloProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export const theme = createTheme({
|
|||||||
main: '#f13154',
|
main: '#f13154',
|
||||||
dark: '#c91737',
|
dark: '#c91737',
|
||||||
},
|
},
|
||||||
|
success: {
|
||||||
|
main: 'rgba(170, 240, 204, 0.3)',
|
||||||
|
contrastText: '#3BB174',
|
||||||
|
},
|
||||||
action: {
|
action: {
|
||||||
hover: '#f3f4f6',
|
hover: '#f3f4f6',
|
||||||
active: '#f3f4f6',
|
active: '#f3f4f6',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export enum AvailableLogsServices {
|
|||||||
AUTH = 'hasura-auth',
|
AUTH = 'hasura-auth',
|
||||||
STORAGE = 'hasura-storage',
|
STORAGE = 'hasura-storage',
|
||||||
HASURA = 'hasura',
|
HASURA = 'hasura',
|
||||||
|
FUNCTIONS = 'functions',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogsCustomInterval = {
|
export type LogsCustomInterval = {
|
||||||
|
|||||||
1303
dashboard/src/utils/__generated__/graphql.ts
generated
1303
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ export function copy(toCopy: string, name: string) {
|
|||||||
triggerToast('Error while copying');
|
triggerToast('Error while copying');
|
||||||
});
|
});
|
||||||
|
|
||||||
triggerToast(`${name} copied to clipboard.`);
|
triggerToast(`${name} copied to clipboard`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default copy;
|
export default copy;
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export const availableServices: {
|
|||||||
label: 'Hasura',
|
label: 'Hasura',
|
||||||
value: AvailableLogsServices.HASURA,
|
value: AvailableLogsServices.HASURA,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Functions',
|
||||||
|
value: AvailableLogsServices.FUNCTIONS,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const logsCustomIntervals: LogsCustomInterval[] = [
|
export const logsCustomIntervals: LogsCustomInterval[] = [
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
import { useWorkspaceContext } from '@/context/workspace-context';
|
||||||
import { useGetUserAllWorkspacesQuery } from '@/generated/graphql';
|
import { useGetUserAllWorkspacesQuery } from '@/generated/graphql';
|
||||||
import { useWithin } from '@/hooks/useWithin';
|
import { useWithin } from '@/hooks/useWithin';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export const useUserFirstWorkspace = () => {
|
export const useUserFirstWorkspace = () => {
|
||||||
const { workspaceContext } = useWorkspaceContext();
|
const { workspaceContext } = useWorkspaceContext();
|
||||||
const { within } = useWithin();
|
const { within } = useWithin();
|
||||||
const fetch = !!workspaceContext.slug || !within;
|
const fetch = !!workspaceContext.slug || !within;
|
||||||
|
|
||||||
const { loading, error, data, stopPolling, client } =
|
const { loading, error, data, startPolling, stopPolling, client } =
|
||||||
useGetUserAllWorkspacesQuery({
|
useGetUserAllWorkspacesQuery({
|
||||||
pollInterval: 1000,
|
|
||||||
skip: fetch,
|
skip: fetch,
|
||||||
fetchPolicy: 'cache-first',
|
fetchPolicy: 'cache-first',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!!workspaceContext.slug || !within) {
|
useEffect(() => {
|
||||||
|
startPolling(1000);
|
||||||
|
}, [startPolling]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceContext.slug && within) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
stopPolling();
|
stopPolling();
|
||||||
}
|
}, [workspaceContext.slug, within, stopPolling]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# @nhost-examples/react-apollo
|
# @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
|
## 0.1.4
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -17,6 +17,33 @@ context('File uploads', () => {
|
|||||||
.contains('Successfully uploaded')
|
.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', () => {
|
it('should upload multiple files', () => {
|
||||||
const files: Required<Cypress.FileReferenceObject>[] = [
|
const files: Required<Cypress.FileReferenceObject>[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost-examples/react-apollo",
|
"name": "@nhost-examples/react-apollo",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.6.9",
|
"@apollo/client": "^3.6.9",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @nhost/apollo
|
# @nhost/apollo
|
||||||
|
|
||||||
|
## 4.12.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/nhost-js@1.12.1
|
||||||
|
|
||||||
## 0.6.0
|
## 0.6.0
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/apollo",
|
"name": "@nhost/apollo",
|
||||||
"version": "0.6.0",
|
"version": "4.12.1",
|
||||||
"description": "Nhost Apollo Client library",
|
"description": "Nhost Apollo Client library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# @nhost/react-apollo
|
# @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
|
## 4.12.0
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/react-apollo",
|
"name": "@nhost/react-apollo",
|
||||||
"version": "4.12.0",
|
"version": "4.12.1",
|
||||||
"description": "Nhost React Apollo client",
|
"description": "Nhost React Apollo client",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# @nhost/react-urql
|
# @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
|
## 0.1.0
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/react-urql",
|
"name": "@nhost/react-urql",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "Nhost React URQL client",
|
"description": "Nhost React URQL client",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# @nhost/hasura-storage-js
|
# @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
|
## 1.12.0
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/hasura-storage-js",
|
"name": "@nhost/hasura-storage-js",
|
||||||
"version": "1.12.0",
|
"version": "1.12.1",
|
||||||
"description": "Hasura-storage client",
|
"description": "Hasura-storage client",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ export type FileUploadEvents =
|
|||||||
| { type: 'CANCEL' }
|
| { type: 'CANCEL' }
|
||||||
| { type: 'DESTROY' }
|
| { 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 type FileUploadMachine = ReturnType<typeof createFileUploadMachine>
|
||||||
export const createFileUploadMachine = () =>
|
export const createFileUploadMachine = () =>
|
||||||
@@ -62,8 +69,20 @@ export const createFileUploadMachine = () =>
|
|||||||
},
|
},
|
||||||
invoke: { src: 'uploadFile' }
|
invoke: { src: 'uploadFile' }
|
||||||
},
|
},
|
||||||
uploaded: { entry: ['setFileMetadata', 'sendDone'] },
|
uploaded: {
|
||||||
error: { entry: ['setError', 'sendError'] },
|
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' }
|
stopped: { type: 'final' }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -88,6 +107,7 @@ export const createFileUploadMachine = () =>
|
|||||||
sendDestroy: () => {},
|
sendDestroy: () => {},
|
||||||
sendDone: () => {},
|
sendDone: () => {},
|
||||||
resetProgress: assign({ progress: (_) => null, loaded: (_) => 0 }),
|
resetProgress: assign({ progress: (_) => null, loaded: (_) => 0 }),
|
||||||
|
resetContext: assign((_) => INITIAL_FILE_CONTEXT),
|
||||||
addFile: assign({
|
addFile: assign({
|
||||||
file: (_, { file }) => file,
|
file: (_, { file }) => file,
|
||||||
bucketId: (_, { bucketId }) => bucketId,
|
bucketId: (_, { bucketId }) => bucketId,
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ export interface Typegen0 {
|
|||||||
}
|
}
|
||||||
missingImplementations: {
|
missingImplementations: {
|
||||||
actions: never
|
actions: never
|
||||||
services: never
|
|
||||||
guards: never
|
|
||||||
delays: never
|
delays: never
|
||||||
|
guards: never
|
||||||
|
services: never
|
||||||
}
|
}
|
||||||
eventsCausingActions: {
|
eventsCausingActions: {
|
||||||
addFile: 'ADD'
|
addFile: 'ADD'
|
||||||
incrementProgress: 'UPLOAD_PROGRESS'
|
incrementProgress: 'UPLOAD_PROGRESS'
|
||||||
|
resetContext: 'UPLOAD'
|
||||||
resetProgress: 'UPLOAD'
|
resetProgress: 'UPLOAD'
|
||||||
sendDestroy: 'DESTROY'
|
sendDestroy: 'DESTROY'
|
||||||
sendDone: 'UPLOAD_DONE'
|
sendDone: 'UPLOAD_DONE'
|
||||||
@@ -25,13 +26,13 @@ export interface Typegen0 {
|
|||||||
setError: 'UPLOAD_ERROR'
|
setError: 'UPLOAD_ERROR'
|
||||||
setFileMetadata: 'UPLOAD_DONE'
|
setFileMetadata: 'UPLOAD_DONE'
|
||||||
}
|
}
|
||||||
eventsCausingServices: {
|
eventsCausingDelays: {}
|
||||||
uploadFile: 'UPLOAD'
|
|
||||||
}
|
|
||||||
eventsCausingGuards: {
|
eventsCausingGuards: {
|
||||||
hasFile: 'UPLOAD'
|
hasFile: 'UPLOAD'
|
||||||
}
|
}
|
||||||
eventsCausingDelays: {}
|
eventsCausingServices: {
|
||||||
|
uploadFile: 'UPLOAD'
|
||||||
|
}
|
||||||
matchesStates: 'error' | 'idle' | 'stopped' | 'uploaded' | 'uploading'
|
matchesStates: 'error' | 'idle' | 'stopped' | 'uploaded' | 'uploading'
|
||||||
tags: never
|
tags: never
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# @nhost/nextjs
|
# @nhost/nextjs
|
||||||
|
|
||||||
|
## 1.12.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [1be6d324]
|
||||||
|
- Updated dependencies [2e8f73df]
|
||||||
|
- Updated dependencies [85683547]
|
||||||
|
- @nhost/react@1.12.1
|
||||||
|
|
||||||
## 1.12.0
|
## 1.12.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/nextjs",
|
"name": "@nhost/nextjs",
|
||||||
"version": "1.12.0",
|
"version": "1.12.1",
|
||||||
"description": "Nhost NextJS library",
|
"description": "Nhost NextJS library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# @nhost/nhost-js
|
# @nhost/nhost-js
|
||||||
|
|
||||||
|
## 1.12.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [85683547]
|
||||||
|
- @nhost/hasura-storage-js@1.12.1
|
||||||
|
|
||||||
## 1.12.0
|
## 1.12.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/nhost-js",
|
"name": "@nhost/nhost-js",
|
||||||
"version": "1.12.0",
|
"version": "1.12.1",
|
||||||
"description": "Nhost JavaScript SDK",
|
"description": "Nhost JavaScript SDK",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# @nhost/react
|
# @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
|
## 1.12.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/react",
|
"name": "@nhost/react",
|
||||||
"version": "1.12.0",
|
"version": "1.12.1",
|
||||||
"description": "Nhost React library",
|
"description": "Nhost React library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
|
AuthMachine,
|
||||||
BackendUrl,
|
BackendUrl,
|
||||||
NhostAuthConstructorParams,
|
NhostAuthConstructorParams,
|
||||||
NhostClient as VanillaNhostClient,
|
NhostClient as _VanillaNhostClient,
|
||||||
|
NhostSession,
|
||||||
|
NHOST_REFRESH_TOKEN_KEY,
|
||||||
Subdomain
|
Subdomain
|
||||||
} from '@nhost/nhost-js'
|
} from '@nhost/nhost-js'
|
||||||
|
|
||||||
export * from '@nhost/nhost-js'
|
export type { NhostSession, NhostAuthConstructorParams, AuthMachine, Subdomain, BackendUrl }
|
||||||
export { VanillaNhostClient }
|
export { NHOST_REFRESH_TOKEN_KEY }
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
export const VanillaNhostClient = _VanillaNhostClient
|
||||||
|
|
||||||
export interface NhostReactClientConstructorParams
|
export interface NhostReactClientConstructorParams
|
||||||
extends Partial<BackendUrl>,
|
extends Partial<BackendUrl>,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NhostReactContext } from './provider'
|
|||||||
export const useAuthInterpreter = (): InterpreterFrom<AuthMachine> => {
|
export const useAuthInterpreter = (): InterpreterFrom<AuthMachine> => {
|
||||||
const nhost = useContext(NhostReactContext)
|
const nhost = useContext(NhostReactContext)
|
||||||
const interpreter = nhost.auth?.client.interpreter
|
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
|
return interpreter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,10 +75,7 @@ export const useFileUploadItem = (
|
|||||||
url: nhost.storage.url,
|
url: nhost.storage.url,
|
||||||
accessToken: nhost.auth.getAccessToken(),
|
accessToken: nhost.auth.getAccessToken(),
|
||||||
adminSecret: nhost.adminSecret,
|
adminSecret: nhost.adminSecret,
|
||||||
file: params.file,
|
...params
|
||||||
bucketId: params.bucketId || bucketId,
|
|
||||||
id,
|
|
||||||
name
|
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# @nhost/vue
|
# @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
|
## 1.12.0
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/vue",
|
"name": "@nhost/vue",
|
||||||
"version": "1.12.0",
|
"version": "1.12.1",
|
||||||
"description": "Nhost Vue library",
|
"description": "Nhost Vue library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -75,10 +75,7 @@ export const useFileUploadItem = (
|
|||||||
url: nhost.storage.url,
|
url: nhost.storage.url,
|
||||||
accessToken: nhost.auth.getAccessToken(),
|
accessToken: nhost.auth.getAccessToken(),
|
||||||
adminSecret: nhost.adminSecret,
|
adminSecret: nhost.adminSecret,
|
||||||
file: params.file,
|
...params
|
||||||
bucketId: params.bucketId || bucketId,
|
|
||||||
id,
|
|
||||||
name
|
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
)
|
)
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -77,7 +77,7 @@ importers:
|
|||||||
|
|
||||||
dashboard:
|
dashboard:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@apollo/client': ^3.6.2
|
'@apollo/client': ^3.7.3
|
||||||
'@babel/core': ^7.20.2
|
'@babel/core': ^7.20.2
|
||||||
'@codemirror/language': ^6.3.0
|
'@codemirror/language': ^6.3.0
|
||||||
'@emotion/cache': ^11.10.5
|
'@emotion/cache': ^11.10.5
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
"outputs": ["dist/**"],
|
"outputs": ["dist/**"],
|
||||||
"env": ["VITE_NHOST_SUBDOMAIN", "VITE_NHOST_REGION"]
|
"env": ["VITE_NHOST_SUBDOMAIN", "VITE_NHOST_REGION"]
|
||||||
},
|
},
|
||||||
|
"@nhost/docs#build": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
"@nhost/dashboard#build": {
|
"@nhost/dashboard#build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": ["dist/**", ".next/**"],
|
"outputs": ["dist/**", ".next/**"],
|
||||||
|
|||||||
Reference in New Issue
Block a user