From 9ce381270f03e0da89870a1eaab4ff204534406b Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Tue, 26 May 2026 16:21:01 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20myRole=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80.=20mainLayout=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EC=8B=9C=20role=20=EC=84=B8=ED=8C=85,=20work?= =?UTF-8?q?spaceSwitcher=20=EC=A0=84=ED=99=98=EC=8B=9C=20role=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=EB=90=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/sidebar/WorkspaceSwitcher.tsx | 6 +++++- src/layout/main/MainLayout.tsx | 12 +++++++++--- src/store/useWorkspaceStore.ts | 6 ++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/components/sidebar/WorkspaceSwitcher.tsx b/src/components/sidebar/WorkspaceSwitcher.tsx index 6044930e..aa43a458 100644 --- a/src/components/sidebar/WorkspaceSwitcher.tsx +++ b/src/components/sidebar/WorkspaceSwitcher.tsx @@ -27,6 +27,7 @@ export function WorkspaceSwitcher({ const selectedOrgId = useWorkspaceStore((s) => s.selectedOrgId); const setSelectedOrgId = useWorkspaceStore((s) => s.setSelectedOrgId); + const setMyRole = useWorkspaceStore((s) => s.setMyRole); const { data: workspaces, isPending } = useCoreQuery( ["my-workspaces"], @@ -52,12 +53,15 @@ export function WorkspaceSwitcher({ const fallback = workspaceList.find((w) => w.isCurrentWorkspace) || workspaceList[0]; setSelectedOrgId(fallback.orgId); - }, [selectedOrgId, workspaceList, setSelectedOrgId]); + setMyRole(fallback.myRole); + }, [selectedOrgId, workspaceList, setSelectedOrgId, setMyRole]); const { mutate: saveWorkspace } = useMutation({ mutationFn: (orgId: number) => saveSelectedWorkspace(orgId), onSuccess: async (_data, orgId) => { + const workspace = workspaceList.find((w) => w.orgId === orgId); setSelectedOrgId(orgId); + if (workspace) setMyRole(workspace.myRole); setIsOpen(false); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["my-workspaces"] }), diff --git a/src/layout/main/MainLayout.tsx b/src/layout/main/MainLayout.tsx index b3bb8d6b..fca8f128 100644 --- a/src/layout/main/MainLayout.tsx +++ b/src/layout/main/MainLayout.tsx @@ -38,6 +38,7 @@ export default function MainLayout() { >(null); const setSelectedOrgId = useWorkspaceStore((s) => s.setSelectedOrgId); + const setMyRole = useWorkspaceStore((s) => s.setMyRole); const savedWorkspaceQuery = useCoreQuery( ["savedWorkspace"], @@ -59,20 +60,25 @@ export default function MainLayout() { const savedId = savedData?.orgId; const isExist = workspaces.some((w) => w.orgId === savedId); - // 1. 현재 워크스페이스 조회 API 데이터 + // 1. 현재 워크스페이스 조회 API 데이터 (저장된 워크스페이스가 있는 경우) if (savedId !== undefined && isExist) { + const workspace = workspaces.find((w) => w.orgId === savedId); setSelectedOrgId(savedId); + if (workspace) setMyRole(workspace.myRole); } // 2. isCurrentWorkspace 또는 첫 번째 else { - const currentOrg = workspaces.find((w) => w.isCurrentWorkspace); - setSelectedOrgId(currentOrg?.orgId || workspaces[0].orgId); + const currentOrg = + workspaces.find((w) => w.isCurrentWorkspace) ?? workspaces[0]; + setSelectedOrgId(currentOrg.orgId); + setMyRole(currentOrg.myRole); } }, [ workspaces, savedWorkspaceQuery.isFetched, savedData, setSelectedOrgId, + setMyRole, selectedOrgId, ]); diff --git a/src/store/useWorkspaceStore.ts b/src/store/useWorkspaceStore.ts index 48bdfbfe..0bbf1e9d 100644 --- a/src/store/useWorkspaceStore.ts +++ b/src/store/useWorkspaceStore.ts @@ -1,13 +1,19 @@ import { create } from "zustand"; +import type { TMemberRole } from "@/types/workspace/workspace"; + interface IWorkspaceState { selectedOrgId: number | null; + myRole: TMemberRole | null; setSelectedOrgId: (orgId: number) => void; + setMyRole: (role: TMemberRole) => void; } const useWorkspaceStore = create((set) => ({ selectedOrgId: null, + myRole: null, setSelectedOrgId: (orgId) => set({ selectedOrgId: orgId }), + setMyRole: (role) => set({ myRole: role }), })); export default useWorkspaceStore; From 6a56978ac81e858af39b044194079972a8873281 Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Tue, 26 May 2026 16:30:49 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20RoleGuard=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/RoleGuard.tsx | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/routes/RoleGuard.tsx diff --git a/src/routes/RoleGuard.tsx b/src/routes/RoleGuard.tsx new file mode 100644 index 00000000..e4f9b9fc --- /dev/null +++ b/src/routes/RoleGuard.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; + +import type { TMemberRole } from "@/types/workspace/workspace"; + +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +interface IRoleGuardProps { + children: ReactNode; + allowedRoles: TMemberRole[]; +} + +function RoleGuard({ children, allowedRoles }: IRoleGuardProps) { + const navigate = useNavigate(); + const myRole = useWorkspaceStore((s) => s.myRole); + + //워크스페이스 초기화 전(null)에 랜더 보류 + if (myRole === null) { + return null; + } + + //허용되지 않는 역할 -> 대시보드로 리다이렉트 + if (!allowedRoles.includes(myRole)) { + return navigate("/dashboard", { replace: true }); + } + return <>{children}; +} + +export default RoleGuard; From 7e2116bba7e4a3bb63e53f7d51d835e77e47d0a7 Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Tue, 26 May 2026 16:56:08 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20ADMIN=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8A=B8=EC=97=90=20RoleGuard=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9,=20RoleGuard=ED=95=A8=EC=88=98=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=AA=85=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/MainRoutes.tsx | 26 +++++++++++++++++++++++--- src/routes/RoleGuard.tsx | 12 +++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/routes/MainRoutes.tsx b/src/routes/MainRoutes.tsx index 964ffbb7..b27443cd 100644 --- a/src/routes/MainRoutes.tsx +++ b/src/routes/MainRoutes.tsx @@ -5,6 +5,8 @@ import { loadable } from "@/utils/loadable"; import WorkspaceListLoading from "@/components/workspace/WorkspaceListLoading"; +import RoleGuard from "./RoleGuard"; + import WorkspaceBillingRedirect from "@/pages/workspace/WorkspaceBillingRedirect"; const OverviewDashboard = loadable( @@ -74,7 +76,11 @@ const MainRoutes: RouteObject[] = [ }, { path: "workspace/billing", - element: , + element: ( + + + + ), }, { path: "workspace/:workspaceId", @@ -82,8 +88,22 @@ const MainRoutes: RouteObject[] = [ children: [ { index: true, element: }, { path: "settings", element: }, - { path: "members", element: }, - { path: "billing", element: }, + { + path: "members", + element: ( + + + + ), + }, + { + path: "billing", + element: ( + + + + ), + }, ], }, { diff --git a/src/routes/RoleGuard.tsx b/src/routes/RoleGuard.tsx index e4f9b9fc..bdf02726 100644 --- a/src/routes/RoleGuard.tsx +++ b/src/routes/RoleGuard.tsx @@ -1,5 +1,5 @@ -import type { ReactNode } from "react"; -import { useNavigate } from "react-router-dom"; +import type { ReactElement, ReactNode } from "react"; +import { Navigate } from "react-router-dom"; import type { TMemberRole } from "@/types/workspace/workspace"; @@ -10,8 +10,10 @@ interface IRoleGuardProps { allowedRoles: TMemberRole[]; } -function RoleGuard({ children, allowedRoles }: IRoleGuardProps) { - const navigate = useNavigate(); +function RoleGuard({ + children, + allowedRoles, +}: IRoleGuardProps): ReactElement | null { const myRole = useWorkspaceStore((s) => s.myRole); //워크스페이스 초기화 전(null)에 랜더 보류 @@ -21,7 +23,7 @@ function RoleGuard({ children, allowedRoles }: IRoleGuardProps) { //허용되지 않는 역할 -> 대시보드로 리다이렉트 if (!allowedRoles.includes(myRole)) { - return navigate("/dashboard", { replace: true }); + return ; } return <>{children}; } From d76148109a90cca19f376bde5582d01fa88327bf Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Tue, 26 May 2026 17:11:40 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20INavItem=EC=97=90=20requiredRole=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20+=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EB=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/sidebar/Sidebar.tsx | 22 ++++++++++++++++++++-- src/constants/sidebarNav.ts | 2 ++ src/types/navigation/navItem.ts | 7 +++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index a6ca1ffc..1ea18460 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -3,6 +3,8 @@ import { useCallback, useMemo } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { twMerge } from "tailwind-merge"; +import type { INavItem } from "@/types/navigation/navItem"; +import type { TMemberRole } from "@/types/workspace/workspace"; import { footerNav, mainNav } from "@/constants/sidebarNav"; import { mainNavSidebar } from "@/utils/navigation/mainNavSidebar"; @@ -20,6 +22,20 @@ import CollapseIcon from "@/assets/icon/chevron/chervon-left.svg?react"; import ChevronIcon from "@/assets/icon/chevron/chevron-up.svg?react"; import useWorkspaceStore from "@/store/useWorkspaceStore"; +function filterNavByRole( + items: INavItem[], + myRole: TMemberRole | null, +): INavItem[] { + return items + .filter((item) => !item.requiredRole || item.requiredRole === myRole) + .map((item) => ({ + ...item, + children: item.children + ? filterNavByRole(item.children, myRole) + : undefined, + })); +} + function getMainItemClass(isActive: boolean, isCollapsed: boolean) { return twMerge( "flex cursor-pointer items-center rounded-2xl px-3 font-body2 transition-colors duration-200", @@ -71,9 +87,11 @@ export default function Sidebar() { const { showComingSoon } = useComingSoon(); const selectedOrgId = useWorkspaceStore((s) => s.selectedOrgId); + const myRole = useWorkspaceStore((s) => s.myRole); const mainNavWithWorkspace = useMemo( - () => applyWorkspacePathsToNav(mainNav, selectedOrgId), - [selectedOrgId], + () => + filterNavByRole(applyWorkspacePathsToNav(mainNav, selectedOrgId), myRole), + [selectedOrgId, myRole], ); const handleFooterItemClick = useCallback( diff --git a/src/constants/sidebarNav.ts b/src/constants/sidebarNav.ts index 67ed1f5d..3bbc9cfe 100644 --- a/src/constants/sidebarNav.ts +++ b/src/constants/sidebarNav.ts @@ -69,11 +69,13 @@ export const mainNav: INavItem[] = [ id: "workspace-members", label: "멤버 관리", workspaceSubpath: "members", + requiredRole: "ADMIN", }, { id: "workspace-billing", label: "플랜 및 결제", workspaceSubpath: "billing", + requiredRole: "ADMIN", }, ], }, diff --git a/src/types/navigation/navItem.ts b/src/types/navigation/navItem.ts index f90eea07..286b04b2 100644 --- a/src/types/navigation/navItem.ts +++ b/src/types/navigation/navItem.ts @@ -1,5 +1,7 @@ import type { ComponentType, SVGProps } from "react"; +import type { TMemberRole } from "../workspace/workspace"; + export interface INavItem { id: string; label: string; @@ -10,4 +12,9 @@ export interface INavItem { /** true면 `path`와 pathname이 정확히 일치할 때만 매칭 (워크스페이스 목록 등) */ pathExact?: boolean; children?: INavItem[]; + /** + * 이 필드가 있으면 -> 해당 역할만 메뉴표시 + * 이 필드가 없으면 -> 역할 상관없이 모두에게 표시 + */ + requiredRole?: TMemberRole; } From 2b8713385e00cc1c36519f74484ae86b5095942e Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Tue, 26 May 2026 18:23:04 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20useIsAdmin=ED=9B=85=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/auth/useIsAdmin.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/hooks/auth/useIsAdmin.ts diff --git a/src/hooks/auth/useIsAdmin.ts b/src/hooks/auth/useIsAdmin.ts new file mode 100644 index 00000000..25e5c2aa --- /dev/null +++ b/src/hooks/auth/useIsAdmin.ts @@ -0,0 +1,9 @@ +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +/*useWorkspaceStore에서 myRole을 읽어서 booelan으로 반환하는 훅*/ +function useIsAdmin(): boolean { + const myRole = useWorkspaceStore((s) => s.myRole); + return myRole === "ADMIN"; +} + +export default useIsAdmin; From b1ef77db1f44c78e2927185b4e04c67f93021d76 Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Tue, 26 May 2026 18:28:29 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20useIsAdmin=ED=9B=85=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/workspace/WorkspaceSetting.tsx | 61 +++++++++++++----------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/src/pages/workspace/WorkspaceSetting.tsx b/src/pages/workspace/WorkspaceSetting.tsx index b32cda12..c1527591 100644 --- a/src/pages/workspace/WorkspaceSetting.tsx +++ b/src/pages/workspace/WorkspaceSetting.tsx @@ -5,6 +5,8 @@ import { toast } from "sonner"; import type { IApiErrorResponse } from "@/types/common/common"; +import useIsAdmin from "@/hooks/auth/useIsAdmin"; + import Button from "@/components/common/button/Button"; import Card from "@/components/common/card/Card"; import Input from "@/components/common/input/Input"; @@ -23,6 +25,7 @@ import WarnIcon from "@/assets/icon/common/warn-circle.svg?react"; import { getImageUrl } from "@/lib/getImageUrl"; export default function WorkspaceSetting() { + const isAdmin = useIsAdmin(); const navigate = useNavigate(); const { workspaceId } = useParams(); const queryClient = useQueryClient(); @@ -221,7 +224,7 @@ export default function WorkspaceSetting() { @@ -264,7 +267,7 @@ export default function WorkspaceSetting() { className="h-7! rounded-3xl border border-surface-400 bg-surface-100 px-4 font-body2 text-text-auth-sub transition-colors duration-200 ease-in-out hover:bg-surface-200" onClick={onResetLogo} aria-label="로고 이미지 초기화 버튼" - disabled={saving || deleting} + disabled={!isAdmin || saving || deleting} > 초기화 @@ -276,7 +279,7 @@ export default function WorkspaceSetting() { value={name} placeholder="조직의 이름 또는 워크스페이스 이름을 입력해주세요" onChange={(e) => setName(e.target.value)} - disabled={saving || deleting} + disabled={!isAdmin || saving || deleting} /> setDesc(e.target.value)} minRows={4} className="min-h-90" - disabled={saving || deleting} + disabled={!isAdmin || saving || deleting} /> -
- - -
+ {isAdmin && ( +
+ + +
+ )} Date: Tue, 26 May 2026 20:59:31 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EB=B3=84=20role=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=EA=B0=80=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/RoleGuard.tsx | 31 ++++++++++++++++++++++++++++--- src/store/useWorkspaceStore.ts | 2 +- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/routes/RoleGuard.tsx b/src/routes/RoleGuard.tsx index bdf02726..81737805 100644 --- a/src/routes/RoleGuard.tsx +++ b/src/routes/RoleGuard.tsx @@ -1,8 +1,11 @@ import type { ReactElement, ReactNode } from "react"; -import { Navigate } from "react-router-dom"; +import { Navigate, useParams } from "react-router-dom"; import type { TMemberRole } from "@/types/workspace/workspace"; +import { useCoreQuery } from "@/hooks/customQuery"; + +import { getMyWorkspaces } from "@/api/workspace/org"; import useWorkspaceStore from "@/store/useWorkspaceStore"; interface IRoleGuardProps { @@ -14,14 +17,36 @@ function RoleGuard({ children, allowedRoles, }: IRoleGuardProps): ReactElement | null { + const { workspaceId } = useParams<{ workspaceId?: string }>(); const myRole = useWorkspaceStore((s) => s.myRole); + const { data: workspaces } = useCoreQuery(["my-workspaces"], getMyWorkspaces); + + //URL에 workspaceId가 있는 경우 -> URL 기준 워크스페이스의 role로 판정 + if (workspaceId) { + //워크스페이스 목록 로드 전 -> 랜더 보류 + if (!workspaces) { + return null; + } + + const targetWorkspace = workspaces.find( + (w) => w.orgId === Number(workspaceId), + ); + + //URL의 workspaceId가 내 워크스페이스 목록에 없는 경우 -> 대시보드로 + if (!targetWorkspace) { + return ; + } + + if (!allowedRoles.includes(targetWorkspace.myRole)) { + return ; + } + return <>{children}; + } //워크스페이스 초기화 전(null)에 랜더 보류 if (myRole === null) { return null; } - - //허용되지 않는 역할 -> 대시보드로 리다이렉트 if (!allowedRoles.includes(myRole)) { return ; } diff --git a/src/store/useWorkspaceStore.ts b/src/store/useWorkspaceStore.ts index 0bbf1e9d..8d8288f0 100644 --- a/src/store/useWorkspaceStore.ts +++ b/src/store/useWorkspaceStore.ts @@ -6,7 +6,7 @@ interface IWorkspaceState { selectedOrgId: number | null; myRole: TMemberRole | null; setSelectedOrgId: (orgId: number) => void; - setMyRole: (role: TMemberRole) => void; + setMyRole: (role: TMemberRole | null) => void; } const useWorkspaceStore = create((set) => ({ From 0beda2ffd36482b4a925df508b74e56530450222 Mon Sep 17 00:00:00 2001 From: JAESEON PARK Date: Tue, 26 May 2026 21:09:46 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EC=BF=BC=EB=A6=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EC=8B=9C=20null=20=EA=B3=A0=EC=A0=95=EB=90=98?= =?UTF-8?q?=EC=96=B4=EC=84=9C=20=ED=99=94=EB=A9=B4=20=EB=B9=84=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/RoleGuard.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/routes/RoleGuard.tsx b/src/routes/RoleGuard.tsx index 81737805..60127ace 100644 --- a/src/routes/RoleGuard.tsx +++ b/src/routes/RoleGuard.tsx @@ -19,15 +19,21 @@ function RoleGuard({ }: IRoleGuardProps): ReactElement | null { const { workspaceId } = useParams<{ workspaceId?: string }>(); const myRole = useWorkspaceStore((s) => s.myRole); - const { data: workspaces } = useCoreQuery(["my-workspaces"], getMyWorkspaces); + const { + data: workspaces, + isPending, + isError, + } = useCoreQuery(["my-workspaces"], getMyWorkspaces); //URL에 workspaceId가 있는 경우 -> URL 기준 워크스페이스의 role로 판정 if (workspaceId) { //워크스페이스 목록 로드 전 -> 랜더 보류 - if (!workspaces) { + if (isPending) { return null; } - + if (isError || !workspaces) { + return ; + } const targetWorkspace = workspaces.find( (w) => w.orgId === Number(workspaceId), );