From 8d92e7203a1c1da4366f428b382356729cea9bee Mon Sep 17 00:00:00 2001 From: 0011810 Date: Wed, 15 Jul 2026 17:11:05 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83/?= =?UTF-8?q?=ED=8C=80=20=ED=83=88=ED=87=B4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 워크스페이스 헤더/사이드바 로그아웃 버튼 연결 (useLogout 공용 훅) - 팀 탈퇴 기능 추가 (workspace_members 하드 딜리트, role='member' 조건으로 원자적 처리) --- .../workspace-member/api/leave-workspace.ts | 50 ++++++ src/entities/workspace-member/index.ts | 1 + .../ui/MemberProfileForm.tsx | 150 +++++++++++++----- src/shared/lib/middleware.ts | 20 ++- src/shared/lib/use-logout.ts | 11 ++ src/views/settings/ui/SettingsView.tsx | 10 +- .../workspace-shell/ui/WorkspaceHeader.tsx | 62 +++++++- .../workspace-shell/ui/WorkspaceShell.tsx | 6 +- .../workspace-shell/ui/WorkspaceSidebar.tsx | 4 + 9 files changed, 265 insertions(+), 49 deletions(-) create mode 100644 src/entities/workspace-member/api/leave-workspace.ts create mode 100644 src/shared/lib/use-logout.ts diff --git a/src/entities/workspace-member/api/leave-workspace.ts b/src/entities/workspace-member/api/leave-workspace.ts new file mode 100644 index 0000000..e73a3df --- /dev/null +++ b/src/entities/workspace-member/api/leave-workspace.ts @@ -0,0 +1,50 @@ +'use server'; + +import { z } from 'zod'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +const leaveWorkspaceInputSchema = z.object({ + workspaceId: z.guid(), +}); + +export type LeaveWorkspaceInput = z.infer; + +export async function leaveWorkspace(input: LeaveWorkspaceInput): Promise { + const parsed = leaveWorkspaceInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error('입력값이 올바르지 않습니다'); + } + + const userId = await getCurrentUserId(); + const supabase = await createSupabaseServerClient(); + + // role 조건을 DELETE 자체에 걸어 확인과 삭제를 한 번에 처리한다(owner는 매칭되지 않아 삭제되지 않음). + const { data: deleted, error } = await supabase + .from('workspace_members') + .delete() + .eq('workspace_id', parsed.data.workspaceId) + .eq('user_id', userId) + .eq('role', 'member') + .select('user_id'); + + if (error) { + throw new Error('팀 탈퇴에 실패했어요. 잠시 후 다시 시도해주세요.'); + } + + if (deleted.length === 0) { + const { data: member } = await supabase + .from('workspace_members') + .select('role') + .eq('workspace_id', parsed.data.workspaceId) + .eq('user_id', userId) + .maybeSingle(); + + if (member?.role === 'owner') { + throw new Error( + '워크스페이스 소유자는 팀을 탈퇴할 수 없어요. 소유권을 이전한 후 탈퇴해주세요.', + ); + } + throw new Error('멤버 정보를 찾을 수 없어요'); + } +} diff --git a/src/entities/workspace-member/index.ts b/src/entities/workspace-member/index.ts index 1915af2..73a2a71 100644 --- a/src/entities/workspace-member/index.ts +++ b/src/entities/workspace-member/index.ts @@ -17,6 +17,7 @@ export { // 서버 전용 조회 함수(next/headers 의존)는 클라이언트 번들 오염을 피하려 barrel에서 제외하고 // RSC에서 직접 경로로 import한다. (getWorkspaceById와 동일한 컨벤션) export { updateMyNickname, type UpdateMyNicknameInput } from './api/update-my-nickname'; +export { leaveWorkspace, type LeaveWorkspaceInput } from './api/leave-workspace'; export { WORKSPACE_MEMBER_ROLE_META, WORKSPACE_MEMBER_STATUS_META, diff --git a/src/features/manage-member-profile/ui/MemberProfileForm.tsx b/src/features/manage-member-profile/ui/MemberProfileForm.tsx index 3eaa808..0f2e60e 100644 --- a/src/features/manage-member-profile/ui/MemberProfileForm.tsx +++ b/src/features/manage-member-profile/ui/MemberProfileForm.tsx @@ -1,21 +1,36 @@ 'use client'; -// 프로필 탭 — 현재 사용자의 워크스페이스 닉네임을 수정한다. -// 초기 닉네임은 서버(RSC)에서 주입받고, 저장은 updateMyNickname 서버액션을 호출한다. +// 프로필 탭 — 닉네임 수정 및 팀 탈퇴 import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; -import { updateMyNickname } from '@/entities/workspace-member'; +import { leaveWorkspace, updateMyNickname } from '@/entities/workspace-member'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/shared/ui/dialog'; interface MemberProfileFormProps { workspaceId: string; initialNickname: string; + isOwner: boolean; } -export function MemberProfileForm({ workspaceId, initialNickname }: MemberProfileFormProps) { +export function MemberProfileForm({ + workspaceId, + initialNickname, + isOwner, +}: MemberProfileFormProps) { + const router = useRouter(); const [committedNickname, setCommittedNickname] = useState(initialNickname); const [nickname, setNickname] = useState(initialNickname); const [isSaved, setIsSaved] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const [isLeaving, setIsLeaving] = useState(false); + const [showLeaveConfirm, setShowLeaveConfirm] = useState(false); const isDirty = nickname !== committedNickname; const canSubmit = nickname.trim().length > 0 && isDirty && !isSubmitting; @@ -46,40 +61,103 @@ export function MemberProfileForm({ workspaceId, initialNickname }: MemberProfil } }; + const handleLeave = async () => { + setIsLeaving(true); + try { + await leaveWorkspace({ workspaceId }); + router.push('/workspaces'); + // 성공 시 페이지 이동으로 언마운트되므로 isLeaving을 리셋하지 않는다(버튼 깜빡임 방지) + } catch (error) { + toast.error( + error instanceof Error ? error.message : '팀 탈퇴에 실패했어요. 잠시 후 다시 시도해주세요.', + ); + setShowLeaveConfirm(false); + setIsLeaving(false); + } + }; + return ( -
-

프로필

-

- 이 워크스페이스에서 표시되는 닉네임을 수정할 수 있어요. -

+
+
+

프로필

+

+ 이 워크스페이스에서 표시되는 닉네임을 수정할 수 있어요. +

+ +
+ + +
+ + {isSaved && !isDirty ? ( + 저장되었습니다. + ) : null} +
+
+
+ +
+

팀 탈퇴

+

+ {isOwner + ? '워크스페이스 소유자는 팀을 탈퇴할 수 없어요. 소유권을 이전한 후 탈퇴해주세요.' + : '탈퇴하면 이 워크스페이스에서 나가게 되며, 데이터를 복구할 수 없어요.'} +

-
- + -
- - {isSaved && !isDirty ? ( - 저장되었습니다. - ) : null} -
-
-
+ + + 정말 탈퇴하시겠어요? + + 탈퇴하면 이 워크스페이스에서 나가게 되며, +
+ 데이터를 복구할 수 없어요. +
+ + + + +
+
+
+ ); } diff --git a/src/shared/lib/middleware.ts b/src/shared/lib/middleware.ts index e907527..be790f6 100644 --- a/src/shared/lib/middleware.ts +++ b/src/shared/lib/middleware.ts @@ -53,9 +53,23 @@ export async function updateSession(request: NextRequest) { } if (user && AUTH_PAGES.includes(request.nextUrl.pathname)) { - const url = request.nextUrl.clone(); - url.pathname = '/workspaces'; - return NextResponse.redirect(url); + const { data: profile } = await supabase + .from('profiles') + .select('real_name') + .eq('id', user.sub) + .maybeSingle(); + + if (profile?.real_name) { + const url = request.nextUrl.clone(); + url.pathname = '/workspaces'; + return NextResponse.redirect(url); + } + + if (request.nextUrl.pathname === '/login') { + const url = request.nextUrl.clone(); + url.pathname = '/signup'; + return NextResponse.redirect(url); + } } // IMPORTANT: You *must* return the supabaseResponse object as it is. diff --git a/src/shared/lib/use-logout.ts b/src/shared/lib/use-logout.ts new file mode 100644 index 0000000..846d63f --- /dev/null +++ b/src/shared/lib/use-logout.ts @@ -0,0 +1,11 @@ +import { createClient } from '@/shared/lib/client'; + +export function useLogout() { + const handleLogout = async () => { + const supabase = createClient(); + await supabase.auth.signOut(); + window.location.href = '/login'; + }; + + return { handleLogout }; +} diff --git a/src/views/settings/ui/SettingsView.tsx b/src/views/settings/ui/SettingsView.tsx index 05207de..667f73e 100644 --- a/src/views/settings/ui/SettingsView.tsx +++ b/src/views/settings/ui/SettingsView.tsx @@ -39,9 +39,7 @@ export function SettingsView({
- {activeTab === 'workspace' && ( - - )} + {activeTab === 'workspace' && } {activeTab === 'members' && ( )} {activeTab === 'profile' && ( - + )}
diff --git a/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx b/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx index dbb17b6..e2f21f3 100644 --- a/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx +++ b/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx @@ -1,25 +1,48 @@ 'use client'; // 하나의 워크스페이스 내부 페이지에서 공통으로 사용하는 상단 헤더입니다. -import { Bell, Search, UserRoundPlus } from 'lucide-react'; +import { Bell, LogOut, Search, Settings, UserRoundPlus } from 'lucide-react'; +import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; import type { WorkspaceMember } from '@/entities/workspace-member'; +import { useLogout } from '@/shared/lib/use-logout'; +import { cn } from '@/shared/lib/utils'; import type { WorkspaceNavigationItem } from '../model/workspace-navigation'; interface WorkspaceHeaderProps { navigationItems: WorkspaceNavigationItem[]; currentMember: WorkspaceMember; + workspaceId: string; } function getCurrentPageTitle(pathname: string, navigationItems: WorkspaceNavigationItem[]): string { const currentNavigationItem = navigationItems.find((item) => pathname.endsWith(`/${item.href}`)); - return currentNavigationItem?.label ?? '대시보드'; } -export function WorkspaceHeader({ navigationItems, currentMember }: WorkspaceHeaderProps) { +export function WorkspaceHeader({ + navigationItems, + currentMember, + workspaceId, +}: WorkspaceHeaderProps) { const pathname = usePathname(); const title = getCurrentPageTitle(pathname, navigationItems); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!isMenuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setIsMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isMenuOpen]); + + const { handleLogout } = useLogout(); return (
@@ -52,8 +75,37 @@ export function WorkspaceHeader({ navigationItems, currentMember }: WorkspaceHea -
- {currentMember.avatarLabel} +
+ + + {isMenuOpen && ( +
+ setIsMenuOpen(false)} + className={cn( + 'hover:text-brand flex items-center gap-3 px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50', + )} + > +
+ )}
diff --git a/src/widgets/workspace-shell/ui/WorkspaceShell.tsx b/src/widgets/workspace-shell/ui/WorkspaceShell.tsx index d5e4f1b..a38498e 100644 --- a/src/widgets/workspace-shell/ui/WorkspaceShell.tsx +++ b/src/widgets/workspace-shell/ui/WorkspaceShell.tsx @@ -36,7 +36,11 @@ export function WorkspaceShell({ />
- +
{children}
diff --git a/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx b/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx index 0653944..ef4eaae 100644 --- a/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx +++ b/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx @@ -7,6 +7,7 @@ import { ChevronRight, LogOut, Menu } from 'lucide-react'; import { usePathname } from 'next/navigation'; import { WORKSPACE_PURPOSE_META, type Workspace } from '@/entities/workspace'; import type { WorkspaceMember } from '@/entities/workspace-member'; +import { useLogout } from '@/shared/lib/use-logout'; import { cn } from '@/shared/lib/utils'; import type { WorkspaceNavigationItem } from '../model/workspace-navigation'; @@ -29,6 +30,8 @@ export function WorkspaceSidebar({ }: WorkspaceSidebarProps) { const pathname = usePathname(); const purposeMeta = WORKSPACE_PURPOSE_META[workspace.purpose]; + + const { handleLogout } = useLogout(); const PurposeIcon = purposeMeta.icon; return ( @@ -141,6 +144,7 @@ export function WorkspaceSidebar({ From c7a1efe593818aa994d1e2eba4d6faa150576124 Mon Sep 17 00:00:00 2001 From: 0011810 Date: Thu, 16 Jul 2026 10:02:17 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20-=20=EC=BA=90=EC=8B=9C=20=EB=AC=B4?= =?UTF-8?q?=ED=9A=A8=ED=99=94,=20=EC=BF=A0=ED=82=A4=20=EC=9C=A0=EC=8B=A4,?= =?UTF-8?q?=20=EB=A1=9C=EB=94=A9=20=EC=A4=91=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EB=8B=AB=ED=9E=98=20=EB=B0=A9=EC=A7=80=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - leave-workspace: revalidatePath 추가, deleted null 체크 - middleware: 리다이렉트 응답에 세션 쿠키 복사 (3곳) - MemberProfileForm: 탈퇴 처리 중 팝업 닫힘 방지 - use-logout: signOut 실패해도 로그인 페이지로 이동하도록 try/finally 처리 --- src/entities/workspace-member/api/leave-workspace.ts | 5 ++++- .../manage-member-profile/ui/MemberProfileForm.tsx | 8 ++++++-- src/shared/lib/middleware.ts | 12 +++++++++--- src/shared/lib/use-logout.ts | 9 ++++++--- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/entities/workspace-member/api/leave-workspace.ts b/src/entities/workspace-member/api/leave-workspace.ts index e73a3df..fef9fec 100644 --- a/src/entities/workspace-member/api/leave-workspace.ts +++ b/src/entities/workspace-member/api/leave-workspace.ts @@ -1,5 +1,6 @@ 'use server'; +import { revalidatePath } from 'next/cache'; import { z } from 'zod'; import { getCurrentUserId } from '@/shared/api/supabase/current-user'; import { createSupabaseServerClient } from '@/shared/api/supabase/server'; @@ -32,7 +33,7 @@ export async function leaveWorkspace(input: LeaveWorkspaceInput): Promise throw new Error('팀 탈퇴에 실패했어요. 잠시 후 다시 시도해주세요.'); } - if (deleted.length === 0) { + if (!deleted || deleted.length === 0) { const { data: member } = await supabase .from('workspace_members') .select('role') @@ -47,4 +48,6 @@ export async function leaveWorkspace(input: LeaveWorkspaceInput): Promise } throw new Error('멤버 정보를 찾을 수 없어요'); } + + revalidatePath('/workspaces'); } diff --git a/src/features/manage-member-profile/ui/MemberProfileForm.tsx b/src/features/manage-member-profile/ui/MemberProfileForm.tsx index 2f540f0..528e05f 100644 --- a/src/features/manage-member-profile/ui/MemberProfileForm.tsx +++ b/src/features/manage-member-profile/ui/MemberProfileForm.tsx @@ -131,7 +131,10 @@ export function MemberProfileForm({ 탈퇴하기 - + !isLeaving && setShowLeaveConfirm(open)} + > 정말 탈퇴하시겠어요? @@ -143,7 +146,8 @@ export function MemberProfileForm({ diff --git a/src/shared/lib/middleware.ts b/src/shared/lib/middleware.ts index be790f6..6c28791 100644 --- a/src/shared/lib/middleware.ts +++ b/src/shared/lib/middleware.ts @@ -49,7 +49,9 @@ export async function updateSession(request: NextRequest) { const url = request.nextUrl.clone(); url.pathname = '/login'; url.search = new URLSearchParams({ redirect: request.nextUrl.pathname }).toString(); - return NextResponse.redirect(url); + const redirectResponse = NextResponse.redirect(url); + supabaseResponse.cookies.getAll().forEach((cookie) => redirectResponse.cookies.set(cookie)); + return redirectResponse; } if (user && AUTH_PAGES.includes(request.nextUrl.pathname)) { @@ -62,13 +64,17 @@ export async function updateSession(request: NextRequest) { if (profile?.real_name) { const url = request.nextUrl.clone(); url.pathname = '/workspaces'; - return NextResponse.redirect(url); + const redirectResponse = NextResponse.redirect(url); + supabaseResponse.cookies.getAll().forEach((cookie) => redirectResponse.cookies.set(cookie)); + return redirectResponse; } if (request.nextUrl.pathname === '/login') { const url = request.nextUrl.clone(); url.pathname = '/signup'; - return NextResponse.redirect(url); + const redirectResponse = NextResponse.redirect(url); + supabaseResponse.cookies.getAll().forEach((cookie) => redirectResponse.cookies.set(cookie)); + return redirectResponse; } } diff --git a/src/shared/lib/use-logout.ts b/src/shared/lib/use-logout.ts index 846d63f..5c41cdd 100644 --- a/src/shared/lib/use-logout.ts +++ b/src/shared/lib/use-logout.ts @@ -2,9 +2,12 @@ import { createClient } from '@/shared/lib/client'; export function useLogout() { const handleLogout = async () => { - const supabase = createClient(); - await supabase.auth.signOut(); - window.location.href = '/login'; + try { + const supabase = createClient(); + await supabase.auth.signOut(); + } finally { + window.location.href = '/login'; + } }; return { handleLogout };