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..fef9fec --- /dev/null +++ b/src/entities/workspace-member/api/leave-workspace.ts @@ -0,0 +1,53 @@ +'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'; + +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 || 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('멤버 정보를 찾을 수 없어요'); + } + + revalidatePath('/workspaces'); +} 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..528e05f 100644 --- a/src/features/manage-member-profile/ui/MemberProfileForm.tsx +++ b/src/features/manage-member-profile/ui/MemberProfileForm.tsx @@ -1,21 +1,37 @@ 'use client'; -// 프로필 탭 — 현재 사용자의 워크스페이스 닉네임을 수정한다. -// 초기 닉네임은 서버(RSC)에서 주입받고, 저장은 updateMyNickname 서버액션을 호출한다. +// 프로필 탭 — 닉네임 수정 및 팀 탈퇴 import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; 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 +62,107 @@ 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} -
-
-
+ !isLeaving && setShowLeaveConfirm(open)} + > + + 정말 탈퇴하시겠어요? + + 탈퇴하면 이 워크스페이스에서 나가게 되며, +
+ 데이터를 복구할 수 없어요. +
+ + + + +
+
+
+ ); } diff --git a/src/shared/lib/middleware.ts b/src/shared/lib/middleware.ts index e907527..6c28791 100644 --- a/src/shared/lib/middleware.ts +++ b/src/shared/lib/middleware.ts @@ -49,13 +49,33 @@ 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)) { - 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'; + 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'; + const redirectResponse = NextResponse.redirect(url); + supabaseResponse.cookies.getAll().forEach((cookie) => redirectResponse.cookies.set(cookie)); + return redirectResponse; + } } // 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..5c41cdd --- /dev/null +++ b/src/shared/lib/use-logout.ts @@ -0,0 +1,14 @@ +import { createClient } from '@/shared/lib/client'; + +export function useLogout() { + const handleLogout = async () => { + try { + const supabase = createClient(); + await supabase.auth.signOut(); + } finally { + 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({