mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 15:36:18 +00:00
feat(hooks): permission-driven gating via useHasPermission
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<string, number> — 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).
This commit is contained in:
@@ -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;
|
|
||||||
@@ -16,6 +16,5 @@ export * from './HasHabboVip';
|
|||||||
export * from './IsOwnerOfFloorFurniture';
|
export * from './IsOwnerOfFloorFurniture';
|
||||||
export * from './IsOwnerOfFurniture';
|
export * from './IsOwnerOfFurniture';
|
||||||
export * from './IsRidingHorse';
|
export * from './IsRidingHorse';
|
||||||
export * from './RankLevels';
|
|
||||||
export * from './StartRoomSession';
|
export * from './StartRoomSession';
|
||||||
export * from './VisitDesktop';
|
export * from './VisitDesktop';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FC, useState } from 'react';
|
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 { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
|
||||||
import { useHasRankLevel } from '../../hooks';
|
import { useHasPermission } from '../../hooks';
|
||||||
import { CalendarItemView } from './CalendarItemView';
|
import { CalendarItemView } from './CalendarItemView';
|
||||||
|
|
||||||
interface CalendarViewProps
|
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 { onClose = null, campaignName = null, currentDay = null, numDays = null, missedDays = null, openedDays = null, openPackage = null, receivedProducts = null } = props;
|
||||||
const [ selectedDay, setSelectedDay ] = useState(currentDay);
|
const [ selectedDay, setSelectedDay ] = useState(currentDay);
|
||||||
const [ index, setIndex ] = useState(Math.max(0, (selectedDay - 1)));
|
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) =>
|
const getDayState = (day: number) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
|
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 { 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 { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
|
||||||
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
|
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
|
||||||
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
|
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
|
||||||
@@ -28,7 +28,7 @@ const CatalogClassicViewInner: FC<{}> = () =>
|
|||||||
{});
|
{});
|
||||||
const loading = catalogAdmin?.loading ?? false;
|
const loading = catalogAdmin?.loading ?? false;
|
||||||
|
|
||||||
const isMod = useHasRankLevel(STAFF_LEVELS.MOD);
|
const isMod = useHasPermission('acc_catalogfurni');
|
||||||
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
||||||
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
|
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 { 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 { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
|
||||||
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
|
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
|
||||||
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
|
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
|
||||||
@@ -32,7 +32,7 @@ const CatalogModernViewInner: FC<{}> = () =>
|
|||||||
const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites();
|
const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites();
|
||||||
const [ showFavorites, setShowFavorites ] = useState(false);
|
const [ showFavorites, setShowFavorites ] = useState(false);
|
||||||
|
|
||||||
const isMod = useHasRankLevel(STAFF_LEVELS.MOD);
|
const isMod = useHasPermission('acc_catalogfurni');
|
||||||
const totalFavs = favoriteOfferIds.length + favoritePageIds.length;
|
const totalFavs = favoriteOfferIds.length + favoritePageIds.length;
|
||||||
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
|
||||||
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useCallback, useEffect, useState } from 'react';
|
import { FC, useCallback, useEffect, useState } from 'react';
|
||||||
import { STAFF_LEVELS } from '../../api';
|
|
||||||
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
|
||||||
import { useHasRankLevel } from '../../hooks';
|
import { useHasPermission } from '../../hooks';
|
||||||
import { useFurniEditor } from '../../hooks/furni-editor';
|
import { useFurniEditor } from '../../hooks/furni-editor';
|
||||||
import { FurniEditorEditView } from './views/FurniEditorEditView';
|
import { FurniEditorEditView } from './views/FurniEditorEditView';
|
||||||
import { FurniEditorSearchView } from './views/FurniEditorSearchView';
|
import { FurniEditorSearchView } from './views/FurniEditorSearchView';
|
||||||
@@ -22,7 +21,7 @@ export const FurniEditorView: FC<{}> = () =>
|
|||||||
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
|
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions
|
||||||
} = useFurniEditor();
|
} = useFurniEditor();
|
||||||
|
|
||||||
const isMod = useHasRankLevel(STAFF_LEVELS.MOD);
|
const isMod = useHasPermission('acc_catalogfurni');
|
||||||
|
|
||||||
// Auto-switch to edit tab when an item is selected
|
// Auto-switch to edit tab when an item is selected
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { CreateLinkEvent, GetCustomRoomFilterMessageComposer, GetGuestRoomMessageComposer, GetSessionDataManager, NavigatorSearchComposer, RemoveOwnRoomRightsRoomMessageComposer, RoomControllerLevel, RoomMuteComposer, RoomSettingsComposer, ToggleStaffPickMessageComposer, UpdateHomeRoomMessageComposer } from '@nitrots/nitro-renderer';
|
import { CreateLinkEvent, GetCustomRoomFilterMessageComposer, GetGuestRoomMessageComposer, GetSessionDataManager, NavigatorSearchComposer, RemoveOwnRoomRightsRoomMessageComposer, RoomControllerLevel, RoomMuteComposer, RoomSettingsComposer, ToggleStaffPickMessageComposer, UpdateHomeRoomMessageComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useMemo, useState } from 'react';
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
import { FaLink, FaSignOutAlt } from 'react-icons/fa';
|
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 { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common';
|
||||||
import { RoomWidgetThumbnailEvent } from '../../../events';
|
import { RoomWidgetThumbnailEvent } from '../../../events';
|
||||||
import { useHasRankLevel, useHelp, useNavigator, useRoom } from '../../../hooks';
|
import { useHasPermission, useHelp, useNavigator, useRoom } from '../../../hooks';
|
||||||
import { classNames } from '../../../layout';
|
import { classNames } from '../../../layout';
|
||||||
|
|
||||||
export interface NavigatorRoomInfoViewProps {
|
export interface NavigatorRoomInfoViewProps {
|
||||||
@@ -19,8 +19,8 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
|
|||||||
const { report = null } = useHelp();
|
const { report = null } = useHelp();
|
||||||
const { navigatorData = null, favouriteRoomIds = [] } = useNavigator();
|
const { navigatorData = null, favouriteRoomIds = [] } = useNavigator();
|
||||||
const { roomSession = null } = useRoom();
|
const { roomSession = null } = useRoom();
|
||||||
const isModerator = useHasRankLevel(STAFF_LEVELS.MOD);
|
const canManageAnyRoom = useHasPermission('acc_anyroomowner');
|
||||||
const isAdmin = useHasRankLevel(STAFF_LEVELS.ADMIN);
|
const canStaffPick = useHasPermission('acc_staff_pick');
|
||||||
|
|
||||||
const enteredRoomId = navigatorData?.enteredGuestRoom?.roomId ?? 0;
|
const enteredRoomId = navigatorData?.enteredGuestRoom?.roomId ?? 0;
|
||||||
|
|
||||||
@@ -53,9 +53,9 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
|
|||||||
switch(permission)
|
switch(permission)
|
||||||
{
|
{
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return (GetSessionDataManager().userId === navigatorData.enteredGuestRoom.ownerId || isModerator);
|
return (GetSessionDataManager().userId === navigatorData.enteredGuestRoom.ownerId || canManageAnyRoom);
|
||||||
case 'staff_pick':
|
case 'staff_pick':
|
||||||
return isAdmin;
|
return canStaffPick;
|
||||||
case 'floor':
|
case 'floor':
|
||||||
return roomSession?.controllerLevel >= RoomControllerLevel.GUEST;
|
return roomSession?.controllerLevel >= RoomControllerLevel.GUEST;
|
||||||
case 'guest':
|
case 'guest':
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { CrackableDataType, CreateLinkEvent, FurnitureFloorUpdateEvent, GetRoomE
|
|||||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { FaCrosshairs, FaTimes } from 'react-icons/fa';
|
import { FaCrosshairs, FaTimes } from 'react-icons/fa';
|
||||||
import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr';
|
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 { 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';
|
import { NitroInput } from '../../../../../layout';
|
||||||
|
|
||||||
interface InfoStandWidgetFurniViewProps
|
interface InfoStandWidgetFurniViewProps
|
||||||
@@ -22,7 +22,7 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
|
|||||||
const { avatarInfo = null, onClose = null } = props;
|
const { avatarInfo = null, onClose = null } = props;
|
||||||
const { roomSession = null } = useRoom();
|
const { roomSession = null } = useRoom();
|
||||||
const { openInspectionForFurni, showInspectButton } = useWiredTools();
|
const { openInspectionForFurni, showInspectButton } = useWiredTools();
|
||||||
const isModerator = useHasRankLevel(STAFF_LEVELS.MOD);
|
const isModerator = useHasPermission('acc_anyroomowner');
|
||||||
|
|
||||||
const [ pickupMode, setPickupMode ] = useState(0);
|
const [ pickupMode, setPickupMode ] = useState(0);
|
||||||
const [ canMove, setCanMove ] = useState(false);
|
const [ canMove, setCanMove ] = useState(false);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PetRespectComposer, PetType, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitGiveHandItemPetComposer } from '@nitrots/nitro-renderer';
|
import { PetRespectComposer, PetType, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitGiveHandItemPetComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useMemo, useState } from 'react';
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
import { AvatarInfoPet, GetOwnRoomObject, LocalizeText, SendMessageComposer, STAFF_LEVELS } from '../../../../../api';
|
import { AvatarInfoPet, GetOwnRoomObject, LocalizeText, SendMessageComposer } from '../../../../../api';
|
||||||
import { useHasRankLevel, useRoom, useSessionInfo } from '../../../../../hooks';
|
import { useHasPermission, useRoom, useSessionInfo } from '../../../../../hooks';
|
||||||
import { ContextMenuHeaderView } from '../../context-menu/ContextMenuHeaderView';
|
import { ContextMenuHeaderView } from '../../context-menu/ContextMenuHeaderView';
|
||||||
import { ContextMenuListItemView } from '../../context-menu/ContextMenuListItemView';
|
import { ContextMenuListItemView } from '../../context-menu/ContextMenuListItemView';
|
||||||
import { ContextMenuView } from '../../context-menu/ContextMenuView';
|
import { ContextMenuView } from '../../context-menu/ContextMenuView';
|
||||||
@@ -23,12 +23,12 @@ export const AvatarInfoWidgetPetView: FC<AvatarInfoWidgetPetViewProps> = props =
|
|||||||
const [ mode, setMode ] = useState(MODE_NORMAL);
|
const [ mode, setMode ] = useState(MODE_NORMAL);
|
||||||
const { roomSession = null, isHandItemBlocked = false } = useRoom();
|
const { roomSession = null, isHandItemBlocked = false } = useRoom();
|
||||||
const { petRespectRemaining = 0, respectPet = null } = useSessionInfo();
|
const { petRespectRemaining = 0, respectPet = null } = useSessionInfo();
|
||||||
const isModerator = useHasRankLevel(STAFF_LEVELS.MOD);
|
const canManageAnyRoom = useHasPermission('acc_anyroomowner');
|
||||||
|
|
||||||
const canPickUp = useMemo(() =>
|
const canPickUp = useMemo(() =>
|
||||||
{
|
{
|
||||||
return (roomSession.isRoomOwner || (roomSession.controllerLevel >= RoomControllerLevel.GUEST) || isModerator);
|
return (roomSession.isRoomOwner || (roomSession.controllerLevel >= RoomControllerLevel.GUEST) || canManageAnyRoom);
|
||||||
}, [ roomSession, isModerator ]);
|
}, [ roomSession, canManageAnyRoom ]);
|
||||||
|
|
||||||
const canGiveHandItem = useMemo(() =>
|
const canGiveHandItem = useMemo(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { FurniturePickupAllComposer } from '@nitrots/nitro-renderer';
|
import { FurniturePickupAllComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useEffectEvent, useMemo, useState } from 'react';
|
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 { Button, Flex, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
||||||
import { useHasRankLevel } from '../../../../hooks';
|
import { useHasPermission } from '../../../../hooks';
|
||||||
import { NitroInput, classNames } from '../../../../layout';
|
import { NitroInput, classNames } from '../../../../layout';
|
||||||
|
|
||||||
const LIMIT_FURNI_PICKALL = 100;
|
const LIMIT_FURNI_PICKALL = 100;
|
||||||
@@ -24,7 +24,7 @@ export const ChooserWidgetView: FC<ChooserWidgetViewProps> = props =>
|
|||||||
const [ searchValue, setSearchValue ] = useState('');
|
const [ searchValue, setSearchValue ] = useState('');
|
||||||
const [ checkAll, setCheckAll ] = useState(false);
|
const [ checkAll, setCheckAll ] = useState(false);
|
||||||
const [ checkedIds, setCheckedIds ] = useState<number[]>([]);
|
const [ checkedIds, setCheckedIds ] = useState<number[]>([]);
|
||||||
const canSeeId = useHasRankLevel(STAFF_LEVELS.MOD);
|
const canSeeId = useHasPermission('acc_supporttool');
|
||||||
|
|
||||||
const ownerNames = useMemo(() =>
|
const ownerNames = useMemo(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { GetAvatarRenderManager, GetSessionDataManager, HabboClubLevelEnum, RoomControllerLevel } from '@nitrots/nitro-renderer';
|
import { GetAvatarRenderManager, GetSessionDataManager, HabboClubLevelEnum, RoomControllerLevel } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useState } from 'react';
|
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 { Button, Column, LayoutAvatarImageView, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
||||||
import { useFurnitureMannequinWidget, useHasRankLevel } from '../../../../hooks';
|
import { useFurnitureMannequinWidget, useHasPermission } from '../../../../hooks';
|
||||||
import { NitroInput } from '../../../../layout';
|
import { NitroInput } from '../../../../layout';
|
||||||
|
|
||||||
const MODE_NONE: number = -1;
|
const MODE_NONE: number = -1;
|
||||||
@@ -17,7 +17,7 @@ export const FurnitureMannequinView: FC<{}> = props =>
|
|||||||
const [ renderedFigure, setRenderedFigure ] = useState<string>(null);
|
const [ renderedFigure, setRenderedFigure ] = useState<string>(null);
|
||||||
const [ mode, setMode ] = useState(MODE_NONE);
|
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 { 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(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
@@ -25,7 +25,7 @@ export const FurnitureMannequinView: FC<{}> = props =>
|
|||||||
|
|
||||||
const roomSession = GetRoomSession();
|
const roomSession = GetRoomSession();
|
||||||
|
|
||||||
if(roomSession.isRoomOwner || (roomSession.controllerLevel >= RoomControllerLevel.GUEST) || isModerator)
|
if(roomSession.isRoomOwner || (roomSession.controllerLevel >= RoomControllerLevel.GUEST) || canManageAnyRoom)
|
||||||
{
|
{
|
||||||
setMode(MODE_CONTROLLER);
|
setMode(MODE_CONTROLLER);
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export const FurnitureMannequinView: FC<{}> = props =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMode(MODE_PEER);
|
setMode(MODE_PEER);
|
||||||
}, [ objectId, gender, clubLevel, isModerator ]);
|
}, [ objectId, gender, clubLevel, canManageAnyRoom ]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { CreateLinkEvent, Dispose, DropBounce, EaseOut, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer';
|
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 { AnimatePresence, motion, Variants } from 'framer-motion';
|
||||||
import { FC, useEffect, useState } from 'react';
|
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 { 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 { ToolbarItemView } from './ToolbarItemView';
|
||||||
import { ToolbarMeView } from './ToolbarMeView';
|
import { ToolbarMeView } from './ToolbarMeView';
|
||||||
import { YouTubePlayerView } from './YouTubePlayerView';
|
import { YouTubePlayerView } from './YouTubePlayerView';
|
||||||
@@ -49,7 +49,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
|||||||
const { requests = [] } = useFriends();
|
const { requests = [] } = useFriends();
|
||||||
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
|
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
|
||||||
const { openMonitor, showToolbarButton } = useWiredTools();
|
const { openMonitor, showToolbarButton } = useWiredTools();
|
||||||
const isMod = useHasRankLevel(STAFF_LEVELS.MOD);
|
const isMod = useHasPermission('acc_supporttool');
|
||||||
const isVisible = (isToolbarOpen || !isInRoom);
|
const isVisible = (isToolbarOpen || !isInRoom);
|
||||||
const visibilityVariant = isVisible ? 'visible' : 'hidden';
|
const visibilityVariant = isVisible ? 'visible' : 'hidden';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomSettingsEvent, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from '@nitrots/nitro-renderer';
|
import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomSettingsEvent, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useRef, useState } from 'react';
|
import { FC, useEffect, useRef, useState } from 'react';
|
||||||
import ReactPlayer from 'react-player/youtube';
|
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 { 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_PREVIOUS_VIDEO = 0;
|
||||||
const CONTROL_COMMAND_NEXT_VIDEO = 1;
|
const CONTROL_COMMAND_NEXT_VIDEO = 1;
|
||||||
@@ -48,7 +48,7 @@ export const YouTubePlayerView: FC<{}> = () =>
|
|||||||
const [youtubeEnabled, setYoutubeEnabled] = useState(getYoutubeRoomEnabled());
|
const [youtubeEnabled, setYoutubeEnabled] = useState(getYoutubeRoomEnabled());
|
||||||
// Reactive — must sit above the `if (!isOpen) return null` below
|
// Reactive — must sit above the `if (!isOpen) return null` below
|
||||||
// so the hook order stays stable across renders.
|
// so the hook order stays stable across renders.
|
||||||
const isModerator = useHasRankLevel(STAFF_LEVELS.MOD);
|
const isModerator = useHasPermission('acc_anyroomowner');
|
||||||
|
|
||||||
useMessageEvent<YouTubeRoomSettingsEvent>(YouTubeRoomSettingsEvent, event =>
|
useMessageEvent<YouTubeRoomSettingsEvent>(YouTubeRoomSettingsEvent, event =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Component, ReactNode, useSyncExternalStore } from 'react';
|
|||||||
import { useBetween } from 'use-between';
|
import { useBetween } from 'use-between';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { GetEventDispatcher, GetSessionDataManager } from '../../nitro-renderer.mock';
|
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.
|
// 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
|
// Permission-driven API — useHasPermission / usePermissionValue /
|
||||||
// invalidation, tied to the permission_ranks DB table (rankId / rankName /
|
// useUserPermissions / useUserRank (display).
|
||||||
// rankBadge / rankPrefix / rankPrefixColor are mirrored on the wire by
|
//
|
||||||
// the extended UserPermissionsComposer in Arcturus ≥ 4.2.10).
|
// 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 = () =>
|
const makeFakeDispatcher = () =>
|
||||||
@@ -139,7 +144,7 @@ const makeFakeDispatcher = () =>
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FakeSnapshot
|
interface FakeUserSnapshot
|
||||||
{
|
{
|
||||||
securityLevel: number;
|
securityLevel: number;
|
||||||
rankId: number;
|
rankId: number;
|
||||||
@@ -149,7 +154,7 @@ interface FakeSnapshot
|
|||||||
rankPrefixColor: string;
|
rankPrefixColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const makeSnapshot = (overrides: Partial<FakeSnapshot> = {}): FakeSnapshot => ({
|
const makeUserSnapshot = (overrides: Partial<FakeUserSnapshot> = {}): FakeUserSnapshot => ({
|
||||||
securityLevel: 0,
|
securityLevel: 0,
|
||||||
rankId: 0,
|
rankId: 0,
|
||||||
rankName: '',
|
rankName: '',
|
||||||
@@ -159,18 +164,21 @@ const makeSnapshot = (overrides: Partial<FakeSnapshot> = {}): FakeSnapshot => ({
|
|||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useHasRankLevel + useIsRank + useUserRank', () =>
|
describe('useHasPermission + usePermissionValue + useUserPermissions', () =>
|
||||||
{
|
{
|
||||||
let snapshot: FakeSnapshot;
|
let userSnapshot: FakeUserSnapshot;
|
||||||
|
let permissionsSnapshot: ReadonlyMap<string, number>;
|
||||||
let fakeDispatcher: ReturnType<typeof makeFakeDispatcher>;
|
let fakeDispatcher: ReturnType<typeof makeFakeDispatcher>;
|
||||||
|
|
||||||
beforeEach(() =>
|
beforeEach(() =>
|
||||||
{
|
{
|
||||||
snapshot = makeSnapshot();
|
userSnapshot = makeUserSnapshot();
|
||||||
|
permissionsSnapshot = new Map();
|
||||||
fakeDispatcher = makeFakeDispatcher();
|
fakeDispatcher = makeFakeDispatcher();
|
||||||
|
|
||||||
vi.mocked(GetSessionDataManager).mockReturnValue({
|
vi.mocked(GetSessionDataManager).mockReturnValue({
|
||||||
getUserDataSnapshot: () => snapshot
|
getUserDataSnapshot: () => userSnapshot,
|
||||||
|
getPermissionsSnapshot: () => permissionsSnapshot
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
vi.mocked(GetEventDispatcher).mockReturnValue(fakeDispatcher as any);
|
vi.mocked(GetEventDispatcher).mockReturnValue(fakeDispatcher as any);
|
||||||
@@ -183,9 +191,9 @@ describe('useHasRankLevel + useIsRank + useUserRank', () =>
|
|||||||
vi.mocked(GetEventDispatcher).mockReset();
|
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,
|
securityLevel: 5,
|
||||||
rankId: 5,
|
rankId: 5,
|
||||||
rankName: 'Moderator',
|
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 });
|
permissionsSnapshot = new Map([
|
||||||
expect(renderHook(() => useHasRankLevel(5)).result.current).toBe(true);
|
[ 'acc_supporttool', 1 ],
|
||||||
expect(renderHook(() => useHasRankLevel(6)).result.current).toBe(false);
|
[ 'acc_anyroomowner', 2 ],
|
||||||
expect(renderHook(() => useHasRankLevel(7)).result.current).toBe(false);
|
[ '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' });
|
permissionsSnapshot = new Map([
|
||||||
expect(renderHook(() => useIsRank('Moderator')).result.current).toBe(true);
|
[ 'acc_supporttool', 1 ],
|
||||||
expect(renderHook(() => useIsRank('Super Mod')).result.current).toBe(false);
|
[ 'acc_anyroomowner', 2 ]
|
||||||
expect(renderHook(() => useIsRank('Administrator')).result.current).toBe(false);
|
]);
|
||||||
|
|
||||||
|
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' });
|
permissionsSnapshot = new Map([ [ 'acc_supporttool', 1 ], [ 'acc_ambassador', 1 ] ]);
|
||||||
const { result } = renderHook(() => useHasRankLevel(5));
|
|
||||||
|
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);
|
expect(result.current).toBe(false);
|
||||||
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
// Renderer invariant: every invalidation produces a NEW
|
// Renderer invariant: every invalidation produces a NEW
|
||||||
// frozen snapshot object. The mock's NitroEventType proxy
|
// map reference. The mock's NitroEventType proxy resolves
|
||||||
// resolves any property to `mock:NitroEventType:<PROP>`, so
|
// any property to `mock:NitroEventType:<PROP>`, so that's
|
||||||
// that's the wire string useSessionSnapshots subscribes against.
|
// the wire string useSessionSnapshots subscribes against.
|
||||||
snapshot = makeSnapshot({ securityLevel: 5, rankName: 'Moderator' });
|
permissionsSnapshot = new Map([ [ 'acc_supporttool', 1 ] ]);
|
||||||
fakeDispatcher.dispatch('mock:NitroEventType:SESSION_DATA_UPDATED');
|
fakeDispatcher.dispatch('mock:NitroEventType:USER_PERMISSIONS_UPDATED');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current).toBe(true);
|
expect(result.current).toBe(true);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const DEFAULT_USER_DATA: Readonly<IUserDataSnapshot> = Object.freeze({
|
|||||||
const EMPTY_IGNORED_LIST: ReadonlyArray<string> = Object.freeze<string[]>([]) as ReadonlyArray<string>;
|
const EMPTY_IGNORED_LIST: ReadonlyArray<string> = Object.freeze<string[]>([]) as ReadonlyArray<string>;
|
||||||
const EMPTY_GROUP_BADGES: ReadonlyMap<number, string> = new Map();
|
const EMPTY_GROUP_BADGES: ReadonlyMap<number, string> = new Map();
|
||||||
const EMPTY_USER_LIST: ReadonlyArray<IRoomUserData> = Object.freeze<IRoomUserData[]>([]) as ReadonlyArray<IRoomUserData>;
|
const EMPTY_USER_LIST: ReadonlyArray<IRoomUserData> = Object.freeze<IRoomUserData[]>([]) as ReadonlyArray<IRoomUserData>;
|
||||||
|
const EMPTY_PERMISSIONS: ReadonlyMap<string, number> = new Map();
|
||||||
|
|
||||||
const DEFAULT_VOLUMES: Readonly<ISoundVolumesSnapshot> = Object.freeze({
|
const DEFAULT_VOLUMES: Readonly<ISoundVolumesSnapshot> = Object.freeze({
|
||||||
system: 0.5,
|
system: 0.5,
|
||||||
@@ -129,14 +130,14 @@ export const useIsUserIgnored = (name: string): boolean =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive view of the current user's rank, mirrored from the
|
* Reactive view of the current user's rank metadata — name, badge,
|
||||||
* `permission_ranks` table via the extended `UserPermissionsComposer`
|
* prefix, prefix color — mirrored from `permission_ranks` via the
|
||||||
* wire (Arcturus-Morningstar-Extended ≥ 4.2.10). Use this in UI code
|
* extended `UserPermissionsComposer` wire (Arcturus ≥ 4.2.10). Use
|
||||||
* that needs to display rank metadata (badge, prefix, prefix color)
|
* this in PRESENTATIONAL code only (chat prefix coloring, badge in
|
||||||
* or to gate behaviour on the actual deployment rank rather than the
|
* the avatar overlay, "rank" line in the user profile). DO NOT use
|
||||||
* generic SecurityLevel constants the renderer exposes — those don't
|
* it for gating UI capabilities: prefer the permission-based family
|
||||||
* line up with the rank names operators actually use ("Moderator",
|
* (`useHasPermission(key)`) below, which is dynamic against
|
||||||
* "Super Mod", "Administrator", …).
|
* `permission_definitions` and survives rank renumbering.
|
||||||
*/
|
*/
|
||||||
export interface IUserRank
|
export interface IUserRank
|
||||||
{
|
{
|
||||||
@@ -163,31 +164,69 @@ export const useUserRank = (): IUserRank =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive predicate: does the current user's rank level satisfy
|
* Resolved permission map for the current user, mirroring
|
||||||
* `>= minLevel`? Use this when you want "at least <rank>" semantics
|
* `permission_definitions` filtered to the user's rank. Backed by
|
||||||
* and have the rank id from your deployment's `permission_ranks`
|
* `SessionDataManager.getPermissionsSnapshot()` and invalidated by
|
||||||
* table (e.g. 5 for Moderator in the default seed). Replaces the
|
* `USER_PERMISSIONS_UPDATED` (Arcturus dispatches the underlying
|
||||||
* older `useHasSecurityLevel` (same wire data, renamed to match the
|
* packet at login + after every `setRank`).
|
||||||
* DB table semantics).
|
*
|
||||||
|
* 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 =>
|
export const useUserPermissions = (): ReadonlyMap<string, number> =>
|
||||||
useUserDataSnapshot().securityLevel >= minLevel;
|
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
|
* Reactive predicate: does the current user have the named
|
||||||
* `permission_ranks.rank_name`. Prefer `useHasRankLevel(min)` when
|
* permission (ALLOWED or ROOM_OWNER)? `key` must match a row in
|
||||||
* the gate is "this rank or higher"; reach for `useIsRank('Foo')`
|
* `permission_definitions.permission_key` (e.g. `'acc_supporttool'`,
|
||||||
* only when an action must be specific to one rank.
|
* `'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
|
* Reactive raw permission value (1 = ALLOWED, 2 = ROOM_OWNER, 0 if
|
||||||
* separate boolean on the snapshot (the emulator computes it server-
|
* absent). Useful for the handful of permissions whose
|
||||||
* side from the `acc_ambassador` permission, which a deployment can
|
* `permission_definitions.max_value > 1` (e.g.
|
||||||
* grant independently of the rank hierarchy).
|
* `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<number, string> =>
|
export const useGroupBadgesSnapshot = (): ReadonlyMap<number, string> =>
|
||||||
useExternalSnapshot(
|
useExternalSnapshot(
|
||||||
|
|||||||
Reference in New Issue
Block a user