diff --git a/.env.example b/.env.example index 5abe02a..71eeae5 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,9 @@ # PUBLISHABLE_KEY에는 publishable key(sb_publishable_...)를 사용한다 NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= + +# 이메일 초대 발송(Resend) — https://resend.com 에서 API 키 발급 +# INVITE_EMAIL_FROM은 Resend에서 인증한 발신 도메인 주소여야 한다. +# 도메인 인증 전에는 테스트 모드라 본인 계정 이메일로만 발송된다(미설정 시 onboarding@resend.dev 사용). +RESEND_API_KEY= +INVITE_EMAIL_FROM= diff --git a/package-lock.json b/package-lock.json index 5316680..58c68be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react-dom": "19.2.4", "react-grid-layout": "^2.2.3", "react-hook-form": "^7.80.0", + "resend": "^6.17.2", "shadcn": "^4.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", @@ -3301,6 +3302,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -6597,6 +6604,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -9350,6 +9363,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -9947,6 +9966,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.17.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.17.2.tgz", + "integrity": "sha512-hbaXEORFIFfT2Bh03NsA/akTTTKkD1hiuJ88ke64c5dWVV6DyoLxzFve3OiZqVOQ+JKLJ5uVkPR6hlUslpJFoA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -10565,6 +10605,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 6befaca..5921ca4 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react-dom": "19.2.4", "react-grid-layout": "^2.2.3", "react-hook-form": "^7.80.0", + "resend": "^6.17.2", "shadcn": "^4.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", diff --git a/src/app/invite/[code]/page.tsx b/src/app/invite/[code]/page.tsx new file mode 100644 index 0000000..980c04a --- /dev/null +++ b/src/app/invite/[code]/page.tsx @@ -0,0 +1,45 @@ +// 초대 참여 라우트 — 초대 코드로 워크스페이스 요약을 조회해 참여 화면을 보여준다. +// 비멤버는 RLS로 워크스페이스를 직접 조회할 수 없어 security definer RPC(get_invite_preview)를 사용한다. +import Link from 'next/link'; +import { getInvitePreview } from '@/entities/workspace/api/get-invite-preview'; +import { plusJakartaSans } from '@/shared/lib/fonts'; +import { InviteAcceptView } from '@/views/invite'; + +interface InvitePageProps { + params: Promise<{ code: string }>; +} + +export default async function InvitePage({ params }: InvitePageProps) { + const { code } = await params; + const preview = await getInvitePreview(code); + + if (!preview) { + return ( +
+
+

유효하지 않은 초대예요

+

+ 링크가 만료되었거나 초대가 비활성화되었을 수 있어요. 초대한 사람에게 새 링크를 + 요청해 주세요. +

+ + 내 워크스페이스로 + +
+
+ ); + } + + return ( + + ); +} diff --git a/src/app/workspaces/[workspaceId]/settings/page.tsx b/src/app/workspaces/[workspaceId]/settings/page.tsx index f9afe8e..a9f14af 100644 --- a/src/app/workspaces/[workspaceId]/settings/page.tsx +++ b/src/app/workspaces/[workspaceId]/settings/page.tsx @@ -1,11 +1,8 @@ // 설정 페이지 라우트 — 활성 탭을 searchParam(?tab=)으로 읽고, 표시에 필요한 데이터를 RSC에서 조회해 주입한다. -// 실 API 전환 시 아래 조회부만 async(Supabase)로 교체하면 되고, 하위 뷰/훅은 그대로 둔다. import { notFound } from 'next/navigation'; import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id'; -import { - getMockWorkspaceMembersByWorkspaceId, - mockCurrentWorkspaceMember, -} from '@/entities/workspace-member'; +import { getWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member/api/get-workspace-members-by-id'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; import { SettingsView, parseSettingsTab } from '@/views/settings'; interface WorkspaceSettingsPageProps { @@ -29,7 +26,13 @@ export default async function WorkspaceSettingsPage({ notFound(); } - const members = getMockWorkspaceMembersByWorkspaceId(workspaceId); + // 멤버 목록과 현재 사용자 식별자를 병렬 조회하고, 현재 사용자의 닉네임은 멤버 목록에서 파생한다. + const [members, currentUserId] = await Promise.all([ + getWorkspaceMembersByWorkspaceId(workspaceId), + getCurrentUserId(), + ]); + const currentNickname = + members.find((member) => member.userId === currentUserId)?.workspaceNickname ?? ''; return ( ); } diff --git a/src/entities/workspace-member/api/update-my-nickname.ts b/src/entities/workspace-member/api/update-my-nickname.ts new file mode 100644 index 0000000..4047120 --- /dev/null +++ b/src/entities/workspace-member/api/update-my-nickname.ts @@ -0,0 +1,45 @@ +'use server'; + +// 현재 사용자의 워크스페이스 닉네임 수정 서버액션 — workspace_members update +// RLS(members_update_self)에 의해 본인 멤버십만 수정할 수 있다. +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 updateMyNicknameInputSchema = z.object({ + workspaceId: z.guid(), + nickname: z + .string() + .trim() + .min(1, '닉네임을 입력해주세요') + .max(20, '닉네임은 20자 이내로 입력해주세요'), +}); + +export type UpdateMyNicknameInput = z.infer; + +export async function updateMyNickname(input: UpdateMyNicknameInput): Promise { + const parsed = updateMyNicknameInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const userId = await getCurrentUserId(); + const supabase = await createSupabaseServerClient(); + const { error } = await supabase + .from('workspace_members') + .update({ workspace_nickname: parsed.data.nickname }) + .eq('workspace_id', parsed.data.workspaceId) + .eq('user_id', userId); + + if (error) { + // (workspace_id, workspace_nickname) 유니크 제약 위반은 사용자 친화적 메시지로 안내한다. + if (error.code === '23505') { + throw new Error('이미 사용 중인 닉네임이에요. 다른 닉네임을 입력해주세요.'); + } + console.error('[updateMyNickname] update 실패:', error); + throw new Error('닉네임 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidatePath(`/workspaces/${parsed.data.workspaceId}/settings`); +} diff --git a/src/entities/workspace-member/index.ts b/src/entities/workspace-member/index.ts index a4e1128..a703d05 100644 --- a/src/entities/workspace-member/index.ts +++ b/src/entities/workspace-member/index.ts @@ -9,6 +9,9 @@ export { getMockWorkspaceMembersByWorkspaceId, mockWorkspaceMembers, } from './model/mock-workspace-members'; +// 서버 전용 조회 함수(next/headers 의존)는 클라이언트 번들 오염을 피하려 barrel에서 제외하고 +// RSC에서 직접 경로로 import한다. (getWorkspaceById와 동일한 컨벤션) +export { updateMyNickname, type UpdateMyNicknameInput } from './api/update-my-nickname'; export { WORKSPACE_MEMBER_ROLE_META, WORKSPACE_MEMBER_STATUS_META, diff --git a/src/entities/workspace/api/get-invite-preview.ts b/src/entities/workspace/api/get-invite-preview.ts new file mode 100644 index 0000000..229bac3 --- /dev/null +++ b/src/entities/workspace/api/get-invite-preview.ts @@ -0,0 +1,30 @@ +// 초대 미리보기 조회 — get_invite_preview RPC (security definer로 비멤버도 활성 코드면 요약 조회) +// 서버 전용(next/headers 의존)이므로 barrel에 넣지 않고 RSC에서 직접 경로로 import한다. +import { cache } from 'react'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +export interface InvitePreview { + workspaceId: string; + name: string; + memberCount: number; +} + +export const getInvitePreview = cache(async (code: string): Promise => { + const supabase = await createSupabaseServerClient(); + const { data, error } = await supabase.rpc('get_invite_preview', { p_code: code }); + + if (error) { + throw new Error(`초대 정보 조회에 실패했습니다: ${error.message}`); + } + + const preview = data?.[0]; + if (!preview) { + return null; + } + + return { + workspaceId: preview.workspace_id, + name: preview.name, + memberCount: preview.member_count, + }; +}); diff --git a/src/entities/workspace/api/get-workspace-by-id.ts b/src/entities/workspace/api/get-workspace-by-id.ts index dc1b68d..8a5b7c5 100644 --- a/src/entities/workspace/api/get-workspace-by-id.ts +++ b/src/entities/workspace/api/get-workspace-by-id.ts @@ -8,7 +8,7 @@ export const getWorkspaceById = cache(async (workspaceId: string): Promise { + const trimmed = code.trim(); + if (!trimmed) { + throw new Error('유효하지 않은 초대 링크입니다.'); + } + + const userId = await getCurrentUserId(); + const supabase = await createSupabaseServerClient(); + const { data, error } = await supabase.rpc('join_workspace_by_invite_code', { + p_user_id: userId, + p_code: trimmed, + }); + + if (error) { + // 유효하지 않거나 비활성화된 초대는 RPC가 던진 메시지를 그대로 전달한다. + console.error('[joinWorkspaceByInviteCode] RPC 실패:', error); + throw new Error(error.message || '워크스페이스 참여에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + return { workspaceId: data }; +} diff --git a/src/entities/workspace/api/send-invite-email.ts b/src/entities/workspace/api/send-invite-email.ts new file mode 100644 index 0000000..12a35f2 --- /dev/null +++ b/src/entities/workspace/api/send-invite-email.ts @@ -0,0 +1,75 @@ +'use server'; + +// 이메일 초대 발송 서버액션 — 입력한 이메일로 초대 링크(invite_code)를 담은 메일을 보낸다. +// 초대 테이블/프로필 조회 없이 링크만 전달하므로, 수신자는 링크로 참여(/invite/[code])한다. +import { headers } from 'next/headers'; +import { Resend } from 'resend'; +import { z } from 'zod'; +import { getEmailEnv } from '@/shared/api/email/env'; +import { getWorkspaceById } from './get-workspace-by-id'; + +const sendInviteEmailInputSchema = z.object({ + workspaceId: z.guid(), + email: z.email('올바른 이메일 주소를 입력해주세요'), +}); + +export type SendInviteEmailInput = z.infer; + +// 서버에서 요청 헤더로 baseURL을 구성한다(클라이언트 origin에 의존하지 않음). +async function resolveOrigin(): Promise { + const headerList = await headers(); + const host = headerList.get('host'); + const proto = headerList.get('x-forwarded-proto') ?? 'http'; + return host ? `${proto}://${host}` : ''; +} + +export async function sendInviteEmail(input: SendInviteEmailInput): Promise { + const parsed = sendInviteEmailInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + // RLS(workspaces_select_member)로 멤버만 조회 가능 → 비멤버의 발송을 차단한다. + const workspace = await getWorkspaceById(parsed.data.workspaceId); + if (!workspace) { + throw new Error('워크스페이스를 찾을 수 없거나 접근 권한이 없습니다.'); + } + + if (!workspace.inviteEnabled || !workspace.inviteCode) { + throw new Error('초대 링크를 먼저 활성화해주세요.'); + } + + const origin = await resolveOrigin(); + if (!origin) { + throw new Error('초대 링크 주소를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.'); + } + + const inviteUrl = `${origin}/invite/${workspace.inviteCode}`; + const { apiKey, from } = getEmailEnv(); + const resend = new Resend(apiKey); + + const { error } = await resend.emails.send({ + from, + to: parsed.data.email, + subject: `[Syncly] ${workspace.name} 워크스페이스에 초대되었어요`, + text: `${workspace.name} 워크스페이스에 초대되었습니다.\n아래 링크로 참여하세요:\n${inviteUrl}`, + html: ` +
+

${workspace.name} 워크스페이스에 초대되었어요.

+

아래 버튼을 눌러 참여하세요.

+

+ + 워크스페이스 참여하기 + +

+

버튼이 동작하지 않으면 이 링크를 복사해 열어주세요:
${inviteUrl}

+
+ `, + }); + + if (error) { + console.error('[sendInviteEmail] 발송 실패:', error); + throw new Error('초대 메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/workspace/api/set-workspace-invite-enabled.ts b/src/entities/workspace/api/set-workspace-invite-enabled.ts new file mode 100644 index 0000000..4485821 --- /dev/null +++ b/src/entities/workspace/api/set-workspace-invite-enabled.ts @@ -0,0 +1,36 @@ +'use server'; + +// 초대 링크 활성화/비활성화 서버액션 — workspaces.invite_enabled update +// RLS(workspaces_update_owner)에 의해 소유자만 변경할 수 있다. +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +const setInviteEnabledInputSchema = z.object({ + workspaceId: z.guid(), + enabled: z.boolean(), +}); + +export type SetWorkspaceInviteEnabledInput = z.infer; + +export async function setWorkspaceInviteEnabled( + input: SetWorkspaceInviteEnabledInput, +): Promise { + const parsed = setInviteEnabledInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase + .from('workspaces') + .update({ invite_enabled: parsed.data.enabled }) + .eq('id', parsed.data.workspaceId); + + if (error) { + console.error('[setWorkspaceInviteEnabled] update 실패:', error); + throw new Error('초대 링크 설정 변경에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidatePath(`/workspaces/${parsed.data.workspaceId}/settings`); +} diff --git a/src/entities/workspace/api/update-workspace-info.ts b/src/entities/workspace/api/update-workspace-info.ts new file mode 100644 index 0000000..4bab60d --- /dev/null +++ b/src/entities/workspace/api/update-workspace-info.ts @@ -0,0 +1,37 @@ +'use server'; + +// 워크스페이스 정보(이름·설명) 수정 서버액션 — workspaces update +// RLS(workspaces_update_owner)에 의해 소유자만 수정할 수 있다. +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import { createWorkspaceSchema } from '../model/create-workspace.schema'; + +// 생성 폼과 동일한 이름·설명 검증에 대상 워크스페이스 id를 더한다. +const updateWorkspaceInfoInputSchema = createWorkspaceSchema.extend({ id: z.guid() }); + +export type UpdateWorkspaceInfoInput = z.infer; + +export async function updateWorkspaceInfo(input: UpdateWorkspaceInfoInput): Promise { + // 클라이언트 검증과 별개로 서버에서 재검증한다. + const parsed = updateWorkspaceInfoInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase + .from('workspaces') + .update({ + name: parsed.data.name, + description: parsed.data.description ?? null, + }) + .eq('id', parsed.data.id); + + if (error) { + console.error('[updateWorkspaceInfo] update 실패:', error); + throw new Error('워크스페이스 정보 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidatePath(`/workspaces/${parsed.data.id}/settings`); +} diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index f7f8771..d6de516 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -25,3 +25,13 @@ 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 { joinWorkspaceByInviteCode } from './api/join-workspace-by-invite-code'; +export { sendInviteEmail, type SendInviteEmailInput } from './api/send-invite-email'; +export { + setWorkspaceInviteEnabled, + type SetWorkspaceInviteEnabledInput, +} from './api/set-workspace-invite-enabled'; diff --git a/src/entities/workspace/model/workspace.types.ts b/src/entities/workspace/model/workspace.types.ts index d3a11a1..911d49a 100644 --- a/src/entities/workspace/model/workspace.types.ts +++ b/src/entities/workspace/model/workspace.types.ts @@ -7,6 +7,10 @@ export interface Workspace { purpose: WorkspacePurpose; // 설정 페이지에서 편집하는 워크스페이스 소개 문구입니다. (선택 값) description?: string; + // 초대 링크 구성에 사용하는 초대 코드입니다. 생성 시 발급되며 없을 수 있습니다. + inviteCode?: string | null; + // 초대 링크 활성화 여부입니다. false면 코드가 있어도 참여가 거부됩니다. + inviteEnabled?: boolean; } // get_my_workspaces RPC 반환 형태 (내 워크스페이스 목록) diff --git a/src/features/manage-member-profile/ui/MemberProfileForm.tsx b/src/features/manage-member-profile/ui/MemberProfileForm.tsx index 7c7bb45..3eaa808 100644 --- a/src/features/manage-member-profile/ui/MemberProfileForm.tsx +++ b/src/features/manage-member-profile/ui/MemberProfileForm.tsx @@ -1,32 +1,49 @@ 'use client'; // 프로필 탭 — 현재 사용자의 워크스페이스 닉네임을 수정한다. -// 초기 닉네임은 서버(RSC)에서 주입받고, 저장은 백엔드 연동 전까지 로컬 상태를 확정하는 목업으로 동작한다. +// 초기 닉네임은 서버(RSC)에서 주입받고, 저장은 updateMyNickname 서버액션을 호출한다. import { useState } from 'react'; +import { toast } from 'sonner'; +import { updateMyNickname } from '@/entities/workspace-member'; interface MemberProfileFormProps { + workspaceId: string; initialNickname: string; } -export function MemberProfileForm({ initialNickname }: MemberProfileFormProps) { +export function MemberProfileForm({ workspaceId, initialNickname }: MemberProfileFormProps) { const [committedNickname, setCommittedNickname] = useState(initialNickname); const [nickname, setNickname] = useState(initialNickname); const [isSaved, setIsSaved] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const isDirty = nickname !== committedNickname; - const canSubmit = nickname.trim().length > 0 && isDirty; + const canSubmit = nickname.trim().length > 0 && isDirty && !isSubmitting; - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!canSubmit) { return; } - // TODO: 백엔드 연동 시 닉네임 저장 API 호출로 교체한다. const nextNickname = nickname.trim(); - setNickname(nextNickname); - setCommittedNickname(nextNickname); - setIsSaved(true); + + setIsSubmitting(true); + try { + await updateMyNickname({ workspaceId, nickname: nextNickname }); + setNickname(nextNickname); + setCommittedNickname(nextNickname); + setIsSaved(true); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : '닉네임 저장에 실패했습니다. 잠시 후 다시 시도해주세요.', + ); + } finally { + setIsSubmitting(false); + } }; return ( @@ -56,7 +73,7 @@ export function MemberProfileForm({ initialNickname }: MemberProfileFormProps) { 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 ? '저장 중…' : '저장'} {isSaved && !isDirty ? ( 저장되었습니다. diff --git a/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx b/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx index fc4229a..6eca420 100644 --- a/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx +++ b/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx @@ -1,15 +1,18 @@ 'use client'; // 워크스페이스 정보(이름·설명) 수정 폼입니다. -// 백엔드 연동 전이므로 저장은 로컬 상태를 확정(commit)하는 목업으로 동작합니다. +// 저장은 updateWorkspaceInfo 서버액션을 호출하고, 성공 시 로컬 committed 값을 확정한다. import { useState, type FormEvent } from 'react'; -import type { Workspace } from '@/entities/workspace'; +import { toast } from 'sonner'; +import { updateWorkspaceInfo, type Workspace } from '@/entities/workspace'; interface WorkspaceInfoFormProps { workspace: Workspace; + // 워크스페이스 정보 수정은 RLS상 소유자만 가능하므로 UI도 소유자에게만 허용한다. + canEdit: boolean; } -export function WorkspaceInfoForm({ workspace }: WorkspaceInfoFormProps) { +export function WorkspaceInfoForm({ workspace, canEdit }: WorkspaceInfoFormProps) { // committed: 마지막으로 저장된 값. 입력값과 비교해 변경 여부(dirty)를 판단한다. const [committedName, setCommittedName] = useState(workspace.name); const [committedDescription, setCommittedDescription] = useState(workspace.description ?? ''); @@ -17,28 +20,53 @@ export function WorkspaceInfoForm({ workspace }: WorkspaceInfoFormProps) { const [name, setName] = useState(workspace.name); const [description, setDescription] = useState(workspace.description ?? ''); const [isSaved, setIsSaved] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const isDirty = name !== committedName || description !== committedDescription; - const canSubmit = name.trim().length > 0 && isDirty; + const canSubmit = canEdit && name.trim().length > 0 && isDirty && !isSubmitting; - const handleSubmit = (event: FormEvent) => { + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); if (!canSubmit) { return; } - // TODO: 백엔드 연동 시 워크스페이스 정보 저장 API 호출로 교체한다. const nextName = name.trim(); - setName(nextName); - setCommittedName(nextName); - setCommittedDescription(description); - setIsSaved(true); + const nextDescription = description.trim(); + + setIsSubmitting(true); + try { + await updateWorkspaceInfo({ + id: workspace.id, + name: nextName, + description: nextDescription.length > 0 ? nextDescription : undefined, + }); + // 성공 시에만 committed 값을 확정해 dirty 판단 기준을 갱신한다. + setName(nextName); + setDescription(nextDescription); + setCommittedName(nextName); + setCommittedDescription(nextDescription); + setIsSaved(true); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : '워크스페이스 정보 저장에 실패했습니다. 잠시 후 다시 시도해주세요.', + ); + } finally { + setIsSubmitting(false); + } }; return (

워크스페이스 정보

-

워크스페이스 이름과 설명을 수정할 수 있어요.

+

+ {canEdit + ? '워크스페이스 이름과 설명을 수정할 수 있어요.' + : '워크스페이스 정보는 소유자만 수정할 수 있어요.'} +

@@ -62,24 +91,27 @@ export function WorkspaceInfoForm({ workspace }: WorkspaceInfoFormProps) { setDescription(event.target.value); setIsSaved(false); }} + disabled={!canEdit} placeholder="워크스페이스를 소개하는 문구를 입력하세요." rows={3} - className="mt-2 min-h-24 w-full resize-none rounded-2xl bg-slate-100 px-4 py-3 text-sm font-medium text-slate-900 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-indigo-300" + className="mt-2 min-h-24 w-full resize-none rounded-2xl bg-slate-100 px-4 py-3 text-sm font-medium text-slate-900 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-indigo-300 disabled:cursor-not-allowed disabled:opacity-60" /> -
- - {isSaved && !isDirty ? ( - 저장되었습니다. - ) : null} -
+ {canEdit ? ( +
+ + {isSaved && !isDirty ? ( + 저장되었습니다. + ) : null} +
+ ) : null}
); diff --git a/src/features/manage-workspace-members/model/use-member-management.ts b/src/features/manage-workspace-members/model/use-member-management.ts index 88e8ed1..866f196 100644 --- a/src/features/manage-workspace-members/model/use-member-management.ts +++ b/src/features/manage-workspace-members/model/use-member-management.ts @@ -1,9 +1,12 @@ 'use client'; // 팀원 초대(이메일/링크)와 멤버 목록 상태를 관리합니다. -// 멤버 목록은 서버(RSC)에서 조회해 initialMembers로 주입받고, 훅은 로컬 상태로 seed만 한다. -// 백엔드 연동 전이므로 초대는 목록에 '초대됨' 상태의 멤버를 추가하는 목업으로 동작합니다. -import { useMemo, useState } from 'react'; +// 멤버 목록은 서버(RSC)에서 조회해 initialMembers로 주입받는다. +// 초대 링크는 서버에서 발급된 inviteCode와 런타임 origin으로 구성한다. +// 이메일 초대는 입력한 주소로 초대 링크가 담긴 메일을 발송한다(sendInviteEmail). +import { useMemo, useState, useSyncExternalStore } from 'react'; +import { toast } from 'sonner'; +import { sendInviteEmail, setWorkspaceInviteEnabled } from '@/entities/workspace'; import type { WorkspaceMember } from '@/entities/workspace-member'; export type InviteMode = 'email' | 'link'; @@ -11,59 +14,93 @@ export type InviteMode = 'email' | 'link'; interface UseMemberManagementParams { workspaceId: string; initialMembers: WorkspaceMember[]; + inviteCode: string | null; + inviteEnabled: boolean; } const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -function createMemberId() { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return `member-${crypto.randomUUID()}`; - } +// origin은 클라이언트에서만 알 수 있는 값이라 useSyncExternalStore로 읽는다. +// 서버 스냅샷은 빈 문자열이라 하이드레이션 불일치가 발생하지 않고, origin은 변하지 않으므로 구독은 no-op이다. +const subscribeToOrigin = () => () => {}; +const getOriginSnapshot = () => window.location.origin; +const getOriginServerSnapshot = () => ''; - return `member-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} +export function useMemberManagement({ + workspaceId, + initialMembers, + inviteCode, + inviteEnabled, +}: UseMemberManagementParams) { + const [members] = useState(initialMembers); + const [inviteMode, setInviteMode] = useState('email'); + const [email, setEmail] = useState(''); + const [isSendingInvite, setIsSendingInvite] = useState(false); -function toNickname(email: string) { - return email.split('@')[0]; -} + // 초대 링크 활성 여부는 서버값으로 seed하고, 토글은 낙관적으로 반영 후 실패 시 롤백한다. + const [isInviteEnabled, setIsInviteEnabled] = useState(inviteEnabled); + const [isTogglingInvite, setIsTogglingInvite] = useState(false); -function toAvatarLabel(email: string) { - return email.charAt(0).toUpperCase(); -} + const toggleInviteEnabled = async () => { + if (isTogglingInvite) { + return; + } -export function useMemberManagement({ workspaceId, initialMembers }: UseMemberManagementParams) { - const [members, setMembers] = useState(initialMembers); - const [inviteMode, setInviteMode] = useState('email'); - const [email, setEmail] = useState(''); + const next = !isInviteEnabled; + setIsInviteEnabled(next); + setIsTogglingInvite(true); + try { + await setWorkspaceInviteEnabled({ workspaceId, enabled: next }); + } catch (error) { + // 실패 시 이전 상태로 롤백한다. + setIsInviteEnabled(!next); + console.error(error); + toast.error( + error instanceof Error + ? error.message + : '초대 링크 설정 변경에 실패했습니다. 잠시 후 다시 시도해주세요.', + ); + } finally { + setIsTogglingInvite(false); + } + }; - // 백엔드 연동 시 서버에서 발급한 초대 링크로 교체한다. - const inviteLink = `https://syncly.app/invite/${workspaceId}`; + const origin = useSyncExternalStore( + subscribeToOrigin, + getOriginSnapshot, + getOriginServerSnapshot, + ); + + // 서버에서 발급된 초대 코드와 런타임 origin으로 초대 링크를 구성한다. + const inviteLink = inviteCode && origin ? `${origin}/invite/${inviteCode}` : ''; const trimmedEmail = email.trim(); const isDuplicate = useMemo( () => members.some((member) => member.email.toLowerCase() === trimmedEmail.toLowerCase()), [members, trimmedEmail], ); - const canInvite = EMAIL_PATTERN.test(trimmedEmail) && !isDuplicate; + const canInvite = EMAIL_PATTERN.test(trimmedEmail) && !isDuplicate && !isSendingInvite; - const inviteByEmail = () => { + const inviteByEmail = async () => { if (!canInvite) { return; } - setMembers((previous) => [ - ...previous, - { - workspaceId, - userId: createMemberId(), - workspaceNickname: toNickname(trimmedEmail), - avatarLabel: toAvatarLabel(trimmedEmail), - email: trimmedEmail, - role: 'member', - status: 'invited', - }, - ]); - setEmail(''); + setIsSendingInvite(true); + try { + await sendInviteEmail({ workspaceId, email: trimmedEmail }); + toast.success('초대 메일을 보냈어요.'); + setEmail(''); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : '초대 메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.', + ); + } finally { + setIsSendingInvite(false); + } }; return { @@ -76,5 +113,9 @@ export function useMemberManagement({ workspaceId, initialMembers }: UseMemberMa canInvite, isDuplicate, inviteByEmail, + isSendingInvite, + isInviteEnabled, + isTogglingInvite, + toggleInviteEnabled, }; } diff --git a/src/features/manage-workspace-members/ui/MemberInviteSection.tsx b/src/features/manage-workspace-members/ui/MemberInviteSection.tsx index 068110a..2e86419 100644 --- a/src/features/manage-workspace-members/ui/MemberInviteSection.tsx +++ b/src/features/manage-workspace-members/ui/MemberInviteSection.tsx @@ -14,7 +14,12 @@ interface MemberInviteSectionProps { canInvite: boolean; isDuplicate: boolean; onInviteByEmail: () => void; + isSendingInvite: boolean; inviteLink: string; + isInviteEnabled: boolean; + isTogglingInvite: boolean; + onToggleInviteEnabled: () => void; + canManageInvite: boolean; } const INVITE_MODES: { key: InviteMode; label: string }[] = [ @@ -30,11 +35,22 @@ export function MemberInviteSection({ canInvite, isDuplicate, onInviteByEmail, + isSendingInvite, inviteLink, + isInviteEnabled, + isTogglingInvite, + onToggleInviteEnabled, + canManageInvite, }: MemberInviteSectionProps) { const [isCopied, setIsCopied] = useState(false); + const canCopy = isInviteEnabled && inviteLink.length > 0; + const handleCopy = async () => { + if (!canCopy) { + return; + } + try { await navigator.clipboard.writeText(inviteLink); setIsCopied(true); @@ -96,37 +112,77 @@ export function MemberInviteSection({ className="flex h-11 shrink-0 items-center gap-2 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" >