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 (
+ <>
+
+ >
+ )
+}
+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"
+ />
+
+
+ Clear all
+
+
+
+ {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 = (
+
+
+
+ Continue
+
+
+ Cancel
+
+
+ )
+
+ return (
+ <>
+
+ setSelectedTabId(id)}
+ align="left"
+ />
+
+ {selectedTabId === 'messages' ? (
+
+
+
+ Channels:
+
+ {authorizedChannels}
+
+
+
+ Filter channels
+
+
+
+ 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"
+ />
+
+
+ Clear all
+
+
+
+ {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
+
+
+
+
+ >
+ )
+}
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 && (
+
+
+
+ Save changes
+
+
+
+ )}
+ >
+ )
+}
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: (
+
+
+ Cancel
+
+
+ Discard Changes
+
+
+ ),
+ })
+ } 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 ? (
+
+
+
+ Continue
+
+
+ Cancel
+
+
+ ) : 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'),