diff --git a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx index 618eb620..7fe70ec9 100644 --- a/src/app/(main)/member-settings/_components/AddMemberPanel.tsx +++ b/src/app/(main)/member-settings/_components/AddMemberPanel.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useRegenerateInviteCode } from "@/hooks/useRooms"; -import { isHostRole } from "@/lib/rooms"; +import { isHostRole, isRoomMemberRole } from "@/lib/rooms"; import { bucketMemberCount, toAnalyticsRoomRole, @@ -42,7 +42,7 @@ function InviteFallback({ canIssue, inviteCode, isRoomDetailLoading, isRoomDetai return ; } let message = "발급에 실패했어요. 아래에서 재발급을 눌러 주세요."; - if (!canIssue) message = "방장만 볼 수 있어요."; + if (!canIssue) message = "초대 링크를 사용할 수 없어요. 방 정보를 다시 확인해 주세요."; else if (isRoomDetailError || inviteCode === undefined) message = "방 정보를 불러오지 못했어요. 아래에서 재발급을 눌러 주세요."; return {message}; } @@ -59,7 +59,8 @@ export function AddMemberPanel({ }: Readonly) { const roomIdTrim = roomId.trim(); // 초대 코드 발급·재발급 API는 방장 전용 — 참여자는 서버가 내려준 코드가 있을 때만 링크를 보여준다 - const canIssue = isHost ?? isHostRole(role); + const canShareInvite = isRoomMemberRole(role) && !isRoomDetailError; + const canIssue = canShareInvite && (isHost ?? isHostRole(role)); const detailInviteCode = normalizeInviteCode(inviteCode); const [copied, setCopied] = useState(false); const copyTimerRef = useRef | null>(null); @@ -110,7 +111,7 @@ export function AddMemberPanel({ const displayedCode = issuedCode === undefined ? detailInviteCode || null : issuedCode; - const inviteUrl = displayedCode + const inviteUrl = canShareInvite && displayedCode ? `${typeof window !== "undefined" ? window.location.origin : ""}/join/${displayedCode}` : null; @@ -163,7 +164,7 @@ export function AddMemberPanel({ } function handleRegenerate() { - if (!roomIdTrim.length) return; + if (!roomIdTrim.length || !canIssue || isRegenerating) return; setCopied(false); regenerate(roomIdTrim, { onSuccess: ({ inviteCode: newCode }) => { diff --git a/src/app/(main)/room-settings/_components/RoomTripSettingsSection.tsx b/src/app/(main)/room-settings/_components/RoomTripSettingsSection.tsx index a9355266..4eb531ec 100644 --- a/src/app/(main)/room-settings/_components/RoomTripSettingsSection.tsx +++ b/src/app/(main)/room-settings/_components/RoomTripSettingsSection.tsx @@ -7,6 +7,7 @@ import { DeleteConfirmModal } from "@/app/home/_components/DeleteConfirmModal"; import { RoomTripEditForm } from "@/components/rooms/RoomTripEditForm"; import { MainPageHeader } from "@/components/layout/MainPageHeader"; import { useCurrentRoomMembership } from "@/hooks/useCurrentRoomMembership"; +import { isRoomMemberRole } from "@/lib/rooms"; import { useSessionStore } from "@/stores/session-store"; /** 방장 전용 여행 삭제 — 제목 옆 작은 텍스트 버튼과 삭제 확인 */ @@ -51,9 +52,10 @@ export function RoomTripSettingsSection({ onCancel?: () => void; onSaved?: () => void; }) { - const { roomId, roomSource, isHost, isLoading } = useCurrentRoomMembership(); - const description = !isLoading && roomSource && roomId && !isHost - ? "방장만 여행 정보를 수정할 수 있어요." + const { roomId, roomSource, isLoading } = useCurrentRoomMembership(); + const canEdit = isRoomMemberRole(roomSource?.role); + const description = !isLoading && roomSource && roomId && !canEdit + ? "여행 멤버만 여행 정보를 수정할 수 있어요." : undefined; return ( @@ -73,7 +75,7 @@ export function RoomTripSettingsSection({ 여행 정보를 불러오지 못했어요.

) : ( - + )} ); diff --git a/src/app/home/_components/InviteRoomModal.tsx b/src/app/home/_components/InviteRoomModal.tsx index 19b5e92e..2847b6d7 100644 --- a/src/app/home/_components/InviteRoomModal.tsx +++ b/src/app/home/_components/InviteRoomModal.tsx @@ -6,7 +6,7 @@ import { AddMemberPanel } from "@/app/(main)/member-settings/_components/AddMemb import { SettingsDialog } from "@/components/settings/SettingsDialog"; import { useRoomDetail } from "@/hooks/useRoomDetail"; import type { RoomListItem } from "@/lib/api/rooms"; -import { isHostRole } from "@/lib/rooms"; +import { isRoomMemberRole } from "@/lib/rooms"; export function InviteRoomModal({ room, onClose }: { room: RoomListItem; @@ -24,8 +24,8 @@ export function InviteRoomModal({ room, onClose }: {

방 정보를 불러오지 못했어요.

- ) : !isHostRole(data.role) ? ( -

방장만 멤버를 초대할 수 있어요.

+ ) : !isRoomMemberRole(data.role) ? ( +

여행 멤버만 초대 링크를 공유할 수 있어요.

) : ( ({ @@ -92,7 +93,8 @@ it.each(["loading", "error", "member"])("does not issue links while detail is %s if (mode === "member") state.detail!.role = "MEMBER"; await openInvite(); expect(state.regenerate).not.toHaveBeenCalled(); - expect(button("복사")).toBeUndefined(); + if (mode === "member") expect(button("복사").disabled).toBe(true); + else expect(button("복사")).toBeUndefined(); if (mode === "error") { await click("다시 시도"); expect(state.refetch).toHaveBeenCalledOnce(); @@ -142,8 +144,35 @@ it("preserves host deletion and member leave permissions", async () => { expect(onDelete).toHaveBeenCalledWith(room); await render("MEMBER"); await click("제주 여행 더보기"); - expect(button("초대하기")).toBeUndefined(); + expect(button("초대하기")).toBeDefined(); expect(button("방 삭제하기")).toBeUndefined(); await click("방 나가기"); expect(onLeave).toHaveBeenCalledWith({ ...room, role: "MEMBER" }); }); + +it("lets a member copy and share the existing link without issuing or regenerating it", async () => { + state.detail!.role = "MEMBER"; + await render("MEMBER"); + await click("제주 여행 더보기"); await click("초대하기"); + await click("복사"); + expect(state.copy).toHaveBeenLastCalledWith(`${window.location.origin}/join/existing-code`); + await click("공유"); + expect(state.copy).toHaveBeenCalledTimes(2); + expect(button("초대 링크 재발급")).toBeUndefined(); + expect(state.regenerate).not.toHaveBeenCalled(); +}); + +it("does not expose an invite from a nonmember detail response", async () => { + state.detail!.role = "PENDING" as RoomDetail["role"]; + await openInvite(); + expect(document.querySelector('input[aria-label="초대 링크"]')).toBeNull(); + expect(state.regenerate).not.toHaveBeenCalled(); +}); + +it.each(["PENDING", undefined])("hides even a cached invite link for invalid role %s", async (role) => { + await act(async () => root.render()); + expect(document.querySelector('input[aria-label="초대 링크"]')).toBeNull(); + expect(button("복사").disabled).toBe(true); + expect(button("초대 링크 재발급")).toBeUndefined(); + expect(state.regenerate).not.toHaveBeenCalled(); +}); diff --git a/src/app/home/_components/RoomCardMenu.tsx b/src/app/home/_components/RoomCardMenu.tsx index 7d9d311b..0351eaef 100644 --- a/src/app/home/_components/RoomCardMenu.tsx +++ b/src/app/home/_components/RoomCardMenu.tsx @@ -7,7 +7,7 @@ import { InviteRoomModal } from "./InviteRoomModal"; import { useOnClickOutside } from "@/hooks/useOnClickOutside"; import { RoomListItem } from "@/lib/api/rooms"; -import { isHostRole } from "@/lib/rooms"; +import { isHostRole, isRoomMemberRole } from "@/lib/rooms"; type Props = { room: RoomListItem; @@ -56,8 +56,7 @@ export function RoomCardMenu({ room, onDelete, onLeave }: Props) { {open && (
- {isHost ? ( - <> + {isRoomMemberRole(room.role) ? ( + ) : null} + {isHost ? ( - ) : (