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 ? (
- >
) : (