마이페이지, 리포트, 메일인증
CI / test (push) Failing after 13m23s
CI / e2e (push) Has been cancelled
CI / pr_and_merge (push) Has been cancelled

This commit is contained in:
2025-12-15 21:59:43 +09:00
parent a8e1a4a960
commit 87cdc1868b
30 changed files with 5488 additions and 16 deletions
+86
View File
@@ -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 },
);
}
}
+96
View File
@@ -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 },
);
}
}
+6 -1
View File
@@ -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;
+16 -4
View File
@@ -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 },
);
}
}
+1
View File
@@ -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;
+65
View File
@@ -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 },
);
}
}
+169
View File
@@ -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 },
);
}
}
+340
View File
@@ -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 },
);
}
}
+50 -1
View File
@@ -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>
)}
+351
View File
@@ -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>
);
}
+53
View File
@@ -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"
+8 -1
View File
@@ -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
+4 -1
View File
@@ -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,
});
}
+9
View File
@@ -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: "오늘 제출 수",
+114
View File
@@ -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];
}