Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e318ba3
feat(icons): 초대·구성원 관리용 디자인 시스템 아이콘 추가
Oct 6, 2026
c79e22a
refactor(ui): 공통 다이얼로그·아바타·배지 확장 옵션 추가
Oct 6, 2026
a3fef36
fix(rooms): 방장 위임 직후 역할 캐시 즉시 반영
Oct 6, 2026
0e8710e
feat(rooms): 방 나가기·여행 삭제 확인 팝업 디자인 통일
Oct 6, 2026
3de0c3f
feat(header): 헤더 구성원 미리보기·초대하기 추가 및 사이드바 멤버 탭 제거
Oct 6, 2026
3f4bc6a
feat(member-settings): 초대하기 화면 개편
Oct 6, 2026
5ae842b
feat(rooms): 여행 정보 수정 모달 Figma 디자인 반영
Oct 6, 2026
7ce27e8
feat(waiting): 승인 대기 화면 디자인 개편
Oct 6, 2026
3458848
feat(sidetab): 헤더 멤버/초대 분리, 토스트 성공/실패처리 이펙트 추가, 모달 화면 수정
Oct 8, 2026
3cfa3c7
feat(rooms): 멤버 초대 공유와 여행 정보 수정 허용
minbros Oct 10, 2026
7bcb052
revert(rooms): 멤버 초대 공유와 여행 정보 수정 변경 분리
minbros Oct 10, 2026
58f96fc
feat(rooms): 멤버 초대 공유와 여행 정보 수정 허용
minbros Oct 10, 2026
dfe2ffa
fix(ui): 초대와 여행 설정의 Sonar 이슈 수정
minbros Oct 10, 2026
3a2afcb
merge(rooms): 기준 PR의 Sonar 수정 반영
minbros Oct 10, 2026
abdc3f6
merge(ui): 최신 dev 반영과 사이드바 충돌 해결
minbros Oct 10, 2026
99f41d6
merge(rooms): 기준 PR의 최신 dev 병합 반영
minbros Oct 10, 2026
30a25ba
merge(rooms): PR 220 병합 후 dev 충돌 해결
minbros Oct 10, 2026
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
11 changes: 6 additions & 5 deletions src/app/(main)/member-settings/_components/AddMemberPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { toast } from "sonner";

import { useRegenerateInviteCode } from "@/hooks/useRooms";
import { isHostRole } from "@/lib/rooms";
import { isHostRole, isRoomMemberRole } from "@/lib/rooms";
import {
bucketMemberCount,
toAnalyticsRoomRole,
Expand Down Expand Up @@ -42,12 +42,12 @@
return <LoadingIndicator label={isRoomDetailLoading ? "방 정보 불러오는 중" : "초대 링크 발급 중"} />;
}
let message = "발급에 실패했어요. 아래에서 재발급을 눌러 주세요.";
if (!canIssue) message = "방장만 볼 수 있어요.";
if (!canIssue) message = "초대 링크를 사용할 수 없어요. 방 정보를 다시 확인해 주세요.";
else if (isRoomDetailError || inviteCode === undefined) message = "방 정보를 불러오지 못했어요. 아래에서 재발급을 눌러 주세요.";
return <span className="truncate text-body-s-regular mobile:text-body-xs-regular text-text-subtle">{message}</span>;
}

export function AddMemberPanel({

Check failure on line 50 in src/app/(main)/member-settings/_components/AddMemberPanel.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaEktMnKvdWhUKH3NbVw&open=AaEktMnKvdWhUKH3NbVw&pullRequest=225
roomId,
inviteCode,
memberCount,
Expand All @@ -59,7 +59,8 @@
}: Readonly<Props>) {
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<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -110,7 +111,7 @@

const displayedCode =
issuedCode === undefined ? detailInviteCode || null : issuedCode;
const inviteUrl = displayedCode
const inviteUrl = canShareInvite && displayedCode
? `${typeof window !== "undefined" ? window.location.origin : ""}/join/${displayedCode}`
: null;

Expand Down Expand Up @@ -163,7 +164,7 @@
}

function handleRegenerate() {
if (!roomIdTrim.length) return;
if (!roomIdTrim.length || !canIssue || isRegenerating) return;
setCopied(false);
regenerate(roomIdTrim, {
onSuccess: ({ inviteCode: newCode }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/** 방장 전용 여행 삭제 — 제목 옆 작은 텍스트 버튼과 삭제 확인 */
Expand Down Expand Up @@ -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 (
Expand All @@ -73,7 +75,7 @@ export function RoomTripSettingsSection({
여행 정보를 불러오지 못했어요.
</p>
) : (
<RoomTripEditForm room={roomSource} readOnly={!isHost} onCancel={onCancel} onSaved={onSaved} />
<RoomTripEditForm room={roomSource} readOnly={!canEdit} onCancel={onCancel} onSaved={onSaved} />
)}
</div>
);
Expand Down
6 changes: 3 additions & 3 deletions src/app/home/_components/InviteRoomModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,8 +24,8 @@ export function InviteRoomModal({ room, onClose }: {
<p className="text-body-m-regular text-text-subtle">방 정보를 불러오지 못했어요.</p>
<button type="button" onClick={() => void refetch()} className="rounded-[12px] bg-primary px-5 py-3 text-label-l-emphasis text-text-inverse">다시 시도</button>
</div>
) : !isHostRole(data.role) ? (
<p className="py-8 text-center text-body-m-regular text-text-subtle">방장만 멤버를 초대할 수 있어요.</p>
) : !isRoomMemberRole(data.role) ? (
<p className="py-8 text-center text-body-m-regular text-text-subtle">여행 멤버만 초대 링크를 공유할 수 있어요.</p>
) : (
<AddMemberPanel
key={room.id}
Expand Down
33 changes: 31 additions & 2 deletions src/app/home/_components/RoomCardMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { RoomDetail, RoomListItem } from "@/lib/api/rooms";
import { AddMemberPanel } from "@/app/(main)/member-settings/_components/AddMemberPanel";
import { RoomCardMenu } from "./RoomCardMenu";

const state = vi.hoisted(() => ({
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(<AddMemberPanel roomId="selected-room" role={role} inviteCode="cached-code" isRoomDetailLoading={false} isRoomDetailError={false} />));
expect(document.querySelector('input[aria-label="초대 링크"]')).toBeNull();
expect(button("복사").disabled).toBe(true);
expect(button("초대 링크 재발급")).toBeUndefined();
expect(state.regenerate).not.toHaveBeenCalled();
});
8 changes: 4 additions & 4 deletions src/app/home/_components/RoomCardMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,8 +56,7 @@ export function RoomCardMenu({ room, onDelete, onLeave }: Props) {

{open && (
<div className="absolute right-[10px] top-[42px] z-30 w-40 overflow-hidden rounded-[12px] bg-white shadow-[0_2px_10px_rgba(0,0,0,0.1)]">
{isHost ? (
<>
{isRoomMemberRole(room.role) ? (
<button
type="button"
onClick={() => {
Expand All @@ -70,6 +69,8 @@ export function RoomCardMenu({ room, onDelete, onLeave }: Props) {
<Image src="/rooms/figma/invite.svg" alt="" width={20} height={20} className="size-5 shrink-0" />
초대하기
</button>
) : null}
{isHost ? (
<button
type="button"
onClick={(e) => {
Expand All @@ -82,7 +83,6 @@ export function RoomCardMenu({ room, onDelete, onLeave }: Props) {
<Image src="/rooms/figma/trash.svg" alt="" width={20} height={20} className="size-5 shrink-0" />
방 삭제하기
</button>
</>
) : (
<button
type="button"
Expand Down
20 changes: 15 additions & 5 deletions src/components/rooms/RoomTripEditDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";

const state = vi.hoisted(() => ({
room: { id: "room", title: "가을 여행", destinations: ["서울"], startDate: "2026-10-10", endDate: "2026-10-12" },
room: { id: "room", title: "가을 여행", destinations: ["서울"], startDate: "2026-10-10", endDate: "2026-10-12", role: "HOST" },
isHost: true, isLoading: false, missing: false, pending: false,
update: vi.fn(),
}));
Expand Down Expand Up @@ -54,7 +54,7 @@ async function open() {
return trigger;
}
beforeEach(async () => {
state.isHost = true; state.isLoading = false; state.missing = false; state.pending = false;
state.room.role = "HOST"; state.isHost = true; state.isLoading = false; state.missing = false; state.pending = false;
state.update.mockReset();
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
await act(async () => root.render(<HeaderBar />));
Expand Down Expand Up @@ -98,11 +98,21 @@ it("retains the original date floor, date ordering and required destination vali
expect(state.update).not.toHaveBeenCalled();
});

it("shows nonhost travel information without editable fields or a save action", async () => {
state.isHost = false; await open();
expect(dialog()!.textContent).toContain("방장만 여행 정보를 수정할 수 있어요.");
it("lets members save travel information without granting deletion", async () => {
state.isHost = false; state.room.role = "MEMBER"; await open();
await change('input[type="text"]', "멤버 수정");
await click("적용하기", dialog()!);
expect(state.update).toHaveBeenCalledWith(expect.objectContaining({
roomId: "room", data: expect.objectContaining({ title: "멤버 수정" }),
}), expect.any(Object));
expect(dialog()!.textContent).not.toContain("여행 삭제");
});

it.each(["PENDING", "", "UNKNOWN"])("denies editing for unrecognized membership %s", async (role) => {
state.isHost = false; state.room.role = role; await open();
expect(dialog()!.querySelector("input")).toBeNull();
expect(dialog()!.textContent).not.toContain("적용하기");
expect(state.update).not.toHaveBeenCalled();
});

it.each(["loading", "missing"])("does not offer editing for %s room data", async (mode) => {
Expand Down
5 changes: 5 additions & 0 deletions src/lib/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export function isHostRole(role: string | undefined | null): boolean {
return role.toUpperCase() === "HOST";
}

/** 서버가 확인한 기존 방 참여자만 초대 링크 공유와 여행 정보 수정을 할 수 있다. */
export function isRoomMemberRole(role: string | undefined | null): boolean {
return isHostRole(role) || role?.toUpperCase() === "MEMBER";
}

/** 방 목록·상세·멤버 role 중 하나라도 HOST 이면 true (캐시 타이밍 보강) */
export function resolveViewerIsHost(sources: {
listRole?: string | null;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/rooms/host-transfer-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("applyHostTransferToCache", () => {
const members = qc.getQueryData<RoomMemberListResponse>(roomMembersQueryKey(ROOM))!.members;
expect(members.map((m) => [m.userId, m.role])).toEqual([[1, "MEMBER"], [2, "HOST"], [3, "MEMBER"]]);
const detail = qc.getQueryData<RoomDetail>(roomDetailQueryKey(ROOM))!;
expect(detail).toMatchObject({ role: "MEMBER", inviteCode: null });
expect(detail).toMatchObject({ role: "MEMBER", inviteCode: "CODE" });
const rooms = qc.getQueryData<RoomListResponse>(ROOMS_QUERY_KEY)!.rooms;
expect(rooms.map((r) => r.role)).toEqual(["MEMBER", "HOST"]);

Expand Down
4 changes: 2 additions & 2 deletions src/lib/rooms/host-transfer-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ export function applyHostTransferToCache(
}),
},
);
// 서버는 참여자에게 inviteCode를 내려주지 않는다
// 위임 후에도 기존 멤버이므로 초대 링크는 유지한다.
queryClient.setQueryData<RoomDetail>(
roomDetailQueryKey(roomId),
(prev) => prev && { ...prev, role: "MEMBER", inviteCode: null },
(prev) => prev && { ...prev, role: "MEMBER" },
);
queryClient.setQueryData<RoomListResponse>(
ROOMS_QUERY_KEY,
Expand Down