From 53298fcdb3ee3af531d7a7ca10e3d056b86df834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=84=B1=EC=A7=84?= Date: Mon, 20 Jul 2026 17:34:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=EB=AA=A9=EC=A0=81=EB=B3=84=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EB=AA=A8=EB=93=88=20=EC=A0=91=EA=B7=BC=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/meeting-notes/new/page.tsx | 2 ++ .../[workspaceId]/meeting-notes/page.tsx | 2 ++ .../workspaces/[workspaceId]/notices/page.tsx | 2 ++ .../[workspaceId]/progress-chart/page.tsx | 2 ++ .../[workspaceId]/project-management/page.tsx | 2 ++ .../[workspaceId]/sprint-board/page.tsx | 2 ++ .../[workspaceId]/work-schedule/page.tsx | 2 ++ src/entities/workspace/index.ts | 1 + .../lib/assert-workspace-route-access.ts | 33 +++++++++++++++++++ 9 files changed, 48 insertions(+) create mode 100644 src/entities/workspace/lib/assert-workspace-route-access.ts diff --git a/src/app/workspaces/[workspaceId]/meeting-notes/new/page.tsx b/src/app/workspaces/[workspaceId]/meeting-notes/new/page.tsx index dab95ba..1507ca7 100644 --- a/src/app/workspaces/[workspaceId]/meeting-notes/new/page.tsx +++ b/src/app/workspaces/[workspaceId]/meeting-notes/new/page.tsx @@ -1,4 +1,5 @@ import { NewMeetingNotePage } from '@/views/meeting-notes'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace'; interface WorkspaceNewMeetingNotePageProps { params: Promise<{ @@ -10,6 +11,7 @@ export default async function WorkspaceNewMeetingNotePage({ params, }: WorkspaceNewMeetingNotePageProps) { const { workspaceId } = await params; + await assertWorkspaceRouteAccess(workspaceId, 'meeting-notes'); return ; } diff --git a/src/app/workspaces/[workspaceId]/meeting-notes/page.tsx b/src/app/workspaces/[workspaceId]/meeting-notes/page.tsx index 84e4faa..6bec1e8 100644 --- a/src/app/workspaces/[workspaceId]/meeting-notes/page.tsx +++ b/src/app/workspaces/[workspaceId]/meeting-notes/page.tsx @@ -1,4 +1,5 @@ import { MeetingNotesPage } from '@/views/meeting-notes'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace'; interface WorkspaceMeetingNotesPageProps { params: Promise<{ @@ -10,6 +11,7 @@ export default async function WorkspaceMeetingNotesPage({ params, }: WorkspaceMeetingNotesPageProps) { const { workspaceId } = await params; + await assertWorkspaceRouteAccess(workspaceId, 'meeting-notes'); return ; } diff --git a/src/app/workspaces/[workspaceId]/notices/page.tsx b/src/app/workspaces/[workspaceId]/notices/page.tsx index 8e069b9..352a15b 100644 --- a/src/app/workspaces/[workspaceId]/notices/page.tsx +++ b/src/app/workspaces/[workspaceId]/notices/page.tsx @@ -1,5 +1,6 @@ // 워크스페이스 공지 페이지의 라우트 진입점입니다. import { getNoticeBoard } from '@/entities/notice/api/get-notice-board'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace'; import { NoticesView } from '@/views/store-operation/notices'; interface NoticesPageProps { @@ -10,6 +11,7 @@ interface NoticesPageProps { export default async function NoticesPage({ params }: NoticesPageProps) { const { workspaceId } = await params; + await assertWorkspaceRouteAccess(workspaceId, 'notices'); const initialData = await getNoticeBoard(workspaceId); return ; diff --git a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx index a6b5722..43e6645 100644 --- a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx +++ b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx @@ -1,5 +1,6 @@ // 진행률 차트 라우트 — 워크스페이스 존재/용도만 서버에서 판정하고, 데이터 조회·조립은 client 컨테이너(useQuery)에 위임한다. import { ProgressChartPage } from '@/views/progress-chart'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace'; interface WorkspaceProgressChartPageProps { params: Promise<{ @@ -11,6 +12,7 @@ export default async function WorkspaceProgressChartPage({ params, }: WorkspaceProgressChartPageProps) { const { workspaceId } = await params; + await assertWorkspaceRouteAccess(workspaceId, 'progress-chart'); return ; } diff --git a/src/app/workspaces/[workspaceId]/project-management/page.tsx b/src/app/workspaces/[workspaceId]/project-management/page.tsx index 3b86497..74f9e78 100644 --- a/src/app/workspaces/[workspaceId]/project-management/page.tsx +++ b/src/app/workspaces/[workspaceId]/project-management/page.tsx @@ -1,4 +1,5 @@ import { ProjectManagementPage } from '@/views/project-management'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace'; interface WorkspaceProjectManagementPageProps { params: Promise<{ @@ -10,6 +11,7 @@ export default async function WorkspaceProjectManagementPage({ params, }: WorkspaceProjectManagementPageProps) { const { workspaceId } = await params; + await assertWorkspaceRouteAccess(workspaceId, 'project-management'); return ; } diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index 7115077..7fde8c2 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -1,6 +1,7 @@ // 스프린트 보드 라우트 — 선택 스프린트를 searchParam(?sprint=id)으로 읽어 client 컨테이너에 넘긴다. // 스프린트/태스크는 컨테이너가 useQuery로 조회하고, 담당자 표시명 해석용 members만 서버에서 조회해 주입한다. import { getWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member/api/get-workspace-members-by-id'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace'; import { SprintBoardView } from '@/views/side-project/sprint-board'; interface SprintBoardRouteProps { @@ -10,6 +11,7 @@ interface SprintBoardRouteProps { export default async function SprintBoardPage({ params, searchParams }: SprintBoardRouteProps) { const { workspaceId } = await params; + await assertWorkspaceRouteAccess(workspaceId, 'sprint-board'); const { sprint: sprintParam } = await searchParams; const selectedSprintId = typeof sprintParam === 'string' ? sprintParam : undefined; diff --git a/src/app/workspaces/[workspaceId]/work-schedule/page.tsx b/src/app/workspaces/[workspaceId]/work-schedule/page.tsx index 22d4b40..084ef7e 100644 --- a/src/app/workspaces/[workspaceId]/work-schedule/page.tsx +++ b/src/app/workspaces/[workspaceId]/work-schedule/page.tsx @@ -1,5 +1,6 @@ // 현재 주의 멤버, 근무유형, 스케줄 데이터를 병렬 조회해 근무 스케줄 화면에 전달하는 서버 페이지입니다. import { getWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member/api/get-workspace-members-by-id'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace'; import { getWorkScheduleEntriesByWeek } from '@/entities/work-schedule/api/get-work-schedule-entries-by-week'; import { getWorkShiftTypesByWorkspaceId } from '@/entities/work-schedule/api/get-work-shift-types-by-workspace-id'; import { ensureWeeklyWorkScheduleEntries } from '@/entities/work-schedule/api/ensure-weekly-work-schedule-entries'; @@ -14,6 +15,7 @@ interface WorkSchedulePageProps { export default async function WorkSchedulePage({ params }: WorkSchedulePageProps) { const { workspaceId } = await params; + await assertWorkspaceRouteAccess(workspaceId, 'work-schedule'); const { startDate, endDate } = getCurrentWeekRange(); await ensureWeeklyWorkScheduleEntries(workspaceId, startDate); const [members, shifts, schedule] = await Promise.all([ diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index d6de516..24f78d7 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -9,6 +9,7 @@ export type { CreateWorkspaceRpcArgs, } from './model/workspace.db.types'; export { toUiPurpose, toDbPurpose } from './model/purpose.mapper'; +export { assertWorkspaceRouteAccess } from './lib/assert-workspace-route-access'; export { createWorkspaceSchema, createWorkspaceInputSchema, diff --git a/src/entities/workspace/lib/assert-workspace-route-access.ts b/src/entities/workspace/lib/assert-workspace-route-access.ts new file mode 100644 index 0000000..4ab63ce --- /dev/null +++ b/src/entities/workspace/lib/assert-workspace-route-access.ts @@ -0,0 +1,33 @@ +// 워크스페이스 목적에 맞지 않는 전용 모듈 URL의 직접 접근을 서버에서 차단합니다. +import { notFound } from 'next/navigation'; +import { getWorkspaceById } from '../api/get-workspace-by-id'; +import type { WorkspacePurpose } from '../model/workspace.types'; + +type WorkspaceProtectedRoute = + | 'meeting-notes' + | 'notices' + | 'progress-chart' + | 'project-management' + | 'sprint-board' + | 'work-schedule'; + +// 각 목적에서 제공하는 전용 모듈 라우트를 정의합니다. 공통 모듈은 별도 보호가 필요하지 않습니다. +const workspaceRoutePurposes: Record = { + 'meeting-notes': ['team-project', 'side-project'], + notices: ['team-project', 'store-operation'], + 'progress-chart': ['team-project', 'side-project'], + 'project-management': ['team-project'], + 'sprint-board': ['side-project'], + 'work-schedule': ['store-operation'], +}; + +export async function assertWorkspaceRouteAccess( + workspaceId: string, + route: WorkspaceProtectedRoute, +): Promise { + const workspace = await getWorkspaceById(workspaceId); + + if (!workspace || !workspaceRoutePurposes[route].includes(workspace.purpose)) { + notFound(); + } +} From 479ae4d08ad310fdaf3cf74f00ae79b6aaeea7ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=84=B1=EC=A7=84?= Date: Mon, 20 Jul 2026 17:36:31 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=EC=A0=84=EC=9A=A9=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EC=A0=91=EA=B7=BC=20=EC=A0=9C=ED=95=9C=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/meeting-notes/new/page.tsx | 2 +- .../[workspaceId]/meeting-notes/page.tsx | 2 +- .../workspaces/[workspaceId]/notices/page.tsx | 2 +- .../[workspaceId]/progress-chart/page.tsx | 2 +- .../[workspaceId]/project-management/page.tsx | 2 +- .../[workspaceId]/sprint-board/page.tsx | 2 +- .../[workspaceId]/unavailable/page.tsx | 14 +++++++++ .../[workspaceId]/work-schedule/page.tsx | 2 +- src/entities/workspace/index.ts | 1 - .../lib/assert-workspace-route-access.ts | 4 +-- src/views/workspace-unavailable/index.ts | 2 ++ .../ui/WorkspaceUnavailableView.tsx | 30 +++++++++++++++++++ .../workspace-shell/ui/WorkspaceHeader.tsx | 1 + 13 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 src/app/workspaces/[workspaceId]/unavailable/page.tsx create mode 100644 src/views/workspace-unavailable/index.ts create mode 100644 src/views/workspace-unavailable/ui/WorkspaceUnavailableView.tsx diff --git a/src/app/workspaces/[workspaceId]/meeting-notes/new/page.tsx b/src/app/workspaces/[workspaceId]/meeting-notes/new/page.tsx index 1507ca7..7b2cce0 100644 --- a/src/app/workspaces/[workspaceId]/meeting-notes/new/page.tsx +++ b/src/app/workspaces/[workspaceId]/meeting-notes/new/page.tsx @@ -1,5 +1,5 @@ import { NewMeetingNotePage } from '@/views/meeting-notes'; -import { assertWorkspaceRouteAccess } from '@/entities/workspace'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace/lib/assert-workspace-route-access'; interface WorkspaceNewMeetingNotePageProps { params: Promise<{ diff --git a/src/app/workspaces/[workspaceId]/meeting-notes/page.tsx b/src/app/workspaces/[workspaceId]/meeting-notes/page.tsx index 6bec1e8..47c125d 100644 --- a/src/app/workspaces/[workspaceId]/meeting-notes/page.tsx +++ b/src/app/workspaces/[workspaceId]/meeting-notes/page.tsx @@ -1,5 +1,5 @@ import { MeetingNotesPage } from '@/views/meeting-notes'; -import { assertWorkspaceRouteAccess } from '@/entities/workspace'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace/lib/assert-workspace-route-access'; interface WorkspaceMeetingNotesPageProps { params: Promise<{ diff --git a/src/app/workspaces/[workspaceId]/notices/page.tsx b/src/app/workspaces/[workspaceId]/notices/page.tsx index 352a15b..40ef63b 100644 --- a/src/app/workspaces/[workspaceId]/notices/page.tsx +++ b/src/app/workspaces/[workspaceId]/notices/page.tsx @@ -1,6 +1,6 @@ // 워크스페이스 공지 페이지의 라우트 진입점입니다. import { getNoticeBoard } from '@/entities/notice/api/get-notice-board'; -import { assertWorkspaceRouteAccess } from '@/entities/workspace'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace/lib/assert-workspace-route-access'; import { NoticesView } from '@/views/store-operation/notices'; interface NoticesPageProps { diff --git a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx index 43e6645..2aa5514 100644 --- a/src/app/workspaces/[workspaceId]/progress-chart/page.tsx +++ b/src/app/workspaces/[workspaceId]/progress-chart/page.tsx @@ -1,6 +1,6 @@ // 진행률 차트 라우트 — 워크스페이스 존재/용도만 서버에서 판정하고, 데이터 조회·조립은 client 컨테이너(useQuery)에 위임한다. import { ProgressChartPage } from '@/views/progress-chart'; -import { assertWorkspaceRouteAccess } from '@/entities/workspace'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace/lib/assert-workspace-route-access'; interface WorkspaceProgressChartPageProps { params: Promise<{ diff --git a/src/app/workspaces/[workspaceId]/project-management/page.tsx b/src/app/workspaces/[workspaceId]/project-management/page.tsx index 74f9e78..66d5c39 100644 --- a/src/app/workspaces/[workspaceId]/project-management/page.tsx +++ b/src/app/workspaces/[workspaceId]/project-management/page.tsx @@ -1,5 +1,5 @@ import { ProjectManagementPage } from '@/views/project-management'; -import { assertWorkspaceRouteAccess } from '@/entities/workspace'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace/lib/assert-workspace-route-access'; interface WorkspaceProjectManagementPageProps { params: Promise<{ diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index 7fde8c2..e6a0049 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -1,7 +1,7 @@ // 스프린트 보드 라우트 — 선택 스프린트를 searchParam(?sprint=id)으로 읽어 client 컨테이너에 넘긴다. // 스프린트/태스크는 컨테이너가 useQuery로 조회하고, 담당자 표시명 해석용 members만 서버에서 조회해 주입한다. import { getWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member/api/get-workspace-members-by-id'; -import { assertWorkspaceRouteAccess } from '@/entities/workspace'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace/lib/assert-workspace-route-access'; import { SprintBoardView } from '@/views/side-project/sprint-board'; interface SprintBoardRouteProps { diff --git a/src/app/workspaces/[workspaceId]/unavailable/page.tsx b/src/app/workspaces/[workspaceId]/unavailable/page.tsx new file mode 100644 index 0000000..5807b33 --- /dev/null +++ b/src/app/workspaces/[workspaceId]/unavailable/page.tsx @@ -0,0 +1,14 @@ +// 목적에 맞지 않는 워크스페이스 전용 모듈에 직접 접근했을 때 표시하는 라우트입니다. +import { WorkspaceUnavailableView } from '@/views/workspace-unavailable'; + +interface WorkspaceUnavailablePageProps { + params: Promise<{ + workspaceId: string; + }>; +} + +export default async function WorkspaceUnavailablePage({ params }: WorkspaceUnavailablePageProps) { + const { workspaceId } = await params; + + return ; +} diff --git a/src/app/workspaces/[workspaceId]/work-schedule/page.tsx b/src/app/workspaces/[workspaceId]/work-schedule/page.tsx index 084ef7e..90a291f 100644 --- a/src/app/workspaces/[workspaceId]/work-schedule/page.tsx +++ b/src/app/workspaces/[workspaceId]/work-schedule/page.tsx @@ -1,6 +1,6 @@ // 현재 주의 멤버, 근무유형, 스케줄 데이터를 병렬 조회해 근무 스케줄 화면에 전달하는 서버 페이지입니다. import { getWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member/api/get-workspace-members-by-id'; -import { assertWorkspaceRouteAccess } from '@/entities/workspace'; +import { assertWorkspaceRouteAccess } from '@/entities/workspace/lib/assert-workspace-route-access'; import { getWorkScheduleEntriesByWeek } from '@/entities/work-schedule/api/get-work-schedule-entries-by-week'; import { getWorkShiftTypesByWorkspaceId } from '@/entities/work-schedule/api/get-work-shift-types-by-workspace-id'; import { ensureWeeklyWorkScheduleEntries } from '@/entities/work-schedule/api/ensure-weekly-work-schedule-entries'; diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index 24f78d7..d6de516 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -9,7 +9,6 @@ export type { CreateWorkspaceRpcArgs, } from './model/workspace.db.types'; export { toUiPurpose, toDbPurpose } from './model/purpose.mapper'; -export { assertWorkspaceRouteAccess } from './lib/assert-workspace-route-access'; export { createWorkspaceSchema, createWorkspaceInputSchema, diff --git a/src/entities/workspace/lib/assert-workspace-route-access.ts b/src/entities/workspace/lib/assert-workspace-route-access.ts index 4ab63ce..2c70da9 100644 --- a/src/entities/workspace/lib/assert-workspace-route-access.ts +++ b/src/entities/workspace/lib/assert-workspace-route-access.ts @@ -1,5 +1,5 @@ // 워크스페이스 목적에 맞지 않는 전용 모듈 URL의 직접 접근을 서버에서 차단합니다. -import { notFound } from 'next/navigation'; +import { redirect } from 'next/navigation'; import { getWorkspaceById } from '../api/get-workspace-by-id'; import type { WorkspacePurpose } from '../model/workspace.types'; @@ -28,6 +28,6 @@ export async function assertWorkspaceRouteAccess( const workspace = await getWorkspaceById(workspaceId); if (!workspace || !workspaceRoutePurposes[route].includes(workspace.purpose)) { - notFound(); + redirect(`/workspaces/${workspaceId}/unavailable`); } } diff --git a/src/views/workspace-unavailable/index.ts b/src/views/workspace-unavailable/index.ts new file mode 100644 index 0000000..983a3d2 --- /dev/null +++ b/src/views/workspace-unavailable/index.ts @@ -0,0 +1,2 @@ +// 접근할 수 없는 워크스페이스 전용 모듈 화면의 Public API입니다. +export { WorkspaceUnavailableView } from './ui/WorkspaceUnavailableView'; diff --git a/src/views/workspace-unavailable/ui/WorkspaceUnavailableView.tsx b/src/views/workspace-unavailable/ui/WorkspaceUnavailableView.tsx new file mode 100644 index 0000000..6932a6a --- /dev/null +++ b/src/views/workspace-unavailable/ui/WorkspaceUnavailableView.tsx @@ -0,0 +1,30 @@ +// 현재 워크스페이스 목적에서 제공하지 않는 모듈에 접근했을 때 안내하는 화면입니다. +import { ArrowLeft, ShieldAlert } from 'lucide-react'; +import Link from 'next/link'; + +interface WorkspaceUnavailableViewProps { + workspaceId: string; +} + +export function WorkspaceUnavailableView({ workspaceId }: WorkspaceUnavailableViewProps) { + return ( +
+
+ + +

접근할 수 없는 페이지입니다.

+

+ 현재 워크스페이스 템플릿에서는 이 기능을 사용할 수 없습니다. +

+ +
+
+ ); +} diff --git a/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx b/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx index 0158ea9..36259e8 100644 --- a/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx +++ b/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx @@ -21,6 +21,7 @@ interface WorkspaceHeaderProps { function getCurrentPageTitle(pathname: string, navigationItems: WorkspaceNavigationItem[]): string { if (pathname.endsWith('/notifications')) return '알림'; + if (pathname.endsWith('/unavailable')) return '접근 제한'; const currentNavigationItem = navigationItems.find((item) => pathname.endsWith(`/${item.href}`)); return currentNavigationItem?.label ?? '대시보드';