diff --git a/src/entities/workspace/api/delete-workspace.ts b/src/entities/workspace/api/delete-workspace.ts new file mode 100644 index 0000000..afce772 --- /dev/null +++ b/src/entities/workspace/api/delete-workspace.ts @@ -0,0 +1,144 @@ +'use server'; + +// 워크스페이스 삭제 서버액션 — owner면 멤버 수와 무관하게 실행 가능 +// begin_workspace_deletion(선점) → mark_workspace_deletion_in_progress(reserved→deleting 전환, 이 시점부터 +// 자동 만료 없음 + 신규 업로드 확실히 차단) → Storage 목록 조회·배치 삭제(목록이 빌 때까지 반복) → +// finalize_workspace_deletion(실제 DB 삭제) 순서로 진행한다. deleting 전환 이후에만 목록을 조회하므로 그 +// 사이에 새 파일이 올라와 누락되는 일이 없다. workspace row와 owner 멤버십은 finalize 전까지 그대로 +// 남아있어 일반 유저 세션으로도 Storage RLS를 그대로 통과하므로 admin 클라이언트가 필요 없다. +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +const WORKSPACE_RESOURCES_BUCKET = 'workspace-resources'; +const STORAGE_LIST_PAGE_SIZE = 1000; + +const DELETE_FAILED_MESSAGE = '워크스페이스 삭제에 실패했어요. 잠시 후 다시 시도해주세요.'; + +const deleteWorkspaceInputSchema = z.object({ + workspaceId: z.guid(), +}); + +export type DeleteWorkspaceInput = z.infer; + +type ServerSupabaseClient = Awaited>; + +// .list()는 한 번에 최대 1000개만 반환하므로, offset을 늘려가며 전체 파일 목록을 모은다. +async function listAllStorageFileNames( + supabase: ServerSupabaseClient, + workspaceId: string, +): Promise { + const names: string[] = []; + let offset = 0; + + while (true) { + const { data: page, error } = await supabase.storage + .from(WORKSPACE_RESOURCES_BUCKET) + .list(workspaceId, { + limit: STORAGE_LIST_PAGE_SIZE, + offset, + sortBy: { column: 'name', order: 'asc' }, + }); + + if (error) { + throw error; + } + if (!page || page.length === 0) { + break; + } + + // id가 null인 항목은 폴더 placeholder라 remove() 대상이 아니다 — 업로드 경로가 항상 평면 구조라 + // 지금은 나타나지 않지만, 혹시 남아있으면 cleanupWorkspaceStorage의 while 루프가 끝나지 않으므로 걸러낸다. + names.push(...page.filter((file) => file.id !== null).map((file) => file.name)); + + if (page.length < STORAGE_LIST_PAGE_SIZE) { + break; + } + offset += STORAGE_LIST_PAGE_SIZE; + } + + return names; +} + +async function removeStorageFiles( + supabase: ServerSupabaseClient, + workspaceId: string, + fileNames: string[], +): Promise { + const paths = fileNames.map((name) => `${workspaceId}/${name}`); + + // remove()도 한 번에 최대 1000개까지만 처리되므로, list()와 동일한 크기로 나눠서 삭제한다. + for (let index = 0; index < paths.length; index += STORAGE_LIST_PAGE_SIZE) { + const batch = paths.slice(index, index + STORAGE_LIST_PAGE_SIZE); + const { error } = await supabase.storage.from(WORKSPACE_RESOURCES_BUCKET).remove(batch); + + if (error) { + throw error; + } + } +} + +// deleting 전환 이후 신규 업로드는 막혀있지만, 혹시 남는 파일이 있을 수 있으니 목록이 빌 때까지 +// 조회·삭제를 반복해 고아 파일 가능성을 줄인다. +async function cleanupWorkspaceStorage( + supabase: ServerSupabaseClient, + workspaceId: string, +): Promise { + while (true) { + const fileNames = await listAllStorageFileNames(supabase, workspaceId); + if (fileNames.length === 0) { + break; + } + await removeStorageFiles(supabase, workspaceId, fileNames); + } +} + +export async function deleteWorkspace(input: DeleteWorkspaceInput): Promise { + const parsed = deleteWorkspaceInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error('입력값이 올바르지 않습니다'); + } + + const { workspaceId } = parsed.data; + const supabase = await createSupabaseServerClient(); + + const { data: token, error: beginError } = await supabase.rpc('begin_workspace_deletion', { + p_workspace_id: workspaceId, + }); + + if (beginError || !token) { + console.error('[deleteWorkspace] 삭제 선점 실패:', beginError); + throw new Error(beginError?.message || '워크스페이스를 삭제할 권한이 없거나 이미 삭제됐어요.'); + } + + const { error: markError } = await supabase.rpc('mark_workspace_deletion_in_progress', { + p_workspace_id: workspaceId, + p_deletion_token: token, + }); + + if (markError) { + console.error('[deleteWorkspace] 삭제 진행 전환 실패:', markError); + throw new Error(markError.message || DELETE_FAILED_MESSAGE); + } + + // 여기서부터는 deleting 상태라 자동 만료되지 않는다 — 실패해도 같은 owner가 재시도하면 + // begin_workspace_deletion이 같은 token을 돌려주고, 이미 지워진 파일은 목록에서 빠지므로 멱등적으로 이어진다. + try { + await cleanupWorkspaceStorage(supabase, workspaceId); + } catch (storageError) { + console.error('[deleteWorkspace] Storage 정리 실패:', storageError); + throw new Error(DELETE_FAILED_MESSAGE); + } + + const { error: finalizeError } = await supabase.rpc('finalize_workspace_deletion', { + p_workspace_id: workspaceId, + p_deletion_token: token, + }); + + if (finalizeError) { + console.error('[deleteWorkspace] 삭제 완료 실패:', finalizeError); + throw new Error(finalizeError.message || DELETE_FAILED_MESSAGE); + } + + revalidatePath('/workspaces'); +} diff --git a/src/entities/workspace/api/transfer-ownership.ts b/src/entities/workspace/api/transfer-ownership.ts new file mode 100644 index 0000000..4f35b47 --- /dev/null +++ b/src/entities/workspace/api/transfer-ownership.ts @@ -0,0 +1,34 @@ +'use server'; + +// 워크스페이스 소유권 이전 서버액션 — transfer_workspace_ownership RPC 호출 +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +const transferOwnershipInputSchema = z.object({ + workspaceId: z.guid(), + newOwnerUserId: z.guid(), +}); + +export type TransferOwnershipInput = z.infer; + +export async function transferOwnership(input: TransferOwnershipInput): Promise { + const parsed = transferOwnershipInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error('입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase.rpc('transfer_workspace_ownership', { + p_workspace_id: parsed.data.workspaceId, + p_new_owner_id: parsed.data.newOwnerUserId, + }); + + if (error) { + // 자기 자신 지정, 비멤버 대상 등 RPC가 던진 메시지를 그대로 전달한다. + console.error('[transferOwnership] RPC 실패:', error); + throw new Error(error.message || '소유권 이전에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidatePath(`/workspaces/${parsed.data.workspaceId}/settings`); +} diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index d6de516..0915d59 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -25,11 +25,10 @@ export { export { getMyWorkspaces } from './api/get-my-workspaces'; export { useMyWorkspaces, myWorkspacesQueryKey } from './api/use-my-workspaces'; export { createWorkspace } from './api/create-workspace'; -export { - updateWorkspaceInfo, - type UpdateWorkspaceInfoInput, -} from './api/update-workspace-info'; +export { updateWorkspaceInfo, type UpdateWorkspaceInfoInput } from './api/update-workspace-info'; export { joinWorkspaceByInviteCode } from './api/join-workspace-by-invite-code'; +export { transferOwnership, type TransferOwnershipInput } from './api/transfer-ownership'; +export { deleteWorkspace, type DeleteWorkspaceInput } from './api/delete-workspace'; export { sendInviteEmail, type SendInviteEmailInput } from './api/send-invite-email'; export { setWorkspaceInviteEnabled, diff --git a/src/features/manage-member-profile/ui/MemberProfileForm.tsx b/src/features/manage-member-profile/ui/MemberProfileForm.tsx index 0028cb6..7528167 100644 --- a/src/features/manage-member-profile/ui/MemberProfileForm.tsx +++ b/src/features/manage-member-profile/ui/MemberProfileForm.tsx @@ -2,6 +2,7 @@ // 프로필 탭 — 닉네임 수정 및 팀 탈퇴 import { useState } from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { Loader2 } from 'lucide-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -18,17 +19,21 @@ import { workspaceMembersByWorkspaceQueryKey, leaveWorkspace, } from '@/entities/workspace-member'; +import { myWorkspacesQueryKey } from '@/entities/workspace'; interface MemberProfileFormProps { workspaceId: string; initialNickname: string; isOwner: boolean; + // owner의 탈퇴 안내 문구 분기용 — 소유권 이전은 팀 관리 탭, 워크스페이스 삭제는 워크스페이스 관리 탭에 있다. + hasOtherMembers: boolean; } export function MemberProfileForm({ workspaceId, initialNickname, isOwner, + hasOtherMembers, }: MemberProfileFormProps) { const router = useRouter(); const queryClient = useQueryClient(); @@ -76,6 +81,8 @@ export function MemberProfileForm({ setIsLeaving(true); try { await leaveWorkspace({ workspaceId }); + // 목록은 client-side react-query 캐시라 별도로 무효화해야 즉시 사라진다. + await queryClient.invalidateQueries({ queryKey: myWorkspacesQueryKey }); router.push('/workspaces'); // 성공 시 페이지 이동으로 언마운트되므로 isLeaving을 리셋하지 않는다(버튼 깜빡임 방지) } catch (error) { @@ -126,52 +133,100 @@ export function MemberProfileForm({

팀 탈퇴

-

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

- - - !isLeaving && setShowLeaveConfirm(open)} - > - - 정말 탈퇴하시겠어요? - - 탈퇴하면 이 워크스페이스에서 나가게 되며, -
- 데이터를 복구할 수 없어요. -
- - - - -
-
+ {!isOwner && ( + <> +

+ 탈퇴하면 이 워크스페이스에서 나가게 되며, 데이터를 복구할 수 없어요. +

+ + + + !isLeaving && setShowLeaveConfirm(open)} + > + + 정말 탈퇴하시겠어요? + + 탈퇴하면 이 워크스페이스에서 나가게 되며, +
+ 데이터를 복구할 수 없어요. +
+ + + + +
+
+ + )} + + {isOwner && ( + <> +

+ {hasOtherMembers ? ( + <> + 워크스페이스 소유자는 팀을 탈퇴할 수 없어요.{' '} + + 팀 관리 탭 + + 에서 소유권을 이전해주세요. + + ) : ( + <> + 소유자는 팀을 탈퇴할 수 없어요. 더 이상 워크스페이스를 사용하지 않는다면{' '} + + 워크스페이스 관리 탭 + + 에서 삭제할 수 있어요. + + )} +

+ + + + )}
); diff --git a/src/features/manage-workspace-info/index.ts b/src/features/manage-workspace-info/index.ts index cbf1045..8d42193 100644 --- a/src/features/manage-workspace-info/index.ts +++ b/src/features/manage-workspace-info/index.ts @@ -1 +1,2 @@ export { WorkspaceInfoForm } from './ui/WorkspaceInfoForm'; +export { DeleteWorkspaceSection } from './ui/DeleteWorkspaceSection'; diff --git a/src/features/manage-workspace-info/ui/DeleteWorkspaceDialog.tsx b/src/features/manage-workspace-info/ui/DeleteWorkspaceDialog.tsx new file mode 100644 index 0000000..9271e08 --- /dev/null +++ b/src/features/manage-workspace-info/ui/DeleteWorkspaceDialog.tsx @@ -0,0 +1,116 @@ +'use client'; + +// 워크스페이스 삭제 확인 다이얼로그 — 이름을 정확히 입력해야만 삭제 버튼이 활성화된다. +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { deleteWorkspace, myWorkspacesQueryKey } from '@/entities/workspace'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/shared/ui/dialog'; + +interface DeleteWorkspaceDialogProps { + workspaceId: string; + workspaceName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DeleteWorkspaceDialog({ + workspaceId, + workspaceName, + open, + onOpenChange, +}: DeleteWorkspaceDialogProps) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [confirmText, setConfirmText] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const canDelete = confirmText === workspaceName && !isDeleting; + + const handleDelete = async () => { + if (!canDelete) return; + setIsDeleting(true); + try { + await deleteWorkspace({ workspaceId }); + // 목록은 client-side react-query 캐시라 별도로 무효화해야 즉시 사라진다. + await queryClient.invalidateQueries({ queryKey: myWorkspacesQueryKey }); + router.push('/workspaces'); + // 성공 시 페이지 이동으로 언마운트되므로 isDeleting을 리셋하지 않는다(버튼 깜빡임 방지) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : '워크스페이스 삭제에 실패했어요. 잠시 후 다시 시도해주세요.', + ); + setIsDeleting(false); + } + }; + + return ( + { + if (isDeleting) return; + setConfirmText(''); + onOpenChange(next); + }} + > + + 워크스페이스를 삭제하시겠어요? + + 워크스페이스의 모든 데이터(업무, 일정, 공지, 자료 등)가 함께 삭제되며, 되돌릴 수 없어요. + + + + + + + + + + + ); +} diff --git a/src/features/manage-workspace-info/ui/DeleteWorkspaceSection.tsx b/src/features/manage-workspace-info/ui/DeleteWorkspaceSection.tsx new file mode 100644 index 0000000..df03b23 --- /dev/null +++ b/src/features/manage-workspace-info/ui/DeleteWorkspaceSection.tsx @@ -0,0 +1,42 @@ +'use client'; + +// 워크스페이스 삭제 섹션 — owner면 다른 멤버 존재 여부와 무관하게 항상 노출한다. +import { useState } from 'react'; +import { DeleteWorkspaceDialog } from './DeleteWorkspaceDialog'; + +interface DeleteWorkspaceSectionProps { + workspaceId: string; + workspaceName: string; +} + +export function DeleteWorkspaceSection({ + workspaceId, + workspaceName, +}: DeleteWorkspaceSectionProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + return ( +
+

워크스페이스 삭제

+

+ 모든 멤버가 워크스페이스에서 제외되고, 업무·일정·공지·자료 등 모든 데이터가 함께 사라지며 + 되돌릴 수 없어요. +

+ + + + +
+ ); +} diff --git a/src/features/manage-workspace-members/index.ts b/src/features/manage-workspace-members/index.ts index b1780a0..55ee04b 100644 --- a/src/features/manage-workspace-members/index.ts +++ b/src/features/manage-workspace-members/index.ts @@ -1,4 +1,5 @@ export { MemberManagementPanel } from './ui/MemberManagementPanel'; export { MemberInviteSection } from './ui/MemberInviteSection'; export { MemberList } from './ui/MemberList'; +export { OwnershipTransferSection } from './ui/OwnershipTransferSection'; export { useMemberManagement, type InviteMode } from './model/use-member-management'; diff --git a/src/features/manage-workspace-members/ui/OwnershipTransferSection.tsx b/src/features/manage-workspace-members/ui/OwnershipTransferSection.tsx new file mode 100644 index 0000000..e02594d --- /dev/null +++ b/src/features/manage-workspace-members/ui/OwnershipTransferSection.tsx @@ -0,0 +1,42 @@ +'use client'; + +// 소유권 이전 섹션 — owner이고 다른 멤버가 있을 때만 팀 관리 탭에 노출한다. +import { useState } from 'react'; +import type { WorkspaceMember } from '@/entities/workspace-member'; +import { TransferOwnershipDialog } from './TransferOwnershipDialog'; + +interface OwnershipTransferSectionProps { + workspaceId: string; + otherMembers: WorkspaceMember[]; +} + +export function OwnershipTransferSection({ + workspaceId, + otherMembers, +}: OwnershipTransferSectionProps) { + const [showTransferDialog, setShowTransferDialog] = useState(false); + + return ( +
+

소유권 이전

+

+ 다른 멤버에게 워크스페이스 소유권을 넘길 수 있어요. 이전 후에도 계속 멤버로 남을 수 있어요. +

+ + + + +
+ ); +} diff --git a/src/features/manage-workspace-members/ui/TransferOwnershipDialog.tsx b/src/features/manage-workspace-members/ui/TransferOwnershipDialog.tsx new file mode 100644 index 0000000..9e12ff4 --- /dev/null +++ b/src/features/manage-workspace-members/ui/TransferOwnershipDialog.tsx @@ -0,0 +1,112 @@ +'use client'; + +// 소유권 이전 확인 다이얼로그 — 다른 멤버 중 한 명을 선택해 소유권을 넘긴다. +import { useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { transferOwnership } from '@/entities/workspace'; +import type { WorkspaceMember } from '@/entities/workspace-member'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/shared/ui/dialog'; + +interface TransferOwnershipDialogProps { + workspaceId: string; + otherMembers: WorkspaceMember[]; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function TransferOwnershipDialog({ + workspaceId, + otherMembers, + open, + onOpenChange, +}: TransferOwnershipDialogProps) { + const [selectedUserId, setSelectedUserId] = useState(''); + const [isTransferring, setIsTransferring] = useState(false); + + // otherMembers는 비동기로 갱신되거나 다이얼로그를 다시 열 때 바뀔 수 있어, 선택값이 더 이상 + // 목록에 없으면 렌더 시점에 첫 멤버로 대체한다(state에 직접 반영하진 않아 effect가 필요 없다). + const effectiveSelectedUserId = otherMembers.some((member) => member.userId === selectedUserId) + ? selectedUserId + : (otherMembers[0]?.userId ?? ''); + + const handleTransfer = async () => { + if (!effectiveSelectedUserId) return; + setIsTransferring(true); + try { + await transferOwnership({ workspaceId, newOwnerUserId: effectiveSelectedUserId }); + // router.refresh()는 사이드바 role 표시까지 확실히 갱신하지 못해 하드 리로드로 대체한다. + window.location.reload(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : '소유권 이전에 실패했어요. 잠시 후 다시 시도해주세요.', + ); + setIsTransferring(false); + } + }; + + return ( + !isTransferring && onOpenChange(next)}> + + 소유권을 이전할 멤버를 선택하세요 + + 이전 후에는 되돌릴 수 없으며, 선택한 멤버가 새 워크스페이스 소유자가 돼요. + + +
+ {otherMembers.map((member) => ( + + ))} +
+ + + + + +
+
+ ); +} diff --git a/src/shared/model/database.types.ts b/src/shared/model/database.types.ts index d99168c..6b36c50 100644 --- a/src/shared/model/database.types.ts +++ b/src/shared/model/database.types.ts @@ -695,6 +695,38 @@ export type Database = { }, ] } + workspace_deletion_jobs: { + Row: { + created_at: string + deletion_token: string + initiated_by: string + status: string + workspace_id: string + } + Insert: { + created_at?: string + deletion_token?: string + initiated_by: string + status?: string + workspace_id: string + } + Update: { + created_at?: string + deletion_token?: string + initiated_by?: string + status?: string + workspace_id?: string + } + Relationships: [ + { + foreignKeyName: "workspace_deletion_jobs_workspace_id_fkey" + columns: ["workspace_id"] + isOneToOne: true + referencedRelation: "workspaces" + referencedColumns: ["id"] + }, + ] + } workspace_members: { Row: { created_at: string @@ -843,6 +875,10 @@ export type Database = { [_ in never]: never } Functions: { + begin_workspace_deletion: { + Args: { p_workspace_id: string } + Returns: string + } create_task: { Args: { p_due_date?: string; p_title: string; p_workspace_id: string } Returns: string @@ -869,6 +905,10 @@ export type Database = { } Returns: string } + finalize_workspace_deletion: { + Args: { p_deletion_token: string; p_workspace_id: string } + Returns: undefined + } get_invite_preview: { Args: { p_code: string } Returns: { @@ -907,6 +947,10 @@ export type Database = { Args: { p_code: string } Returns: string } + mark_workspace_deletion_in_progress: { + Args: { p_deletion_token: string; p_workspace_id: string } + Returns: undefined + } replace_and_delete_work_shift_type: { Args: { p_deleted_shift_type_id: string @@ -915,6 +959,10 @@ export type Database = { } Returns: undefined } + transfer_workspace_ownership: { + Args: { p_new_owner_id: string; p_workspace_id: string } + Returns: undefined + } update_task_board: { Args: { p_tasks: Json; p_workspace_id: string } Returns: undefined diff --git a/src/views/settings/model/settings-tab.ts b/src/views/settings/model/settings-tab.ts index 3c9d394..73dfe36 100644 --- a/src/views/settings/model/settings-tab.ts +++ b/src/views/settings/model/settings-tab.ts @@ -7,9 +7,9 @@ export interface SettingsTab { } export const SETTINGS_TABS: SettingsTab[] = [ - { key: 'workspace', label: '워크스페이스 정보' }, - { key: 'members', label: '팀원 관리' }, - { key: 'profile', label: '프로필' }, + { key: 'workspace', label: '워크스페이스 관리' }, + { key: 'members', label: '팀 관리' }, + { key: 'profile', label: '프로필 설정' }, ]; export const DEFAULT_SETTINGS_TAB: SettingsTabKey = 'workspace'; diff --git a/src/views/settings/ui/SettingsView.tsx b/src/views/settings/ui/SettingsView.tsx index 667f73e..ca655ce 100644 --- a/src/views/settings/ui/SettingsView.tsx +++ b/src/views/settings/ui/SettingsView.tsx @@ -5,8 +5,11 @@ // 각 탭 내용은 feature 컴포넌트에 위임하고, 표시 데이터는 서버에서 주입받는다. import type { Workspace } from '@/entities/workspace'; import type { WorkspaceMember } from '@/entities/workspace-member'; -import { WorkspaceInfoForm } from '@/features/manage-workspace-info'; -import { MemberManagementPanel } from '@/features/manage-workspace-members'; +import { WorkspaceInfoForm, DeleteWorkspaceSection } from '@/features/manage-workspace-info'; +import { + MemberManagementPanel, + OwnershipTransferSection, +} from '@/features/manage-workspace-members'; import { MemberProfileForm } from '@/features/manage-member-profile'; import { plusJakartaSans } from '@/shared/lib/fonts'; import type { SettingsTabKey } from '../model/settings-tab'; @@ -31,6 +34,8 @@ export function SettingsView({ }: SettingsViewProps) { // 워크스페이스 정보 수정은 RLS상 소유자만 가능하므로, 현재 사용자의 역할로 편집 권한을 판단한다. const isOwner = members.find((member) => member.userId === currentUserId)?.role === 'owner'; + const otherMembers = members.filter((member) => member.userId !== currentUserId); + const hasOtherMembers = otherMembers.length > 0; return (
@@ -39,21 +44,34 @@ export function SettingsView({
- {activeTab === 'workspace' && } + {activeTab === 'workspace' && ( +
+ + {isOwner && ( + + )} +
+ )} {activeTab === 'members' && ( - +
+ + {isOwner && hasOtherMembers && ( + + )} +
)} {activeTab === 'profile' && ( )}
diff --git a/supabase/migrations/20260722000000_create_workspace_ownership_transfer_rpc.sql b/supabase/migrations/20260722000000_create_workspace_ownership_transfer_rpc.sql new file mode 100644 index 0000000..7da408f --- /dev/null +++ b/supabase/migrations/20260722000000_create_workspace_ownership_transfer_rpc.sql @@ -0,0 +1,63 @@ +-- 워크스페이스 소유권 이전 RPC — 현재 소유자만, 같은 워크스페이스의 다른 멤버에게만 가능하다. +create or replace function public.transfer_workspace_ownership( + p_workspace_id uuid, + p_new_owner_id uuid +) +returns void +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_caller uuid := auth.uid(); + v_current_owner_id uuid; +begin + if v_caller is null then + raise exception '인증된 사용자만 소유권을 이전할 수 있습니다.' + using errcode = '28000'; + end if; + + if p_new_owner_id = v_caller then + raise exception '자기 자신에게는 소유권을 이전할 수 없습니다.'; + end if; + + -- 동시 이전/삭제 요청과 겹치지 않도록 워크스페이스 row를 잠근다. + select owner_id into v_current_owner_id + from workspaces + where id = p_workspace_id + for update; + + if not found then + raise exception '워크스페이스를 찾을 수 없습니다.'; + end if; + + if v_current_owner_id != v_caller then + raise exception '워크스페이스 소유자만 소유권을 이전할 수 있습니다.' + using errcode = '42501'; + end if; + + -- 대상 멤버 row도 잠가, 확인과 role 변경 사이에 본인이 탈퇴(row 삭제)하는 걸 막는다. + perform 1 from workspace_members + where workspace_id = p_workspace_id and user_id = p_new_owner_id + for update; + + if not found then + raise exception '대상 사용자는 이 워크스페이스의 멤버가 아닙니다.'; + end if; + + update workspace_members + set role = 'owner' + where workspace_id = p_workspace_id and user_id = p_new_owner_id; + + update workspace_members + set role = 'member' + where workspace_id = p_workspace_id and user_id = v_caller; + + update workspaces + set owner_id = p_new_owner_id + where id = p_workspace_id; +end; +$$; + +revoke all on function public.transfer_workspace_ownership(uuid, uuid) from public; +grant execute on function public.transfer_workspace_ownership(uuid, uuid) to authenticated; \ No newline at end of file diff --git a/supabase/migrations/20260723000000_create_workspace_deletion_jobs.sql b/supabase/migrations/20260723000000_create_workspace_deletion_jobs.sql new file mode 100644 index 0000000..3bbb40a --- /dev/null +++ b/supabase/migrations/20260723000000_create_workspace_deletion_jobs.sql @@ -0,0 +1,335 @@ +-- 워크스페이스 삭제 선점 메커니즘 — TOCTOU 없이 삭제와 소유권 이전이 같은 workspaces row를 두고 직렬화되도록 한다. +-- 삭제는 "선점(reserved) → 진행 전환(deleting) → Storage 목록 조회·배치 삭제(목록이 빌 때까지 반복) → 완료 +-- (finalize)" 순서로 나누고, workspace row와 owner 멤버십은 finalize 전까지 그대로 유지되므로 Storage RLS를 +-- 그대로 통과한다(admin 클라이언트 불필요). reserved 상태(아직 deleting 전환 전)는 10분 지나면 자동 만료돼, +-- 전환 전에 실패하고 사용자가 포기해도 소유권 이전/업로드가 영구히 막히지 않는다. 반면 deleting 상태(신규 업로드가 +-- 확실히 막힌 이후)는 자동 만료되지 않아, Storage 정리가 중간에 끊겨도 워크스페이스는 남고 파일만 사라지는 +-- 상황을 막는다. workspaces에 대한 직접 DELETE RLS는 제거해, finalize_workspace_deletion(security definer)을 +-- 거치지 않고는 워크스페이스를 지울 수 없게 한다. finalize 내부에서도 storage.objects에 남은 파일이 +-- 있는지 조회만 해서(delete/update 없음) 있으면 거부해, begin→mark→finalize만 직접 호출해 Storage +-- 정리를 우회하는 경로를 막는다. +-- (SQL Editor에서 수동 실행 시에는 앞뒤로 begin;/commit;을 직접 감싸서 실행할 것 — 이 파일 자체에는 +-- 마이그레이션 도구가 자체적으로 트랜잭션을 감쌀 수 있으므로 begin/commit을 넣지 않는다.) + +create table if not exists public.workspace_deletion_jobs ( + workspace_id uuid primary key references public.workspaces(id) on delete cascade, + initiated_by uuid not null references auth.users(id), + deletion_token uuid not null default gen_random_uuid(), + status text not null default 'reserved' check (status in ('reserved', 'deleting')), + created_at timestamptz not null default now() +); + +-- RLS만으로도 기본 거부지만, anon/authenticated의 테이블 권한 자체도 명시적으로 없애 +-- PostgREST로 직접 읽거나 쓸 수 없게 한다. 아래 security definer RPC/헬퍼로만 접근한다. +alter table public.workspace_deletion_jobs enable row level security; +revoke all on public.workspace_deletion_jobs from anon, authenticated; + +-- 활성 삭제 작업이 있는지 확인하는 헬퍼 — 소유권 이전 RPC와 Storage 업로드 정책에서 공용으로 쓴다. +-- deleting은 항상 활성으로 보고, reserved는 10분 이내일 때만 활성으로 본다. +create or replace function private.has_active_workspace_deletion_job(p_workspace_id uuid) +returns boolean +language sql stable security definer +set search_path = public, pg_temp +as $$ + select exists ( + select 1 from workspace_deletion_jobs + where workspace_id = p_workspace_id + and (status = 'deleting' or created_at >= now() - interval '10 minutes') + ); +$$; + +grant execute on function private.has_active_workspace_deletion_job(uuid) to authenticated; +revoke execute on function private.has_active_workspace_deletion_job(uuid) from anon, public; + +-- 삭제 선점 — owner 확인 후 삭제 작업을 원자적으로 생성/재획득한다. +-- deleting 상태이거나 만료되지 않은 reserved 상태를 다른 사람이 선점 중이면 거부하고, +-- 본인 job이면(reserved든 deleting이든) token을 그대로 재사용해 이어서 진행할 수 있게 한다. +create or replace function public.begin_workspace_deletion(p_workspace_id uuid) +returns uuid +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_caller uuid := auth.uid(); + v_owner_id uuid; + v_job workspace_deletion_jobs%rowtype; + v_token uuid; +begin + if v_caller is null then + raise exception '인증된 사용자만 워크스페이스를 삭제할 수 있습니다.' + using errcode = '28000'; + end if; + + -- 동시 소유권 이전 요청과 겹치지 않도록 워크스페이스 row를 잠근다. + select owner_id into v_owner_id + from workspaces + where id = p_workspace_id + for update; + + if not found then + raise exception '워크스페이스를 찾을 수 없습니다.'; + end if; + + if v_owner_id != v_caller then + raise exception '워크스페이스 소유자만 삭제할 수 있습니다.' + using errcode = '42501'; + end if; + + select * into v_job + from workspace_deletion_jobs + where workspace_id = p_workspace_id + for update; + + if found then + if v_job.status = 'deleting' then + if v_job.initiated_by != v_caller then + raise exception '이미 다른 삭제 작업이 진행 중입니다.'; + end if; + -- 이미 파일 삭제가 시작된 job — 자동 만료 대상이 아니므로 token을 그대로 재사용한다. + return v_job.deletion_token; + end if; + + if v_job.created_at >= now() - interval '10 minutes' then + if v_job.initiated_by != v_caller then + raise exception '이미 다른 삭제 작업이 진행 중입니다.'; + end if; + -- 만료되지 않은 본인 예약 — token을 그대로 재사용한다. + return v_job.deletion_token; + end if; + -- 10분 넘게 지난 reserved job은 방치된 것으로 보고 새로 선점한다. + end if; + + v_token := gen_random_uuid(); + + insert into workspace_deletion_jobs (workspace_id, initiated_by, deletion_token, status, created_at) + values (p_workspace_id, v_caller, v_token, 'reserved', now()) + on conflict (workspace_id) + do update set initiated_by = excluded.initiated_by, + deletion_token = excluded.deletion_token, + status = 'reserved', + created_at = excluded.created_at; + + return v_token; +end; +$$; + +revoke all on function public.begin_workspace_deletion(uuid) from public; +grant execute on function public.begin_workspace_deletion(uuid) to authenticated; + +-- Storage 목록 조회·삭제를 시작하기 전에 호출한다. 이 시점부터 job은 deleting 상태가 되어 +-- 자동 만료되지 않고, 신규 업로드도 확실히 막혀서 이후의 목록 조회가 최종 스냅숏이 된다. +create or replace function public.mark_workspace_deletion_in_progress( + p_workspace_id uuid, + p_deletion_token uuid +) +returns void +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_caller uuid := auth.uid(); + v_owner_id uuid; + v_job workspace_deletion_jobs%rowtype; +begin + if v_caller is null then + raise exception '인증된 사용자만 워크스페이스를 삭제할 수 있습니다.' + using errcode = '28000'; + end if; + + select owner_id into v_owner_id + from workspaces + where id = p_workspace_id + for update; + + if not found then + raise exception '워크스페이스를 찾을 수 없습니다.'; + end if; + + if v_owner_id != v_caller then + raise exception '워크스페이스 소유자만 삭제를 진행할 수 있습니다.' + using errcode = '42501'; + end if; + + select * into v_job + from workspace_deletion_jobs + where workspace_id = p_workspace_id + for update; + + if not found + or v_job.initiated_by != v_caller + or v_job.deletion_token != p_deletion_token + or (v_job.status = 'reserved' and v_job.created_at < now() - interval '10 minutes') then + raise exception '삭제 작업이 만료되었거나 유효하지 않습니다. 다시 시도해주세요.'; + end if; + + update workspace_deletion_jobs + set status = 'deleting' + where workspace_id = p_workspace_id; +end; +$$; + +revoke all on function public.mark_workspace_deletion_in_progress(uuid, uuid) from public; +grant execute on function public.mark_workspace_deletion_in_progress(uuid, uuid) to authenticated; + +-- 삭제 완료 — Storage 정리가 끝난 뒤 호출한다. deleting 상태 + token이 유효할 때만 실제로 workspaces row를 지운다. +create or replace function public.finalize_workspace_deletion( + p_workspace_id uuid, + p_deletion_token uuid +) +returns void +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_caller uuid := auth.uid(); + v_owner_id uuid; + v_job workspace_deletion_jobs%rowtype; +begin + if v_caller is null then + raise exception '인증된 사용자만 워크스페이스를 삭제할 수 있습니다.' + using errcode = '28000'; + end if; + + select owner_id into v_owner_id + from workspaces + where id = p_workspace_id + for update; + + if not found then + raise exception '워크스페이스를 찾을 수 없습니다.'; + end if; + + if v_owner_id != v_caller then + raise exception '워크스페이스 소유자만 삭제를 완료할 수 있습니다.' + using errcode = '42501'; + end if; + + select * into v_job + from workspace_deletion_jobs + where workspace_id = p_workspace_id + for update; + + if not found + or v_job.status != 'deleting' + or v_job.initiated_by != v_caller + or v_job.deletion_token != p_deletion_token then + raise exception '삭제 작업이 유효하지 않습니다. 다시 시도해주세요.'; + end if; + + -- owner가 Storage 정리 없이 begin → mark → finalize만 직접 호출해 우회하지 못하도록, + -- 실제로 남은 파일이 있는지 확인만 하고(조회 전용, delete/update 없음) 있으면 거부한다. + if exists ( + select 1 from storage.objects + where bucket_id = 'workspace-resources' + and (storage.foldername(name))[1] = p_workspace_id::text + ) then + raise exception '삭제되지 않은 Storage 파일이 남아있어 워크스페이스를 삭제할 수 없습니다.'; + end if; + + delete from workspaces where id = p_workspace_id; + -- workspace_deletion_jobs row는 workspaces에 대한 on delete cascade로 함께 삭제된다. +end; +$$; + +revoke all on function public.finalize_workspace_deletion(uuid, uuid) from public; +grant execute on function public.finalize_workspace_deletion(uuid, uuid) to authenticated; + +-- 소유권 이전 중에도 활성 삭제 작업이 있으면 거부한다(워크스페이스 row 잠금으로 begin_workspace_deletion과 직렬화됨). +create or replace function public.transfer_workspace_ownership( + p_workspace_id uuid, + p_new_owner_id uuid +) +returns void +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_caller uuid := auth.uid(); + v_current_owner_id uuid; +begin + if v_caller is null then + raise exception '인증된 사용자만 소유권을 이전할 수 있습니다.' + using errcode = '28000'; + end if; + + if p_new_owner_id = v_caller then + raise exception '자기 자신에게는 소유권을 이전할 수 없습니다.'; + end if; + + -- 동시 이전/삭제 요청과 겹치지 않도록 워크스페이스 row를 잠근다. + select owner_id into v_current_owner_id + from workspaces + where id = p_workspace_id + for update; + + if not found then + raise exception '워크스페이스를 찾을 수 없습니다.'; + end if; + + if v_current_owner_id != v_caller then + raise exception '워크스페이스 소유자만 소유권을 이전할 수 있습니다.' + using errcode = '42501'; + end if; + + if private.has_active_workspace_deletion_job(p_workspace_id) then + raise exception '워크스페이스 삭제가 진행 중이라 소유권을 이전할 수 없습니다.'; + end if; + + -- 대상 멤버 row도 잠가, 확인과 role 변경 사이에 본인이 탈퇴(row 삭제)하는 걸 막는다. + perform 1 from workspace_members + where workspace_id = p_workspace_id and user_id = p_new_owner_id + for update; + + if not found then + raise exception '대상 사용자는 이 워크스페이스의 멤버가 아닙니다.'; + end if; + + update workspace_members + set role = 'owner' + where workspace_id = p_workspace_id and user_id = p_new_owner_id; + + update workspace_members + set role = 'member' + where workspace_id = p_workspace_id and user_id = v_caller; + + update workspaces + set owner_id = p_new_owner_id + where id = p_workspace_id; +end; +$$; + +revoke all on function public.transfer_workspace_ownership(uuid, uuid) from public; +grant execute on function public.transfer_workspace_ownership(uuid, uuid) to authenticated; + +-- 활성 삭제 작업이 있는 워크스페이스에는 신규 파일 업로드를 막는다(삭제 도중 새 파일이 올라오는 경우 방지). +drop policy if exists workspace_resources_insert_member on storage.objects; + +create policy workspace_resources_insert_member +on storage.objects +for insert +to authenticated +with check ( + bucket_id = 'workspace-resources' + and private.is_workspace_member( + case + when (storage.foldername(name))[1] ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + then (storage.foldername(name))[1]::uuid + else null + end + ) + and not private.has_active_workspace_deletion_job( + case + when (storage.foldername(name))[1] ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + then (storage.foldername(name))[1]::uuid + else null + end + ) +); + +-- owner가 finalize_workspace_deletion을 거치지 않고 workspaces row를 직접 DELETE하는 걸 막는다. +-- 이후로는 security definer인 finalize_workspace_deletion(RLS 우회)을 통해서만 삭제할 수 있다. +drop policy if exists workspaces_delete_owner on public.workspaces; \ No newline at end of file