diff --git a/package-lock.json b/package-lock.json index 4c20f7dbe..1bb9ad585 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "front-aleph-cloud", "version": "0.36.1", "dependencies": { - "@aleph-front/core": "^1.32.0", + "@aleph-front/core": "^1.32.1", "@aleph-sdk/account": "^1.2.0", "@aleph-sdk/avalanche": "^1.5.0", "@aleph-sdk/base": "^1.5.0", @@ -71,9 +71,9 @@ "license": "MIT" }, "node_modules/@aleph-front/core": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/@aleph-front/core/-/core-1.32.0.tgz", - "integrity": "sha512-pOg327nhOH+eNjXtfcmV6oF8PPhqs8KVw/wIc6+c1gMv0Oqd1MNuchDj8ncTPpfEEcGDFtwkw6L7LA75kTfP0Q==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@aleph-front/core/-/core-1.32.1.tgz", + "integrity": "sha512-39tp1xTQM1vkE4TiBk/FI22uoLR0/WMy/FQlKDzXBOolpezBgap9wvHd+gruzoN6fWTJ4Ql0M5cUwH/MWMw+lA==", "license": "ISC", "dependencies": { "@monaco-editor/react": "^4.4.6", diff --git a/package.json b/package.json index ef5555057..eb40a1127 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint:fix": "next lint --fix" }, "dependencies": { - "@aleph-front/core": "^1.32.0", + "@aleph-front/core": "^1.32.1", "@aleph-sdk/account": "^1.2.0", "@aleph-sdk/avalanche": "^1.5.0", "@aleph-sdk/base": "^1.5.0", diff --git a/src/components/common/CopyToClipboard/cmp.tsx b/src/components/common/CopyToClipboard/cmp.tsx new file mode 100644 index 000000000..96d4e8a5f --- /dev/null +++ b/src/components/common/CopyToClipboard/cmp.tsx @@ -0,0 +1,22 @@ +import { memo } from 'react' +import { useCopyToClipboardAndNotify } from '@aleph-front/core' +import { StyledCopytoClipboard, StyledIcon } from './styles' +import { CopyToClipboardProps } from './types' + +export const CopytoClipboard = ({ + text, + textToCopy, + iconColor = 'purple3', +}: CopyToClipboardProps) => { + const handleCopy = useCopyToClipboardAndNotify(textToCopy || '') + + return ( + + {text} + + + ) +} +CopytoClipboard.displayName = 'CopytoClipboard' + +export default memo(CopytoClipboard) diff --git a/src/components/common/CopyToClipboard/index.ts b/src/components/common/CopyToClipboard/index.ts new file mode 100644 index 000000000..7a9f83f3c --- /dev/null +++ b/src/components/common/CopyToClipboard/index.ts @@ -0,0 +1 @@ +export { default } from './cmp' diff --git a/src/components/common/CopyToClipboard/styles.ts b/src/components/common/CopyToClipboard/styles.ts new file mode 100644 index 000000000..5ec8b0bfa --- /dev/null +++ b/src/components/common/CopyToClipboard/styles.ts @@ -0,0 +1,23 @@ +import { Icon } from '@aleph-front/core' +import styled, { css } from 'styled-components' +import tw from 'twin.macro' + +export const StyledCopytoClipboard = styled.div` + ${({ theme }) => css` + ${tw`cursor-pointer flex gap-x-2 items-center`} + + &:hover ${StyledIcon} { + color: ${theme.color.main0}; + } + `} +` + +export const StyledIcon = styled(Icon)<{ $color: string }>` + ${({ theme, $color }) => css` + color: ${theme.color[$color]}; + + transition-property: color; + transition-duration: ${theme.transition.duration.fast}ms; + transition-timing-function: ${theme.transition.timing}; + `} +` diff --git a/src/components/common/CopyToClipboard/types.ts b/src/components/common/CopyToClipboard/types.ts new file mode 100644 index 000000000..f577de5a5 --- /dev/null +++ b/src/components/common/CopyToClipboard/types.ts @@ -0,0 +1,7 @@ +import { ReactNode } from 'react' + +export type CopyToClipboardProps = { + text?: ReactNode + iconColor?: string + textToCopy?: string +} diff --git a/src/components/common/CountBadge/cmp.tsx b/src/components/common/CountBadge/cmp.tsx new file mode 100644 index 000000000..916a9bf68 --- /dev/null +++ b/src/components/common/CountBadge/cmp.tsx @@ -0,0 +1,17 @@ +import React, { memo } from 'react' +import { CountBadgeProps } from './types' + +export const CountBadge = ({ count }: CountBadgeProps) => { + return ( +
+ {count} +
+ ) +} + +CountBadge.displayName = 'CountBadge' + +export default memo(CountBadge) as typeof CountBadge diff --git a/src/components/common/CountBadge/index.ts b/src/components/common/CountBadge/index.ts new file mode 100644 index 000000000..d40872e19 --- /dev/null +++ b/src/components/common/CountBadge/index.ts @@ -0,0 +1,2 @@ +export { default } from './cmp' +export * from './types' diff --git a/src/components/common/CountBadge/types.ts b/src/components/common/CountBadge/types.ts new file mode 100644 index 000000000..a22dbcec3 --- /dev/null +++ b/src/components/common/CountBadge/types.ts @@ -0,0 +1,3 @@ +export type CountBadgeProps = { + count: number +} diff --git a/src/components/common/DashboardCardWithSideImage/cmp.tsx b/src/components/common/DashboardCardWithSideImage/cmp.tsx index 5ab365647..594784f42 100644 --- a/src/components/common/DashboardCardWithSideImage/cmp.tsx +++ b/src/components/common/DashboardCardWithSideImage/cmp.tsx @@ -33,7 +33,7 @@ export const DashboardCardWithSideImage = ({ {title} - {description} +
{description}
{(withButton || externalLinkUrl) && (
diff --git a/src/components/common/DashboardCardWithSideImage/types.ts b/src/components/common/DashboardCardWithSideImage/types.ts index 8e327929d..cd4ec024d 100644 --- a/src/components/common/DashboardCardWithSideImage/types.ts +++ b/src/components/common/DashboardCardWithSideImage/types.ts @@ -1,7 +1,9 @@ +import { ReactNode } from 'react' + export type DashboardCardWithSideImageProps = { info: string title: string - description: string + description: ReactNode imageSrc: string imageAlt: string withButton?: boolean diff --git a/src/components/common/PermissionsDetail/cmp.tsx b/src/components/common/PermissionsDetail/cmp.tsx new file mode 100644 index 000000000..1017a6649 --- /dev/null +++ b/src/components/common/PermissionsDetail/cmp.tsx @@ -0,0 +1,84 @@ +import React, { memo } from 'react' +import { PermissionsDetailProps } from './types' +import { NoisyContainer, ObjectImg } from '@aleph-front/core' +import CopyToClipboard from '../CopyToClipboard' +import Form from '@/components/form/Form' +import { usePermissionsDetailForm } from './hook' +import PermissionsConfiguration from '@/components/form/PermissionsConfiguration' + +export const PermissionsDetail = ({ + permissions, + onSubmit, + onUpdate, + channelsPanelOrder = 1, +}: PermissionsDetailProps) => { + const { handleSubmit, errors, control } = usePermissionsDetailForm({ + permissions, + onSubmitSuccess: onSubmit, + onUpdate, + }) + + return ( + <> +
+
+
+
Recipient details
+ +
+
+ +
+
+ {/*
+
Created at
+
+ {permissions.created_at || 'Unknown'} +
+
*/} +
+
+ Recipient account address +
+
+ + {permissions.id} + + } + textToCopy={permissions.id} + /> +
+
+
+
+ Recipient alias +
+
{permissions.alias || '-'}
+
+
+
+
+
+
+
Permissions details
+ +
+
+
+ + ) +} +PermissionsDetail.displayName = 'PermissionsDetail' + +export default memo(PermissionsDetail) as typeof PermissionsDetail diff --git a/src/components/common/PermissionsDetail/hook.ts b/src/components/common/PermissionsDetail/hook.ts new file mode 100644 index 000000000..2c77cd0d9 --- /dev/null +++ b/src/components/common/PermissionsDetail/hook.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect } from 'react' +import { useWatch } from 'react-hook-form' +import { useForm } from '@/hooks/common/useForm' +import { + AccountPermissions, + MessageTypePermissions, +} from '@/domain/permissions' + +export type PermissionsDetailFormState = { + channels: string[] + messageTypes: MessageTypePermissions[] + revoked: boolean +} + +export type UsePermissionsDetailFormProps = { + permissions: AccountPermissions + onSubmitSuccess?: (updatedPermission: AccountPermissions) => void + onUpdate?: (updatedPermission: AccountPermissions) => void +} + +export function usePermissionsDetailForm({ + permissions, + onSubmitSuccess, + onUpdate, +}: UsePermissionsDetailFormProps) { + const defaultValues: PermissionsDetailFormState = { + channels: permissions.channels, + messageTypes: permissions.messageTypes, + revoked: permissions.revoked, + } + + const onSubmit = useCallback( + async (state: PermissionsDetailFormState) => { + const updatedPermission: AccountPermissions = { + id: permissions.id, + alias: permissions.alias, + ...state, + } + console.log('Updated permissions:', updatedPermission) + + onSubmitSuccess?.(updatedPermission) + }, + [permissions.id, permissions.alias, onSubmitSuccess], + ) + + const { + control, + handleSubmit, + formState: { errors, isDirty }, + } = useForm({ + defaultValues, + onSubmit, + onSuccess: () => Promise.resolve(), + }) + + // Watch form values for real-time updates + const watchedValues = useWatch({ control }) + + useEffect(() => { + if (onUpdate) { + const updatedPermission: AccountPermissions = { + id: permissions.id, + alias: permissions.alias, + channels: watchedValues.channels ?? permissions.channels, + messageTypes: + (watchedValues.messageTypes as MessageTypePermissions[]) ?? + permissions.messageTypes, + revoked: permissions.revoked, + } + onUpdate(updatedPermission) + } + }, [ + watchedValues, + permissions.id, + permissions.alias, + permissions.channels, + permissions.messageTypes, + permissions.revoked, + onUpdate, + ]) + + return { + control, + handleSubmit, + errors, + isDirty, + } +} diff --git a/src/components/common/PermissionsDetail/index.tsx b/src/components/common/PermissionsDetail/index.tsx new file mode 100644 index 000000000..7a9f83f3c --- /dev/null +++ b/src/components/common/PermissionsDetail/index.tsx @@ -0,0 +1 @@ +export { default } from './cmp' diff --git a/src/components/common/PermissionsDetail/types.tsx b/src/components/common/PermissionsDetail/types.tsx new file mode 100644 index 000000000..5dc4c8283 --- /dev/null +++ b/src/components/common/PermissionsDetail/types.tsx @@ -0,0 +1,9 @@ +import { AccountPermissions } from '@/domain/permissions' + +export type PermissionsDetailProps = { + permissions: AccountPermissions + onSubmit?: (updatedPermission: AccountPermissions) => void + onUpdate?: (updatedPermission: AccountPermissions) => void + channelsPanelOrder?: number + onCancel?: () => void +} diff --git a/src/components/common/Portal/cmp.tsx b/src/components/common/Portal/cmp.tsx new file mode 100644 index 000000000..6772c9b34 --- /dev/null +++ b/src/components/common/Portal/cmp.tsx @@ -0,0 +1,22 @@ +import { memo, useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { PortalProps } from './types' + +export const Portal = ({ children, containerRef }: PortalProps) => { + const [SSR, setSSR] = useState(true) + const container = containerRef?.current + + const shouldRender = container || !SSR + + useEffect(() => { + if (container) return + setSSR(false) + }, [container]) + + return shouldRender + ? createPortal(children, container || window.document.body) + : null +} +Portal.displayName = 'Portal' + +export default memo(Portal) as typeof Portal diff --git a/src/components/common/Portal/index.tsx b/src/components/common/Portal/index.tsx new file mode 100644 index 000000000..028ff6b97 --- /dev/null +++ b/src/components/common/Portal/index.tsx @@ -0,0 +1,2 @@ +export { default as Portal } from './cmp' +export type { PortalProps } from './types' diff --git a/src/components/common/Portal/types.ts b/src/components/common/Portal/types.ts new file mode 100644 index 000000000..8aa90c9bd --- /dev/null +++ b/src/components/common/Portal/types.ts @@ -0,0 +1,6 @@ +import { ReactNode, RefObject } from 'react' + +export type PortalProps = { + containerRef?: RefObject + children: ReactNode +} diff --git a/src/components/form/FloatingFooter/cmp.tsx b/src/components/form/FloatingFooter/cmp.tsx index 99fffedf9..66986ad06 100644 --- a/src/components/form/FloatingFooter/cmp.tsx +++ b/src/components/form/FloatingFooter/cmp.tsx @@ -13,6 +13,7 @@ export type FloatingFooterProps = { children: ReactNode containerRef: RefObject shouldHide?: boolean + shouldRender?: boolean offset?: number thresholdOffset?: number deps?: any[] @@ -24,6 +25,7 @@ export const FloatingFooter = ({ offset = 0, thresholdOffset = offset, shouldHide = true, + shouldRender = true, deps: depsProp = [], }: FloatingFooterProps) => { const thresholdRef = useRef(null) @@ -46,14 +48,19 @@ export const FloatingFooter = ({ const theme = useTheme() - const { shouldMount, stage } = useTransition( - sticked, - theme.transition.duration.fast, + const { shouldMount: shouldMountSticked, stage: stageSticked } = + useTransition(sticked, theme.transition.duration.fast) + + const { shouldMount: shouldMountRender, stage: stageRender } = useTransition( + shouldRender, + theme.transition.duration.normal, ) - const show = stage === 'enter' + const show = stageSticked === 'enter' + const visible = stageRender === 'enter' if (!containerBounds) return null + if (!shouldMountRender) return null const position = !shouldHide ? 'sticky' : 'fixed' const opacity = !shouldHide ? 1 : show ? 1 : 0 @@ -65,6 +72,7 @@ export const FloatingFooter = ({ const contentNode = ( {children} @@ -74,7 +82,7 @@ export const FloatingFooter = ({ return ( <> {shouldHide - ? shouldMount && + ? shouldMountSticked && typeof window === 'object' && createPortal(contentNode, window.document.body) : contentNode} diff --git a/src/components/form/FloatingFooter/styles.tsx b/src/components/form/FloatingFooter/styles.tsx index cb37929dc..d73f97149 100644 --- a/src/components/form/FloatingFooter/styles.tsx +++ b/src/components/form/FloatingFooter/styles.tsx @@ -3,10 +3,11 @@ import tw from 'twin.macro' export type StyledContainerProps = { $sticked?: boolean + $visible?: boolean } export const StyledContainer = styled.div` - ${({ theme, $sticked }) => { + ${({ theme, $sticked, $visible }) => { const { shadow } = theme.component.walletPicker const { timing, duration } = theme.transition @@ -15,7 +16,10 @@ export const StyledContainer = styled.div` background: ${theme.color.base1}; box-shadow: ${$sticked ? shadow : 'none'}; - transition: all ${timing} ${duration.fast}ms 0s; + transform: translateY(${$visible ? '0' : '100%'}); + transition: + box-shadow ${timing} ${duration.fast}ms 0s, + transform ${timing} ${duration.normal}ms 0s; ` }} ` diff --git a/src/components/form/Form/cmp.tsx b/src/components/form/Form/cmp.tsx index 366cd8b8b..b7d21b526 100644 --- a/src/components/form/Form/cmp.tsx +++ b/src/components/form/Form/cmp.tsx @@ -3,9 +3,9 @@ import { StyledForm } from './styles' import { FormError, FormErrorProps } from '@aleph-front/core' import { CenteredContainer } from '@/components/common/CenteredContainer' -export const Form = ({ children, onSubmit, errors }: FormProps) => { +export const Form = ({ children, onSubmit, errors, id }: FormProps) => { return ( - + {children} {errors?.root && ( diff --git a/src/components/form/Form/types.ts b/src/components/form/Form/types.ts index c1dff9787..026419cd4 100644 --- a/src/components/form/Form/types.ts +++ b/src/components/form/Form/types.ts @@ -5,4 +5,5 @@ export type FormProps = { children: ReactNode onSubmit: (e: FormEvent) => Promise errors: FieldErrors + id?: string } diff --git a/src/components/form/PermissionsConfiguration/cmp.tsx b/src/components/form/PermissionsConfiguration/cmp.tsx new file mode 100644 index 000000000..85ea597ed --- /dev/null +++ b/src/components/form/PermissionsConfiguration/cmp.tsx @@ -0,0 +1,456 @@ +import React, { memo, useMemo, useState, useRef, useCallback } from 'react' +import styled, { css } from 'styled-components' +import tw from 'twin.macro' +import { + Button, + Checkbox, + Icon, + NoisyContainer, + Spinner, + Tabs, + TextInput, + useClickOutside, + useFloatPosition, + useTransition, + useWindowScroll, + useWindowSize, +} from '@aleph-front/core' +import { MessageType } from '@aleph-sdk/message' +import { + RowActionsButton, + StyledPortal, +} from '@/components/pages/console/permissions/PermissionsRowActions/styles' +import StyledTable from '@/components/common/Table' +import { Portal } from '@/components/common/Portal' +import SidePanel from '@/components/common/SidePanel' +import { usePermissionsConfiguration } from './hook' +import { PermissionsConfigurationProps } from './types' + +const CollapsibleList = styled.div<{ + $isCollapsed: boolean + $maxHeight?: string +}>` + ${({ theme, $isCollapsed, $maxHeight = '13rem' }) => css` + ${tw`flex flex-col gap-y-3 overflow-y-auto`} + max-height: ${$isCollapsed ? '0' : $maxHeight}; + opacity: ${$isCollapsed ? '0' : '1'}; + transition: + max-height ${theme.transition.duration.normal}ms + ${theme.transition.timing}, + opacity ${theme.transition.duration.normal}ms ${theme.transition.timing}; + `} +` + +type FilterScopeButtonProps = { + authorized: boolean + availableItems: string[] + selectedItems: string[] + onSelectionChange: (items: string[]) => void + isLoading?: boolean +} + +const FilterScopeButton = ({ + authorized, + availableItems, + selectedItems, + onSelectionChange, + isLoading = false, +}: FilterScopeButtonProps) => { + const [showPortal, setShowPortal] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [allSelected, setAllSelected] = useState(selectedItems.length === 0) + + const buttonRef = useRef(null) + const portalRef = useRef(null) + + const windowSize = useWindowSize(0) + const windowScroll = useWindowScroll(0) + + const { shouldMount, stage } = useTransition(showPortal, 250) + + const isOpen = stage === 'enter' + + const { + myRef: floatRef, + atRef: triggerRef, + position: portalPosition, + } = useFloatPosition({ + my: 'top-right', + at: 'bottom-right', + myRef: portalRef, + atRef: buttonRef, + deps: [windowSize, windowScroll, shouldMount], + }) + + useClickOutside(() => { + if (showPortal) setShowPortal(false) + }, [floatRef, triggerRef]) + + const filteredItems = useMemo(() => { + if (!searchQuery) return availableItems + return availableItems.filter((item) => + item.toLowerCase().includes(searchQuery.toLowerCase()), + ) + }, [availableItems, searchQuery]) + + const handleToggleItem = (item: string) => { + const isSelected = selectedItems.includes(item) + if (isSelected) { + onSelectionChange(selectedItems.filter((i) => i !== item)) + } else { + onSelectionChange([...selectedItems, item]) + } + } + + const handleClearAll = () => { + onSelectionChange([]) + } + + const handleSelectAll = useCallback( + (isAllSelected: boolean) => { + isAllSelected + ? onSelectionChange([...availableItems]) + : onSelectionChange([]) + + setAllSelected(!isAllSelected) + }, + [availableItems, onSelectionChange], + ) + + const count = selectedItems.length + + return ( + <> + setShowPortal(!showPortal)} + > + {!authorized ? ( + + ) : isLoading ? ( + + ) : count > 0 ? ( + count + ) : ( + 'All' + )} + + + {shouldMount && ( + +
+ } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> +
+
+ handleSelectAll(allSelected)} + size="sm" + /> +
+ +
+ + {isLoading ? ( +
Loading...
+ ) : filteredItems.length > 0 ? ( + filteredItems.map((item) => ( +
+ handleToggleItem(item)} + size="sm" + /> + {item} +
+ )) + ) : ( +
+ {searchQuery ? 'No matches found' : 'No items available'} +
+ )} +
+
+
+ )} +
+ + ) +} + +export const PermissionsConfiguration = ({ + control, + name = 'permissions', + channelsPanelOrder = 1, +}: PermissionsConfigurationProps) => { + const { + messageTypesCtrl, + authorizedChannels, + isChannelsPanelOpen, + channelsSearchQuery, + setChannelsSearchQuery, + selectedChannels, + isLoadingChannels, + filteredChannels, + availablePostTypes, + isLoadingPostTypes, + isLoadingAggregateKeys, + getAllAggregateKeysForRow, + selectedTabId, + setSelectedTabId, + handleToggleMessageType, + handlePostTypesChange, + handleAggregateKeysChange, + handleOpenChannelsPanel, + handleToggleChannel, + handleClearAllChannels, + handleSelectAllChannels, + handleApplyChannels, + handleCancelChannels, + handleCloseChannelsPanel, + } = usePermissionsConfiguration({ control, name }) + + const [allChannelsSelected, setAllChannelsSelected] = useState( + selectedChannels.length === 0, + ) + + const handleSelectAllChannelsToggle = useCallback( + (isAllSelected: boolean) => { + if (isAllSelected) { + handleSelectAllChannels() + } else { + handleClearAllChannels() + } + setAllChannelsSelected(!isAllSelected) + }, + [handleSelectAllChannels, handleClearAllChannels], + ) + + const channelsPanelFooter = ( +
+ + +
+ ) + + return ( + <> + + setSelectedTabId(id)} + align="left" + /> +
+ {selectedTabId === 'messages' ? ( +
+
+
+ Channels: + + {authorizedChannels} + +
+ +
+
+ row.type} + rowBackgroundColors={['purple2', 'purple3']} + hoverHighlight={false} + clickableRows={false} + data={messageTypesCtrl.field.value} + columns={[ + { + label: 'Message type', + render: (row) => ( + {row.type} + ), + }, + { + label: 'Allowed', + render: (row, _col, rowIndex) => ( + handleToggleMessageType(rowIndex)} + size="sm" + /> + ), + }, + { + label: 'Filters / scope', + render: (row, _col, rowIndex) => { + if (row.type === MessageType.post) { + return ( + + handlePostTypesChange(rowIndex, items) + } + isLoading={isLoadingPostTypes} + /> + ) + } else if (row.type === MessageType.aggregate) { + return ( + + handleAggregateKeysChange(rowIndex, items) + } + isLoading={isLoadingAggregateKeys} + /> + ) + } else { + return ( + + N/A + + ) + } + }, + }, + ]} + rowProps={() => ({ + className: 'tp-info', + })} + /> +
+
+ ) : null} +
+
+ + + +
+
Permissions details
+ + } + value={channelsSearchQuery} + onChange={(e) => setChannelsSearchQuery(e.target.value)} + /> +
+
+ + handleSelectAllChannelsToggle(allChannelsSelected) + } + size="sm" + /> +
+ +
+ + {isLoadingChannels ? ( +
Loading...
+ ) : filteredChannels.length > 0 ? ( + filteredChannels.map((channel) => ( +
+ handleToggleChannel(channel)} + size="sm" + /> + {channel} +
+ )) + ) : ( +
+ {channelsSearchQuery + ? 'No matches found' + : 'No channels available'} +
+ )} +
+
+
+
+
+ + ) +} +PermissionsConfiguration.displayName = 'PermissionsConfiguration' + +export default memo(PermissionsConfiguration) as typeof PermissionsConfiguration diff --git a/src/components/form/PermissionsConfiguration/hook.ts b/src/components/form/PermissionsConfiguration/hook.ts new file mode 100644 index 000000000..b94f9b824 --- /dev/null +++ b/src/components/form/PermissionsConfiguration/hook.ts @@ -0,0 +1,250 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Control, useController, UseControllerReturn } from 'react-hook-form' +import { useAppState } from '@/contexts/appState' +import { useChannels } from '@/hooks/common/useChannels' +import { usePostTypes } from '@/hooks/common/usePostTypes' +import { useAggregateKeys } from '@/hooks/common/useAggregateKeys' +import { MessageType } from '@aleph-sdk/message' + +export type UsePermissionsConfigurationProps = { + control: Control + name?: string +} + +export type UsePermissionsConfigurationReturn = { + channelsCtrl: UseControllerReturn + messageTypesCtrl: UseControllerReturn + authorizedChannels: string + isChannelsPanelOpen: boolean + setIsChannelsPanelOpen: (open: boolean) => void + channelsSearchQuery: string + setChannelsSearchQuery: (query: string) => void + selectedChannels: string[] + availableChannels: string[] + isLoadingChannels: boolean + allChannels: string[] + filteredChannels: string[] + availablePostTypes: string[] + isLoadingPostTypes: boolean + availableAggregateKeys: string[] + isLoadingAggregateKeys: boolean + getAllAggregateKeysForRow: (rowAggregateKeys: string[]) => string[] + selectedTabId: string + setSelectedTabId: (id: string) => void + handleToggleMessageType: (index: number) => void + handlePostTypesChange: (rowIndex: number, items: string[]) => void + handleAggregateKeysChange: (rowIndex: number, items: string[]) => void + handleOpenChannelsPanel: () => void + handleToggleChannel: (channel: string) => void + handleClearAllChannels: () => void + handleSelectAllChannels: () => void + handleApplyChannels: () => void + handleCancelChannels: () => void + handleCloseChannelsPanel: () => void +} + +export function usePermissionsConfiguration({ + control, + name = 'permissions', +}: UsePermissionsConfigurationProps): UsePermissionsConfigurationReturn { + const channelsCtrl = useController({ + control, + name: `${name}.channels`, + }) + + const messageTypesCtrl = useController({ + control, + name: `${name}.messageTypes`, + }) + + const [appState] = useAppState() + const { account } = appState.connection + + const connectedAccountAddress = account?.address + + const { postTypes: availablePostTypes, isLoading: isLoadingPostTypes } = + usePostTypes(connectedAccountAddress) + + const { channels: availableChannels, isLoading: isLoadingChannels } = + useChannels(connectedAccountAddress) + + const { + aggregateKeys: availableAggregateKeys, + isLoading: isLoadingAggregateKeys, + } = useAggregateKeys(connectedAccountAddress) + + const [isChannelsPanelOpen, setIsChannelsPanelOpen] = useState(false) + const [channelsSearchQuery, setChannelsSearchQuery] = useState('') + const [selectedChannels, setSelectedChannels] = useState([]) + const [originalChannels, setOriginalChannels] = useState([]) + + const [selectedTabId, setSelectedTabId] = useState('messages') + + const authorizedChannels = useMemo(() => { + const currentChannels = channelsCtrl.field.value + if (!currentChannels?.length) return 'All' + return currentChannels.join(', ') + }, [channelsCtrl.field.value]) + + const allChannels = useMemo(() => { + const permissionChannels = channelsCtrl.field.value || [] + const merged = Array.from( + new Set([...availableChannels, ...permissionChannels]), + ) + return merged.sort() + }, [availableChannels, channelsCtrl.field.value]) + + const filteredChannels = useMemo(() => { + if (!channelsSearchQuery) return allChannels + return allChannels.filter((channel) => + channel.toLowerCase().includes(channelsSearchQuery.toLowerCase()), + ) + }, [allChannels, channelsSearchQuery]) + + const getAllAggregateKeysForRow = useCallback( + (rowAggregateKeys: string[]) => { + const merged = Array.from( + new Set([...availableAggregateKeys, ...(rowAggregateKeys || [])]), + ) + return merged.sort() + }, + [availableAggregateKeys], + ) + + useEffect(() => { + if (isChannelsPanelOpen) { + const currentChannels = channelsCtrl.field.value + setSelectedChannels(currentChannels) + setOriginalChannels(currentChannels) + } + }, [isChannelsPanelOpen, channelsCtrl.field.value]) + + const handleToggleMessageType = useCallback( + (index: number) => { + const currentTypes = messageTypesCtrl.field.value + const updatedTypes = [...currentTypes] + + const newAuthorized = !updatedTypes[index].authorized + + updatedTypes[index] = { + ...updatedTypes[index], + authorized: newAuthorized, + } + + // If deauthorizing, clear specific filters/scopes + if (!newAuthorized) { + switch (updatedTypes[index].type) { + case MessageType.post: + updatedTypes[index].postTypes = [] + break + case MessageType.aggregate: + updatedTypes[index].aggregateKeys = [] + break + } + } + + messageTypesCtrl.field.onChange(updatedTypes) + }, + [messageTypesCtrl], + ) + + const handlePostTypesChange = useCallback( + (rowIndex: number, newPostTypes: string[]) => { + const currentTypes = messageTypesCtrl.field.value + const updatedTypes = [...currentTypes] + const currentRow = updatedTypes[rowIndex] + if (currentRow.type === MessageType.post) { + updatedTypes[rowIndex] = { + ...currentRow, + postTypes: newPostTypes, + } + messageTypesCtrl.field.onChange(updatedTypes) + } + }, + [messageTypesCtrl], + ) + + const handleAggregateKeysChange = useCallback( + (rowIndex: number, newAggregateKeys: string[]) => { + const currentTypes = messageTypesCtrl.field.value + const updatedTypes = [...currentTypes] + const currentRow = updatedTypes[rowIndex] + if (currentRow.type === MessageType.aggregate) { + updatedTypes[rowIndex] = { + ...currentRow, + aggregateKeys: newAggregateKeys, + } + messageTypesCtrl.field.onChange(updatedTypes) + } + }, + [messageTypesCtrl], + ) + + const handleOpenChannelsPanel = useCallback(() => { + setIsChannelsPanelOpen(true) + }, []) + + const handleToggleChannel = useCallback((channel: string) => { + setSelectedChannels((prev) => + prev.includes(channel) + ? prev.filter((c) => c !== channel) + : [...prev, channel], + ) + }, []) + + const handleClearAllChannels = useCallback(() => { + setSelectedChannels([]) + }, []) + + const handleSelectAllChannels = useCallback(() => { + setSelectedChannels([...allChannels]) + }, [allChannels]) + + const handleApplyChannels = useCallback(() => { + channelsCtrl.field.onChange(selectedChannels) + setIsChannelsPanelOpen(false) + setChannelsSearchQuery('') + }, [selectedChannels, channelsCtrl]) + + const handleCancelChannels = useCallback(() => { + setSelectedChannels(originalChannels) + setIsChannelsPanelOpen(false) + setChannelsSearchQuery('') + }, [originalChannels]) + + const handleCloseChannelsPanel = useCallback(() => { + handleApplyChannels() + }, [handleApplyChannels]) + + return { + channelsCtrl, + messageTypesCtrl, + authorizedChannels, + isChannelsPanelOpen, + setIsChannelsPanelOpen, + channelsSearchQuery, + setChannelsSearchQuery, + selectedChannels, + availableChannels, + isLoadingChannels, + allChannels, + filteredChannels, + availablePostTypes, + isLoadingPostTypes, + availableAggregateKeys, + isLoadingAggregateKeys, + getAllAggregateKeysForRow, + selectedTabId, + setSelectedTabId, + handleToggleMessageType, + handlePostTypesChange, + handleAggregateKeysChange, + handleOpenChannelsPanel, + handleToggleChannel, + handleClearAllChannels, + handleSelectAllChannels, + handleApplyChannels, + handleCancelChannels, + handleCloseChannelsPanel, + } +} diff --git a/src/components/form/PermissionsConfiguration/index.ts b/src/components/form/PermissionsConfiguration/index.ts new file mode 100644 index 000000000..fabaf9ef9 --- /dev/null +++ b/src/components/form/PermissionsConfiguration/index.ts @@ -0,0 +1,3 @@ +export { default } from './cmp' +export * from './types' +export * from './hook' diff --git a/src/components/form/PermissionsConfiguration/types.ts b/src/components/form/PermissionsConfiguration/types.ts new file mode 100644 index 000000000..791145202 --- /dev/null +++ b/src/components/form/PermissionsConfiguration/types.ts @@ -0,0 +1,7 @@ +import { Control } from 'react-hook-form' + +export type PermissionsConfigurationProps = { + control: Control + name?: string + channelsPanelOrder?: number +} diff --git a/src/components/pages/console/permissions/NewPermissionPage/cmp.tsx b/src/components/pages/console/permissions/NewPermissionPage/cmp.tsx new file mode 100644 index 000000000..db9a2398a --- /dev/null +++ b/src/components/pages/console/permissions/NewPermissionPage/cmp.tsx @@ -0,0 +1,144 @@ +import React from 'react' +import { useRef } from 'react' +import Head from 'next/head' +import { NoisyContainer, TooltipProps, TextInput } from '@aleph-front/core' +import ButtonWithInfoTooltip from '@/components/common/ButtonWithInfoTooltip' +import { CenteredContainer } from '@/components/common/CenteredContainer' +import { useNewPermissionPage, UseNewPermissionPageReturn } from './hook' +import Form from '@/components/form/Form' +import { SectionTitle } from '@/components/common/CompositeTitle' +import { PageProps } from '@/types/types' +import BackButtonSection from '@/components/common/BackButtonSection' +import BorderBox from '@/components/common/BorderBox' +import FloatingFooter from '@/components/form/FloatingFooter' +import PermissionsConfiguration from '@/components/form/PermissionsConfiguration' + +const CheckoutButton = React.memo( + ({ + disabled, + title = 'Save permissions', + tooltipContent, + isFooter, + handleSubmit, + }: { + disabled: boolean + title?: string + tooltipContent?: TooltipProps['content'] + isFooter: boolean + handleSubmit: UseNewPermissionPageReturn['handleSubmit'] + }) => { + const checkoutButtonRef = useRef(null) + + return ( + + {title} + + ) + }, +) +CheckoutButton.displayName = 'CheckoutButton' + +export default function NewInstancePage({ mainRef }: PageProps) { + const { + createPermissionDisabled, + createPermissionDisabledMessage, + createPermissionButtonTitle, + control, + errors, + handleSubmit, + handleBack, + addressCtrl, + aliasCtrl, + isDirty, + } = useNewPermissionPage() + + return ( + <> + + Console | New Permission | Aleph Cloud + + + +
+ {createPermissionDisabledMessage && ( +
+ + + {createPermissionDisabledMessage} + + +
+ )} +
+ + Recipient + +
+ +
+ + +
+
+
+
+
+
+ + Permissions +

+ Select what this account may do and revoke access at any time. +

+
+ +
+
+
+ +
+ +
+
+
+ + ) +} diff --git a/src/components/pages/console/permissions/NewPermissionPage/disabledMessages.tsx b/src/components/pages/console/permissions/NewPermissionPage/disabledMessages.tsx new file mode 100644 index 000000000..e2840fda1 --- /dev/null +++ b/src/components/pages/console/permissions/NewPermissionPage/disabledMessages.tsx @@ -0,0 +1,29 @@ +import { TooltipProps } from '@aleph-front/core' +import React from 'react' + +type DisabledMessageProps = { + title: React.ReactNode + description: React.ReactNode +} + +function tooltipContent({ + title, + description, +}: DisabledMessageProps): TooltipProps['content'] { + return ( +
+

{title}

+

{description}

+
+ ) +} + +export function accountConnectionRequiredDisabledMessage( + actionDescription: string, +): TooltipProps['content'] { + return tooltipContent({ + title: `Account connection required`, + description: `Please connect your account to ${actionDescription}. + Connect your wallet using the top-right button to access all features.`, + }) +} diff --git a/src/components/pages/console/permissions/NewPermissionPage/hook.ts b/src/components/pages/console/permissions/NewPermissionPage/hook.ts new file mode 100644 index 000000000..a7f97dce7 --- /dev/null +++ b/src/components/pages/console/permissions/NewPermissionPage/hook.ts @@ -0,0 +1,212 @@ +import { FormEvent, useCallback, useMemo } from 'react' +import { useRouter } from 'next/router' +import { useForm } from '@/hooks/common/useForm' +import { Control, FieldErrors, useController, useWatch } from 'react-hook-form' +import { + stepsCatalog, + useCheckoutNotification, +} from '@/hooks/form/useCheckoutNotification' +import { useConnection } from '@/hooks/common/useConnection' +import Err from '@/helpers/errors' +import { TooltipProps } from '@aleph-front/core' +import { accountConnectionRequiredDisabledMessage } from './disabledMessages' +import { usePermissionsManager } from '@/hooks/common/useManager/usePermissionManager' +import { + AccountPermissions, + MessageTypePermissions, + PermissionsManager, +} from '@/domain/permissions' +import { MessageType } from '@aleph-sdk/message' +import { zodResolver } from '@hookform/resolvers/zod' +import { useRequestPermissions } from '@/hooks/common/useRequestEntity/useRequestPermissions' +import { NAVIGATION_URLS } from '@/helpers/constants' + +export type NewPermissionFormState = { + address: string + alias: string + permissions: { + channels: string[] + messageTypes: MessageTypePermissions[] + } +} + +export type Modal = 'node-list' | 'terms-and-conditions' + +export type UseNewPermissionPageReturn = { + createPermissionDisabled: boolean + createPermissionDisabledMessage?: TooltipProps['content'] + createPermissionButtonTitle?: string + values: any + control: Control + errors: FieldErrors + handleSubmit: (e: FormEvent) => Promise + handleBack: () => void + addressCtrl: any + aliasCtrl: any + isDirty: boolean +} + +export function useNewPermissionPage(): UseNewPermissionPageReturn { + const { account } = useConnection({ + triggerOnMount: false, + }) + + const router = useRouter() + + // ------------------------- + // Handlers + + const handleBack = useCallback(() => { + router.push('.') + }, [router]) + + // ------------------------- + // Checkout flow + + const manager = usePermissionsManager() + const { refetch: refetchPermissions } = useRequestPermissions({ + triggerOnMount: false, + }) + const { noti, next, stop } = useCheckoutNotification({}) + + const onSubmit = useCallback( + async (state: NewPermissionFormState) => { + if (!manager) throw Err.ConnectYourWallet + if (!account) throw Err.InvalidAccount + + const permission: AccountPermissions = { + id: state.address, + alias: state.alias, + channels: state.permissions.channels, + messageTypes: state.permissions.messageTypes, + revoked: false, + } + + const iSteps = await manager.getAddSteps() + const nSteps = iSteps.map((i) => stepsCatalog[i]) + + const steps = manager.addNewAccountPermissionSteps(permission) + + try { + let result + + while (!result) { + const { value, done } = await steps.next() + + if (done) { + result = value + break + } + + await next(nSteps) + } + } finally { + await stop() + } + }, + [manager, account, next, stop], + ) + + const handleSubmissionSuccess = useCallback(async () => { + noti?.add({ + variant: 'info', + title: 'Permission created', + text: 'Redirecting to permissions page...', + }) + + // Wait 2 second to refetch latest data from backend before navigating back + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // Refresh permissions data from backend + await refetchPermissions() + + // Navigate to permissions page + router.push(NAVIGATION_URLS.console.permissions.home) + }, [router, noti, refetchPermissions]) + + // ------------------------- + // Setup form + + const defaultValues: Partial = useMemo( + () => ({ + address: '', + alias: '', + permissions: { + channels: [], + messageTypes: [ + { type: MessageType.post, postTypes: [], authorized: false }, + { + type: MessageType.aggregate, + aggregateKeys: [], + authorized: false, + }, + { type: MessageType.instance, authorized: false }, + { type: MessageType.program, authorized: false }, + { type: MessageType.store, authorized: false }, + ], + }, + }), + [], + ) + + const { + control, + handleSubmit, + formState: { errors, isDirty }, + } = useForm({ + defaultValues, + onSubmit, + onSuccess: handleSubmissionSuccess, + resolver: zodResolver(PermissionsManager.addSchema), + readyDeps: [], + }) + + const formValues = useWatch({ control }) as NewPermissionFormState + + const addressCtrl = useController({ + control, + name: 'address', + }) + + const aliasCtrl = useController({ + control, + name: 'alias', + }) + + // ------------------------- + // Memos + const createPermissionDisabledMessage: UseNewPermissionPageReturn['createPermissionDisabledMessage'] = + useMemo(() => { + if (!account) + return accountConnectionRequiredDisabledMessage( + 'create a new permission', + ) + }, [account]) + + const createPermissionButtonTitle: UseNewPermissionPageReturn['createPermissionButtonTitle'] = + useMemo(() => { + if (!account) return 'Connect' + + return 'Save permissions' + }, [account]) + + const createPermissionDisabled = useMemo(() => { + if (createPermissionButtonTitle !== 'Save permissions') return true + + return !!createPermissionDisabledMessage + }, [createPermissionButtonTitle, createPermissionDisabledMessage]) + + return { + createPermissionDisabled, + createPermissionDisabledMessage, + createPermissionButtonTitle, + values: formValues, + control, + errors, + handleSubmit, + handleBack, + addressCtrl, + aliasCtrl, + isDirty, + } +} diff --git a/src/components/pages/console/permissions/NewPermissionPage/index.ts b/src/components/pages/console/permissions/NewPermissionPage/index.ts new file mode 100644 index 000000000..7a9f83f3c --- /dev/null +++ b/src/components/pages/console/permissions/NewPermissionPage/index.ts @@ -0,0 +1 @@ +export { default } from './cmp' diff --git a/src/components/pages/console/permissions/PermissionTypeItem/cmp.tsx b/src/components/pages/console/permissions/PermissionTypeItem/cmp.tsx new file mode 100644 index 000000000..5fc8b0b18 --- /dev/null +++ b/src/components/pages/console/permissions/PermissionTypeItem/cmp.tsx @@ -0,0 +1,19 @@ +import React, { memo } from 'react' +import { PermissionTypeItemProps } from './types' +import CountBadge from '@/components/common/CountBadge' + +export const PermissionTypeItem = ({ + type, + count, +}: PermissionTypeItemProps) => { + return ( +
+ {type} + {count !== undefined && count > 0 && } +
+ ) +} + +PermissionTypeItem.displayName = 'PermissionTypeItem' + +export default memo(PermissionTypeItem) as typeof PermissionTypeItem diff --git a/src/components/pages/console/permissions/PermissionTypeItem/index.ts b/src/components/pages/console/permissions/PermissionTypeItem/index.ts new file mode 100644 index 000000000..d40872e19 --- /dev/null +++ b/src/components/pages/console/permissions/PermissionTypeItem/index.ts @@ -0,0 +1,2 @@ +export { default } from './cmp' +export * from './types' diff --git a/src/components/pages/console/permissions/PermissionTypeItem/types.ts b/src/components/pages/console/permissions/PermissionTypeItem/types.ts new file mode 100644 index 000000000..eafe07db3 --- /dev/null +++ b/src/components/pages/console/permissions/PermissionTypeItem/types.ts @@ -0,0 +1,4 @@ +export type PermissionTypeItemProps = { + type: string + count?: number +} diff --git a/src/components/pages/console/permissions/PermissionsDashboardPage/cmp.tsx b/src/components/pages/console/permissions/PermissionsDashboardPage/cmp.tsx new file mode 100644 index 000000000..722234856 --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsDashboardPage/cmp.tsx @@ -0,0 +1,173 @@ +import React, { useCallback, useState } from 'react' +import Head from 'next/head' +import { CenteredContainer } from '@/components/common/CenteredContainer' +import { usePermissionsDashboardPage } from './hook' +import HoldTokenDisclaimer from '@/components/common/HoldTokenDisclaimer' +import PermissionsTabContent from '../PermissionsTabContent' +import DashboardCardWithSideImage from '@/components/common/DashboardCardWithSideImage' +import FloatingFooter from '@/components/form/FloatingFooter' +import { PageProps } from '@/types/types' +import { AccountPermissions } from '@/domain/permissions' +import { Button } from '@aleph-front/core' +import { + stepsCatalog, + useCheckoutNotification, +} from '@/hooks/form/useCheckoutNotification' +import { NAVIGATION_URLS } from '@/helpers/constants' + +export default function PermissionsDashboardPage({ mainRef }: PageProps) { + const { permissions, manager, refetchPermissions } = + usePermissionsDashboardPage() + const { noti, next, stop } = useCheckoutNotification({}) + const [pendingChanges, setPendingChanges] = useState< + Map + >(new Map()) + const [originalPermissions, setOriginalPermissions] = useState< + AccountPermissions[] + >([]) + + // Sync original permissions and clear pending changes when permissions load + React.useEffect(() => { + if (permissions) { + setOriginalPermissions(permissions) + setPendingChanges(new Map()) + } + }, [permissions]) + + const handlePermissionChange = useCallback( + (updatedPermission: AccountPermissions) => { + // Find the original permission to compare + const originalPermission = originalPermissions.find( + (p) => p.id === updatedPermission.id, + ) + + // @ts-expect-error TS7053 - Ignore 'key' property added by Table component + delete updatedPermission['key'] // Remove key added by Table + + setPendingChanges((prev) => { + const newChanges = new Map(prev) + + // Compare updated vs original using JSON.stringify + const hasRealChanges = + JSON.stringify(updatedPermission) !== + JSON.stringify(originalPermission) + + console.log('original', originalPermission) + console.log('updated', updatedPermission) + console.log('hasRealChanges', hasRealChanges) + + if (hasRealChanges) { + // Add to pending changes if different from original + newChanges.set(updatedPermission.id, updatedPermission) + } else { + // Remove from pending changes if reverted to original + newChanges.delete(updatedPermission.id) + } + + return newChanges + }) + }, + [originalPermissions], + ) + + const handleSaveAllChanges = useCallback(async () => { + if (!manager) return + + const permissionsToUpdate = Array.from(pendingChanges.values()) + + const iSteps = await manager.getAddSteps() + const nSteps = iSteps.map((i) => stepsCatalog[i]) + + const steps = manager.updatePermissionsSteps(permissionsToUpdate) + + let result + + try { + while (!result) { + const { value, done } = await steps.next() + + if (done) { + result = value + break + } + + await next(nSteps) + } + + setPendingChanges(new Map()) + } finally { + await stop() + + if (result) { + noti?.add({ + variant: 'info', + title: 'Permissions updated', + text: 'Refreshing permissions data...', + }) + + // Wait 2 seconds to refetch latest data from backend + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // Refresh permissions data from backend + await refetchPermissions() + } + } + }, [manager, pendingChanges, next, stop, noti, refetchPermissions]) + + return ( + <> + + Console | Permissions | Aleph Cloud + + +
+ {!!permissions?.length && ( + + + + )} + +
+ Ideal for teams, apps, or automated workloads. +
{' '} + Empower teammates or connected wallets to spend your credits + safely. Grant permission to use your credits, define what they can + deploy, and revoke access whenever you need. + + } + withButton={permissions?.length === 0} + buttonUrl={NAVIGATION_URLS.console.permissions.new} + buttonText="Create permissions" + /> +
+ + + + {pendingChanges.size && ( + +
+ +
+
+ )} + + ) +} diff --git a/src/components/pages/console/permissions/PermissionsDashboardPage/hook.ts b/src/components/pages/console/permissions/PermissionsDashboardPage/hook.ts new file mode 100644 index 000000000..d1fcac8a3 --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsDashboardPage/hook.ts @@ -0,0 +1,21 @@ +import { AccountPermissions, PermissionsManager } from '@/domain/permissions' +import { usePermissionsManager } from '@/hooks/common/useManager/usePermissionManager' +import { useRequestPermissions } from '@/hooks/common/useRequestEntity/useRequestPermissions' + +export type UsePermissionsDashboardPageReturn = { + permissions: AccountPermissions[] + manager: PermissionsManager | undefined + refetchPermissions: () => Promise +} + +export function usePermissionsDashboardPage(): UsePermissionsDashboardPageReturn { + const { entities: permissions = [], refetch: refetchPermissions } = + useRequestPermissions() + const manager = usePermissionsManager() + + return { + permissions, + manager, + refetchPermissions, + } +} diff --git a/src/components/pages/console/permissions/PermissionsDashboardPage/index.ts b/src/components/pages/console/permissions/PermissionsDashboardPage/index.ts new file mode 100644 index 000000000..7a9f83f3c --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsDashboardPage/index.ts @@ -0,0 +1 @@ +export { default } from './cmp' diff --git a/src/components/pages/console/permissions/PermissionsList/cmp.tsx b/src/components/pages/console/permissions/PermissionsList/cmp.tsx new file mode 100644 index 000000000..3949ab502 --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsList/cmp.tsx @@ -0,0 +1,52 @@ +import React, { memo } from 'react' +import { MessageType } from '@aleph-sdk/message' +import { PermissionsListProps } from './types' +import PermissionTypeItem from '../PermissionTypeItem' + +export const PermissionsList = ({ messageTypes }: PermissionsListProps) => { + // Filter only authorized message types + const authorizedTypes = messageTypes.filter((mt) => mt.authorized) + + if (authorizedTypes.length === 0) { + return
None
+ } + + return ( +
+ {authorizedTypes.map((messageType, index) => { + const isLast = index === authorizedTypes.length - 1 + const typeName = messageType.type.toUpperCase() + + let count: number | undefined + + // For POST and AGGREGATE, show count if there are filters + if ( + messageType.type === MessageType.post && + 'postTypes' in messageType + ) { + count = messageType.postTypes.length || undefined + } else if ( + messageType.type === MessageType.aggregate && + 'aggregateKeys' in messageType + ) { + count = messageType.aggregateKeys.length || undefined + } + + return ( + + + {!isLast && ( + + , + + )} + + ) + })} +
+ ) +} + +PermissionsList.displayName = 'PermissionsList' + +export default memo(PermissionsList) as typeof PermissionsList diff --git a/src/components/pages/console/permissions/PermissionsList/index.ts b/src/components/pages/console/permissions/PermissionsList/index.ts new file mode 100644 index 000000000..d40872e19 --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsList/index.ts @@ -0,0 +1,2 @@ +export { default } from './cmp' +export * from './types' diff --git a/src/components/pages/console/permissions/PermissionsList/types.ts b/src/components/pages/console/permissions/PermissionsList/types.ts new file mode 100644 index 000000000..0d593579c --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsList/types.ts @@ -0,0 +1,5 @@ +import { MessageTypePermissions } from '@/domain/permissions' + +export type PermissionsListProps = { + messageTypes: MessageTypePermissions[] +} diff --git a/src/components/pages/console/permissions/PermissionsRowActions/cmp.tsx b/src/components/pages/console/permissions/PermissionsRowActions/cmp.tsx new file mode 100644 index 000000000..213f58098 --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsRowActions/cmp.tsx @@ -0,0 +1,112 @@ +import React, { memo, useRef } from 'react' +import { PermissionsRowActionsProps } from './types' +import { StyledPortal, RowActionsButton, ActionButton } from './styles' +import { + Icon, + useClickOutside, + useFloatPosition, + useTransition, + useWindowScroll, + useWindowSize, +} from '@aleph-front/core' +import { Portal } from '@/components/common/Portal' + +export const PermissionsRowActions = ({ + isRevoked = false, + onConfigure, + onRevoke, + onRestore, +}: PermissionsRowActionsProps) => { + const [showActions, setShowActions] = React.useState(false) + + const actionsElementRef = useRef(null) + const actionsButtonRef = useRef(null) + + const windowSize = useWindowSize(0) + const windowScroll = useWindowScroll(0) + + const { shouldMount, stage } = useTransition(showActions, 250) + + const isOpen = stage === 'enter' + + const { + myRef: actionsRef, + atRef: triggerRef, + position: actionsPosition, + } = useFloatPosition({ + my: 'top-right', + at: 'bottom-right', + myRef: actionsElementRef, + atRef: actionsButtonRef, + deps: [windowSize, windowScroll, shouldMount], + }) + + useClickOutside(() => { + if (showActions) setShowActions(false) + }, [actionsRef, triggerRef]) + + const handleConfigure = () => { + setShowActions(false) + onConfigure() + } + + const handleRevoke = () => { + setShowActions(false) + onRevoke() + } + + const handleRestore = () => { + setShowActions(false) + onRestore() + } + + return ( + e.stopPropagation()}> + setShowActions(!showActions)} + > + {isRevoked ? : } + + + {showActions && ( + +
+ + Configure + + {/* check if row is revoked */} + {isRevoked ? ( + + Restore + + ) : ( + + Revoke + + )} +
+
+ )} +
+
+ ) +} + +PermissionsRowActions.displayName = 'PermissionsRowActions' + +export default memo(PermissionsRowActions) as typeof PermissionsRowActions diff --git a/src/components/pages/console/permissions/PermissionsRowActions/index.ts b/src/components/pages/console/permissions/PermissionsRowActions/index.ts new file mode 100644 index 000000000..d40872e19 --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsRowActions/index.ts @@ -0,0 +1,2 @@ +export { default } from './cmp' +export * from './types' diff --git a/src/components/pages/console/permissions/PermissionsRowActions/styles.tsx b/src/components/pages/console/permissions/PermissionsRowActions/styles.tsx new file mode 100644 index 000000000..1e54a6c7e --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsRowActions/styles.tsx @@ -0,0 +1,57 @@ +import styled from 'styled-components' +import tw, { css } from 'twin.macro' +import { FloatPosition } from '@aleph-front/core' + +export type StyledPortalProps = { + $position: FloatPosition + $isOpen: boolean +} + +export const StyledPortal = styled.div` + ${({ theme, $position: { x, y }, $isOpen }) => { + return css` + ${tw`fixed -top-1.5 left-1.5 z-40`} + min-width: 8rem; + background: ${theme.color.background}; + box-shadow: 0px 4px 24px ${theme.color.main0}26; + backdrop-filter: blur(50px); + transform: ${`translate3d(${x}px, ${y}px, 0)`}; + opacity: ${$isOpen ? 1 : 0}; + will-change: opacity transform; + transition: opacity ease-in-out 250ms 0s; + ` + }} +` + +export const RowActionsButton = styled.button` + ${tw`w-12 h-10`}; + + ${({ theme, disabled }) => css` + background: ${theme.color.background}; + transition: all 0.2s ease-in-out; + + &:hover { + box-shadow: 0px 4px 24px ${theme.color.main0}26; + backdrop-filter: blur(50px); + } + + ${disabled && tw`opacity-40 cursor-not-allowed`} + `}) +` + +export const ActionButton = styled.button` + ${({ theme, disabled }) => { + return css` + &:hover { + background-color: ${theme.color.purple2}; + } + + ${disabled && + css` + ${tw`opacity-60 cursor-not-allowed!`} + + background-color: ${theme.color.purple2}; + `} + ` + }} +` diff --git a/src/components/pages/console/permissions/PermissionsRowActions/types.ts b/src/components/pages/console/permissions/PermissionsRowActions/types.ts new file mode 100644 index 000000000..017d80fca --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsRowActions/types.ts @@ -0,0 +1,6 @@ +export type PermissionsRowActionsProps = { + isRevoked?: boolean + onConfigure: () => void + onRevoke: () => void + onRestore: () => void +} diff --git a/src/components/pages/console/permissions/PermissionsTabContent/cmp.tsx b/src/components/pages/console/permissions/PermissionsTabContent/cmp.tsx new file mode 100644 index 000000000..8232d4608 --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsTabContent/cmp.tsx @@ -0,0 +1,345 @@ +import ButtonLink from '@/components/common/ButtonLink' +import EntityTable from '@/components/common/EntityTable' +import PermissionsDetail from '@/components/common/PermissionsDetail' +import SidePanel from '@/components/common/SidePanel' +import { AccountPermissions } from '@/domain/permissions' +import { Button, Icon, TextGradient, useModal } from '@aleph-front/core' +import { memo, useCallback, useEffect, useState } from 'react' +import { getPermissionsTableColumns } from './columns' +import { PermissionsTabContentProps } from './types' +import { NAVIGATION_URLS } from '@/helpers/constants' + +// Type for side panel content +type SidePanelContent = { + isOpen: boolean + title: string + type?: 'configure' + selectedRow?: AccountPermissions +} + +export const PermissionsTabContent = memo( + ({ data, onPermissionChange }: PermissionsTabContentProps) => { + const [sidePanel, setSidePanel] = useState({ + isOpen: false, + title: '', + }) + + const [showUnsavedChangesModal, setShowUnsavedChangesModal] = + useState(false) + + // State to track pending changes in permissions + const [updatedPermissions, setUpdatedPermissions] = + useState(data) + + // Track currently editing permission to detect unsaved changes + const [editingOriginalPermission, setEditingOriginalPermission] = + useState(null) + + const modal = useModal() + const modalOpen = modal?.open + const modalClose = modal?.close + + // Sync with data prop changes + useEffect(() => { + setUpdatedPermissions(data) + }, [data]) + + // Keep sidePanel.selectedRow in sync with updatedPermissions + useEffect(() => { + if (sidePanel.selectedRow && sidePanel.isOpen) { + const currentPermission = updatedPermissions.find( + (p) => p.id === sidePanel.selectedRow?.id, + ) + if (currentPermission && currentPermission !== sidePanel.selectedRow) { + setSidePanel((prev) => ({ + ...prev, + selectedRow: currentPermission, + })) + } + } + }, [updatedPermissions, sidePanel.selectedRow, sidePanel.isOpen]) + + const handleRowConfigure = (row: AccountPermissions) => { + // Find corresponding permission from updatedPermissions + const permission = updatedPermissions.find((p) => p.id === row.id) || row + // Store the original permission for comparison when closing + setEditingOriginalPermission(permission) + setSidePanel({ + isOpen: true, + title: 'Permissions', + type: 'configure', + selectedRow: permission, + }) + } + + const handleRowRevoke = (row: AccountPermissions) => { + // Set revoked to true for this permission + const updatedPermission = { ...row, revoked: true } + setUpdatedPermissions((prev) => + prev.map((p) => (p.id === row.id ? updatedPermission : p)), + ) + // Notify parent component of the change + onPermissionChange?.(updatedPermission) + } + + const handleRowRestore = (row: AccountPermissions) => { + // Set revoked to false for this permission + const updatedPermission = { ...row, revoked: false } + setUpdatedPermissions((prev) => + prev.map((p) => (p.id === row.id ? updatedPermission : p)), + ) + // Notify parent component of the change + onPermissionChange?.(updatedPermission) + } + + const handleRowClick = (row: AccountPermissions) => { + handleRowConfigure(row) + } + + // Real-time update handler - updates local state as form changes + const handlePermissionUpdate = useCallback( + (updatedPermission: AccountPermissions) => { + setUpdatedPermissions((prev) => + prev.map((p) => + p.id === updatedPermission.id ? updatedPermission : p, + ), + ) + }, + [], + ) + + // Submit handler - called when user clicks "Continue" + const handlePermissionSubmit = useCallback( + (updatedPermission: AccountPermissions) => { + // Update the specific permission in updatedPermissions + setUpdatedPermissions((prev) => + prev.map((p) => + p.id === updatedPermission.id ? updatedPermission : p, + ), + ) + // Call the parent's onPermissionChange immediately + onPermissionChange?.(updatedPermission) + // Clear state and close panel + setEditingOriginalPermission(null) + setSidePanel({ isOpen: false, title: '' }) + }, + [onPermissionChange], + ) + + const handleCancelClick = useCallback(() => { + // Revert the permission to its original state (discard changes) + if (editingOriginalPermission) { + setUpdatedPermissions((prev) => + prev.map((p) => + p.id === editingOriginalPermission.id + ? editingOriginalPermission + : p, + ), + ) + } + setEditingOriginalPermission(null) + setSidePanel({ isOpen: false, title: '' }) + }, [editingOriginalPermission]) + + const handleClosePanel = useCallback(() => { + setSidePanel((prev) => ({ ...prev, isOpen: false })) + + // Compare current permission with original to detect changes + if (editingOriginalPermission) { + const currentPermission = updatedPermissions.find( + (p) => p.id === editingOriginalPermission.id, + ) + const hasChanges = + JSON.stringify(currentPermission) !== + JSON.stringify(editingOriginalPermission) + + if (hasChanges) { + setShowUnsavedChangesModal(true) + } else { + // No changes - clear state completely + setEditingOriginalPermission(null) + setSidePanel({ isOpen: false, title: '' }) + } + } + }, [editingOriginalPermission, updatedPermissions]) + + const handleDiscardChanges = useCallback(() => { + // Revert the permission to its original state + if (editingOriginalPermission) { + setUpdatedPermissions((prev) => + prev.map((p) => + p.id === editingOriginalPermission.id + ? editingOriginalPermission + : p, + ), + ) + } + + setEditingOriginalPermission(null) + setSidePanel({ isOpen: false, title: '' }) + setShowUnsavedChangesModal(false) + modalClose?.() + }, [editingOriginalPermission, modalClose]) + + const handleCancelDiscard = useCallback(() => { + setShowUnsavedChangesModal(false) + setSidePanel((prev) => ({ ...prev, isOpen: true })) + modalClose?.() + }, [modalClose]) + + const columns = getPermissionsTableColumns({ + onRowConfigure: handleRowConfigure, + onRowRevoke: handleRowRevoke, + onRowRestore: handleRowRestore, + }) + + useEffect( + () => { + if (!modalOpen) return + if (!modalClose) return + + if (showUnsavedChangesModal) { + return modalOpen({ + header: Unsaved Changes, + width: '34rem', + closeOnClickOutside: false, + closeOnCloseButton: false, + content: ( +
+

+ You've made updates to your permission settings. +

+

+ If you leave now, these changes won't be saved. +

+
+ ), + footer: ( +
+ + +
+ ), + }) + } else { + return modalClose() + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + showUnsavedChangesModal, + handleDiscardChanges, + handleCancelDiscard, + /* + Both modalOpen and modalClose are not included in the dependencies because there's + an infinite refresh loop when they are included. This is because the modalOpen + and modalClose functions are being redefined on every render, causing the + useEffect to run again and again. + */ + // modalOpen, + // modalClose, + ], + ) + + const permissionsDetailFooter = sidePanel.selectedRow ? ( +
+ + +
+ ) : null + + return ( + <> + {updatedPermissions.length ? ( + <> +
+ id} + data={updatedPermissions} + columns={columns} + rowProps={(row: AccountPermissions) => ({ + onClick: () => { + !row.revoked && handleRowClick(row) + }, + })} + /> +
+ +
+ + Create permissions + +
+ + ) : ( +
+ + Create permissions + +
+ )} + + {sidePanel.type === 'configure' ? ( + sidePanel.selectedRow && ( + + ) + ) : ( + <>ERROR + )} + + + ) + }, +) +PermissionsTabContent.displayName = 'PermissionsTabContent' + +export default PermissionsTabContent diff --git a/src/components/pages/console/permissions/PermissionsTabContent/columns.tsx b/src/components/pages/console/permissions/PermissionsTabContent/columns.tsx new file mode 100644 index 000000000..95665335d --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsTabContent/columns.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import tw from 'twin.macro' +import { AccountPermissions } from '@/domain/permissions' +import { ellipseText } from '@/helpers/utils' +import CopyToClipboard from '@/components/common/CopyToClipboard' +import CountBadge from '@/components/common/CountBadge' +import PermissionsList from '../PermissionsList' +import PermissionsRowActions from '../PermissionsRowActions' +import { PermissionsTableColumnsProps } from './types' +import { TableProps } from '@aleph-front/core' + +export const getPermissionsTableColumns = ({ + onRowConfigure, + onRowRevoke, + onRowRestore, +}: PermissionsTableColumnsProps) => + [ + { + label: 'Address', + sortable: true, + cellProps: (row) => ({ + css: row.revoked ? tw`opacity-60 line-through` : '', + }), + render: ({ id }: AccountPermissions) => ( + e.stopPropagation()}> + {ellipseText(id, 7, 8)} + } + textToCopy={id} + /> + + ), + }, + { + label: 'Alias', + sortable: true, + cellProps: (row) => ({ + css: row.revoked ? tw`opacity-60 line-through` : '', + }), + render: ({ alias }: AccountPermissions) => ( +
{alias || '-'}
+ ), + }, + { + label: 'Channels', + cellProps: (row) => ({ + css: row.revoked ? tw`opacity-60 line-through` : '', + }), + render: ({ channels }: AccountPermissions) => { + if (!channels.length) { + return
All
+ } + + return + }, + }, + { + label: 'Permissions', + cellProps: (row) => ({ + css: row.revoked ? tw`opacity-60 line-through` : '', + }), + render: ({ messageTypes }: AccountPermissions) => { + return + }, + }, + { + label: '', + width: '100%', + align: 'right' as const, + render: (row: AccountPermissions) => ( + onRowConfigure(row)} + onRevoke={() => onRowRevoke(row)} + onRestore={() => onRowRestore(row)} + /> + ), + cellProps: () => ({ + css: tw`pl-3!`, + }), + }, + ] as TableProps['columns'] diff --git a/src/components/pages/console/permissions/PermissionsTabContent/index.ts b/src/components/pages/console/permissions/PermissionsTabContent/index.ts new file mode 100644 index 000000000..5a6f6bb82 --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsTabContent/index.ts @@ -0,0 +1,2 @@ +export { default } from './cmp' +export type { PermissionsTabContentProps } from './types' diff --git a/src/components/pages/console/permissions/PermissionsTabContent/types.ts b/src/components/pages/console/permissions/PermissionsTabContent/types.ts new file mode 100644 index 000000000..36e3756be --- /dev/null +++ b/src/components/pages/console/permissions/PermissionsTabContent/types.ts @@ -0,0 +1,12 @@ +import { AccountPermissions } from '@/domain/permissions' + +export type PermissionsTabContentProps = { + data: AccountPermissions[] + onPermissionChange?: (updatedPermission: AccountPermissions) => void +} + +export type PermissionsTableColumnsProps = { + onRowConfigure: (row: AccountPermissions) => void + onRowRevoke: (row: AccountPermissions) => void + onRowRestore: (row: AccountPermissions) => void +} diff --git a/src/domain/aggregateManager.ts b/src/domain/aggregateManager.ts index f8804005a..4f13ba584 100644 --- a/src/domain/aggregateManager.ts +++ b/src/domain/aggregateManager.ts @@ -191,6 +191,7 @@ export abstract class AggregateManager // eslint-disable-next-line @typescript-eslint/no-explicit-any protected parseAggregate(response: any): Entity[] { const aggregate = response as AggregateContent + console.log('aggregate', aggregate) return this.parseAggregateItems(aggregate).sort((a, b) => this.getEntityDate(b).localeCompare(this.getEntityDate(a)), ) diff --git a/src/domain/permissions.ts b/src/domain/permissions.ts new file mode 100644 index 000000000..c87548c83 --- /dev/null +++ b/src/domain/permissions.ts @@ -0,0 +1,365 @@ +import { Account } from '@aleph-sdk/account' +import { + AlephHttpClient, + AuthenticatedAlephHttpClient, +} from '@aleph-sdk/client' +import { + defaultConsoleChannel, + defaultPermissionsAggregateKey, +} from '@/helpers/constants' +import { CheckoutStepType } from '@/hooks/form/useCheckoutNotification' +import { AggregateContent, AggregateManager } from './aggregateManager' +import { MessageType } from '@aleph-sdk/message' +import { newPermissionSchema } from '@/helpers/schemas/permissions' + +// ================================== +// = API types (from Aleph backend) = +// ================================== + +type PermissionsAggregateItemApi = { + address: string + alias?: string + channels?: string[] + types?: MessageType[] + post_types?: string[] + aggregate_keys?: string[] +} + +type AddPermissionsApi = PermissionsAggregateItemApi & { + alias: string + revoked?: boolean +} + +type PermissionsConfigApi = { + authorizations: PermissionsAggregateItemApi[] +} + +// ================================== +// = Domain types (used by the app) = +// ================================== + +export type BaseMessageTypePermissions = { + type: Exclude + authorized: boolean +} + +export type PostPermissions = { + type: MessageType.post + postTypes: string[] | [] + authorized: boolean +} + +export type AggregatePermissions = { + type: MessageType.aggregate + aggregateKeys: string[] | [] + authorized: boolean +} + +export type MessageTypePermissions = + | PostPermissions + | AggregatePermissions + | BaseMessageTypePermissions + +export type AccountPermissions = { + id: string // account address + alias?: string + channels: string[] | [] + messageTypes: MessageTypePermissions[] + revoked: boolean +} + +export type Permissions = AccountPermissions[] + +export class PermissionsManager extends AggregateManager< + AccountPermissions, + AddPermissionsApi, + PermissionsAggregateItemApi +> { + static addSchema = newPermissionSchema + + protected addStepType: CheckoutStepType = 'permissions' + protected delStepType: CheckoutStepType = 'permissionsDel' + + constructor( + protected account: Account | undefined, + protected sdkClient: AlephHttpClient | AuthenticatedAlephHttpClient, + protected key = defaultPermissionsAggregateKey, + protected channel = defaultConsoleChannel, + ) { + super(account, sdkClient, key, channel) + } + + async getAll(): Promise { + if (!this.account) return [] + + try { + const response: PermissionsConfigApi = + await this.sdkClient.fetchAggregate(this.account.address, this.key) + + return this.mapPermissionsConfigApiToAccountPermissions(response) + } catch (err) { + return [] + } + } + + /** + * Maps API message types to domain format + */ + private mapMessageTypesApiToMessageTypePermissions( + types: MessageType[] | undefined, + postTypes: string[] | undefined, + aggregateKeys: string[] | undefined, + ): MessageTypePermissions[] { + const allMessageTypes = [ + MessageType.post, + MessageType.aggregate, + MessageType.instance, + MessageType.program, + MessageType.store, + // @todo: should we include the forget message type? + // MessageType.forget, + ] + + const authorizedTypes = new Set(types || []) + const messageTypes: MessageTypePermissions[] = [] + + // Process each message type + for (const type of allMessageTypes) { + const authorized = authorizedTypes.has(type) + + if (type === MessageType.post) { + messageTypes.push({ + type: MessageType.post, + postTypes: authorized && postTypes?.length ? postTypes : [], + authorized, + }) + } else if (type === MessageType.aggregate) { + messageTypes.push({ + type: MessageType.aggregate, + aggregateKeys: + authorized && aggregateKeys?.length ? aggregateKeys : [], + authorized, + }) + } else { + messageTypes.push({ + type, + authorized, + } as BaseMessageTypePermissions) + } + } + + return messageTypes + } + + protected mapPermissionsConfigApiToAccountPermissions( + config: PermissionsConfigApi, + ): AccountPermissions[] { + if (!config.authorizations || !Array.isArray(config.authorizations)) { + return [] + } + return config.authorizations.map( + (auth: PermissionsAggregateItemApi, index: number) => { + return { + id: auth.address || `permission-${index}`, + alias: auth.alias, + channels: auth.channels?.length ? auth.channels : [], + messageTypes: this.mapMessageTypesApiToMessageTypePermissions( + auth.types, + auth.post_types, + auth.aggregate_keys, + ), + revoked: false, + } + }, + ) + } + + getKeyFromAddEntity(entity: AddPermissionsApi): string { + return entity.address + } + + buildAggregateItemContent( + entity: AddPermissionsApi, + ): PermissionsAggregateItemApi { + return { + types: entity.types, + address: entity.address, + alias: entity.alias, + channels: entity.channels, + post_types: entity.post_types || [], + aggregate_keys: entity.aggregate_keys || [], + } + } + + parseEntityFromAggregateItem( + _address: string, + content: PermissionsAggregateItemApi, + ): Partial { + return { + id: content.address, + alias: content.alias, + channels: + content.channels && content.channels.length > 0 ? content.channels : [], + messageTypes: this.mapMessageTypesApiToMessageTypePermissions( + content.types, + content.post_types, + content.aggregate_keys, + ), + } + } + + /** + * Converts domain AccountPermissions to AddPermissionsApi format + */ + private convertToAddPermissionsApi( + permission: AccountPermissions, + ): AddPermissionsApi { + const authorizedTypes: MessageType[] = [] + let postTypes: string[] = [] + let aggregateKeys: string[] = [] + + for (const mt of permission.messageTypes) { + if (!mt.authorized) continue + + authorizedTypes.push(mt.type) + + if (mt.type === MessageType.post) { + postTypes = (mt as PostPermissions).postTypes || [] + } else if (mt.type === MessageType.aggregate) { + aggregateKeys = (mt as AggregatePermissions).aggregateKeys || [] + } + } + + return { + address: permission.id, + alias: permission.alias || '', + channels: permission.channels.length > 0 ? permission.channels : [], + types: authorizedTypes.length > 0 ? authorizedTypes : [], + post_types: postTypes, + aggregate_keys: aggregateKeys, + revoked: permission.revoked, + } + } + + /** + * Fetches the raw aggregate data from the API + */ + private async fetchRawAggregate(): Promise { + if (!this.account) return null + + try { + return await this.sdkClient.fetchAggregate(this.account.address, this.key) + } catch { + return null + } + } + + /** + * Builds the aggregate content merging with existing permissions. + * Items with revoked=true are removed from the authorizations list. + */ + private async buildPermissionsAggregateContent( + entity: AddPermissionsApi | AddPermissionsApi[], + ): Promise> { + const items = Array.isArray(entity) ? entity : [entity] + + const existingConfig = await this.fetchRawAggregate() + const existingAuthorizations = existingConfig?.authorizations || [] + + const updatedAuthorizations = [...existingAuthorizations] + + for (const item of items) { + const existingIndex = updatedAuthorizations.findIndex( + (auth) => auth.address === item.address, + ) + + if (item.revoked) { + if (existingIndex >= 0) { + updatedAuthorizations.splice(existingIndex, 1) + } + } else { + const newItem = this.buildAggregateItemContent(item) + + if (existingIndex >= 0) { + updatedAuthorizations[existingIndex] = newItem + } else { + updatedAuthorizations.push(newItem) + } + } + } + + return { + authorizations: updatedAuthorizations, + } as unknown as AggregateContent + } + + /** + * Overrides addSteps to handle the permissions-specific aggregate structure + */ + async *addSteps( + entity: AddPermissionsApi | AddPermissionsApi[], + ): AsyncGenerator { + if (!(this.sdkClient instanceof AuthenticatedAlephHttpClient)) { + throw new Error('Authenticated client required') + } + + const content = await this.buildPermissionsAggregateContent(entity) + + yield + await this.sdkClient.createAggregate({ + key: this.key, + channel: this.channel, + content, + }) + + return this.getAll() + } + + /** + * Adds a new account permission, merging with existing permissions + */ + async addNewAccountPermission( + permission: AccountPermissions, + ): Promise { + const addPermissionsApi = this.convertToAddPermissionsApi(permission) + return this.add(addPermissionsApi) + } + + /** + * Returns the steps generator for adding a new account permission. + * Use this with checkout notification flow. + */ + addNewAccountPermissionSteps( + permission: AccountPermissions, + ): AsyncGenerator { + const addPermissionsApi = this.convertToAddPermissionsApi(permission) + return this.addSteps(addPermissionsApi) + } + + /** + * Updates multiple permissions at once. + * Permissions with revoked=true are removed from the authorizations list. + * Other permissions are updated/added. + */ + async updatePermissions( + permissions: AccountPermissions[], + ): Promise { + const addPermissionsApi = permissions.map((p) => + this.convertToAddPermissionsApi(p), + ) + return this.add(addPermissionsApi) + } + + /** + * Returns the steps generator for updating permissions. + * Use this with checkout notification flow. + */ + updatePermissionsSteps( + permissions: AccountPermissions[], + ): AsyncGenerator { + const addPermissionsApi = permissions.map((p) => + this.convertToAddPermissionsApi(p), + ) + return this.addSteps(addPermissionsApi) + } +} diff --git a/src/helpers/constants.ts b/src/helpers/constants.ts index 537d447e9..3c95299e3 100644 --- a/src/helpers/constants.ts +++ b/src/helpers/constants.ts @@ -52,6 +52,7 @@ export const defaultFileExtension: Record = { export const defaultDomainAggregateKey = 'domains' export const defaultWebsiteAggregateKey = 'websites' export const defaultPortForwardingAggregateKey = 'port-forwarding' +export const defaultPermissionsAggregateKey = 'security' export const defaultSSHPostType = 'ALEPH-SSH' export const defaultConsoleChannel = 'ALEPH-CLOUDSOLUTIONS' @@ -88,6 +89,7 @@ type CheckoutAddStepType = | 'reserve' | 'allocate' | 'portForwarding' + | 'permissions' type CheckoutDelStepType = | 'sshDel' @@ -98,6 +100,7 @@ type CheckoutDelStepType = | 'programDel' | 'websiteDel' | 'portForwardingDel' + | 'permissionsDel' type CheckoutUpStepType = | 'sshUp' @@ -199,6 +202,10 @@ export const EXTRA_WEI = 3600 / 10 ** 18 export const NAVIGATION_URLS = { console: { home: '/console', + permissions: { + home: '/console/permissions', + new: '/console/permissions/new', + }, settings: { home: '/console/settings', ssh: { diff --git a/src/helpers/schemas/permissions.ts b/src/helpers/schemas/permissions.ts new file mode 100644 index 000000000..79d137c95 --- /dev/null +++ b/src/helpers/schemas/permissions.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' +import { ethereumAddressSchema, requiredStringSchema } from './base' +import { MessageType } from '@aleph-sdk/message' + +const messageTypeSchema = z.nativeEnum(MessageType) + +const baseMessageTypePermissionsSchema = z.object({ + type: messageTypeSchema, + authorized: z.boolean(), +}) + +const postPermissionsSchema = z.object({ + type: z.literal(MessageType.post), + postTypes: z.array(z.string()), + authorized: z.boolean(), +}) + +const aggregatePermissionsSchema = z.object({ + type: z.literal(MessageType.aggregate), + aggregateKeys: z.array(z.string()), + authorized: z.boolean(), +}) + +const messageTypePermissionsSchema = z.union([ + postPermissionsSchema, + aggregatePermissionsSchema, + baseMessageTypePermissionsSchema, +]) + +const permissionsConfigSchema = z.object({ + channels: z.array(z.string()), + messageTypes: z.array(messageTypePermissionsSchema), +}) + +export const newPermissionSchema = z.object({ + address: ethereumAddressSchema, + alias: requiredStringSchema, + permissions: permissionsConfigSchema, +}) + +export const accountPermissionSchema = z.object({ + id: ethereumAddressSchema, + alias: z.string().optional(), + channels: z.array(z.string()), + messageTypes: z.array(messageTypePermissionsSchema), + revoked: z.boolean().optional(), +}) + +export const accountPermissionsSchema = z.array(accountPermissionSchema) diff --git a/src/hooks/common/useAggregateKeys.ts b/src/hooks/common/useAggregateKeys.ts new file mode 100644 index 000000000..0a88a6300 --- /dev/null +++ b/src/hooks/common/useAggregateKeys.ts @@ -0,0 +1,43 @@ +import { useState, useEffect } from 'react' +import { AlephHttpClient } from '@aleph-sdk/client' +import { apiServer } from '@/helpers/server' + +export function useAggregateKeys(address: string | undefined) { + const [aggregateKeys, setAggregateKeys] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + // const [appState] = useAppState() + + // const {manager: } = appState + + useEffect(() => { + if (!address) { + setAggregateKeys([]) + return + } + + const fetchAggregateKeys = async () => { + setIsLoading(true) + setError(null) + + try { + const sdkClient = new AlephHttpClient(apiServer) + const response: Record = + await sdkClient.fetchAggregates(address) + + const keys = Object.keys(response).filter((key) => key !== '') + setAggregateKeys(keys.sort()) + } catch (err) { + setError(err instanceof Error ? err : new Error('Unknown error')) + setAggregateKeys([]) + } finally { + setIsLoading(false) + } + } + + fetchAggregateKeys() + }, [address]) + + return { aggregateKeys, isLoading, error } +} diff --git a/src/hooks/common/useChannels.ts b/src/hooks/common/useChannels.ts new file mode 100644 index 000000000..ec5c1a84a --- /dev/null +++ b/src/hooks/common/useChannels.ts @@ -0,0 +1,37 @@ +import { useLocalRequest } from '@aleph-front/core' +import { apiServer } from '@/helpers/server' + +type ChannelsResponse = { + address: string + channels: string[] +} + +export function useChannels(address: string | undefined) { + const { + data: channels = [], + loading: isLoading, + error, + } = useLocalRequest({ + doRequest: async () => { + if (!address) return [] + + const response = await fetch( + `${apiServer}/api/v0/addresses/${address}/channels`, + ) + + if (!response.ok) { + throw new Error(`Failed to fetch channels: ${response.statusText}`) + } + + const data: ChannelsResponse = await response.json() + return data.channels || [] + }, + onSuccess: () => null, + onError: () => null, + flushData: true, + triggerOnMount: true, + triggerDeps: [address], + }) + + return { channels, isLoading, error } +} diff --git a/src/hooks/common/useManager/usePermissionManager.ts b/src/hooks/common/useManager/usePermissionManager.ts new file mode 100644 index 000000000..b27629cff --- /dev/null +++ b/src/hooks/common/useManager/usePermissionManager.ts @@ -0,0 +1,9 @@ +import { useAppState } from '@/contexts/appState' +import { PermissionsManager } from '@/domain/permissions' + +export function usePermissionsManager(): PermissionsManager | undefined { + const [appState] = useAppState() + const { permissionsManager } = appState.manager + + return permissionsManager +} diff --git a/src/hooks/common/usePostTypes.ts b/src/hooks/common/usePostTypes.ts new file mode 100644 index 000000000..be74ef35d --- /dev/null +++ b/src/hooks/common/usePostTypes.ts @@ -0,0 +1,37 @@ +import { useLocalRequest } from '@aleph-front/core' +import { apiServer } from '@/helpers/server' + +type PostTypesResponse = { + address: string + post_types: string[] +} + +export function usePostTypes(address: string | undefined) { + const { + data: postTypes = [], + loading: isLoading, + error, + } = useLocalRequest({ + doRequest: async () => { + if (!address) return [] + + const response = await fetch( + `${apiServer}/api/v0/addresses/${address}/post_types`, + ) + + if (!response.ok) { + throw new Error(`Failed to fetch post types: ${response.statusText}`) + } + + const data: PostTypesResponse = await response.json() + return data.post_types || [] + }, + onSuccess: () => null, + onError: () => null, + flushData: true, + triggerOnMount: true, + triggerDeps: [address], + }) + + return { postTypes, isLoading, error } +} diff --git a/src/hooks/common/useRequestEntity/useRequestConfidentialDomains.ts b/src/hooks/common/useRequestEntity/useRequestConfidentialDomains.ts index 583066d26..cc5052452 100644 --- a/src/hooks/common/useRequestEntity/useRequestConfidentialDomains.ts +++ b/src/hooks/common/useRequestEntity/useRequestConfidentialDomains.ts @@ -12,8 +12,10 @@ export type UseRequestConfidentialDomainsProps = Omit< 'name' > -export type UseRequestConfidentialDomainsReturn = - UseRequestEntitiesReturn +export type UseRequestConfidentialDomainsReturn = Omit< + UseRequestEntitiesReturn, + 'refetch' +> export function useRequestConfidentialDomains( props: UseRequestConfidentialDomainsProps = {}, diff --git a/src/hooks/common/useRequestEntity/useRequestEntities.ts b/src/hooks/common/useRequestEntity/useRequestEntities.ts index 09d30150a..27a7a509b 100644 --- a/src/hooks/common/useRequestEntity/useRequestEntities.ts +++ b/src/hooks/common/useRequestEntity/useRequestEntities.ts @@ -22,6 +22,7 @@ export type UseRequestEntitiesProps = { export type UseRequestEntitiesReturn = { entities?: Entity[] loading: boolean + refetch: () => Promise } type RequestType = 'single' | 'multiple' | 'all' @@ -33,9 +34,10 @@ const handleSingleEntityRequest = async < manager: EntityManager | ReadOnlyEntityManager, entityId: string, currentState: { entities?: Entity[] }, + force = false, ): Promise => { - // Check cache for single entity - if (currentState?.entities?.length) { + // Check cache for single entity (skip if force is true) + if (!force && currentState?.entities?.length) { const cachedEntity = currentState.entities.find( (entity) => entity.id === entityId, ) @@ -57,11 +59,12 @@ const handleMultipleEntitiesRequest = async < manager: EntityManager | ReadOnlyEntityManager, entityIds: string[], currentState: { entities?: Entity[] }, + force = false, ): Promise => { if (!entityIds.length) return [] - // Check cache for multiple entities - if (currentState?.entities?.length) { + // Check cache for multiple entities (skip if force is true) + if (!force && currentState?.entities?.length) { const existingIds = currentState.entities.map((entity) => entity.id) const allEntitiesCached = entityIds.every((id) => existingIds.includes(id)) @@ -82,9 +85,10 @@ const handleAllEntitiesRequest = async < >( manager: EntityManager | ReadOnlyEntityManager, currentState: { entities?: Entity[] }, + force = false, ): Promise => { - // Check cache for all entities - if (currentState?.entities?.length) { + // Check cache for all entities (skip if force is true) + if (!force && currentState?.entities?.length) { return currentState.entities } @@ -153,6 +157,40 @@ export function useRequestEntities< cacheStrategy, }) + // Force request handler for refetch (bypasses cache) + const { request: forceRequest } = useAppStoreEntityRequest({ + name, + doRequest: async () => { + if (!manager) return [] + + const currentState = state[name] as { entities?: Entity[] } + + switch (requestType) { + case 'single': + return handleSingleEntityRequest( + manager, + ids as string, + currentState, + true, + ) + case 'multiple': + return handleMultipleEntitiesRequest( + manager, + ids as string[], + currentState, + true, + ) + case 'all': + return handleAllEntitiesRequest(manager, currentState, true) + } + }, + onSuccess: () => null, + flushData: true, + triggerOnMount: false, + triggerDeps: [], + cacheStrategy, + }) + // Filter entities based on request type const filteredEntities = useMemo(() => { if (!allEntities) return allEntities @@ -180,5 +218,6 @@ export function useRequestEntities< return { entities: filteredEntities, loading, + refetch: forceRequest, } } diff --git a/src/hooks/common/useRequestEntity/useRequestGpuInstanceDomains.ts b/src/hooks/common/useRequestEntity/useRequestGpuInstanceDomains.ts index 0505da972..fe030e83d 100644 --- a/src/hooks/common/useRequestEntity/useRequestGpuInstanceDomains.ts +++ b/src/hooks/common/useRequestEntity/useRequestGpuInstanceDomains.ts @@ -12,8 +12,10 @@ export type UseRequestGpuInstanceDomainsProps = Omit< 'name' > -export type UseRequestGpuInstanceDomainsReturn = - UseRequestEntitiesReturn +export type UseRequestGpuInstanceDomainsReturn = Omit< + UseRequestEntitiesReturn, + 'refetch' +> export function useRequestGpuInstanceDomains( props: UseRequestGpuInstanceDomainsProps = {}, diff --git a/src/hooks/common/useRequestEntity/useRequestInstanceDomains.ts b/src/hooks/common/useRequestEntity/useRequestInstanceDomains.ts index e3531113e..11a28bcf8 100644 --- a/src/hooks/common/useRequestEntity/useRequestInstanceDomains.ts +++ b/src/hooks/common/useRequestEntity/useRequestInstanceDomains.ts @@ -12,7 +12,10 @@ export type UseRequestInstanceDomainsProps = Omit< 'name' > -export type UseRequestInstanceDomainsReturn = UseRequestEntitiesReturn +export type UseRequestInstanceDomainsReturn = Omit< + UseRequestEntitiesReturn, + 'refetch' +> export function useRequestInstanceDomains( props: UseRequestInstanceDomainsProps = {}, diff --git a/src/hooks/common/useRequestEntity/useRequestPermissions.ts b/src/hooks/common/useRequestEntity/useRequestPermissions.ts new file mode 100644 index 000000000..dd73d45d2 --- /dev/null +++ b/src/hooks/common/useRequestEntity/useRequestPermissions.ts @@ -0,0 +1,23 @@ +import { AccountPermissions } from '@/domain/permissions' +import { usePermissionsManager } from '../useManager/usePermissionManager' + +import { + UseRequestEntitiesProps, + UseRequestEntitiesReturn, + useRequestEntities, +} from './useRequestEntities' + +export type UseRequestPermissionsProps = Omit< + UseRequestEntitiesProps, + 'name' +> + +export type UseRequestPermissionsReturn = + UseRequestEntitiesReturn + +export function useRequestPermissions( + props: UseRequestPermissionsProps = {}, +): UseRequestPermissionsReturn { + const manager = usePermissionsManager() + return useRequestEntities({ ...props, manager, name: 'permissions' }) +} diff --git a/src/hooks/common/useRequestEntity/useRequestProgramDomains.ts b/src/hooks/common/useRequestEntity/useRequestProgramDomains.ts index b0ea85b4e..3f80d467b 100644 --- a/src/hooks/common/useRequestEntity/useRequestProgramDomains.ts +++ b/src/hooks/common/useRequestEntity/useRequestProgramDomains.ts @@ -13,7 +13,10 @@ export type UseRequestProgramDomainsProps = Omit< 'name' > -export type UseRequestProgramDomainsReturn = UseRequestEntitiesReturn +export type UseRequestProgramDomainsReturn = Omit< + UseRequestEntitiesReturn, + 'refetch' +> export function useRequestProgramDomains( props: UseRequestProgramDomainsProps = {}, diff --git a/src/hooks/common/useRequestEntity/useRequestWebsiteDomains.ts b/src/hooks/common/useRequestEntity/useRequestWebsiteDomains.ts index 5acf57c1c..3eed74cdb 100644 --- a/src/hooks/common/useRequestEntity/useRequestWebsiteDomains.ts +++ b/src/hooks/common/useRequestEntity/useRequestWebsiteDomains.ts @@ -13,7 +13,10 @@ export type UseRequestWebsiteDomainsProps = Omit< 'name' > -export type UseRequestWebsiteDomainsReturn = UseRequestEntitiesReturn +export type UseRequestWebsiteDomainsReturn = Omit< + UseRequestEntitiesReturn, + 'refetch' +> export function useRequestWebsiteDomains( props: UseRequestWebsiteDomainsProps = {}, diff --git a/src/hooks/common/useRoutes.ts b/src/hooks/common/useRoutes.ts index bad84eb06..f7f6dafb6 100644 --- a/src/hooks/common/useRoutes.ts +++ b/src/hooks/common/useRoutes.ts @@ -69,10 +69,14 @@ export function useRoutes(): UseRoutesReturn { exact: true, icon: 'dashboard', }, + { + name: 'Permissions', + href: NAVIGATION_URLS.console.permissions.home, + icon: 'settings', // @todo: Change icon + }, { name: 'Settings', href: NAVIGATION_URLS.console.settings.home, - exact: true, icon: 'settings', }, { diff --git a/src/hooks/form/useCheckoutNotification.tsx b/src/hooks/form/useCheckoutNotification.tsx index 0e3a02693..5682b6315 100644 --- a/src/hooks/form/useCheckoutNotification.tsx +++ b/src/hooks/form/useCheckoutNotification.tsx @@ -185,4 +185,13 @@ export const stepsCatalog: Record = content: 'By signing this, you confirm the deletion of your port forwarding configuration.', }, + permissions: { + title: 'Sign Permissions Creation', + content: + 'By signing this, you confirm the creation of new permissions for delegated access control.', + }, + permissionsDel: { + title: 'Sign Permissions Deletion', + content: 'By signing this, you confirm the deletion of your permissions.', + }, } diff --git a/src/pages/console/permissions/index.ts b/src/pages/console/permissions/index.ts new file mode 100644 index 000000000..001a51df2 --- /dev/null +++ b/src/pages/console/permissions/index.ts @@ -0,0 +1 @@ +export { default } from '@/components/pages/console/permissions/PermissionsDashboardPage' diff --git a/src/pages/console/permissions/new.ts b/src/pages/console/permissions/new.ts new file mode 100644 index 000000000..2b1156741 --- /dev/null +++ b/src/pages/console/permissions/new.ts @@ -0,0 +1 @@ +export { default } from '@/components/pages/console/permissions/NewPermissionPage' diff --git a/src/store/entity.ts b/src/store/entity.ts index 9708da961..ec52d4a8e 100644 --- a/src/store/entity.ts +++ b/src/store/entity.ts @@ -17,6 +17,7 @@ type CachedEntityName = | 'confidentialVolume' | 'ccns' | 'crns' + | 'permissions' // Entity relationship mapping for cascade invalidation // When ANY entity is deleted, clear ALL entity caches for complete consistency @@ -56,6 +57,7 @@ export const ENTITY_RELATIONSHIPS: Record< confidentialVolume: ['volume', 'confidential'], ccns: ['crns'], crns: ['ccns'], + permissions: [], } export type EntityState = { diff --git a/src/store/manager.ts b/src/store/manager.ts index 14aca811c..68886fb56 100644 --- a/src/store/manager.ts +++ b/src/store/manager.ts @@ -21,6 +21,7 @@ import { VoucherManager } from '@/domain/voucher' import { GpuInstanceManager } from '@/domain/gpuInstance' import { CostManager } from '@/domain/cost' import { ForwardedPortsManager } from '@/domain/forwardedPorts' +import { PermissionsManager } from '@/domain/permissions' function createDefaultManagers(account?: Account) { const sdkClient = !account @@ -85,6 +86,7 @@ function createDefaultManagers(account?: Account) { domainManager, ) const voucherManager = new VoucherManager(account, sdkClient) + const permissionsManager = new PermissionsManager(account, sdkClient) return { sdkClient, @@ -102,6 +104,7 @@ function createDefaultManagers(account?: Account) { websiteManager, voucherManager, forwardedPortsManager, + permissionsManager, } } @@ -120,6 +123,7 @@ const { websiteManager, voucherManager, forwardedPortsManager, + permissionsManager, } = createDefaultManagers() export type ManagerState = { @@ -137,6 +141,7 @@ export type ManagerState = { voucherManager?: VoucherManager costManager?: CostManager forwardedPortsManager?: ForwardedPortsManager + permissionsManager?: PermissionsManager } export const initialState: ManagerState = { @@ -154,6 +159,7 @@ export const initialState: ManagerState = { websiteManager, voucherManager, forwardedPortsManager, + permissionsManager, } export type ManagerAction = ConnectionAction @@ -179,6 +185,7 @@ export function getManagerReducer(): ManagerReducer { websiteManager, voucherManager, forwardedPortsManager, + permissionsManager, } = createDefaultManagers() return { @@ -197,6 +204,7 @@ export function getManagerReducer(): ManagerReducer { websiteManager, voucherManager, forwardedPortsManager, + permissionsManager, } } @@ -218,6 +226,7 @@ export function getManagerReducer(): ManagerReducer { websiteManager, voucherManager, forwardedPortsManager, + permissionsManager, } = createDefaultManagers(account) return { @@ -236,6 +245,7 @@ export function getManagerReducer(): ManagerReducer { voucherManager, costManager, forwardedPortsManager, + permissionsManager, } } diff --git a/src/store/store.ts b/src/store/store.ts index 87bc1f60b..44bdaec95 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -14,6 +14,7 @@ import { CCN, CRN, NodeLastVersions } from '@/domain/node' import { RequestState, getRequestReducer } from './request' import { RewardsResponse } from '@/domain/stake' import { FilterState, getFilterReducer } from './filter' +import { AccountPermissions } from '@/domain/permissions' export type StoreSubstate = Record @@ -68,6 +69,7 @@ export type StoreState = { program: EntityState volume: EntityState website: EntityState + permissions: EntityState // Volume relationships programVolume: EntityState @@ -104,6 +106,7 @@ export const storeReducer = mergeReducers({ program: getEntityReducer('program', 'id'), volume: getEntityReducer('volume', 'id'), website: getEntityReducer('website', 'id'), + permissions: getEntityReducer('permissions', 'id'), // Volume relationships programVolume: getEntityReducer('programVolume', 'id'),