Compare commits

..

113 Commits

Author SHA1 Message Date
Szilárd Dóró
ca090436af Merge pull request #1469 from nhost/changeset-release/main 2023-01-04 22:51:49 +01:00
github-actions[bot]
3fb12c189b chore: update versions 2023-01-04 19:22:01 +00:00
Guido Curcio
c4d5366b22 Merge pull request #1468 from nhost/fix/twitter 2023-01-04 14:20:19 -05:00
Szilárd Dóró
bd68e916cf chore(dashboard): cleanup unused GQL file 2023-01-04 20:04:52 +01:00
Szilárd Dóró
7cadd9447b fix(dashboard): display Twitter provider settings 2023-01-04 19:52:21 +01:00
Szilárd Dóró
b649f178e0 Merge pull request #1454 from nhost/changeset-release/main
chore: update versions
2023-01-04 14:43:52 +01:00
github-actions[bot]
1f5e1e3d42 chore: update versions 2023-01-03 18:20:01 +00:00
Nuno Pato
5727b0b0fe Merge pull request #1426 from nhost/feat/add-functions-to-logs-dashboard
feat(dashboard): add functions to logs
2023-01-03 17:18:24 -01:00
Pilou
2f38ed56f5 Merge pull request #1459 from nhost/fix/reuse-file-upload
fix: 🐛 allow useFileUpload to be reused
2023-01-03 13:41:20 +01:00
Guido Curcio
16502ea175 Merge pull request #1382 from nhost/feat/auth-management 2023-01-03 06:16:57 -05:00
Guido Curcio
beee0407df Merge branch 'main' into feat/auth-management 2023-01-03 05:13:46 -05:00
Guido Curcio
3990b1ffbb Update dashboard/src/components/users/CreateUserForm/CreateUserForm.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-03 06:12:22 -03:00
Guido Curcio
1fb03708e3 Update dashboard/src/components/users/EditUserPasswordForm/EditUserPasswordForm.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-03 06:12:15 -03:00
Szilárd Dóró
d42719ee65 Merge pull request #1453 from nhost/feat/custom-jwt-secret
feat(dashboard): add JWT secret editor modal
2023-01-03 09:30:09 +01:00
Guido Curcio
72ff489ea8 fix inconsistent padding in modals. 2023-01-03 03:43:11 -03:00
Pilou
c9bf2dde0e Merge pull request #1458 from nhost/refactor/do-not-over-export
Only export what is required by the user or `@nhost/nextjs`
2023-01-02 22:04:52 +01:00
Pierre-Louis Mercereau
613533d377 chore: do not cache documentation build 2023-01-02 21:22:54 +01:00
Pierre-Louis Mercereau
8568354718 fix: 🐛 allow useFileUpload to be reused 2023-01-02 21:03:58 +01:00
Pierre-Louis Mercereau
1be6d32455 Only export what is required by the user or @nhost/nextjs 2023-01-02 16:19:00 +01:00
Pilou
812a6e5005 Merge pull request #1452 from nhost/fix/improve-missing-react-provider-error
fix: improve missing React provider error
2023-01-02 15:54:48 +01:00
Szilárd Dóró
34cc230b61 fix(dashboard): improve responsive layout 2023-01-02 15:29:02 +01:00
Szilárd Dóró
898a7c835f chore(dashboard): improve JWT secret validation 2023-01-02 14:41:28 +01:00
Szilárd Dóró
7766624bc5 feat(dashboard): add JWT secret editor modal 2023-01-02 14:34:36 +01:00
Pierre-Louis Mercereau
2e8f73df38 chore: changeset 2023-01-02 14:25:29 +01:00
Pierre-Louis Mercereau
6a419e060e fix: improve missing React provider error 2023-01-02 14:23:49 +01:00
Guido Curcio
43480ca735 spacing on action buttons 2023-01-02 09:07:21 -03:00
Guido Curcio
efc42d77fd fix loading state on EditUserForm (drawer) 2023-01-02 08:42:45 -03:00
Guido Curcio
31e2523eca spacing on create user modal 2023-01-02 08:19:54 -03:00
Guido Curcio
fbf4f40ab7 Merge branch 'feat/auth-management' of https://github.com/nhost/nhost into feat/auth-management 2023-01-02 08:15:31 -03:00
Guido Curcio
cbe203e720 fix alt props and spacing on users table 2023-01-02 08:14:49 -03:00
Szilárd Dóró
378a6684b0 Merge pull request #1451 from nhost/changeset-release/main
chore: update versions
2023-01-02 11:33:20 +01:00
github-actions[bot]
1999ae09e6 chore: update versions 2023-01-02 09:47:28 +00:00
Szilárd Dóró
0fe48a0833 Merge pull request #1425 from nhost/fix/refresh-after-provisioning
fix(dashboard): provisioning status polling
2023-01-02 10:46:12 +01:00
Szilárd Dóró
52ccfdec89 Merge branch 'main' into fix/refresh-after-provisioning 2023-01-02 10:05:46 +01:00
Guido Curcio
70cfeb1fcf Update dashboard/src/components/users/UsersBody/UsersBody.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2022-12-23 12:38:12 -03:00
Guido Curcio
3116562b58 fix service urls; pagination props 2022-12-23 11:31:43 -03:00
Guido Curcio
693e40d385 Merge branch 'main' into feat/auth-management 2022-12-23 11:28:53 -03:00
Nuno Pato
9a1aa7bb2e changeset 2022-12-22 20:08:28 -01:00
Nuno Pato
98345f2e78 dashboard: add functions to the logs dashboard 2022-12-22 20:05:39 -01:00
Guido Curcio
f29abe6238 add changeset 2022-12-22 16:51:19 -03:00
Szilárd Dóró
8956d47bce fix(dashboard): start polling manually
reference issue: https://github.com/apollographql/apollo-client/issues/9819
2022-12-22 18:22:00 +01:00
Szilárd Dóró
dd0738d5f7 chore(dashboard): add changeset 2022-12-22 17:57:36 +01:00
Szilárd Dóró
11d77d6011 fix(dashboard): provisioning status polling 2022-12-22 17:53:07 +01:00
Guido Curcio
229c47cf16 set up redirect from /users/<userId> -> users?userId=<userId> 2022-12-22 10:04:56 -03:00
Guido Curcio
e5e705350d fix: style inconsistencies in users page 2022-12-22 09:56:37 -03:00
Guido Curcio
4f81b0695d add responsive breakpoints to users's table, user's drawer 2022-12-22 09:51:47 -03:00
Guido Curcio
800db1b300 remove page query param from url if currentPage is equal to 1. 2022-12-22 03:02:53 -03:00
Guido Curcio
a40baa8c63 show up to 3 providers in users table, render a chip with remainder length 2022-12-22 02:23:17 -03:00
Guido Curcio
5cc06609c2 fix(EditUserForm): copy button. 2022-12-22 01:51:17 -03:00
Guido Curcio
efa68aab83 fix(roles): only show alert when editing role. 2022-12-21 14:15:48 -03:00
Guido Curcio
3a696d366a clicking on auth button of sidebar goes to page 1 2022-12-21 13:55:15 -03:00
Guido Curcio
e3e21b6164 upper bound on number of pages 2022-12-21 13:44:50 -03:00
Guido Curcio
9259663c76 missing key prop on providers 2022-12-21 13:43:28 -03:00
Guido Curcio
26dd7faf05 retrigger effect when nr of pages changes 2022-12-21 13:31:30 -03:00
Guido Curcio
1b5cb93761 include alert when editing roles 2022-12-21 12:18:51 -03:00
Guido Curcio
b6df9e2e8c fix: correct onPageChange prop comments. 2022-12-21 08:21:39 -03:00
Guido Curcio
48f15eb849 remove unhelpful comments 2022-12-21 08:16:19 -03:00
Guido Curcio
141642d40d show only two providers in users table 2022-12-21 08:07:23 -03:00
Guido Curcio
def4a3a2ea fix: password change error handling, refetch, and closing dialog; createdAt in users body; use internal ban state for unbanning user; implement error handling for odd pages from URL & side-effect for user id queries; add comments to side-effects. 2022-12-21 08:02:50 -03:00
Guido Curcio
b876a4ada1 handle pagination between pages (parse & mutate url) 2022-12-20 11:02:35 -03:00
Guido Curcio
7e064355ba show banned users on users table 2022-12-20 10:47:27 -03:00
Guido Curcio
0f6ece6b8c remove pagination when there are no users from filter. 2022-12-20 10:28:46 -03:00
Guido Curcio
793e7392da error handling for creating users 2022-12-20 10:24:53 -03:00
Guido Curcio
a9bbd1303e close drawer after deleting user; track disabled state of an user. 2022-12-20 09:30:41 -03:00
Guido Curcio
b0ed2b6f14 button instead of IconButton for password change 2022-12-20 09:03:33 -03:00
Guido Curcio
3e951eab4f fix: don't hide the OAuth methods section; remove the active chip. 2022-12-20 08:42:13 -03:00
Guido Curcio
cd7a198715 fix: fix showing minutes instead of month; correct format. 2022-12-20 08:36:36 -03:00
Guido Curcio
7c4d05a25e fix: loading states, remove sign-in methods, fix default role, move handlers to specific component. 2022-12-20 07:15:43 -03:00
Guido Curcio
357e0933ff pass roles with payload; handle adding and removing roles from user 2022-12-17 03:18:53 -03:00
Guido Curcio
7d8f82b99d user.lastSeen fix 2022-12-16 09:36:22 -03:00
Guido Curcio
e10480b761 missing refetch when deleting user. 2022-12-15 15:02:05 -03:00
Guido Curcio
1343abbe50 comments & types 2022-12-15 12:33:04 -03:00
Guido Curcio
fa37546139 handle banning users 2022-12-15 11:22:55 -03:00
Guido Curcio
112526a984 render email & password only if email and no social oauth. 2022-12-15 11:04:40 -03:00
Guido Curcio
0c74806245 fix showing limit in pagination rather than total users. 2022-12-14 13:31:47 -03:00
Guido Curcio
5df84d7f50 fix remoteAppUser type 2022-12-14 13:08:39 -03:00
Guido Curcio
d58de8fcf8 join totalUsers query with main users query 2022-12-14 12:55:49 -03:00
Guido Curcio
85674c4d90 handle users providers other than email (e.g. github); handle removed providers from a user. 2022-12-14 11:32:28 -03:00
Guido Curcio
5a1d3b9bfc handle anonymous users 2022-12-14 11:06:35 -03:00
Guido Curcio
942570ed29 show table header on not found search strings 2022-12-14 10:33:29 -03:00
Guido Curcio
3058eee48f 25 limit, loading screen 2022-12-14 09:21:14 -03:00
Guido Curcio
bacb1b9720 fix number of pages going to 0 on nextPageClick 2022-12-13 11:53:42 -03:00
Guido Curcio
e119e4fc18 styles on userBody 2022-12-13 11:19:23 -03:00
Guido Curcio
b1fe2be963 users body loader, pagination elemnts; input fix for pages > 9 2022-12-13 09:07:36 -03:00
Guido Curcio
9b6e8ab3bc slotProps for pagination component 2022-12-13 07:50:43 -03:00
Guido Curcio
d2c4b7cad1 custom styles for pagination in users tab 2022-12-13 02:26:32 -03:00
Guido Curcio
59d737696a onChangePage handler for Pagination 2022-12-13 02:13:57 -03:00
Guido Curcio
35cd76e562 UsersBodyProps types 2022-12-12 11:16:56 -03:00
Guido Curcio
266bbe837d type users, add comments UsersBodyProps, memo limit & offset. 2022-12-12 11:15:31 -03:00
Guido Curcio
caf785a938 feat: Pagination common component. 2022-12-12 10:48:06 -03:00
Guido Curcio
96f9c1a55d handle pagination when searching for users 2022-12-12 06:08:35 -03:00
Guido Curcio
731460b20d handle pagination in users' table 2022-12-12 06:05:22 -03:00
Guido Curcio
1537d46b1d handle states of search: case insensitive query, preserve search query. 2022-12-12 04:29:12 -03:00
Guido Curcio
632def158d render activated providers from users. 2022-12-12 02:38:53 -03:00
Guido Curcio
39271a67e2 render all available roles from the app, check roles form user. 2022-12-12 02:11:43 -03:00
Guido Curcio
9e25c4f386 render verified checkbox as helpertext except when errors; disable prop on phoneNumberVerified when no phoneNumber. 2022-12-12 01:26:48 -03:00
Guido Curcio
dd58a4ac7f handle mutation of displayName, email, and avatarUrl; render avatar url if not default=blank. 2022-12-12 01:14:04 -03:00
Guido Curcio
b9c3567baa update gql query with locale, lastSeen, emailVerified, and phoneNumberVerified 2022-12-12 00:56:00 -03:00
Guido Curcio
108937789a EditUserForm: roles & sign-in methods styles. 2022-12-12 00:20:59 -03:00
Guido Curcio
e651745a7e no users found; default & allowed roles 2022-12-11 23:15:29 -03:00
Guido Curcio
6091b4a8e8 handle delete users & refetch main users query 2022-12-09 06:46:01 -03:00
Guido Curcio
82ddcbd180 handle search strings, static UsersBodyHeader, nullish render on no users 2022-12-09 03:48:34 -03:00
Guido Curcio
8aa7aafa3b add form footer to EditUserForm 2022-12-09 03:10:23 -03:00
Guido Curcio
183cb4b26a EditUserPasswordForm: handle change password edits, remote gql client, toast on edit. 2022-12-09 03:00:20 -03:00
Guido Curcio
3a7377c6e2 EditUserForm: add avatar with letters, change password button & modal. 2022-12-08 23:15:05 -03:00
Guido Curcio
1529f58c33 fetch remote project users on page load 2022-12-08 23:00:26 -03:00
Guido Curcio
95af5421d1 UsersBody: render first two letters of displayname if no avatar 2022-12-08 22:51:11 -03:00
Guido Curcio
feb39404db first section of user's drawer 2022-12-08 22:26:54 -03:00
Guido Curcio
15b3100c63 pass on data from previously fetched users table 2022-12-08 12:17:22 -03:00
Guido Curcio
f7ef7d106d open drawer on edit user 2022-12-08 11:18:11 -03:00
Guido Curcio
72c31622cd render user's table with dropdown trigger 2022-12-08 09:25:07 -03:00
Guido Curcio
6959461e3f handle creating users (CreateUserForm) 2022-12-08 08:40:13 -03:00
Guido Curcio
103472ac77 empty state for new users page 2022-12-08 07:57:42 -03:00
70 changed files with 2295 additions and 989 deletions

View File

@@ -1,5 +1,30 @@
# @nhost/dashboard
## 0.8.1
### Patch Changes
- 7cadd944: fix(dashboard): display Twitter provider settings
## 0.8.0
### Minor Changes
- 9a1aa7bb: add functions to the log dashboard
- f29abe62: feat(dashboard): Users Management v2
### Patch Changes
- 7766624b: feat(dashboard): add JWT secret editor modal
- @nhost/react-apollo@4.12.1
- @nhost/nextjs@1.12.1
## 0.7.13
### Patch Changes
- dd0738d5: fix(dashboard): provisioning status polling
## 0.7.12
### Patch Changes

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.7.12",
"version": "0.8.1",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -17,7 +17,7 @@
"build-storybook": "build-storybook"
},
"dependencies": {
"@apollo/client": "^3.6.2",
"@apollo/client": "^3.7.3",
"@codemirror/language": "^6.3.0",
"@emotion/cache": "^11.10.5",
"@emotion/react": "^11.10.5",

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,10 +15,14 @@ export type DialogType =
| 'EDIT_FOREIGN_KEY'
| 'CREATE_ROLE'
| 'EDIT_ROLE'
| 'CREATE_USER'
| 'CREATE_PERMISSION_VARIABLE'
| 'EDIT_PERMISSION_VARIABLE'
| 'CREATE_ENVIRONMENT_VARIABLE'
| 'EDIT_ENVIRONMENT_VARIABLE';
| 'EDIT_ENVIRONMENT_VARIABLE'
| 'EDIT_USER'
| 'EDIT_USER_PASSWORD'
| 'EDIT_JWT_SECRET';
export interface DialogConfig<TPayload = unknown> {
/**

View File

@@ -3,10 +3,14 @@ import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm'
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
import CreatePermissionVariableForm from '@/components/settings/permissions/CreatePermissionVariableForm';
import EditPermissionVariableForm from '@/components/settings/permissions/EditPermissionVariableForm';
import CreateRoleForm from '@/components/settings/roles/CreateRoleForm';
import EditRoleForm from '@/components/settings/roles/EditRoleForm';
import CreateUserForm from '@/components/users/CreateUserForm';
import EditUserForm from '@/components/users/EditUserForm';
import EditUserPasswordForm from '@/components/users/EditUserPasswordForm';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import AlertDialog from '@/ui/v2/AlertDialog';
import { BaseDialog } from '@/ui/v2/Dialog';
@@ -353,6 +357,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<EditRoleForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_USER' && (
<CreateUserForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_PERMISSION_VARIABLE' && (
<CreatePermissionVariableForm {...sharedDialogProps} />
)}
@@ -368,6 +376,17 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
{activeDialogType === 'EDIT_ENVIRONMENT_VARIABLE' && (
<EditEnvironmentVariableForm {...sharedDialogProps} />
)}
{activeDialogType === 'EDIT_USER_PASSWORD' && (
<EditUserPasswordForm
{...sharedDialogProps}
user={sharedDialogProps?.user}
/>
)}
{activeDialogType === 'EDIT_JWT_SECRET' && (
<EditJwtSecretForm {...sharedDialogProps} />
)}
</RetryableErrorBoundary>
</BaseDialog>
@@ -413,6 +432,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
schema={drawerPayload?.schema}
/>
)}
{activeDrawerType === 'EDIT_USER' && (
<EditUserForm {...sharedDrawerProps} {...drawerPayload} />
)}
</RetryableErrorBoundary>
</Drawer>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,6 @@ import Divider from '@/ui/v2/Divider';
import IconButton from '@/ui/v2/IconButton';
import EyeIcon from '@/ui/v2/icons/EyeIcon';
import EyeOffIcon from '@/ui/v2/icons/EyeOffIcon';
import Input from '@/ui/v2/Input';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
@@ -27,7 +26,7 @@ export default function SystemEnvironmentVariableSettings() {
const [showAdminSecret, setShowAdminSecret] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const { openAlertDialog } = useDialog();
const { openDialog } = useDialog();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const { data, loading, error } = useGetAppInjectedVariablesQuery({
variables: { id: currentApplication?.id },
@@ -49,30 +48,39 @@ export default function SystemEnvironmentVariableSettings() {
throw error;
}
function showJwtSecret() {
openAlertDialog({
title: 'Auth JWT Secret',
payload: (
<div className="grid grid-flow-row gap-2">
<Text variant="subtitle2">
function showViewJwtSecretModal() {
openDialog('EDIT_JWT_SECRET', {
title: (
<span className="grid grid-flow-row">
<span>Auth JWT Secret</span>
<Text variant="subtitle1" component="span">
This is the key used for generating JWTs. It&apos;s HMAC-SHA-based
and the same as configured in Hasura.
</Text>
<Input
defaultValue={data?.app?.hasuraGraphqlJwtSecret}
disabled
fullWidth
multiline
minRows={5}
hideEmptyHelperText
inputProps={{ className: 'font-mono' }}
/>
</div>
</span>
),
props: {
hidePrimaryAction: true,
secondaryButtonText: 'Close',
payload: {
disabled: true,
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
},
});
}
function showEditJwtSecretModal() {
openDialog('EDIT_JWT_SECRET', {
title: (
<span className="grid grid-flow-row">
<span>Edit JWT Secret</span>
<Text variant="subtitle1" component="span">
You can add your custom JWT secret here. Hasura will use it to
validate the identity of your users.
</Text>
</span>
),
payload: {
jwtSecret: data?.app?.hasuraGraphqlJwtSecret,
},
});
}
@@ -207,14 +215,25 @@ export default function SystemEnvironmentVariableSettings() {
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 justify-start">
<ListItem.Text>NHOST_JWT_SECRET</ListItem.Text>
<Button
variant="borderless"
onClick={showJwtSecret}
size="small"
className="justify-self-start"
>
Show JWT Secret
</Button>
<div className="grid grid-flow-row md:grid-flow-col gap-1.5 justify-center text-center lg:text-left lg:justify-start items-center lg:col-span-2">
<Button
variant="borderless"
onClick={showViewJwtSecretModal}
size="small"
>
Show JWT Secret
</Button>
<Text component="span">or</Text>
<Button
variant="borderless"
onClick={showEditJwtSecretModal}
size="small"
>
Edit JWT Secret
</Button>
</div>
</ListItem.Root>
</List>
</SettingsContainer>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,10 @@ export const theme = createTheme({
main: '#f13154',
dark: '#c91737',
},
success: {
main: 'rgba(170, 240, 204, 0.3)',
contrastText: '#3BB174',
},
action: {
hover: '#f3f4f6',
active: '#f3f4f6',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
pnpm-lock.yaml generated
View File

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

View File

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