Skip to content
22 changes: 20 additions & 2 deletions src/components/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion src/components/sidebar/WorkspaceSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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"] }),
Expand Down
2 changes: 2 additions & 0 deletions src/constants/sidebarNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,13 @@ export const mainNav: INavItem[] = [
id: "workspace-members",
label: "멤버 관리",
workspaceSubpath: "members",
requiredRole: "ADMIN",
},
{
id: "workspace-billing",
label: "플랜 및 결제",
workspaceSubpath: "billing",
requiredRole: "ADMIN",
},
],
},
Expand Down
9 changes: 9 additions & 0 deletions src/hooks/auth/useIsAdmin.ts
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 9 additions & 3 deletions src/layout/main/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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,
]);

Expand Down
61 changes: 33 additions & 28 deletions src/pages/workspace/WorkspaceSetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -221,7 +224,7 @@ export default function WorkspaceSetting() {
<button
type="button"
onClick={openFilePicker}
disabled={saving || deleting}
disabled={!isAdmin || saving || deleting}
aria-label="로고 이미지 업로드 또는 변경"
className="flex h-60 w-60 cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-surface-400 bg-surface-200 outline-none transition-colors hover:bg-surface-300/70 focus-visible:ring-2 focus-visible:ring-primary-500/40 disabled:cursor-not-allowed disabled:opacity-50 tablet:h-46 tablet:w-46"
>
Expand Down Expand Up @@ -254,7 +257,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={openFilePicker}
aria-label="로고 이미지 업로드 버튼"
disabled={saving || deleting}
disabled={!isAdmin || saving || deleting}
>
업로드
</Button>
Expand All @@ -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}
>
초기화
</Button>
Expand All @@ -276,7 +279,7 @@ export default function WorkspaceSetting() {
value={name}
placeholder="조직의 이름 또는 워크스페이스 이름을 입력해주세요"
onChange={(e) => setName(e.target.value)}
disabled={saving || deleting}
disabled={!isAdmin || saving || deleting}
/>
<TextareaField
id="workspace-setting-desc"
Expand All @@ -286,33 +289,35 @@ export default function WorkspaceSetting() {
onChange={(e) => setDesc(e.target.value)}
minRows={4}
className="min-h-90"
disabled={saving || deleting}
disabled={!isAdmin || saving || deleting}
/>
</div>
</div>
<div className="mt-6 flex flex-wrap items-center justify-end gap-3 tablet:flex-col tablet:items-stretch">
<Button
type="button"
variant="dangerSoft"
size="big"
onClick={openDeleteModal}
disabled={saving || deleting}
className="w-auto tablet:w-full"
>
워크스페이스 삭제
</Button>
<Button
size="big"
variant="primary"
type="button"
onClick={onSave}
disabled={!name.trim() || saving || deleting}
aria-label="변경사항 저장하기"
className="w-auto tablet:w-full"
>
{saving ? "저장 중.." : "변경사항 저장하기"}
</Button>
</div>
{isAdmin && (
<div className="mt-6 flex flex-wrap items-center justify-end gap-3 tablet:flex-col tablet:items-stretch">
<Button
type="button"
variant="dangerSoft"
size="big"
onClick={openDeleteModal}
disabled={saving || deleting}
className="w-auto tablet:w-full"
>
워크스페이스 삭제
</Button>
<Button
size="big"
variant="primary"
type="button"
onClick={onSave}
disabled={!name.trim() || saving || deleting}
aria-label="변경사항 저장하기"
className="w-auto tablet:w-full"
>
{saving ? "저장 중.." : "변경사항 저장하기"}
</Button>
</div>
)}
</Card>

<Modal
Expand Down
26 changes: 23 additions & 3 deletions src/routes/MainRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -74,16 +76,34 @@ const MainRoutes: RouteObject[] = [
},
{
path: "workspace/billing",
element: <WorkspaceBillingRedirect />,
element: (
<RoleGuard allowedRoles={["ADMIN"]}>
<WorkspaceBillingRedirect />
</RoleGuard>
),
},
{
path: "workspace/:workspaceId",
element: <WorkspaceManageLayout />,
children: [
{ index: true, element: <Navigate to="settings" replace /> },
{ path: "settings", element: <WorkspaceSetting /> },
{ path: "members", element: <MemberManagement /> },
{ path: "billing", element: <Billing /> },
{
path: "members",
element: (
<RoleGuard allowedRoles={["ADMIN"]}>
<MemberManagement />
</RoleGuard>
),
},
{
path: "billing",
element: (
<RoleGuard allowedRoles={["ADMIN"]}>
<Billing />
</RoleGuard>
),
},
],
},
{
Expand Down
62 changes: 62 additions & 0 deletions src/routes/RoleGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { ReactElement, ReactNode } from "react";
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 {
children: ReactNode;
allowedRoles: TMemberRole[];
}

function RoleGuard({
children,
allowedRoles,
}: IRoleGuardProps): ReactElement | null {
const { workspaceId } = useParams<{ workspaceId?: string }>();
const myRole = useWorkspaceStore((s) => s.myRole);
const {
data: workspaces,
isPending,
isError,
} = useCoreQuery(["my-workspaces"], getMyWorkspaces);

//URL에 workspaceId가 있는 경우 -> URL 기준 워크스페이스의 role로 판정
if (workspaceId) {
//워크스페이스 목록 로드 전 -> 랜더 보류
if (isPending) {
return null;
}
if (isError || !workspaces) {
return <Navigate to="/dashboard" replace />;
}
const targetWorkspace = workspaces.find(
(w) => w.orgId === Number(workspaceId),
);

//URL의 workspaceId가 내 워크스페이스 목록에 없는 경우 -> 대시보드로
if (!targetWorkspace) {
return <Navigate to="/dashboard" replace />;
}

if (!allowedRoles.includes(targetWorkspace.myRole)) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
}

//워크스페이스 초기화 전(null)에 랜더 보류
if (myRole === null) {
return null;
}
if (!allowedRoles.includes(myRole)) {
return <Navigate to="/dashboard" replace />;
}
Comment thread
jjjsun marked this conversation as resolved.
Resolved
return <>{children}</>;
}

export default RoleGuard;
6 changes: 6 additions & 0 deletions src/store/useWorkspaceStore.ts
Original file line number Diff line number Diff line change
@@ -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 | null) => void;
}

const useWorkspaceStore = create<IWorkspaceState>((set) => ({
selectedOrgId: null,
myRole: null,
setSelectedOrgId: (orgId) => set({ selectedOrgId: orgId }),
setMyRole: (role) => set({ myRole: role }),
}));

export default useWorkspaceStore;
7 changes: 7 additions & 0 deletions src/types/navigation/navItem.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ComponentType, SVGProps } from "react";

import type { TMemberRole } from "../workspace/workspace";

export interface INavItem {
id: string;
label: string;
Expand All @@ -10,4 +12,9 @@ export interface INavItem {
/** true면 `path`와 pathname이 정확히 일치할 때만 매칭 (워크스페이스 목록 등) */
pathExact?: boolean;
children?: INavItem[];
/**
* 이 필드가 있으면 -> 해당 역할만 메뉴표시
* 이 필드가 없으면 -> 역할 상관없이 모두에게 표시
*/
requiredRole?: TMemberRole;
}