Skip to content

Commit

Permalink
Account profile (#795)
Browse files Browse the repository at this point in the history
* Organization edit should be organization (alone)

* Rename organization edit, to properly reflect its scope

* No need for a folder here (yet)

* account profile pages

* Org<->Account renamings

* Type fixes
  • Loading branch information
elboletaire authored Oct 29, 2024
1 parent 19e89f4 commit cb58c01
Show file tree
Hide file tree
Showing 24 changed files with 507 additions and 51 deletions.
43 changes: 43 additions & 0 deletions src/components/Account/Edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Button, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react'
import { Trans } from 'react-i18next'
import { InnerContentsMaxWidth } from '~constants'
import { useProfile } from '~src/queries/account'
import AccountForm from './Form'
import PasswordForm from './PasswordForm'
import Teams from './Teams'

export const AccountEdit = () => {
const { data: profile } = useProfile()
return (
<Flex flexDirection='column' h='100%'>
<Tabs w='full' maxW={InnerContentsMaxWidth} mx='auto'>
<TabList>
<Tab>
<Trans i18nKey='profile'>Profile</Trans>
</Tab>
<Tab>
<Trans i18nKey='password'>Password</Trans>
</Tab>
<Tab>
<Trans i18nKey='teams'>Teams</Trans>
</Tab>
</TabList>

<TabPanels>
<TabPanel>
<AccountForm profile={profile} />
</TabPanel>
<TabPanel>
<PasswordForm />
</TabPanel>
<TabPanel>
<Teams roles={profile.organizations} />
</TabPanel>
</TabPanels>
</Tabs>
<Button colorScheme='red' variant='outline' alignSelf='center' mt='auto' isDisabled>
<Trans i18nKey='delete_my_account'>Delete my account</Trans>
</Button>
</Flex>
)
}
108 changes: 108 additions & 0 deletions src/components/Account/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
Avatar,
Button,
Flex,
FormControl,
FormErrorMessage,
FormLabel,
Input,
Text,
useToast,
VStack,
} from '@chakra-ui/react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { User, useUpdateProfile } from '~src/queries/account'

interface ProfileFormData {
firstName: string
lastName: string
email: string
}

const AccountForm = ({ profile }: { profile: User }) => {
const { t } = useTranslation()
const toast = useToast()
const updateProfile = useUpdateProfile()

const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ProfileFormData>({
values: profile
? {
firstName: profile.firstName,
lastName: profile.lastName,
email: profile.email,
}
: undefined,
})

const onSubmit = async (data: ProfileFormData) => {
try {
await updateProfile.mutateAsync({
firstName: data.firstName,
lastName: data.lastName,
})

toast({
title: t('profile.success', { defaultValue: 'Profile updated successfully' }),
status: 'success',
})
} catch (error) {
toast({
title: t('profile.error', { defaultValue: 'Failed to update profile' }),
status: 'error',
})
}
}

return (
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={8} align='stretch'>
<FormControl isDisabled style={{ cursor: 'not-allowed' }}>
<FormLabel>{t('profile.avatar.label', { defaultValue: 'Avatar' })}</FormLabel>
<Flex align='center' gap={4}>
<Avatar size='lg' name={profile ? `${profile.firstName} ${profile.lastName}` : undefined} />
<Text color='gray.500' fontSize='sm'>
{t('avatar.hint', { defaultValue: 'Min 200x200px .PNG or .JPEG' })}
</Text>
</Flex>
</FormControl>

<FormControl isInvalid={!!errors.firstName}>
<FormLabel>{t('name.label', { defaultValue: 'Name' })}</FormLabel>
<Input
{...register('firstName', {
required: t('name.required', { defaultValue: 'Name is required' }),
})}
/>
<FormErrorMessage>{errors.firstName?.message}</FormErrorMessage>
</FormControl>

<FormControl isInvalid={!!errors.lastName}>
<FormLabel>{t('surname.label', { defaultValue: 'Surname' })}</FormLabel>
<Input
{...register('lastName', {
required: t('surname.required', { defaultValue: 'Surname is required' }),
})}
/>
<FormErrorMessage>{errors.lastName?.message}</FormErrorMessage>
</FormControl>

<FormControl isInvalid={!!errors.email}>
<FormLabel>{t('email.label', { defaultValue: 'Email' })}</FormLabel>
<Input {...register('email')} isDisabled type='email' />
<FormErrorMessage>{errors.email?.message}</FormErrorMessage>
</FormControl>

<Button type='submit' size='lg' isLoading={isSubmitting || updateProfile.isPending}>
{t('actions.save', { defaultValue: 'Save Changes' })}
</Button>
</VStack>
</form>
)
}

export default AccountForm
49 changes: 49 additions & 0 deletions src/components/Account/Menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Avatar, Box, BoxProps, IconButton, Menu, MenuButton, MenuItem, MenuList, Spinner } from '@chakra-ui/react'
import { Trans } from 'react-i18next'
import { Link as RouterLink } from 'react-router-dom'
import { useAuth } from '~components/Auth/useAuth'
import { useProfile } from '~src/queries/account'
import { Routes } from '~src/router/routes'

const AccountMenu: React.FC<BoxProps> = (props) => {
const { logout } = useAuth()
const { data: profile, isLoading } = useProfile()

if (isLoading) {
return (
<Box {...props}>
<Spinner />
</Box>
)
}

return (
<Box {...props}>
<Menu>
<MenuButton
as={IconButton}
icon={
<Avatar
name={`${profile.firstName} ${profile.lastName}`}
src={profile.organizations[0]?.organization.logo || ''}
size='sm'
/>
}
size='sm'
variant='outline'
aria-label='User menu'
/>
<MenuList>
<MenuItem as={RouterLink} to={Routes.dashboard.profile}>
<Trans i18nKey='profile'>Profile</Trans>
</MenuItem>
<MenuItem onClick={logout}>
<Trans i18nKey='logout'>Logout</Trans>
</MenuItem>
</MenuList>
</Menu>
</Box>
)
}

export default AccountMenu
116 changes: 116 additions & 0 deletions src/components/Account/PasswordForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Button, FormControl, FormErrorMessage, FormLabel, Input, useToast, VStack } from '@chakra-ui/react'
import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { ApiEndpoints } from '~components/Auth/api'
import { useAuth } from '~components/Auth/useAuth'

interface PasswordFormData {
oldPassword: string
newPassword: string
confirmPassword: string
}

export interface UpdatePasswordParams {
oldPassword: string
newPassword: string
}

const useUpdatePassword = () => {
const { bearedFetch } = useAuth()

return useMutation<void, Error, UpdatePasswordParams>({
mutationFn: (params) =>
bearedFetch<void>(ApiEndpoints.Password, {
method: 'PUT',
body: params,
}),
})
}

const PasswordForm = () => {
const { t } = useTranslation()
const toast = useToast()
const updatePassword = useUpdatePassword()

const {
register,
handleSubmit,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<PasswordFormData>()

const password = watch('newPassword')

const onSubmit = async (data: PasswordFormData) => {
try {
await updatePassword.mutateAsync({
oldPassword: data.oldPassword,
newPassword: data.newPassword,
})

toast({
title: t('password.success', { defaultValue: 'Password updated successfully' }),
status: 'success',
})
reset()
} catch (error) {
toast({
title: t('password.error', { defaultValue: 'Failed to update password' }),
status: 'error',
})
}
}

return (
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={6} align='stretch'>
<FormControl isInvalid={!!errors.oldPassword}>
<FormLabel>{t('password.old.label', { defaultValue: 'Current Password' })}</FormLabel>
<Input
type='password'
{...register('oldPassword', {
required: t('password.old.required', { defaultValue: 'Current password is required' }),
})}
/>
<FormErrorMessage>{errors.oldPassword?.message}</FormErrorMessage>
</FormControl>

<FormControl isInvalid={!!errors.newPassword}>
<FormLabel>{t('password.new.label', { defaultValue: 'New Password' })}</FormLabel>
<Input
type='password'
{...register('newPassword', {
required: t('password.new.required', { defaultValue: 'Password is required' }),
minLength: {
value: 8,
message: t('password.new.minLength', { defaultValue: 'Password must be at least 8 characters' }),
},
})}
/>
<FormErrorMessage>{errors.newPassword?.message}</FormErrorMessage>
</FormControl>

<FormControl isInvalid={!!errors.confirmPassword}>
<FormLabel>{t('password.confirm.label', { defaultValue: 'Confirm Password' })}</FormLabel>
<Input
type='password'
{...register('confirmPassword', {
required: t('password.confirm.required', { defaultValue: 'Please confirm your password' }),
validate: (value) =>
value === password || t('password.confirm.mismatch', { defaultValue: "Passwords don't match" }),
})}
/>
<FormErrorMessage>{errors.confirmPassword?.message}</FormErrorMessage>
</FormControl>

<Button type='submit' size='lg' width='100%' isLoading={isSubmitting || updatePassword.isPending} mt={4}>
{t('password.actions.update', { defaultValue: 'Update Password' })}
</Button>
</VStack>
</form>
)
}

export default PasswordForm
32 changes: 32 additions & 0 deletions src/components/Account/Teams.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Avatar, Badge, Box, HStack, Text, VStack } from '@chakra-ui/react'
import { UserRole } from '~src/queries/account'

const Teams = ({ roles }: { roles: UserRole[] }) => (
<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>
))}
</VStack>
)

export default Teams
2 changes: 2 additions & 0 deletions src/components/Auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ type MethodTypes = 'GET' | 'POST' | 'PUT' | 'DELETE'

export enum ApiEndpoints {
Login = 'auth/login',
Me = 'users/me',
Password = 'users/password',
Refresh = 'auth/refresh',
Register = 'users',
Organizations = 'organizations',
Expand Down
8 changes: 4 additions & 4 deletions src/components/Auth/useAuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { api, ApiEndpoints, ApiParams, UnauthorizedApiError } from '~components/
import { LoginResponse, useLogin, useRegister, useVerifyMail } from '~components/Auth/authQueries'

enum LocalStorageKeys {
AUTH_TOKEN = 'authToken',
Token = 'authToken',
}

/**
Expand Down Expand Up @@ -41,7 +41,7 @@ const useSigner = () => {

export const useAuthProvider = () => {
const { signer: clientSigner, clear } = useClient()
const [bearer, setBearer] = useState<string | null>(localStorage.getItem(LocalStorageKeys.AUTH_TOKEN))
const [bearer, setBearer] = useState<string | null>(localStorage.getItem(LocalStorageKeys.Token))

const login = useLogin({
onSuccess: (data, variables) => {
Expand Down Expand Up @@ -83,13 +83,13 @@ export const useAuthProvider = () => {
)

const storeLogin = useCallback(({ token }: LoginResponse) => {
localStorage.setItem(LocalStorageKeys.AUTH_TOKEN, token)
localStorage.setItem(LocalStorageKeys.Token, token)
setBearer(token)
updateSigner(token)
}, [])

const logout = useCallback(() => {
localStorage.removeItem(LocalStorageKeys.AUTH_TOKEN)
localStorage.removeItem(LocalStorageKeys.Token)
setBearer(null)
clear()
}, [])
Expand Down
Loading

2 comments on commit cb58c01

@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.