From 94278ccf73009dc231021e6e540f2e0baf9b72ee Mon Sep 17 00:00:00 2001 From: naywin <170845045+naywin101@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:39:43 +0700 Subject: [PATCH 1/2] fix permission access --- src/contexts/AuthContext.tsx | 13 ++++++ src/helpers/socket.helpers.ts | 14 +++++++ src/hooks/usePermissions.test.ts | 59 ++++++++++++++++++++++++++- src/hooks/usePermissions.ts | 70 ++++++++++++++++++++------------ src/hooks/useSocket.ts | 19 ++++++++- src/models/user.model.ts | 2 + src/routes/ProtectedRoute.tsx | 10 ++++- 7 files changed, 156 insertions(+), 31 deletions(-) diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 3199a7be..25b28213 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -13,6 +13,7 @@ import { useAtom } from "jotai"; import { IUser } from "../models/user.model"; import atoms from "../atoms"; import { isTokenExpired } from "../helpers"; +import UserService from "../modules/user/user.service"; import { useLoading } from "./LoadingContext"; interface IAuthContextType { @@ -22,6 +23,7 @@ interface IAuthContextType { setCurrentUser: (user: IUser | null) => void; signin: (token: string, user: IUser) => void; signout: () => void; + refreshCurrentUser: () => Promise; googleChallengeToken: string | null; setGoogleChallengeToken: (token: string | null) => void; } @@ -91,6 +93,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ [setToken, setCurrentUser, setGoogleChallengeToken], ); + const refreshCurrentUser = useCallback(async () => { + if (!token || isTokenExpired(token)) return; + + const response = await UserService.getCurrentUser(); + if (response.data?.data.user) { + setCurrentUser(response.data.data.user); + } + }, [setCurrentUser, token]); + const value = useMemo( () => ({ isAuthenticated, @@ -99,6 +110,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ setCurrentUser, signin, signout, + refreshCurrentUser, googleChallengeToken, setGoogleChallengeToken, }), @@ -109,6 +121,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ setCurrentUser, signin, signout, + refreshCurrentUser, googleChallengeToken, ], ); diff --git a/src/helpers/socket.helpers.ts b/src/helpers/socket.helpers.ts index b5134905..ceb26abe 100644 --- a/src/helpers/socket.helpers.ts +++ b/src/helpers/socket.helpers.ts @@ -70,6 +70,20 @@ export function getSpeechEventType(data: ISocketMessage): string { return typeof data.data?.type === "string" ? data.data.type : ""; } +export const ASSET_SOCKET_TOAST_TYPES = [ + NOTIFICATION_SOCKET_TYPES.ASSET_COMPRESSING, + NOTIFICATION_SOCKET_TYPES.ASSET_COMPRESSED, + NOTIFICATION_SOCKET_TYPES.ASSET_COMPRESSION_FAILED, + NOTIFICATION_SOCKET_TYPES.ASSET_THUMBNAIL_GENERATED, + NOTIFICATION_SOCKET_TYPES.ASSET_THUMBNAIL_FAILED, +] as const; + +export function isAssetSocketToast(data: ISocketMessage): boolean { + return (ASSET_SOCKET_TOAST_TYPES as readonly string[]).includes( + getSpeechEventType(data), + ); +} + export function isSpeechLiveMessage(data: ISocketMessage): boolean { if (data.channel === SOCKET_CHANNELS.SPEECH_LIVE) { return true; diff --git a/src/hooks/usePermissions.test.ts b/src/hooks/usePermissions.test.ts index d63fe30a..40674c13 100644 --- a/src/hooks/usePermissions.test.ts +++ b/src/hooks/usePermissions.test.ts @@ -14,7 +14,11 @@ vi.mock("react", () => ({ useMemo: (fn: () => unknown) => (fn as () => unknown)(), })); -import { getAdminRoleResourceScope } from "./usePermissions"; +import { + getAdminPermissions, + getAdminRoleResourceScope, + getScopedAdminPermissions, +} from "./usePermissions"; import { isAdminRoleName, hasAdminRole, @@ -140,6 +144,59 @@ describe("getAdminRoleResourceScope", () => { }); }); +describe("getAdminPermissions", () => { + it("uses the returned admin permission map as the source of truth for admin roles", () => { + const permissions = getAdminPermissions( + { + [ADMIN_RESOURCES.NOTIFICATIONS]: ["read", "create", "update", "delete"], + [ADMIN_RESOURCES.USERS]: ["read"], + [ADMIN_RESOURCES.PRODUCTS]: ["read"], + }, + ["notification_admin"], + ); + + expect(permissions).toContainEqual({ + action: "read", + resource: ADMIN_RESOURCES.USERS, + }); + expect(permissions).toContainEqual({ + action: "read", + resource: ADMIN_RESOURCES.PRODUCTS, + }); + }); + + it("does not grant admin permissions to non-admin roles", () => { + const permissions = getAdminPermissions( + { + [ADMIN_RESOURCES.USERS]: ["read"], + }, + ["user"], + ); + + expect(permissions).toEqual([]); + }); +}); + +describe("getScopedAdminPermissions", () => { + it("keeps older combined permission payloads scoped to the admin role prefix", () => { + const permissions = getScopedAdminPermissions( + { + [ADMIN_RESOURCES.USERS]: ["read"], + [ADMIN_RESOURCES.PRODUCTS]: ["read"], + [ADMIN_RESOURCES.NOTIFICATIONS]: ["read"], + }, + ["notification_admin"], + ); + + expect(permissions).toEqual([ + { + action: "read", + resource: ADMIN_RESOURCES.NOTIFICATIONS, + }, + ]); + }); +}); + describe("isAdminRoleName", () => { it("returns true for 'admin'", () => { expect(isAdminRoleName(ADMIN_ROLE_NAMES.ADMIN)).toBe(true); diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index bbe9364b..b0515de3 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -34,6 +34,8 @@ const ADMIN_ROLE_RESOURCE_PREFIXES: Record = { [ADMIN_RESOURCES.USER_VERSIONS]: ["user_versions", "user_version"], }; +const ADMIN_RESOURCE_VALUES = Object.values(ADMIN_RESOURCES) as string[]; + interface IUsePermissionsResult { permissions: IPermission[]; isSuperAdmin: boolean; @@ -43,6 +45,40 @@ interface IUsePermissionsResult { refresh: () => Promise; } +export const getAdminPermissions = ( + permissionMap: unknown, + roleNames: AdminRoleName[] | null | undefined, +): IPermission[] => { + if (!hasAdminRole(roleNames) || Array.isArray(permissionMap)) { + return []; + } + + return Object.entries(permissionMap ?? {}).flatMap(([resource, actions]) => { + if (!ADMIN_RESOURCE_VALUES.includes(resource)) { + return []; + } + + return Array.isArray(actions) + ? actions.map((action) => ({ + action: action as AdminAction, + resource: resource as AdminResource, + })) + : []; + }); +}; + +export const getScopedAdminPermissions = ( + permissionMap: unknown, + roleNames: AdminRoleName[] | null | undefined, +): IPermission[] => { + const permissions = getAdminPermissions(permissionMap, roleNames); + const scopedResources = getAdminRoleResourceScope(roleNames); + + return permissions.filter((permission) => + scopedResources.has(permission.resource), + ); +}; + export const getAdminRoleResourceScope = ( roleNames: AdminRoleName[] | null | undefined, ): Set => { @@ -77,7 +113,7 @@ export const getAdminRoleResourceScope = ( }; export const usePermissions = (): IUsePermissionsResult => { - const { currentUser, isAuthenticated } = useAuth(); + const { currentUser, isAuthenticated, refreshCurrentUser } = useAuth(); const isLoading = isAuthenticated && !currentUser; const roleNames = currentUser?.role_names ?? currentUser?.roles; @@ -91,30 +127,10 @@ export const usePermissions = (): IUsePermissionsResult => { ); const permissions = useMemo(() => { - // Non-admin roles (user, etc.) NEVER grant access to admin capabilities - if (!hasAdminRole(roleNames)) { - return []; - } - - const scopedResources = getAdminRoleResourceScope(roleNames); - const permissionMap = !Array.isArray(currentUser?.permissions) - ? (currentUser?.permissions ?? {}) - : {}; - - return Object.entries(permissionMap).flatMap(([resource, actions]) => { - const adminResource = resource as AdminResource; - - // Filter strictly: only accept permissions for resources covered by the user's admin roles - if (scopedResources.has(adminResource)) { - return (actions ?? []).map((action: AdminAction) => ({ - action, - resource: adminResource, - })); - } - - return []; - }); - }, [currentUser?.permissions, roleNames]); + return currentUser?.admin_permissions + ? getAdminPermissions(currentUser.admin_permissions, roleNames) + : getScopedAdminPermissions(currentUser?.permissions, roleNames); + }, [currentUser?.admin_permissions, currentUser?.permissions, roleNames]); const permissionKeys = useMemo( () => @@ -135,7 +151,9 @@ export const usePermissions = (): IUsePermissionsResult => { [isSuperAdmin, permissionKeys, roleNames], ); - const refresh = useCallback(async () => undefined, []); + const refresh = useCallback(async () => { + await refreshCurrentUser(); + }, [refreshCurrentUser]); return { permissions, isSuperAdmin, isLoading, error: "", can, refresh }; }; diff --git a/src/hooks/useSocket.ts b/src/hooks/useSocket.ts index de84ae97..e1afb39c 100644 --- a/src/hooks/useSocket.ts +++ b/src/hooks/useSocket.ts @@ -1,7 +1,13 @@ import { useEffect, useState, useCallback } from "react"; +import { useLocation } from "react-router-dom"; +import AppRoutes from "../AppRoutes"; import { useAuth } from "../contexts/AuthContext"; import { useToast } from "../contexts/ToastContext"; -import { getSocketToast, SOCKET_MESSAGE_TYPES } from "../helpers/socket.helpers"; +import { + getSocketToast, + isAssetSocketToast, + SOCKET_MESSAGE_TYPES, +} from "../helpers/socket.helpers"; import SocketService, { ISocketMessage } from "../services/socket.service"; import { ToastTypes } from "../constants"; @@ -15,6 +21,7 @@ export interface INotification { export const useSocket = () => { const { token, isAuthenticated } = useAuth(); const { success, error, info } = useToast(); + const location = useLocation(); const [notifications, setNotifications] = useState([]); useEffect(() => { @@ -25,6 +32,14 @@ export const useSocket = () => { } const handleNotification = (data: ISocketMessage) => { + const isAdminAssetPage = location.pathname.startsWith( + AppRoutes.client.protected.admin.ASSETS, + ); + + if (isAssetSocketToast(data) && !isAdminAssetPage) { + return; + } + const toast = getSocketToast(data); if (!toast) { return; @@ -63,7 +78,7 @@ export const useSocket = () => { SocketService.removeListener(handleNotification); // Don't disconnect here - let the effect handle it }; - }, [token, isAuthenticated, success, error, info]); + }, [token, isAuthenticated, success, error, info, location.pathname]); const sendMessage = useCallback( (channel: string, message: string, data: Record = {}) => { diff --git a/src/models/user.model.ts b/src/models/user.model.ts index 05ab557a..27e29f31 100644 --- a/src/models/user.model.ts +++ b/src/models/user.model.ts @@ -16,6 +16,8 @@ export interface IUser { role_ids?: string[]; role_names?: AdminRoleName[]; permissions?: IUserPermissionMap | string[]; + admin_permissions?: IUserPermissionMap; + non_admin_permissions?: IUserPermissionMap; is_admin?: boolean; is_super_admin?: boolean; created_at: Date | string; diff --git a/src/routes/ProtectedRoute.tsx b/src/routes/ProtectedRoute.tsx index f664ea30..301b4aa2 100644 --- a/src/routes/ProtectedRoute.tsx +++ b/src/routes/ProtectedRoute.tsx @@ -1,13 +1,19 @@ -import React from "react"; +import React, { useEffect } from "react"; import { Navigate, Outlet, useLocation } from "react-router-dom"; import { useAuth } from "../contexts"; import AppRoutes from "../AppRoutes"; import { PageLayout } from "../design/pages"; export const ProtectedRoute: React.FC = () => { - const { isAuthenticated } = useAuth(); + const { isAuthenticated, refreshCurrentUser } = useAuth(); const location = useLocation(); + useEffect(() => { + if (!isAuthenticated) return; + + void refreshCurrentUser(); + }, [isAuthenticated, location.pathname, refreshCurrentUser]); + if (!isAuthenticated) { return ; } From 7676c8f67105008a2bfc103d5fe03f16a9096b33 Mon Sep 17 00:00:00 2001 From: naywin <170845045+naywin101@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:31:27 +0700 Subject: [PATCH 2/2] 404 page fix --- src/routes/AdminRootRoute.tsx | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/routes/AdminRootRoute.tsx b/src/routes/AdminRootRoute.tsx index 0f1b43d5..788a24ee 100644 --- a/src/routes/AdminRootRoute.tsx +++ b/src/routes/AdminRootRoute.tsx @@ -1,11 +1,11 @@ -import React from "react"; -import { Outlet } from "react-router-dom"; -import { useAuth } from "../contexts"; -import { NotFoundPage } from "../design/pages"; -import { usePermissions } from "../hooks"; -import type { AdminAction, AdminResource } from "../modules/admin/role"; -import { hasAdminRole } from "../modules/admin/role"; -import { AdminLayout } from "../modules/admin/components/AdminLayout"; +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { useAuth } from '../contexts'; +import { NotFoundPage } from '../design/pages'; +import { usePermissions } from '../hooks'; +import type { AdminAction, AdminResource } from '../modules/admin/role'; +import { hasAdminRole } from '../modules/admin/role'; +import { AdminLayout } from '../modules/admin/components/AdminLayout'; interface IAdminRootRouteProps { action: AdminAction; @@ -34,11 +34,5 @@ export const AdminRootRoute: React.FC = ({ ? isSuperAdmin : hasAdminAccess && can(action, resource); - return isAllowed ? ( - - - - ) : ( - - ); + return {isAllowed ? : }; };