Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/entities/workspace-member/api/leave-workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use server';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 구현은 일반 멤버만 멤버십 행을 하드 삭제하고, 소유자는 삭제 대상에서 제외한 뒤 소유권 이전 안내를 반환하므로 요구사항에 맞는 것으로 보입니다


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<typeof leaveWorkspaceInputSchema>;

export async function leaveWorkspace(input: LeaveWorkspaceInput): Promise<void> {
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');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
1 change: 1 addition & 0 deletions src/entities/workspace-member/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
155 changes: 119 additions & 36 deletions src/features/manage-member-profile/ui/MemberProfileForm.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 (
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-base font-bold text-slate-950">프로필</h2>
<p className="mt-1 text-sm text-slate-500">
이 워크스페이스에서 표시되는 닉네임을 수정할 수 있어요.
</p>
<div className="space-y-6">
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-base font-bold text-slate-950">프로필</h2>
<p className="mt-1 text-sm text-slate-500">
이 워크스페이스에서 표시되는 닉네임을 수정할 수 있어요.
</p>

<form className="mt-6 space-y-5" onSubmit={handleSubmit}>
<label className="block">
<span className="text-sm font-bold text-slate-900">닉네임</span>
<input
value={nickname}
onChange={(event) => {
setNickname(event.target.value);
setIsSaved(false);
}}
placeholder="닉네임을 입력하세요."
className="mt-2 h-11 w-full rounded-2xl bg-slate-100 px-4 text-sm font-medium text-slate-900 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-indigo-300"
/>
</label>

<div className="flex items-center gap-3">
<button
type="submit"
disabled={!canSubmit}
className="h-10 rounded-2xl bg-[var(--color-brand)] px-5 text-sm font-bold text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? '저장 중…' : '저장'}
</button>
{isSaved && !isDirty ? (
<span className="text-sm font-medium text-emerald-600">저장되었습니다.</span>
) : null}
</div>
</form>
</section>

<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-base font-bold text-slate-950">팀 탈퇴</h2>
<p className="mt-1 text-sm text-slate-500">
{isOwner
? '워크스페이스 소유자는 팀을 탈퇴할 수 없어요. 소유권을 이전한 후 탈퇴해주세요.'
: '탈퇴하면 이 워크스페이스에서 나가게 되며, 데이터를 복구할 수 없어요.'}
</p>

<form className="mt-6 space-y-5" onSubmit={handleSubmit}>
<label className="block">
<span className="text-sm font-bold text-slate-900">닉네임</span>
<input
value={nickname}
onChange={(event) => {
setNickname(event.target.value);
setIsSaved(false);
}}
placeholder="닉네임을 입력하세요."
className="mt-2 h-11 w-full rounded-2xl bg-slate-100 px-4 text-sm font-medium text-slate-900 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-indigo-300"
/>
</label>
<button
type="button"
disabled={isOwner}
onClick={() => setShowLeaveConfirm(true)}
className="mt-4 h-10 rounded-2xl border border-slate-200 px-5 text-sm font-bold text-slate-600 hover:bg-slate-50 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-40"
>
탈퇴하기
</button>

<div className="flex items-center gap-3">
<button
type="submit"
disabled={!canSubmit}
className="h-10 rounded-2xl bg-[var(--color-brand)] px-5 text-sm font-bold text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? '저장 중…' : '저장'}
</button>
{isSaved && !isDirty ? (
<span className="text-sm font-medium text-emerald-600">저장되었습니다.</span>
) : null}
</div>
</form>
</section>
<Dialog
open={showLeaveConfirm}
onOpenChange={(open) => !isLeaving && setShowLeaveConfirm(open)}
>
<DialogContent className="p-6 sm:max-w-[380px]">
<DialogTitle className="font-bold">정말 탈퇴하시겠어요?</DialogTitle>
<DialogDescription>
탈퇴하면 이 워크스페이스에서 나가게 되며,
<br />
데이터를 복구할 수 없어요.
</DialogDescription>
<DialogFooter className="mx-0 mb-0 border-t-0 bg-transparent p-0 pt-2">
<button
type="button"
onClick={() => setShowLeaveConfirm(false)}
disabled={isLeaving}
className="h-10 rounded-2xl border border-slate-200 px-5 text-sm font-bold text-slate-600 hover:bg-slate-50 disabled:opacity-50"
>
취소
</button>
<button
type="button"
onClick={handleLeave}
disabled={isLeaving}
className="flex h-10 items-center justify-center rounded-2xl bg-red-500 px-5 text-sm font-bold text-white hover:bg-red-600 disabled:opacity-50"
>
{isLeaving ? <Loader2 size={18} className="animate-spin" /> : '탈퇴하기'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</div>
);
}
28 changes: 24 additions & 4 deletions src/shared/lib/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// IMPORTANT: You *must* return the supabaseResponse object as it is.
Expand Down
14 changes: 14 additions & 0 deletions src/shared/lib/use-logout.ts
Original file line number Diff line number Diff line change
@@ -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';
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { handleLogout };
}
10 changes: 6 additions & 4 deletions src/views/settings/ui/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ export function SettingsView({
<SettingsTabs activeTab={activeTab} />

<div className="mt-6">
{activeTab === 'workspace' && (
<WorkspaceInfoForm workspace={workspace} canEdit={isOwner} />
)}
{activeTab === 'workspace' && <WorkspaceInfoForm workspace={workspace} canEdit={isOwner} />}
{activeTab === 'members' && (
<MemberManagementPanel
workspaceId={workspaceId}
Expand All @@ -52,7 +50,11 @@ export function SettingsView({
/>
)}
{activeTab === 'profile' && (
<MemberProfileForm workspaceId={workspaceId} initialNickname={currentNickname} />
<MemberProfileForm
workspaceId={workspaceId}
initialNickname={currentNickname}
isOwner={isOwner}
/>
)}
</div>
</div>
Expand Down
Loading