마이페이지, 리포트, 메일인증
This commit is contained in:
Generated
+1344
-2
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -19,12 +19,14 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "^16.0.3",
|
||||
"nodemailer": "^7.0.11",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zustand": "^5.0.8"
|
||||
@@ -35,9 +37,9 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[emailVerifyToken]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "emailVerifyToken" TEXT,
|
||||
ADD COLUMN "emailVerifyTokenExpiresAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_emailVerifyToken_key" ON "User"("emailVerifyToken");
|
||||
@@ -37,6 +37,9 @@ model User {
|
||||
email String @unique
|
||||
passwordHash String
|
||||
tokenVersion Int @default(0)
|
||||
emailVerified Boolean @default(false)
|
||||
emailVerifyToken String? @unique
|
||||
emailVerifyTokenExpiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { verifyPassword, verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// /api/auth/change-email
|
||||
// - pb_access JWT 쿠키로 인증된 사용자의 이메일을 변경한다.
|
||||
// - 현재 비밀번호를 재입력 받아 검증한 뒤, 아직 사용되지 않은 새 이메일로 교체한다.
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) {
|
||||
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as any;
|
||||
const newEmailRaw: string | undefined = body?.newEmail;
|
||||
const password: string | undefined = body?.password;
|
||||
|
||||
if (typeof newEmailRaw !== "string" || typeof password !== "string") {
|
||||
return NextResponse.json({ message: "newEmail 과 password 는 필수입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const newEmail = newEmailRaw.trim().toLowerCase();
|
||||
if (!newEmail || !newEmail.includes("@")) {
|
||||
return NextResponse.json({ message: "유효한 이메일 주소를 입력해주세요." }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) {
|
||||
return NextResponse.json({ message: "사용자를 찾을 수 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(password, (user as any).passwordHash);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ message: "비밀번호가 일치하지 않습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
// 이미 다른 유저가 사용 중인 이메일인지 확인한다.
|
||||
const existing = await prisma.user.findUnique({ where: { email: newEmail } });
|
||||
if (existing && existing.id !== user.id) {
|
||||
return NextResponse.json({ message: "이미 사용 중인 이메일입니다." }, { status: 409 });
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
email: newEmail,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "이메일이 변경되었습니다.", email: (updated as any).email },
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ message: "이메일 변경 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { verifyPassword, hashPassword, signAccessToken, verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// /api/auth/change-password
|
||||
// - pb_access JWT 쿠키로 인증된 사용자의 비밀번호를 변경한다.
|
||||
// - 현재 비밀번호를 한 번 더 입력받아 검증한 뒤, 새 비밀번호로 passwordHash 를 교체하고 tokenVersion 을 증가시킨다.
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) {
|
||||
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as any;
|
||||
const currentPassword: string | undefined = body?.currentPassword;
|
||||
const newPassword: string | undefined = body?.newPassword;
|
||||
|
||||
if (typeof currentPassword !== "string" || typeof newPassword !== "string") {
|
||||
return NextResponse.json({ message: "currentPassword 와 newPassword 는 필수입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) {
|
||||
return NextResponse.json({ message: "사용자를 찾을 수 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(currentPassword, (user as any).passwordHash);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ message: "현재 비밀번호가 일치하지 않습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return NextResponse.json({ message: "비밀번호는 최소 8자 이상이어야 합니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const newHash = await hashPassword(newPassword);
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash: newHash,
|
||||
tokenVersion: (user as any).tokenVersion + 1,
|
||||
},
|
||||
});
|
||||
|
||||
const newToken = await signAccessToken({
|
||||
id: updated.id,
|
||||
email: (updated as any).email,
|
||||
tokenVersion: (updated as any).tokenVersion,
|
||||
emailVerified: (updated as any).emailVerified ?? false,
|
||||
});
|
||||
|
||||
const res = NextResponse.json({ message: "비밀번호가 변경되었습니다." }, { status: 200 });
|
||||
|
||||
res.cookies.set("pb_access", newToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ message: "비밀번호 변경 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,12 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = await signAccessToken({ id: user.id, email: user.email, tokenVersion: user.tokenVersion });
|
||||
const token = await signAccessToken({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
tokenVersion: user.tokenVersion,
|
||||
emailVerified: (user as any).emailVerified ?? false,
|
||||
});
|
||||
|
||||
const { passwordHash: _ph, ...safeUser } = user;
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// /api/auth/me
|
||||
// - 요청 쿠키에 포함된 JWT(pb_access)를 검증하고,
|
||||
// - 유효한 경우 토큰에서 식별 가능한 유저 정보를 반환한다.
|
||||
// - 유효한 경우 JWT 의 sub(id) 를 기준으로 실제 DB(User) 에서 최신 유저 정보를 조회해 반환한다.
|
||||
// - 이렇게 하면 emailVerified 와 같은 필드가 브라우저/세션에 관계없이 항상 DB 기준으로 일관되게 반영된다.
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
@@ -34,10 +38,18 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
// JWT 에서 식별한 ID 를 기준으로 실제 User 를 조회해 최신 정보를 사용한다.
|
||||
const dbUser = await prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
|
||||
if (!dbUser) {
|
||||
return NextResponse.json({ message: "사용자를 찾을 수 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
tokenVersion: payload.tokenVersion,
|
||||
id: dbUser.id,
|
||||
email: dbUser.email,
|
||||
tokenVersion: (dbUser as any).tokenVersion,
|
||||
emailVerified: (dbUser as any).emailVerified ?? false,
|
||||
};
|
||||
|
||||
return NextResponse.json(user, { status: 200 });
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
import { sendVerificationEmail } from "@/features/email/sendVerificationEmail";
|
||||
|
||||
// /api/auth/request-email-verification
|
||||
// - pb_access JWT 쿠키로 인증된 사용자의 이메일 인증 토큰을 새로 발급한다.
|
||||
// - 개발 모드에서는 verificationToken 을 응답 바디에 포함해 테스트에서 사용할 수 있도록 한다.
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) {
|
||||
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) {
|
||||
return NextResponse.json({ message: "사용자를 찾을 수 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const verificationToken = randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerifyToken: verificationToken,
|
||||
emailVerifyTokenExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
// 이메일 인증 링크를 Gmail SMTP 를 통해 전송한다.
|
||||
await sendVerificationEmail({
|
||||
to: (user as any).email,
|
||||
token: verificationToken,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "이메일 인증 토큰이 생성되었습니다.",
|
||||
verificationToken,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ message: "이메일 인증 토큰 생성 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export async function POST(request: Request) {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
tokenVersion: user.tokenVersion,
|
||||
emailVerified: (user as any).emailVerified ?? false,
|
||||
});
|
||||
|
||||
const { passwordHash: _ph, ...safeUser } = user;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// /api/auth/verify-email
|
||||
// - 이메일 인증 토큰을 검증하고, 성공 시 emailVerified 를 true 로 설정한다.
|
||||
// - 토큰이 없거나 잘못되었거나 만료된 경우 400 을 반환한다.
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: "이메일 인증 토큰이 없습니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { emailVerifyToken: token } });
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ message: "유효하지 않은 이메일 인증 토큰입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const expiresAt = (user as any).emailVerifyTokenExpiresAt as Date | null;
|
||||
if (!expiresAt || expiresAt.getTime() < Date.now()) {
|
||||
return NextResponse.json({ message: "이메일 인증 토큰이 만료되었거나 유효하지 않습니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
emailVerifyToken: null,
|
||||
emailVerifyTokenExpiresAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const newToken = await signAccessToken({
|
||||
id: updated.id,
|
||||
email: (updated as any).email,
|
||||
tokenVersion: (updated as any).tokenVersion,
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
// 인증 완료 후 마이페이지로 리다이렉트하면서, pb_access 쿠키도 갱신한다.
|
||||
const redirectUrl = new URL("/mypage", request.url);
|
||||
const res = NextResponse.redirect(redirectUrl);
|
||||
res.cookies.set("pb_access", newToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
return res;
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{ message: "이메일 인증 처리 중 오류가 발생했습니다.", error: error?.message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// /api/reports/leads
|
||||
// - 로그인한 사용자의 FormSubmission(리드) 목록을 기간/프로젝트 필터에 맞게 조회해,
|
||||
// 최신순으로 정렬된 리드 리스트와 총 개수를 반환한다.
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
type RangeParam = "7d" | "30d" | "all";
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const range = (url.searchParams.get("range") as RangeParam | null) ?? "all";
|
||||
const projectIdFilter = url.searchParams.get("projectId");
|
||||
const fromParam = url.searchParams.get("from");
|
||||
const toParam = url.searchParams.get("to");
|
||||
|
||||
if (range !== "7d" && range !== "30d" && range !== "all") {
|
||||
return NextResponse.json({ message: "Invalid range parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) {
|
||||
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = payload.sub;
|
||||
|
||||
const [projects, submissions] = await Promise.all([
|
||||
prisma.project.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
},
|
||||
}),
|
||||
prisma.formSubmission.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
projectSlug: true,
|
||||
createdAt: true,
|
||||
payloadJson: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
let fromDate: Date | null = null;
|
||||
let toDate: Date | null = null;
|
||||
|
||||
if (fromParam || toParam) {
|
||||
if (fromParam) {
|
||||
const parsedFrom = new Date(`${fromParam}T00:00:00.000Z`);
|
||||
if (Number.isNaN(parsedFrom.getTime())) {
|
||||
return NextResponse.json({ message: "Invalid from parameter" }, { status: 400 });
|
||||
}
|
||||
fromDate = parsedFrom;
|
||||
}
|
||||
if (toParam) {
|
||||
const parsedTo = new Date(`${toParam}T23:59:59.999Z`);
|
||||
if (Number.isNaN(parsedTo.getTime())) {
|
||||
return NextResponse.json({ message: "Invalid to parameter" }, { status: 400 });
|
||||
}
|
||||
toDate = parsedTo;
|
||||
}
|
||||
} else {
|
||||
const now = new Date();
|
||||
if (range === "7d") {
|
||||
fromDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
} else if (range === "30d") {
|
||||
fromDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// 1단계: 기간/프로젝트 필터 적용
|
||||
const filtered = submissions.filter((s) => {
|
||||
const createdAt = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any);
|
||||
|
||||
if (fromDate && createdAt < fromDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toDate && createdAt > toDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (projectIdFilter && s.projectId !== projectIdFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 2단계: 최신순 정렬 (createdAt desc)
|
||||
filtered.sort((a, b) => {
|
||||
const aDate = a.createdAt instanceof Date ? a.createdAt : new Date(a.createdAt as any);
|
||||
const bDate = b.createdAt instanceof Date ? b.createdAt : new Date(b.createdAt as any);
|
||||
return bDate.getTime() - aDate.getTime();
|
||||
});
|
||||
|
||||
const projectById = new Map<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
slug: string;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const p of projects) {
|
||||
projectById.set(p.id, { title: p.title, slug: p.slug });
|
||||
}
|
||||
|
||||
const items = filtered.map((s) => {
|
||||
const createdAt = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any);
|
||||
const projectMeta = s.projectId ? projectById.get(s.projectId) : null;
|
||||
|
||||
return {
|
||||
id: s.id,
|
||||
projectId: s.projectId ?? null,
|
||||
projectSlug: s.projectSlug,
|
||||
projectTitle: projectMeta?.title ?? null,
|
||||
createdAt: createdAt.toISOString(),
|
||||
payload: (s as any).payloadJson ?? {},
|
||||
};
|
||||
});
|
||||
|
||||
const body = {
|
||||
total: items.length,
|
||||
items,
|
||||
};
|
||||
|
||||
return NextResponse.json(body, { status: 200 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "리드 리포트를 불러오는 중 오류가 발생했습니다.",
|
||||
error: error?.message,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// /api/reports/overview
|
||||
// - 로그인한 사용자의 프로젝트/폼 제출 데이터를 기간/프로젝트 필터에 맞게 집계해 리포트용 요약/일별/프로젝트별 통계를 반환한다.
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
type RangeParam = "7d" | "30d" | "all";
|
||||
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const range = (url.searchParams.get("range") as RangeParam | null) ?? "all";
|
||||
const projectIdFilter = url.searchParams.get("projectId");
|
||||
const fromParam = url.searchParams.get("from");
|
||||
const toParam = url.searchParams.get("to");
|
||||
|
||||
if (range !== "7d" && range !== "30d" && range !== "all") {
|
||||
return NextResponse.json({ message: "Invalid range parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) {
|
||||
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = payload.sub;
|
||||
|
||||
const [projects, submissions] = await Promise.all([
|
||||
prisma.project.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
status: true,
|
||||
},
|
||||
}),
|
||||
prisma.formSubmission.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
projectSlug: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
let fromDate: Date | null = null;
|
||||
let toDate: Date | null = null;
|
||||
|
||||
if (fromParam || toParam) {
|
||||
if (fromParam) {
|
||||
const parsedFrom = new Date(`${fromParam}T00:00:00.000Z`);
|
||||
if (Number.isNaN(parsedFrom.getTime())) {
|
||||
return NextResponse.json({ message: "Invalid from parameter" }, { status: 400 });
|
||||
}
|
||||
fromDate = parsedFrom;
|
||||
}
|
||||
if (toParam) {
|
||||
const parsedTo = new Date(`${toParam}T23:59:59.999Z`);
|
||||
if (Number.isNaN(parsedTo.getTime())) {
|
||||
return NextResponse.json({ message: "Invalid to parameter" }, { status: 400 });
|
||||
}
|
||||
toDate = parsedTo;
|
||||
}
|
||||
} else {
|
||||
const now = new Date();
|
||||
if (range === "7d") {
|
||||
fromDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
} else if (range === "30d") {
|
||||
fromDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// 1단계: 기간/프로젝트 필터가 적용된 제출 목록을 만든다.
|
||||
const filteredSubmissions = submissions.filter((s) => {
|
||||
const createdAt = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any);
|
||||
|
||||
if (fromDate && createdAt < fromDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toDate && createdAt > toDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (projectIdFilter && s.projectId !== projectIdFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalSubmissions = filteredSubmissions.length;
|
||||
|
||||
// uniqueProjects: 필터된 제출에서 등장하는 프로젝트 수
|
||||
const projectIdsSet = new Set<string>();
|
||||
for (const s of filteredSubmissions) {
|
||||
if (s.projectId) {
|
||||
projectIdsSet.add(s.projectId);
|
||||
}
|
||||
}
|
||||
const uniqueProjects = projectIdsSet.size;
|
||||
|
||||
// avgPerDay: 최소 1일 기준으로 일평균 제출 수를 계산한다.
|
||||
let avgPerDay = 0;
|
||||
if (totalSubmissions > 0) {
|
||||
// all 인 경우에는 제출이 존재하는 기간(min~max)을 기준으로 일수를 계산하고,
|
||||
// 7d/30d 인 경우에는 고정 윈도우 길이를 사용한다.
|
||||
let days = 1;
|
||||
if (range === "all") {
|
||||
let minDate = new Date(filteredSubmissions[0].createdAt as any);
|
||||
let maxDate = minDate;
|
||||
for (const s of filteredSubmissions) {
|
||||
const d = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any);
|
||||
if (d < minDate) minDate = d;
|
||||
if (d > maxDate) maxDate = d;
|
||||
}
|
||||
const diffMs = maxDate.getTime() - minDate.getTime();
|
||||
days = Math.max(1, Math.floor(diffMs / (24 * 60 * 60 * 1000)) + 1);
|
||||
} else if (range === "7d") {
|
||||
days = 7;
|
||||
} else if (range === "30d") {
|
||||
days = 30;
|
||||
}
|
||||
avgPerDay = totalSubmissions / days;
|
||||
}
|
||||
|
||||
// daily: 일별 제출 수 집계
|
||||
const countsByDate = new Map<string, number>();
|
||||
for (const s of filteredSubmissions) {
|
||||
const createdAt = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any);
|
||||
const dateKey = createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const prev = countsByDate.get(dateKey) ?? 0;
|
||||
countsByDate.set(dateKey, prev + 1);
|
||||
}
|
||||
|
||||
const daily = Array.from(countsByDate.entries())
|
||||
.map(([date, count]) => ({ date, count }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// byProject: 프로젝트별 제출 수/비율 집계 (제출이 1건 이상인 프로젝트만 포함)
|
||||
const statsByProjectId = new Map<
|
||||
string,
|
||||
{
|
||||
projectId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
submissions: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const project of projects) {
|
||||
if (projectIdFilter && project.id !== projectIdFilter) {
|
||||
continue;
|
||||
}
|
||||
statsByProjectId.set(project.id, {
|
||||
projectId: project.id,
|
||||
title: project.title,
|
||||
slug: project.slug,
|
||||
submissions: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of filteredSubmissions) {
|
||||
if (!s.projectId) continue;
|
||||
const stat = statsByProjectId.get(s.projectId);
|
||||
if (!stat) continue;
|
||||
stat.submissions += 1;
|
||||
}
|
||||
|
||||
const byProjectRaw = Array.from(statsByProjectId.values()).filter((item) => item.submissions > 0);
|
||||
|
||||
const byProject = byProjectRaw.map((item) => ({
|
||||
...item,
|
||||
ratio: totalSubmissions > 0 ? item.submissions / totalSubmissions : 0,
|
||||
}));
|
||||
|
||||
// timePatterns: 주/월/요일/시간대별 제출 수 집계
|
||||
const countsByWeek = new Map<string, number>();
|
||||
const countsByMonth = new Map<string, number>();
|
||||
const countsByWeekday = new Map<number, number>();
|
||||
const countsByHour = new Map<number, number>();
|
||||
|
||||
function getIsoWeekKey(date: Date): string {
|
||||
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
const day = d.getUTCDay() || 7; // 1~7 (Mon..Sun)
|
||||
d.setUTCDate(d.getUTCDate() + 4 - day); // nearest Thursday
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / (24 * 60 * 60 * 1000) + 1) / 7);
|
||||
const year = d.getUTCFullYear();
|
||||
return `${year}-W${String(week).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
for (const s of filteredSubmissions) {
|
||||
const createdAt = s.createdAt instanceof Date ? s.createdAt : new Date(s.createdAt as any);
|
||||
|
||||
const monthKey = `${createdAt.getUTCFullYear()}-${String(createdAt.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
const weekKey = getIsoWeekKey(createdAt);
|
||||
const weekday = createdAt.getUTCDay(); // 0~6
|
||||
const hour = createdAt.getUTCHours(); // 0~23
|
||||
|
||||
countsByMonth.set(monthKey, (countsByMonth.get(monthKey) ?? 0) + 1);
|
||||
countsByWeek.set(weekKey, (countsByWeek.get(weekKey) ?? 0) + 1);
|
||||
countsByWeekday.set(weekday, (countsByWeekday.get(weekday) ?? 0) + 1);
|
||||
countsByHour.set(hour, (countsByHour.get(hour) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const timePatterns = {
|
||||
byWeek: Array.from(countsByWeek.entries())
|
||||
.map(([week, count]) => ({ week, count }))
|
||||
.sort((a, b) => a.week.localeCompare(b.week)),
|
||||
byMonth: Array.from(countsByMonth.entries())
|
||||
.map(([month, count]) => ({ month, count }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month)),
|
||||
byWeekday: Array.from(countsByWeekday.entries())
|
||||
.map(([weekday, count]) => ({ weekday, count }))
|
||||
.sort((a, b) => a.weekday - b.weekday),
|
||||
byHour: Array.from(countsByHour.entries())
|
||||
.map(([hour, count]) => ({ hour, count }))
|
||||
.sort((a, b) => a.hour - b.hour),
|
||||
};
|
||||
|
||||
// projectStatusSummary: 상태별 프로젝트 수/제출 수 집계
|
||||
const projectStatusById = new Map<
|
||||
string,
|
||||
{
|
||||
status: string;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const project of projects) {
|
||||
if (projectIdFilter && project.id !== projectIdFilter) {
|
||||
continue;
|
||||
}
|
||||
projectStatusById.set(project.id, {
|
||||
status: (project as any).status ?? "UNKNOWN",
|
||||
});
|
||||
}
|
||||
|
||||
const statusStatsMap = new Map<
|
||||
string,
|
||||
{
|
||||
status: string;
|
||||
projectIds: Set<string>;
|
||||
submissions: number;
|
||||
}
|
||||
>();
|
||||
|
||||
function ensureStatusEntry(statusKey: string): {
|
||||
status: string;
|
||||
projectIds: Set<string>;
|
||||
submissions: number;
|
||||
} {
|
||||
let entry = statusStatsMap.get(statusKey);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
status: statusKey,
|
||||
projectIds: new Set<string>(),
|
||||
submissions: 0,
|
||||
};
|
||||
statusStatsMap.set(statusKey, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
// 프로젝트마다 상태 기반 프로젝트 수 초기화
|
||||
for (const [projectId, meta] of projectStatusById.entries()) {
|
||||
const entry = ensureStatusEntry(meta.status);
|
||||
entry.projectIds.add(projectId);
|
||||
}
|
||||
|
||||
// 제출 건수를 상태별로 합산
|
||||
for (const s of filteredSubmissions) {
|
||||
if (!s.projectId) continue;
|
||||
const meta = projectStatusById.get(s.projectId);
|
||||
const statusKey = meta?.status ?? "UNKNOWN";
|
||||
const entry = ensureStatusEntry(statusKey);
|
||||
entry.submissions += 1;
|
||||
}
|
||||
|
||||
const projectStatusSummary = {
|
||||
byStatus: Array.from(statusStatsMap.values())
|
||||
.map((entry) => ({
|
||||
status: entry.status,
|
||||
projects: entry.projectIds.size,
|
||||
submissions: entry.submissions,
|
||||
}))
|
||||
.sort((a, b) => a.status.localeCompare(b.status)),
|
||||
};
|
||||
|
||||
const body = {
|
||||
summary: {
|
||||
totalSubmissions,
|
||||
uniqueProjects,
|
||||
avgPerDay,
|
||||
},
|
||||
daily,
|
||||
byProject,
|
||||
timePatterns,
|
||||
projectStatusSummary,
|
||||
};
|
||||
|
||||
return NextResponse.json(body, { status: 200 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "리포트 데이터를 계산하는 중 오류가 발생했습니다.",
|
||||
error: error?.message,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
|
||||
import { BarChart2, FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
|
||||
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
||||
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function DashboardPage() {
|
||||
const [status, setStatus] = useState<PageStatus>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [emailVerified, setEmailVerified] = useState<boolean | null>(null);
|
||||
|
||||
// 마운트 시 대시보드 개요 데이터를 불러온다.
|
||||
useEffect(() => {
|
||||
@@ -103,6 +104,31 @@ export default function DashboardPage() {
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
// 마운트 시 현재 사용자 이메일 인증 여부를 확인한다.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadMe = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (cancelled || !res.ok) return;
|
||||
|
||||
const data = (await res.json().catch(() => null)) as any;
|
||||
if (!cancelled && data) {
|
||||
setEmailVerified(Boolean(data.emailVerified));
|
||||
}
|
||||
} catch {
|
||||
// ignore – 인증 여부를 알 수 없으면 배너만 표시하지 않는다.
|
||||
}
|
||||
};
|
||||
|
||||
void loadMe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const safeSummary: SummaryStats =
|
||||
summary ?? {
|
||||
totalProjects: 0,
|
||||
@@ -170,6 +196,13 @@ export default function DashboardPage() {
|
||||
<ListChecks className="w-4 h-4" aria-hidden="true" />
|
||||
<span>{t.navSubmissions}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/reports"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
|
||||
>
|
||||
<BarChart2 className="w-4 h-4" aria-hidden="true" />
|
||||
<span>{t.navReports ?? "Reports"}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
@@ -191,6 +224,16 @@ export default function DashboardPage() {
|
||||
</button>
|
||||
{isMenuOpen && (
|
||||
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
router.push("/mypage");
|
||||
}}
|
||||
>
|
||||
{t.myPageLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
@@ -212,6 +255,12 @@ export default function DashboardPage() {
|
||||
<p className="text-xs text-red-500 dark:text-red-300">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
{emailVerified === false && (
|
||||
<p className="text-xs text-amber-700 mb-1 dark:text-amber-300">
|
||||
{t.emailNotVerifiedBanner}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="text-xs text-slate-400">{t.loadingText}</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
||||
import { getMyPageMessages } from "@/features/i18n/messages/mypage";
|
||||
|
||||
// 마이페이지(/mypage) 화면 컴포넌트
|
||||
// - 현재 로그인 유저 정보를 /api/auth/me 에서 불러와 표시한다.
|
||||
// - 비로그인(401) 응답 시 /login 으로 리다이렉트한다.
|
||||
// - 비밀번호 변경 및 이메일 변경 폼을 통해 각각 /api/auth/change-password, /api/auth/change-email 를 호출한다.
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion?: number;
|
||||
emailVerified?: boolean;
|
||||
}
|
||||
|
||||
export default function MyPage() {
|
||||
const router = useRouter();
|
||||
const locale = useAppLocale();
|
||||
const m = getMyPageMessages(locale);
|
||||
|
||||
// 현재 로그인된 유저 정보와 로딩/에러 상태를 관리한다.
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 비밀번호/이메일 변경 폼 상태
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
|
||||
const [changePasswordMessage, setChangePasswordMessage] = useState<string | null>(null);
|
||||
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [emailChangePassword, setEmailChangePassword] = useState("");
|
||||
const [changeEmailMessage, setChangeEmailMessage] = useState<string | null>(null);
|
||||
|
||||
// 이메일 인증 메일 재발송 상태
|
||||
const [verificationMessage, setVerificationMessage] = useState<string | null>(null);
|
||||
const [verificationLoading, setVerificationLoading] = useState(false);
|
||||
|
||||
// 마운트 시 /api/auth/me 를 호출해 인증 상태와 유저 정보를 확인한다.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (cancelled) return;
|
||||
|
||||
if (res.status === 401) {
|
||||
// 비로그인 상태이므로 로그인 페이지로 보낸다.
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
setError(m.loadError);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as AuthUser;
|
||||
setUser(data);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError(m.loadError);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const handleSubmitChangePassword = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setChangePasswordMessage(null);
|
||||
|
||||
if (!currentPassword || !newPassword || !newPasswordConfirm) {
|
||||
setChangePasswordMessage(m.changePasswordErrorRequired);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
setChangePasswordMessage(m.changePasswordErrorMismatch);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null as any);
|
||||
|
||||
if (!res.ok) {
|
||||
setChangePasswordMessage(data?.message || m.changePasswordErrorNetwork);
|
||||
return;
|
||||
}
|
||||
|
||||
setChangePasswordMessage(data?.message || m.changePasswordSuccess);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setNewPasswordConfirm("");
|
||||
} catch {
|
||||
setChangePasswordMessage(m.changePasswordErrorNetwork);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestEmailVerification = async () => {
|
||||
// 이메일 인증 메일 전송 요청 상태를 초기화한다.
|
||||
setVerificationMessage(null);
|
||||
setVerificationLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/request-email-verification", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null as any);
|
||||
|
||||
if (!res.ok) {
|
||||
setVerificationMessage(data?.message || m.emailVerificationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setVerificationMessage(data?.message || m.emailVerificationSuccess);
|
||||
} catch {
|
||||
setVerificationMessage(m.emailVerificationError);
|
||||
} finally {
|
||||
setVerificationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitChangeEmail = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setChangeEmailMessage(null);
|
||||
|
||||
if (!newEmail || !emailChangePassword) {
|
||||
setChangeEmailMessage(m.changeEmailErrorRequired);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/change-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ newEmail, password: emailChangePassword }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null as any);
|
||||
|
||||
if (!res.ok) {
|
||||
setChangeEmailMessage(data?.message || m.changeEmailErrorNetwork);
|
||||
return;
|
||||
}
|
||||
|
||||
setChangeEmailMessage(data?.message || m.changeEmailSuccess);
|
||||
setNewEmail("");
|
||||
setEmailChangePassword("");
|
||||
|
||||
// 응답에 새로운 이메일이 있으면 프로필에도 반영한다.
|
||||
if (data?.email && user) {
|
||||
setUser({ ...user, email: data.email });
|
||||
}
|
||||
} catch {
|
||||
setChangeEmailMessage(m.changeEmailErrorNetwork);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
|
||||
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{m.title}</h1>
|
||||
<p className="text-sm text-slate-500 mt-1 dark:text-slate-400">
|
||||
{m.description}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center rounded-full border border-slate-300 bg-white px-3 py-1 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
{m.backToDashboardLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="flex-1 px-6 py-4 space-y-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 dark:text-red-300">{error}</p>
|
||||
)}
|
||||
|
||||
{loading && !user && !error && (
|
||||
<p className="text-xs text-slate-400">{m.loadingText}</p>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<div className="space-y-4 text-xs">
|
||||
<div className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900/70">
|
||||
<h2 className="text-sm font-semibold mb-2">{m.profileSectionTitle}</h2>
|
||||
<p className="text-slate-600 dark:text-slate-300">
|
||||
<span className="font-medium">{m.profileEmailLabel}</span> {user.email}
|
||||
</p>
|
||||
<p className="text-slate-600 dark:text-slate-300 mt-1">
|
||||
<span className="font-medium">{m.profileUserIdLabel}</span> {user.id}
|
||||
</p>
|
||||
{user.emailVerified && (
|
||||
<p className="mt-2 text-[11px] font-medium text-emerald-600 dark:text-emerald-300">
|
||||
{m.emailVerifiedBadge}
|
||||
</p>
|
||||
)}
|
||||
{!user.emailVerified && (
|
||||
<>
|
||||
<p className="text-slate-600 dark:text-slate-300 mt-2">
|
||||
{m.emailVerificationNotice}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 inline-flex items-center justify-center rounded border border-sky-600 bg-sky-50 px-3 py-1 text-[11px] font-medium text-sky-700 hover:bg-sky-100 disabled:opacity-50 disabled:cursor-not-allowed dark:border-sky-500 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900"
|
||||
onClick={() => {
|
||||
void handleRequestEmailVerification();
|
||||
}}
|
||||
disabled={verificationLoading}
|
||||
>
|
||||
{m.emailVerificationButtonLabel}
|
||||
</button>
|
||||
{verificationMessage && (
|
||||
<p className="mt-2 text-[11px] text-slate-600 dark:text-slate-300">
|
||||
{verificationMessage}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900/70">
|
||||
<h2 className="text-sm font-semibold mb-2">{m.changePasswordTitle}</h2>
|
||||
<form onSubmit={handleSubmitChangePassword} className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-slate-700 dark:text-slate-200">
|
||||
{m.changePasswordCurrentLabel}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-slate-700 dark:text-slate-200">
|
||||
{m.changePasswordNewLabel}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-slate-700 dark:text-slate-200">
|
||||
{m.changePasswordConfirmLabel}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPasswordConfirm}
|
||||
onChange={(e) => setNewPasswordConfirm(e.target.value)}
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-1 inline-flex items-center justify-center rounded bg-sky-600 hover:bg-sky-500 px-3 py-1 text-xs font-medium text-white"
|
||||
>
|
||||
{m.changePasswordSubmitLabel}
|
||||
</button>
|
||||
{changePasswordMessage && (
|
||||
<p className="mt-2 text-[11px] text-slate-600 dark:text-slate-300">
|
||||
{changePasswordMessage}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900/70">
|
||||
<h2 className="text-sm font-semibold mb-2">{m.changeEmailTitle}</h2>
|
||||
<form onSubmit={handleSubmitChangeEmail} className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-slate-700 dark:text-slate-200">
|
||||
{m.changeEmailNewLabel}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-slate-700 dark:text-slate-200">
|
||||
Current password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={emailChangePassword}
|
||||
onChange={(e) => setEmailChangePassword(e.target.value)}
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-1 inline-flex items-center justify-center rounded bg-sky-600 hover:bg-sky-500 px-3 py-1 text-xs font-medium text-white"
|
||||
>
|
||||
{m.changeEmailSubmitLabel}
|
||||
</button>
|
||||
{changeEmailMessage && (
|
||||
<p className="mt-2 text-[11px] text-slate-600 dark:text-slate-300">
|
||||
{changeEmailMessage}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
LayoutDashboard,
|
||||
FolderKanban,
|
||||
SunMoon,
|
||||
BarChart2,
|
||||
} from "lucide-react";
|
||||
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
||||
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
|
||||
@@ -39,6 +40,7 @@ export default function ProjectsPage() {
|
||||
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [emailVerified, setEmailVerified] = useState<boolean | null>(null);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -85,6 +87,31 @@ export default function ProjectsPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 이메일 인증 여부를 확인해 퍼블릭 페이지 링크 노출 여부를 결정한다.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadMe = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (cancelled || !res.ok) return;
|
||||
|
||||
const data = (await res.json().catch(() => null)) as any;
|
||||
if (!cancelled && data) {
|
||||
setEmailVerified(Boolean(data.emailVerified));
|
||||
}
|
||||
} catch {
|
||||
// ignore – 인증 여부를 알 수 없으면 기존 동작(링크 노출)을 유지한다.
|
||||
}
|
||||
};
|
||||
|
||||
void loadMe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(projects.length / pageSize));
|
||||
setCurrentPage((prev) => Math.min(prev, totalPages));
|
||||
@@ -241,6 +268,13 @@ export default function ProjectsPage() {
|
||||
<ListChecks className="w-4 h-4" aria-hidden="true" />
|
||||
<span>{t.navSubmissions}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/reports"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
|
||||
>
|
||||
<BarChart2 className="w-4 h-4" aria-hidden="true" />
|
||||
<span>{t.navReports ?? "Reports"}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
@@ -262,6 +296,16 @@ export default function ProjectsPage() {
|
||||
</button>
|
||||
{isMenuOpen && (
|
||||
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
router.push("/mypage");
|
||||
}}
|
||||
>
|
||||
{t.myPageLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
@@ -403,6 +447,15 @@ export default function ProjectsPage() {
|
||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||
<span>{p.actionPreview}</span>
|
||||
</Link>
|
||||
{emailVerified !== false && (
|
||||
<Link
|
||||
href={`/p/${encodeURIComponent(project.slug)}`}
|
||||
className="inline-flex items-center gap-1 text-sky-700 hover:text-sky-900 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
|
||||
>
|
||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||
<span>{t.projectOpenPublicPageLink}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
|
||||
className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
|
||||
import { BarChart2, FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
|
||||
import { useAppLocale } from "@/features/i18n/LocaleProvider";
|
||||
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
|
||||
import { getSubmissionsMessages } from "@/features/i18n/messages/submissions";
|
||||
@@ -139,6 +139,13 @@ export default function AllProjectSubmissionsPage() {
|
||||
<ListChecks className="w-4 h-4" aria-hidden="true" />
|
||||
<span>{t.navSubmissions}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/reports"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
|
||||
>
|
||||
<BarChart2 className="w-4 h-4" aria-hidden="true" />
|
||||
<span>{t.navReports ?? "Reports"}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,12 +11,14 @@ export interface AccessTokenPayload extends JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
emailVerified?: boolean;
|
||||
}
|
||||
|
||||
export interface JwtUserLike {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
emailVerified?: boolean;
|
||||
}
|
||||
|
||||
// 내부에서 사용할 JWT 시크릿을 가져온다.
|
||||
@@ -72,9 +74,10 @@ export async function signAccessToken(user: JwtUserLike): Promise<string> {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
tokenVersion: user.tokenVersion,
|
||||
emailVerified: user.emailVerified ?? false,
|
||||
};
|
||||
|
||||
// 만료 시간은 기본 7일으로 설정한다.
|
||||
// 만료 시간은 기본 7일로 설정한다.
|
||||
const token = jwt.sign(payload, secret, {
|
||||
algorithm: "HS256",
|
||||
expiresIn: "7d",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
export type SendVerificationEmailParams = {
|
||||
to: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export async function sendVerificationEmail(params: SendVerificationEmailParams): Promise<void> {
|
||||
const host = process.env.EMAIL_SERVER_HOST;
|
||||
const portRaw = process.env.EMAIL_SERVER_PORT;
|
||||
const user = process.env.EMAIL_SERVER_USER;
|
||||
const pass = process.env.EMAIL_SERVER_PASSWORD;
|
||||
const from = process.env.EMAIL_FROM ?? user;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
throw new Error("Email server configuration is missing.");
|
||||
}
|
||||
|
||||
const port = portRaw ? Number(portRaw) : 587;
|
||||
|
||||
const baseUrl =
|
||||
process.env.NEXTAUTH_URL ?? process.env.APP_BASE_URL ?? "http://localhost:3000";
|
||||
const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(
|
||||
params.token,
|
||||
)}`;
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: {
|
||||
user,
|
||||
pass,
|
||||
},
|
||||
});
|
||||
|
||||
const subject = "이메일 인증을 완료해 주세요";
|
||||
const text = `아래 링크를 클릭해서 이메일 인증을 완료해 주세요.\n\n${verifyUrl}\n\n이 링크는 1시간 후 만료됩니다.`;
|
||||
const html = `<p>아래 링크를 클릭해서 이메일 인증을 완료해 주세요.</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>이 링크는 1시간 후 만료됩니다.</p>`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: from ?? undefined,
|
||||
to: params.to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
@@ -12,10 +12,13 @@ export type DashboardMessages = {
|
||||
navDashboard: string;
|
||||
navProjects: string;
|
||||
navSubmissions: string;
|
||||
navReports?: string;
|
||||
// 헤더 오른쪽 액션 버튼/메뉴
|
||||
themeToggleLabel: string;
|
||||
menuLabel: string;
|
||||
logoutLabel: string;
|
||||
myPageLabel: string;
|
||||
emailNotVerifiedBanner: string;
|
||||
summaryTotalProjectsLabel: string;
|
||||
summaryTotalSubmissionsLabel: string;
|
||||
summaryTodaySubmissionsLabel: string;
|
||||
@@ -43,9 +46,12 @@ const DASHBOARD_MESSAGES: Record<AppLocale, DashboardMessages> = {
|
||||
navDashboard: "Dashboard",
|
||||
navProjects: "Projects",
|
||||
navSubmissions: "All submissions",
|
||||
navReports: "Reports",
|
||||
themeToggleLabel: "Toggle theme",
|
||||
menuLabel: "Menu",
|
||||
logoutLabel: "Log out",
|
||||
myPageLabel: "My page",
|
||||
emailNotVerifiedBanner: "Your email is not verified yet.",
|
||||
summaryTotalProjectsLabel: "Projects",
|
||||
summaryTotalSubmissionsLabel: "Total form submissions",
|
||||
summaryTodaySubmissionsLabel: "Submissions today",
|
||||
@@ -71,9 +77,12 @@ const DASHBOARD_MESSAGES: Record<AppLocale, DashboardMessages> = {
|
||||
navDashboard: "대시보드",
|
||||
navProjects: "프로젝트 목록",
|
||||
navSubmissions: "전체 제출 내역",
|
||||
navReports: "리포트",
|
||||
themeToggleLabel: "테마 전환",
|
||||
menuLabel: "메뉴",
|
||||
logoutLabel: "로그아웃",
|
||||
myPageLabel: "마이페이지",
|
||||
emailNotVerifiedBanner: "이메일 인증이 아직 완료되지 않았습니다.",
|
||||
summaryTotalProjectsLabel: "프로젝트 수",
|
||||
summaryTotalSubmissionsLabel: "전체 폼 제출 수",
|
||||
summaryTodaySubmissionsLabel: "오늘 제출 수",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { AppLocale } from "../locale";
|
||||
import { DEFAULT_LOCALE } from "../locale";
|
||||
|
||||
// 마이페이지(/mypage) 전용 i18n 메시지
|
||||
|
||||
export type MyPageMessages = {
|
||||
title: string;
|
||||
description: string;
|
||||
loadError: string;
|
||||
loadingText: string;
|
||||
profileSectionTitle: string;
|
||||
profileEmailLabel: string;
|
||||
profileUserIdLabel: string;
|
||||
emailVerifiedBadge: string;
|
||||
backToDashboardLabel: string;
|
||||
emailVerificationNotice: string;
|
||||
emailVerificationButtonLabel: string;
|
||||
emailVerificationSuccess: string;
|
||||
emailVerificationError: string;
|
||||
changePasswordTitle: string;
|
||||
changePasswordCurrentLabel: string;
|
||||
changePasswordNewLabel: string;
|
||||
changePasswordConfirmLabel: string;
|
||||
changePasswordSubmitLabel: string;
|
||||
changePasswordErrorRequired: string;
|
||||
changePasswordErrorMismatch: string;
|
||||
changePasswordErrorNetwork: string;
|
||||
changePasswordSuccess: string;
|
||||
changeEmailTitle: string;
|
||||
changeEmailNewLabel: string;
|
||||
changeEmailCurrentPasswordLabel: string;
|
||||
changeEmailSubmitLabel: string;
|
||||
changeEmailErrorRequired: string;
|
||||
changeEmailErrorNetwork: string;
|
||||
changeEmailSuccess: string;
|
||||
};
|
||||
|
||||
const MYPAGE_MESSAGES: Record<AppLocale, MyPageMessages> = {
|
||||
en: {
|
||||
title: "My page",
|
||||
description: "Manage your account information, password, and email.",
|
||||
loadError: "An error occurred while loading your account information.",
|
||||
loadingText: "Loading account information...",
|
||||
profileSectionTitle: "Profile",
|
||||
profileEmailLabel: "Email:",
|
||||
profileUserIdLabel: "User ID:",
|
||||
emailVerifiedBadge: "Your email has been verified.",
|
||||
backToDashboardLabel: "Go to dashboard",
|
||||
emailVerificationNotice:
|
||||
"If your email is not verified yet, you can send a verification email from here.",
|
||||
emailVerificationButtonLabel: "Send verification email",
|
||||
emailVerificationSuccess: "Verification email has been sent.",
|
||||
emailVerificationError:
|
||||
"Failed to send verification email. Please try again.",
|
||||
changePasswordTitle: "Change password",
|
||||
changePasswordCurrentLabel: "Current password",
|
||||
changePasswordNewLabel: "New password",
|
||||
changePasswordConfirmLabel: "Confirm new password",
|
||||
changePasswordSubmitLabel: "Update password",
|
||||
changePasswordErrorRequired: "Please fill in all password fields.",
|
||||
changePasswordErrorMismatch: "New password and confirmation do not match.",
|
||||
changePasswordErrorNetwork:
|
||||
"A network error occurred while changing your password. Please try again.",
|
||||
changePasswordSuccess: "Your password has been changed.",
|
||||
changeEmailTitle: "Change email",
|
||||
changeEmailNewLabel: "New email",
|
||||
changeEmailCurrentPasswordLabel: "Current password",
|
||||
changeEmailSubmitLabel: "Update email",
|
||||
changeEmailErrorRequired: "Please enter your new email and current password.",
|
||||
changeEmailErrorNetwork:
|
||||
"A network error occurred while changing your email. Please try again.",
|
||||
changeEmailSuccess: "Your email has been changed.",
|
||||
},
|
||||
ko: {
|
||||
title: "마이페이지",
|
||||
description: "내 계정 정보, 비밀번호, 이메일을 관리할 수 있는 페이지입니다.",
|
||||
loadError: "계정 정보를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
|
||||
loadingText: "계정 정보를 불러오는 중입니다...",
|
||||
profileSectionTitle: "프로필",
|
||||
profileEmailLabel: "이메일:",
|
||||
profileUserIdLabel: "사용자 ID:",
|
||||
emailVerifiedBadge: "이메일 인증이 완료되었습니다.",
|
||||
backToDashboardLabel: "대시보드로 이동",
|
||||
emailVerificationNotice:
|
||||
"이메일 인증이 아직 완료되지 않았다면, 여기에서 인증 메일을 보낼 수 있습니다.",
|
||||
emailVerificationButtonLabel: "인증 메일 보내기",
|
||||
emailVerificationSuccess: "인증 메일을 보냈습니다.",
|
||||
emailVerificationError:
|
||||
"인증 메일 전송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
|
||||
changePasswordTitle: "비밀번호 변경",
|
||||
changePasswordCurrentLabel: "현재 비밀번호",
|
||||
changePasswordNewLabel: "새 비밀번호",
|
||||
changePasswordConfirmLabel: "새 비밀번호 확인",
|
||||
changePasswordSubmitLabel: "비밀번호 업데이트",
|
||||
changePasswordErrorRequired: "모든 비밀번호 입력란을 채워주세요.",
|
||||
changePasswordErrorMismatch: "새 비밀번호와 확인 비밀번호가 일치하지 않습니다.",
|
||||
changePasswordErrorNetwork:
|
||||
"비밀번호 변경 중 네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
|
||||
changePasswordSuccess: "비밀번호가 변경되었습니다.",
|
||||
changeEmailTitle: "이메일 변경",
|
||||
changeEmailNewLabel: "새 이메일",
|
||||
changeEmailCurrentPasswordLabel: "현재 비밀번호",
|
||||
changeEmailSubmitLabel: "이메일 업데이트",
|
||||
changeEmailErrorRequired: "새 이메일과 현재 비밀번호를 모두 입력해주세요.",
|
||||
changeEmailErrorNetwork:
|
||||
"이메일 변경 중 네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
|
||||
changeEmailSuccess: "이메일이 변경되었습니다.",
|
||||
},
|
||||
};
|
||||
|
||||
export function getMyPageMessages(locale: AppLocale | null | undefined): MyPageMessages {
|
||||
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
|
||||
return MYPAGE_MESSAGES[key] ?? MYPAGE_MESSAGES[DEFAULT_LOCALE];
|
||||
}
|
||||
+195
-4
@@ -9,11 +9,25 @@ const BASE_URL = "http://localhost";
|
||||
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 Auth API 테스트를 실행할 수 있다.
|
||||
const inMemoryUsers: any[] = [];
|
||||
|
||||
// 이메일 인증 토큰 발송 유틸을 목 처리해, 실제 SMTP 로 메일을 보내지 않고 호출 여부/인자를 검증한다.
|
||||
const sendVerificationEmailMock = vi.fn();
|
||||
|
||||
vi.mock("@prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
user = {
|
||||
findUnique: async ({ where: { email } }: any) => {
|
||||
return inMemoryUsers.find((u) => u.email === email) ?? null;
|
||||
// where.email 또는 where.id 기준으로 단일 유저를 찾는다.
|
||||
findUnique: async ({ where }: any) => {
|
||||
if (!where) return null;
|
||||
if (where.email) {
|
||||
return inMemoryUsers.find((u) => u.email === where.email) ?? null;
|
||||
}
|
||||
if (where.id) {
|
||||
return inMemoryUsers.find((u) => u.id === where.id) ?? null;
|
||||
}
|
||||
if (where.emailVerifyToken) {
|
||||
return inMemoryUsers.find((u) => u.emailVerifyToken === where.emailVerifyToken) ?? null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
create: async ({ data }: any) => {
|
||||
const now = new Date();
|
||||
@@ -26,17 +40,34 @@ vi.mock("@prisma/client", () => {
|
||||
inMemoryUsers.push(user);
|
||||
return user;
|
||||
},
|
||||
// change-password / change-email 에서 사용할 update 목
|
||||
update: async ({ where, data }: any) => {
|
||||
const target = inMemoryUsers.find((u) => u.id === where.id);
|
||||
if (!target) {
|
||||
throw new Error("User not found in inMemoryUsers");
|
||||
}
|
||||
Object.assign(target, data, { updatedAt: new Date() });
|
||||
return target;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient: PrismaClientMock };
|
||||
});
|
||||
|
||||
vi.mock("@/features/email/sendVerificationEmail", () => {
|
||||
return {
|
||||
sendVerificationEmail: (...args: any[]) => sendVerificationEmailMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// 각 테스트 전에 메모리 유저 저장소를 초기화한다.
|
||||
inMemoryUsers.length = 0;
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-api";
|
||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
sendVerificationEmailMock.mockReset();
|
||||
});
|
||||
|
||||
describe("/api/auth", () => {
|
||||
@@ -232,8 +263,22 @@ describe("/api/auth", () => {
|
||||
it("유효한 JWT 쿠키가 있을 때 현재 로그인 유저 정보를 반환해야 한다", async () => {
|
||||
const { GET: me } = await import("@/app/api/auth/me/route");
|
||||
|
||||
const user = { id: "user-1", email: "me@example.com", tokenVersion: 1 };
|
||||
const token = await signAccessToken(user);
|
||||
const user = { id: "user-1", email: "me@example.com", tokenVersion: 1, emailVerified: true };
|
||||
|
||||
// /api/auth/me 가 DB(User) 기준으로 동작하므로, Prisma 목(inMemoryUsers)에 동일한 유저를 추가한다.
|
||||
inMemoryUsers.push({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
passwordHash: "hashed",
|
||||
tokenVersion: user.tokenVersion,
|
||||
emailVerified: user.emailVerified,
|
||||
emailVerifyToken: null,
|
||||
emailVerifyTokenExpiresAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const token = await signAccessToken(user as any);
|
||||
|
||||
const res = await me(
|
||||
new Request(`${BASE_URL}/api/auth/me`, {
|
||||
@@ -247,6 +292,7 @@ describe("/api/auth", () => {
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.id).toBe(user.id);
|
||||
expect(json.email).toBe(user.email);
|
||||
expect(json.emailVerified).toBe(true);
|
||||
expect(json.passwordHash).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -264,4 +310,149 @@ describe("/api/auth", () => {
|
||||
expect(res2.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/request-email-verification", () => {
|
||||
it("로그인된 사용자가 호출하면 emailVerifyToken 과 만료 시간이 설정되어야 한다", async () => {
|
||||
const { POST: requestEmailVerification } = await import(
|
||||
"@/app/api/auth/request-email-verification/route",
|
||||
);
|
||||
|
||||
const email = "verify@example.com";
|
||||
inMemoryUsers.push({
|
||||
id: "user-20",
|
||||
email,
|
||||
passwordHash: "hashed",
|
||||
tokenVersion: 1,
|
||||
emailVerified: false,
|
||||
emailVerifyToken: null,
|
||||
emailVerifyTokenExpiresAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const token = await signAccessToken({ id: "user-20", email, tokenVersion: 1 });
|
||||
|
||||
const res = await requestEmailVerification(
|
||||
new Request(`${BASE_URL}/api/auth/request-email-verification`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: `pb_access=${token}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.message).toBeDefined();
|
||||
expect(typeof json.verificationToken).toBe("string");
|
||||
expect(json.verificationToken.length).toBeGreaterThan(0);
|
||||
|
||||
// in-memory 유저에 토큰과 만료 시간이 설정되었는지 확인한다.
|
||||
const user = inMemoryUsers[0];
|
||||
expect(typeof user.emailVerifyToken).toBe("string");
|
||||
expect(user.emailVerifyToken.length).toBeGreaterThan(0);
|
||||
expect(user.emailVerifyTokenExpiresAt instanceof Date).toBe(true);
|
||||
|
||||
// 이메일 발송 유틸이 올바른 인자로 한 번 호출되었는지 확인한다.
|
||||
expect(sendVerificationEmailMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendVerificationEmailMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: email,
|
||||
token: json.verificationToken,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("인증 정보가 없으면 401 을 반환해야 한다", async () => {
|
||||
const { POST: requestEmailVerification } = await import(
|
||||
"@/app/api/auth/request-email-verification/route",
|
||||
);
|
||||
|
||||
const res = await requestEmailVerification(
|
||||
new Request(`${BASE_URL}/api/auth/request-email-verification`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/verify-email", () => {
|
||||
it("유효한 토큰으로 호출하면 emailVerified 가 true 로 설정되고 토큰 필드가 제거되어야 한다", async () => {
|
||||
const { GET: verifyEmail } = await import("@/app/api/auth/verify-email/route");
|
||||
|
||||
const token = "verify-token-123";
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000);
|
||||
inMemoryUsers.push({
|
||||
id: "user-21",
|
||||
email: "verify2@example.com",
|
||||
passwordHash: "hashed",
|
||||
tokenVersion: 1,
|
||||
emailVerified: false,
|
||||
emailVerifyToken: token,
|
||||
emailVerifyTokenExpiresAt: future,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await verifyEmail(
|
||||
new Request(`${BASE_URL}/api/auth/verify-email?token=${encodeURIComponent(token)}`),
|
||||
);
|
||||
|
||||
// 리다이렉트 응답이어야 한다.
|
||||
expect(res.status).toBeGreaterThanOrEqual(300);
|
||||
expect(res.status).toBeLessThan(400);
|
||||
const location = res.headers.get("location");
|
||||
expect(location).toContain("/mypage");
|
||||
|
||||
// in-memory 유저 상태가 업데이트되었는지 확인한다.
|
||||
const user = inMemoryUsers[0];
|
||||
expect(user.emailVerified).toBe(true);
|
||||
expect(user.emailVerifyToken).toBeNull();
|
||||
expect(user.emailVerifyTokenExpiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it("토큰이 없거나 잘못된 경우 400 을 반환해야 한다", async () => {
|
||||
const { GET: verifyEmail } = await import("@/app/api/auth/verify-email/route");
|
||||
|
||||
// 쿼리 파라미터 없이 호출
|
||||
const res1 = await verifyEmail(
|
||||
new Request(`${BASE_URL}/api/auth/verify-email`),
|
||||
);
|
||||
expect(res1.status).toBe(400);
|
||||
|
||||
// 잘못된 토큰으로 호출
|
||||
const res2 = await verifyEmail(
|
||||
new Request(`${BASE_URL}/api/auth/verify-email?token=unknown-token`),
|
||||
);
|
||||
expect(res2.status).toBe(400);
|
||||
});
|
||||
|
||||
it("만료된 토큰으로 호출하면 400 을 반환해야 한다", async () => {
|
||||
const { GET: verifyEmail } = await import("@/app/api/auth/verify-email/route");
|
||||
|
||||
const token = "expired-token-123";
|
||||
const past = new Date(Date.now() - 60 * 60 * 1000);
|
||||
inMemoryUsers.push({
|
||||
id: "user-22",
|
||||
email: "expired@example.com",
|
||||
passwordHash: "hashed",
|
||||
tokenVersion: 1,
|
||||
emailVerified: false,
|
||||
emailVerifyToken: token,
|
||||
emailVerifyTokenExpiresAt: past,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await verifyEmail(
|
||||
new Request(`${BASE_URL}/api/auth/verify-email?token=${encodeURIComponent(token)}`),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// in-memory Prisma 목 데이터
|
||||
const inMemoryProjects: any[] = [];
|
||||
const inMemorySubmissions: any[] = [];
|
||||
|
||||
vi.mock("@prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
project = {
|
||||
findMany: async ({ where }: any) => {
|
||||
if (!where || !where.userId) return [];
|
||||
return inMemoryProjects.filter((p) => p.userId === where.userId);
|
||||
},
|
||||
};
|
||||
|
||||
formSubmission = {
|
||||
findMany: async ({ where }: any) => {
|
||||
if (!where || !where.userId) return [];
|
||||
return inMemorySubmissions.filter((s) => s.userId === where.userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient: PrismaClientMock };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
inMemoryProjects.length = 0;
|
||||
inMemorySubmissions.length = 0;
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-reports-leads";
|
||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-10T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("/api/reports/leads", () => {
|
||||
it("range=all 에서 필터된 모든 리드를 최신순으로 반환해야 한다", async () => {
|
||||
const { GET: leads } = await import("@/app/api/reports/leads/route");
|
||||
|
||||
const userId = "user-reports-leads-all";
|
||||
|
||||
inMemoryProjects.push(
|
||||
{ id: "p1", userId, title: "Project A", slug: "project-a" },
|
||||
{ id: "p2", userId, title: "Project B", slug: "project-b" },
|
||||
);
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"),
|
||||
payloadJson: { name: "Alice" },
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p2",
|
||||
projectSlug: "project-b",
|
||||
createdAt: new Date("2025-01-08T09:00:00.000Z"),
|
||||
payloadJson: { email: "bob@example.com" },
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-10T00:00:00.000Z"),
|
||||
payloadJson: { phone: "010-0000-0000" },
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-leads@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await leads(
|
||||
new Request(`${BASE_URL}/api/reports/leads?range=all`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.total).toBe(3);
|
||||
expect(Array.isArray(json.items)).toBe(true);
|
||||
expect(json.items.length).toBe(3);
|
||||
|
||||
// 최신순 정렬: createdAt 이 가장 최근인 s3 가 첫 번째여야 한다.
|
||||
expect(json.items[0].id).toBe("s3");
|
||||
expect(json.items[1].id).toBe("s1");
|
||||
expect(json.items[2].id).toBe("s2");
|
||||
|
||||
const first = json.items[0];
|
||||
expect(first.projectSlug).toBe("project-a");
|
||||
expect(first.projectTitle).toBe("Project A");
|
||||
expect(first.payload).toEqual({ phone: "010-0000-0000" });
|
||||
});
|
||||
|
||||
it("range=7d 와 projectId 가 적용되면 해당 조건에 맞는 리드만 반환해야 한다", async () => {
|
||||
const { GET: leads } = await import("@/app/api/reports/leads/route");
|
||||
|
||||
const userId = "user-reports-leads-filter";
|
||||
|
||||
inMemoryProjects.push({ id: "p1", userId, title: "Project A", slug: "project-a" });
|
||||
|
||||
// 기준 시간: 2025-01-10
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"), // within 7d
|
||||
payloadJson: { name: "Alice" },
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-01T12:00:00.000Z"), // older than 7d
|
||||
payloadJson: { name: "Old" },
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-leads-filter@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await leads(
|
||||
new Request(`${BASE_URL}/api/reports/leads?range=7d&projectId=p1`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.total).toBe(1);
|
||||
expect(json.items.length).toBe(1);
|
||||
expect(json.items[0].id).toBe("s1");
|
||||
});
|
||||
|
||||
it("from/to 쿼리로 커스텀 기간의 리드만 반환해야 한다", async () => {
|
||||
const { GET: leads } = await import("@/app/api/reports/leads/route");
|
||||
|
||||
const userId = "user-reports-leads-from-to";
|
||||
|
||||
inMemoryProjects.push({ id: "p1", userId, title: "Project A", slug: "project-a" });
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-01T12:00:00.000Z"),
|
||||
payloadJson: { label: "old" },
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-05T12:00:00.000Z"),
|
||||
payloadJson: { label: "mid" },
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"),
|
||||
payloadJson: { label: "new" },
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-leads-from-to@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await leads(
|
||||
new Request(`${BASE_URL}/api/reports/leads?from=2025-01-02&to=2025-01-08`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.total).toBe(1);
|
||||
expect(json.items.length).toBe(1);
|
||||
expect(json.items[0].id).toBe("s2");
|
||||
});
|
||||
|
||||
it("JWT 가 없으면 401 을 반환해야 한다", async () => {
|
||||
const { GET: leads } = await import("@/app/api/reports/leads/route");
|
||||
|
||||
const res = await leads(new Request(`${BASE_URL}/api/reports/leads?range=all`));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// in-memory Prisma 목 데이터
|
||||
const inMemoryProjects: any[] = [];
|
||||
const inMemorySubmissions: any[] = [];
|
||||
|
||||
vi.mock("@prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
project = {
|
||||
findMany: async ({ where }: any) => {
|
||||
if (!where || !where.userId) return [];
|
||||
return inMemoryProjects.filter((p) => p.userId === where.userId);
|
||||
},
|
||||
};
|
||||
|
||||
formSubmission = {
|
||||
findMany: async ({ where }: any) => {
|
||||
if (!where || !where.userId) return [];
|
||||
return inMemorySubmissions.filter((s) => s.userId === where.userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient: PrismaClientMock };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
inMemoryProjects.length = 0;
|
||||
inMemorySubmissions.length = 0;
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-reports";
|
||||
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-10T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("/api/reports/overview", () => {
|
||||
it("range=all 에서 전체 프로젝트/제출 기준으로 summary/daily/byProject 를 집계해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const userId = "user-reports";
|
||||
|
||||
inMemoryProjects.push(
|
||||
{
|
||||
id: "p1",
|
||||
userId,
|
||||
title: "Project A",
|
||||
slug: "project-a",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
userId,
|
||||
title: "Project B",
|
||||
slug: "project-b",
|
||||
},
|
||||
);
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-05T09:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
userId,
|
||||
projectId: "p2",
|
||||
projectSlug: "project-b",
|
||||
createdAt: new Date("2024-12-20T08:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await reports(
|
||||
new Request(`${BASE_URL}/api/reports/overview?range=all`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.summary.totalSubmissions).toBe(3);
|
||||
expect(json.summary.uniqueProjects).toBe(2);
|
||||
expect(json.summary.avgPerDay).toBeGreaterThan(0);
|
||||
|
||||
expect(Array.isArray(json.daily)).toBe(true);
|
||||
expect(json.daily.length).toBe(3);
|
||||
|
||||
expect(Array.isArray(json.byProject)).toBe(true);
|
||||
expect(json.byProject).toHaveLength(2);
|
||||
|
||||
const byId: Record<string, any> = {};
|
||||
for (const row of json.byProject) {
|
||||
byId[row.projectId] = row;
|
||||
}
|
||||
|
||||
expect(byId["p1"].submissions).toBe(2);
|
||||
expect(byId["p2"].submissions).toBe(1);
|
||||
expect(byId["p1"].ratio + byId["p2"].ratio).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("range=7d 에서는 최근 7일간의 제출만 포함해 summary/daily/byProject 를 집계해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const userId = "user-reports-7d";
|
||||
|
||||
inMemoryProjects.push({ id: "p1", userId, title: "Project A", slug: "project-a" });
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"), // within 7d
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-01T12:00:00.000Z"), // older than 7d
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports7d@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await reports(
|
||||
new Request(`${BASE_URL}/api/reports/overview?range=7d`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.summary.totalSubmissions).toBe(1);
|
||||
expect(json.summary.uniqueProjects).toBe(1);
|
||||
expect(json.daily).toHaveLength(1);
|
||||
expect(json.byProject).toHaveLength(1);
|
||||
expect(json.byProject[0].submissions).toBe(1);
|
||||
});
|
||||
|
||||
it("projectId 를 지정하면 해당 프로젝트의 데이터만 포함해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const userId = "user-reports-project";
|
||||
|
||||
inMemoryProjects.push(
|
||||
{ id: "p1", userId, title: "Project A", slug: "project-a" },
|
||||
{ id: "p2", userId, title: "Project B", slug: "project-b" },
|
||||
);
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p2",
|
||||
projectSlug: "project-b",
|
||||
createdAt: new Date("2025-01-09T15:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-project@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await reports(
|
||||
new Request(`${BASE_URL}/api/reports/overview?range=all&projectId=p1`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.summary.totalSubmissions).toBe(1);
|
||||
expect(json.summary.uniqueProjects).toBe(1);
|
||||
expect(json.byProject).toHaveLength(1);
|
||||
expect(json.byProject[0].projectId).toBe("p1");
|
||||
});
|
||||
|
||||
it("range=all 에서 timePatterns 과 projectStatusSummary 를 함께 집계해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const userId = "user-reports-v2";
|
||||
|
||||
inMemoryProjects.push(
|
||||
{
|
||||
id: "p1",
|
||||
userId,
|
||||
title: "Published Project",
|
||||
slug: "published-project",
|
||||
status: "PUBLISHED",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
userId,
|
||||
title: "Draft Project",
|
||||
slug: "draft-project",
|
||||
status: "DRAFT",
|
||||
},
|
||||
);
|
||||
|
||||
// 3개의 제출: p1 에 2개, p2 에 1개, 서로 다른 시간대
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "published-project",
|
||||
createdAt: new Date("2025-01-09T01:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "published-project",
|
||||
createdAt: new Date("2025-01-09T15:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
userId,
|
||||
projectId: "p2",
|
||||
projectSlug: "draft-project",
|
||||
createdAt: new Date("2025-01-08T09:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-v2@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await reports(
|
||||
new Request(`${BASE_URL}/api/reports/overview?range=all`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.summary.totalSubmissions).toBe(3);
|
||||
|
||||
// timePatterns
|
||||
expect(json.timePatterns).toBeDefined();
|
||||
expect(Array.isArray(json.timePatterns.byHour)).toBe(true);
|
||||
const sumByHour = (json.timePatterns.byHour as Array<{ hour: number; count: number }>).reduce(
|
||||
(sum, item) => sum + item.count,
|
||||
0,
|
||||
);
|
||||
expect(sumByHour).toBe(3);
|
||||
|
||||
// projectStatusSummary
|
||||
expect(json.projectStatusSummary).toBeDefined();
|
||||
expect(Array.isArray(json.projectStatusSummary.byStatus)).toBe(true);
|
||||
const byStatus: Record<string, { projects: number; submissions: number }> = {};
|
||||
for (const row of json.projectStatusSummary.byStatus as Array<{
|
||||
status: string;
|
||||
projects: number;
|
||||
submissions: number;
|
||||
}>) {
|
||||
byStatus[row.status] = { projects: row.projects, submissions: row.submissions };
|
||||
}
|
||||
|
||||
expect(byStatus["PUBLISHED"].projects).toBe(1);
|
||||
expect(byStatus["PUBLISHED"].submissions).toBe(2);
|
||||
expect(byStatus["DRAFT"].projects).toBe(1);
|
||||
expect(byStatus["DRAFT"].submissions).toBe(1);
|
||||
});
|
||||
|
||||
it("from/to 쿼리로 커스텀 기간을 필터링해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const userId = "user-reports-from-to";
|
||||
|
||||
inMemoryProjects.push({ id: "p1", userId, title: "Project A", slug: "project-a" });
|
||||
|
||||
inMemorySubmissions.push(
|
||||
{
|
||||
id: "s1",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-01T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-05T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
userId,
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
createdAt: new Date("2025-01-09T12:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
const token = await signAccessToken({
|
||||
id: userId,
|
||||
email: "reports-from-to@example.com",
|
||||
tokenVersion: 1,
|
||||
emailVerified: true,
|
||||
} as any);
|
||||
|
||||
const res = await reports(
|
||||
new Request(`${BASE_URL}/api/reports/overview?from=2025-01-02&to=2025-01-08`, {
|
||||
headers: { cookie: `pb_access=${token}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.summary.totalSubmissions).toBe(1);
|
||||
expect(json.summary.uniqueProjects).toBe(1);
|
||||
expect(json.daily).toHaveLength(1);
|
||||
expect(json.daily[0].date).toBe("2025-01-05");
|
||||
expect(json.byProject).toHaveLength(1);
|
||||
expect(json.byProject[0].submissions).toBe(1);
|
||||
});
|
||||
|
||||
it("JWT 가 없으면 401 을 반환해야 한다", async () => {
|
||||
const { GET: reports } = await import("@/app/api/reports/overview/route");
|
||||
|
||||
const res = await reports(new Request(`${BASE_URL}/api/reports/overview?range=all`));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -81,7 +81,7 @@ test("회원가입 후에는 /dashboard, /editor, /preview 에 정상 접근할
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-1", email, tokenVersion: 1 }),
|
||||
body: JSON.stringify({ id: "user-1", email, tokenVersion: 1, emailVerified: false }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,11 @@ test("회원가입 후에는 /dashboard, /editor, /preview 에 정상 접근할
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
|
||||
// 이메일 인증 안내 배너가 보여야 한다.
|
||||
await expect(
|
||||
page.getByText(/Your email is not verified yet\./i),
|
||||
).toBeVisible();
|
||||
|
||||
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
|
||||
await page.goto("/editor");
|
||||
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
||||
|
||||
@@ -115,6 +115,43 @@ test("로그인 사용자가 /dashboard 에 접근하면 요약 지표와 프로
|
||||
await expect(page.getByRole("button", { name: "Log out" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("대시보드 헤더 메뉴에서 My page 로 이동할 수 있어야 한다", async ({ page }) => {
|
||||
await page.route("**/api/dashboard/overview", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
summaryStats: {
|
||||
totalProjects: 0,
|
||||
totalSubmissions: 0,
|
||||
todaySubmissions: 0,
|
||||
last7DaysSubmissions: 0,
|
||||
},
|
||||
projectStats: [],
|
||||
dailySubmissions: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-mypage", email: "mypage@example.com", tokenVersion: 1 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
const menuButton = page.getByRole("button", { name: "Menu" });
|
||||
await menuButton.click();
|
||||
|
||||
await page.getByRole("button", { name: "My page" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/mypage/);
|
||||
await expect(page.getByRole("heading", { name: "My page" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("로그인 사용자가 / 에 접속하면 대시보드로 진입해야 한다", async ({ page }) => {
|
||||
await page.route("**/api/dashboard/overview", async (route) => {
|
||||
await route.fulfill({
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// 마이페이지(/mypage) E2E
|
||||
// - 비로그인 사용자는 /login 으로 리다이렉트되어야 한다.
|
||||
// - 로그인 사용자는 My page 헤더와 프로필/계정 관리 섹션을 볼 수 있어야 한다.
|
||||
// - 로그인 사용자는 마이페이지에서 이메일 인증 메일을 보낼 수 있어야 한다.
|
||||
|
||||
test("비로그인 사용자가 /mypage 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||
// /api/auth/me 를 401 로 목 처리해 비로그인 상태를 시뮬레이션한다.
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "인증이 필요합니다." }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/mypage");
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole("heading", { name: "Log in" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("로그인 사용자가 /mypage 에 접근하면 My page 헤더와 프로필/계정 관리 섹션이 보여야 한다", async ({ page }) => {
|
||||
const email = `mypage+${Date.now()}@example.com`;
|
||||
|
||||
// 로그인된 상태를 시뮬레이션하기 위해 /api/auth/me 를 200 으로 목 처리한다.
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-mypage", email, tokenVersion: 1 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/mypage");
|
||||
|
||||
// 마이페이지 헤더와 기본 섹션들이 보여야 한다.
|
||||
await expect(page.getByRole("heading", { name: "My page" })).toBeVisible();
|
||||
await expect(page.getByText(email)).toBeVisible();
|
||||
|
||||
// 비밀번호/이메일 변경 섹션의 기본 라벨이 존재하는지만 확인한다.
|
||||
await expect(page.getByText(/Change password/i)).toBeVisible();
|
||||
await expect(page.getByText(/Change email/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("마이페이지 헤더에서 대시보드로 이동할 수 있어야 한다", async ({ page }) => {
|
||||
const email = `mypage-nav+${Date.now()}@example.com`;
|
||||
|
||||
// /api/auth/me 를 200 으로 목 처리해 로그인 상태를 시뮬레이션한다.
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-mypage-nav", email, tokenVersion: 1, emailVerified: true }),
|
||||
});
|
||||
});
|
||||
|
||||
// 대시보드 개요 API 를 목 처리해 /dashboard 진입 시 데이터를 안정적으로 반환한다.
|
||||
await page.route("**/api/dashboard/overview", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
summaryStats: {
|
||||
totalProjects: 0,
|
||||
totalSubmissions: 0,
|
||||
todaySubmissions: 0,
|
||||
last7DaysSubmissions: 0,
|
||||
},
|
||||
projectStats: [],
|
||||
dailySubmissions: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/mypage");
|
||||
|
||||
const toDashboard = page.getByRole("link", { name: "Go to dashboard" });
|
||||
await expect(toDashboard).toBeVisible();
|
||||
|
||||
await toDashboard.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("로그인 사용자는 마이페이지에서 이메일 인증 메일을 보낼 수 있어야 한다", async ({ page }) => {
|
||||
const email = `verify+${Date.now()}@example.com`;
|
||||
|
||||
// 로그인된 상태를 시뮬레이션하기 위해 /api/auth/me 를 200 으로 목 처리한다.
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-verify", email, tokenVersion: 1 }),
|
||||
});
|
||||
});
|
||||
|
||||
// 이메일 인증 메일 전송 API 를 목 처리해 성공 응답을 시뮬레이션한다.
|
||||
await page.route("**/api/auth/request-email-verification", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
message: "Verification email has been sent.",
|
||||
verificationToken: "dummy-token",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/mypage");
|
||||
|
||||
// 이메일 인증 안내 텍스트와 버튼이 보여야 한다.
|
||||
await expect(
|
||||
page.getByText(/If your email is not verified yet, you can send a verification email from here\./i),
|
||||
).toBeVisible();
|
||||
|
||||
const button = page.getByRole("button", { name: "Send verification email" });
|
||||
await expect(button).toBeVisible();
|
||||
|
||||
await button.click();
|
||||
|
||||
// 성공 메시지가 노출되어야 한다.
|
||||
await expect(page.getByText(/Verification email has been sent\./i)).toBeVisible();
|
||||
});
|
||||
@@ -227,6 +227,55 @@ test("프로젝트 목록에서 '폼 제출 내역' 링크를 클릭하면 해
|
||||
await expect(page.getByText("message: 안녕하세요")).toBeVisible();
|
||||
});
|
||||
|
||||
test("이메일 미인증 사용자는 프로젝트 목록에서 퍼블릭 페이지 열기 링크를 볼 수 없어야 한다", async ({ page }) => {
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-unverified", email: "unverified@example.com", tokenVersion: 1, emailVerified: false }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/projects", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const method = request.method();
|
||||
|
||||
if (url.pathname === "/api/projects" && method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
createdAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
|
||||
updatedAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
|
||||
},
|
||||
]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Not implemented in E2E mock" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/projects");
|
||||
|
||||
// 프로젝트 행은 보여야 한다.
|
||||
await expect(page.getByText("테스트 프로젝트 A")).toBeVisible();
|
||||
await expect(page.getByText("test-project-a")).toBeVisible();
|
||||
|
||||
// 이메일 미인증 상태에서는 'Open public page' 링크가 보이지 않아야 한다.
|
||||
await expect(page.getByRole("link", { name: "Open public page" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("프로젝트 목록 헤더에서 대시보드로 이동할 수 있어야 한다", async ({ page }) => {
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
@@ -329,3 +378,31 @@ test("전체 제출 내역 페이지에서 공통 GNB와 메뉴가 보여야 한
|
||||
await menuButton.click();
|
||||
await expect(page.getByRole("button", { name: "Log out" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Projects GNB 메뉴에서 My page 로 이동할 수 있어야 한다", async ({ page }) => {
|
||||
await page.route("**/api/projects", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-mypage", email: "mypage@example.com", tokenVersion: 1 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/projects");
|
||||
|
||||
const menuButton = page.getByRole("button", { name: "Menu" });
|
||||
await menuButton.click();
|
||||
|
||||
await page.getByRole("button", { name: "My page" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/mypage/);
|
||||
await expect(page.getByRole("heading", { name: "My page" })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// 리포트 페이지(/reports) E2E
|
||||
// - 로그인 사용자는 GNB 의 Reports 탭을 통해 /reports 로 이동할 수 있어야 한다.
|
||||
// - /reports 에서는 필터/요약 카드/그래프/프로젝트별 테이블 컨테이너가 렌더링되어야 한다.
|
||||
|
||||
test("로그인 사용자가 GNB Reports 탭을 통해 /reports 에 접근할 수 있어야 한다", async ({ page }) => {
|
||||
const email = `reports+${Date.now()}@example.com`;
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-reports", email, tokenVersion: 1, emailVerified: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/projects", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "p1",
|
||||
title: "Project A",
|
||||
slug: "project-a",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/projects", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "p1",
|
||||
title: "Project A",
|
||||
slug: "project-a",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
title: "Project B",
|
||||
slug: "project-b",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-02T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
// 대시보드 개요 API 를 목 처리해 /dashboard 진입 시 401/리다이렉트가 발생하지 않도록 한다.
|
||||
await page.route("**/api/dashboard/overview", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
summaryStats: {
|
||||
totalProjects: 0,
|
||||
totalSubmissions: 0,
|
||||
todaySubmissions: 0,
|
||||
last7DaysSubmissions: 0,
|
||||
},
|
||||
projectStats: [],
|
||||
dailySubmissions: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/projects", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: "p1",
|
||||
title: "Project A",
|
||||
slug: "project-a",
|
||||
status: "PUBLISHED",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
title: "Project B",
|
||||
slug: "project-b",
|
||||
status: "DRAFT",
|
||||
createdAt: "2025-01-02T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/reports/leads*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ total: 0, items: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/reports/overview*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
summary: {
|
||||
totalSubmissions: 3,
|
||||
uniqueProjects: 2,
|
||||
avgPerDay: 1,
|
||||
},
|
||||
daily: [
|
||||
{ date: "2025-01-01", count: 1 },
|
||||
{ date: "2025-01-02", count: 2 },
|
||||
],
|
||||
byProject: [
|
||||
{ projectId: "p1", title: "Project A", slug: "project-a", submissions: 2, ratio: 2 / 3 },
|
||||
{ projectId: "p2", title: "Project B", slug: "project-b", submissions: 1, ratio: 1 / 3 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
const reportsTab = page.getByRole("link", { name: "Reports" });
|
||||
await expect(reportsTab).toBeVisible();
|
||||
|
||||
await reportsTab.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/reports/);
|
||||
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("reports-summary-total-submissions")).toContainText("3");
|
||||
await expect(page.getByTestId("reports-daily-chart")).toBeVisible();
|
||||
await expect(page.getByTestId("reports-by-project-table")).toBeVisible();
|
||||
});
|
||||
|
||||
test("/reports 에서는 기간/프로젝트 필터와 기본 리포트 섹션이 렌더링되어야 한다", async ({ page }) => {
|
||||
const email = `reports2+${Date.now()}@example.com`;
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-reports2", email, tokenVersion: 1, emailVerified: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/reports/overview*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
summary: {
|
||||
totalSubmissions: 0,
|
||||
uniqueProjects: 0,
|
||||
avgPerDay: 0,
|
||||
},
|
||||
daily: [],
|
||||
byProject: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/reports");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible();
|
||||
|
||||
// 기간/프로젝트 필터 컨트롤이 존재해야 한다.
|
||||
await expect(page.getByTestId("reports-filter-range-toggle")).toBeVisible();
|
||||
await expect(page.getByTestId("reports-filter-project")).toBeVisible();
|
||||
|
||||
// 요약/그래프/테이블 컨테이너가 렌더링되어야 한다.
|
||||
await expect(page.getByTestId("reports-summary-total-submissions")).toBeVisible();
|
||||
await expect(page.getByTestId("reports-daily-chart")).toBeVisible();
|
||||
await expect(page.getByTestId("reports-by-project-table")).toBeVisible();
|
||||
});
|
||||
|
||||
test("/reports 에서는 시간 패턴과 프로젝트 상태 요약 섹션도 함께 볼 수 있어야 한다", async ({ page }) => {
|
||||
const email = `reports3+${Date.now()}@example.com`;
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-reports3", email, tokenVersion: 1, emailVerified: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/reports/overview*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
summary: {
|
||||
totalSubmissions: 3,
|
||||
uniqueProjects: 2,
|
||||
avgPerDay: 1.5,
|
||||
},
|
||||
daily: [
|
||||
{ date: "2025-01-01", count: 1 },
|
||||
{ date: "2025-01-02", count: 2 },
|
||||
],
|
||||
byProject: [
|
||||
{ projectId: "p1", title: "Project A", slug: "project-a", submissions: 2, ratio: 2 / 3 },
|
||||
{ projectId: "p2", title: "Project B", slug: "project-b", submissions: 1, ratio: 1 / 3 },
|
||||
],
|
||||
timePatterns: {
|
||||
byWeek: [{ week: "2025-W01", count: 3 }],
|
||||
byMonth: [{ month: "2025-01", count: 3 }],
|
||||
byWeekday: [
|
||||
{ weekday: 1, count: 1 },
|
||||
{ weekday: 2, count: 2 },
|
||||
],
|
||||
byHour: [
|
||||
{ hour: 9, count: 1 },
|
||||
{ hour: 15, count: 2 },
|
||||
],
|
||||
},
|
||||
projectStatusSummary: {
|
||||
byStatus: [
|
||||
{ status: "PUBLISHED", projects: 1, submissions: 2 },
|
||||
{ status: "DRAFT", projects: 1, submissions: 1 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/reports");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("reports-time-patterns")).toBeVisible();
|
||||
await expect(page.getByTestId("reports-project-status-summary")).toBeVisible();
|
||||
});
|
||||
|
||||
test("/reports 에서는 Leads 요약과 리드 리스트 테이블이 렌더링되어야 한다", async ({ page }) => {
|
||||
const email = `reports-leads+${Date.now()}@example.com`;
|
||||
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "user-reports-leads", email, tokenVersion: 1, emailVerified: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/reports/overview*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
summary: {
|
||||
totalSubmissions: 3,
|
||||
uniqueProjects: 2,
|
||||
avgPerDay: 1.5,
|
||||
},
|
||||
daily: [],
|
||||
byProject: [],
|
||||
timePatterns: {
|
||||
byWeek: [],
|
||||
byMonth: [],
|
||||
byWeekday: [],
|
||||
byHour: [],
|
||||
},
|
||||
projectStatusSummary: { byStatus: [] },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/reports/leads*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
id: "s1",
|
||||
projectId: "p1",
|
||||
projectSlug: "project-a",
|
||||
projectTitle: "Project A",
|
||||
createdAt: "2025-01-09T12:00:00.000Z",
|
||||
payload: { name: "Alice", email: "alice@example.com" },
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
projectId: "p2",
|
||||
projectSlug: "project-b",
|
||||
projectTitle: "Project B",
|
||||
createdAt: "2025-01-08T09:00:00.000Z",
|
||||
payload: { name: "Bob" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/reports");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible();
|
||||
|
||||
const leadsSection = page.getByTestId("reports-leads-table");
|
||||
await expect(leadsSection).toBeVisible();
|
||||
|
||||
// 요약 텍스트에 total 값이 반영되어야 한다.
|
||||
await expect(page.getByTestId("reports-leads-summary")).toContainText("2");
|
||||
|
||||
// 테이블 행이 두 개 렌더링되는지만 간단히 확인.
|
||||
await expect(leadsSection.getByRole("row")).toHaveCount(1 + 2); // 헤더 1 + 데이터 2
|
||||
});
|
||||
Reference in New Issue
Block a user