Commit Graph

4 Commits

Author SHA1 Message Date
simoleo89 8aa02249e1 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.
2026-05-19 18:38:31 +02:00
simoleo89 c11a6c4699 feat(hooks): generalise security-level family + audit catch + reactivity test
Build on the useIsModerator landing (532cb28c) along three axes:

1. Family. Extract `useHasSecurityLevel(min)` as the primitive,
   backed by a fresh `useUserSecurityLevel()` raw-level reader. The
   six SecurityLevel constants (1..9) deserve named wrappers so the
   "show this only to X-and-up" pattern doesn't get re-derived ad-hoc
   each time: shipped `useIsModerator` / `useIsPlayerSupport` /
   `useIsCommunity` / `useIsAdmin` as one-line shims. Also added
   `useIsAmbassador()` as a sibling — not derived from security level,
   reads the boolean field on the snapshot directly.

2. Audit. The 532cb28c migration covered 6 React-render reads but
   missed 5 more discovered by a follow-up grep:
   - FurniEditorView (top-level `const isMod`)
   - InfoStandWidgetFurniView (inline JSX, mod-only build-tools button)
   - NavigatorRoomInfoView (3 reads in hasPermission(): isModerator
     and securityLevel >= COMMUNITY for the staff-pick gate. The
     userId read stays imperative — userId doesn't flip at runtime in
     practice, no reactivity gain.)
   - AvatarInfoWidgetPetView (inside useMemo with [roomSession] deps;
     migrated and isModerator added to the deps so a runtime
     promote/demote re-derives canPickUp without remount)
   - FurnitureMannequinView (inside useEffect; same treatment — added
     isModerator to the deps so the mode re-resolves on flip)

   The remaining ~17 reads (CanManipulateFurniture,
   AvatarInfoUtilities.populate*, useChatInputActions,
   useFurnitureDimmerWidget / useFurniturePlaylistEditorWidget /
   useFurnitureStickieWidget canModify checks, useCatalog admin
   filter, useNavigator door-mode guard) are click-time / event-time
   imperative — they read at the moment a user action fires, so a
   reactive value would be cached at hook execution and stale by the
   time the action runs. Leaving them on the synchronous manager read
   is correct.

3. Test. Added four cases pinning the contract:
   - useUserSecurityLevel returns the raw level.
   - useHasSecurityLevel does `>=` against the threshold.
   - Named wrappers map to the right constants (MODERATOR=5,
     COMMUNITY=7, ADMINISTRATOR=8).
   - **Reactive flip** — mutate the snapshot, dispatch the
     SESSION_DATA_UPDATED event on the mock dispatcher, assert the
     hook re-derives. Locks in the whole point of the snapshot
     pattern (a static read would pass cases 1-3 but fail case 4).

Mock changes:
- Added SecurityLevel class (mirrors the renderer enum 0..9) so the
  family wrappers resolve to actual numbers in jsdom — without it
  `useIsModerator()` would call `useHasSecurityLevel(undefined)` and
  the test would silently pass false-positives.

Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
213/213 (209 baseline + 4 new family/reactivity cases).
2026-05-19 18:18:20 +02:00
simoleo89 a029ee63cb fix(catalog,ci): catch hook-order violations + add CI gate
Two follow-ups to the CatalogPurchaseWidgetView fix (6bf3366):

1. CatalogItemGridWidgetView had the same shape — four useCallback
   declarations (handleDragStart / handleDragOver / handleDrop /
   handleDragEnd) sat below an `if(!currentPage) return null` early
   return. When currentPage flipped from null to a real page the hook
   count jumped by 4 and React would have thrown "Rendered more hooks
   than during the previous render" the moment any consumer rendered
   the grid in admin mode. Moved the four useCallback declarations
   above the early-return; their bodies are safe pre-load (only
   currentPage?.offers is accessed inside handleDrop, optional-chained
   already).

2. CI gate — the existing GitHub Actions workflow runs `yarn
   typecheck` and `yarn test`, but NOT `yarn eslint`. That's why this
   pattern slipped through twice in a row: ESLint flags it locally
   but no PR check enforces it. Full `yarn eslint` emits ~900
   pre-existing baseline errors (brace-style, indentation,
   recommended TS rules — out of scope for this branch), so a blanket
   step would always fail. Instead added a focused
   `eslint.hooks.config.mjs` + `yarn lint:hooks` script that runs
   ESLint with ONLY `react-hooks/rules-of-hooks: error`. Wired into
   ci.yml between `typecheck` and `test`. The local repo now has
   zero violations of the rule.

3. useSessionSnapshots.test.tsx — added eslint-disable-next-line
   comments on the three lines that intentionally violate the rule
   (they're the assertions that the broken pattern crashes). Without
   the comments the new CI gate would fail on the regression-guard
   suite.

Verification: yarn lint:hooks green, yarn typecheck clean, yarn test
209/209.
2026-05-19 17:57:28 +02:00
simoleo89 d28819db89 fix(snapshots): re-apply the 3 snapshot-consumer migrations with the use-between/useSyncExternalStore incompatibility resolved
Root cause of last session's "(intermediate value)() is undefined" at
ToolbarView.tsx:46:

  use-between 1.x ships its own React-dispatcher proxy (ownDispatcher
  in node_modules/use-between/release/index.esm.js:54-169) that
  re-implements only useState, useReducer, useEffect, useLayoutEffect,
  useCallback, useMemo, useRef and useImperativeHandle. It does NOT
  implement useSyncExternalStore. When the inner state function of
  useBetween(stateFn) calls useSyncExternalStore (directly or via
  useExternalSnapshot / useUserDataSnapshot), React resolves the
  dispatcher to use-between's proxy, finds .useSyncExternalStore
  missing, and calls undefined() — that's the exact production crash
  in Firefox. Chrome reports the same as
  "dispatcher.useSyncExternalStore is not a function".

Neither the vite alias (790ad2b) nor the defensive renderer-method
guards (c35a2d4) could fix it — both addressed downstream symptoms
(stale dist / missing manager methods) but the dispatcher is upstream
of both. That's why every retry kept reproducing the same error.

Fix is structural: snapshot hooks (useUserDataSnapshot,
useIsUserIgnored, etc.) MUST run outside any useBetween scope. Three
re-applied migrations:

- useSessionInfo: snapshot read moved into the outer wrapper. The
  inner useSessionInfoState (useBetween-shared) now contains only
  use-between-safe hooks: useState, useMessageEvent, plain actions.
  userFigure / userRespectRemaining / petRespectRemaining come from
  useUserDataSnapshot() OUTSIDE useBetween, so useSyncExternalStore
  installs against the real React dispatcher.

- useChatWidget.ownUserId: direct snapshot read. useChatWidget is
  exported as `useChatWidget = useChatWidgetState` (NOT wrapped in
  useBetween), so this hook never sat inside the broken scope — the
  precautionary rollback was unnecessary in retrospect. Gains
  session-change reactivity (e.g. reconnect under a different user id).

- AvatarInfoWidgetAvatarView Ignore/Unignore: useIsUserIgnored(name)
  read directly in the component body. Same reasoning as
  useChatWidget — never inside useBetween. The menu auto-flips
  Ignore <-> Unignore while the popup is open.

Added regression guard at src/hooks/session/useSessionSnapshots.test.tsx
with two cases: (1) useSyncExternalStore inside useBetween throws,
(2) useSyncExternalStore outside useBetween in the same render works.
Pins the constraint so future migrations cannot reintroduce the bad
shape silently.

Verification: yarn typecheck clean, yarn test 209/209 (207 baseline
+ 2 new regression cases), no consumer surface changes — every
destructured field (userFigure, userRespectRemaining, respectUser,
petRespectRemaining, respectPet, chatStyleId, updateChatStyleId) is
still returned with the same name and shape.
2026-05-19 17:30:03 +02:00