Skip to content

Commit

Permalink
Invite to team fixes, improvements and API implementation (#828)
Browse files Browse the repository at this point in the history
* Fix issues related to the Team view
* Fix accidentaly changed props
* Remove unnecessary (and confusing) form default value
* Moved utils to new utils folder
* Implement invite to team logic (or most of it)
- Added new CallbackProvider to easily add success/error callbacks to
  components
* Fix teams list in account not showing team name
* Implement pending members list
- Moved team members table to a new component and edited that one to use
  the common component instead
- Created a new PendingTeamMembersList component to show the pending
  members table/list
- Created a new wrapper component to have both of them as <TeamMembers
  />
* Implemented roles call
* Fix query cache not properly cleaned when inviting someone
Also changed the order of the members table order to be more consistent
* Properly select data structure using react-query
* Recover roles border radius
* Properly show both member lists in team
* Set isLoading to team invite form button
* Update translations
* Accept invitation flow
- Created #839 to tackle an issue I've found doing this
- Changed how the auth.ts routes were sorted in order to be able to
  reuse the AuthLayout without requiring an account to not be logged in.
- Updated the signup component to be reused in the verification for
  non-existing accounts.
- Minor changes to AuthProvider to have errors in an Enum
- Most if not all cases covered, but the verification process needs a
  revamp:
  + If the invited user has an unverified account, a page with a button to
    go to the verify page is shown, but such verify requires the params in
    the URL, which cannot be get from this point (at all). The verify user
    needs a revamp so it can be accessed without url params, and show
    there a button to request a new code if required
* Minor translation changes
  • Loading branch information
elboletaire authored Nov 19, 2024
1 parent c593714 commit 8b61573
Show file tree
Hide file tree
Showing 28 changed files with 1,111 additions and 760 deletions.
112 changes: 112 additions & 0 deletions src/components/Account/AcceptInvitation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Alert, AlertDescription, AlertIcon, Button, Flex, Spinner, Text, useToast } from '@chakra-ui/react'
import { useMutation } from '@tanstack/react-query'
import { ReactNode, useEffect } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { generatePath, Link as RouterLink, useNavigate, useOutletContext } from 'react-router-dom'
import { api, ApiEndpoints, ApiError, ErrorCode } from '~components/Auth/api'
import SignUp, { InviteFields } from '~components/Auth/SignUp'
import { AuthOutletContextType } from '~elements/LayoutAuth'
import { Routes } from '~src/router/routes'

const Error = ({ error }: { error: ReactNode }) => (
<Alert status='error'>
<AlertIcon />
<AlertDescription>{error}</AlertDescription>
</Alert>
)

const AcceptInvitation: React.FC<InviteFields> = ({ address, code, email }) => {
const { t } = useTranslation()
const navigate = useNavigate()
const toast = useToast()
const { setTitle, setSubTitle } = useOutletContext<AuthOutletContextType>()

const acceptInvitationMutation = useMutation({
mutationFn: ({ code, address }: { code: string; address: string }) =>
api(ApiEndpoints.InviteAccept.replace('{address}', address), {
method: 'POST',
body: { code },
}),
})

// Accept the invitation
useEffect(() => {
if (
!code ||
!address ||
acceptInvitationMutation.isPending ||
acceptInvitationMutation.isError ||
acceptInvitationMutation.isSuccess
)
return

acceptInvitationMutation.mutate({ code, address })
}, [code, address, acceptInvitationMutation])

// Redirect on success
useEffect(() => {
if (!acceptInvitationMutation.isSuccess) return

toast({
title: t('invite.success_title', { defaultValue: 'Invitation accepted' }),
description: t('invite.success_description', { defaultValue: 'You can now sign in' }),
status: 'success',
})
navigate(Routes.auth.signIn)
}, [acceptInvitationMutation.isSuccess])

// Change layout title and subtitle
useEffect(() => {
if (!acceptInvitationMutation.isError || !(acceptInvitationMutation.error instanceof ApiError)) return
const error = (acceptInvitationMutation.error as ApiError).apiError
if (error?.code !== ErrorCode.MalformedJSONBody) return

setTitle(t('invite.create_account_title', { defaultValue: 'Create your account' }))
setSubTitle(
t('invite.create_account_subtitle', { defaultValue: 'You need an account first, in order to accept your invite' })
)
}, [acceptInvitationMutation.isError])

if (!code || !address || !email) {
return <Error error={<Trans i18nKey='invite.invalid_link'>Invalid invite link received</Trans>} />
}

if (acceptInvitationMutation.isPending) {
return (
<Flex justify='center' p={4} gap={3}>
<Spinner />
<Text>
<Trans i18nKey='invite.processing'>Processing your invitation...</Trans>
</Text>
</Flex>
)
}

if (acceptInvitationMutation.isError) {
const error = (acceptInvitationMutation.error as ApiError)?.apiError
if (error?.code === ErrorCode.MalformedJSONBody) {
return <SignUp invite={{ address, code, email }} />
}

if (error?.code === ErrorCode.UserNotVerified) {
return (
<Flex direction='column' justify='center' p={4} gap={4}>
<Text>
<Trans i18nKey='invite.account_not_verified'>
Your account is not verified. Please verify your account to continue.
</Trans>
</Text>
<Button as={RouterLink} to={generatePath(Routes.auth.verify)}>
<Trans i18nKey='invite.go_to_verify'>Verify Account</Trans>
</Button>
</Flex>
)
}

return <Error error={error?.error || t('invite.unexpected_error')} />
}

return <Spinner />
}

export default AcceptInvitation
49 changes: 26 additions & 23 deletions src/components/Account/Teams.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
import { Avatar, Badge, Box, HStack, Text, VStack } from '@chakra-ui/react'
import { Avatar, Badge, Box, HStack, VStack } from '@chakra-ui/react'
import { OrganizationName } from '@vocdoni/chakra-components'
import { OrganizationProvider } from '@vocdoni/react-providers'
import { UserRole } from '~src/queries/account'

const Teams = ({ roles }: { roles: UserRole[] }) => {
if (!roles) return null

return (
<VStack spacing={4} align='stretch'>
{roles.map((role, k) => (
<Box
key={k}
p={4}
borderWidth='1px'
borderRadius='lg'
_hover={{ bg: 'gray.50', _dark: { bg: 'gray.700' } }}
transition='background 0.2s'
>
<HStack spacing={4}>
<Avatar size='md' src={role.organization.logo} name={role.organization.name} />
<Box flex='1'>
<Text fontWeight='medium'>{role.organization.name}</Text>
<Badge
colorScheme={role.role === 'admin' ? 'purple' : role.role === 'owner' ? 'green' : 'blue'}
fontSize='sm'
>
{role.role}
</Badge>
</Box>
</HStack>
</Box>
{roles.map((role) => (
<OrganizationProvider key={role.organization.address} id={role.organization.address}>
<Box
p={4}
borderWidth='1px'
borderRadius='lg'
_hover={{ bg: 'gray.50', _dark: { bg: 'gray.700' } }}
transition='background 0.2s'
>
<HStack spacing={4}>
<Avatar size='md' src={role.organization.logo} name={role.organization.name} />
<Box flex='1'>
<OrganizationName fontWeight='medium' />
<Badge
colorScheme={role.role === 'admin' ? 'purple' : role.role === 'owner' ? 'green' : 'blue'}
fontSize='sm'
>
{role.role}
</Badge>
</Box>
</HStack>
</Box>
</OrganizationProvider>
))}
</VStack>
)
Expand Down
61 changes: 41 additions & 20 deletions src/components/Auth/SignUp.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,72 @@
import { Button, Flex, Link, Text } from '@chakra-ui/react'
import { useEffect, useState } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { Trans, useTranslation } from 'react-i18next'
import { NavLink, Link as ReactRouterLink, useOutletContext } from 'react-router-dom'
import { Navigate, NavLink, Link as ReactRouterLink } from 'react-router-dom'
import { IRegisterParams } from '~components/Auth/authQueries'
import { useAuth } from '~components/Auth/useAuth'
import { VerifyAccountNeeded } from '~components/Auth/Verify'
import FormSubmitMessage from '~components/Layout/FormSubmitMessage'
import InputPassword from '~components/Layout/InputPassword'
import { AuthOutletContextType } from '~elements/LayoutAuth'
import { useSignupFromInvite } from '~src/queries/account'
import { Routes } from '~src/router/routes'
import CustomCheckbox from '../Layout/CheckboxCustom'
import { default as InputBasic } from '../Layout/InputBasic'
import GoogleAuth from './GoogleAuth'
import { HSeparator } from './SignIn'

export type InviteFields = {
code: string
address: string
email: string
}

export type SignupProps = {
invite?: InviteFields
}

type FormData = {
terms: boolean
} & IRegisterParams

const SignUp = () => {
const SignUp = ({ invite }: SignupProps) => {
const { t } = useTranslation()
const { setTitle, setSubTitle } = useOutletContext<AuthOutletContextType>()

const {
register: { mutateAsync: signup, isError, error, isPending },
} = useAuth()

const methods = useForm<FormData>()
const { register } = useAuth()
const inviteSignup = useSignupFromInvite(invite?.address)
const methods = useForm<FormData>({
defaultValues: {
terms: false,
email: invite?.email,
},
})
const { handleSubmit, watch } = methods
const email = watch('email')

// State to show signup is successful
const [isSuccess, setIsSuccess] = useState(false)
const isPending = register.isPending || inviteSignup.isPending
const isError = register.isError || inviteSignup.isError
const error = register.error || inviteSignup.error

useEffect(() => {
setTitle(t('signup_title'))
setSubTitle(t('signup_subtitle'))
}, [])
const onSubmit = (user: FormData) => {
if (!invite) {
return register.mutate(user)
}

const onSubmit = async (data: FormData) => {
await signup(data).then(() => setIsSuccess(true))
// if there's an invite, the process' a bit different
return inviteSignup.mutate({
code: invite.code,
user,
})
}

if (isSuccess) {
// normally registered accounts need verification
if (register.isSuccess) {
return <VerifyAccountNeeded email={email} />
}

// accounts coming from invites don't need verification
if (inviteSignup.isSuccess) {
return <Navigate to={Routes.auth.signIn} />
}

return (
<>
<GoogleAuth />
Expand All @@ -69,6 +89,7 @@ const SignUp = () => {
placeholder={t('email_placeholder', { defaultValue: '[email protected]' })}
type='email'
required
isDisabled={!!invite}
/>
<InputPassword
formValue='password'
Expand Down
24 changes: 18 additions & 6 deletions src/components/Auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,30 @@ type MethodTypes = 'GET' | 'POST' | 'PUT' | 'DELETE'
export enum ApiEndpoints {
Login = 'auth/login',
Me = 'users/me',
InviteAccept = 'organizations/{address}/members/accept',
Organization = 'organizations/{address}',
OrganizationMembers = 'organizations/{address}/members',
OrganizationPendingMembers = 'organizations/{address}/members/pending',
Organizations = 'organizations',
OrganizationsRoles = 'organizations/roles',
Password = 'users/password',
PasswordRecovery = 'users/password/recovery',
PasswordReset = 'users/password/reset',
Refresh = 'auth/refresh',
Register = 'users',
Organizations = 'organizations',
Organization = 'organizations/{address}',
OrganizationMembers = 'organizations/{address}/members',
Verify = 'users/verify',
VerifyCode = 'users/verify/code',
}

export enum ErrorCode {
// HTTP errors
Unauthorized = 401,
// Custom API errors
MalformedJSONBody = 40004,
UserNotAuthorized = 40001,
UserNotVerified = 40014,
}

interface IApiError {
error: string
code?: number
Expand Down Expand Up @@ -71,8 +83,8 @@ export const api = <T>(
error = { error: sanitized.length ? sanitized : response.statusText }
}
// Handle unauthorized error
if (response.status === 401) {
if (error?.code === 40014) {
if (response.status === ErrorCode.Unauthorized) {
if (error?.code === ErrorCode.UserNotVerified) {
throw new UnverifiedApiError(error, response)
}
throw new UnauthorizedApiError(error, response)
Expand All @@ -82,7 +94,7 @@ export const api = <T>(
}
return sanitized ? (JSON.parse(sanitized) as T) : undefined
})
.catch((error: Error) => {
.catch((error: Error | IApiError) => {
throw error
})
}
5 changes: 3 additions & 2 deletions src/components/Auth/useAuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const useSigner = () => {
// Once the signer is set, try to get the signer address
// This is an asynchronous call because the address are fetched from the server,
// and we don't know if we need to create an organization until we try to retrieve the address

try {
return await signer.getAddress()
} catch (e) {
Expand All @@ -44,13 +45,13 @@ export const useAuthProvider = () => {
const [bearer, setBearer] = useState<string | null>(localStorage.getItem(LocalStorageKeys.Token))

const login = useLogin({
onSuccess: (data, variables) => {
onSuccess: (data) => {
storeLogin(data)
},
})
const register = useRegister()
const mailVerify = useVerifyMail({
onSuccess: (data, variables) => {
onSuccess: (data) => {
storeLogin(data)
},
})
Expand Down
Loading

2 comments on commit 8b61573

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.