Skip to content

Commit

Permalink
Multiple organization support (#939)
Browse files Browse the repository at this point in the history
* refs #937 feat: implement signer address persistence in useAuthProvider

- Add SignerAddress to LocalStorageKeys
- Store and retrieve signer address from localStorage
- Use stored address if available, otherwise use first address
- Clear stored address on logout

* Switch organizations

- Fixed organization type incorrectly having name and description
- Updated components due to the previous change
- Added an organization selector for people having more than one org
- Made required changes to properly change the signer between
  organization changes
- Updated organization creation form to properly manage multiple
  organizations creation
- Removed check in previous section to ensure you can always create an
  org, even having one already

refs #937

* refs #937 Move organization switcher logic to its own component

* Show organization name in OrganizationSwitcher

refs #937

* refs #937 refactor: fetch organization names upfront to avoid re-renders in select dropdown

* refs #937 Properly redirect to /admin on success after creating new org

* refs #937 Add button to create new orgs in the menu

* refs #937 Fixed translation for color mode switcher
  • Loading branch information
elboletaire authored Jan 17, 2025
1 parent db39f67 commit 0339b2c
Show file tree
Hide file tree
Showing 9 changed files with 207 additions and 58 deletions.
50 changes: 29 additions & 21 deletions src/components/Account/Teams.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Avatar, Badge, Box, HStack, VStack } from '@chakra-ui/react'
import { OrganizationName } from '@vocdoni/chakra-components'
import { OrganizationProvider } from '@vocdoni/react-providers'
import { OrganizationProvider, useOrganization } from '@vocdoni/react-providers'
import { NoOrganizations } from '~components/Organization/NoOrganizations'
import { UserRole } from '~src/queries/account'

Expand All @@ -11,30 +11,38 @@ const Teams = ({ roles }: { roles: UserRole[] }) => {
<VStack spacing={4} align='stretch'>
{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>
<Team role={role} />
</OrganizationProvider>
))}
</VStack>
)
}

export const Team = ({ role }: { role: UserRole }) => {
const { organization } = useOrganization()

return (
<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={organization?.account.name.default} />
<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>
)
}

export default Teams
36 changes: 31 additions & 5 deletions src/components/Auth/useAuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import { useTranslation } from 'react-i18next'
import { api, ApiEndpoints, ApiParams } from '~components/Auth/api'
import { LoginResponse, useLogin, useRegister, useVerifyMail } from '~components/Auth/authQueries'

enum LocalStorageKeys {
export enum LocalStorageKeys {
Token = 'authToken',
Expiry = 'authExpiry',
RenewSession = 'authRenewSession',
SignerAddress = 'signerAddress',
}

// One week in milliseconds
Expand All @@ -22,17 +23,41 @@ const OneWeek = 7 * 24 * 60 * 60 * 1000
* to create organization page if not.
*/
const useSigner = () => {
const { setSigner } = useClient()
const { setSigner, fetchAccount, client, setClient } = useClient()

const updateSigner = useCallback(async (token?: string) => {
const t = token || localStorage.getItem(LocalStorageKeys.Token)

const updateSigner = useCallback(async (token: string) => {
const signer = new RemoteSigner({
url: import.meta.env.SAAS_URL,
token,
token: t,
})
// Once the signer is set, try to get the signer address
try {
await signer.getAddress()
const addresses = await signer.remoteSignerService.addresses()
if (!addresses.length) {
throw new Error('No addresses available')
}

// Get stored address from local storage
const storedAddress = localStorage.getItem(LocalStorageKeys.SignerAddress)

// Use stored address if it exists and is in the available addresses, otherwise use first address
const selectedAddress = storedAddress && addresses.includes(storedAddress) ? storedAddress : addresses[0]

// Store the selected address
localStorage.setItem(LocalStorageKeys.SignerAddress, selectedAddress)

// Set the signer address and update the client
signer.address = selectedAddress
setSigner(signer)

// update client, since it's the one used for some queries
client.wallet = signer
setClient(client)

await fetchAccount()

return signer
} catch (e) {
// If is NoOrganizationsError ignore the error
Expand Down Expand Up @@ -88,6 +113,7 @@ export const useAuthProvider = () => {
localStorage.removeItem(LocalStorageKeys.Token)
localStorage.removeItem(LocalStorageKeys.Expiry)
localStorage.removeItem(LocalStorageKeys.RenewSession)
localStorage.removeItem(LocalStorageKeys.SignerAddress)
setBearer(null)
clear()
}, [])
Expand Down
5 changes: 2 additions & 3 deletions src/components/Dashboard/Menu/Options.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Box, Collapse } from '@chakra-ui/react'
import { OrganizationName } from '@vocdoni/chakra-components'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HiSquares2X2 } from 'react-icons/hi2'
import { IoIosSettings } from 'react-icons/io'
import { generatePath, matchPath, useLocation } from 'react-router-dom'
import { Routes } from '~src/router/routes'
import { DashboardMenuItem } from './Item'
import { OrganizationSwitcher } from './OrganizationSwitcher'

type MenuItem = {
label: string
Expand All @@ -19,7 +19,6 @@ export const DashboardMenuOptions = () => {
const { t } = useTranslation()
const location = useLocation()
const [openSection, setOpenSection] = useState<string | null>(null)

const menuItems: MenuItem[] = [
// {
// label: t('organization.dashboard'),
Expand Down Expand Up @@ -82,7 +81,7 @@ export const DashboardMenuOptions = () => {

return (
<Box>
<OrganizationName mb={2} px={3.5} />
<OrganizationSwitcher />

{menuItems.map((item, index) => (
<Box key={index}>
Expand Down
110 changes: 110 additions & 0 deletions src/components/Dashboard/Menu/OrganizationSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Box, IconButton } from '@chakra-ui/react'
import { PlusSquare } from '@untitled-ui/icons-react'
import { OrganizationName } from '@vocdoni/chakra-components'
import { useClient } from '@vocdoni/react-providers'
import { Select } from 'chakra-react-select'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { useQueryClient } from 'wagmi'
import { useAuth } from '~components/Auth/useAuth'
import { LocalStorageKeys } from '~components/Auth/useAuthProvider'
import { Organization, useProfile } from '~src/queries/account'
import { Routes } from '~src/router/routes'

type SelectOption = {
value: string
label: string
organization: Organization
}

export const OrganizationSwitcher = () => {
const { t } = useTranslation()
const { data: profile } = useProfile()
const [selectedOrg, setSelectedOrg] = useState<string | null>(localStorage.getItem(LocalStorageKeys.SignerAddress))
const [names, setNames] = useState<Record<string, string>>({})
const { signerRefresh } = useAuth()
const queryClient = useQueryClient()
const { client } = useClient()

// Fetch organization names
useEffect(() => {
if (!profile?.organizations) return

const fetchOrgNames = async () => {
const names: Record<string, string> = {}
for (const org of profile.organizations) {
const address = org.organization.address
try {
const data = await client.fetchAccount(address)
names[address] = data?.account?.name?.default || address
} catch (error) {
console.error('Error fetching organization name:', error)
names[address] = address
}
}
setNames(names)
}

fetchOrgNames()
}, [profile])

// Populate organizations for the selector
const organizations = useMemo(() => {
if (!profile?.organizations) return []
return profile.organizations.map((org) => ({
value: org.organization.address,
label: names[org.organization.address] || org.organization.address,
organization: org.organization,
}))
}, [profile, names])

// Set first organization as default if none selected
useEffect(() => {
if (organizations.length && !selectedOrg) {
const firstOrgAddress = organizations[0].value
setSelectedOrg(firstOrgAddress)
localStorage.setItem(LocalStorageKeys.SignerAddress, firstOrgAddress)
}
}, [organizations, selectedOrg])

const handleOrgChange = async (option: SelectOption | null) => {
if (!option) return
setSelectedOrg(option.value)
localStorage.setItem(LocalStorageKeys.SignerAddress, option.value)
// clear all query client query cache
queryClient.clear()
// refresh signer
await signerRefresh()
}

return (
<Box mb={2} px={3.5} display='flex' alignItems='center' gap={2} justifyContent='space-between'>
{organizations.length > 1 ? (
<Select
value={organizations.find((org) => org.value === selectedOrg)}
onChange={handleOrgChange}
options={organizations}
size='sm'
chakraStyles={{
container: (provided) => ({
...provided,
width: '100%',
}),
}}
/>
) : (
<OrganizationName mb={2} px={3.5} />
)}
<IconButton
size='xs'
as={Link}
variant='solid'
colorScheme='gray'
aria-label={t('create_org.title')}
icon={<PlusSquare />}
to={Routes.dashboard.organizationCreate}
/>
</Box>
)
}
2 changes: 1 addition & 1 deletion src/components/Layout/ColorModeSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const DropdownColorModeSwitcher = (props) => {
return (
<MenuItem onClick={toggleColorMode} closeOnSelect={true} {...props}>
<Icon as={SwitchIcon} />
<Trans i18nKey={isLightMode ? 'dark_mode' : 'light_mode'}>Light mode</Trans>
{!isLightMode ? <Trans i18nKey='light_mode'>Light mode</Trans> : <Trans i18nKey='dark_mode'>Dark mode</Trans>}
</MenuItem>
)
}
38 changes: 29 additions & 9 deletions src/components/Organization/Create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,32 @@ import { Button, Flex, FlexProps, Stack, Text } from '@chakra-ui/react'
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { useClient } from '@vocdoni/react-providers'
import { Account, RemoteSigner } from '@vocdoni/sdk'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { Trans, useTranslation } from 'react-i18next'
import { Link as ReactRouterLink, useNavigate } from 'react-router-dom'
import { Link as ReactRouterLink, To, useNavigate } from 'react-router-dom'
import { CreateOrgParams } from '~components/Account/AccountTypes'
import LogoutBtn from '~components/Account/LogoutBtn'
import { ApiEndpoints } from '~components/Auth/api'
import { useAuth } from '~components/Auth/useAuth'
import { useAuthProvider } from '~components/Auth/useAuthProvider'
import { LocalStorageKeys, useAuthProvider } from '~components/Auth/useAuthProvider'
import FormSubmitMessage from '~components/Layout/FormSubmitMessage'
import { QueryKeys } from '~src/queries/keys'
import { Routes } from '~src/router/routes'
import { PrivateOrgForm, PrivateOrgFormData, PublicOrgForm } from './Form'

type FormData = PrivateOrgFormData & CreateOrgParams

const useOrganizationCreate = (options?: Omit<UseMutationOptions<void, Error, CreateOrgParams>, 'mutationFn'>) => {
type OrganizationCreateResponse = {
address: string
}

const useOrganizationCreate = (
options?: Omit<UseMutationOptions<OrganizationCreateResponse, Error, CreateOrgParams>, 'mutationFn'>
) => {
const { bearedFetch } = useAuth()
const client = useQueryClient()
return useMutation<void, Error, CreateOrgParams>({
return useMutation<OrganizationCreateResponse, Error, CreateOrgParams>({
mutationFn: (params: CreateOrgParams) => bearedFetch(ApiEndpoints.Organizations, { body: params, method: 'POST' }),
onSuccess: () => {
client.invalidateQueries({ queryKey: QueryKeys.profile })
Expand All @@ -36,7 +42,7 @@ export const OrganizationCreate = ({
onSuccessRoute = Routes.dashboard.base,
...props
}: {
onSuccessRoute?: number | string
onSuccessRoute?: To
canSkip?: boolean
} & FlexProps) => {
const { t } = useTranslation()
Expand All @@ -45,8 +51,9 @@ export const OrganizationCreate = ({
const methods = useForm<FormData>()
const { handleSubmit } = methods
const { bearer, signerRefresh } = useAuthProvider()
const { client, fetchAccount } = useClient()
const { client, fetchAccount, setClient, setSigner } = useClient()
const [promiseError, setPromiseError] = useState<Error | null>(null)
const [redirect, setRedirect] = useState<To | null>(null)

const { mutateAsync: createSaasAccount, isError: isSaasError, error: saasError } = useOrganizationCreate()

Expand All @@ -65,12 +72,19 @@ export const OrganizationCreate = ({
type: values.type?.value,
communications: values.communications,
})
.then(() => {
.then(({ address }: { address: string }) => {
const signer = new RemoteSigner({
url: import.meta.env.SAAS_URL,
token: bearer,
})

signer.address = address
client.wallet = signer

setSigner(signer)
setClient(client)
localStorage.setItem(LocalStorageKeys.SignerAddress, address)

return client.createAccount({
account: new Account({
name: typeof values.name === 'object' ? values.name.default : values.name,
Expand All @@ -81,14 +95,20 @@ export const OrganizationCreate = ({
// update state info and redirect
.then(() => {
fetchAccount().then(() => signerRefresh())
return navigate(onSuccessRoute as unknown)
setRedirect(onSuccessRoute)
})
.catch((e) => {
setPromiseError(e)
})
.finally(() => setIsPending(false))
}

// redirect on success
useEffect(() => {
if (!redirect) return
navigate(redirect)
}, [redirect])

return (
<FormProvider {...methods}>
<Flex
Expand Down
Loading

2 comments on commit 0339b2c

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