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

feat: roles and permissions setup [SW-601] #4807

Merged
merged 43 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
001ccc7
feat: define config for roles + permissions
tmjssz Jan 18, 2025
cc44942
feat: add role permissions retrieval functionality
tmjssz Jan 18, 2025
cf64918
refactor: add new `useIsSpendingLimitBeneficiary` hook
tmjssz Jan 18, 2025
2870a53
feat: add `useRoles` hook to determine user roles
tmjssz Jan 18, 2025
4ca1839
feat: add `useHasRoles` hook to check user has certain roles
tmjssz Jan 18, 2025
6c5a662
refactor: remove role type prop for Proposer role
tmjssz Jan 18, 2025
9e952d3
feat: add `useRoleProps` hook to retrieve role-specific props based o…
tmjssz Jan 18, 2025
90e00cc
feat: add `usePermissions` hook to retrieve permissions based on user…
tmjssz Jan 18, 2025
54a67d4
feat: add `usePermission` hook to evaluate permissions for the user's…
tmjssz Jan 18, 2025
e85e768
feat: add `useHasPermission` hook to check if the user has a specific…
tmjssz Jan 18, 2025
7345a17
feat: add `withPermission` HOC to conditionally render components bas…
tmjssz Jan 18, 2025
e6eaaab
fix: add missing props in useEffect and useMemo dependency arrays
tmjssz Jan 18, 2025
b3fc1f1
fix: lint error for unused vars by prefixing with underscore
tmjssz Jan 18, 2025
a724856
fix: define displayName for component returned by withPermission HOC
tmjssz Jan 18, 2025
5879de2
fix: optimize roleApplicableMap calculation using useMemo for perform…
tmjssz Jan 18, 2025
aab9aed
fix: update imports to use type-only imports for better clarity and p…
tmjssz Jan 18, 2025
e5d2d54
fix: simplify props type definition in getRolePermissions
tmjssz Jan 19, 2025
6bd950c
fix: rename withPermissions to withPermission for consistency and cla…
tmjssz Jan 20, 2025
bb0cfa7
feat: add NestedOwner role and update useRoles hook to include nested…
tmjssz Jan 20, 2025
7345c1c
feat: add EnablePushNotifications permission to role permissions conf…
tmjssz Jan 20, 2025
185aead
feat: add CheckWalletWithPermission component for wallet connection a…
tmjssz Jan 20, 2025
5e39fa8
feat: replace CheckWallet in push notification components with CheckW…
tmjssz Jan 21, 2025
3147d4b
test: add unit tests for CheckWalletWithPermission component
tmjssz Jan 21, 2025
4ba4c12
fix: add missing isNestedSafeOwner in useMemo dependency array
tmjssz Jan 21, 2025
bc042cd
fix: change imports to use type imports for Permission and Permission…
tmjssz Jan 21, 2025
0bee821
fix: update ExecuteTransaction permission function to always return true
tmjssz Jan 21, 2025
076a482
feat: add CreateSpendingLimitTransaction permission and implement cor…
tmjssz Jan 21, 2025
74a41fe
feat: implement permission checks for creating standard and spending …
tmjssz Jan 21, 2025
428f923
fix: update usePermission and PermissionFn types to improve type hand…
tmjssz Jan 21, 2025
30a80f5
fix: change import to use type import for TokenInfo in PermissionProps
tmjssz Jan 21, 2025
df904c1
fix: handle default parameter for token in CreateSpendingLimitTransac…
tmjssz Jan 21, 2025
0fc7d7c
fix: update documentation examples in withPermission HOC for clarity
tmjssz Jan 21, 2025
29340a8
refactor: remove usePermissions hook, moving its logic into usePermis…
tmjssz Jan 21, 2025
6b1f8b4
feat: add withRole HOC for role-based component rendering
tmjssz Jan 21, 2025
63e972c
test: add permission checks for CreateTokenTransfer component
tmjssz Jan 21, 2025
e665916
refactor: simplify getRolePermissions by removing redundant getRolePe…
tmjssz Jan 21, 2025
6df6184
test: add unit tests for getRolePermissions function
tmjssz Jan 21, 2025
87b892d
refactor: change import to type for SpendingLimitState in getRolePerm…
tmjssz Jan 21, 2025
5c027ce
refactor: optimize useHasRoles hook by removing isArrayEqualSet utili…
tmjssz Jan 22, 2025
191fffb
fix: always call permission function, also if no props are given
tmjssz Jan 22, 2025
90ecbe4
test: add unit tests for role and permission hooks and HOCs
tmjssz Jan 22, 2025
a1397a0
refactor: change imports to type for SpendingLimitState and Connected…
tmjssz Jan 22, 2025
4e91412
refactor: update displayName assignment in withPermission and withRol…
tmjssz Jan 23, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK'
import { render } from '@/tests/test-utils'
import CheckWalletWithPermission from './index'
import useIsWrongChain from '@/hooks/useIsWrongChain'
import useWallet from '@/hooks/wallets/useWallet'
import { chainBuilder } from '@/tests/builders/chains'
import { faker } from '@faker-js/faker'
import { extendedSafeInfoBuilder } from '@/tests/builders/safe'
import useSafeInfo from '@/hooks/useSafeInfo'
import type Safe from '@safe-global/protocol-kit'
import * as useHasPermission from '@/permissions/hooks/useHasPermission'
import { Permission } from '@/permissions/types'

const mockWalletAddress = faker.finance.ethereumAddress()
// mock useWallet
jest.mock('@/hooks/wallets/useWallet', () => ({
__esModule: true,
default: jest.fn(() => ({
address: mockWalletAddress,
})),
}))

// mock useCurrentChain
jest.mock('@/hooks/useChains', () => ({
__esModule: true,
useCurrentChain: jest.fn(() => chainBuilder().build()),
}))

// mock useIsWrongChain
jest.mock('@/hooks/useIsWrongChain', () => ({
__esModule: true,
default: jest.fn(() => false),
}))

jest.mock('@/hooks/useSafeInfo', () => ({
__esModule: true,
default: jest.fn(() => {
const safeAddress = faker.finance.ethereumAddress()
return {
safeAddress,
safe: extendedSafeInfoBuilder()
.with({ address: { value: safeAddress } })
.with({ deployed: true })
.build(),
}
}),
}))

jest.mock('@/hooks/coreSDK/safeCoreSDK')
const mockUseSafeSdk = useSafeSDK as jest.MockedFunction<typeof useSafeSDK>

const renderButton = () =>
render(
<CheckWalletWithPermission permission={Permission.SignTransaction} checkNetwork={false}>
{(isOk) => <button disabled={!isOk}>Continue</button>}
</CheckWalletWithPermission>,
)

describe('CheckWalletWithPermission', () => {
const useHasPermissionSpy = jest.spyOn(useHasPermission, 'useHasPermission')

beforeEach(() => {
jest.clearAllMocks()
mockUseSafeSdk.mockReturnValue({} as unknown as Safe)
useHasPermissionSpy.mockReturnValue(true)
})

it('renders correctly when the wallet is connected to the right chain and is an owner', () => {
const { getByText } = renderButton()

// Check that the button is enabled
expect(getByText('Continue')).not.toBeDisabled()
})

it('should disable the button when the wallet is not connected', () => {
;(useWallet as jest.MockedFunction<typeof useWallet>).mockReturnValueOnce(null)

const { getByText, getByLabelText } = renderButton()

// Check that the button is disabled
expect(getByText('Continue')).toBeDisabled()

// Check the tooltip text
expect(getByLabelText('Please connect your wallet')).toBeInTheDocument()
})

it('should disable the button when the current user does not have the specified permission', () => {
useHasPermissionSpy.mockReturnValue(false)

const { getByText, getByLabelText } = renderButton()

expect(getByText('Continue')).toBeDisabled()
expect(getByLabelText('Your connected wallet is not a signer of this Safe Account')).toBeInTheDocument()

expect(useHasPermissionSpy).toHaveBeenCalledTimes(1)
expect(useHasPermissionSpy).toHaveBeenCalledWith(Permission.SignTransaction)
})

it('should be disabled when connected to the wrong network', () => {
;(useIsWrongChain as jest.MockedFunction<typeof useIsWrongChain>).mockReturnValue(true)

const renderButtonWithNetworkCheck = () =>
render(
<CheckWalletWithPermission permission={Permission.SignTransaction} checkNetwork={true}>
{(isOk) => <button disabled={!isOk}>Continue</button>}
</CheckWalletWithPermission>,
)

const { getByText } = renderButtonWithNetworkCheck()

expect(getByText('Continue')).toBeDisabled()
})

it('should disable the button for counterfactual Safes', () => {
const safeAddress = faker.finance.ethereumAddress()
const mockSafeInfo = {
safeAddress,
safe: extendedSafeInfoBuilder()
.with({ address: { value: safeAddress } })
.with({ deployed: false })
.build(),
}

;(useSafeInfo as jest.MockedFunction<typeof useSafeInfo>).mockReturnValueOnce(
mockSafeInfo as unknown as ReturnType<typeof useSafeInfo>,
)

const { getByText, getByLabelText } = renderButton()

expect(getByText('Continue')).toBeDisabled()
expect(getByLabelText('You need to activate the Safe before transacting')).toBeInTheDocument()
})

it('should enable the button for counterfactual Safes if allowed', () => {
const safeAddress = faker.finance.ethereumAddress()
const mockSafeInfo = {
safeAddress,
safe: extendedSafeInfoBuilder()
.with({ address: { value: safeAddress } })
.with({ deployed: false })
.build(),
}

;(useSafeInfo as jest.MockedFunction<typeof useSafeInfo>).mockReturnValueOnce(
mockSafeInfo as unknown as ReturnType<typeof useSafeInfo>,
)

const { getByText } = render(
<CheckWalletWithPermission permission={Permission.SignTransaction} allowUndeployedSafe>
{(isOk) => <button disabled={!isOk}>Continue</button>}
</CheckWalletWithPermission>,
)

expect(getByText('Continue')).toBeEnabled()
})

it('should disable the button if SDK is not initialized and safe is loaded', () => {
mockUseSafeSdk.mockReturnValue(undefined)

const mockSafeInfo = {
safeLoaded: true,
safe: extendedSafeInfoBuilder(),
}

;(useSafeInfo as jest.MockedFunction<typeof useSafeInfo>).mockReturnValueOnce(
mockSafeInfo as unknown as ReturnType<typeof useSafeInfo>,
)

const { getByText, getByLabelText } = render(
<CheckWalletWithPermission permission={Permission.SignTransaction}>
{(isOk) => <button disabled={!isOk}>Continue</button>}
</CheckWalletWithPermission>,
)

expect(getByText('Continue')).toBeDisabled()
expect(getByLabelText('SDK is not initialized yet'))
})

it('should not disable the button if SDK is not initialized and safe is not loaded', () => {
mockUseSafeSdk.mockReturnValue(undefined)

const safeAddress = faker.finance.ethereumAddress()
const mockSafeInfo = {
safeAddress,
safe: extendedSafeInfoBuilder()
.with({ address: { value: safeAddress } })
.with({ deployed: true })
.build(),
safeLoaded: false,
}

;(useSafeInfo as jest.MockedFunction<typeof useSafeInfo>).mockReturnValueOnce(
mockSafeInfo as unknown as ReturnType<typeof useSafeInfo>,
)

const { queryByText } = render(
<CheckWalletWithPermission permission={Permission.SignTransaction}>
{(isOk) => <button disabled={!isOk}>Continue</button>}
</CheckWalletWithPermission>,
)

expect(queryByText('Continue')).not.toBeDisabled()
})
})
81 changes: 81 additions & 0 deletions apps/web/src/components/common/CheckWalletWithPermission/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK'
import { useMemo, type ReactElement } from 'react'
import useWallet from '@/hooks/wallets/useWallet'
import useConnectWallet from '../ConnectWallet/useConnectWallet'
import useIsWrongChain from '@/hooks/useIsWrongChain'
import { Tooltip } from '@mui/material'
import useSafeInfo from '@/hooks/useSafeInfo'
import type { Permission, PermissionProps } from '@/permissions/types'
import { useHasPermission } from '@/permissions/hooks/useHasPermission'

type CheckWalletWithPermissionProps<
P extends Permission,
PProps = PermissionProps<P> extends undefined ? { permissionProps?: never } : { permissionProps: PermissionProps<P> },
> = {
children: (ok: boolean) => ReactElement
permission: P
noTooltip?: boolean
checkNetwork?: boolean
allowUndeployedSafe?: boolean
} & PProps

enum Message {
WalletNotConnected = 'Please connect your wallet',
SDKNotInitialized = 'SDK is not initialized yet',
NotSafeOwner = 'Your connected wallet is not a signer of this Safe Account',
SafeNotActivated = 'You need to activate the Safe before transacting',
}

const CheckWalletWithPermission = <P extends Permission>({
children,
permission,
permissionProps,
noTooltip,
checkNetwork = false,
allowUndeployedSafe = false,
}: CheckWalletWithPermissionProps<P>): ReactElement => {
const wallet = useWallet()
const connectWallet = useConnectWallet()
const isWrongChain = useIsWrongChain()
const sdk = useSafeSDK()
const hasPermission = useHasPermission(
permission,
...((permissionProps ? [permissionProps] : []) as PermissionProps<P> extends undefined
? []
: [props: PermissionProps<P>]),
)

const { safe, safeLoaded } = useSafeInfo()

const isUndeployedSafe = !safe.deployed

const message = useMemo(() => {
if (!wallet) {
return Message.WalletNotConnected
}

if (!sdk && safeLoaded) {
return Message.SDKNotInitialized
}

if (isUndeployedSafe && !allowUndeployedSafe) {
return Message.SafeNotActivated
}

if (!hasPermission) {
return Message.NotSafeOwner
}
}, [allowUndeployedSafe, hasPermission, isUndeployedSafe, sdk, wallet, safeLoaded])

if (checkNetwork && isWrongChain) return children(false)
if (!message) return children(true)
if (noTooltip) return children(false)

return (
<Tooltip title={message}>
<span onClick={wallet ? undefined : connectWallet}>{children(false)}</span>
</Tooltip>
)
}

export default CheckWalletWithPermission
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useState, type ReactElement } from 'react'
import { Alert, Box, Button, Typography } from '@mui/material'
import useSafeInfo from '@/hooks/useSafeInfo'
import CheckWallet from '@/components/common/CheckWallet'
import CheckWalletWithPermission from '@/components/common/CheckWalletWithPermission'
import { useNotificationsRenewal } from '@/components/settings/PushNotifications/hooks/useNotificationsRenewal'
import { useIsNotificationsRenewalEnabled } from '@/components/settings/PushNotifications/hooks/useNotificationsTokenVersion'
import { RENEWAL_MESSAGE } from '@/components/settings/PushNotifications/constants'
import { Permission } from '@/permissions/types'

const NotificationRenewal = (): ReactElement => {
const { safe } = useSafeInfo()
Expand Down Expand Up @@ -32,7 +33,10 @@ const NotificationRenewal = (): ReactElement => {
<Typography variant="body2">{RENEWAL_MESSAGE}</Typography>
</Alert>
<Box>
<CheckWallet allowNonOwner checkNetwork={!isRegistering && safe.deployed}>
<CheckWalletWithPermission
permission={Permission.EnablePushNotifications}
checkNetwork={!isRegistering && safe.deployed}
>
{(isOk) => (
<Button
variant="contained"
Expand All @@ -44,7 +48,7 @@ const NotificationRenewal = (): ReactElement => {
Sign now
</Button>
)}
</CheckWallet>
</CheckWalletWithPermission>
</Box>
</>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notif
import { requestNotificationPermission } from './logic'
import type { NotifiableSafes } from './logic'
import type { PushNotificationPreferences } from '@/services/push-notifications/preferences'
import CheckWallet from '@/components/common/CheckWallet'
import CheckWalletWithPermission from '@/components/common/CheckWalletWithPermission'
import { Permission } from '@/permissions/types'

import css from './styles.module.css'
import useAllOwnedSafes from '@/features/myAccounts/hooks/useAllOwnedSafes'
Expand Down Expand Up @@ -403,13 +404,13 @@ export const GlobalPushNotifications = (): ReactElement | null => {
</Typography>
)}

<CheckWallet allowNonOwner>
<CheckWalletWithPermission permission={Permission.EnablePushNotifications}>
{(isOk) => (
<Button variant="contained" disabled={!canSave || !isOk || isLoading} onClick={onSave}>
{isLoading ? <CircularProgress size={20} /> : 'Save'}
</Button>
)}
</CheckWallet>
</CheckWalletWithPermission>
</Box>
</Grid>

Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/settings/PushNotifications/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ import { HelpCenterArticle, IS_DEV } from '@/config/constants'
import { trackEvent } from '@/services/analytics'
import { PUSH_NOTIFICATION_EVENTS } from '@/services/analytics/events/push-notifications'
import { AppRoutes } from '@/config/routes'
import CheckWallet from '@/components/common/CheckWallet'
import CheckWalletWithPermission from '@/components/common/CheckWalletWithPermission'
import { useIsMac } from '@/hooks/useIsMac'
import ExternalLink from '@/components/common/ExternalLink'
import { Permission } from '@/permissions/types'

import css from './styles.module.css'
import NetworkWarning from '@/components/new-safe/create/NetworkWarning'
Expand Down Expand Up @@ -148,7 +149,10 @@ export const PushNotifications = (): ReactElement => {
showName={true}
hasExplorer
/>
<CheckWallet allowNonOwner checkNetwork={!isRegistering && safe.deployed}>
<CheckWalletWithPermission
permission={Permission.EnablePushNotifications}
checkNetwork={!isRegistering && safe.deployed}
>
{(isOk) => (
<FormControlLabel
data-testid="notifications-switch"
Expand All @@ -157,7 +161,7 @@ export const PushNotifications = (): ReactElement => {
disabled={!isOk || isRegistering || !safe.deployed}
/>
)}
</CheckWallet>
</CheckWalletWithPermission>
</div>

<Paper className={css.globalInfo} variant="outlined">
Expand Down
Loading
Loading