Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Invite to team fixes, improvements and API implementation #828

Merged
merged 16 commits into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading