From c7e258e3d18170155991cf6b6dd79a94fa498d49 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 19 May 2026 19:00:10 +0200 Subject: [PATCH] feat(hooks): permission-driven gating via useHasPermission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the rank-level family (useHasRankLevel + STAFF_LEVELS constants + useIsRank) with a permission-driven family that reads straight from the deployment's `permission_definitions` table — no more hardcoded SecurityLevel/rank-id thresholds on the client. A new rank in permission_ranks or a re-shuffling of permission_definitions rank columns now propagates through the UI automatically. Renderer-side wire shipped in companion commit feat/react19-event-bus@159c5eb (UserPermissionsMapParser + Event, SessionDataManager.getPermissionsSnapshot + USER_PERMISSIONS_UPDATED). New public API in `useSessionSnapshots.ts`: - useUserPermissions(): ReadonlyMap — full map - useHasPermission(key): boolean — > 0 ⇒ true - usePermissionValue(key): number — raw 1/2 or 0 - useIsAmbassador() now aliases useHasPermission('acc_ambassador') - useUserRank() kept for PRESENTATIONAL use only (badge, prefix, prefix color) — documented as such in JSDoc; gating must use useHasPermission. Dropped: - src/api/nitro/session/RankLevels.ts (STAFF_LEVELS constants) - useHasRankLevel / useIsRank exports (rank-based gating) 11 consumer migrations, each mapped to the right `permission_definitions.permission_key`: - ToolbarView (mod-only chat-input button) → acc_supporttool - ChooserWidgetView (room-object id column) → acc_supporttool - CatalogClassicView (admin toggles) → acc_catalogfurni - CatalogModernView (admin toggles) → acc_catalogfurni - FurniEditorView (panel access) → acc_catalogfurni - CalendarView (force-open day) → acc_calendar_force - InfoStandWidgetFurniView (mod buildtools btn) → acc_anyroomowner - AvatarInfoWidgetPetView (canPickUp) → acc_anyroomowner - FurnitureMannequinView (controller mode) → acc_anyroomowner - YouTubePlayerView (isMyRoom) → acc_anyroomowner - NavigatorRoomInfoView 'settings' → acc_anyroomowner - NavigatorRoomInfoView 'staff_pick' → acc_staff_pick Test refresh: - useUserRank still tested for the presentational shape. - useHasPermission: true for non-zero, false for absent/zero. - usePermissionValue: raw 1 / 2 / 0 (default). - useUserPermissions: full map exposure. - Runtime promote test: mutate the permissions map + dispatch USER_PERMISSIONS_UPDATED, assert useHasPermission flips false→true. Locks in the new reactive contract. Mock unchanged (the test sets getPermissionsSnapshot via vi.mocked). Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test 214/214 (213 prior + 1 net new for useUserPermissions). Backward compatible: older Arcturus deployments don't ship the map → empty snapshot → every gate is false → mod UI hidden (safe default). --- src/api/nitro/session/RankLevels.ts | 29 ------ src/api/nitro/session/index.ts | 1 - src/components/campaign/CalendarView.tsx | 6 +- src/components/catalog/CatalogClassicView.tsx | 6 +- src/components/catalog/CatalogModernView.tsx | 6 +- .../furni-editor/FurniEditorView.tsx | 5 +- .../navigator/views/NavigatorRoomInfoView.tsx | 12 +-- .../infostand/InfoStandWidgetFurniView.tsx | 6 +- .../menu/AvatarInfoWidgetPetView.tsx | 10 +- .../widgets/choosers/ChooserWidgetView.tsx | 6 +- .../furniture/FurnitureMannequinView.tsx | 10 +- src/components/toolbar/ToolbarView.tsx | 6 +- src/components/toolbar/YouTubePlayerView.tsx | 6 +- .../session/useSessionSnapshots.test.tsx | 91 ++++++++++++------- src/hooks/session/useSessionSnapshots.ts | 91 +++++++++++++------ 15 files changed, 164 insertions(+), 127 deletions(-) delete mode 100644 src/api/nitro/session/RankLevels.ts diff --git a/src/api/nitro/session/RankLevels.ts b/src/api/nitro/session/RankLevels.ts deleted file mode 100644 index f68b2b9..0000000 --- a/src/api/nitro/session/RankLevels.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Deployment-specific rank-level constants. - * - * Mirrors the `level` column of `permission_ranks` for the operator's - * Arcturus instance. Defined in ONE place so widget code can express - * intent via `useHasRankLevel(STAFF_LEVELS.MOD)` instead of bare - * integers, and so a deployment that re-numbers its ranks (e.g. adds - * a "Super Admin" at level 8) only has to update this file. - * - * Default seed (Arcturus-Morningstar-Extended ≥ 4.2.10): - * 1 Member | 2 VIP | 3 X - * 4 Support | 5 Moderator | 6 Super Mod - * 7 Administrator - * - * Update the constants here to match `permission_ranks.level` in your - * deployment if you customised them. - */ -export const STAFF_LEVELS = { - /** Member level — the floor for non-staff users. */ - MEMBER: 1, - /** Lowest staff tier — Support agents. */ - SUPPORT: 4, - /** Moderator (covers in-room moderation actions). */ - MOD: 5, - /** Super Mod (extended moderation surface). */ - SUPER_MOD: 6, - /** Administrator — full staff privileges. */ - ADMIN: 7 -} as const; diff --git a/src/api/nitro/session/index.ts b/src/api/nitro/session/index.ts index 5028554..88ee45a 100644 --- a/src/api/nitro/session/index.ts +++ b/src/api/nitro/session/index.ts @@ -16,6 +16,5 @@ export * from './HasHabboVip'; export * from './IsOwnerOfFloorFurniture'; export * from './IsOwnerOfFurniture'; export * from './IsRidingHorse'; -export * from './RankLevels'; export * from './StartRoomSession'; export * from './VisitDesktop'; diff --git a/src/components/campaign/CalendarView.tsx b/src/components/campaign/CalendarView.tsx index ee231f7..fc1a943 100644 --- a/src/components/campaign/CalendarView.tsx +++ b/src/components/campaign/CalendarView.tsx @@ -1,7 +1,7 @@ import { FC, useState } from 'react'; -import { CalendarItemState, ICalendarItem, LocalizeText, STAFF_LEVELS } from '../../api'; +import { CalendarItemState, ICalendarItem, LocalizeText } from '../../api'; import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; -import { useHasRankLevel } from '../../hooks'; +import { useHasPermission } from '../../hooks'; import { CalendarItemView } from './CalendarItemView'; interface CalendarViewProps @@ -23,7 +23,7 @@ export const CalendarView: FC = props => const { onClose = null, campaignName = null, currentDay = null, numDays = null, missedDays = null, openedDays = null, openPackage = null, receivedProducts = null } = props; const [ selectedDay, setSelectedDay ] = useState(currentDay); const [ index, setIndex ] = useState(Math.max(0, (selectedDay - 1))); - const isModerator = useHasRankLevel(STAFF_LEVELS.MOD); + const isModerator = useHasPermission('acc_calendar_force'); const getDayState = (day: number) => { diff --git a/src/components/catalog/CatalogClassicView.tsx b/src/components/catalog/CatalogClassicView.tsx index e72c702..a9ba44b 100644 --- a/src/components/catalog/CatalogClassicView.tsx +++ b/src/components/catalog/CatalogClassicView.tsx @@ -1,9 +1,9 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, useEffect } from 'react'; import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa'; -import { CatalogType, GetConfigurationValue, LocalizeText, STAFF_LEVELS } from '../../api'; +import { CatalogType, GetConfigurationValue, LocalizeText } from '../../api'; import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; -import { useCatalogActions, useCatalogData, useCatalogUiState, useHasRankLevel } from '../../hooks'; +import { useCatalogActions, useCatalogData, useCatalogUiState, useHasPermission } from '../../hooks'; import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; @@ -28,7 +28,7 @@ const CatalogClassicViewInner: FC<{}> = () => {}); const loading = catalogAdmin?.loading ?? false; - const isMod = useHasRankLevel(STAFF_LEVELS.MOD); + const isMod = useHasPermission('acc_catalogfurni'); const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER) ? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' } : undefined; diff --git a/src/components/catalog/CatalogModernView.tsx b/src/components/catalog/CatalogModernView.tsx index 9c517fb..ffd5300 100644 --- a/src/components/catalog/CatalogModernView.tsx +++ b/src/components/catalog/CatalogModernView.tsx @@ -1,9 +1,9 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa'; -import { CatalogType, LocalizeText, STAFF_LEVELS } from '../../api'; +import { CatalogType, LocalizeText } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; -import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useHasRankLevel } from '../../hooks'; +import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useHasPermission } from '../../hooks'; import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; @@ -32,7 +32,7 @@ const CatalogModernViewInner: FC<{}> = () => const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites(); const [ showFavorites, setShowFavorites ] = useState(false); - const isMod = useHasRankLevel(STAFF_LEVELS.MOD); + const isMod = useHasPermission('acc_catalogfurni'); const totalFavs = favoriteOfferIds.length + favoritePageIds.length; const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER) ? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' } diff --git a/src/components/furni-editor/FurniEditorView.tsx b/src/components/furni-editor/FurniEditorView.tsx index 443405c..65206ba 100644 --- a/src/components/furni-editor/FurniEditorView.tsx +++ b/src/components/furni-editor/FurniEditorView.tsx @@ -1,8 +1,7 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useState } from 'react'; -import { STAFF_LEVELS } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; -import { useHasRankLevel } from '../../hooks'; +import { useHasPermission } from '../../hooks'; import { useFurniEditor } from '../../hooks/furni-editor'; import { FurniEditorEditView } from './views/FurniEditorEditView'; import { FurniEditorSearchView } from './views/FurniEditorSearchView'; @@ -22,7 +21,7 @@ export const FurniEditorView: FC<{}> = () => searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions } = useFurniEditor(); - const isMod = useHasRankLevel(STAFF_LEVELS.MOD); + const isMod = useHasPermission('acc_catalogfurni'); // Auto-switch to edit tab when an item is selected useEffect(() => diff --git a/src/components/navigator/views/NavigatorRoomInfoView.tsx b/src/components/navigator/views/NavigatorRoomInfoView.tsx index c5d909b..2073c15 100644 --- a/src/components/navigator/views/NavigatorRoomInfoView.tsx +++ b/src/components/navigator/views/NavigatorRoomInfoView.tsx @@ -1,10 +1,10 @@ import { CreateLinkEvent, GetCustomRoomFilterMessageComposer, GetGuestRoomMessageComposer, GetSessionDataManager, NavigatorSearchComposer, RemoveOwnRoomRightsRoomMessageComposer, RoomControllerLevel, RoomMuteComposer, RoomSettingsComposer, ToggleStaffPickMessageComposer, UpdateHomeRoomMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useState } from 'react'; import { FaLink, FaSignOutAlt } from 'react-icons/fa'; -import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, STAFF_LEVELS, ToggleFavoriteRoom } from '../../../api'; +import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../api'; import { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common'; import { RoomWidgetThumbnailEvent } from '../../../events'; -import { useHasRankLevel, useHelp, useNavigator, useRoom } from '../../../hooks'; +import { useHasPermission, useHelp, useNavigator, useRoom } from '../../../hooks'; import { classNames } from '../../../layout'; export interface NavigatorRoomInfoViewProps { @@ -19,8 +19,8 @@ export const NavigatorRoomInfoView: FC = props => const { report = null } = useHelp(); const { navigatorData = null, favouriteRoomIds = [] } = useNavigator(); const { roomSession = null } = useRoom(); - const isModerator = useHasRankLevel(STAFF_LEVELS.MOD); - const isAdmin = useHasRankLevel(STAFF_LEVELS.ADMIN); + const canManageAnyRoom = useHasPermission('acc_anyroomowner'); + const canStaffPick = useHasPermission('acc_staff_pick'); const enteredRoomId = navigatorData?.enteredGuestRoom?.roomId ?? 0; @@ -53,9 +53,9 @@ export const NavigatorRoomInfoView: FC = props => switch(permission) { case 'settings': - return (GetSessionDataManager().userId === navigatorData.enteredGuestRoom.ownerId || isModerator); + return (GetSessionDataManager().userId === navigatorData.enteredGuestRoom.ownerId || canManageAnyRoom); case 'staff_pick': - return isAdmin; + return canStaffPick; case 'floor': return roomSession?.controllerLevel >= RoomControllerLevel.GUEST; case 'guest': diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index 857fa5a..da4db6c 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -2,9 +2,9 @@ import { CrackableDataType, CreateLinkEvent, FurnitureFloorUpdateEvent, GetRoomE import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { FaCrosshairs, FaTimes } from 'react-icons/fa'; import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr'; -import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer, STAFF_LEVELS } from '../../../../../api'; +import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer } from '../../../../../api'; import { Button, Column, Flex, LayoutBadgeImageView, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; -import { useHasRankLevel, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks'; +import { useHasPermission, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks'; import { NitroInput } from '../../../../../layout'; interface InfoStandWidgetFurniViewProps @@ -22,7 +22,7 @@ export const InfoStandWidgetFurniView: FC = props const { avatarInfo = null, onClose = null } = props; const { roomSession = null } = useRoom(); const { openInspectionForFurni, showInspectButton } = useWiredTools(); - const isModerator = useHasRankLevel(STAFF_LEVELS.MOD); + const isModerator = useHasPermission('acc_anyroomowner'); const [ pickupMode, setPickupMode ] = useState(0); const [ canMove, setCanMove ] = useState(false); diff --git a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetPetView.tsx b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetPetView.tsx index a80595b..35426e9 100644 --- a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetPetView.tsx +++ b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetPetView.tsx @@ -1,7 +1,7 @@ import { PetRespectComposer, PetType, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitGiveHandItemPetComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useState } from 'react'; -import { AvatarInfoPet, GetOwnRoomObject, LocalizeText, SendMessageComposer, STAFF_LEVELS } from '../../../../../api'; -import { useHasRankLevel, useRoom, useSessionInfo } from '../../../../../hooks'; +import { AvatarInfoPet, GetOwnRoomObject, LocalizeText, SendMessageComposer } from '../../../../../api'; +import { useHasPermission, useRoom, useSessionInfo } from '../../../../../hooks'; import { ContextMenuHeaderView } from '../../context-menu/ContextMenuHeaderView'; import { ContextMenuListItemView } from '../../context-menu/ContextMenuListItemView'; import { ContextMenuView } from '../../context-menu/ContextMenuView'; @@ -23,12 +23,12 @@ export const AvatarInfoWidgetPetView: FC = props = const [ mode, setMode ] = useState(MODE_NORMAL); const { roomSession = null, isHandItemBlocked = false } = useRoom(); const { petRespectRemaining = 0, respectPet = null } = useSessionInfo(); - const isModerator = useHasRankLevel(STAFF_LEVELS.MOD); + const canManageAnyRoom = useHasPermission('acc_anyroomowner'); const canPickUp = useMemo(() => { - return (roomSession.isRoomOwner || (roomSession.controllerLevel >= RoomControllerLevel.GUEST) || isModerator); - }, [ roomSession, isModerator ]); + return (roomSession.isRoomOwner || (roomSession.controllerLevel >= RoomControllerLevel.GUEST) || canManageAnyRoom); + }, [ roomSession, canManageAnyRoom ]); const canGiveHandItem = useMemo(() => { diff --git a/src/components/room/widgets/choosers/ChooserWidgetView.tsx b/src/components/room/widgets/choosers/ChooserWidgetView.tsx index 58abbe3..4f06122 100644 --- a/src/components/room/widgets/choosers/ChooserWidgetView.tsx +++ b/src/components/room/widgets/choosers/ChooserWidgetView.tsx @@ -1,8 +1,8 @@ import { FurniturePickupAllComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useEffectEvent, useMemo, useState } from 'react'; -import { LocalizeText, RoomObjectItem, SendMessageComposer, STAFF_LEVELS, chooserSelectionVisualizer } from '../../../../api'; +import { LocalizeText, RoomObjectItem, SendMessageComposer, chooserSelectionVisualizer } from '../../../../api'; import { Button, Flex, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; -import { useHasRankLevel } from '../../../../hooks'; +import { useHasPermission } from '../../../../hooks'; import { NitroInput, classNames } from '../../../../layout'; const LIMIT_FURNI_PICKALL = 100; @@ -24,7 +24,7 @@ export const ChooserWidgetView: FC = props => const [ searchValue, setSearchValue ] = useState(''); const [ checkAll, setCheckAll ] = useState(false); const [ checkedIds, setCheckedIds ] = useState([]); - const canSeeId = useHasRankLevel(STAFF_LEVELS.MOD); + const canSeeId = useHasPermission('acc_supporttool'); const ownerNames = useMemo(() => { diff --git a/src/components/room/widgets/furniture/FurnitureMannequinView.tsx b/src/components/room/widgets/furniture/FurnitureMannequinView.tsx index 2276531..2418c6b 100644 --- a/src/components/room/widgets/furniture/FurnitureMannequinView.tsx +++ b/src/components/room/widgets/furniture/FurnitureMannequinView.tsx @@ -1,8 +1,8 @@ import { GetAvatarRenderManager, GetSessionDataManager, HabboClubLevelEnum, RoomControllerLevel } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; -import { GetClubMemberLevel, GetRoomSession, LocalizeText, MannequinUtilities, STAFF_LEVELS } from '../../../../api'; +import { GetClubMemberLevel, GetRoomSession, LocalizeText, MannequinUtilities } from '../../../../api'; import { Button, Column, LayoutAvatarImageView, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; -import { useFurnitureMannequinWidget, useHasRankLevel } from '../../../../hooks'; +import { useFurnitureMannequinWidget, useHasPermission } from '../../../../hooks'; import { NitroInput } from '../../../../layout'; const MODE_NONE: number = -1; @@ -17,7 +17,7 @@ export const FurnitureMannequinView: FC<{}> = props => const [ renderedFigure, setRenderedFigure ] = useState(null); const [ mode, setMode ] = useState(MODE_NONE); const { objectId = -1, figure = null, gender = null, clubLevel = HabboClubLevelEnum.NO_CLUB, name = null, setName = null, saveFigure = null, wearFigure = null, saveName = null, onClose = null } = useFurnitureMannequinWidget(); - const isModerator = useHasRankLevel(STAFF_LEVELS.MOD); + const canManageAnyRoom = useHasPermission('acc_anyroomowner'); useEffect(() => { @@ -25,7 +25,7 @@ export const FurnitureMannequinView: FC<{}> = props => const roomSession = GetRoomSession(); - if(roomSession.isRoomOwner || (roomSession.controllerLevel >= RoomControllerLevel.GUEST) || isModerator) + if(roomSession.isRoomOwner || (roomSession.controllerLevel >= RoomControllerLevel.GUEST) || canManageAnyRoom) { setMode(MODE_CONTROLLER); @@ -47,7 +47,7 @@ export const FurnitureMannequinView: FC<{}> = props => } setMode(MODE_PEER); - }, [ objectId, gender, clubLevel, isModerator ]); + }, [ objectId, gender, clubLevel, canManageAnyRoom ]); useEffect(() => { diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 74eb789..9b492c7 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -1,9 +1,9 @@ import { CreateLinkEvent, Dispose, DropBounce, EaseOut, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer'; import { AnimatePresence, motion, Variants } from 'framer-motion'; import { FC, useEffect, useState } from 'react'; -import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, STAFF_LEVELS, VisitDesktop } from '../../api'; +import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api'; import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; -import { useAchievements, useFriends, useHasRankLevel, useInventoryUnseenTracker, useMessageEvent, useMessenger, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks'; +import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks'; import { ToolbarItemView } from './ToolbarItemView'; import { ToolbarMeView } from './ToolbarMeView'; import { YouTubePlayerView } from './YouTubePlayerView'; @@ -49,7 +49,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => const { requests = [] } = useFriends(); const { iconState = MessengerIconState.HIDDEN } = useMessenger(); const { openMonitor, showToolbarButton } = useWiredTools(); - const isMod = useHasRankLevel(STAFF_LEVELS.MOD); + const isMod = useHasPermission('acc_supporttool'); const isVisible = (isToolbarOpen || !isInRoom); const visibilityVariant = isVisible ? 'visible' : 'hidden'; diff --git a/src/components/toolbar/YouTubePlayerView.tsx b/src/components/toolbar/YouTubePlayerView.tsx index a733090..e4c986a 100644 --- a/src/components/toolbar/YouTubePlayerView.tsx +++ b/src/components/toolbar/YouTubePlayerView.tsx @@ -1,9 +1,9 @@ import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomSettingsEvent, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useRef, useState } from 'react'; import ReactPlayer from 'react-player/youtube'; -import { GetRoomSession, getYoutubeRoomEnabled, LocalizeText, SendMessageComposer, STAFF_LEVELS, YoutubeVideoPlaybackStateEnum } from '../../api'; +import { GetRoomSession, getYoutubeRoomEnabled, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } from '../../common'; -import { useFurnitureYoutubeWidget, useHasRankLevel, useMessageEvent } from '../../hooks'; +import { useFurnitureYoutubeWidget, useHasPermission, useMessageEvent } from '../../hooks'; const CONTROL_COMMAND_PREVIOUS_VIDEO = 0; const CONTROL_COMMAND_NEXT_VIDEO = 1; @@ -48,7 +48,7 @@ export const YouTubePlayerView: FC<{}> = () => const [youtubeEnabled, setYoutubeEnabled] = useState(getYoutubeRoomEnabled()); // Reactive — must sit above the `if (!isOpen) return null` below // so the hook order stays stable across renders. - const isModerator = useHasRankLevel(STAFF_LEVELS.MOD); + const isModerator = useHasPermission('acc_anyroomowner'); useMessageEvent(YouTubeRoomSettingsEvent, event => { diff --git a/src/hooks/session/useSessionSnapshots.test.tsx b/src/hooks/session/useSessionSnapshots.test.tsx index 61b70cd..1905fd8 100644 --- a/src/hooks/session/useSessionSnapshots.test.tsx +++ b/src/hooks/session/useSessionSnapshots.test.tsx @@ -5,7 +5,7 @@ import { Component, ReactNode, useSyncExternalStore } from 'react'; import { useBetween } from 'use-between'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GetEventDispatcher, GetSessionDataManager } from '../../nitro-renderer.mock'; -import { useHasRankLevel, useIsRank, useUserRank } from './useSessionSnapshots'; +import { useHasPermission, usePermissionValue, useUserPermissions, useUserRank } from './useSessionSnapshots'; // Regression guard for the rolled-back snapshot-consumer migration. // @@ -110,10 +110,15 @@ describe('use-between + useSyncExternalStore incompatibility', () => }); // ============================================================================ -// useHasRankLevel / useIsRank / useUserRank — reactive flip on snapshot -// invalidation, tied to the permission_ranks DB table (rankId / rankName / -// rankBadge / rankPrefix / rankPrefixColor are mirrored on the wire by -// the extended UserPermissionsComposer in Arcturus ≥ 4.2.10). +// Permission-driven API — useHasPermission / usePermissionValue / +// useUserPermissions / useUserRank (display). +// +// Wire-fed by Arcturus' UserPermissionsMapComposer (resolved against +// permission_definitions for the user's rank) + the legacy +// UserPermissionsComposer (clubLevel/securityLevel/isAmbassador + rank +// metadata extension). The renderer's SessionDataManager keeps two +// snapshots: userDataSnapshot (display info) and permissionsSnapshot +// (gating). Tests fake both sides. // ============================================================================ const makeFakeDispatcher = () => @@ -139,7 +144,7 @@ const makeFakeDispatcher = () => }; }; -interface FakeSnapshot +interface FakeUserSnapshot { securityLevel: number; rankId: number; @@ -149,7 +154,7 @@ interface FakeSnapshot rankPrefixColor: string; } -const makeSnapshot = (overrides: Partial = {}): FakeSnapshot => ({ +const makeUserSnapshot = (overrides: Partial = {}): FakeUserSnapshot => ({ securityLevel: 0, rankId: 0, rankName: '', @@ -159,18 +164,21 @@ const makeSnapshot = (overrides: Partial = {}): FakeSnapshot => ({ ...overrides }); -describe('useHasRankLevel + useIsRank + useUserRank', () => +describe('useHasPermission + usePermissionValue + useUserPermissions', () => { - let snapshot: FakeSnapshot; + let userSnapshot: FakeUserSnapshot; + let permissionsSnapshot: ReadonlyMap; let fakeDispatcher: ReturnType; beforeEach(() => { - snapshot = makeSnapshot(); + userSnapshot = makeUserSnapshot(); + permissionsSnapshot = new Map(); fakeDispatcher = makeFakeDispatcher(); vi.mocked(GetSessionDataManager).mockReturnValue({ - getUserDataSnapshot: () => snapshot + getUserDataSnapshot: () => userSnapshot, + getPermissionsSnapshot: () => permissionsSnapshot } as any); vi.mocked(GetEventDispatcher).mockReturnValue(fakeDispatcher as any); @@ -183,9 +191,9 @@ describe('useHasRankLevel + useIsRank + useUserRank', () => vi.mocked(GetEventDispatcher).mockReset(); }); - it('useUserRank surfaces the full rank metadata from the snapshot', () => + it('useUserRank surfaces rank metadata for presentational use', () => { - snapshot = makeSnapshot({ + userSnapshot = makeUserSnapshot({ securityLevel: 5, rankId: 5, rankName: 'Moderator', @@ -206,36 +214,57 @@ describe('useHasRankLevel + useIsRank + useUserRank', () => }); }); - it('useHasRankLevel compares >= the threshold (5=Mod, 7=Admin in default seed)', () => + it('useHasPermission returns true for any non-zero value, false for absent/zero', () => { - snapshot = makeSnapshot({ securityLevel: 5 }); - expect(renderHook(() => useHasRankLevel(5)).result.current).toBe(true); - expect(renderHook(() => useHasRankLevel(6)).result.current).toBe(false); - expect(renderHook(() => useHasRankLevel(7)).result.current).toBe(false); + permissionsSnapshot = new Map([ + [ 'acc_supporttool', 1 ], + [ 'acc_anyroomowner', 2 ], + [ 'acc_closedice_room', 0 ] + ]); + + expect(renderHook(() => useHasPermission('acc_supporttool')).result.current).toBe(true); + expect(renderHook(() => useHasPermission('acc_anyroomowner')).result.current).toBe(true); + expect(renderHook(() => useHasPermission('acc_closedice_room')).result.current).toBe(false); + expect(renderHook(() => useHasPermission('acc_unknown_key')).result.current).toBe(false); }); - it('useIsRank matches the exact rank_name from permission_ranks', () => + it('usePermissionValue returns the raw integer (or 0 if absent)', () => { - snapshot = makeSnapshot({ rankName: 'Moderator' }); - expect(renderHook(() => useIsRank('Moderator')).result.current).toBe(true); - expect(renderHook(() => useIsRank('Super Mod')).result.current).toBe(false); - expect(renderHook(() => useIsRank('Administrator')).result.current).toBe(false); + permissionsSnapshot = new Map([ + [ 'acc_supporttool', 1 ], + [ 'acc_anyroomowner', 2 ] + ]); + + expect(renderHook(() => usePermissionValue('acc_supporttool')).result.current).toBe(1); + expect(renderHook(() => usePermissionValue('acc_anyroomowner')).result.current).toBe(2); + expect(renderHook(() => usePermissionValue('acc_missing')).result.current).toBe(0); }); - it('re-renders when SESSION_DATA_UPDATED fires after a runtime promote', () => + it('useUserPermissions exposes the full map', () => { - snapshot = makeSnapshot({ securityLevel: 1, rankName: 'Member' }); - const { result } = renderHook(() => useHasRankLevel(5)); + permissionsSnapshot = new Map([ [ 'acc_supporttool', 1 ], [ 'acc_ambassador', 1 ] ]); + + const { result } = renderHook(() => useUserPermissions()); + + expect(result.current.size).toBe(2); + expect(result.current.get('acc_supporttool')).toBe(1); + expect(result.current.get('acc_ambassador')).toBe(1); + }); + + it('re-renders when USER_PERMISSIONS_UPDATED fires after a runtime promote', () => + { + permissionsSnapshot = new Map(); + const { result } = renderHook(() => useHasPermission('acc_supporttool')); expect(result.current).toBe(false); act(() => { // Renderer invariant: every invalidation produces a NEW - // frozen snapshot object. The mock's NitroEventType proxy - // resolves any property to `mock:NitroEventType:`, so - // that's the wire string useSessionSnapshots subscribes against. - snapshot = makeSnapshot({ securityLevel: 5, rankName: 'Moderator' }); - fakeDispatcher.dispatch('mock:NitroEventType:SESSION_DATA_UPDATED'); + // map reference. The mock's NitroEventType proxy resolves + // any property to `mock:NitroEventType:`, so that's + // the wire string useSessionSnapshots subscribes against. + permissionsSnapshot = new Map([ [ 'acc_supporttool', 1 ] ]); + fakeDispatcher.dispatch('mock:NitroEventType:USER_PERMISSIONS_UPDATED'); }); expect(result.current).toBe(true); diff --git a/src/hooks/session/useSessionSnapshots.ts b/src/hooks/session/useSessionSnapshots.ts index a1121e1..b512403 100644 --- a/src/hooks/session/useSessionSnapshots.ts +++ b/src/hooks/session/useSessionSnapshots.ts @@ -59,6 +59,7 @@ const DEFAULT_USER_DATA: Readonly = Object.freeze({ const EMPTY_IGNORED_LIST: ReadonlyArray = Object.freeze([]) as ReadonlyArray; const EMPTY_GROUP_BADGES: ReadonlyMap = new Map(); const EMPTY_USER_LIST: ReadonlyArray = Object.freeze([]) as ReadonlyArray; +const EMPTY_PERMISSIONS: ReadonlyMap = new Map(); const DEFAULT_VOLUMES: Readonly = Object.freeze({ system: 0.5, @@ -129,14 +130,14 @@ export const useIsUserIgnored = (name: string): boolean => }; /** - * Reactive view of the current user's rank, mirrored from the - * `permission_ranks` table via the extended `UserPermissionsComposer` - * wire (Arcturus-Morningstar-Extended ≥ 4.2.10). Use this in UI code - * that needs to display rank metadata (badge, prefix, prefix color) - * or to gate behaviour on the actual deployment rank rather than the - * generic SecurityLevel constants the renderer exposes — those don't - * line up with the rank names operators actually use ("Moderator", - * "Super Mod", "Administrator", …). + * Reactive view of the current user's rank metadata — name, badge, + * prefix, prefix color — mirrored from `permission_ranks` via the + * extended `UserPermissionsComposer` wire (Arcturus ≥ 4.2.10). Use + * this in PRESENTATIONAL code only (chat prefix coloring, badge in + * the avatar overlay, "rank" line in the user profile). DO NOT use + * it for gating UI capabilities: prefer the permission-based family + * (`useHasPermission(key)`) below, which is dynamic against + * `permission_definitions` and survives rank renumbering. */ export interface IUserRank { @@ -163,31 +164,69 @@ export const useUserRank = (): IUserRank => }; /** - * Reactive predicate: does the current user's rank level satisfy - * `>= minLevel`? Use this when you want "at least " semantics - * and have the rank id from your deployment's `permission_ranks` - * table (e.g. 5 for Moderator in the default seed). Replaces the - * older `useHasSecurityLevel` (same wire data, renamed to match the - * DB table semantics). + * Resolved permission map for the current user, mirroring + * `permission_definitions` filtered to the user's rank. Backed by + * `SessionDataManager.getPermissionsSnapshot()` and invalidated by + * `USER_PERMISSIONS_UPDATED` (Arcturus dispatches the underlying + * packet at login + after every `setRank`). + * + * Values: 1 = ALLOWED, 2 = ROOM_OWNER (legacy gate that requires + * the user to also be the room owner). Absent key = DISALLOWED. + * + * Empty Map when the connected emulator doesn't ship the extension + * (older deployments) — `useHasPermission` then returns false for + * every key, which hides mod-only UI by default (safe). */ -export const useHasRankLevel = (minLevel: number): boolean => - useUserDataSnapshot().securityLevel >= minLevel; +export const useUserPermissions = (): ReadonlyMap => + useExternalSnapshot( + subscribeTo(NitroEventType.USER_PERMISSIONS_UPDATED), + () => + { + const manager = GetSessionDataManager(); + + if(!manager || typeof manager.getPermissionsSnapshot !== 'function') return EMPTY_PERMISSIONS; + + return manager.getPermissionsSnapshot(); + } + ); /** - * Reactive exact-match predicate against the rank name from - * `permission_ranks.rank_name`. Prefer `useHasRankLevel(min)` when - * the gate is "this rank or higher"; reach for `useIsRank('Foo')` - * only when an action must be specific to one rank. + * Reactive predicate: does the current user have the named + * permission (ALLOWED or ROOM_OWNER)? `key` must match a row in + * `permission_definitions.permission_key` (e.g. `'acc_supporttool'`, + * `'acc_anyroomowner'`, `'acc_catalogfurni'`). Prefer this over any + * rank-based gate — it survives rank renumbering and adding new + * ranks without touching the React code. */ -export const useIsRank = (name: string): boolean => useUserDataSnapshot().rankName === name; +export const useHasPermission = (key: string): boolean => +{ + const permissions = useUserPermissions(); + + return useMemo(() => (permissions.get(key) ?? 0) > 0, [ permissions, key ]); +}; /** - * Reactive ambassador flag. Not derived from rank level — it's a - * separate boolean on the snapshot (the emulator computes it server- - * side from the `acc_ambassador` permission, which a deployment can - * grant independently of the rank hierarchy). + * Reactive raw permission value (1 = ALLOWED, 2 = ROOM_OWNER, 0 if + * absent). Useful for the handful of permissions whose + * `permission_definitions.max_value > 1` (e.g. + * `acc_closedice_room`) where the precise value matters. */ -export const useIsAmbassador = (): boolean => useUserDataSnapshot().isAmbassador; +export const usePermissionValue = (key: string): number => +{ + const permissions = useUserPermissions(); + + return useMemo(() => permissions.get(key) ?? 0, [ permissions, key ]); +}; + +/** + * Reactive ambassador flag. Alias of + * `useHasPermission('acc_ambassador')` — the snapshot also carries + * an explicit `isAmbassador` boolean (legacy + * `UserPermissionsComposer` field), but routing it through the + * permission map keeps a single source of truth for runtime + * promote/demote. + */ +export const useIsAmbassador = (): boolean => useHasPermission('acc_ambassador'); export const useGroupBadgesSnapshot = (): ReadonlyMap => useExternalSnapshot(