diff --git a/src/app.tsx b/src/app.tsx index 5051a9d3..c07a6ed0 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -4,6 +4,7 @@ import { useAccount, useAccountComment, useCommunity } from '@bitsocialnet/bitso import { initSnow, removeSnow } from './lib/snow'; import { isAllView, isCatalogView, isModView, isSubscriptionsView } from './lib/utils/view-utils'; import { preloadReplyModal, preloadThemeAssets } from './lib/utils/preload-utils'; +import { hasModQueueAccessRole } from './lib/utils/mod-access'; import useReplyModalStore from './stores/use-reply-modal-store'; import useCreateBoardModalStore from './stores/use-create-board-modal-store'; import useSpecialThemeStore from './stores/use-special-theme-store'; @@ -60,8 +61,6 @@ const SettingsModal = lazy(() => import('./components/settings-modal')); preloadThemeAssets(); preloadReplyModal(); -const hasModQueueAccessRole = (role?: string): boolean => role === 'admin' || role === 'owner' || role === 'moderator'; - const BoardLayout = () => { const params = useParams(); const { accountCommentIndex, boardIdentifier, pageNumber } = params; diff --git a/src/lib/utils/__tests__/mod-access.test.ts b/src/lib/utils/__tests__/mod-access.test.ts new file mode 100644 index 00000000..afd75312 --- /dev/null +++ b/src/lib/utils/__tests__/mod-access.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { canAccessBoardModQueue, hasModQueueAccessRole } from '../mod-access'; + +describe('hasModQueueAccessRole', () => { + it('accepts board moderation roles', () => { + expect(hasModQueueAccessRole('owner')).toBe(true); + expect(hasModQueueAccessRole('admin')).toBe(true); + expect(hasModQueueAccessRole('moderator')).toBe(true); + }); + + it('rejects missing and unrelated roles', () => { + expect(hasModQueueAccessRole(undefined)).toBe(false); + expect(hasModQueueAccessRole('viewer')).toBe(false); + }); +}); + +describe('canAccessBoardModQueue', () => { + it('allows access to the global queue when at least one moderated board is cached', () => { + expect( + canAccessBoardModQueue({ + accountCommunityAddresses: ['music-posting.eth'], + }), + ).toBe(true); + }); + + it('rejects access to the global queue when no moderated boards are cached', () => { + expect( + canAccessBoardModQueue({ + accountCommunityAddresses: [], + }), + ).toBe(false); + }); + + it('allows access when the current board role is moderator even without cached board membership', () => { + expect( + canAccessBoardModQueue({ + boardAddress: '12D3KooWNFgjQWX2EUEs7pixdjkWSLh21EZ9NeYnV8iMaCyYhLGJ', + accountCommunityAddresses: [], + accountRole: 'moderator', + }), + ).toBe(true); + }); + + it('rejects board-scoped access when only the cached moderated board matches by alias', () => { + expect( + canAccessBoardModQueue({ + boardAddress: 'music-posting.eth', + accountCommunityAddresses: ['music-posting.bso'], + }), + ).toBe(false); + }); + + it('rejects access when neither the role nor moderated board list matches', () => { + expect( + canAccessBoardModQueue({ + boardAddress: 'music-posting.eth', + accountCommunityAddresses: ['tech-posting.eth'], + }), + ).toBe(false); + }); +}); diff --git a/src/lib/utils/mod-access.ts b/src/lib/utils/mod-access.ts new file mode 100644 index 00000000..5bbef23c --- /dev/null +++ b/src/lib/utils/mod-access.ts @@ -0,0 +1,19 @@ +export const hasModQueueAccessRole = (role?: string): boolean => role === 'admin' || role === 'owner' || role === 'moderator'; + +interface BoardModQueueAccessArgs { + boardAddress?: string; + accountCommunityAddresses: string[]; + accountRole?: string; +} + +export const canAccessBoardModQueue = ({ boardAddress, accountCommunityAddresses, accountRole }: BoardModQueueAccessArgs): boolean => { + if (hasModQueueAccessRole(accountRole)) { + return true; + } + + if (!boardAddress) { + return accountCommunityAddresses.length > 0; + } + + return false; +}; diff --git a/src/views/mod-queue/mod-queue.tsx b/src/views/mod-queue/mod-queue.tsx index 66f0673d..f44fed6b 100644 --- a/src/views/mod-queue/mod-queue.tsx +++ b/src/views/mod-queue/mod-queue.tsx @@ -1,14 +1,14 @@ import React, { useMemo, useState, useEffect, useCallback, memo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams, Link } from 'react-router-dom'; -import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity } from '@bitsocialnet/bitsocial-react-hooks'; +import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useAccount } from '@bitsocialnet/bitsocial-react-hooks'; import { Virtuoso } from 'react-virtuoso'; import styles from './mod-queue.module.css'; import useModQueueStore from '../../stores/use-mod-queue-store'; import LoadingEllipsis from '../../components/loading-ellipsis'; import ErrorDisplay from '../../components/error-display/error-display'; import { useFeedStateString } from '../../hooks/use-state-string'; -import { getSubplebbitAddress, getBoardPath, extractDirectoryFromTitle } from '../../lib/utils/route-utils'; +import { getSubplebbitAddress, getBoardPath, extractDirectoryFromTitle, areSameBoardAddress } from '../../lib/utils/route-utils'; import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories'; import getShortAddress from '../../lib/get-short-address'; import { BOARD_CODE_GROUPS } from '../../constants/board-codes'; @@ -28,6 +28,7 @@ import { useAccountCommunityAddresses } from '../../hooks/use-account-community- import useIsMobile from '../../hooks/use-is-mobile'; import { useCurrentTime } from '../../hooks/use-current-time'; import { Post } from '../post/post'; +import { canAccessBoardModQueue, hasModQueueAccessRole } from '../../lib/utils/mod-access'; import capitalize from 'lodash/capitalize'; import lowerCase from 'lodash/lowerCase'; import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer'; @@ -715,6 +716,8 @@ const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, i export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => { const { getAlertThresholdSeconds } = useModQueueStore(); + const account = useAccount(); + const accountAddress = account?.author?.address; const accountCommunityAddresses = useAccountCommunityAddresses(); const directories = useDirectories(); @@ -725,6 +728,7 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp } return undefined; }, [boardIdentifier, directories]); + const community = useCommunity({ communityAddress: resolvedAddress }); const communityAddresses = useMemo(() => { if (resolvedAddress) { @@ -733,11 +737,25 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp return accountCommunityAddresses; }, [resolvedAddress, accountCommunityAddresses]); - // If specific board, check if user is mod using resolved address - const isModOfBoard = resolvedAddress ? accountCommunityAddresses.includes(resolvedAddress) : true; + const accountRole = accountAddress ? community?.roles?.[accountAddress]?.role : undefined; + const hasBoardAccessFromAccountCommunities = resolvedAddress + ? accountCommunityAddresses.some((address) => areSameBoardAddress(address, resolvedAddress)) + : accountCommunityAddresses.length > 0; + const hasBoardAccess = canAccessBoardModQueue({ + boardAddress: resolvedAddress, + accountCommunityAddresses, + accountRole, + }); + const isBoardAccessLoading = + Boolean(resolvedAddress) && + Boolean(accountAddress) && + !hasModQueueAccessRole(accountRole) && + !hasBoardAccessFromAccountCommunities && + community?.state !== 'succeeded' && + community?.state !== 'failed'; // Only fetch if we have addresses to check and permissions - const shouldFetch = communityAddresses.length > 0 && isModOfBoard; + const shouldFetch = !isBoardAccessLoading && communityAddresses.length > 0 && hasBoardAccess; const feedAddresses = shouldFetch ? communityAddresses : []; const feedOptions = useMemo(