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
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 9a1aa7bb: add functions to the log dashboard
|
||||
- f29abe62: feat(dashboard): Users Management v2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 7766624b: feat(dashboard): add JWT secret editor modal
|
||||
- @nhost/react-apollo@4.12.1
|
||||
- @nhost/nextjs@1.12.1
|
||||
|
||||
## 0.7.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- dd0738d5: fix(dashboard): provisioning status polling
|
||||
|
||||
## 0.7.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -66,6 +66,11 @@ module.exports = withBundleAnalyzer({
|
||||
destination: '/:workspaceSlug/:appSlug/settings/environment-variables',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/:workspaceSlug/:appSlug/users/:userId',
|
||||
destination: '/:workspaceSlug/:appSlug/users?userId=:userId',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.7.12",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -17,7 +17,7 @@
|
||||
"build-storybook": "build-storybook"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.6.2",
|
||||
"@apollo/client": "^3.7.3",
|
||||
"@codemirror/language": "^6.3.0",
|
||||
"@emotion/cache": "^11.10.5",
|
||||
"@emotion/react": "^11.10.5",
|
||||
|
||||
@@ -32,9 +32,12 @@ export default function ConnectGithubModal({ close }: ConnectGithubModalProps) {
|
||||
useState<ConnectGithubModalState>('CONNECTING');
|
||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null);
|
||||
|
||||
const { data, loading, error } = useGetGithubRepositoriesQuery({
|
||||
pollInterval: 2000,
|
||||
});
|
||||
const { data, loading, error, startPolling } =
|
||||
useGetGithubRepositoriesQuery();
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(2000);
|
||||
}, [startPolling]);
|
||||
|
||||
const handleSelectAnotherRepository = () => {
|
||||
setSelectedRepoId(null);
|
||||
|
||||
@@ -13,14 +13,17 @@ export function FunctionsLogsTerminalPage({ functionName }: any) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const [normalizedFunctionData, setNormalizedFunctionData] = useState(null);
|
||||
|
||||
const { data } = useGetFunctionLogQuery({
|
||||
const { data, startPolling } = useGetFunctionLogQuery({
|
||||
variables: {
|
||||
subdomain: currentApplication.subdomain,
|
||||
functionPaths: [functionName?.split('/').slice(1, 3).join('/')],
|
||||
},
|
||||
pollInterval: 3000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(3000);
|
||||
}, [startPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || data.getFunctionLogs.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLCl
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Option from '@/ui/v2/Option';
|
||||
import Select from '@/ui/v2/Select';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import type { RemoteAppGetUsersCustomQuery } from '@/utils/__generated__/graphql';
|
||||
import { useRemoteAppGetUsersCustomQuery } from '@/utils/__generated__/graphql';
|
||||
import { DEFAULT_ROLES } from './utils';
|
||||
|
||||
@@ -57,7 +57,7 @@ export function UserSelect({ onUserChange, ...props }: UserSelectProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user: RemoteAppGetUsersQuery['users'][number] = users.find(
|
||||
const user: RemoteAppGetUsersCustomQuery['users'][0] = users.find(
|
||||
({ id }) => id === userId,
|
||||
);
|
||||
|
||||
|
||||
@@ -35,15 +35,15 @@ function InsertPlaceholderTableRow({
|
||||
}: InsertPlaceholderTableRowProps) {
|
||||
return (
|
||||
<div
|
||||
className="h-12 border-r-1 border-b-1 border-gray-200 bg-white"
|
||||
className="h-12 bg-white border-gray-200 border-r-1 border-b-1"
|
||||
{...props}
|
||||
>
|
||||
<Button
|
||||
onClick={onInsertRow}
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="h-full w-full justify-start rounded-none px-2 py-3 text-xs font-normal hover:shadow-none focus:shadow-none focus:outline-none"
|
||||
startIcon={<PlusIcon className="h-4 w-4 text-greyscaleGrey" />}
|
||||
className="justify-start w-full h-full px-2 py-3 text-xs font-normal rounded-none hover:shadow-none focus:shadow-none focus:outline-none"
|
||||
startIcon={<PlusIcon className="w-4 h-4 text-greyscaleGrey" />}
|
||||
>
|
||||
Insert New Row
|
||||
</Button>
|
||||
@@ -181,7 +181,7 @@ export default function DataGridBody<T extends object>({
|
||||
return (
|
||||
<div {...getTableBodyProps()} ref={bodyRef} {...props}>
|
||||
{rows.length === 0 && !loading && (
|
||||
<div className="flex flex-nowrap pr-5">
|
||||
<div className="flex pr-5 flex-nowrap">
|
||||
{onInsertRow ? (
|
||||
<InsertPlaceholderTableRow
|
||||
style={{
|
||||
@@ -279,7 +279,7 @@ export default function DataGridBody<T extends object>({
|
||||
})}
|
||||
|
||||
{allowInsertColumn && (
|
||||
<div className="h-12 w-25 border-r-1 border-b-1 border-gray-200 bg-white" />
|
||||
<div className="h-12 bg-white border-gray-200 w-25 border-r-1 border-b-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,10 +15,14 @@ export type DialogType =
|
||||
| 'EDIT_FOREIGN_KEY'
|
||||
| 'CREATE_ROLE'
|
||||
| 'EDIT_ROLE'
|
||||
| 'CREATE_USER'
|
||||
| 'CREATE_PERMISSION_VARIABLE'
|
||||
| 'EDIT_PERMISSION_VARIABLE'
|
||||
| 'CREATE_ENVIRONMENT_VARIABLE'
|
||||
| 'EDIT_ENVIRONMENT_VARIABLE';
|
||||
| 'EDIT_ENVIRONMENT_VARIABLE'
|
||||
| 'EDIT_USER'
|
||||
| 'EDIT_USER_PASSWORD'
|
||||
| 'EDIT_JWT_SECRET';
|
||||
|
||||
export interface DialogConfig<TPayload = unknown> {
|
||||
/**
|
||||
|
||||
@@ -3,10 +3,14 @@ import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm'
|
||||
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
|
||||
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
|
||||
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
|
||||
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
|
||||
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
|
||||
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
|
||||
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
|
||||
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
|
||||
import CreateUserForm from '@/components/users/CreateUserForm';
|
||||
import EditUserForm from '@/components/users/EditUserForm';
|
||||
import EditUserPasswordForm from '@/components/users/EditUserPasswordForm';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import AlertDialog from '@/ui/v2/AlertDialog';
|
||||
import { BaseDialog } from '@/ui/v2/Dialog';
|
||||
@@ -353,6 +357,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
<EditRoleForm {...sharedDialogProps} />
|
||||
)}
|
||||
|
||||
{activeDialogType === 'CREATE_USER' && (
|
||||
<CreateUserForm {...sharedDialogProps} />
|
||||
)}
|
||||
|
||||
{activeDialogType === 'CREATE_PERMISSION_VARIABLE' && (
|
||||
<CreatePermissionVariableForm {...sharedDialogProps} />
|
||||
)}
|
||||
@@ -368,6 +376,17 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
{activeDialogType === 'EDIT_ENVIRONMENT_VARIABLE' && (
|
||||
<EditEnvironmentVariableForm {...sharedDialogProps} />
|
||||
)}
|
||||
|
||||
{activeDialogType === 'EDIT_USER_PASSWORD' && (
|
||||
<EditUserPasswordForm
|
||||
{...sharedDialogProps}
|
||||
user={sharedDialogProps?.user}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDialogType === 'EDIT_JWT_SECRET' && (
|
||||
<EditJwtSecretForm {...sharedDialogProps} />
|
||||
)}
|
||||
</RetryableErrorBoundary>
|
||||
</BaseDialog>
|
||||
|
||||
@@ -413,6 +432,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
schema={drawerPayload?.schema}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDrawerType === 'EDIT_USER' && (
|
||||
<EditUserForm {...sharedDrawerProps} {...drawerPayload} />
|
||||
)}
|
||||
</RetryableErrorBoundary>
|
||||
</Drawer>
|
||||
|
||||
|
||||
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 { useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function InviteAnnounce() {
|
||||
const user = useUserData();
|
||||
@@ -21,15 +22,18 @@ export function InviteAnnounce() {
|
||||
useSubmitState();
|
||||
|
||||
// @FIX: We probably don't want to poll every ten seconds for possible invites. (We can change later depending on how it works in production.) Maybe just on the workspace page?
|
||||
const { data, loading, error, refetch } =
|
||||
const { data, loading, error, refetch, startPolling } =
|
||||
useGetWorkspaceMemberInvitesToManageQuery({
|
||||
variables: {
|
||||
userId: user?.id,
|
||||
},
|
||||
pollInterval: 15000,
|
||||
skip: !isPlatform,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(15000);
|
||||
}, [startPolling]);
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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 EyeIcon from '@/ui/v2/icons/EyeIcon';
|
||||
import EyeOffIcon from '@/ui/v2/icons/EyeOffIcon';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
@@ -27,7 +26,7 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
const [showAdminSecret, setShowAdminSecret] = useState(false);
|
||||
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { openDialog } = useDialog();
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data, loading, error } = useGetAppInjectedVariablesQuery({
|
||||
variables: { id: currentApplication?.id },
|
||||
@@ -49,30 +48,39 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
function showJwtSecret() {
|
||||
openAlertDialog({
|
||||
title: 'Auth JWT Secret',
|
||||
payload: (
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Text variant="subtitle2">
|
||||
function showViewJwtSecretModal() {
|
||||
openDialog('EDIT_JWT_SECRET', {
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Auth JWT Secret</span>
|
||||
|
||||
<Text variant="subtitle1" component="span">
|
||||
This is the key used for generating JWTs. It's HMAC-SHA-based
|
||||
and the same as configured in Hasura.
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
defaultValue={data?.app?.hasuraGraphqlJwtSecret}
|
||||
disabled
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={5}
|
||||
hideEmptyHelperText
|
||||
inputProps={{ className: 'font-mono' }}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
),
|
||||
props: {
|
||||
hidePrimaryAction: true,
|
||||
secondaryButtonText: 'Close',
|
||||
payload: {
|
||||
disabled: true,
|
||||
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function showEditJwtSecretModal() {
|
||||
openDialog('EDIT_JWT_SECRET', {
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Edit JWT Secret</span>
|
||||
|
||||
<Text variant="subtitle1" component="span">
|
||||
You can add your custom JWT secret here. Hasura will use it to
|
||||
validate the identity of your users.
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
payload: {
|
||||
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -207,14 +215,25 @@ export default function SystemEnvironmentVariableSettings() {
|
||||
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 justify-start">
|
||||
<ListItem.Text>NHOST_JWT_SECRET</ListItem.Text>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={showJwtSecret}
|
||||
size="small"
|
||||
className="justify-self-start"
|
||||
>
|
||||
Show JWT Secret
|
||||
</Button>
|
||||
<div className="grid grid-flow-row md:grid-flow-col gap-1.5 justify-center text-center lg:text-left lg:justify-start items-center lg:col-span-2">
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={showViewJwtSecretModal}
|
||||
size="small"
|
||||
>
|
||||
Show JWT Secret
|
||||
</Button>
|
||||
|
||||
<Text component="span">or</Text>
|
||||
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={showEditJwtSecretModal}
|
||||
size="small"
|
||||
>
|
||||
Edit JWT Secret
|
||||
</Button>
|
||||
</div>
|
||||
</ListItem.Root>
|
||||
</List>
|
||||
</SettingsContainer>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import Text from '@/ui/v2/Text';
|
||||
@@ -55,11 +56,20 @@ export default function BaseRoleForm({
|
||||
}, [isDirty, onDirtyStateChange]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-2 px-6 pb-6">
|
||||
<div className="grid grid-flow-row gap-3 px-6 pb-6">
|
||||
<Text variant="subtitle1" component="span">
|
||||
Enter the name for the role below.
|
||||
</Text>
|
||||
|
||||
{submitButtonText !== 'Create' && (
|
||||
<Alert severity="warning" className="text-left">
|
||||
<span className="text-left">
|
||||
<strong>Note:</strong> Changing the name of the role will lose the
|
||||
associated permissions with that role.
|
||||
</span>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form onSubmit={onSubmit} className="grid grid-flow-row gap-4">
|
||||
<Input
|
||||
{...register('name')}
|
||||
|
||||
@@ -8,18 +8,18 @@ import Chip from '@/ui/v2/Chip';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||
import IconButton from '@/ui/v2/IconButton';
|
||||
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
|
||||
import LockIcon from '@/ui/v2/icons/LockIcon';
|
||||
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||
import List from '@/ui/v2/List';
|
||||
import { ListItem } from '@/ui/v2/ListItem';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import DotsVerticalIcon from '@/ui/v2/icons/DotsVerticalIcon';
|
||||
import LockIcon from '@/ui/v2/icons/LockIcon';
|
||||
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||
import {
|
||||
useGetRolesQuery,
|
||||
useUpdateAppMutation,
|
||||
useUpdateAppMutation
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { Fragment } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -156,7 +156,7 @@ export default function RoleSettings() {
|
||||
className="px-0 my-2"
|
||||
slotProps={{ submitButton: { className: 'invisible' } }}
|
||||
>
|
||||
<div className="border-b-1 border-gray-200 px-4 py-3">
|
||||
<div className="px-4 py-3 border-gray-200 border-b-1">
|
||||
<Text className="font-medium">Name</Text>
|
||||
</div>
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function RoleSettings() {
|
||||
<Dropdown.Trigger
|
||||
asChild
|
||||
hideChevron
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2"
|
||||
className="absolute -translate-y-1/2 right-4 top-1/2"
|
||||
>
|
||||
<IconButton variant="borderless" color="secondary">
|
||||
<DotsVerticalIcon />
|
||||
@@ -257,7 +257,7 @@ export default function RoleSettings() {
|
||||
</List>
|
||||
|
||||
<Button
|
||||
className="justify-self-start mx-4"
|
||||
className="mx-4 justify-self-start"
|
||||
variant="borderless"
|
||||
startIcon={<PlusIcon />}
|
||||
onClick={handleOpenCreator}
|
||||
|
||||
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() {
|
||||
const user = nhost.auth.getUser();
|
||||
const { data, loading, stopPolling } = useGetWorkspacesQuery({
|
||||
pollInterval: 1000,
|
||||
});
|
||||
const { data, loading, startPolling, stopPolling } = useGetWorkspacesQuery();
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(1000);
|
||||
}, [startPolling]);
|
||||
|
||||
// keep polling for workspaces until there is a workspace available.
|
||||
// We do this because when a user signs up a workspace is created automatically
|
||||
|
||||
@@ -4,20 +4,39 @@ fragment RemoteAppGetUsers on users {
|
||||
displayName
|
||||
avatarUrl
|
||||
email
|
||||
emailVerified
|
||||
phoneNumber
|
||||
phoneNumberVerified
|
||||
disabled
|
||||
defaultRole
|
||||
lastSeen
|
||||
locale
|
||||
roles {
|
||||
id
|
||||
role
|
||||
}
|
||||
userProviders {
|
||||
id
|
||||
providerId
|
||||
}
|
||||
disabled
|
||||
}
|
||||
|
||||
query remoteAppGetUsers($where: users_bool_exp!, $limit: Int!, $offset: Int!) {
|
||||
users(where: $where, limit: $limit, offset: $offset) {
|
||||
users(
|
||||
where: $where
|
||||
limit: $limit
|
||||
offset: $offset
|
||||
order_by: { createdAt: desc }
|
||||
) {
|
||||
...RemoteAppGetUsers
|
||||
}
|
||||
usersAggregate(where: $where) {
|
||||
# make same where query here
|
||||
filteredUsersAggreggate: usersAggregate(where: $where) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
usersAggregate {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
|
||||
@@ -18,12 +18,15 @@ export default function useProjectRedirectWhenReady(
|
||||
options: UseProjectRedirectWhenReadyOptions = {},
|
||||
) {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { data, client, ...rest } = useGetApplicationStateQuery({
|
||||
pollInterval: 2000,
|
||||
const { data, client, startPolling, ...rest } = useGetApplicationStateQuery({
|
||||
...options,
|
||||
variables: { ...options.variables, appId: currentApplication.id },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(options.pollInterval || 2000);
|
||||
}, [options.pollInterval, startPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
async function updateOwnCache() {
|
||||
await client.refetchQueries({
|
||||
|
||||
@@ -21,11 +21,12 @@ export function useCheckProvisioning() {
|
||||
const [currentApplicationState, setCurrentApplicationState] =
|
||||
useState<ApplicationStateMetadata>({ state: ApplicationStatus.Empty });
|
||||
const isPlatform = useIsPlatform();
|
||||
const { data, stopPolling, client } = useGetApplicationStateQuery({
|
||||
variables: { appId: currentApplication.id },
|
||||
pollInterval: 2000,
|
||||
skip: !isPlatform,
|
||||
});
|
||||
|
||||
const { data, startPolling, stopPolling, client } =
|
||||
useGetApplicationStateQuery({
|
||||
variables: { appId: currentApplication?.id },
|
||||
skip: !isPlatform || !currentApplication?.id,
|
||||
});
|
||||
|
||||
async function updateOwnCache() {
|
||||
await client.refetchQueries({
|
||||
@@ -35,6 +36,10 @@ export function useCheckProvisioning() {
|
||||
|
||||
const memoizedUpdateCache = useCallback(updateOwnCache, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(2000);
|
||||
}, [startPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
@@ -81,7 +86,13 @@ export function useCheckProvisioning() {
|
||||
stopPolling();
|
||||
memoizedUpdateCache();
|
||||
}
|
||||
}, [data, stopPolling, memoizedUpdateCache, currentApplication.id]);
|
||||
}, [
|
||||
data,
|
||||
stopPolling,
|
||||
memoizedUpdateCache,
|
||||
currentApplication.id,
|
||||
currentApplicationState.state,
|
||||
]);
|
||||
|
||||
return currentApplicationState;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export function useRemoteApplicationGQLClient() {
|
||||
: currentApplication?.hasuraGraphqlAdminSecret,
|
||||
},
|
||||
}),
|
||||
|
||||
}),
|
||||
[
|
||||
currentApplication?.subdomain,
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function LogsPage() {
|
||||
}, [subscribeToMoreLogs, toDate, client]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<RetryableErrorBoundary>
|
||||
<LogsHeader
|
||||
fromDate={fromDate}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Container from '@/components/layout/Container';
|
||||
import SettingsLayout from '@/components/settings/SettingsLayout';
|
||||
import AllowedEmailDomainsSettings from '@/components/settings/authentication/AllowedEmailSettings';
|
||||
import AllowedRedirectURLsSettings from '@/components/settings/authentication/AllowedRedirectURLsSettings';
|
||||
import BlockedEmailSettings from '@/components/settings/authentication/BlockedEmailSettings';
|
||||
@@ -6,7 +7,6 @@ import ClientURLSettings from '@/components/settings/authentication/ClientURLSet
|
||||
import DisableNewUsersSettings from '@/components/settings/authentication/DisableNewUsersSettings';
|
||||
import GravatarSettings from '@/components/settings/authentication/GravatarSettings';
|
||||
import MFASettings from '@/components/settings/authentication/MFASettings';
|
||||
import SettingsLayout from '@/components/settings/SettingsLayout';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import { useGetAppQuery } from '@/utils/__generated__/graphql';
|
||||
@@ -37,7 +37,7 @@ export default function SettingsAuthenticationPage() {
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-y-6 bg-transparent"
|
||||
className="grid max-w-5xl grid-flow-row bg-transparent gap-y-6"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<ClientURLSettings />
|
||||
|
||||
@@ -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 ProjectLayout from '@/components/layout/ProjectLayout';
|
||||
import UsersList from '@/components/users/UsersList';
|
||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
|
||||
import { NhostApolloProvider } from '@nhost/react-apollo';
|
||||
import type { ReactElement } from 'react';
|
||||
import UsersBody from '@/components/users/UsersBody';
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||
import UserIcon from '@/ui/v2/icons/UserIcon';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import { useRemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import { SearchIcon } from '@heroicons/react/solid';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Router, { useRouter } from 'next/router';
|
||||
import type { ChangeEvent, ReactElement } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export type RemoteAppUser = Exclude<
|
||||
RemoteAppGetUsersQuery['users'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export default function UsersPage() {
|
||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||
const [searchString, setSearchString] = useState<string>('');
|
||||
|
||||
if (!currentApplication) {
|
||||
return <LoadingScreen />;
|
||||
const limit = useRef(25);
|
||||
const router = useRouter();
|
||||
const [nrOfPages, setNrOfPages] = useState(
|
||||
parseInt(router.query.page as string, 10) || 1,
|
||||
);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(
|
||||
parseInt(router.query.page as string, 10) || 1,
|
||||
);
|
||||
|
||||
const offset = useMemo(() => currentPage - 1, [currentPage]);
|
||||
|
||||
const remoteAppGetUserVariables = useMemo(
|
||||
() => ({
|
||||
where:
|
||||
router.query.userId !== undefined
|
||||
? {
|
||||
id: {
|
||||
_eq: searchString,
|
||||
},
|
||||
}
|
||||
: {
|
||||
_or: [
|
||||
{
|
||||
displayName: {
|
||||
_ilike: `%${searchString}%`,
|
||||
},
|
||||
},
|
||||
{
|
||||
email: {
|
||||
_ilike: `%${searchString}%`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
limit: limit.current,
|
||||
offset: offset * limit.current,
|
||||
}),
|
||||
[router.query.userId, searchString, offset],
|
||||
);
|
||||
|
||||
const {
|
||||
data: dataRemoteAppUsers,
|
||||
refetch: refetchProjectUsers,
|
||||
loading: loadingRemoteAppUsersQuery,
|
||||
} = useRemoteAppGetUsersQuery({
|
||||
variables: remoteAppGetUserVariables,
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
|
||||
/**
|
||||
* This function will remove query params from the URL.
|
||||
*
|
||||
* @remarks This function is used when we want to update the URL query
|
||||
* params without refreshing the page. For example if we want to remove
|
||||
* the page query param from the URL when we are on the first page.
|
||||
*
|
||||
* @param removeList - List of query params we want to remove from the URL
|
||||
*/
|
||||
const removeQueryParamsFromRouter = useCallback(
|
||||
(removeList: string[] = []) => {
|
||||
if (removeList.length > 0) {
|
||||
removeList.forEach(
|
||||
(param: string | number) => delete Router.query[param],
|
||||
);
|
||||
} else {
|
||||
// Remove all
|
||||
Object.keys(Router.query).forEach(
|
||||
(param) => delete Router.query[param],
|
||||
);
|
||||
}
|
||||
Router.replace(
|
||||
{
|
||||
pathname: Router.pathname,
|
||||
query: Router.query,
|
||||
},
|
||||
undefined,
|
||||
/**
|
||||
* Do not refresh the page
|
||||
*/
|
||||
{ shallow: true },
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* If a user of the app enters the users tab with a page query param of the following structure:
|
||||
* `users?page=2` this useEffect will update the current page to 2.
|
||||
* which in turn will update the offset and trigger fetching the data with the new variables.
|
||||
* If the user enters a page number that is greater than the number of pages we will redirect
|
||||
* the user to the first page and update the URL.
|
||||
*
|
||||
* @remarks If the user navigates the page back and forth we handle the URL change through
|
||||
* props passed to the Pagination component.
|
||||
* @see {@link Pagination}
|
||||
*
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (router.query.page === undefined) {
|
||||
setCurrentPage(1);
|
||||
return;
|
||||
}
|
||||
if (router.query.page && typeof router.query.page === 'string') {
|
||||
const pageNumber = parseInt(router.query.page, 10);
|
||||
if (nrOfPages >= pageNumber) {
|
||||
setCurrentPage(pageNumber);
|
||||
} else {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}
|
||||
}, [nrOfPages, router.query.page]);
|
||||
|
||||
/**
|
||||
* If the user is on the first page, we want to remove the page query param from the URL.
|
||||
* e.g. `users?page=1` -> `users`
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (currentPage === 1) {
|
||||
removeQueryParamsFromRouter(['page']);
|
||||
}
|
||||
}, [currentPage, removeQueryParamsFromRouter]);
|
||||
|
||||
/**
|
||||
* If the users enters the page with a page query param with the following structure:
|
||||
* `users?userId=<id>` this useEffect will update the search string to the id.
|
||||
* which in turn will trigger fetching the data with the new variables.
|
||||
*
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (router.query.userId && typeof router.query.userId === 'string') {
|
||||
setSearchString(router.query.userId);
|
||||
}
|
||||
}, [router.query.userId]);
|
||||
|
||||
/**
|
||||
* We want to update the number of pages when the data changes
|
||||
* (either fetch for the first time or making a search).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (loadingRemoteAppUsersQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userCount = searchString
|
||||
? dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count
|
||||
: dataRemoteAppUsers?.usersAggregate?.aggregate?.count;
|
||||
|
||||
setNrOfPages(Math.ceil(userCount / limit.current));
|
||||
}, [
|
||||
dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count,
|
||||
dataRemoteAppUsers?.usersAggregate.aggregate.count,
|
||||
loadingRemoteAppUsersQuery,
|
||||
searchString,
|
||||
]);
|
||||
|
||||
const handleSearchStringChange = useMemo(
|
||||
() =>
|
||||
debounce((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentPage(1);
|
||||
setSearchString(event.target.value);
|
||||
}, 1000),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => handleSearchStringChange.cancel(),
|
||||
[handleSearchStringChange],
|
||||
);
|
||||
|
||||
function handleViewUser() {
|
||||
openDialog('CREATE_USER', {
|
||||
title: 'Create User',
|
||||
payload: {
|
||||
onSuccess: async () => {
|
||||
await refetchProjectUsers();
|
||||
closeDialog();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const users = useMemo(
|
||||
() => dataRemoteAppUsers?.users.map((user) => user) ?? [],
|
||||
[dataRemoteAppUsers],
|
||||
);
|
||||
|
||||
const usersCount = useMemo(
|
||||
() => dataRemoteAppUsers?.usersAggregate?.aggregate?.count ?? -1,
|
||||
[dataRemoteAppUsers],
|
||||
);
|
||||
|
||||
const thereAreUsers =
|
||||
dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count ||
|
||||
usersCount <= 0;
|
||||
|
||||
if (loadingRemoteAppUsersQuery) {
|
||||
return (
|
||||
<Container className="mx-auto overflow-x-hidden max-w-9xl">
|
||||
<div className="flex flex-row place-content-between">
|
||||
<Input
|
||||
className="rounded-sm"
|
||||
placeholder="Search users"
|
||||
startAdornment={
|
||||
<SearchIcon className="w-4 h-4 ml-2 -mr-1 text-greyscaleGrey shrink-0" />
|
||||
}
|
||||
onChange={handleSearchStringChange}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleViewUser}
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
className="grid h-full grid-flow-col gap-1 p-2 place-items-center"
|
||||
size="small"
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-screen h-screen overflow-hidden">
|
||||
<div className="absolute top-0 left-0 z-50 block w-full h-full">
|
||||
<span className="relative block mx-auto my-0 top50percent top-1/2">
|
||||
<ActivityIndicator
|
||||
label="Loading users..."
|
||||
className="flex items-center justify-center my-auto"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NhostApolloProvider
|
||||
graphqlUrl={generateAppServiceUrl(
|
||||
currentApplication.subdomain,
|
||||
currentApplication.region.awsName,
|
||||
'graphql',
|
||||
<Container className="mx-auto space-y-5 overflow-x-hidden max-w-9xl">
|
||||
<div className="flex flex-row place-content-between">
|
||||
<Input
|
||||
className="rounded-sm"
|
||||
placeholder="Search users"
|
||||
startAdornment={
|
||||
<SearchIcon className="w-4 h-4 ml-2 -mr-1 text-greyscaleGrey shrink-0" />
|
||||
}
|
||||
onChange={handleSearchStringChange}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleViewUser}
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
className="grid h-full grid-flow-col gap-1 p-2 place-items-center"
|
||||
size="small"
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
{usersCount === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-48 py-12 space-y-5 border rounded-lg shadow-sm border-veryLightGray">
|
||||
<UserIcon strokeWidth={1} className="w-10 h-10 text-greyscaleGrey" />
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Text className="font-medium text-center" variant="h3">
|
||||
There are no users yet
|
||||
</Text>
|
||||
<Text variant="subtitle1" className="text-center">
|
||||
All users for your project will be listed here.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-row place-content-between rounded-lg lg:w-[230px]">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-full"
|
||||
aria-label="Create User"
|
||||
onClick={handleViewUser}
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-flow-row gap-2 lg:w-9xl">
|
||||
<div className="grid w-full h-full grid-flow-row pb-4 overflow-hidden">
|
||||
<div className="grid w-full p-2 border-b md:grid-cols-6">
|
||||
<Text className="font-medium md:col-span-2">Name</Text>
|
||||
<Text className="hidden font-medium md:block">Signed up at</Text>
|
||||
<Text className="hidden font-medium md:block">Last Seen</Text>
|
||||
<Text className="hidden col-span-2 font-medium md:block">
|
||||
OAuth Providers
|
||||
</Text>
|
||||
</div>
|
||||
{dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count ===
|
||||
0 &&
|
||||
usersCount !== 0 && (
|
||||
<div className="flex flex-col items-center justify-center px-48 py-12 space-y-5 border-b border-l border-r border-veryLightGray">
|
||||
<UserIcon
|
||||
strokeWidth={1}
|
||||
className="w-10 h-10 text-greyscaleGrey"
|
||||
/>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Text className="font-medium text-center" variant="h3">
|
||||
No results for "{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"
|
||||
headers={{
|
||||
'x-hasura-admin-secret':
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? 'nhost-admin-secret'
|
||||
: currentApplication.hasuraGraphqlAdminSecret,
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
<UsersList />
|
||||
</Container>
|
||||
</NhostApolloProvider>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@ export const theme = createTheme({
|
||||
main: '#f13154',
|
||||
dark: '#c91737',
|
||||
},
|
||||
success: {
|
||||
main: 'rgba(170, 240, 204, 0.3)',
|
||||
contrastText: '#3BB174',
|
||||
},
|
||||
action: {
|
||||
hover: '#f3f4f6',
|
||||
active: '#f3f4f6',
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum AvailableLogsServices {
|
||||
AUTH = 'hasura-auth',
|
||||
STORAGE = 'hasura-storage',
|
||||
HASURA = 'hasura',
|
||||
FUNCTIONS = 'functions',
|
||||
}
|
||||
|
||||
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(`${name} copied to clipboard.`);
|
||||
triggerToast(`${name} copied to clipboard`);
|
||||
}
|
||||
|
||||
export default copy;
|
||||
|
||||
@@ -25,6 +25,10 @@ export const availableServices: {
|
||||
label: 'Hasura',
|
||||
value: AvailableLogsServices.HASURA,
|
||||
},
|
||||
{
|
||||
label: 'Functions',
|
||||
value: AvailableLogsServices.FUNCTIONS,
|
||||
},
|
||||
];
|
||||
|
||||
export const logsCustomIntervals: LogsCustomInterval[] = [
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
||||
import { useGetUserAllWorkspacesQuery } from '@/generated/graphql';
|
||||
import { useWithin } from '@/hooks/useWithin';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useUserFirstWorkspace = () => {
|
||||
const { workspaceContext } = useWorkspaceContext();
|
||||
const { within } = useWithin();
|
||||
const fetch = !!workspaceContext.slug || !within;
|
||||
|
||||
const { loading, error, data, stopPolling, client } =
|
||||
const { loading, error, data, startPolling, stopPolling, client } =
|
||||
useGetUserAllWorkspacesQuery({
|
||||
pollInterval: 1000,
|
||||
skip: fetch,
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
if (!!workspaceContext.slug || !within) {
|
||||
useEffect(() => {
|
||||
startPolling(1000);
|
||||
}, [startPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceContext.slug && within) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopPolling();
|
||||
}
|
||||
}, [workspaceContext.slug, within, stopPolling]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @nhost-examples/react-apollo
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 85683547: Allow `useFileUpload` to be reused
|
||||
Once a file were uploaded with `useFileUpload`, it was not possible to reuse it as the returned file id were kept in memory and sent again to hasura-storage, leading to a conflict error.
|
||||
File upload now makes sure to clear the metadata information from the first file before uploading the second file.
|
||||
- Updated dependencies [1be6d324]
|
||||
- Updated dependencies [2e8f73df]
|
||||
- Updated dependencies [85683547]
|
||||
- @nhost/react@1.12.1
|
||||
- @nhost/react-apollo@4.12.1
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -17,6 +17,33 @@ context('File uploads', () => {
|
||||
.contains('Successfully uploaded')
|
||||
})
|
||||
|
||||
it('should upload two files using the same single file uploader', () => {
|
||||
cy.signUpAndConfirmEmail()
|
||||
cy.findByRole('button', { name: /Storage/i }).click()
|
||||
cy.findByRole('button', { name: /Drag a file here or click to select/i })
|
||||
.children('input[type=file]')
|
||||
.selectFile(
|
||||
{
|
||||
contents: Cypress.Buffer.from('file contents'),
|
||||
fileName: 'file.txt',
|
||||
mimeType: 'text/plain',
|
||||
lastModified: Date.now()
|
||||
},
|
||||
{ force: true }
|
||||
)
|
||||
.selectFile(
|
||||
{
|
||||
contents: Cypress.Buffer.from('file contents'),
|
||||
fileName: 'file.txt',
|
||||
mimeType: 'text/plain',
|
||||
lastModified: Date.now()
|
||||
},
|
||||
{ force: true }
|
||||
)
|
||||
.parent()
|
||||
.contains('Successfully uploaded')
|
||||
})
|
||||
|
||||
it('should upload multiple files', () => {
|
||||
const files: Required<Cypress.FileReferenceObject>[] = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/react-apollo",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.6.9",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @nhost/apollo
|
||||
|
||||
## 4.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/nhost-js@1.12.1
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/apollo",
|
||||
"version": "0.6.0",
|
||||
"version": "4.12.1",
|
||||
"description": "Nhost Apollo Client library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# @nhost/react-apollo
|
||||
|
||||
## 4.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1be6d324]
|
||||
- Updated dependencies [2e8f73df]
|
||||
- Updated dependencies [85683547]
|
||||
- @nhost/react@1.12.1
|
||||
- @nhost/apollo@4.12.1
|
||||
|
||||
## 4.12.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/react-apollo",
|
||||
"version": "4.12.0",
|
||||
"version": "4.12.1",
|
||||
"description": "Nhost React Apollo client",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# @nhost/react-urql
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1be6d324]
|
||||
- Updated dependencies [2e8f73df]
|
||||
- Updated dependencies [85683547]
|
||||
- @nhost/react@1.12.1
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/react-urql",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Nhost React URQL client",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @nhost/hasura-storage-js
|
||||
|
||||
## 1.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 85683547: Allow `useFileUpload` to be reused
|
||||
Once a file were uploaded with `useFileUpload`, it was not possible to reuse it as the returned file id were kept in memory and sent again to hasura-storage, leading to a conflict error.
|
||||
File upload now makes sure to clear the metadata information from the first file before uploading the second file.
|
||||
|
||||
## 1.12.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/hasura-storage-js",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.1",
|
||||
"description": "Hasura-storage client",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -28,7 +28,14 @@ export type FileUploadEvents =
|
||||
| { type: 'CANCEL' }
|
||||
| { type: 'DESTROY' }
|
||||
|
||||
export const INITIAL_FILE_CONTEXT: FileUploadContext = { progress: null, loaded: 0, error: null }
|
||||
export const INITIAL_FILE_CONTEXT: FileUploadContext = {
|
||||
progress: null,
|
||||
loaded: 0,
|
||||
error: null,
|
||||
bucketId: undefined,
|
||||
file: undefined,
|
||||
id: undefined
|
||||
}
|
||||
|
||||
export type FileUploadMachine = ReturnType<typeof createFileUploadMachine>
|
||||
export const createFileUploadMachine = () =>
|
||||
@@ -62,8 +69,20 @@ export const createFileUploadMachine = () =>
|
||||
},
|
||||
invoke: { src: 'uploadFile' }
|
||||
},
|
||||
uploaded: { entry: ['setFileMetadata', 'sendDone'] },
|
||||
error: { entry: ['setError', 'sendError'] },
|
||||
uploaded: {
|
||||
entry: ['setFileMetadata', 'sendDone'],
|
||||
on: {
|
||||
ADD: { actions: 'addFile', target: 'idle' },
|
||||
UPLOAD: { actions: 'resetContext', target: 'uploading' }
|
||||
}
|
||||
},
|
||||
error: {
|
||||
entry: ['setError', 'sendError'],
|
||||
on: {
|
||||
ADD: { actions: 'addFile', target: 'idle' },
|
||||
UPLOAD: { actions: 'resetContext', target: 'uploading' }
|
||||
}
|
||||
},
|
||||
stopped: { type: 'final' }
|
||||
}
|
||||
},
|
||||
@@ -88,6 +107,7 @@ export const createFileUploadMachine = () =>
|
||||
sendDestroy: () => {},
|
||||
sendDone: () => {},
|
||||
resetProgress: assign({ progress: (_) => null, loaded: (_) => 0 }),
|
||||
resetContext: assign((_) => INITIAL_FILE_CONTEXT),
|
||||
addFile: assign({
|
||||
file: (_, { file }) => file,
|
||||
bucketId: (_, { bucketId }) => bucketId,
|
||||
|
||||
@@ -10,13 +10,14 @@ export interface Typegen0 {
|
||||
}
|
||||
missingImplementations: {
|
||||
actions: never
|
||||
services: never
|
||||
guards: never
|
||||
delays: never
|
||||
guards: never
|
||||
services: never
|
||||
}
|
||||
eventsCausingActions: {
|
||||
addFile: 'ADD'
|
||||
incrementProgress: 'UPLOAD_PROGRESS'
|
||||
resetContext: 'UPLOAD'
|
||||
resetProgress: 'UPLOAD'
|
||||
sendDestroy: 'DESTROY'
|
||||
sendDone: 'UPLOAD_DONE'
|
||||
@@ -25,13 +26,13 @@ export interface Typegen0 {
|
||||
setError: 'UPLOAD_ERROR'
|
||||
setFileMetadata: 'UPLOAD_DONE'
|
||||
}
|
||||
eventsCausingServices: {
|
||||
uploadFile: 'UPLOAD'
|
||||
}
|
||||
eventsCausingDelays: {}
|
||||
eventsCausingGuards: {
|
||||
hasFile: 'UPLOAD'
|
||||
}
|
||||
eventsCausingDelays: {}
|
||||
eventsCausingServices: {
|
||||
uploadFile: 'UPLOAD'
|
||||
}
|
||||
matchesStates: 'error' | 'idle' | 'stopped' | 'uploaded' | 'uploading'
|
||||
tags: never
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# @nhost/nextjs
|
||||
|
||||
## 1.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1be6d324]
|
||||
- Updated dependencies [2e8f73df]
|
||||
- Updated dependencies [85683547]
|
||||
- @nhost/react@1.12.1
|
||||
|
||||
## 1.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/nextjs",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.1",
|
||||
"description": "Nhost NextJS library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @nhost/nhost-js
|
||||
|
||||
## 1.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [85683547]
|
||||
- @nhost/hasura-storage-js@1.12.1
|
||||
|
||||
## 1.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/nhost-js",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.1",
|
||||
"description": "Nhost JavaScript SDK",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# @nhost/react
|
||||
|
||||
## 1.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1be6d324: Only export what is required by the user or `@nhost/nextjs`
|
||||
- 2e8f73df: Improve the error message when the application is not wrapped in `<NhostProvider></NhostProvider>`
|
||||
- 85683547: Allow `useFileUpload` to be reused
|
||||
Once a file were uploaded with `useFileUpload`, it was not possible to reuse it as the returned file id were kept in memory and sent again to hasura-storage, leading to a conflict error.
|
||||
File upload now makes sure to clear the metadata information from the first file before uploading the second file.
|
||||
- @nhost/nhost-js@1.12.1
|
||||
|
||||
## 1.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/react",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.1",
|
||||
"description": "Nhost React library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import {
|
||||
AuthMachine,
|
||||
BackendUrl,
|
||||
NhostAuthConstructorParams,
|
||||
NhostClient as VanillaNhostClient,
|
||||
NhostClient as _VanillaNhostClient,
|
||||
NhostSession,
|
||||
NHOST_REFRESH_TOKEN_KEY,
|
||||
Subdomain
|
||||
} from '@nhost/nhost-js'
|
||||
|
||||
export * from '@nhost/nhost-js'
|
||||
export { VanillaNhostClient }
|
||||
export type { NhostSession, NhostAuthConstructorParams, AuthMachine, Subdomain, BackendUrl }
|
||||
export { NHOST_REFRESH_TOKEN_KEY }
|
||||
|
||||
/** @internal */
|
||||
export const VanillaNhostClient = _VanillaNhostClient
|
||||
|
||||
export interface NhostReactClientConstructorParams
|
||||
extends Partial<BackendUrl>,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NhostReactContext } from './provider'
|
||||
export const useAuthInterpreter = (): InterpreterFrom<AuthMachine> => {
|
||||
const nhost = useContext(NhostReactContext)
|
||||
const interpreter = nhost.auth?.client.interpreter
|
||||
if (!interpreter) throw Error('No interpreter')
|
||||
if (!interpreter)
|
||||
throw Error('Could not find the Nhost auth client. Did you wrap your app in <NhostProvider />?')
|
||||
return interpreter
|
||||
}
|
||||
|
||||
@@ -75,10 +75,7 @@ export const useFileUploadItem = (
|
||||
url: nhost.storage.url,
|
||||
accessToken: nhost.auth.getAccessToken(),
|
||||
adminSecret: nhost.adminSecret,
|
||||
file: params.file,
|
||||
bucketId: params.bucketId || bucketId,
|
||||
id,
|
||||
name
|
||||
...params
|
||||
},
|
||||
ref
|
||||
)
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# @nhost/vue
|
||||
|
||||
## 1.12.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 85683547: Allow `useFileUpload` to be reused
|
||||
Once a file were uploaded with `useFileUpload`, it was not possible to reuse it as the returned file id were kept in memory and sent again to hasura-storage, leading to a conflict error.
|
||||
File upload now makes sure to clear the metadata information from the first file before uploading the second file.
|
||||
- @nhost/nhost-js@1.12.1
|
||||
|
||||
## 1.12.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/vue",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.1",
|
||||
"description": "Nhost Vue library",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||
@@ -75,10 +75,7 @@ export const useFileUploadItem = (
|
||||
url: nhost.storage.url,
|
||||
accessToken: nhost.auth.getAccessToken(),
|
||||
adminSecret: nhost.adminSecret,
|
||||
file: params.file,
|
||||
bucketId: params.bucketId || bucketId,
|
||||
id,
|
||||
name
|
||||
...params
|
||||
},
|
||||
ref
|
||||
)
|
||||
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -77,7 +77,7 @@ importers:
|
||||
|
||||
dashboard:
|
||||
specifiers:
|
||||
'@apollo/client': ^3.6.2
|
||||
'@apollo/client': ^3.7.3
|
||||
'@babel/core': ^7.20.2
|
||||
'@codemirror/language': ^6.3.0
|
||||
'@emotion/cache': ^11.10.5
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"outputs": ["dist/**"],
|
||||
"env": ["VITE_NHOST_SUBDOMAIN", "VITE_NHOST_REGION"]
|
||||
},
|
||||
"@nhost/docs#build": {
|
||||
"cache": false
|
||||
},
|
||||
"@nhost/dashboard#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**"],
|
||||
|
||||
Reference in New Issue
Block a user