mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
feat(hooks): rank-based API tied to permission_ranks DB table
Drop the SecurityLevel-named family (useIsModerator / useIsAdmin /
useIsCommunity / useIsPlayerSupport / useHasSecurityLevel /
useUserSecurityLevel) in favour of a rank-based family tied to the
operator's actual `permission_ranks` rows in the Arcturus DB:
- `useUserRank()` returns `{ id, name, level, badge, prefix,
prefixColor }` derived from the snapshot. Powered by the renderer's
extended IUserDataSnapshot (companion commit 87e67d5 on
feat/react19-event-bus).
- `useHasRankLevel(min)` replaces useHasSecurityLevel; consumers
pass a `permission_ranks.level` threshold from the deployment.
- `useIsRank(name)` matches `permission_ranks.rank_name` exactly.
To avoid bare integers in widget bodies, added a deployment-scoped
constants file at `src/api/nitro/session/RankLevels.ts`:
export const STAFF_LEVELS = {
MEMBER: 1, SUPPORT: 4, MOD: 5, SUPER_MOD: 6, ADMIN: 7
};
A deployment that re-numbers `permission_ranks` only edits this file.
Migrated all 11 consumer reads (same set as the earlier session's
useIsModerator migration plus the audit catch): ToolbarView,
CatalogClassicView, CatalogModernView, ChooserWidgetView,
CalendarView, YouTubePlayerView, FurniEditorView,
InfoStandWidgetFurniView, AvatarInfoWidgetPetView,
FurnitureMannequinView, NavigatorRoomInfoView. The
NavigatorRoomInfoView `staff_pick` permission was previously
`securityLevel >= COMMUNITY (7)` via the renderer-enum wrapper —
ported to `useHasRankLevel(STAFF_LEVELS.ADMIN)` because in the
default seed level 7 is Administrator, which is the actual rank that
gets the `acc_anyroomowner`-style permissions for staff-picking.
Tests refreshed under `useSessionSnapshots.test.tsx`:
- useUserRank surfaces the full metadata block;
- useHasRankLevel does `>=` against the threshold;
- useIsRank exact-matches against rank_name;
- a runtime promote (snapshot mutation + SESSION_DATA_UPDATED
dispatch) flips the result, locking in the reactive contract.
Mock extended only minimally — kept the SecurityLevel enum class for
any consumer outside the dropped family that still imports it.
Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
213/213. The Arcturus-side composer change (UserPermissionsComposer
appending the 5 extra fields) is staged but UNCOMMITTED on Arcturus
main (which has unrelated WIP); the wire is backward-compatible so
the React client works against both pre- and post-extension
emulators.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -16,5 +16,6 @@ export * from './HasHabboVip';
|
||||
export * from './IsOwnerOfFloorFurniture';
|
||||
export * from './IsOwnerOfFurniture';
|
||||
export * from './IsRidingHorse';
|
||||
export * from './RankLevels';
|
||||
export * from './StartRoomSession';
|
||||
export * from './VisitDesktop';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { CalendarItemState, ICalendarItem, LocalizeText } from '../../api';
|
||||
import { CalendarItemState, ICalendarItem, LocalizeText, STAFF_LEVELS } from '../../api';
|
||||
import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
||||
import { useIsModerator } from '../../hooks';
|
||||
import { useHasRankLevel } from '../../hooks';
|
||||
import { CalendarItemView } from './CalendarItemView';
|
||||
|
||||
interface CalendarViewProps
|
||||
@@ -23,7 +23,7 @@ export const CalendarView: FC<CalendarViewProps> = 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 = useIsModerator();
|
||||
const isModerator = useHasRankLevel(STAFF_LEVELS.MOD);
|
||||
|
||||
const getDayState = (day: number) =>
|
||||
{
|
||||
|
||||
@@ -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 } from '../../api';
|
||||
import { CatalogType, GetConfigurationValue, LocalizeText, STAFF_LEVELS } from '../../api';
|
||||
import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||
import { useCatalogActions, useCatalogData, useCatalogUiState, useIsModerator } from '../../hooks';
|
||||
import { useCatalogActions, useCatalogData, useCatalogUiState, useHasRankLevel } 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 = useIsModerator();
|
||||
const isMod = useHasRankLevel(STAFF_LEVELS.MOD);
|
||||
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
||||
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
||||
: undefined;
|
||||
|
||||
@@ -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 } from '../../api';
|
||||
import { CatalogType, LocalizeText, STAFF_LEVELS } from '../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
|
||||
import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useIsModerator } from '../../hooks';
|
||||
import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useHasRankLevel } 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 = useIsModerator();
|
||||
const isMod = useHasRankLevel(STAFF_LEVELS.MOD);
|
||||
const totalFavs = favoriteOfferIds.length + favoritePageIds.length;
|
||||
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
||||
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { useIsModerator } from '../../hooks';
|
||||
import { useHasRankLevel } from '../../hooks';
|
||||
import { useFurniEditor } from '../../hooks/furni-editor';
|
||||
import { FurniEditorEditView } from './views/FurniEditorEditView';
|
||||
import { FurniEditorSearchView } from './views/FurniEditorSearchView';
|
||||
@@ -21,7 +22,7 @@ export const FurniEditorView: FC<{}> = () =>
|
||||
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
|
||||
} = useFurniEditor();
|
||||
|
||||
const isMod = useIsModerator();
|
||||
const isMod = useHasRankLevel(STAFF_LEVELS.MOD);
|
||||
|
||||
// Auto-switch to edit tab when an item is selected
|
||||
useEffect(() =>
|
||||
|
||||
@@ -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, ToggleFavoriteRoom } from '../../../api';
|
||||
import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, STAFF_LEVELS, ToggleFavoriteRoom } from '../../../api';
|
||||
import { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common';
|
||||
import { RoomWidgetThumbnailEvent } from '../../../events';
|
||||
import { useHelp, useIsCommunity, useIsModerator, useNavigator, useRoom } from '../../../hooks';
|
||||
import { useHasRankLevel, useHelp, useNavigator, useRoom } from '../../../hooks';
|
||||
import { classNames } from '../../../layout';
|
||||
|
||||
export interface NavigatorRoomInfoViewProps {
|
||||
@@ -19,8 +19,8 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
|
||||
const { report = null } = useHelp();
|
||||
const { navigatorData = null, favouriteRoomIds = [] } = useNavigator();
|
||||
const { roomSession = null } = useRoom();
|
||||
const isModerator = useIsModerator();
|
||||
const isCommunity = useIsCommunity();
|
||||
const isModerator = useHasRankLevel(STAFF_LEVELS.MOD);
|
||||
const isAdmin = useHasRankLevel(STAFF_LEVELS.ADMIN);
|
||||
|
||||
const enteredRoomId = navigatorData?.enteredGuestRoom?.roomId ?? 0;
|
||||
|
||||
@@ -55,7 +55,7 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
|
||||
case 'settings':
|
||||
return (GetSessionDataManager().userId === navigatorData.enteredGuestRoom.ownerId || isModerator);
|
||||
case 'staff_pick':
|
||||
return isCommunity;
|
||||
return isAdmin;
|
||||
case 'floor':
|
||||
return roomSession?.controllerLevel >= RoomControllerLevel.GUEST;
|
||||
case 'guest':
|
||||
|
||||
@@ -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 } from '../../../../../api';
|
||||
import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer, STAFF_LEVELS } from '../../../../../api';
|
||||
import { Button, Column, Flex, LayoutBadgeImageView, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common';
|
||||
import { useIsModerator, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks';
|
||||
import { useHasRankLevel, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks';
|
||||
import { NitroInput } from '../../../../../layout';
|
||||
|
||||
interface InfoStandWidgetFurniViewProps
|
||||
@@ -22,7 +22,7 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
|
||||
const { avatarInfo = null, onClose = null } = props;
|
||||
const { roomSession = null } = useRoom();
|
||||
const { openInspectionForFurni, showInspectButton } = useWiredTools();
|
||||
const isModerator = useIsModerator();
|
||||
const isModerator = useHasRankLevel(STAFF_LEVELS.MOD);
|
||||
|
||||
const [ pickupMode, setPickupMode ] = useState(0);
|
||||
const [ canMove, setCanMove ] = useState(false);
|
||||
|
||||
@@ -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 } from '../../../../../api';
|
||||
import { useIsModerator, useRoom, useSessionInfo } from '../../../../../hooks';
|
||||
import { AvatarInfoPet, GetOwnRoomObject, LocalizeText, SendMessageComposer, STAFF_LEVELS } from '../../../../../api';
|
||||
import { useHasRankLevel, useRoom, useSessionInfo } from '../../../../../hooks';
|
||||
import { ContextMenuHeaderView } from '../../context-menu/ContextMenuHeaderView';
|
||||
import { ContextMenuListItemView } from '../../context-menu/ContextMenuListItemView';
|
||||
import { ContextMenuView } from '../../context-menu/ContextMenuView';
|
||||
@@ -23,7 +23,7 @@ export const AvatarInfoWidgetPetView: FC<AvatarInfoWidgetPetViewProps> = props =
|
||||
const [ mode, setMode ] = useState(MODE_NORMAL);
|
||||
const { roomSession = null, isHandItemBlocked = false } = useRoom();
|
||||
const { petRespectRemaining = 0, respectPet = null } = useSessionInfo();
|
||||
const isModerator = useIsModerator();
|
||||
const isModerator = useHasRankLevel(STAFF_LEVELS.MOD);
|
||||
|
||||
const canPickUp = useMemo(() =>
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FurniturePickupAllComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useEffectEvent, useMemo, useState } from 'react';
|
||||
import { LocalizeText, RoomObjectItem, SendMessageComposer, chooserSelectionVisualizer } from '../../../../api';
|
||||
import { LocalizeText, RoomObjectItem, SendMessageComposer, STAFF_LEVELS, chooserSelectionVisualizer } from '../../../../api';
|
||||
import { Button, Flex, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
||||
import { useIsModerator } from '../../../../hooks';
|
||||
import { useHasRankLevel } from '../../../../hooks';
|
||||
import { NitroInput, classNames } from '../../../../layout';
|
||||
|
||||
const LIMIT_FURNI_PICKALL = 100;
|
||||
@@ -24,7 +24,7 @@ export const ChooserWidgetView: FC<ChooserWidgetViewProps> = props =>
|
||||
const [ searchValue, setSearchValue ] = useState('');
|
||||
const [ checkAll, setCheckAll ] = useState(false);
|
||||
const [ checkedIds, setCheckedIds ] = useState<number[]>([]);
|
||||
const canSeeId = useIsModerator();
|
||||
const canSeeId = useHasRankLevel(STAFF_LEVELS.MOD);
|
||||
|
||||
const ownerNames = useMemo(() =>
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { GetAvatarRenderManager, GetSessionDataManager, HabboClubLevelEnum, RoomControllerLevel } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { GetClubMemberLevel, GetRoomSession, LocalizeText, MannequinUtilities } from '../../../../api';
|
||||
import { GetClubMemberLevel, GetRoomSession, LocalizeText, MannequinUtilities, STAFF_LEVELS } from '../../../../api';
|
||||
import { Button, Column, LayoutAvatarImageView, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
||||
import { useFurnitureMannequinWidget, useIsModerator } from '../../../../hooks';
|
||||
import { useFurnitureMannequinWidget, useHasRankLevel } from '../../../../hooks';
|
||||
import { NitroInput } from '../../../../layout';
|
||||
|
||||
const MODE_NONE: number = -1;
|
||||
@@ -17,7 +17,7 @@ export const FurnitureMannequinView: FC<{}> = props =>
|
||||
const [ renderedFigure, setRenderedFigure ] = useState<string>(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 = useIsModerator();
|
||||
const isModerator = useHasRankLevel(STAFF_LEVELS.MOD);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
||||
@@ -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, VisitDesktop } from '../../api';
|
||||
import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, STAFF_LEVELS, VisitDesktop } from '../../api';
|
||||
import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common';
|
||||
import { useAchievements, useFriends, useInventoryUnseenTracker, useIsModerator, useMessageEvent, useMessenger, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks';
|
||||
import { useAchievements, useFriends, useHasRankLevel, 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 = useIsModerator();
|
||||
const isMod = useHasRankLevel(STAFF_LEVELS.MOD);
|
||||
const isVisible = (isToolbarOpen || !isInRoom);
|
||||
const visibilityVariant = isVisible ? 'visible' : 'hidden';
|
||||
|
||||
|
||||
@@ -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, YoutubeVideoPlaybackStateEnum } from '../../api';
|
||||
import { GetRoomSession, getYoutubeRoomEnabled, LocalizeText, SendMessageComposer, STAFF_LEVELS, YoutubeVideoPlaybackStateEnum } from '../../api';
|
||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } from '../../common';
|
||||
import { useFurnitureYoutubeWidget, useIsModerator, useMessageEvent } from '../../hooks';
|
||||
import { useFurnitureYoutubeWidget, useHasRankLevel, 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 = useIsModerator();
|
||||
const isModerator = useHasRankLevel(STAFF_LEVELS.MOD);
|
||||
|
||||
useMessageEvent<YouTubeRoomSettingsEvent>(YouTubeRoomSettingsEvent, event =>
|
||||
{
|
||||
|
||||
@@ -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 { useHasSecurityLevel, useIsAdmin, useIsCommunity, useIsModerator, useUserSecurityLevel } from './useSessionSnapshots';
|
||||
import { useHasRankLevel, useIsRank, useUserRank } from './useSessionSnapshots';
|
||||
|
||||
// Regression guard for the rolled-back snapshot-consumer migration.
|
||||
//
|
||||
@@ -110,15 +110,11 @@ describe('use-between + useSyncExternalStore incompatibility', () =>
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// useHasSecurityLevel + named wrappers — reactive flip on snapshot invalidation
|
||||
// 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).
|
||||
// ============================================================================
|
||||
//
|
||||
// The family hangs off useUserDataSnapshot() which is a useSyncExternalStore
|
||||
// wrapper. The renderer's real SessionDataManager pushes a frozen snapshot
|
||||
// out of getUserDataSnapshot() and dispatches a SESSION_DATA_UPDATED event
|
||||
// whenever a mutator invalidates the cache. These tests fake both sides:
|
||||
// a mock dispatcher with a real .subscribe(), and a mock SessionDataManager
|
||||
// whose snapshot can be mutated between dispatches.
|
||||
|
||||
const makeFakeDispatcher = () =>
|
||||
{
|
||||
@@ -143,20 +139,37 @@ const makeFakeDispatcher = () =>
|
||||
};
|
||||
};
|
||||
|
||||
describe('useHasSecurityLevel + named wrappers', () =>
|
||||
interface FakeSnapshot
|
||||
{
|
||||
let snapshot: { securityLevel: number };
|
||||
securityLevel: number;
|
||||
rankId: number;
|
||||
rankName: string;
|
||||
rankBadge: string;
|
||||
rankPrefix: string;
|
||||
rankPrefixColor: string;
|
||||
}
|
||||
|
||||
const makeSnapshot = (overrides: Partial<FakeSnapshot> = {}): FakeSnapshot => ({
|
||||
securityLevel: 0,
|
||||
rankId: 0,
|
||||
rankName: '',
|
||||
rankBadge: '',
|
||||
rankPrefix: '',
|
||||
rankPrefixColor: '',
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('useHasRankLevel + useIsRank + useUserRank', () =>
|
||||
{
|
||||
let snapshot: FakeSnapshot;
|
||||
let fakeDispatcher: ReturnType<typeof makeFakeDispatcher>;
|
||||
|
||||
beforeEach(() =>
|
||||
{
|
||||
snapshot = { securityLevel: 0 };
|
||||
snapshot = makeSnapshot();
|
||||
fakeDispatcher = makeFakeDispatcher();
|
||||
|
||||
vi.mocked(GetSessionDataManager).mockReturnValue({
|
||||
// useSessionSnapshots reads getUserDataSnapshot() and guards on
|
||||
// `typeof manager.getUserDataSnapshot !== 'function'`, so we
|
||||
// expose it as a real function returning the mutable test snapshot.
|
||||
getUserDataSnapshot: () => snapshot
|
||||
} as any);
|
||||
|
||||
@@ -170,48 +183,58 @@ describe('useHasSecurityLevel + named wrappers', () =>
|
||||
vi.mocked(GetEventDispatcher).mockReset();
|
||||
});
|
||||
|
||||
it('useUserSecurityLevel reads the raw level', () =>
|
||||
it('useUserRank surfaces the full rank metadata from the snapshot', () =>
|
||||
{
|
||||
snapshot = { securityLevel: 7 };
|
||||
const { result } = renderHook(() => useUserSecurityLevel());
|
||||
expect(result.current).toBe(7);
|
||||
snapshot = makeSnapshot({
|
||||
securityLevel: 5,
|
||||
rankId: 5,
|
||||
rankName: 'Moderator',
|
||||
rankBadge: 'ADM',
|
||||
rankPrefix: '[MOD]',
|
||||
rankPrefixColor: '#327fa8'
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserRank());
|
||||
|
||||
expect(result.current).toEqual({
|
||||
id: 5,
|
||||
name: 'Moderator',
|
||||
level: 5,
|
||||
badge: 'ADM',
|
||||
prefix: '[MOD]',
|
||||
prefixColor: '#327fa8'
|
||||
});
|
||||
});
|
||||
|
||||
it('useHasSecurityLevel compares >= the threshold', () =>
|
||||
it('useHasRankLevel compares >= the threshold (5=Mod, 7=Admin in default seed)', () =>
|
||||
{
|
||||
snapshot = { securityLevel: 5 };
|
||||
const { result } = renderHook(() => useHasSecurityLevel(5));
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
const { result: lowResult } = renderHook(() => useHasSecurityLevel(8));
|
||||
expect(lowResult.current).toBe(false);
|
||||
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);
|
||||
});
|
||||
|
||||
it('named wrappers map to the right thresholds (MODERATOR=5, COMMUNITY=7, ADMINISTRATOR=8)', () =>
|
||||
it('useIsRank matches the exact rank_name from permission_ranks', () =>
|
||||
{
|
||||
snapshot = { securityLevel: 7 }; // COMMUNITY
|
||||
|
||||
expect(renderHook(() => useIsModerator()).result.current).toBe(true); // 7 >= 5
|
||||
expect(renderHook(() => useIsCommunity()).result.current).toBe(true); // 7 >= 7
|
||||
expect(renderHook(() => useIsAdmin()).result.current).toBe(false); // 7 < 8
|
||||
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);
|
||||
});
|
||||
|
||||
it('re-renders when SESSION_DATA_UPDATED fires after the snapshot mutates', () =>
|
||||
it('re-renders when SESSION_DATA_UPDATED fires after a runtime promote', () =>
|
||||
{
|
||||
snapshot = { securityLevel: 0 };
|
||||
const { result } = renderHook(() => useIsModerator());
|
||||
snapshot = makeSnapshot({ securityLevel: 1, rankName: 'Member' });
|
||||
const { result } = renderHook(() => useHasRankLevel(5));
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Mutate the snapshot reference (renderer invariant: every
|
||||
// invalidation produces a NEW frozen object) and dispatch the
|
||||
// event. The hook's getSnapshot closure reads `snapshot`, so a
|
||||
// fresh object reference flips React's bailout.
|
||||
act(() =>
|
||||
{
|
||||
snapshot = { securityLevel: 5 };
|
||||
// The mock's NitroEventType proxy resolves any property to
|
||||
// `mock:NitroEventType:<PROP>`, so that's the wire string
|
||||
// useSessionSnapshots subscribes against.
|
||||
// Renderer invariant: every invalidation produces a NEW
|
||||
// frozen snapshot object. The mock's NitroEventType proxy
|
||||
// resolves any property to `mock:NitroEventType:<PROP>`, so
|
||||
// that's the wire string useSessionSnapshots subscribes against.
|
||||
snapshot = makeSnapshot({ securityLevel: 5, rankName: 'Moderator' });
|
||||
fakeDispatcher.dispatch('mock:NitroEventType:SESSION_DATA_UPDATED');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GetEventDispatcher, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, IRoomSessionSnapshot, IRoomUserData, ISoundVolumesSnapshot, IUserDataSnapshot, NitroEventType, SecurityLevel } from '@nitrots/nitro-renderer';
|
||||
import { GetEventDispatcher, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, IRoomSessionSnapshot, IRoomUserData, ISoundVolumesSnapshot, IUserDataSnapshot, NitroEventType } from '@nitrots/nitro-renderer';
|
||||
import { useMemo } from 'react';
|
||||
import { useExternalSnapshot } from '../events/useExternalSnapshot';
|
||||
|
||||
@@ -48,7 +48,12 @@ const DEFAULT_USER_DATA: Readonly<IUserDataSnapshot> = Object.freeze({
|
||||
isSystemOpen: false,
|
||||
isSystemShutdown: false,
|
||||
uiFlags: 0,
|
||||
tags: Object.freeze<string[]>([]) as ReadonlyArray<string>
|
||||
tags: Object.freeze<string[]>([]) as ReadonlyArray<string>,
|
||||
rankId: 0,
|
||||
rankName: '',
|
||||
rankBadge: '',
|
||||
rankPrefix: '',
|
||||
rankPrefixColor: ''
|
||||
}) as Readonly<IUserDataSnapshot>;
|
||||
|
||||
const EMPTY_IGNORED_LIST: ReadonlyArray<string> = Object.freeze<string[]>([]) as ReadonlyArray<string>;
|
||||
@@ -124,36 +129,63 @@ export const useIsUserIgnored = (name: string): boolean =>
|
||||
};
|
||||
|
||||
/**
|
||||
* Reactive raw security level from the user snapshot. Use this when
|
||||
* you need the numeric level (e.g. to compare against a threshold not
|
||||
* covered by the named wrappers below); for the common case of "is
|
||||
* the user at least <X>?", prefer the matching `useIsXxx` predicate.
|
||||
* 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", …).
|
||||
*/
|
||||
export const useUserSecurityLevel = (): number => useUserDataSnapshot().securityLevel;
|
||||
export interface IUserRank
|
||||
{
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly level: number;
|
||||
readonly badge: string;
|
||||
readonly prefix: string;
|
||||
readonly prefixColor: string;
|
||||
}
|
||||
|
||||
export const useUserRank = (): IUserRank =>
|
||||
{
|
||||
const userData = useUserDataSnapshot();
|
||||
|
||||
return useMemo<IUserRank>(() => ({
|
||||
id: userData.rankId,
|
||||
name: userData.rankName,
|
||||
level: userData.securityLevel,
|
||||
badge: userData.rankBadge,
|
||||
prefix: userData.rankPrefix,
|
||||
prefixColor: userData.rankPrefixColor
|
||||
}), [ userData.rankId, userData.rankName, userData.securityLevel, userData.rankBadge, userData.rankPrefix, userData.rankPrefixColor ]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reactive predicate: does the current user's security level satisfy
|
||||
* `>= minLevel`? Mirrors the renderer-side comparison used by
|
||||
* `SessionDataManager.isModerator` (and its peers) and propagates the
|
||||
* SESSION_DATA_UPDATED invalidation, so a runtime promote/demote
|
||||
* re-renders the consumer.
|
||||
*
|
||||
* The named wrappers below (`useIsModerator`, `useIsAdmin`, …) are
|
||||
* one-line shims over this primitive — use them in widget bodies for
|
||||
* readability; reach for `useHasSecurityLevel(level)` directly only
|
||||
* when the threshold is dynamic or not covered by a named wrapper.
|
||||
* Reactive predicate: does the current user's rank level satisfy
|
||||
* `>= minLevel`? Use this when you want "at least <rank>" 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).
|
||||
*/
|
||||
export const useHasSecurityLevel = (minLevel: number): boolean =>
|
||||
useUserSecurityLevel() >= minLevel;
|
||||
|
||||
export const useIsModerator = (): boolean => useHasSecurityLevel(SecurityLevel.MODERATOR);
|
||||
export const useIsPlayerSupport = (): boolean => useHasSecurityLevel(SecurityLevel.PLAYER_SUPPORT);
|
||||
export const useIsCommunity = (): boolean => useHasSecurityLevel(SecurityLevel.COMMUNITY);
|
||||
export const useIsAdmin = (): boolean => useHasSecurityLevel(SecurityLevel.ADMINISTRATOR);
|
||||
export const useHasRankLevel = (minLevel: number): boolean =>
|
||||
useUserDataSnapshot().securityLevel >= minLevel;
|
||||
|
||||
/**
|
||||
* Reactive ambassador flag. Not derived from security level — it's a
|
||||
* separate boolean on the snapshot.
|
||||
* 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.
|
||||
*/
|
||||
export const useIsRank = (name: string): boolean => useUserDataSnapshot().rankName === name;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export const useIsAmbassador = (): boolean => useUserDataSnapshot().isAmbassador;
|
||||
|
||||
|
||||
@@ -124,9 +124,9 @@ export class RoomControllerLevel
|
||||
static readonly MODERATOR = 5;
|
||||
}
|
||||
|
||||
// Mirrors `packages/api/src/nitro/session/enum/SecurityLevel.ts`. Tests
|
||||
// that gate behaviour on `securityLevel >= SecurityLevel.MODERATOR` (via
|
||||
// useIsModerator / useHasSecurityLevel) depend on these constants.
|
||||
// Mirrors `packages/api/src/nitro/session/enum/SecurityLevel.ts`. Kept
|
||||
// around so any consumer that still imports the renderer enum
|
||||
// (non-deprecated code path) compiles cleanly under the mock.
|
||||
export class SecurityLevel
|
||||
{
|
||||
static readonly NONE = 0;
|
||||
|
||||
Reference in New Issue
Block a user