Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 22 additions & 5 deletions app/mypage/_components/ScreenMyPage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"use client";

import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect, useMemo, useTransition } from "react";
import { BackButton } from "@/components/ui/BackButton";
import { useRouter } from "next/navigation";
import Button from "@/components/ui/Button";
import ProfileButton from "@/app/profile-builder/_components/ProfileButton";
import ProfileImageSelection from "@/app/profile-image/_components/ProfileImageSelection";
import { ChevronRight, Shuffle } from "lucide-react";

Check warning on line 9 in app/mypage/_components/ScreenMyPage.tsx

View workflow job for this annotation

GitHub Actions / lint

'ChevronRight' is defined but never used
import { cn } from "@/lib/utils";

Check warning on line 10 in app/mypage/_components/ScreenMyPage.tsx

View workflow job for this annotation

GitHub Actions / lint

'cn' is defined but never used
import { generateRandomNickname } from "@/lib/utils/nickname";
import {
getDefaultProfilesByGender,
Expand Down Expand Up @@ -39,6 +40,7 @@
NicknameAvailabilityResponse,
} from "@/hooks/useNicknameAvailability";
import axios from "axios";
import { withdrawAction } from "@/lib/actions/authAction";

/* ───── 상수 맵핑 ───── */

Expand Down Expand Up @@ -162,6 +164,7 @@
}

const ScreenMyPage = ({ initialProfile }: ScreenMyPageProps) => {
const router = useRouter();
// 서버에서 프로필 데이터 가져오기
const { data: profileResponse, isLoading, isError } = useMyProfile();
const { mutate: updateMyProfileMutate, isPending: isUpdating } =
Expand All @@ -171,7 +174,8 @@
isPending: isCheckingNickname,
} = useNicknameAvailability();

const isSubmitting = isUpdating || isCheckingNickname;
const [isWithdrawing, startWithdrawTransition] = useTransition();
const isSubmitting = isUpdating || isCheckingNickname || isWithdrawing;

const profile = profileResponse?.data;

Expand Down Expand Up @@ -860,10 +864,23 @@
{/* ── 탈퇴하기 ── */}
<button
Comment thread
dasosann marked this conversation as resolved.
type="button"
className="typo-16-500 self-center text-[#B3B3B3] underline"
className="typo-16-500 self-center text-[#B3B3B3] underline disabled:opacity-50"
disabled={isSubmitting}
onClick={() => {
if (confirm("정말 탈퇴하시겠습니까?")) {
// TODO: 탈퇴 API 호출
if (
confirm(
"정말 탈퇴하시겠습니까?\n모든 데이터가 삭제되며 복구할 수 없습니다.",
)
) {
startWithdrawTransition(async () => {
const result = await withdrawAction();
if (result.success) {
alert(result.message);
router.replace("/");
} else {
alert(result.message);
}
});
}
}}
>
Expand Down
50 changes: 2 additions & 48 deletions lib/actions/admin/adminLoginAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,54 +32,8 @@ export async function adminLoginAction(
body: { email, password },
});

// 🍪 백엔드로부터 받은 쿠키가 있다면 브라우저에 배달해줍니다.
if (setCookie) {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();

setCookie.forEach((cookieStr) => {
const [nameValue, ...options] = cookieStr.split(";");
const [name, ...nameParts] = nameValue.split("=");
const value = nameParts.join("=");

const cookieOptions: {
path?: string;
httpOnly?: boolean;
secure?: boolean;
maxAge?: number;
expires?: Date;
sameSite?: "strict" | "lax" | "none" | boolean;
domain?: string;
} = {};

options.forEach((opt) => {
const [key, ...valueParts] = opt.trim().split("=");
const val = valueParts.join("=");
const k = key.toLowerCase();
if (k === "path") cookieOptions.path = val;
if (k === "httponly") cookieOptions.httpOnly = true;
if (k === "secure") cookieOptions.secure = true;
if (k === "max-age") cookieOptions.maxAge = parseInt(val);
if (k === "expires") cookieOptions.expires = new Date(val);
if (k === "domain") cookieOptions.domain = val;
if (k === "samesite")
cookieOptions.sameSite = val.toLowerCase() as
| "strict"
| "lax"
| "none";
});

// 배포 환경에서는 백엔드와 직접 통신하므로, 서브도메인 간 공유를 위해 명시적으로 도메인 설정
if (process.env.NODE_ENV === "production") {
cookieOptions.domain = ".comatching.site";
} else {
// 로컬에서는 도메인을 지워야 브라우저가 localhost에서 쿠키를 정상적으로 저장함
delete cookieOptions.domain;
}

cookieStore.set(name, value, cookieOptions);
});
}
// 🍪 serverApi.post 내부에서 response header의 set-cookie를 파싱하여
// 이미 cookieStore에 저장했으므로, 여기서 별도로 처리할 필요가 없습니다.
} catch (error) {
if (
error instanceof Error &&
Expand Down
45 changes: 45 additions & 0 deletions lib/actions/authAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,48 @@ export async function resetPasswordAction(
};
}
}

export type WithdrawResponse = {
success: boolean;
message: string;
};

/**
* 회원 탈퇴 Server Action
*/
export async function withdrawAction(): Promise<WithdrawResponse> {
try {
await serverApi.delete({
path: "/api/auth/withdraw",
});

// 탈퇴 성공 시 로컬 쿠키(토큰) 삭제
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
cookieStore.delete("accessToken");
cookieStore.delete("refreshToken");
cookieStore.delete("sessionId");

return { success: true, message: "회원 탈퇴가 완료되었습니다." };
} catch (error) {
if (
error instanceof Error &&
(error as Error & { digest?: string }).digest?.startsWith("NEXT_REDIRECT")
) {
throw error;
}

if (isAxiosError(error)) {
const message =
error.response?.data?.message || "회원 탈퇴에 실패했습니다.";
console.error("[withdrawAction] API Error", {
status: error.response?.status,
message,
});
return { success: false, message };
}

console.error("[withdrawAction] Unexpected Error", error);
return { success: false, message: "알 수 없는 오류가 발생했습니다." };
}
}
51 changes: 2 additions & 49 deletions lib/actions/loginAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,55 +30,8 @@ export async function loginAction(
body: { email, password },
});

// 🍪 백엔드로부터 받은 쿠키가 있다면 브라우저에 배달해줍니다.
if (setCookie) {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();

setCookie.forEach((cookieStr) => {
// Axios가 준 쿠키 문자열을 파싱합니다 (name=value; Path=/ ...)
const [nameValue, ...options] = cookieStr.split(";");
const [name, ...nameParts] = nameValue.split("=");
const value = nameParts.join("=");

const cookieOptions: {
path?: string;
httpOnly?: boolean;
secure?: boolean;
maxAge?: number;
expires?: Date;
sameSite?: "strict" | "lax" | "none" | boolean;
domain?: string;
} = {};

options.forEach((opt) => {
const [key, ...valueParts] = opt.trim().split("=");
const val = valueParts.join("=");
const k = key.toLowerCase();
if (k === "path") cookieOptions.path = val;
if (k === "httponly") cookieOptions.httpOnly = true;
if (k === "secure") cookieOptions.secure = true;
if (k === "max-age") cookieOptions.maxAge = parseInt(val);
if (k === "expires") cookieOptions.expires = new Date(val);
if (k === "domain") cookieOptions.domain = val;
if (k === "samesite")
cookieOptions.sameSite = val.toLowerCase() as
| "strict"
| "lax"
| "none";
});

// 배포 환경에서는 백엔드와 직접 통신하므로, 서브도메인 간 공유를 위해 명시적으로 도메인 설정
if (process.env.NODE_ENV === "production") {
cookieOptions.domain = ".comatching.site";
} else {
// 로컬에서는 도메인을 지워야 브라우저가 localhost에서 쿠키를 정상적으로 저장함
delete cookieOptions.domain;
}

cookieStore.set(name, value, cookieOptions);
});
}
// 🍪 serverApi.post 내부에서 response header의 set-cookie를 파싱하여
// 이미 cookieStore에 저장했으므로, 여기서 별도로 처리할 필요가 없습니다.

if (finalUrl) {
// https://comatching.site/onboarding -> /onboarding 추출
Expand Down
Loading