From 0339b2ccdfa9955f58e6335c3128dd3bad1dccd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=92scar=20Casajuana?= Date: Fri, 17 Jan 2025 13:35:13 +0100 Subject: [PATCH] Multiple organization support (#939) * 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 --- src/components/Account/Teams.tsx | 50 ++++---- src/components/Auth/useAuthProvider.ts | 36 +++++- src/components/Dashboard/Menu/Options.tsx | 5 +- .../Dashboard/Menu/OrganizationSwitcher.tsx | 110 ++++++++++++++++++ src/components/Layout/ColorModeSwitcher.tsx | 2 +- src/components/Organization/Create.tsx | 38 ++++-- src/components/Organization/Edit.tsx | 8 +- .../dashboard/organization/create.tsx | 14 +-- src/queries/account.ts | 2 - 9 files changed, 207 insertions(+), 58 deletions(-) create mode 100644 src/components/Dashboard/Menu/OrganizationSwitcher.tsx diff --git a/src/components/Account/Teams.tsx b/src/components/Account/Teams.tsx index 1e86017ce..1f594e061 100644 --- a/src/components/Account/Teams.tsx +++ b/src/components/Account/Teams.tsx @@ -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' @@ -11,30 +11,38 @@ const Teams = ({ roles }: { roles: UserRole[] }) => { {roles.map((role) => ( - - - - - - - {role.role} - - - - + ))} ) } +export const Team = ({ role }: { role: UserRole }) => { + const { organization } = useOrganization() + + return ( + + + + + + + {role.role} + + + + + ) +} + export default Teams diff --git a/src/components/Auth/useAuthProvider.ts b/src/components/Auth/useAuthProvider.ts index 2859f4db6..7bd28c046 100644 --- a/src/components/Auth/useAuthProvider.ts +++ b/src/components/Auth/useAuthProvider.ts @@ -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 @@ -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 @@ -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() }, []) diff --git a/src/components/Dashboard/Menu/Options.tsx b/src/components/Dashboard/Menu/Options.tsx index 62ea36703..db72b20c4 100644 --- a/src/components/Dashboard/Menu/Options.tsx +++ b/src/components/Dashboard/Menu/Options.tsx @@ -1,5 +1,4 @@ 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' @@ -7,6 +6,7 @@ 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 @@ -19,7 +19,6 @@ export const DashboardMenuOptions = () => { const { t } = useTranslation() const location = useLocation() const [openSection, setOpenSection] = useState(null) - const menuItems: MenuItem[] = [ // { // label: t('organization.dashboard'), @@ -82,7 +81,7 @@ export const DashboardMenuOptions = () => { return ( - + {menuItems.map((item, index) => ( diff --git a/src/components/Dashboard/Menu/OrganizationSwitcher.tsx b/src/components/Dashboard/Menu/OrganizationSwitcher.tsx new file mode 100644 index 000000000..1640a6525 --- /dev/null +++ b/src/components/Dashboard/Menu/OrganizationSwitcher.tsx @@ -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(localStorage.getItem(LocalStorageKeys.SignerAddress)) + const [names, setNames] = useState>({}) + const { signerRefresh } = useAuth() + const queryClient = useQueryClient() + const { client } = useClient() + + // Fetch organization names + useEffect(() => { + if (!profile?.organizations) return + + const fetchOrgNames = async () => { + const names: Record = {} + 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 ( + + {organizations.length > 1 ? ( +