From 1ee649d4cdb77f54e89b43862ce3f2a54fc55b56 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 17 May 2024 14:54:03 +0200 Subject: [PATCH 01/14] refactor(react-routing): expose "RoutingProvider" --- .../src/bootstrap/ApplicationEntrypoint.tsx | 38 +++++++++---------- .../react-routing/src/RoutingProvider.tsx | 19 ++++++++++ packages/react-routing/src/index.ts | 1 + 3 files changed, 38 insertions(+), 20 deletions(-) create mode 100644 packages/react-routing/src/RoutingProvider.tsx diff --git a/packages/interface/src/bootstrap/ApplicationEntrypoint.tsx b/packages/interface/src/bootstrap/ApplicationEntrypoint.tsx index 6a1c9b2bd..b680b220c 100644 --- a/packages/interface/src/bootstrap/ApplicationEntrypoint.tsx +++ b/packages/interface/src/bootstrap/ApplicationEntrypoint.tsx @@ -1,7 +1,7 @@ import { Environment, EnvironmentContext, EnvironmentExtensionProvider } from '@contember/react-binding' import { ContemberClient, ContemberClientProps } from '@contember/react-client' import { ReactNode } from 'react' -import { RequestProvider, RouteMap, RoutingContext, RoutingContextValue, SelectedDimension } from '@contember/react-routing' +import { RequestProvider, RouteMap, RoutingContext, RoutingContextValue, RoutingProvider, SelectedDimension } from '@contember/react-routing' import { DataViewPageNameKeyProvider } from './DataViewPageNameKeyProvider' import { IdentityProvider, projectEnvironmentExtension } from '@contember/react-identity' @@ -36,25 +36,23 @@ export const ApplicationEntrypoint = (props: ApplicationEntrypointProps) => { return ( - - - - - - - {props.children} - - - - - - + + + + + + {props.children} + + + + + ) } diff --git a/packages/react-routing/src/RoutingProvider.tsx b/packages/react-routing/src/RoutingProvider.tsx new file mode 100644 index 000000000..3938bc54e --- /dev/null +++ b/packages/react-routing/src/RoutingProvider.tsx @@ -0,0 +1,19 @@ +import { RoutingContext, RoutingContextValue } from './RoutingContext' +import { RequestProvider } from './RequestContext' +import { ReactNode } from 'react' + +export type RoutingProviderProps = + & Partial + & { + children: ReactNode + } + +export const RoutingProvider = ({ children, ...props }: RoutingProviderProps) => { + return ( + + + {children} + + + ) +} diff --git a/packages/react-routing/src/index.ts b/packages/react-routing/src/index.ts index e1fa8e67e..d6d02b450 100644 --- a/packages/react-routing/src/index.ts +++ b/packages/react-routing/src/index.ts @@ -10,3 +10,4 @@ export * from './useRoutingLink' export * from './RoutingParameter' export * from './Pages' export * from './Page' +export * from './RoutingProvider' From b65936a8693fb85df40cec20650e11fb5b9bf986 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 20 May 2024 13:55:16 +0200 Subject: [PATCH 02/14] feat(react-client-tenant): add various mutations and queries --- .../react-client-tenant/src/hooks/index.ts | 4 ++ .../src/hooks/mutations/apiKey/index.ts | 3 ++ .../apiKey/useCreateApiKeyMutation.tsx | 19 +++++++ .../apiKey/useCreateGlobalApiKeyMutation.tsx | 19 +++++++ .../apiKey/useDisableApiKeyMutation.tsx | 14 ++++++ .../src/hooks/mutations/auth/index.ts | 3 ++ .../auth/useChangeMyPasswordMutation.ts | 14 ++++++ .../hooks/mutations/auth/useSignInMutation.ts | 25 ++++++++++ .../mutations/auth/useSignOutMutation.ts | 12 +++++ .../src/hooks/mutations/idp/index.ts | 2 + .../mutations/idp/useInitSignInIDPMutation.ts | 29 +++++++++++ .../mutations/idp/useSignInIdpMutation.ts | 35 +++++++++++++ .../src/hooks/mutations/index.ts | 7 +++ .../src/hooks/mutations/invite/index.ts | 1 + .../mutations/invite/useInviteMutation.tsx | 20 ++++++++ .../src/hooks/mutations/memberships/index.ts | 3 ++ .../useAddProjectMemberMutation.ts | 13 +++++ .../useRemoveProjectMemberMutation.ts | 13 +++++ .../useUpdateProjectMemberMutation.ts | 13 +++++ .../src/hooks/mutations/otp/index.ts | 3 ++ .../mutations/otp/useConfirmOtpMutation.ts | 13 +++++ .../mutations/otp/useDisableOtpMutation.ts | 10 ++++ .../mutations/otp/usePrepareOtpMutation.ts | 16 ++++++ .../hooks/mutations/passwordReset/index.ts | 2 + .../useCreateResetPasswordRequestMutation.ts | 17 +++++++ .../passwordReset/useResetPasswordMutation.ts | 17 +++++++ .../src/hooks/queries/index.ts | 4 ++ .../src/hooks/queries/useMeQuery.ts | 23 +++++++++ .../hooks/queries/useProjectMembersQuery.ts | 43 ++++++++++++++++ .../queries/useProjectMembershipsQuery.ts | 25 ++++++++++ .../queries/useProjectRolesDefinitionQuery.ts | 34 +++++++++++++ .../src/hooks/useTenantApi.ts | 24 ++++++--- .../src/hooks/useTenantMutation.ts | 45 +++++++++++++++++ .../src/hooks/useTenantQueryLoader.ts | 49 +++++++++++++++++++ 34 files changed, 568 insertions(+), 6 deletions(-) create mode 100644 packages/react-client-tenant/src/hooks/mutations/apiKey/index.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/apiKey/useCreateApiKeyMutation.tsx create mode 100644 packages/react-client-tenant/src/hooks/mutations/apiKey/useCreateGlobalApiKeyMutation.tsx create mode 100644 packages/react-client-tenant/src/hooks/mutations/apiKey/useDisableApiKeyMutation.tsx create mode 100644 packages/react-client-tenant/src/hooks/mutations/auth/index.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/auth/useChangeMyPasswordMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/auth/useSignInMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/auth/useSignOutMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/idp/index.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/idp/useInitSignInIDPMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/idp/useSignInIdpMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/index.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/invite/index.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/invite/useInviteMutation.tsx create mode 100644 packages/react-client-tenant/src/hooks/mutations/memberships/index.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/memberships/useAddProjectMemberMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/memberships/useRemoveProjectMemberMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/memberships/useUpdateProjectMemberMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/otp/index.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/otp/useConfirmOtpMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/otp/useDisableOtpMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/otp/usePrepareOtpMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/passwordReset/index.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/passwordReset/useCreateResetPasswordRequestMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/mutations/passwordReset/useResetPasswordMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/queries/index.ts create mode 100644 packages/react-client-tenant/src/hooks/queries/useMeQuery.ts create mode 100644 packages/react-client-tenant/src/hooks/queries/useProjectMembersQuery.ts create mode 100644 packages/react-client-tenant/src/hooks/queries/useProjectMembershipsQuery.ts create mode 100644 packages/react-client-tenant/src/hooks/queries/useProjectRolesDefinitionQuery.ts create mode 100644 packages/react-client-tenant/src/hooks/useTenantMutation.ts create mode 100644 packages/react-client-tenant/src/hooks/useTenantQueryLoader.ts diff --git a/packages/react-client-tenant/src/hooks/index.ts b/packages/react-client-tenant/src/hooks/index.ts index 6862ff0b4..d34523eb6 100644 --- a/packages/react-client-tenant/src/hooks/index.ts +++ b/packages/react-client-tenant/src/hooks/index.ts @@ -1 +1,5 @@ +export * from './mutations' +export * from './queries' export * from './useTenantApi' +export * from './useTenantQueryLoader' +export * from './useTenantMutation' diff --git a/packages/react-client-tenant/src/hooks/mutations/apiKey/index.ts b/packages/react-client-tenant/src/hooks/mutations/apiKey/index.ts new file mode 100644 index 000000000..bac62e35f --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/apiKey/index.ts @@ -0,0 +1,3 @@ +export * from './useCreateApiKeyMutation' +export * from './useCreateGlobalApiKeyMutation' +export * from './useDisableApiKeyMutation' diff --git a/packages/react-client-tenant/src/hooks/mutations/apiKey/useCreateApiKeyMutation.tsx b/packages/react-client-tenant/src/hooks/mutations/apiKey/useCreateApiKeyMutation.tsx new file mode 100644 index 000000000..12e59e4cc --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/apiKey/useCreateApiKeyMutation.tsx @@ -0,0 +1,19 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { ModelType } from 'graphql-ts-client-api' +import { createTenantMutation } from '../../useTenantMutation' + +const createApiKeyMutationResult = TenantApi.createApiKeyResult$.apiKey(TenantApi.apiKeyWithToken$$.identity(TenantApi.identity$$)) + +export type CreateApiKeyMutationResult = ModelType + +export const createApiKeyMutation = TenantApi.mutation$ + .createApiKey( + TenantApi + .createApiKeyResponse$$ + .error(TenantApi.createApiKeyError$$.membershipValidation(TenantApi.membershipValidationError$$)) + .result(createApiKeyMutationResult), + options => options.alias('mutation'), + ) + +export const useCreateApiKeyMutation = createTenantMutation(createApiKeyMutation) +export type CreateApiKeyMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/apiKey/useCreateGlobalApiKeyMutation.tsx b/packages/react-client-tenant/src/hooks/mutations/apiKey/useCreateGlobalApiKeyMutation.tsx new file mode 100644 index 000000000..34ebdd9fa --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/apiKey/useCreateGlobalApiKeyMutation.tsx @@ -0,0 +1,19 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { ModelType } from 'graphql-ts-client-api' +import { createTenantMutation } from '../../useTenantMutation' + +const createGlobalApiKeyMutationResult = TenantApi.createApiKeyResult$.apiKey(TenantApi.apiKeyWithToken$$.identity(TenantApi.identity$$)) + +export type CreateGlobalApiKeyMutationResult = ModelType + +export const createGlobalApiKeyMutation = TenantApi.mutation$ + .createGlobalApiKey( + TenantApi + .createApiKeyResponse$$ + .error(TenantApi.createApiKeyError$$) + .result(createGlobalApiKeyMutationResult), + options => options.alias('mutation'), + ) + +export const useCreateGlobalApiKeyMutation = createTenantMutation(createGlobalApiKeyMutation) +export type CreateGlobalApiKeyMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/apiKey/useDisableApiKeyMutation.tsx b/packages/react-client-tenant/src/hooks/mutations/apiKey/useDisableApiKeyMutation.tsx new file mode 100644 index 000000000..ae7863e71 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/apiKey/useDisableApiKeyMutation.tsx @@ -0,0 +1,14 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { createTenantMutation } from '../../useTenantMutation' + + +export const disableApiKeyMutation = TenantApi.mutation$ + .disableApiKey( + TenantApi + .disableApiKeyResponse$$ + .error(TenantApi.disableApiKeyError$$), + options => options.alias('mutation'), + ) + +export const useDisableApiKeyMutation = createTenantMutation(disableApiKeyMutation) +export type DisableApiKeyMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/auth/index.ts b/packages/react-client-tenant/src/hooks/mutations/auth/index.ts new file mode 100644 index 000000000..f9a0d68bf --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/auth/index.ts @@ -0,0 +1,3 @@ +export * from './useChangeMyPasswordMutation' +export * from './useSignInMutation' +export * from './useSignOutMutation' diff --git a/packages/react-client-tenant/src/hooks/mutations/auth/useChangeMyPasswordMutation.ts b/packages/react-client-tenant/src/hooks/mutations/auth/useChangeMyPasswordMutation.ts new file mode 100644 index 000000000..2039f75cb --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/auth/useChangeMyPasswordMutation.ts @@ -0,0 +1,14 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { createTenantMutation } from '../../useTenantMutation' + +export const changeMyPasswordMutation = TenantApi + .mutation$ + .changeMyPassword( + TenantApi + .changeMyPasswordResponse$$ + .error(TenantApi.changeMyPasswordError$$), + options => options.alias('mutation'), + ) + +export const useChangeMyPasswordMutation = createTenantMutation(changeMyPasswordMutation) +export type ChangeMyPasswordMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/auth/useSignInMutation.ts b/packages/react-client-tenant/src/hooks/mutations/auth/useSignInMutation.ts new file mode 100644 index 000000000..90edffa35 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/auth/useSignInMutation.ts @@ -0,0 +1,25 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { LoginToken } from '../../useTenantApi' +import { ModelType } from 'graphql-ts-client-api' +import { createTenantMutation } from '../../useTenantMutation' + +const signInResultFragment = TenantApi.signInResult$$.person(TenantApi.person$$) + +export type SignInMutationResult = ModelType + +export const signInMutation = TenantApi + .mutation$ + .signIn( + TenantApi + .signInResponse$$ + .error(TenantApi.signInError$$) + .result(signInResultFragment), + options => options.alias('mutation'), + ) + + +export const useSignInMutation = createTenantMutation(signInMutation, { + apiToken: LoginToken, +}) + +export type SignInMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/auth/useSignOutMutation.ts b/packages/react-client-tenant/src/hooks/mutations/auth/useSignOutMutation.ts new file mode 100644 index 000000000..6385da70b --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/auth/useSignOutMutation.ts @@ -0,0 +1,12 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { createTenantMutation } from '../../useTenantMutation' + +export const signOutMutation = TenantApi.mutation$.signOut( + TenantApi + .signOutResponse$$ + .error(TenantApi.signOutError$$), + options => options.alias('mutation'), +) + +export const useSignOutMutation = createTenantMutation(signOutMutation) +export type SignOutMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/idp/index.ts b/packages/react-client-tenant/src/hooks/mutations/idp/index.ts new file mode 100644 index 000000000..66b567730 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/idp/index.ts @@ -0,0 +1,2 @@ +export * from './useInitSignInIDPMutation' +export * from './useSignInIdpMutation' diff --git a/packages/react-client-tenant/src/hooks/mutations/idp/useInitSignInIDPMutation.ts b/packages/react-client-tenant/src/hooks/mutations/idp/useInitSignInIDPMutation.ts new file mode 100644 index 000000000..c43411779 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/idp/useInitSignInIDPMutation.ts @@ -0,0 +1,29 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { LoginToken } from '../../useTenantApi' +import { createTenantMutation } from '../../useTenantMutation' +import { ModelType } from 'graphql-ts-client-api' + + +export type InitSignInIDPMutationVariables = { + identityProvider: string + data: { + redirectUrl?: string + } & { + [key: string]: string + } +} +export type InitSignInIDPMutationResult = ModelType + +const InitSignInIDPMutation = TenantApi + .mutation$ + .initSignInIDP( + TenantApi + .initSignInIDPResponse$$ + .error(TenantApi.initSignInIDPError$$) + .result(TenantApi.initSignInIDPResult$$), + options => options.alias('mutation'), + ) + +export const useInitSignInIDPMutation = createTenantMutation(InitSignInIDPMutation, { + apiToken: LoginToken, +}) diff --git a/packages/react-client-tenant/src/hooks/mutations/idp/useSignInIdpMutation.ts b/packages/react-client-tenant/src/hooks/mutations/idp/useSignInIdpMutation.ts new file mode 100644 index 000000000..4cce03872 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/idp/useSignInIdpMutation.ts @@ -0,0 +1,35 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { LoginToken } from '../../useTenantApi' +import { createTenantMutation } from '../../useTenantMutation' +import { ModelType } from 'graphql-ts-client-api' + +const signInIdpFragment = TenantApi.signInIDPResult$$.person(TenantApi.person$$) + +export type SignInIDPMutationResult = ModelType + +export const signInIDPMutation = TenantApi + .mutation$ + .signInIDP( + TenantApi + .signInIDPResponse$$ + .error(TenantApi.signInIDPError$$) + .result(signInIdpFragment), + options => options.alias('mutation'), + ) + +export type SignInIDPMutationVariables = { + identityProvider: string + expiration?: number + data: + & { + url?: string + redirectUrl?: string + sessionData?: any + } + & { [key: string]: any } +} + + +export const useSignInIDPMutation = createTenantMutation(signInIDPMutation, { + apiToken: LoginToken, +}) diff --git a/packages/react-client-tenant/src/hooks/mutations/index.ts b/packages/react-client-tenant/src/hooks/mutations/index.ts new file mode 100644 index 000000000..a13493caa --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/index.ts @@ -0,0 +1,7 @@ +export * from './apiKey' +export * from './auth' +export * from './invite' +export * from './idp' +export * from './memberships' +export * from './otp' +export * from './passwordReset' diff --git a/packages/react-client-tenant/src/hooks/mutations/invite/index.ts b/packages/react-client-tenant/src/hooks/mutations/invite/index.ts new file mode 100644 index 000000000..653c50b31 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/invite/index.ts @@ -0,0 +1 @@ +export * from './useInviteMutation' diff --git a/packages/react-client-tenant/src/hooks/mutations/invite/useInviteMutation.tsx b/packages/react-client-tenant/src/hooks/mutations/invite/useInviteMutation.tsx new file mode 100644 index 000000000..4e5a34e09 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/invite/useInviteMutation.tsx @@ -0,0 +1,20 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { ModelType } from 'graphql-ts-client-api' +import { createTenantMutation } from '../../useTenantMutation' + +const inviteMutationResult = TenantApi.inviteResult$$ + .person(TenantApi.person$$.identity(TenantApi.identity$$)) + +export type InviteMutationResult = ModelType + +export const inviteMutation = TenantApi.mutation$ + .invite( + TenantApi + .inviteResponse$$ + .error(TenantApi.inviteError$$.membershipValidation(TenantApi.membershipValidationError$$)) + .result(inviteMutationResult), + options => options.alias('mutation'), + ) + +export const useInviteMutation = createTenantMutation(inviteMutation) +export type InviteMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/memberships/index.ts b/packages/react-client-tenant/src/hooks/mutations/memberships/index.ts new file mode 100644 index 000000000..47ba55e00 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/memberships/index.ts @@ -0,0 +1,3 @@ +export * from './useRemoveProjectMemberMutation' +export * from './useAddProjectMemberMutation' +export * from './useUpdateProjectMemberMutation' diff --git a/packages/react-client-tenant/src/hooks/mutations/memberships/useAddProjectMemberMutation.ts b/packages/react-client-tenant/src/hooks/mutations/memberships/useAddProjectMemberMutation.ts new file mode 100644 index 000000000..84a4ee671 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/memberships/useAddProjectMemberMutation.ts @@ -0,0 +1,13 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { createTenantMutation } from '../../useTenantMutation' + +export const addProjectMemberMutation = TenantApi.mutation$ + .addProjectMember( + TenantApi + .addProjectMemberResponse$$ + .error(TenantApi.addProjectMemberError$$.membershipValidation(TenantApi.membershipValidationError$$)), + options => options.alias('mutation'), + ) + +export const useAddProjectMemberMutation = createTenantMutation(addProjectMemberMutation) +export type AddProjectMemberMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/memberships/useRemoveProjectMemberMutation.ts b/packages/react-client-tenant/src/hooks/mutations/memberships/useRemoveProjectMemberMutation.ts new file mode 100644 index 000000000..e9965d8fe --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/memberships/useRemoveProjectMemberMutation.ts @@ -0,0 +1,13 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { createTenantMutation } from '../../useTenantMutation' + +export const removeProjectMemberMutation = TenantApi.mutation$ + .removeProjectMember( + TenantApi + .removeProjectMemberResponse$$ + .error(TenantApi.removeProjectMemberError$$), + options => options.alias('mutation'), + ) + +export const useRemoveProjectMemberMutation = createTenantMutation(removeProjectMemberMutation) +export type RemoveProjectMemberMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/memberships/useUpdateProjectMemberMutation.ts b/packages/react-client-tenant/src/hooks/mutations/memberships/useUpdateProjectMemberMutation.ts new file mode 100644 index 000000000..5a1351e62 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/memberships/useUpdateProjectMemberMutation.ts @@ -0,0 +1,13 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { createTenantMutation } from '../../useTenantMutation' + +export const updateProjectMemberMutation = TenantApi.mutation$ + .updateProjectMember( + TenantApi + .updateProjectMemberResponse$$ + .error(TenantApi.updateProjectMemberError$$.membershipValidation(TenantApi.membershipValidationError$$)), + options => options.alias('mutation'), + ) + +export const useUpdateProjectMemberMutation = createTenantMutation(updateProjectMemberMutation) +export type UpdateProjectMemberMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/otp/index.ts b/packages/react-client-tenant/src/hooks/mutations/otp/index.ts new file mode 100644 index 000000000..19bc57020 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/otp/index.ts @@ -0,0 +1,3 @@ +export * from './useConfirmOtpMutation' +export * from './useDisableOtpMutation' +export * from './usePrepareOtpMutation' diff --git a/packages/react-client-tenant/src/hooks/mutations/otp/useConfirmOtpMutation.ts b/packages/react-client-tenant/src/hooks/mutations/otp/useConfirmOtpMutation.ts new file mode 100644 index 000000000..c61f39123 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/otp/useConfirmOtpMutation.ts @@ -0,0 +1,13 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { createTenantMutation } from '../../useTenantMutation' + +export const confirmOtpMutation = TenantApi.mutation$ + .confirmOtp(TenantApi + .confirmOtpResponse$$ + .error(TenantApi.confirmOtpError$$), + options => options.alias('mutation'), + ) + + +export const useConfirmOtpMutation = createTenantMutation(confirmOtpMutation) +export type ConfirmOtpMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/otp/useDisableOtpMutation.ts b/packages/react-client-tenant/src/hooks/mutations/otp/useDisableOtpMutation.ts new file mode 100644 index 000000000..81959198a --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/otp/useDisableOtpMutation.ts @@ -0,0 +1,10 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { createTenantMutation } from '../../useTenantMutation' + +export const disableOtpMutation = TenantApi.mutation$.disableOtp( + TenantApi.disableOtpResponse$$.error(TenantApi.disableOtpError$$), + options => options.alias('mutation'), +) + +export const useDisableOtpMutation = createTenantMutation(disableOtpMutation) +export type DisableOtpMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/otp/usePrepareOtpMutation.ts b/packages/react-client-tenant/src/hooks/mutations/otp/usePrepareOtpMutation.ts new file mode 100644 index 000000000..3a85465ff --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/otp/usePrepareOtpMutation.ts @@ -0,0 +1,16 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { ModelType } from 'graphql-ts-client-api' +import { createTenantMutation } from '../../useTenantMutation' + +const prepareOtmMutationResult = TenantApi.prepareOtpResponse$$.result(TenantApi.prepareOtpResult$$) + +export type PrepareOtpMutationResult = ModelType + +export const prepareOtpMutation = TenantApi.mutation$.prepareOtp( + prepareOtmMutationResult, + options => options.alias('mutation'), +) + + +export const usePrepareOtpMutation = createTenantMutation(prepareOtpMutation) +export type PrepareOtpMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/passwordReset/index.ts b/packages/react-client-tenant/src/hooks/mutations/passwordReset/index.ts new file mode 100644 index 000000000..395ed43df --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/passwordReset/index.ts @@ -0,0 +1,2 @@ +export * from './useCreateResetPasswordRequestMutation' +export * from './useResetPasswordMutation' diff --git a/packages/react-client-tenant/src/hooks/mutations/passwordReset/useCreateResetPasswordRequestMutation.ts b/packages/react-client-tenant/src/hooks/mutations/passwordReset/useCreateResetPasswordRequestMutation.ts new file mode 100644 index 000000000..6e0788582 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/passwordReset/useCreateResetPasswordRequestMutation.ts @@ -0,0 +1,17 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { LoginToken } from '../../useTenantApi' +import { createTenantMutation } from '../../useTenantMutation' + +export const createResetPasswordRequestMutation = TenantApi + .mutation$ + .createResetPasswordRequest( + TenantApi + .createPasswordResetRequestResponse$$ + .error(TenantApi.createPasswordResetRequestError$$), + options => options.alias('mutation'), + ) + +export const useCreateResetPasswordRequestMutation = createTenantMutation(createResetPasswordRequestMutation, { + apiToken: LoginToken, +}) +export type CreateResetPasswordRequestMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/mutations/passwordReset/useResetPasswordMutation.ts b/packages/react-client-tenant/src/hooks/mutations/passwordReset/useResetPasswordMutation.ts new file mode 100644 index 000000000..cadcb5b1c --- /dev/null +++ b/packages/react-client-tenant/src/hooks/mutations/passwordReset/useResetPasswordMutation.ts @@ -0,0 +1,17 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { LoginToken } from '../../useTenantApi' +import { createTenantMutation } from '../../useTenantMutation' + +export const resetPasswordMutation = TenantApi + .mutation$ + .resetPassword( + TenantApi + .resetPasswordResponse$$ + .error(TenantApi.resetPasswordError$$), + options => options.alias('mutation'), + ) + +export const useResetPasswordMutation = createTenantMutation(resetPasswordMutation, { + apiToken: LoginToken, +}) +export type ResetPasswordMutationVariables = Parameters>[0] diff --git a/packages/react-client-tenant/src/hooks/queries/index.ts b/packages/react-client-tenant/src/hooks/queries/index.ts new file mode 100644 index 000000000..675553dcd --- /dev/null +++ b/packages/react-client-tenant/src/hooks/queries/index.ts @@ -0,0 +1,4 @@ +export * from './useMeQuery' +export * from './useProjectMembersQuery' +export * from './useProjectMembershipsQuery' +export * from './useProjectRolesDefinitionQuery' diff --git a/packages/react-client-tenant/src/hooks/queries/useMeQuery.ts b/packages/react-client-tenant/src/hooks/queries/useMeQuery.ts new file mode 100644 index 000000000..a46ce3aa7 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/queries/useMeQuery.ts @@ -0,0 +1,23 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { ModelType } from 'graphql-ts-client-api' +import { TenantApiOptions, useTenantApi } from '../useTenantApi' +import { useCallback } from 'react' + +const identityFragment = TenantApi + .identity$$ + .person(TenantApi.person$$) + .projects(TenantApi + .identityProjectRelation$ + .project(TenantApi.project$$) + .memberships(TenantApi.membership$$.variables(TenantApi.variableEntry$$)), + ) + .permissions(TenantApi.identityGlobalPermissions$$) + +export type MeQueryData = ModelType + +export const useMeQuery = (options: TenantApiOptions = {}) => { + const executor = useTenantApi(options) + return useCallback(async ({}: {}): Promise => { + return (await executor(TenantApi.query$.me(identityFragment))).me + }, [executor]) +} diff --git a/packages/react-client-tenant/src/hooks/queries/useProjectMembersQuery.ts b/packages/react-client-tenant/src/hooks/queries/useProjectMembersQuery.ts new file mode 100644 index 000000000..c4fb91152 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/queries/useProjectMembersQuery.ts @@ -0,0 +1,43 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { ModelType, ParameterRef } from 'graphql-ts-client-api' +import { TenantApiOptions, useTenantApi } from '../useTenantApi' +import { useCallback } from 'react' + +const projectIdentityRelationFragment = TenantApi + .projectIdentityRelation$ + .identity(TenantApi.identity$$ + .person(TenantApi.person$$) + .apiKey(TenantApi.apiKey$$) + , + ) + .memberships(TenantApi.membership$$.variables(TenantApi.variableEntry$$)) + +export type ProjectMembersQueryResult = readonly ModelType[] + +export type ProjectMembersQueryVariables = + & { + projectSlug: string + } + & TenantApi.ProjectMembersInput + + +export const useProjectMembersQuery = ({ headers, apiToken }: TenantApiOptions = {}) => { + const executor = useTenantApi({ + headers, + apiToken, + }) + return useCallback(async ({ projectSlug, ...membersInput }: ProjectMembersQueryVariables): Promise => { + const result = await executor(TenantApi.query$.projectBySlug({ + slug: ParameterRef.of('projectSlug'), + }, TenantApi.project$.members({ + input: ParameterRef.of('membersInput'), + }, projectIdentityRelationFragment)), { + variables: { + projectSlug, + membersInput, + }, + }) + + return result.projectBySlug?.members ?? [] + }, [executor]) +} diff --git a/packages/react-client-tenant/src/hooks/queries/useProjectMembershipsQuery.ts b/packages/react-client-tenant/src/hooks/queries/useProjectMembershipsQuery.ts new file mode 100644 index 000000000..e7066c7e7 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/queries/useProjectMembershipsQuery.ts @@ -0,0 +1,25 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { ModelType, ParameterRef } from 'graphql-ts-client-api' +import { TenantApiOptions, useTenantApi } from '../useTenantApi' +import { useCallback } from 'react' + +const projectMembershipsFragment = TenantApi.membership$$.variables(TenantApi.variableEntry$$) + +export type ProjectMembershipsQueryResult = readonly ModelType[] + +export type ProjectMembershipsQueryVariables = { + projectSlug: string + identityId: string +} + + +export const useProjectMembershipsQuery = (options: TenantApiOptions = {}) => { + const executor = useTenantApi(options) + return useCallback(async (input: ProjectMembershipsQueryVariables): Promise => { + const result = await executor(TenantApi.query$.projectMemberships(TenantApi.membership$$.variables(TenantApi.variableEntry$$)), { + variables: input, + }) + + return result.projectMemberships ?? [] + }, [executor]) +} diff --git a/packages/react-client-tenant/src/hooks/queries/useProjectRolesDefinitionQuery.ts b/packages/react-client-tenant/src/hooks/queries/useProjectRolesDefinitionQuery.ts new file mode 100644 index 000000000..469376370 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/queries/useProjectRolesDefinitionQuery.ts @@ -0,0 +1,34 @@ +import * as TenantApi from '@contember/graphql-client-tenant' +import { ModelType } from 'graphql-ts-client-api' +import { TenantApiOptions, useTenantApi } from '../useTenantApi' +import { useCallback } from 'react' + +const projectRolesDefinitionFragment = TenantApi.roleDefinition$$ + .variables( + TenantApi.roleVariableDefinition$$ + .on(TenantApi.roleConditionVariableDefinition$$) + .on(TenantApi.roleEntityVariableDefinition$$) + .on(TenantApi.rolePredefinedVariableDefinition$$) + , + ) + +export type ProjectRoleDefinition = ModelType +export type ProjectRolesDefinitionQueryResult = readonly ProjectRoleDefinition[] + +const projectRolesDefinitionQuery = TenantApi.query$ + .projectBySlug( + TenantApi + .project$ + .roles(projectRolesDefinitionFragment), + ) + +export interface ProjectRolesDefinitionQueryVariables { + slug: string +} + +export const useProjectRolesDefinitionQuery = ({ headers, apiToken }: TenantApiOptions = {}) => { + const executor = useTenantApi() + return useCallback(async (variables: ProjectRolesDefinitionQueryVariables): Promise => { + return (await executor(projectRolesDefinitionQuery, { headers, apiToken, variables })).projectBySlug?.roles ?? [] + }, [apiToken, executor, headers]) +} diff --git a/packages/react-client-tenant/src/hooks/useTenantApi.ts b/packages/react-client-tenant/src/hooks/useTenantApi.ts index a1c8f2dc5..bd2aba224 100644 --- a/packages/react-client-tenant/src/hooks/useTenantApi.ts +++ b/packages/react-client-tenant/src/hooks/useTenantApi.ts @@ -1,16 +1,24 @@ -import { useTenantGraphQlClient } from '@contember/react-client' +import { useLoginToken, useTenantGraphQlClient } from '@contember/react-client' import { useCallback } from 'react' import { Fetcher, TextWriter, util } from 'graphql-ts-client-api' -export const useTenantApi = () => { +export const LoginToken = Symbol('LoginToken') + +export type TenantApiOptions = { + readonly headers?: Record + readonly apiToken?: string | typeof LoginToken +} + +export const useTenantApi = ({ headers, apiToken }: TenantApiOptions = {}) => { const client = useTenantGraphQlClient() + const loginToken = useLoginToken() return useCallback(( fetcher: Fetcher<'Query' | 'Mutation', TData, TVariables>, options?: { readonly variables?: TVariables readonly headers?: Record - readonly apiToken?: string + readonly apiToken?: string | typeof LoginToken }, ): Promise => { @@ -27,10 +35,14 @@ export const useTenantApi = () => { writer.text(fetcher.toString()) writer.text(fetcher.toFragmentString()) + const apiTokenResolved = apiToken ?? options?.apiToken return client.execute(writer.toString(), { variables: options?.variables, - headers: options?.headers, - apiToken: options?.apiToken, + headers: { + ...headers, + ...options?.headers, + }, + apiToken: apiTokenResolved === LoginToken ? loginToken : apiTokenResolved, }) - }, [client]) + }, [apiToken, client, headers, loginToken]) } diff --git a/packages/react-client-tenant/src/hooks/useTenantMutation.ts b/packages/react-client-tenant/src/hooks/useTenantMutation.ts new file mode 100644 index 000000000..0eefd6d73 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/useTenantMutation.ts @@ -0,0 +1,45 @@ +import { TenantApiOptions, useTenantApi } from './useTenantApi' +import { useCallback, useMemo } from 'react' +import { MutationFetcher } from '@contember/graphql-client-tenant' + +export type TenantMutationOkResponse = { ok: true, result: Result } +export type TenantMutationErrorResponse = { ok: false, error: Error, developerMessage?: string } +export type TenantMutationResponse = TenantMutationOkResponse | TenantMutationErrorResponse + +export type TenantMutation = { readonly mutation?: { readonly ok: boolean, readonly error?: { readonly code: Error, readonly developerMessage: string}, readonly result?: Result} } + +export const useTenantMutation = ( + fetcher: MutationFetcher, TVariables>, + { headers, apiToken }: TenantApiOptions = {}, +) => { + const tenantApi = useTenantApi({ headers, apiToken }) + + return useCallback(async (variables: TVariables): Promise> => { + const result = await tenantApi( + fetcher, + { + variables, + }, + ) + if (!result.mutation?.ok) { + return { + ok: false, + error: result.mutation?.error?.code as TError, + developerMessage: result.mutation?.error?.developerMessage, + } + } else { + return { ok: true, result: result.mutation.result as TResult } + } + }, [fetcher, tenantApi]) +} + + +export const createTenantMutation = ( + fetcher: MutationFetcher, TVariables>, + defaultOptions?: TenantApiOptions, +) => { + return ({ headers, apiToken }: TenantApiOptions = {}) => useTenantMutation(fetcher, { + headers: useMemo(() => ({ ...defaultOptions?.headers, ...headers }), [headers]), + apiToken: apiToken ?? defaultOptions?.apiToken, + }) +} diff --git a/packages/react-client-tenant/src/hooks/useTenantQueryLoader.ts b/packages/react-client-tenant/src/hooks/useTenantQueryLoader.ts new file mode 100644 index 000000000..7de362353 --- /dev/null +++ b/packages/react-client-tenant/src/hooks/useTenantQueryLoader.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useObjectMemo } from '@contember/react-utils' + +export type TenantQueryLoaderState = + | { state: 'loading' } + | { state: 'error'; error: unknown } + | { state: 'success'; data: Result } + | { state: 'refreshing'; data: Result } + +export type TenantQueryLoaderMethods = { + refresh: () => void +} + +export const useTenantQueryLoader = (fetcher: (variables: TVariables) => Promise, variables: TVariables): [TenantQueryLoaderState, TenantQueryLoaderMethods] => { + const [state, setState] = useState>({ state: 'loading' }) + const stateRef = useRef(state) + stateRef.current = state + const variablesMemo = useObjectMemo(variables) + + const reqId = useRef(0) + + const triggerLoad = useCallback(async () => { + const currentReqId = ++reqId.current + try { + if (stateRef.current.state === 'success') { + setState({ state: 'refreshing', data: stateRef.current.data }) + } + const data = await fetcher(variablesMemo) + if (reqId.current !== currentReqId) { + return + } + setState({ state: 'success', data }) + } catch (e) { + console.error(e) + if (reqId.current !== currentReqId) { + return + } + setState({ state: 'error', error: e }) + } + + }, [fetcher, variablesMemo]) + + + useEffect(() => { + triggerLoad() + }, [triggerLoad]) + + return [state, { refresh: triggerLoad }] +} From 8a60fb5b746e9aaac47df6163e340179535e0fe5 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 20 May 2024 14:22:16 +0200 Subject: [PATCH 03/14] feat(react-client-tenant): add various components --- packages/react-client-tenant/package.json | 4 + .../src/components/IdentityProvider.tsx | 6 +- .../src/components/IdentityState.tsx | 2 +- .../components/forms/ChangeMyPasswordForm.tsx | 75 +++++++++++ .../src/components/forms/CreateApiKeyForm.tsx | 72 +++++++++++ .../src/components/forms/InviteForm.tsx | 79 ++++++++++++ .../src/components/forms/LoginForm.tsx | 110 +++++++++++++++++ .../src/components/forms/OtpConfirmForm.tsx | 61 +++++++++ .../src/components/forms/OtpPrepareForm.tsx | 45 +++++++ .../components/forms/PasswordResetForm.tsx | 80 ++++++++++++ .../forms/PasswordResetRequestForm.tsx | 64 ++++++++++ .../src/components/forms/TenantForm.tsx | 116 ++++++++++++++++++ .../forms/UpdateProjectMemberForm.tsx | 77 ++++++++++++ .../src/components/forms/index.ts | 9 ++ .../src/components/idp/IDP.tsx | 65 ++++++++++ .../src/components/idp/IDPInitTrigger.tsx | 17 +++ .../src/components/idp/IDPState.tsx | 14 +++ .../src/components/idp/index.ts | 3 + .../src/components/index.ts | 5 + .../components/triggers/DisableOtpTrigger.tsx | 25 ++++ .../components/triggers}/LogoutTrigger.tsx | 2 +- .../triggers/RemoveProjectMemberTrigger.tsx | 18 +++ .../triggers/TenantActionTrigger.tsx | 41 +++++++ .../src/components/triggers/index.ts | 3 + packages/react-client-tenant/src/contexts.ts | 34 +++++ .../react-client-tenant/src/hooks/index.ts | 2 + .../src/hooks/useFetchIdentity.ts | 6 +- .../src/hooks/useLogout.ts | 2 +- packages/react-client-tenant/src/index.ts | 3 + .../internal/hooks/useHandleIDPResponse.ts | 64 ++++++++++ .../src/internal/hooks/useIDPAutoInit.ts | 18 +++ .../src/internal/hooks/useIDPStateStore.ts | 18 +++ .../src/internal/hooks/useInitIDPRedirect.ts | 48 ++++++++ .../src/internal/hooks/useLogoutInternal.ts | 6 +- .../internal/hooks/useRedirectToBacklink.ts | 42 +++++++ .../src/internal/utils/getBaseHref.ts | 4 + .../react-client-tenant/src/tsconfig.json | 1 + .../src/types/Identity.ts | 0 .../src/types/IdentityMethods.ts | 0 .../src/types/IdentityStateValue.ts | 0 .../react-client-tenant/src/types/forms.ts | 26 ++++ packages/react-client-tenant/src/types/idp.ts | 25 ++++ .../src/types/index.ts | 2 + packages/react-identity/package.json | 5 +- .../IdentityEnvironmentProvider.tsx | 19 +++ .../react-identity/src/components/index.ts | 4 +- .../IdentityEnvironmentExtension.ts | 2 +- packages/react-identity/src/hooks/index.ts | 8 +- .../src/hooks/useProjectUserRoles.tsx | 2 +- packages/react-identity/src/index.ts | 2 +- .../react-identity/src/internal/contexts.ts | 8 -- .../src/internal/hooks/useFetchMe.ts | 22 ---- .../src/internal/hooks/useSignOut.ts | 14 --- packages/react-identity/src/tsconfig.json | 1 - 54 files changed, 1305 insertions(+), 76 deletions(-) rename packages/{react-identity => react-client-tenant}/src/components/IdentityProvider.tsx (78%) rename packages/{react-identity => react-client-tenant}/src/components/IdentityState.tsx (88%) create mode 100644 packages/react-client-tenant/src/components/forms/ChangeMyPasswordForm.tsx create mode 100644 packages/react-client-tenant/src/components/forms/CreateApiKeyForm.tsx create mode 100644 packages/react-client-tenant/src/components/forms/InviteForm.tsx create mode 100644 packages/react-client-tenant/src/components/forms/LoginForm.tsx create mode 100644 packages/react-client-tenant/src/components/forms/OtpConfirmForm.tsx create mode 100644 packages/react-client-tenant/src/components/forms/OtpPrepareForm.tsx create mode 100644 packages/react-client-tenant/src/components/forms/PasswordResetForm.tsx create mode 100644 packages/react-client-tenant/src/components/forms/PasswordResetRequestForm.tsx create mode 100644 packages/react-client-tenant/src/components/forms/TenantForm.tsx create mode 100644 packages/react-client-tenant/src/components/forms/UpdateProjectMemberForm.tsx create mode 100644 packages/react-client-tenant/src/components/forms/index.ts create mode 100644 packages/react-client-tenant/src/components/idp/IDP.tsx create mode 100644 packages/react-client-tenant/src/components/idp/IDPInitTrigger.tsx create mode 100644 packages/react-client-tenant/src/components/idp/IDPState.tsx create mode 100644 packages/react-client-tenant/src/components/idp/index.ts create mode 100644 packages/react-client-tenant/src/components/index.ts create mode 100644 packages/react-client-tenant/src/components/triggers/DisableOtpTrigger.tsx rename packages/{react-identity/src/components => react-client-tenant/src/components/triggers}/LogoutTrigger.tsx (84%) create mode 100644 packages/react-client-tenant/src/components/triggers/RemoveProjectMemberTrigger.tsx create mode 100644 packages/react-client-tenant/src/components/triggers/TenantActionTrigger.tsx create mode 100644 packages/react-client-tenant/src/components/triggers/index.ts create mode 100644 packages/react-client-tenant/src/contexts.ts rename packages/{react-identity => react-client-tenant}/src/hooks/useFetchIdentity.ts (93%) rename packages/{react-identity => react-client-tenant}/src/hooks/useLogout.ts (77%) create mode 100644 packages/react-client-tenant/src/internal/hooks/useHandleIDPResponse.ts create mode 100644 packages/react-client-tenant/src/internal/hooks/useIDPAutoInit.ts create mode 100644 packages/react-client-tenant/src/internal/hooks/useIDPStateStore.ts create mode 100644 packages/react-client-tenant/src/internal/hooks/useInitIDPRedirect.ts rename packages/{react-identity => react-client-tenant}/src/internal/hooks/useLogoutInternal.ts (87%) create mode 100644 packages/react-client-tenant/src/internal/hooks/useRedirectToBacklink.ts create mode 100644 packages/react-client-tenant/src/internal/utils/getBaseHref.ts rename packages/{react-identity => react-client-tenant}/src/types/Identity.ts (100%) rename packages/{react-identity => react-client-tenant}/src/types/IdentityMethods.ts (100%) rename packages/{react-identity => react-client-tenant}/src/types/IdentityStateValue.ts (100%) create mode 100644 packages/react-client-tenant/src/types/forms.ts create mode 100644 packages/react-client-tenant/src/types/idp.ts rename packages/{react-identity => react-client-tenant}/src/types/index.ts (68%) create mode 100644 packages/react-identity/src/components/IdentityEnvironmentProvider.tsx delete mode 100644 packages/react-identity/src/internal/contexts.ts delete mode 100644 packages/react-identity/src/internal/hooks/useFetchMe.ts delete mode 100644 packages/react-identity/src/internal/hooks/useSignOut.ts diff --git a/packages/react-client-tenant/package.json b/packages/react-client-tenant/package.json index 16ce48fc6..34d426efd 100644 --- a/packages/react-client-tenant/package.json +++ b/packages/react-client-tenant/package.json @@ -40,7 +40,11 @@ "directory": "packages/react-client-tenant" }, "dependencies": { + "@contember/graphql-client-tenant": "^1.3.7", "@contember/react-client": "workspace:*", + "@contember/react-utils": "workspace:*", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/primitive": "^1.0.1", "graphql-ts-client-api": "^3.1.17" }, "peerDependencies": { diff --git a/packages/react-identity/src/components/IdentityProvider.tsx b/packages/react-client-tenant/src/components/IdentityProvider.tsx similarity index 78% rename from packages/react-identity/src/components/IdentityProvider.tsx rename to packages/react-client-tenant/src/components/IdentityProvider.tsx index 6a7e1f01f..eff2eeb28 100644 --- a/packages/react-identity/src/components/IdentityProvider.tsx +++ b/packages/react-client-tenant/src/components/IdentityProvider.tsx @@ -1,9 +1,7 @@ import { useSessionToken } from '@contember/react-client' import { ReactNode, useEffect } from 'react' -import { EnvironmentExtensionProvider } from '@contember/react-binding' import { useFetchIdentity } from '../hooks' -import { IdentityContext, IdentityMethodsContext, IdentityStateContext } from '../internal/contexts' -import { identityEnvironmentExtension } from '../environment' +import { IdentityContext, IdentityMethodsContext, IdentityStateContext } from '../contexts' export interface IdentityProviderProps { @@ -33,9 +31,7 @@ export const IdentityProvider: React.FC = ({ children }) - {children} - diff --git a/packages/react-identity/src/components/IdentityState.tsx b/packages/react-client-tenant/src/components/IdentityState.tsx similarity index 88% rename from packages/react-identity/src/components/IdentityState.tsx rename to packages/react-client-tenant/src/components/IdentityState.tsx index ed9703f86..9f683fbcf 100644 --- a/packages/react-identity/src/components/IdentityState.tsx +++ b/packages/react-client-tenant/src/components/IdentityState.tsx @@ -1,6 +1,6 @@ import React, { ReactNode } from 'react' import { IdentityStateValue } from '../types' -import { useIdentityState } from '../internal/contexts' +import { useIdentityState } from '../contexts' export interface IdentityStateProps { state: IdentityStateValue | IdentityStateValue[] diff --git a/packages/react-client-tenant/src/components/forms/ChangeMyPasswordForm.tsx b/packages/react-client-tenant/src/components/forms/ChangeMyPasswordForm.tsx new file mode 100644 index 000000000..bb7bf8bb4 --- /dev/null +++ b/packages/react-client-tenant/src/components/forms/ChangeMyPasswordForm.tsx @@ -0,0 +1,75 @@ +import { ReactElement } from 'react' +import { ChangeMyPasswordErrorCode } from '@contember/graphql-client-tenant' +import { TenantForm } from './TenantForm' +import { FormContextValue, FormError, FormState } from '../../types' +import { useForm } from '../../contexts' +import { useChangeMyPasswordMutation } from '../../hooks' + +export type ChangeMyPasswordFormValues = { + currentPassword: string + newPassword: string + passwordConfirmation: string +} + +export type ChangeMyPasswordFormErrorCode = + | ChangeMyPasswordErrorCode + | 'FIELD_REQUIRED' + | 'INVALID_VALUE' + | 'PASSWORD_MISMATCH' + | 'UNKNOWN_ERROR' + +export type ChangeMyPasswordFormState = FormState + +export type ChangeMyPasswordFormError = FormError + +export type ChangeMyPasswordFormContextValue = FormContextValue + +export interface ChangeMyPasswordFormProps { + children: ReactElement + onSuccess?: () => void +} + +export const useChangeMyPasswordForm = useForm as () => ChangeMyPasswordFormContextValue + +export const ChangeMyPasswordForm = ({ children, onSuccess }: ChangeMyPasswordFormProps) => { + const changePassword = useChangeMyPasswordMutation() + return ( + + onSuccess={onSuccess} + errorMapping={errorToField} + initialValues={{ + currentPassword: '', + newPassword: '', + passwordConfirmation: '', + }} + validate={({ values }) => { + const errors: ChangeMyPasswordFormError[] = [] + if (!values.currentPassword) { + errors.push({ code: 'FIELD_REQUIRED', field: 'currentPassword' }) + } + if (!values.newPassword) { + errors.push({ code: 'FIELD_REQUIRED', field: 'newPassword' }) + } else if (values.newPassword.length < 6) { + errors.push({ code: 'INVALID_VALUE', field: 'newPassword' }) + } else if (!values.passwordConfirmation) { + errors.push({ code: 'FIELD_REQUIRED', field: 'passwordConfirmation' }) + } else if (values.newPassword !== values.passwordConfirmation) { + errors.push({ code: 'PASSWORD_MISMATCH', field: 'passwordConfirmation' }) + } + return errors + }} + execute={async ({ values }) => { + return await changePassword(values) + }} + > + {children} + + ) + +} +const errorToField: Partial> = { + PASSWORD_MISMATCH: 'passwordConfirmation', + TOO_WEAK: 'newPassword', + NO_PASSWORD_SET: 'currentPassword', + INVALID_PASSWORD: 'currentPassword', +} diff --git a/packages/react-client-tenant/src/components/forms/CreateApiKeyForm.tsx b/packages/react-client-tenant/src/components/forms/CreateApiKeyForm.tsx new file mode 100644 index 000000000..6b97b7eed --- /dev/null +++ b/packages/react-client-tenant/src/components/forms/CreateApiKeyForm.tsx @@ -0,0 +1,72 @@ +import { ReactElement } from 'react' +import { TenantForm } from './TenantForm' +import { FormContextValue, FormError, FormState } from '../../types' +import { useForm } from '../../contexts' +import { CreateApiKeyErrorCode, MembershipInput } from '@contember/graphql-client-tenant' +import { CreateApiKeyMutationResult, useCreateApiKeyMutation } from '../../hooks' + +export type CreateApiKeyFormValues = { + description: string + memberships: readonly MembershipInput[] +} + +export type CreateApiKeyFormErrorCode = + | CreateApiKeyErrorCode + | 'UNKNOWN_ERROR' + | 'FIELD_REQUIRED' + +export type CreateApiKeyFormState = FormState + +export type CreateApiKeyFormError = FormError + +export type CreateApiKeyFormContextValue = FormContextValue + +export interface CreateApiKeyFormProps { + children: ReactElement + projectSlug: string + initialMemberships?: readonly MembershipInput[] + onSuccess?: (args: { result: CreateApiKeyMutationResult }) => void +} + +export const useCreateApiKeyForm = useForm as () => CreateApiKeyFormContextValue + +export const CreateApiKeyForm = ({ children, onSuccess, projectSlug, initialMemberships }: CreateApiKeyFormProps) => { + const createApiKey = useCreateApiKeyMutation() + return ( + + onSuccess={onSuccess} + initialValues={{ + description: '', + memberships: initialMemberships || [], + }} + validate={({ values }) => { + const errors: CreateApiKeyFormError[] = [] + if (!values.description) { + errors.push({ code: 'FIELD_REQUIRED', field: 'description' }) + } + if (values.memberships.length === 0) { + errors.push({ code: 'FIELD_REQUIRED', field: 'memberships' }) + } + return errors + }} + execute={async ({ values }) => { + return await createApiKey({ + description: values.description, + projectSlug, + memberships: values.memberships, + }) + }} + errorMapping={errorToField} + > + {children} + + ) +} + +const errorToField: Record = { + INVALID_MEMBERSHIP: 'memberships', + PROJECT_NOT_FOUND: undefined, + ROLE_NOT_FOUND: 'memberships', + VARIABLE_EMPTY: 'memberships', + VARIABLE_NOT_FOUND: 'memberships', +} diff --git a/packages/react-client-tenant/src/components/forms/InviteForm.tsx b/packages/react-client-tenant/src/components/forms/InviteForm.tsx new file mode 100644 index 000000000..2dd364413 --- /dev/null +++ b/packages/react-client-tenant/src/components/forms/InviteForm.tsx @@ -0,0 +1,79 @@ +import { ReactElement } from 'react' +import { TenantForm } from './TenantForm' +import { FormContextValue, FormError, FormState } from '../../types' +import { useForm } from '../../contexts' +import { InviteErrorCode, InviteOptions, MembershipInput } from '@contember/graphql-client-tenant' +import { InviteMutationResult, useInviteMutation } from '../../hooks' + +export type InviteFormValues = { + email: string + name: string + memberships: readonly MembershipInput[] +} + +export type InviteFormErrorCode = + | InviteErrorCode + | 'UNKNOWN_ERROR' + | 'FIELD_REQUIRED' + +export type InviteFormState = FormState + +export type InviteFormError = FormError + +export type InviteFormContextValue = FormContextValue + +export interface InviteFormProps { + children: ReactElement + projectSlug: string + inviteOptions?: InviteOptions + initialMemberships?: readonly MembershipInput[] + onSuccess?: (args: { result: InviteMutationResult }) => void +} + +export const useInviteForm = useForm as () => InviteFormContextValue + +export const InviteForm = ({ children, onSuccess, projectSlug, initialMemberships, inviteOptions }: InviteFormProps) => { + const invite = useInviteMutation() + return ( + + onSuccess={onSuccess} + initialValues={{ + email: '', + name: '', + memberships: initialMemberships || [], + }} + validate={({ values }) => { + const errors: InviteFormError[] = [] + if (!values.email) { + errors.push({ code: 'FIELD_REQUIRED', field: 'email' }) + } + if (values.memberships.length === 0) { + errors.push({ code: 'FIELD_REQUIRED', field: 'memberships' }) + } + return errors + }} + execute={async ({ values }) => { + return await invite({ + email: values.email, + name: values.name, + projectSlug, + options: inviteOptions, + memberships: values.memberships, + }) + }} + errorMapping={errorToField} + > + {children} + + ) +} + +const errorToField: Record = { + ALREADY_MEMBER: 'email', + INVALID_EMAIL_FORMAT: 'email', + INVALID_MEMBERSHIP: 'memberships', + PROJECT_NOT_FOUND: undefined, + ROLE_NOT_FOUND: 'memberships', + VARIABLE_EMPTY: 'memberships', + VARIABLE_NOT_FOUND: 'memberships', +} diff --git a/packages/react-client-tenant/src/components/forms/LoginForm.tsx b/packages/react-client-tenant/src/components/forms/LoginForm.tsx new file mode 100644 index 000000000..018b0f5d8 --- /dev/null +++ b/packages/react-client-tenant/src/components/forms/LoginForm.tsx @@ -0,0 +1,110 @@ +import { ReactElement, useCallback, useMemo } from 'react' +import * as TenantApi from '@contember/graphql-client-tenant' +import { TenantForm } from './TenantForm' +import { SignInErrorCode } from '@contember/graphql-client-tenant' +import { FormContextValue, FormError, FormState } from '../../types' +import { useForm } from '../../contexts' +import { useSetSessionToken } from '@contember/react-client' +import { SignInMutationResult, useSignInMutation } from '../../hooks' +import { useRedirectToBacklinkCallback } from '../../internal/hooks/useRedirectToBacklink' + + +export type LoginFormValues = { + email: string + password: string + otpToken: string +} + +export type LoginFormErrorCode = + | SignInErrorCode + | 'FIELD_REQUIRED' + | 'INVALID_VALUE' + | 'UNKNOWN_ERROR' + +export type LoginFormState = + | FormState + | 'otp-required' + +export type LoginFormError = FormError + +export type LoginFormContextValue = FormContextValue + +export interface LoginFormProps { + expiration?: number + children: ReactElement + onSuccess?: () => void +} + +export const useLoginForm = useForm as () => LoginFormContextValue + +const headers = { + 'X-Contember-Token-Path': 'data.signIn.result.token', +} + +const DEFAULT_LOGIN_EXPIRATION = 14 * 24 * 3600 // 14 days + +export const LoginForm = ({ children, expiration = DEFAULT_LOGIN_EXPIRATION, onSuccess }: LoginFormProps) => { + const login = useSignInMutation({ headers }) + const redirectToBacklink = useRedirectToBacklinkCallback() + const setSessionToken = useSetSessionToken() + return ( + + onSuccess={args => { + onSuccess?.() + setSessionToken(args.result.token) + redirectToBacklink() + }} + errorMapping={errorToField} + initialValues={{ + email: '', + password: '', + otpToken: '', + }} + validate={({ values, state }) => { + const errors: LoginFormError[] = [] + if (!values.email) { + errors.push({ code: 'FIELD_REQUIRED', field: 'email' }) + } else if (!values.email.match(/^.+@.+$/)) { + errors.push({ code: 'INVALID_VALUE', field: 'email' }) + } + + if (!values.password) { + errors.push({ code: 'FIELD_REQUIRED', field: 'password' }) + } else if (values.password.length < 6) { + errors.push({ code: 'INVALID_VALUE', field: 'password' }) + } + if (!values.otpToken && state === 'otp-required') { + errors.push({ code: 'FIELD_REQUIRED', field: 'otpToken' }) + } else if (values.otpToken && !values.otpToken.match(/^\d{6}$/)) { + errors.push({ code: 'INVALID_VALUE', field: 'otpToken' }) + } + return errors + }} + execute={async ({ values }) => { + const result = await login({ + email: values.email!, + password: values.password!, + otpToken: values.otpToken || undefined, + expiration: expiration, + }) + + if (!result.ok && result.error === 'OTP_REQUIRED') { + return { ...result, state: 'otp-required' } + } + + return result + }} + > + {children} + + ) + +} +const errorToField: Record = { + 'INVALID_PASSWORD': 'password', + 'NO_PASSWORD_SET': 'password', + 'UNKNOWN_EMAIL': 'email', + 'PERSON_DISABLED': 'email', + 'INVALID_OTP_TOKEN': 'otpToken', + 'OTP_REQUIRED': undefined, +} diff --git a/packages/react-client-tenant/src/components/forms/OtpConfirmForm.tsx b/packages/react-client-tenant/src/components/forms/OtpConfirmForm.tsx new file mode 100644 index 000000000..cbf04a12c --- /dev/null +++ b/packages/react-client-tenant/src/components/forms/OtpConfirmForm.tsx @@ -0,0 +1,61 @@ +import { ReactElement } from 'react' +import { TenantForm } from './TenantForm' +import { FormContextValue, FormError, FormState } from '../../types' +import { useForm, useIdentityMethods } from '../../contexts' +import { ConfirmOtpErrorCode } from '@contember/graphql-client-tenant' +import { useConfirmOtpMutation } from '../../hooks' + +export type OtpConfirmFormValues = { + otpToken: string +} + +export type OtpConfirmFormErrorCode = + | ConfirmOtpErrorCode + | 'FIELD_REQUIRED' + | 'UNKNOWN_ERROR' + +export type OtpConfirmFormState = FormState + +export type OtpConfirmFormError = FormError + +export type OtpConfirmFormContextValue = FormContextValue + +export interface OtpConfirmFormProps { + children: ReactElement + onSuccess?: () => void +} + +export const useOtpConfirmForm = useForm as () => OtpConfirmFormContextValue + +export const OtpConfirmForm = ({ children, onSuccess }: OtpConfirmFormProps) => { + const confirmOtp = useConfirmOtpMutation() + const { refreshIdentity } = useIdentityMethods() + return ( + + onSuccess={async args => { + await refreshIdentity() + onSuccess?.() + }} + initialValues={{ + otpToken: '', + }} + validate={({ values }) => { + if (!values.otpToken) { + return [{ code: 'FIELD_REQUIRED', field: 'otpToken' }] + } + }} + execute={async ({ values }) => { + return await confirmOtp({ + otpToken: values.otpToken, + }) + }} + errorMapping={{ + INVALID_OTP_TOKEN: 'otpToken', + NOT_PREPARED: 'otpToken', + }} + > + {children} + + ) +} + diff --git a/packages/react-client-tenant/src/components/forms/OtpPrepareForm.tsx b/packages/react-client-tenant/src/components/forms/OtpPrepareForm.tsx new file mode 100644 index 000000000..c499adf1a --- /dev/null +++ b/packages/react-client-tenant/src/components/forms/OtpPrepareForm.tsx @@ -0,0 +1,45 @@ +import { ReactElement, useCallback, useMemo } from 'react' +import { TenantForm } from './TenantForm' +import { FormContextValue, FormError, FormState } from '../../types' +import { useForm } from '../../contexts' +import { PrepareOtpMutationResult, usePrepareOtpMutation } from '../../hooks' + +export type OtpPrepareFormValues = { + label: string +} + +export type OtpPrepareFormErrorCode = + | 'UNKNOWN_ERROR' + +export type OtpPrepareFormState = FormState + +export type OtpPrepareFormError = FormError + +export type OtpPrepareFormContextValue = FormContextValue + +export interface OtpPrepareFormProps { + children: ReactElement + onSuccess?: (args: { result: PrepareOtpMutationResult }) => void +} + +export const useOtpPrepareForm = useForm as () => OtpPrepareFormContextValue + +export const OtpPrepareForm = ({ children, onSuccess }: OtpPrepareFormProps) => { + const prepareOtp = usePrepareOtpMutation() + return ( + + onSuccess={onSuccess} + initialValues={{ + label: window.location.hostname, + }} + execute={async ({ values }) => { + return await prepareOtp({ + label: values.label, + }) + }} + > + {children} + + ) +} + diff --git a/packages/react-client-tenant/src/components/forms/PasswordResetForm.tsx b/packages/react-client-tenant/src/components/forms/PasswordResetForm.tsx new file mode 100644 index 000000000..c16a34ef0 --- /dev/null +++ b/packages/react-client-tenant/src/components/forms/PasswordResetForm.tsx @@ -0,0 +1,80 @@ +import { ReactElement } from 'react' +import * as TenantApi from '@contember/graphql-client-tenant' +import { ResetPasswordErrorCode } from '@contember/graphql-client-tenant' +import { TenantForm } from './TenantForm' +import { FormContextValue, FormError, FormState } from '../../types' +import { useForm } from '../../contexts' +import { useResetPasswordMutation } from '../../hooks' + +export type PasswordResetFormValues = { + token: string + password: string + passwordConfirmation: string +} + +export type PasswordResetFormErrorCode = + | ResetPasswordErrorCode + | 'FIELD_REQUIRED' + | 'INVALID_VALUE' + | 'PASSWORD_MISMATCH' + | 'UNKNOWN_ERROR' + +export type PasswordResetFormState = FormState + +export type PasswordResetFormError = FormError + +export type PasswordResetFormContextValue = FormContextValue + +export interface PasswordResetFormProps { + children: ReactElement + onSuccess?: () => void + token?: string +} + +export const usePasswordResetForm = useForm as () => PasswordResetFormContextValue + +export const PasswordResetForm = ({ children, onSuccess, token }: PasswordResetFormProps) => { + const createReset = useResetPasswordMutation() + return ( + + onSuccess={onSuccess} + errorMapping={errorToField} + initialValues={{ + token: token ?? '', + password: '', + passwordConfirmation: '', + }} + validate={({ values }) => { + const errors: PasswordResetFormError[] = [] + if (!values.token) { + errors.push({ code: 'FIELD_REQUIRED', field: 'token' }) + } + if (!values.password) { + errors.push({ code: 'FIELD_REQUIRED', field: 'password' }) + } else if (values.password.length < 6) { + errors.push({ code: 'INVALID_VALUE', field: 'password' }) + } else if (!values.passwordConfirmation) { + errors.push({ code: 'FIELD_REQUIRED', field: 'passwordConfirmation' }) + } else if (values.password !== values.passwordConfirmation) { + errors.push({ code: 'PASSWORD_MISMATCH', field: 'passwordConfirmation' }) + } + return errors + }} + execute={async ({ values }) => { + return await createReset({ + password: values.password, + token: values.token, + }) + }} + > + {children} + + ) + +} +const errorToField: Record = { + PASSWORD_TOO_WEAK: 'password', + TOKEN_EXPIRED: 'token', + TOKEN_NOT_FOUND: 'token', + TOKEN_USED: 'token', +} diff --git a/packages/react-client-tenant/src/components/forms/PasswordResetRequestForm.tsx b/packages/react-client-tenant/src/components/forms/PasswordResetRequestForm.tsx new file mode 100644 index 000000000..542c6d625 --- /dev/null +++ b/packages/react-client-tenant/src/components/forms/PasswordResetRequestForm.tsx @@ -0,0 +1,64 @@ +import { ReactElement, useCallback, useMemo } from 'react' +import * as TenantApi from '@contember/graphql-client-tenant' +import { CreatePasswordResetRequestErrorCode } from '@contember/graphql-client-tenant' +import { TenantForm } from './TenantForm' +import { FormContextValue, FormError, FormState } from '../../types/forms' +import { useForm } from '../../contexts' +import { useCreateResetPasswordRequestMutation } from '../../hooks' + +export type PasswordResetRequestFormValues = { + email: string +} + +export type PasswordResetRequestFormErrorCode = + | CreatePasswordResetRequestErrorCode + | 'FIELD_REQUIRED' + | 'INVALID_VALUE' + | 'UNKNOWN_ERROR' + +export type PasswordResetRequestFormState = FormState + +export type PasswordResetRequestFormError = FormError + +export type PasswordResetRequestFormContextValue = FormContextValue + + +export interface PasswordResetRequestFormProps { + children: ReactElement + onSuccess?: () => void +} + +export const usePasswordResetRequestForm = useForm as () => PasswordResetRequestFormContextValue + +export const PasswordResetRequestForm = ({ children, onSuccess }: PasswordResetRequestFormProps) => { + + const createResetRequest = useCreateResetPasswordRequestMutation() + return ( + + onSuccess={onSuccess} + errorMapping={errorToField} + initialValues={useMemo(() => ({ + email: '', + }), [])} + validate={({ values }) => { + if (!values.email) { + return [{ code: 'FIELD_REQUIRED', field: 'email' }] + } else if (!values.email.match(/^.+@.+$/)) { + return [{ code: 'INVALID_VALUE', field: 'email' }] + } + return [] + }} + execute={useCallback(async ({ values }) => { + return await createResetRequest({ + email: values.email, + }) + }, [createResetRequest])} + > + {children} + + ) + +} +const errorToField: Record = { + PERSON_NOT_FOUND: 'email', +} diff --git a/packages/react-client-tenant/src/components/forms/TenantForm.tsx b/packages/react-client-tenant/src/components/forms/TenantForm.tsx new file mode 100644 index 000000000..fd98ead04 --- /dev/null +++ b/packages/react-client-tenant/src/components/forms/TenantForm.tsx @@ -0,0 +1,116 @@ +import { ComponentType, ReactElement, useCallback, useEffect, useState } from 'react' +import { Slot } from '@radix-ui/react-slot' +import { FormContextValue } from '../../types/forms' +import { FormContext } from '../../contexts' +import { useReferentiallyStableCallback } from '@contember/react-utils' + +const SlotForm = Slot as ComponentType> + + +type ExecuteResult, OkResult = undefined> = + | ({ ok: true } & (OkResult extends undefined ? {} : { result: OkResult })) + | { ok: false, error?: T['errors'][number]['code'], developerMessage?: string, state?: T['state'] } + +export interface TenantFormProps, OkResult = undefined> { + children: ReactElement + loading?: boolean + initialValues: T['values'] + errorMapping?: Partial> + validate?: (args: { values: T['values'], state: T['state'] }) => T['errors'] | undefined + execute: (args: { values: T['values'], state: T['state'] }) => Promise> + onSuccess?: (args: { result: OkResult }) => void + onChange?: (args: { values: T['values'], state: T['state'], submit: () => Promise }) => void +} + +export const TenantForm = , OkResult = undefined>({ + children, + initialValues, + loading, + errorMapping, + validate: validateIn, + execute: executeIn, + onSuccess: onSuccessIn, + onChange: onChangeIn, +}: TenantFormProps) => { + const [values, setValues] = useState(initialValues) + + const [errors, setErrors] = useState([]) + const [state, setState] = useState(loading ? 'loading' : 'initial') + + useEffect(() => { + if (loading) { + setState('loading') + } + }, [loading]) + + useEffect(() => { + if (state === 'loading' && !loading) { + setValues(initialValues) + setState('initial') + } + }, [initialValues, loading, state]) + + const validate = useReferentiallyStableCallback(validateIn || (() => undefined)) + const onSuccess = useReferentiallyStableCallback(onSuccessIn || (() => undefined)) + const execute = useReferentiallyStableCallback(executeIn) + + const submit = useReferentiallyStableCallback(async (event?: React.FormEvent) => { + event?.preventDefault() + const errors = validate?.({ values, state }) ?? [] + setErrors(errors) + + if (errors.length > 0) { + setState('error') + return + } + + setState('submitting') + + try { + const response = await execute({ values, state }) + + if (!response.ok) { + const error = response.error + setState(response.state ?? 'error') + setErrors([{ + code: error || 'UNKNOWN_ERROR', + developerMessage: response.developerMessage, + field: errorMapping && error && error in errorMapping ? errorMapping[error] : undefined, + }]) + } else { + setState('success') + setValues(initialValues) + onSuccess?.({ result: 'result' in response ? response.result : undefined as OkResult }) + } + } catch (e) { + console.error(e) + setState('error') + setErrors([{ + code: 'UNKNOWN_ERROR', + developerMessage: typeof e === 'string' ? e : (e && typeof e === 'object' && 'message' in e && typeof e.message === 'string') ? e.message : undefined, + }]) + } + }) + + + const onChange = useReferentiallyStableCallback(onChangeIn || (() => undefined)) + useEffect(() => { + onChange?.({ values, state, submit }) + }, [onChange, state, submit, values]) + + const setValue = useCallback((field: string, value: string) => setValues(values => ({ ...values, [field]: value })), []) + + return ( + + + {children} + + + ) +} diff --git a/packages/react-client-tenant/src/components/forms/UpdateProjectMemberForm.tsx b/packages/react-client-tenant/src/components/forms/UpdateProjectMemberForm.tsx new file mode 100644 index 000000000..5899e90fe --- /dev/null +++ b/packages/react-client-tenant/src/components/forms/UpdateProjectMemberForm.tsx @@ -0,0 +1,77 @@ +import { ReactElement, useEffect, useState } from 'react' +import { TenantForm } from './TenantForm' +import { FormContextValue, FormError, FormState } from '../../types' +import { useForm } from '../../contexts' +import { UpdateProjectMemberErrorCode, MembershipInput } from '@contember/graphql-client-tenant' +import { useProjectMembershipsQuery, useTenantQueryLoader, useUpdateProjectMemberMutation } from '../../hooks' + +export type UpdateProjectMemberFormValues = { + memberships: readonly MembershipInput[] +} + +export type UpdateProjectMemberFormErrorCode = + | UpdateProjectMemberErrorCode + | 'UNKNOWN_ERROR' + | 'FIELD_REQUIRED' + +export type UpdateProjectMemberFormState = FormState + +export type UpdateProjectMemberFormError = FormError + +export type UpdateProjectMemberFormContextValue = FormContextValue + +export interface UpdateProjectMemberFormProps { + children: ReactElement + identityId: string + projectSlug: string + onSuccess?: (args: { }) => void +} + +export const useUpdateProjectMemberForm = useForm as () => UpdateProjectMemberFormContextValue + +export const UpdateProjectMemberForm = ({ children, onSuccess, identityId, projectSlug }: UpdateProjectMemberFormProps) => { + const updateProjectMember = useUpdateProjectMemberMutation() + const [state] = useTenantQueryLoader(useProjectMembershipsQuery(), { projectSlug, identityId }) + const [memberships, setMemberships] = useState() + + useEffect(() => { + setMemberships(state.state === 'success' ? state.data : undefined) + }, [state]) + + return ( + + onSuccess={onSuccess} + loading={memberships === undefined} + initialValues={memberships ? { memberships } : { memberships: [] }} + onChange={({ values, submit }) => { + setMemberships(values?.memberships) + }} + validate={({ values }) => { + const errors: UpdateProjectMemberFormError[] = [] + if (values.memberships.length === 0) { + errors.push({ code: 'FIELD_REQUIRED', field: 'memberships' }) + } + return errors + }} + execute={async ({ values }) => { + return await updateProjectMember({ + identityId, + projectSlug, + memberships: values.memberships, + }) + }} + errorMapping={errorToField} + > + {children} + + ) +} + +const errorToField: Record = { + INVALID_MEMBERSHIP: 'memberships', + PROJECT_NOT_FOUND: undefined, + ROLE_NOT_FOUND: 'memberships', + VARIABLE_EMPTY: 'memberships', + VARIABLE_NOT_FOUND: 'memberships', + NOT_MEMBER: undefined, +} diff --git a/packages/react-client-tenant/src/components/forms/index.ts b/packages/react-client-tenant/src/components/forms/index.ts new file mode 100644 index 000000000..9ada61f03 --- /dev/null +++ b/packages/react-client-tenant/src/components/forms/index.ts @@ -0,0 +1,9 @@ +export * from './CreateApiKeyForm' +export * from './ChangeMyPasswordForm' +export * from './InviteForm' +export * from './LoginForm' +export * from './PasswordResetForm' +export * from './PasswordResetRequestForm' +export * from './OtpPrepareForm' +export * from './OtpConfirmForm' +export * from './UpdateProjectMemberForm' diff --git a/packages/react-client-tenant/src/components/idp/IDP.tsx b/packages/react-client-tenant/src/components/idp/IDP.tsx new file mode 100644 index 000000000..8b3f6231d --- /dev/null +++ b/packages/react-client-tenant/src/components/idp/IDP.tsx @@ -0,0 +1,65 @@ +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { IDPMethodsContextProvider, IDPStateContextProvider } from '../../contexts' +import { IDPInitError, IDPResponseError, IDPStateValue } from '../../types/idp' +import { useHandleIDPResponse } from '../../internal/hooks/useHandleIDPResponse' +import { useIDPAutoInitProvider } from '../../internal/hooks/useIDPAutoInit' +import { useInitIDPRedirect } from '../../internal/hooks/useInitIDPRedirect' + +export interface IDPProps { + children: ReactNode + onLogin?: () => void + onInitError?: (error: IDPInitError) => void + onResponseError?: (error: IDPResponseError) => void +} + +export const IDP = ({ children, onResponseError, onInitError, onLogin }: IDPProps) => { + const hasOauthResponse = useMemo(() => { + const params = new URLSearchParams(window.location.search) + return params.has('state') && (params.has('code') || params.has('id_token')) + }, []) + + const autoInit = useIDPAutoInitProvider() + const [state, setState] = useState(() => { + if (hasOauthResponse) { + return { type: 'processing_response' } + } + if (autoInit) { + return { type: 'processing_init' } + } + return { type: 'nothing' } + }) + useHandleIDPResponse({ hasOauthResponse, setState, onLogin }) + + const initRedirect = useInitIDPRedirect({ setState }) + + const isFirstRender = useRef(true) + useEffect(() => { + if (!autoInit) { + return + } + if (!isFirstRender.current) { + return + } + isFirstRender.current = false + initRedirect({ provider: autoInit }) + }, [autoInit, initRedirect]) + + useEffect(() => { + if (state.type === 'init_failed' && onInitError) { + onInitError(state.error) + } + if (state.type === 'response_failed' && onResponseError) { + onResponseError(state.error) + } + }, [onInitError, onResponseError, state]) + + + return ( + + + {children} + + + ) + +} diff --git a/packages/react-client-tenant/src/components/idp/IDPInitTrigger.tsx b/packages/react-client-tenant/src/components/idp/IDPInitTrigger.tsx new file mode 100644 index 000000000..404969996 --- /dev/null +++ b/packages/react-client-tenant/src/components/idp/IDPInitTrigger.tsx @@ -0,0 +1,17 @@ +import { Slot } from '@radix-ui/react-slot' +import { ComponentType, ReactElement } from 'react' +import { useIDPMethods } from '../../contexts' + +const SlotButton = Slot as ComponentType> + +export interface IDPInitTriggerProps { + children: ReactElement + identityProvider: string +} + +export const IDPInitTrigger = ({ identityProvider, ...props }: IDPInitTriggerProps) => { + const initIdp = useIDPMethods().initRedirect + return ( + initIdp({ provider: identityProvider })} {...props} /> + ) +} diff --git a/packages/react-client-tenant/src/components/idp/IDPState.tsx b/packages/react-client-tenant/src/components/idp/IDPState.tsx new file mode 100644 index 000000000..487e6f7c7 --- /dev/null +++ b/packages/react-client-tenant/src/components/idp/IDPState.tsx @@ -0,0 +1,14 @@ +import React, { ReactNode } from 'react' +import { IDPStateType } from '../../types' +import { useIDPState } from '../../contexts' + +export interface IDPStateProps { + state: IDPStateType | IDPStateType[] + children: ReactNode +} + +export const IDPState = ({ state, children }: IDPStateProps) => { + const currentState = useIDPState().type + + return state === currentState || (Array.isArray(state) && state.includes(currentState)) ? <>{children} : null +} diff --git a/packages/react-client-tenant/src/components/idp/index.ts b/packages/react-client-tenant/src/components/idp/index.ts new file mode 100644 index 000000000..694a6b9d8 --- /dev/null +++ b/packages/react-client-tenant/src/components/idp/index.ts @@ -0,0 +1,3 @@ +export * from './IDPState' +export * from './IDP' +export * from './IDPInitTrigger' diff --git a/packages/react-client-tenant/src/components/index.ts b/packages/react-client-tenant/src/components/index.ts new file mode 100644 index 000000000..83de4f535 --- /dev/null +++ b/packages/react-client-tenant/src/components/index.ts @@ -0,0 +1,5 @@ +export * from './IdentityProvider' +export * from './IdentityState' +export * from './forms' +export * from './idp' +export * from './triggers' diff --git a/packages/react-client-tenant/src/components/triggers/DisableOtpTrigger.tsx b/packages/react-client-tenant/src/components/triggers/DisableOtpTrigger.tsx new file mode 100644 index 000000000..c272040c9 --- /dev/null +++ b/packages/react-client-tenant/src/components/triggers/DisableOtpTrigger.tsx @@ -0,0 +1,25 @@ +import { ReactElement, useCallback } from 'react' +import { useDisableOtpMutation } from '../../hooks' +import { TenantActionTrigger } from './TenantActionTrigger' +import { useIdentityMethods } from '../../contexts' + +export interface DisableOtpTriggerProps { + children: ReactElement + onSuccess?: () => void + onError?: (e: unknown) => void +} + +export const DisableOtpTrigger = ({ onSuccess, ...props }: DisableOtpTriggerProps) => { + const disableOtp = useDisableOtpMutation() + const { refreshIdentity } = useIdentityMethods() + + return ( + disableOtp({}), [disableOtp])} + onSuccess={() => { + refreshIdentity() + onSuccess?.() + }} {...props} + /> + ) +} diff --git a/packages/react-identity/src/components/LogoutTrigger.tsx b/packages/react-client-tenant/src/components/triggers/LogoutTrigger.tsx similarity index 84% rename from packages/react-identity/src/components/LogoutTrigger.tsx rename to packages/react-client-tenant/src/components/triggers/LogoutTrigger.tsx index a8d35d3b3..e455023b5 100644 --- a/packages/react-identity/src/components/LogoutTrigger.tsx +++ b/packages/react-client-tenant/src/components/triggers/LogoutTrigger.tsx @@ -1,6 +1,6 @@ import { ReactNode, useCallback } from 'react' import { Slot } from '@radix-ui/react-slot' -import { useLogout } from '../hooks' +import { useLogout } from '../../hooks/useLogout' export const LogoutTrigger = ({ children }: { children: ReactNode diff --git a/packages/react-client-tenant/src/components/triggers/RemoveProjectMemberTrigger.tsx b/packages/react-client-tenant/src/components/triggers/RemoveProjectMemberTrigger.tsx new file mode 100644 index 000000000..e97ee2f0f --- /dev/null +++ b/packages/react-client-tenant/src/components/triggers/RemoveProjectMemberTrigger.tsx @@ -0,0 +1,18 @@ +import { ReactElement, useCallback } from 'react' +import { TenantActionTrigger } from './TenantActionTrigger' +import { RemoveProjectMemberMutationVariables, useRemoveProjectMemberMutation } from '../../hooks' + +export type RemoveProjectMemberTriggerProps = + & RemoveProjectMemberMutationVariables + & { + children: ReactElement + onSuccess?: () => void + onError?: (e: unknown) => void + } + + +export const RemoveProjectMemberTrigger = ({ identityId, projectSlug, ...props }: RemoveProjectMemberTriggerProps) => { + const removeProjectMember = useRemoveProjectMemberMutation() + const execute = useCallback(async () => await removeProjectMember({ projectSlug, identityId }), [identityId, projectSlug, removeProjectMember]) + return +} diff --git a/packages/react-client-tenant/src/components/triggers/TenantActionTrigger.tsx b/packages/react-client-tenant/src/components/triggers/TenantActionTrigger.tsx new file mode 100644 index 000000000..fda5501a4 --- /dev/null +++ b/packages/react-client-tenant/src/components/triggers/TenantActionTrigger.tsx @@ -0,0 +1,41 @@ +import { Slot } from '@radix-ui/react-slot' +import { ComponentType, MouseEventHandler, ReactElement, useMemo, useState } from 'react' +import { composeEventHandlers } from '@radix-ui/primitive' +import { useReferentiallyStableCallback } from '@contember/react-utils' + +const SlotButton = Slot as ComponentType> + +export interface TenantActionTriggerProps { + children: ReactElement + onClick?: MouseEventHandler + onSuccess?: (args: {result: OkResult}) => void + onError?: (args: { code: Error | 'UNKNOWN_ERROR', error: unknown }) => void + execute: () => Promise<({ ok: true } & (OkResult extends undefined ? {} : { result: OkResult })) | { ok: false, error?: Error }> +} + +export const TenantActionTrigger = ({ onError: onErrorIn, onSuccess: onSuccessIn, onClick: onClickProp, execute: executeIn, ...props }: TenantActionTriggerProps) => { + const [submitting, setSubmitting] = useState(false) + + const onSuccess = useReferentiallyStableCallback(onSuccessIn || (() => undefined)) + const onError = useReferentiallyStableCallback(onErrorIn || (() => undefined)) + const execute = useReferentiallyStableCallback(executeIn) + + const onClick = useMemo(() => async () => { + setSubmitting(true) + try { + const response = await execute() + setSubmitting(false) + if (response.ok) { + onSuccess?.({ result: 'result' in response ? response.result : undefined as OkResult }) + } else { + onError?.({ code: response.error ?? 'UNKNOWN_ERROR', error: response }) + } + } catch (e) { + console.error(e) + setSubmitting(false) + onError?.({ code: 'UNKNOWN_ERROR', error: e }) + } + }, [execute, onError, onSuccess]) + + return +} diff --git a/packages/react-client-tenant/src/components/triggers/index.ts b/packages/react-client-tenant/src/components/triggers/index.ts new file mode 100644 index 000000000..8483c6b57 --- /dev/null +++ b/packages/react-client-tenant/src/components/triggers/index.ts @@ -0,0 +1,3 @@ +export * from './DisableOtpTrigger' +export * from './LogoutTrigger' +export * from './RemoveProjectMemberTrigger' diff --git a/packages/react-client-tenant/src/contexts.ts b/packages/react-client-tenant/src/contexts.ts new file mode 100644 index 000000000..1d6ecc832 --- /dev/null +++ b/packages/react-client-tenant/src/contexts.ts @@ -0,0 +1,34 @@ +import { createContext, createRequiredContext } from '@contember/react-utils' +import { FormContextValue, Identity, IdentityMethods, IdentityStateValue, IDPMethods, IDPStateValue } from './types' + +const IdentityContext_ = createContext('IdentityContext', undefined) +/** @internal */ +export const IdentityContext = IdentityContext_[0] +export const useIdentity = IdentityContext_[1] + +const IdentityMethodsContext_ = createRequiredContext('IdentityMethodsContext') +/** @internal */ +export const IdentityMethodsContext = IdentityMethodsContext_[0] +export const useIdentityMethods = IdentityMethodsContext_[1] + +const IdentityStateContext_ = createRequiredContext('IdentityStateContext') +/** @internal */ +export const IdentityStateContext = IdentityStateContext_[0] +export const useIdentityState = IdentityStateContext_[1] + + +const FormContext_ = createRequiredContext>('FormContext') +/** @internal */ +export const FormContext = FormContext_[0] +export const useForm = FormContext_[1] + + +const IDPStateContext = createRequiredContext('IDPStateContext') +/** @internal */ +export const IDPStateContextProvider = IDPStateContext[0] +export const useIDPState = IDPStateContext[1] + +const IDPMethodsContext = createRequiredContext('IDPMethodsContext') +/** @internal */ +export const IDPMethodsContextProvider = IDPMethodsContext[0] +export const useIDPMethods = IDPMethodsContext[1] diff --git a/packages/react-client-tenant/src/hooks/index.ts b/packages/react-client-tenant/src/hooks/index.ts index d34523eb6..fc0431c3a 100644 --- a/packages/react-client-tenant/src/hooks/index.ts +++ b/packages/react-client-tenant/src/hooks/index.ts @@ -1,5 +1,7 @@ export * from './mutations' export * from './queries' +export * from './useFetchIdentity' +export * from './useLogout' export * from './useTenantApi' export * from './useTenantQueryLoader' export * from './useTenantMutation' diff --git a/packages/react-identity/src/hooks/useFetchIdentity.ts b/packages/react-client-tenant/src/hooks/useFetchIdentity.ts similarity index 93% rename from packages/react-identity/src/hooks/useFetchIdentity.ts rename to packages/react-client-tenant/src/hooks/useFetchIdentity.ts index 9f89c7305..6006023b7 100644 --- a/packages/react-identity/src/hooks/useFetchIdentity.ts +++ b/packages/react-client-tenant/src/hooks/useFetchIdentity.ts @@ -1,12 +1,12 @@ import { useCallback, useMemo, useState } from 'react' import { Identity, IdentityMethods, IdentityStateValue } from '../types' import { useSessionToken } from '@contember/react-client' -import { useFetchMe } from '../internal/hooks/useFetchMe' import { useLogoutInternal } from '../internal/hooks/useLogoutInternal' +import { useMeQuery } from './queries' export const useFetchIdentity = (): [{ state: IdentityStateValue, identity: Identity | undefined }, IdentityMethods] => { const sessionToken = useSessionToken() - const fetchMe = useFetchMe() + const fetchMe = useMeQuery() const [identityState, setIdentityState] = useState[0]>({ state: sessionToken ? 'loading' : 'none', identity: undefined }) @@ -16,7 +16,7 @@ export const useFetchIdentity = (): [{ state: IdentityStateValue, identity: Iden const fetch = useCallback(async () => { setIdentityState({ state: 'loading', identity: undefined }) try { - const response = await fetchMe() + const response = await fetchMe({}) const person = response.person const projects = response.projects const permissions = response.permissions ?? { canCreateProject: false } diff --git a/packages/react-identity/src/hooks/useLogout.ts b/packages/react-client-tenant/src/hooks/useLogout.ts similarity index 77% rename from packages/react-identity/src/hooks/useLogout.ts rename to packages/react-client-tenant/src/hooks/useLogout.ts index 1a25a6945..f27b48105 100644 --- a/packages/react-identity/src/hooks/useLogout.ts +++ b/packages/react-client-tenant/src/hooks/useLogout.ts @@ -1,4 +1,4 @@ -import { useIdentityMethods } from '../internal/contexts' +import { useIdentityMethods } from '../contexts' import { useLogoutInternal } from '../internal/hooks/useLogoutInternal' export const useLogout = () => { diff --git a/packages/react-client-tenant/src/index.ts b/packages/react-client-tenant/src/index.ts index a9da8fe99..c671af505 100644 --- a/packages/react-client-tenant/src/index.ts +++ b/packages/react-client-tenant/src/index.ts @@ -1,3 +1,6 @@ +export * from './components' +export * from './contexts' export * from './hooks' +export * from './types' export type { ModelType } from 'graphql-ts-client-api' diff --git a/packages/react-client-tenant/src/internal/hooks/useHandleIDPResponse.ts b/packages/react-client-tenant/src/internal/hooks/useHandleIDPResponse.ts new file mode 100644 index 000000000..14f0d6420 --- /dev/null +++ b/packages/react-client-tenant/src/internal/hooks/useHandleIDPResponse.ts @@ -0,0 +1,64 @@ +import { SetStateAction, useEffect, useRef } from 'react' +import { useSetSessionToken } from '@contember/react-client' +import { useIDPStateStore } from './useIDPStateStore' +import { getBaseHref } from '../utils/getBaseHref' +import { IDPStateValue } from '../../types/idp' +import { useSignInIDPMutation } from '../../hooks' +import { useRedirectToBacklinkCallback } from './useRedirectToBacklink' + +export interface UseHandleIDPResponseProps { + onLogin?: () => void + expiration?: number + + setState: (state: SetStateAction) => void + hasOauthResponse: boolean +} + +const headers = { + 'X-Contember-Token-Path': 'data.signInIDP.result.token', +} + +const DEFAULT_LOGIN_EXPIRATION = 14 * 24 * 3600 // 14 days + +export const useHandleIDPResponse = ({ onLogin, expiration = DEFAULT_LOGIN_EXPIRATION, setState, hasOauthResponse }: UseHandleIDPResponseProps): void => { + const idpSignIn = useSignInIDPMutation({ headers }) + const setSessionToken = useSetSessionToken() + + const firstRenderRef = useRef(true) + + const { get: loadIdpState } = useIDPStateStore() + const redirectToBackLink = useRedirectToBacklinkCallback() + + useEffect(() => { + if (!hasOauthResponse || !firstRenderRef.current) { + return + } + firstRenderRef.current = false + ;(async () => { + const idpState = loadIdpState() + if (!idpState) { + setState({ type: 'response_failed', error: 'INVALID_LOCAL_STATE' }) + return + } + + const response = await idpSignIn({ + data: { + url: window.location.href, + redirectUrl: getBaseHref(), + sessionData: idpState.sessionData, + }, + identityProvider: idpState.provider, + expiration, + }) + if (!response.ok) { + setState({ type: 'response_failed', error: response.error || 'UNKNOWN_ERROR' }) + } else { + setSessionToken(response.result.token) + setState({ type: 'success' }) + + onLogin?.() + redirectToBackLink() + } + })() + }, [idpSignIn, onLogin, setSessionToken, hasOauthResponse, loadIdpState, expiration, setState, redirectToBackLink]) +} diff --git a/packages/react-client-tenant/src/internal/hooks/useIDPAutoInit.ts b/packages/react-client-tenant/src/internal/hooks/useIDPAutoInit.ts new file mode 100644 index 000000000..c51faaaf5 --- /dev/null +++ b/packages/react-client-tenant/src/internal/hooks/useIDPAutoInit.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react' + +export const useIDPAutoInitProvider = () => { + return useMemo(() => { + const params = new URLSearchParams(window.location.search) + const idp = params.get('idp') + if (idp !== null) { + return idp + } + const backlink = params.get('backlink') + + if (backlink !== null) { + const resolvedBacklink = new URL(backlink, window.location.href) + return resolvedBacklink.searchParams.get('idp') + } + }, []) +} + diff --git a/packages/react-client-tenant/src/internal/hooks/useIDPStateStore.ts b/packages/react-client-tenant/src/internal/hooks/useIDPStateStore.ts new file mode 100644 index 000000000..24d3f642d --- /dev/null +++ b/packages/react-client-tenant/src/internal/hooks/useIDPStateStore.ts @@ -0,0 +1,18 @@ +import { useCallback } from 'react' + +export type IDPState = { provider: string, sessionData: any } + +export const useIDPStateStore = (): {get: () => IDPState | null, set: (state: IDPState) => void} => { + return { + get: useCallback(() => { + const item = sessionStorage.getItem('idp') + if (!item) { + return null + } + return JSON.parse(item) + }, []), + set: useCallback((state: IDPState) => { + sessionStorage.setItem('idp', JSON.stringify(state)) + }, []), + } +} diff --git a/packages/react-client-tenant/src/internal/hooks/useInitIDPRedirect.ts b/packages/react-client-tenant/src/internal/hooks/useInitIDPRedirect.ts new file mode 100644 index 000000000..ece41bd84 --- /dev/null +++ b/packages/react-client-tenant/src/internal/hooks/useInitIDPRedirect.ts @@ -0,0 +1,48 @@ +import { SetStateAction, useCallback } from 'react' +import { useIDPStateStore } from './useIDPStateStore' +import { getBaseHref } from '../utils/getBaseHref' +import { IDPInitError, IDPStateValue } from '../../types/idp' +import { useInitSignInIDPMutation } from '../../hooks' +import { useSaveBacklink } from './useRedirectToBacklink' + +export interface UseInitIDPRedirectProps { + onRedirect?: (url: string) => void + setState: (state: SetStateAction) => void +} + +export const useInitIDPRedirect = ({ onRedirect, setState }: UseInitIDPRedirectProps) => { + const initRequest = useInitSignInIDPMutation() + const saveBacklink = useSaveBacklink() + const { set: saveIdpState } = useIDPStateStore() + + return useCallback(async ({ provider }: { provider: string }): Promise<{ ok: true } | { ok: false, error: IDPInitError }> => { + const fail = (error: IDPInitError) => { + setState({ type: 'init_failed', error }) + return { ok: false, error } + } + try { + setState({ type: 'processing_init' }) + const response = await initRequest({ + data: { + redirectUrl: getBaseHref(), + }, + identityProvider: provider, + }) + if (!response.ok) { + return fail(response.error || 'UNKNOWN_ERROR') + } else { + saveIdpState({ provider, sessionData: response.result.sessionData as string }) + saveBacklink() + if (onRedirect) { + onRedirect(response.result.authUrl as string) + } else { + window.location.href = response.result.authUrl as string + } + return { ok: true } + } + } catch (e) { + console.error(e) + return fail('UNKNOWN_ERROR') + } + }, [initRequest, onRedirect, saveBacklink, saveIdpState, setState]) +} diff --git a/packages/react-identity/src/internal/hooks/useLogoutInternal.ts b/packages/react-client-tenant/src/internal/hooks/useLogoutInternal.ts similarity index 87% rename from packages/react-identity/src/internal/hooks/useLogoutInternal.ts rename to packages/react-client-tenant/src/internal/hooks/useLogoutInternal.ts index 54adfd4d6..c40f9ef99 100644 --- a/packages/react-identity/src/internal/hooks/useLogoutInternal.ts +++ b/packages/react-client-tenant/src/internal/hooks/useLogoutInternal.ts @@ -1,9 +1,9 @@ import { useSetSessionToken } from '@contember/react-client' import { useCallback } from 'react' -import { useSignOut } from './useSignOut' +import { useSignOutMutation } from '../../hooks' export const useLogoutInternal = (clearIdentity?: () => void) => { - const tenantLogout = useSignOut() + const tenantLogout = useSignOutMutation() const setSessionToken = useSetSessionToken() return useCallback( @@ -19,7 +19,7 @@ export const useLogoutInternal = (clearIdentity?: () => void) => { clearIdentity?.() setSessionToken(undefined) try { - const response = await tenantLogout() + const response = await tenantLogout({}) if (!response?.ok) { console.warn(response?.error) } diff --git a/packages/react-client-tenant/src/internal/hooks/useRedirectToBacklink.ts b/packages/react-client-tenant/src/internal/hooks/useRedirectToBacklink.ts new file mode 100644 index 000000000..4bf3fb811 --- /dev/null +++ b/packages/react-client-tenant/src/internal/hooks/useRedirectToBacklink.ts @@ -0,0 +1,42 @@ +import { useCallback, useEffect } from 'react' +import { useIdentity } from '../../contexts' + +const getBacklinkFromUrl = () => { + return new URLSearchParams(window.location.search).get('backlink') +} + +export const useSaveBacklink = () => { + return useCallback(() => { + const backlink = getBacklinkFromUrl() + if (!backlink) { + return + } + sessionStorage.setItem('backlink', backlink) + }, []) +} + +export const useRedirectToBacklinkCallback = () => { + return useCallback(() => { + const backlink = getBacklinkFromUrl() ?? sessionStorage.getItem('backlink') + if (!backlink) { + return + } + sessionStorage.removeItem('backlink') + const resolvedBacklink = new URL(backlink, window.location.href) + if (resolvedBacklink.origin === window.location.origin) { + window.location.href = resolvedBacklink.toString() + } + }, []) +} + +export const useRedirectToBacklink = () => { + const identity = useIdentity() + const redirect = useRedirectToBacklinkCallback() + useEffect(() => { + if (!identity) { + return + } + redirect() + }, [identity, redirect]) + +} diff --git a/packages/react-client-tenant/src/internal/utils/getBaseHref.ts b/packages/react-client-tenant/src/internal/utils/getBaseHref.ts new file mode 100644 index 000000000..20e8542b9 --- /dev/null +++ b/packages/react-client-tenant/src/internal/utils/getBaseHref.ts @@ -0,0 +1,4 @@ +export const getBaseHref = () => { + const href = window.location.href + return href.includes('?') ? href.slice(0, href.indexOf('?')) : href +} diff --git a/packages/react-client-tenant/src/tsconfig.json b/packages/react-client-tenant/src/tsconfig.json index 2699c1868..00cf210ee 100644 --- a/packages/react-client-tenant/src/tsconfig.json +++ b/packages/react-client-tenant/src/tsconfig.json @@ -5,5 +5,6 @@ }, "references": [ { "path": "../../react-client/src" }, + { "path": "../../react-utils/src" }, ] } diff --git a/packages/react-identity/src/types/Identity.ts b/packages/react-client-tenant/src/types/Identity.ts similarity index 100% rename from packages/react-identity/src/types/Identity.ts rename to packages/react-client-tenant/src/types/Identity.ts diff --git a/packages/react-identity/src/types/IdentityMethods.ts b/packages/react-client-tenant/src/types/IdentityMethods.ts similarity index 100% rename from packages/react-identity/src/types/IdentityMethods.ts rename to packages/react-client-tenant/src/types/IdentityMethods.ts diff --git a/packages/react-identity/src/types/IdentityStateValue.ts b/packages/react-client-tenant/src/types/IdentityStateValue.ts similarity index 100% rename from packages/react-identity/src/types/IdentityStateValue.ts rename to packages/react-client-tenant/src/types/IdentityStateValue.ts diff --git a/packages/react-client-tenant/src/types/forms.ts b/packages/react-client-tenant/src/types/forms.ts new file mode 100644 index 000000000..3abbf7f04 --- /dev/null +++ b/packages/react-client-tenant/src/types/forms.ts @@ -0,0 +1,26 @@ +import { SetStateAction } from 'react' + +export type FormState = + | 'loading' + | 'initial' + | 'submitting' + | 'error' + | 'success' + +type FormValueType = Record; + +export type FormErrorCode = 'UNKNOWN_ERROR' + +export type FormError = { + field?: keyof V + code: FormErrorCode | E + developerMessage?: string +}; + +export interface FormContextValue { + values: V + state: FormState | S + setValues: (values: SetStateAction) => void + setValue: (field: F, value: V[F]) => void + errors: FormError[] +} diff --git a/packages/react-client-tenant/src/types/idp.ts b/packages/react-client-tenant/src/types/idp.ts new file mode 100644 index 000000000..36e216a8d --- /dev/null +++ b/packages/react-client-tenant/src/types/idp.ts @@ -0,0 +1,25 @@ +import { InitSignInIDPErrorCode, SignInIDPErrorCode } from '@contember/graphql-client-tenant' + +export type IDPStateValue = + | { type: 'nothing' } + | { type: 'processing_init' } + | { type: 'processing_response' } + | { type: 'success' } + | { type: 'init_failed', error: IDPInitError } + | { type: 'response_failed', error: IDPResponseError } + +export type IDPStateType = IDPStateValue['type'] + +export type IDPInitError = + | InitSignInIDPErrorCode + | 'UNKNOWN_ERROR' + +export type IDPResponseError = + | SignInIDPErrorCode + | 'INVALID_LOCAL_STATE' + | 'UNKNOWN_ERROR' + + +export type IDPMethods = { + initRedirect: (args: { provider: string }) => Promise<{ ok: true } | { ok: false, error: IDPInitError }> +} diff --git a/packages/react-identity/src/types/index.ts b/packages/react-client-tenant/src/types/index.ts similarity index 68% rename from packages/react-identity/src/types/index.ts rename to packages/react-client-tenant/src/types/index.ts index 38b9156d2..4bd35424e 100644 --- a/packages/react-identity/src/types/index.ts +++ b/packages/react-client-tenant/src/types/index.ts @@ -1,3 +1,5 @@ +export * from './forms' export * from './IdentityMethods' export * from './IdentityStateValue' export * from './Identity' +export * from './idp' diff --git a/packages/react-identity/package.json b/packages/react-identity/package.json index fa36e2821..2825e35ff 100644 --- a/packages/react-identity/package.json +++ b/packages/react-identity/package.json @@ -40,12 +40,9 @@ "directory": "packages/react-identity" }, "dependencies": { - "@contember/graphql-client-tenant": "^1.3.7", "@contember/react-binding": "workspace:*", "@contember/react-client": "workspace:*", - "@contember/react-client-tenant": "workspace:*", - "@contember/react-utils": "workspace:*", - "@radix-ui/react-slot": "^1.0.2" + "@contember/react-client-tenant": "workspace:*" }, "peerDependencies": { "react": "^18 || ^19", diff --git a/packages/react-identity/src/components/IdentityEnvironmentProvider.tsx b/packages/react-identity/src/components/IdentityEnvironmentProvider.tsx new file mode 100644 index 000000000..d0a0dbdfc --- /dev/null +++ b/packages/react-identity/src/components/IdentityEnvironmentProvider.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from 'react' +import { EnvironmentExtensionProvider } from '@contember/react-binding' +import { identityEnvironmentExtension } from '../environment' +import { useIdentity } from '@contember/react-client-tenant' + + +export interface IdentityEnvironmentProviderProps { + children: ReactNode +} + +export const IdentityEnvironmentProvider: React.FC = ({ children }) => { + const identity = useIdentity() + return ( + + {children} + + + ) +} diff --git a/packages/react-identity/src/components/index.ts b/packages/react-identity/src/components/index.ts index 4c9d9cf9b..c3576e947 100644 --- a/packages/react-identity/src/components/index.ts +++ b/packages/react-identity/src/components/index.ts @@ -1,4 +1,2 @@ export * from './HasRole' -export * from './IdentityProvider' -export * from './IdentityState' -export * from './LogoutTrigger' +export * from './IdentityEnvironmentProvider' diff --git a/packages/react-identity/src/environment/IdentityEnvironmentExtension.ts b/packages/react-identity/src/environment/IdentityEnvironmentExtension.ts index 550eb53de..f3a67254c 100644 --- a/packages/react-identity/src/environment/IdentityEnvironmentExtension.ts +++ b/packages/react-identity/src/environment/IdentityEnvironmentExtension.ts @@ -1,5 +1,5 @@ import { BindingError, Environment } from '@contember/react-binding' -import { Identity } from '../types' +import { Identity } from '@contember/react-client-tenant' export const identityEnvironmentExtension = Environment.createExtension((state: Identity | null | undefined) => { if (state === undefined) { diff --git a/packages/react-identity/src/hooks/index.ts b/packages/react-identity/src/hooks/index.ts index 420b18fcd..1b6a732c9 100644 --- a/packages/react-identity/src/hooks/index.ts +++ b/packages/react-identity/src/hooks/index.ts @@ -1,8 +1,2 @@ -export * from './useLogout' -export * from './useFetchIdentity' export * from './useProjectUserRoles' -export { - useIdentityMethods, - useIdentity, - useIdentityState, -} from '../internal/contexts' + diff --git a/packages/react-identity/src/hooks/useProjectUserRoles.tsx b/packages/react-identity/src/hooks/useProjectUserRoles.tsx index 8a40055e3..e129c2e3b 100644 --- a/packages/react-identity/src/hooks/useProjectUserRoles.tsx +++ b/packages/react-identity/src/hooks/useProjectUserRoles.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { useProjectSlug } from '@contember/react-client' -import { useIdentity } from '../internal/contexts' +import { useIdentity } from '@contember/react-client-tenant' export type ProjectUserRoles = Set diff --git a/packages/react-identity/src/index.ts b/packages/react-identity/src/index.ts index 61a56d148..ba0fb9b2b 100644 --- a/packages/react-identity/src/index.ts +++ b/packages/react-identity/src/index.ts @@ -1,4 +1,4 @@ -export * from './types' export * from './components' export * from './hooks' export * from './environment' +export * from '@contember/react-client-tenant' diff --git a/packages/react-identity/src/internal/contexts.ts b/packages/react-identity/src/internal/contexts.ts deleted file mode 100644 index 7c2f33368..000000000 --- a/packages/react-identity/src/internal/contexts.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createContext, createRequiredContext } from '@contember/react-utils' -import { IdentityMethods } from '../types/IdentityMethods' -import { Identity } from '../types/Identity' -import { IdentityStateValue } from '../types/IdentityStateValue' - -export const [IdentityContext, useIdentity] = createContext('IdentityContext', undefined) -export const [IdentityMethodsContext, useIdentityMethods] = createRequiredContext('IdentityMethodsContext') -export const [IdentityStateContext, useIdentityState] = createRequiredContext('IdentityStateContext') diff --git a/packages/react-identity/src/internal/hooks/useFetchMe.ts b/packages/react-identity/src/internal/hooks/useFetchMe.ts deleted file mode 100644 index 5070e7912..000000000 --- a/packages/react-identity/src/internal/hooks/useFetchMe.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as TenantApi from '@contember/graphql-client-tenant' -import { ModelType, useTenantApi } from '@contember/react-client-tenant' -import { useCallback } from 'react' - -const identityFragment = TenantApi - .identity$$ - .person(TenantApi.person$$) - .projects(TenantApi - .identityProjectRelation$ - .project(TenantApi.project$$) - .memberships(TenantApi.membership$$.variables(TenantApi.variableEntry$$)), - ) - .permissions(TenantApi.identityGlobalPermissions$$) - -export type FetchedIdentity = ModelType - -export const useFetchMe = () => { - const executor = useTenantApi() - return useCallback(async () => { - return (await executor(TenantApi.query$.me(identityFragment))).me - }, [executor]) -} diff --git a/packages/react-identity/src/internal/hooks/useSignOut.ts b/packages/react-identity/src/internal/hooks/useSignOut.ts deleted file mode 100644 index e065cf82e..000000000 --- a/packages/react-identity/src/internal/hooks/useSignOut.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as TenantApi from '@contember/graphql-client-tenant' -import { ModelType, useTenantApi } from '@contember/react-client-tenant' -import { useCallback } from 'react' - -const signOutFragment = TenantApi.signOutResponse$$.error(TenantApi.signOutError$$) - -export type SignOutResult = ModelType - -export const useSignOut = () => { - const api = useTenantApi() - return useCallback(async () => { - return (await api(TenantApi.mutation$.signOut(signOutFragment))).signOut - }, [api]) -} diff --git a/packages/react-identity/src/tsconfig.json b/packages/react-identity/src/tsconfig.json index f4bb6b6de..f444e20f9 100644 --- a/packages/react-identity/src/tsconfig.json +++ b/packages/react-identity/src/tsconfig.json @@ -7,6 +7,5 @@ { "path": "../../react-binding/src" }, { "path": "../../react-client/src" }, { "path": "../../react-client-tenant/src" }, - { "path": "../../react-utils/src" }, ] } From 95953eb1d19e8695c6b31c1b1195bd64506e4514 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 20 May 2024 14:22:47 +0200 Subject: [PATCH 04/14] refactor(interface,admin): fix usage of identity providder --- .../components/Identity/IdentityProvider.tsx | 34 ++++++++++--------- .../src/bootstrap/ApplicationEntrypoint.tsx | 6 ++-- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/admin/src/components/Identity/IdentityProvider.tsx b/packages/admin/src/components/Identity/IdentityProvider.tsx index ab4c74643..19783d8eb 100644 --- a/packages/admin/src/components/Identity/IdentityProvider.tsx +++ b/packages/admin/src/components/Identity/IdentityProvider.tsx @@ -2,7 +2,7 @@ import { Message, SpinnerOverlay } from '@contember/ui' import { ReactNode, useEffect } from 'react' import { MiscPageLayout } from '../MiscPageLayout' import { InvalidIdentityFallback } from './InvalidIdentityFallback' -import { IdentityProvider as BaseIdentityProvider, IdentityState } from '@contember/react-identity' +import { IdentityProvider as BaseIdentityProvider, IdentityEnvironmentProvider, IdentityState } from '@contember/react-identity' export interface IdentityProviderProps { children: ReactNode @@ -28,21 +28,23 @@ const ClearIdentityHandler = ({ onInvalidIdentity }: { export const IdentityProvider: React.FC = ({ children, onInvalidIdentity, allowUnauthenticated }) => { return ( - - - - Logging out… - - - - - - - - - - {children} - + + + + + Logging out… + + + + + + + + + + {children} + + ) } diff --git a/packages/interface/src/bootstrap/ApplicationEntrypoint.tsx b/packages/interface/src/bootstrap/ApplicationEntrypoint.tsx index b680b220c..f78b49eef 100644 --- a/packages/interface/src/bootstrap/ApplicationEntrypoint.tsx +++ b/packages/interface/src/bootstrap/ApplicationEntrypoint.tsx @@ -3,7 +3,7 @@ import { ContemberClient, ContemberClientProps } from '@contember/react-client' import { ReactNode } from 'react' import { RequestProvider, RouteMap, RoutingContext, RoutingContextValue, RoutingProvider, SelectedDimension } from '@contember/react-routing' import { DataViewPageNameKeyProvider } from './DataViewPageNameKeyProvider' -import { IdentityProvider, projectEnvironmentExtension } from '@contember/react-identity' +import { IdentityEnvironmentProvider, IdentityProvider, projectEnvironmentExtension } from '@contember/react-identity' export interface ApplicationEntrypointProps extends ContemberClientProps { basePath: string @@ -47,7 +47,9 @@ export const ApplicationEntrypoint = (props: ApplicationEntrypointProps) => { - {props.children} + + {props.children} + From ceea33db712b7d42baceb57129b53d8259849b2a Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 20 May 2024 14:23:34 +0200 Subject: [PATCH 05/14] feat(playground): tenant components usage --- packages/playground/admin/.env.development | 1 + packages/playground/admin/.env.production | 1 + .../admin/app/components/navigation.tsx | 7 +- packages/playground/admin/{ => app}/config.ts | 7 +- packages/playground/admin/app/index.html | 14 ++ packages/playground/admin/app/index.tsx | 40 +++ .../playground/admin/app/pages/tenant.tsx | 125 ++++++++++ .../playground/admin/app/pages/ui/button.tsx | 2 +- packages/playground/admin/index.html | 6 +- packages/playground/admin/index.tsx | 230 +++++++++++++++--- .../admin/lib/components/form/ui.tsx | 2 +- .../lib/components/tenant/apiKeyList.tsx | 25 ++ .../tenant/changeMyPasswordForm.tsx | 40 +++ .../admin/lib/components/tenant/common.tsx | 93 +++++++ .../components/tenant/createApiKeyForm.tsx | 39 +++ .../lib/components/tenant/inviteForm.tsx | 46 ++++ .../admin/lib/components/tenant/loginForm.tsx | 46 ++++ .../components/tenant/memberDeleteDialog.tsx | 38 +++ .../lib/components/tenant/memberList.tsx | 147 +++++++++++ .../components/tenant/membershipsControl.tsx | 128 ++++++++++ .../admin/lib/components/tenant/otpSetup.tsx | 146 +++++++++++ .../components/tenant/passwordResetForm.tsx | 44 ++++ .../tenant/passwordResetRequestForm.tsx | 31 +++ .../lib/components/tenant/personList.tsx | 25 ++ .../tenant/updateProjectMemberForm.tsx | 32 +++ .../admin/lib/components/ui/input.tsx | 5 +- .../admin/lib/components/ui/label.tsx | 2 +- .../admin/lib/components/ui/loader.tsx | 30 +-- .../admin/lib/components/ui/overlay.tsx | 33 +++ packages/playground/admin/lib/dict.ts | 175 +++++++++++++ packages/playground/admin/vite.config.ts | 34 +++ packages/playground/api/client/entities.ts | 90 +++++++ packages/playground/api/client/enums.ts | 5 + packages/playground/api/client/names.ts | 95 ++++++++ .../migrations/2024-05-16-133645-roles.json | 126 ++++++++++ .../2024-05-16-133700-roles-data.ts | 17 ++ packages/playground/api/model/Acl.ts | 14 ++ packages/playground/api/model/index.ts | 1 + packages/playground/package.json | 4 +- packages/playground/vite.config.js | 5 - packages/playground/vite.config.ts | 9 - 41 files changed, 1876 insertions(+), 84 deletions(-) rename packages/playground/admin/{ => app}/config.ts (85%) create mode 100644 packages/playground/admin/app/index.html create mode 100644 packages/playground/admin/app/index.tsx create mode 100644 packages/playground/admin/app/pages/tenant.tsx create mode 100644 packages/playground/admin/lib/components/tenant/apiKeyList.tsx create mode 100644 packages/playground/admin/lib/components/tenant/changeMyPasswordForm.tsx create mode 100644 packages/playground/admin/lib/components/tenant/common.tsx create mode 100644 packages/playground/admin/lib/components/tenant/createApiKeyForm.tsx create mode 100644 packages/playground/admin/lib/components/tenant/inviteForm.tsx create mode 100644 packages/playground/admin/lib/components/tenant/loginForm.tsx create mode 100644 packages/playground/admin/lib/components/tenant/memberDeleteDialog.tsx create mode 100644 packages/playground/admin/lib/components/tenant/memberList.tsx create mode 100644 packages/playground/admin/lib/components/tenant/membershipsControl.tsx create mode 100644 packages/playground/admin/lib/components/tenant/otpSetup.tsx create mode 100644 packages/playground/admin/lib/components/tenant/passwordResetForm.tsx create mode 100644 packages/playground/admin/lib/components/tenant/passwordResetRequestForm.tsx create mode 100644 packages/playground/admin/lib/components/tenant/personList.tsx create mode 100644 packages/playground/admin/lib/components/tenant/updateProjectMemberForm.tsx create mode 100644 packages/playground/admin/lib/components/ui/overlay.tsx create mode 100644 packages/playground/admin/vite.config.ts create mode 100644 packages/playground/api/migrations/2024-05-16-133645-roles.json create mode 100644 packages/playground/api/migrations/2024-05-16-133700-roles-data.ts create mode 100644 packages/playground/api/model/Acl.ts delete mode 100644 packages/playground/vite.config.js delete mode 100644 packages/playground/vite.config.ts diff --git a/packages/playground/admin/.env.development b/packages/playground/admin/.env.development index 313e4539c..ae8b9bc7b 100644 --- a/packages/playground/admin/.env.development +++ b/packages/playground/admin/.env.development @@ -1,3 +1,4 @@ VITE_CONTEMBER_ADMIN_API_BASE_URL=http://localhost:3001 VITE_CONTEMBER_ADMIN_SESSION_TOKEN=0000000000000000000000000000000000000000 +VITE_CONTEMBER_ADMIN_LOGIN_TOKEN=1111111111111111111111111111111111111111 VITE_CONTEMBER_ADMIN_PROJECT_NAME=playground diff --git a/packages/playground/admin/.env.production b/packages/playground/admin/.env.production index c91bc3e88..9a64004ec 100644 --- a/packages/playground/admin/.env.production +++ b/packages/playground/admin/.env.production @@ -1,2 +1,3 @@ VITE_CONTEMBER_ADMIN_API_BASE_URL=/_api VITE_CONTEMBER_ADMIN_SESSION_TOKEN=__SESSION_TOKEN__ +VITE_CONTEMBER_ADMIN_LOGIN_TOKEN=__LOGIN_TOKEN__ diff --git a/packages/playground/admin/app/components/navigation.tsx b/packages/playground/admin/app/components/navigation.tsx index 88de28ffd..9c43b8673 100644 --- a/packages/playground/admin/app/components/navigation.tsx +++ b/packages/playground/admin/app/components/navigation.tsx @@ -1,4 +1,4 @@ -import { ArchiveIcon, BrushIcon, FormInputIcon, GripVertical, HomeIcon, KanbanIcon, LanguagesIcon, PencilIcon, TableIcon, UploadIcon } from 'lucide-react' +import { ArchiveIcon, BrushIcon, FormInputIcon, GripVertical, HomeIcon, KanbanIcon, KeyRoundIcon, LanguagesIcon, LockKeyholeIcon, PencilIcon, TableIcon, UploadIcon, UserIcon, UsersIcon } from 'lucide-react' import { Menu, MenuItem } from '../../lib/components/ui/menu' @@ -8,6 +8,11 @@ export const Navigation = () => {
} label={'Home'} to={'index'} /> + } label={'Tenant'}> + } label={'Security'} to={'tenant/security'} /> + } label={'Members'} to={'tenant/members'} /> + } label={'API keys'} to={'tenant/apiKeys'} /> + } label={'UI'}> diff --git a/packages/playground/admin/config.ts b/packages/playground/admin/app/config.ts similarity index 85% rename from packages/playground/admin/config.ts rename to packages/playground/admin/app/config.ts index fe47dbbf0..7dd15ebfd 100644 --- a/packages/playground/admin/config.ts +++ b/packages/playground/admin/app/config.ts @@ -1,14 +1,13 @@ export const getConfig = () => { let project = import.meta.env.VITE_CONTEMBER_ADMIN_PROJECT_NAME + if (project === '__PROJECT_SLUG__') { project = window.location.pathname.split('/')[1] } - let basePath = import.meta.env.BASE_URL ?? '/' - if (basePath === './') { - basePath = `/${project}/` - } + const basePath = `/${window.location.pathname.split('/')[1]}/` + const apiBaseUrl = import.meta.env.VITE_CONTEMBER_ADMIN_API_BASE_URL as string if (!apiBaseUrl) { throw new Error('VITE_CONTEMBER_ADMIN_API_BASE_URL is not set') diff --git a/packages/playground/admin/app/index.html b/packages/playground/admin/app/index.html new file mode 100644 index 000000000..d6c212019 --- /dev/null +++ b/packages/playground/admin/app/index.html @@ -0,0 +1,14 @@ + + + + + + + + + Contember Interface + + + + + diff --git a/packages/playground/admin/app/index.tsx b/packages/playground/admin/app/index.tsx new file mode 100644 index 000000000..3778c76c8 --- /dev/null +++ b/packages/playground/admin/app/index.tsx @@ -0,0 +1,40 @@ +import { ApplicationEntrypoint, PageModule, Pages } from '@contember/interface' +import { SlotsProvider } from '@contember/react-slots' +import { Layout } from './components/layout' +import '../index.css' +import { Toaster } from '../lib/components/ui/toast' +import { createErrorHandler, DevBar, DevPanel } from '@contember/react-devbar' +import { LogInIcon } from 'lucide-react' +import { LoginWithEmail } from '../lib/components/dev/login-panel' +import { createRoot } from 'react-dom/client' +import { getConfig } from './config' +import { OutdatedApplicationDialog } from '../lib/components/outdated-application-dialog' + +const errorHandler = createErrorHandler((dom, react, onRecoverableError) => createRoot(dom, { onRecoverableError }).render(react)) + +const rootEl = document.body.appendChild(document.createElement('div')) + +errorHandler(onRecoverableError => createRoot(rootEl, { onRecoverableError }).render(<> + + + + ( + './pages/**/*.tsx', + { eager: true }, + )} + /> + {import.meta.env.DEV && + }> + } + + + + } + /> + +)) diff --git a/packages/playground/admin/app/pages/tenant.tsx b/packages/playground/admin/app/pages/tenant.tsx new file mode 100644 index 000000000..11771f728 --- /dev/null +++ b/packages/playground/admin/app/pages/tenant.tsx @@ -0,0 +1,125 @@ +import { ChangeMyPasswordForm, CreateApiKeyForm, InviteForm } from '@contember/react-identity' +import { Card, CardContent, CardHeader, CardTitle } from '../../lib/components/ui/card' +import { ChangeMyPasswordFormFields } from '../../lib/components/tenant/changeMyPasswordForm' +import { ToastContent, useShowToast } from '../../lib/components/ui/toast' +import { OtpSetup } from '../../lib/components/tenant/otpSetup' +import { PersonList } from '../../lib/components/tenant/personList' +import { InviteFormFields } from '../../lib/components/tenant/inviteForm' +import { useProjectSlug } from '@contember/react-client' +import { Input } from '../../lib/components/ui/input' +import { CreateApiKeyFormFields } from '../../lib/components/tenant/createApiKeyForm' +import { ApiKeyList } from '../../lib/components/tenant/apiKeyList' +import { useRef } from 'react' +import { MemberListController } from '../../lib/components/tenant/memberList' + +export const Security = () => { + const showToast = useShowToast() + return ( +
+ + + Change Password + + + showToast(Password changed, { type: 'success' })}> +
+ + +
+
+
+ + + Two-factor setup + + + + + +
+ ) +} + + +export const Members = () => { + const projectSlug = useProjectSlug()! + const showToast = useShowToast() + const memberListController = useRef() + return ( +
+
+ + + Members + + + + + +
+
+ + + Invite + + + { + showToast(Invitation sent to {args.result.person?.email}, { type: 'success' }) + memberListController.current?.refresh() + }} + > +
+ + +
+
+
+
+
+ ) +} + +export const ApiKeys = () => { + const projectSlug = useProjectSlug()! + const showToast = useShowToast() + const memberListController = useRef() + return ( +
+
+ + + API keys + + + + + +
+
+ + + Create API key + + + { + showToast(, { type: 'success' }) + memberListController.current?.refresh() + }} + > +
+ + +
+
+ +
+
+
+ ) +} diff --git a/packages/playground/admin/app/pages/ui/button.tsx b/packages/playground/admin/app/pages/ui/button.tsx index 58f1c5811..f9c84a88e 100644 --- a/packages/playground/admin/app/pages/ui/button.tsx +++ b/packages/playground/admin/app/pages/ui/button.tsx @@ -3,7 +3,7 @@ import { HomeIcon } from 'lucide-react' export default <> -
+

Button variants

diff --git a/packages/playground/admin/index.html b/packages/playground/admin/index.html index 189bd7f3c..07d3d87b7 100644 --- a/packages/playground/admin/index.html +++ b/packages/playground/admin/index.html @@ -4,10 +4,10 @@ - - + + Contember Interface - + diff --git a/packages/playground/admin/index.tsx b/packages/playground/admin/index.tsx index 0c9bc56b3..dce25e857 100644 --- a/packages/playground/admin/index.tsx +++ b/packages/playground/admin/index.tsx @@ -1,40 +1,208 @@ -import { ApplicationEntrypoint, PageModule, Pages } from '@contember/interface' -import { SlotsProvider } from '@contember/react-slots' -import { Layout } from './app/components/layout' import './index.css' -import { Toaster } from './lib/components/ui/toast' -import { createErrorHandler, DevBar, DevPanel } from '@contember/react-devbar' -import { LogInIcon } from 'lucide-react' -import { LoginWithEmail } from './lib/components/dev/login-panel' +import { createErrorHandler } from '@contember/react-devbar' import { createRoot } from 'react-dom/client' -import { getConfig } from './config' -import { OutdatedApplicationDialog } from './lib/components/outdated-application-dialog' +import { LoginFormFields } from './lib/components/tenant/loginForm' +import { ContemberClient } from '@contember/react-client' +import { IdentityProvider, IdentityState, IDP, IDPInitTrigger, IDPState, LoginForm, LogoutTrigger, PasswordResetForm, PasswordResetRequestForm } from '@contember/react-identity' +import { Link, RoutingProvider, useCurrentRequest, useRedirect } from '@contember/react-routing' +import { Pages } from '@contember/interface' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './lib/components/ui/card' +import { AnchorButton, Button } from './lib/components/ui/button' +import { PasswordResetRequestFormFields } from './lib/components/tenant/passwordResetRequestForm' +import { MailIcon } from 'lucide-react' +import { PasswordResetFormFields } from './lib/components/tenant/passwordResetForm' +import { ToastContent, Toaster, useShowToast } from './lib/components/ui/toast' +import { Loader } from './lib/components/ui/loader' +import { Overlay } from './lib/components/ui/overlay' const errorHandler = createErrorHandler((dom, react, onRecoverableError) => createRoot(dom, { onRecoverableError }).render(react)) const rootEl = document.body.appendChild(document.createElement('div')) +const LoginPage = () => { + const showToast = useShowToast() + return ( + + showToast(Failed to initialize IdP login: {error}, { type: 'error' })} + onResponseError={error => showToast(Failed to process IdP response: {error}, { type: 'error' })} + > + + + + Login + + Enter your email below to login to your account + + + + +
+ + +
+ + + + +
+ + + + + + + + + + + + +
+

+ Failed to load identity. +

+ + + +
+ +
+
+
+
+
+ ) +} + +const PasswordResetRequestPage = () => { + const redirect = useRedirect() + return ( + + + Password reset request + + Enter your email below to reset your password + + + + redirect('resetRequestSuccess')}> +
+ + +
+
+ + + + Back to login + + + +
+ ) +} + +const PasswordResetPage = () => { + const request = useCurrentRequest() + const redirect = useRedirect() + const showToast = useShowToast() + const token = request?.parameters.token as string | undefined + return ( + + + Password reset + + Enter new password + + + + { + showToast(Password has been reset, { type: 'success' }) + redirect('index') + }} token={token}> +
+ + +
+
+ + + + Back to login + + + +
+ ) +} + +const PasswordResetRequestSuccessPage = () => ( + + + Password reset request + + Password reset link has been sent + + + +
+ +
+ Please check you mailbox for instructions on how to reset your password. +
+
+ Or entry password reset code directly. +
+
+
+ + + + Back to login + + + +
+) + +const Layout = ({ children }: { children?: React.ReactNode }) => ( +
+
+ {children} +
+
+
+
+
+
+
+) + + errorHandler(onRecoverableError => createRoot(rootEl, { onRecoverableError }).render(<> - - - - ( - './app/pages/**/*.tsx', - { eager: true }, - )} - /> - - {import.meta.env.DEV && - }> - } - - - } - /> - + + + + password reset success
, + + }} + /> + + + + )) + diff --git a/packages/playground/admin/lib/components/form/ui.tsx b/packages/playground/admin/lib/components/form/ui.tsx index ef720857e..820eff398 100644 --- a/packages/playground/admin/lib/components/form/ui.tsx +++ b/packages/playground/admin/lib/components/form/ui.tsx @@ -20,7 +20,7 @@ export const FormLabelWrapperUI = uic('div', { displayName: 'FormLabelWrapper', }) export const FormLabelUI = uic(Label, { - baseClass: 'data-[invalid]:text-destructive text-left', + baseClass: 'text-left', displayName: 'FormLabel', }) export const FormContainerUI = uic('div', { diff --git a/packages/playground/admin/lib/components/tenant/apiKeyList.tsx b/packages/playground/admin/lib/components/tenant/apiKeyList.tsx new file mode 100644 index 000000000..ae000a2f3 --- /dev/null +++ b/packages/playground/admin/lib/components/tenant/apiKeyList.tsx @@ -0,0 +1,25 @@ +import { ProjectMembersFilter } from '@contember/graphql-client-tenant' +import * as React from 'react' +import { TableCell } from '../ui/table' +import { dict } from '../../dict' +import { MemberList, MemberListController } from './memberList' + +const filter: ProjectMembersFilter = { + memberType: 'API_KEY', +} + +export const ApiKeyList = (props: { controller?: { current?: MemberListController } }) => ( + <> + {it.identity.description ?? dict.tenant.apiKeyList.unnamed} + } + /> +) diff --git a/packages/playground/admin/lib/components/tenant/changeMyPasswordForm.tsx b/packages/playground/admin/lib/components/tenant/changeMyPasswordForm.tsx new file mode 100644 index 000000000..cbfa52224 --- /dev/null +++ b/packages/playground/admin/lib/components/tenant/changeMyPasswordForm.tsx @@ -0,0 +1,40 @@ +import { ChangeMyPasswordFormErrorCode, useChangeMyPasswordForm } from '@contember/react-identity' +import { Button } from '../ui/button' +import { Loader } from '../ui/loader' +import { TenantFormError, TenantFormField } from './common' +import { dict } from '../../dict' + + +export const ChangeMyPasswordFormFields = () => { + const form = useChangeMyPasswordForm() + return ( +
+ {form.state === 'submitting' ? : null} + + + {dict.tenant.changePassword.currentPassword} + + + {dict.tenant.changePassword.newPassword} + + + {dict.tenant.changePassword.confirmPassword} + + + +
+ ) +} diff --git a/packages/playground/admin/lib/components/tenant/common.tsx b/packages/playground/admin/lib/components/tenant/common.tsx new file mode 100644 index 000000000..061625896 --- /dev/null +++ b/packages/playground/admin/lib/components/tenant/common.tsx @@ -0,0 +1,93 @@ +import { FormContextValue } from '@contember/react-identity' +import { FormErrorUI } from '../form' +import { Input } from '../ui/input' +import { HTMLInputTypeAttribute, useState } from 'react' +import { dataAttribute } from '@contember/utilities' +import { Label } from '../ui/label' + +export interface TenantFormErrorsProps> { + form: CtxValue + field?: keyof CtxValue['values'] + messages: Record ? E : never, string> +} + +export const TenantFormError = >({ form, field, messages }: TenantFormErrorsProps) => { + return ( +
+ {form.errors + .filter(error => error.field === field) + .map(error => [error.code, { error: (messages as any)[error.code] || 'Unknown error', developerMessage: error.developerMessage }]) + .map(([code, error]) => { + return () + })} +
+ ) +} + +const TenantFormSingleError = ({ error, developerMessage }: { error: string, developerMessage?: string }) => { + const [showDeveloperMessage, setShowDeveloperMessage] = useState(false) + return <> + {error} + {/*{developerMessage &&
*/} + {/* {showDeveloperMessage*/} + {/* ?
{developerMessage}
*/} + {/* : }*/} + {/*
}*/} + +} + +export type TenantFormInputProps> = + & { + form: CtxValue + type: HTMLInputTypeAttribute + field: keyof CtxValue['values'] & string + } + & Omit>, 'form' | 'field'> + +export const TenantFormInput = >({ form, field, ...props }: TenantFormInputProps) => { + return ( + form.setValue(field, e.target.value)} + value={form.values[field]} + data-invalid={dataAttribute(form.errors.some(it => it.field === field))} + {...props} + /> + ) +} + +export type TenantFormLabelProps> = + & { + form: CtxValue + field: keyof CtxValue['values'] & string + } + & Omit>, 'form' | 'htmlFor'> + +export const TenantFormLabel = >({ form, field, ...props }: TenantFormLabelProps) => { + return ( +