Feat: 세션 유지 컴포넌트 추가 및 주석 디자인 다크테마 적용

This commit is contained in:
2026-02-06 10:43:16 +09:00
parent d31e3f9bc9
commit d2c66a639d
19 changed files with 1341 additions and 273 deletions

View File

@@ -1,4 +1,14 @@
"use server";
/**
* @file features/auth/actions.ts
* @description 인증 관련 서버 액션 (Server Actions) 모음
* @remarks
* - [레이어] Service/API (Server Actions)
* - [역할] 로그인, 회원가입, 로그아웃, 비밀번호 재설정 등 인증 로직 처리
* - [데이터 흐름] Client Form -> Server Action -> Supabase Auth -> Client Redirect
* - [연관 파일] login-form.tsx, signup-form.tsx, utils/supabase/server.ts
*/
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
@@ -17,14 +27,9 @@ import { getAuthErrorMessage } from "./errors";
// ========================================
/**
* [FormData 추출 헬퍼]
*
* FormData에서 이메일과 비밀번호를 안전하게 추출합니다.
* - 이메일은 trim()으로 공백 제거
* - null/undefined 방지를 위해 기본값 "" 사용
*
* @param formData - HTML form에서 전달된 FormData 객체
* @returns AuthFormData - 추출된 이메일과 비밀번호
* FormData 추출 헬퍼 (이메일/비밀번호)
* @param formData HTML form 데이터
* @returns 이메일(trim 적용), 비밀번호
*/
function extractAuthData(formData: FormData): AuthFormData {
const email = (formData.get("email") as string)?.trim() || "";
@@ -34,22 +39,13 @@ function extractAuthData(formData: FormData): AuthFormData {
}
/**
* [비밀번호 강도 검증 함수]
*
* 안전한 비밀번호인지 검사합니다.
*
* 비밀번호 정책:
* - 최소 8자 이상
* - 대문자 1개 이상 포함
* - 소문자 1개 이상 포함
* - 숫자 1개 이상 포함
* - 특수문자 1개 이상 포함 (!@#$%^&*(),.?":{}|<> 등)
*
* @param password - 검증할 비밀번호
* @returns AuthError | null - 에러가 있으면 에러 객체, 없으면 null
* 비밀번호 강도 검증 함수
* @param password 검증할 비밀번호
* @returns 에러 객체 또는 null
* @remarks 최소 8자, 대/소문자, 숫자, 특수문자 포함 필수
*/
function validatePassword(password: string): AuthError | null {
// 1. 최소 길이 체크 (8자 이상)
// [Step 1] 최소 길이 체크 (8자 이상)
if (password.length < 8) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_SHORT,
@@ -57,7 +53,7 @@ function validatePassword(password: string): AuthError | null {
};
}
// 2. 대문자 포함 여부
// [Step 2] 대문자 포함 여부
if (!/[A-Z]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
@@ -65,7 +61,7 @@ function validatePassword(password: string): AuthError | null {
};
}
// 3. 소문자 포함 여부
// [Step 3] 소문자 포함 여부
if (!/[a-z]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
@@ -73,7 +69,7 @@ function validatePassword(password: string): AuthError | null {
};
}
// 4. 숫자 포함 여부
// [Step 4] 숫자 포함 여부
if (!/[0-9]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
@@ -81,7 +77,7 @@ function validatePassword(password: string): AuthError | null {
};
}
// 5. 특수문자 포함 여부
// [Step 5] 특수문자 포함 여부
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
@@ -89,26 +85,20 @@ function validatePassword(password: string): AuthError | null {
};
}
// 모든 검증 통과
// [Step 6] 모든 검증 통과
return null;
}
/**
* [입력값 검증 함수]
*
* 사용자가 입력한 이메일과 비밀번호의 유효성을 검사합니다.
*
* 검증 항목:
* 1. 빈 값 체크 - 이메일 또는 비밀번호가 비어있는지 확인
* 2. 이메일 형식 - '@' 포함 여부로 간단한 형식 검증
* 3. 비밀번호 강도 - 8자 이상, 대소문자/숫자/특수문자 포함 확인
*
* @param email - 사용자 이메일
* @param password - 사용자 비밀번호
* @returns AuthError | null - 에러가 있으면 에러 객체, 없으면 null
* 입력값 유효성 검증 함수
* @param email 사용자 이메일
* @param password 사용자 비밀번호
* @returns 에러 객체 또는 null
* @see login - 로그인 액션에서 호출
* @see signup - 회원가입 액션에서 호출
*/
function validateAuthInput(email: string, password: string): AuthError | null {
// 1. 빈 값 체크
// [Step 1] 빈 값 체크
if (!email || !password) {
return {
message: AUTH_ERROR_MESSAGES.EMPTY_FIELDS,
@@ -116,7 +106,7 @@ function validateAuthInput(email: string, password: string): AuthError | null {
};
}
// 2. 이메일 형식 체크 (간단한 @ 포함 여부 확인)
// [Step 2] 이메일 형식 체크 (간단한 @ 포함 여부 확인)
if (!email.includes("@")) {
return {
message: AUTH_ERROR_MESSAGES.INVALID_EMAIL,
@@ -124,36 +114,19 @@ function validateAuthInput(email: string, password: string): AuthError | null {
};
}
// 3. 비밀번호 강도 체크
// [Step 3] 비밀번호 강도 체크
const passwordValidation = validatePassword(password);
if (passwordValidation) {
return passwordValidation;
}
// 모든 검증 통과
// [Step 4] 검증 통과
return null;
}
/**
* [에러 메시지 번역 헬퍼]
*
* Supabase의 영문 에러 메시지를 사용자 친화적인 한글로 변환합니다.
*
* @param error - Supabase에서 받은 에러 메시지
* @returns string - 한글로 번역된 에러 메시지
*/
// 에러 메시지는 getAuthErrorMessage로 통일합니다.
// ========================================
// Server Actions (서버 액션)
// ========================================
// 흐름 요약 (어디서 호출되는지)
// - /login: features/auth/components/login-form.tsx -> login, signInWithGoogle, signInWithKakao
// - /signup: features/auth/components/signup-form.tsx -> signup
// - /forgot-password: app/forgot-password/page.tsx -> requestPasswordReset
// - /reset-password: features/auth/components/reset-password-form.tsx -> updatePassword
// - 메인(/): app/page.tsx -> signout
// - OAuth: signInWithGoogle/Kakao -> Supabase -> /auth/callback 라우트
/**
* [로그인 액션]
@@ -164,17 +137,17 @@ function validateAuthInput(email: string, password: string): AuthError | null {
* 1. FormData에서 이메일/비밀번호 추출
* 2. 입력값 유효성 검증 (빈 값, 이메일 형식, 비밀번호 길이)
* 3. Supabase Auth를 통한 로그인 시도
* 4. 성공 시 메인 페이지로 리다이렉트
* 5. 실패 시 에러 메시지와 함께 로그인 페이지로 리다이렉트
* 4. 로그인 실패 시 에러 메시지와 함께 로그인 페이지로 리다이렉트
* 5. 로그인 성공 시 캐시 무효화 및 메인 페이지로 리다이렉트
*
* @param formData - HTML form에서 전달된 FormData (이메일, 비밀번호 포함)
* @param formData 이메일, 비밀번호 포함된 FormData
* @see login-form.tsx - 로그인 폼 제출 시 호출
*/
export async function login(formData: FormData) {
// 호출: features/auth/components/login-form.tsx (로그인 폼 action)
// 1. FormData에서 이메일/비밀번호 추출
// [Step 1] FormData에서 이메일/비밀번호 추출
const { email, password } = extractAuthData(formData);
// 2. 입력값 유효성 검증
// [Step 2] 입력값 유효성 검증
const validationError = validateAuthInput(email, password);
if (validationError) {
return redirect(
@@ -182,21 +155,20 @@ export async function login(formData: FormData) {
);
}
// 3. Supabase 클라이언트 생성 및 로그인 시도
// [Step 3] Supabase 클라이언트 생성 및 로그인 시도
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
// 4. 로그인 실패 시 에러 처리
// [Step 4] 로그인 실패 시 에러 처리
if (error) {
const message = getAuthErrorMessage(error);
return redirect(`/login?message=${encodeURIComponent(message)}`);
}
// 5. 로그인 성공 - 캐시 무효화 및 메인 페이지로 리다이렉트
// revalidatePath: Next.js 캐시를 무효화하여 최신 인증 상태 반영
// [Step 5] 로그인 성공 - 캐시 무효화 및 메인 페이지로 리다이렉트
revalidatePath("/", "layout");
redirect("/");
}
@@ -214,14 +186,14 @@ export async function login(formData: FormData) {
* 5-1. 즉시 세션 생성 시: 메인 페이지로 리다이렉트 (바로 로그인됨)
* 5-2. 이메일 인증 필요 시: 로그인 페이지로 리다이렉트 (안내 메시지 포함)
*
* @param formData - HTML form에서 전달된 FormData (이메일, 비밀번호 포함)
* @param formData 이메일, 비밀번호 포함된 FormData
* @see signup-form.tsx - 회원가입 폼 제출 시 호출
*/
export async function signup(formData: FormData) {
// 호출: features/auth/components/signup-form.tsx (회원가입 폼 action)
// 1. FormData에서 이메일/비밀번호 추출
// [Step 1] FormData에서 이메일/비밀번호 추출
const { email, password } = extractAuthData(formData);
// 2. 입력값 유효성 검증
// [Step 2] 입력값 유효성 검증
const validationError = validateAuthInput(email, password);
if (validationError) {
return redirect(
@@ -229,35 +201,31 @@ export async function signup(formData: FormData) {
);
}
// 3. Supabase 클라이언트 생성 및 회원가입 시도
// [Step 3] Supabase 클라이언트 생성 및 회원가입 시도
const supabase = await createClient();
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
// 이메일 인증 완료 후 리다이렉트될 URL
// 로컬 개발 환경: http://localhost:3001/auth/callback
// 프로덕션: NEXT_PUBLIC_BASE_URL 환경 변수에 설정된 주소
emailRedirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/auth/callback?auth_type=signup`,
},
});
// 4. 회원가입 실패 시 에러 처리
// [Step 4] 회원가입 실패 시 에러 처리
if (error) {
const message = getAuthErrorMessage(error);
return redirect(`/signup?message=${encodeURIComponent(message)}`);
}
// 5. 회원가입 성공 - 세션 생성 여부에 따라 분기 처리
// [Step 5] 회원가입 성공 처리
if (data.session) {
// 5-1. 즉시 세션 생성된 경우 (이메일 인증 불필요)
// → 바로 메인 페이지로 이동
// [Case 1] 즉시 세션 생성 (이메일 인증 불필요)
revalidatePath("/", "layout");
redirect("/");
}
// 5-2. 이메일 인증 필요한 경우
// → 로그인 페이지로 이동 + 안내 메시지
// [Case 2] 이메일 인증 필요 (로그인 페이지로 이동)
revalidatePath("/", "layout");
redirect(
`/login?message=${encodeURIComponent("회원가입이 완료되었습니다. 이메일을 확인하여 인증을 완료해 주세요.")}`,
@@ -270,21 +238,23 @@ export async function signup(formData: FormData) {
* 현재 세션을 종료하고 로그인 페이지로 이동합니다.
*
* 처리 과정:
* 1. Supabase Auth 세션 종료
* 2. 캐시 무효화하여 인증 상태 갱신
* 1. Supabase Auth 세션 종료 (서버 + 클라이언트 쿠키 삭제)
* 2. Next.js 캐시 무효화하여 인증 상태 갱신
* 3. 로그인 페이지로 리다이렉트
*
* @see user-menu.tsx - 로그아웃 메뉴 클릭 시 호출
* @see session-manager.tsx - 세션 타임아웃 시 호출
*/
export async function signout() {
// 호출: app/page.tsx (로그아웃 버튼의 formAction)
const supabase = await createClient();
// 1. Supabase 세션 종료 (서버 + 클라이언트 쿠키 삭제)
// [Step 1] Supabase 세션 종료 (서버 + 클라이언트 쿠키 삭제)
await supabase.auth.signOut();
// 2. Next.js 캐시 무효화
// [Step 2] Next.js 캐시 무효화
revalidatePath("/", "layout");
// 3. 로그인 페이지로 리다이렉트
// [Step 3] 로그인 페이지로 리다이렉트
redirect("/");
}
@@ -292,10 +262,7 @@ export async function signout() {
* [비밀번호 재설정 요청 액션]
*
* 사용자 이메일로 비밀번호 재설정 링크를 발송합니다.
*
* 보안 고려사항:
* - 이메일 존재 여부와 관계없이 동일한 성공 메시지 표시
* - 이메일 열거 공격(Email Enumeration) 방지
* 보안을 위해 이메일 존재 여부와 관계없이 동일한 메시지를 표시합니다.
*
* 처리 과정:
* 1. FormData에서 이메일 추출
@@ -303,14 +270,14 @@ export async function signout() {
* 3. Supabase를 통한 재설정 링크 발송
* 4. 성공 메시지와 함께 로그인 페이지로 리다이렉트
*
* @param formData - 이메일 포함된 FormData
* @param formData 이메일 포함
* @see forgot-password/page.tsx - 비밀번호 찾기 폼 제출 시 호출
*/
export async function requestPasswordReset(formData: FormData) {
// 호출: app/forgot-password/page.tsx (비밀번호 재설정 요청 폼)
// 1. FormData에서 이메일 추출
// [Step 1] FormData에서 이메일 추출
const email = (formData.get("email") as string)?.trim() || "";
// 2. 이메일 검증
// [Step 2] 이메일 유효성 검증
if (!email) {
return redirect(
`/forgot-password?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.EMPTY_EMAIL)}`,
@@ -323,20 +290,20 @@ export async function requestPasswordReset(formData: FormData) {
);
}
// 3. Supabase를 통한 재설정 링크 발송
// [Step 3] Supabase를 통한 재설정 링크 발송
const supabase = await createClient();
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/reset-password`,
});
// 4. 에러 처리
// [Step 4] 에러 처리
if (error) {
console.error("Password reset error:", error.message);
const message = getAuthErrorMessage(error);
return redirect(`/forgot-password?message=${encodeURIComponent(message)}`);
}
// 5. 성공 메시지 표시
// [Step 5] 성공 메시지 표시 (보안상 항상 성공 메시지 리턴 권장)
redirect(
`/login?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.PASSWORD_RESET_SENT)}`,
);
@@ -349,36 +316,44 @@ export async function requestPasswordReset(formData: FormData) {
*
* 처리 과정:
* 1. FormData에서 새 비밀번호 추출
* 2. 비밀번호 길이 검증
* 2. 비밀번호 길이 및 강도 검증
* 3. Supabase를 통한 비밀번호 업데이트
* 4. 성공로그인 페이지로 리다이렉트
* 4. 실패에러 메시지 반환
* 5. 성공 시 세션/쿠키 정리 후 로그아웃 및 캐시 무효화
* 6. 성공 결과 반환
*
* @param formData - 새 비밀번호 포함된 FormData
* @param formData 새 비밀번호 포함
* @see reset-password-form.tsx - 비밀번호 재설정 폼 제출 시 호출
*/
export async function updatePassword(formData: FormData) {
// 호출: features/auth/components/reset-password-form.tsx (재설정 폼)
// [Step 1] 새 비밀번호 추출
const password = (formData.get("password") as string) || "";
// [Step 2] 비밀번호 강도 검증
const passwordValidation = validatePassword(password);
if (passwordValidation) {
return { ok: false, message: passwordValidation.message };
}
// [Step 3] Supabase를 통한 비밀번호 업데이트
const supabase = await createClient();
const { error } = await supabase.auth.updateUser({
password: password,
});
// [Step 4] 에러 처리
if (error) {
const message = getAuthErrorMessage(error);
return { ok: false, message };
}
// [Step 5] 세션 및 쿠키 정리 후 로그아웃
const cookieStore = await cookies();
cookieStore.delete(RECOVERY_COOKIE_NAME);
await supabase.auth.signOut();
revalidatePath("/", "layout");
// [Step 6] 성공 응답 반환
return {
ok: true,
message: AUTH_ERROR_MESSAGES.PASSWORD_RESET_SUCCESS,
@@ -390,53 +365,47 @@ export async function updatePassword(formData: FormData) {
// ========================================
/**
* [OAuth 로그인 헬퍼 함수]
* [OAuth 로그인 공통 헬퍼]
*
* Google, Kakao 등의 소셜 로그인 제공자를 통해 인증을 수행합니다.
* 처리 과정:
* 1. Supabase OAuth 로그인 URL 생성 (PKCE)
* 2. 생성 중 에러 발생 시 로그인 페이지로 리다이렉트 (에러 메시지 포함)
* 3. 성공 시 해당 OAuth 제공자 페이지(data.url)로 리다이렉트
*
* PKCE (Proof Key for Code Exchange) 플로우:
* 1. 이 함수가 signInWithOAuth를 호출하면 Supabase가 OAuth URL 반환
* 2. 사용자를 해당 OAuth 제공자(Google/Kakao) 로그인 페이지로 리다이렉트
* 3. 사용자가 로그인 및 권한 동의
* 4. OAuth 제공자가 /auth/callback?code=xxx로 리다이렉트
* 5. callback 라우트에서 code를 세션으로 교환 (exchangeCodeForSession)
* 6. 메인 페이지로 최종 리다이렉트
*
* 자동 회원가입:
* - 첫 로그인 시 auth.users 테이블에 자동으로 사용자 생성
* - 이메일, provider, metadata(프로필 사진, 이름 등) 저장
* - 기존 사용자는 그냥 로그인
*
* @param provider - OAuth 제공자 ('google' | 'kakao')
* @param provider 'google' | 'kakao'
* @param extraOptions 추가 옵션 (예: prompt)
* @see signInWithGoogle
* @see signInWithKakao
*/
async function signInWithProvider(provider: "google" | "kakao") {
// 호출: 아래 signInWithGoogle / signInWithKakao에서 공통 사용
async function signInWithProvider(
provider: "google" | "kakao",
extraOptions: { queryParams?: { [key: string]: string } } = {},
) {
const supabase = await createClient();
// 1. OAuth 인증 시작
// [Step 1] OAuth 인증 시작 (URL 생성)
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: {
// PKCE 플로우를 위한 콜백 URL
// OAuth 제공자 인증 후 이 URL로 code와 함께 리다이렉트됨
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/auth/callback`,
...extraOptions,
},
});
// 2. 에러 처리
// [Step 2] 에러 처리
if (error) {
console.error(`[${provider} OAuth] 로그인 실패:`, error.message);
const message = getAuthErrorMessage(error);
return redirect(`/login?message=${encodeURIComponent(message)}`);
}
// 3. OAuth 제공자 로그인 페이지로 리다이렉트
// data.url은 Google/Kakao의 인증 페이지 URL
// [Step 3] OAuth 제공자 로그인 페이지로 리다이렉트
if (data.url) {
redirect(data.url);
}
// 4. URL이 없는 경우 (예상치 못한 상황)
// [Step 4] URL 생성 실패 시 에러 처리
redirect(
`/login?message=${encodeURIComponent("로그인 처리 중 오류가 발생했습니다.")}`,
);
@@ -444,36 +413,16 @@ async function signInWithProvider(provider: "google" | "kakao") {
/**
* [Google 로그인 액션]
*
* Google 계정으로 로그인합니다.
* "Google로 로그인" 버튼에서 호출됩니다.
*
* 처리 과정:
* 1. signInWithOAuth 호출하여 Google OAuth URL 받기
* 2. Google 로그인 페이지로 리다이렉트
* 3. (사용자가 Google에서 로그인 및 권한 동의)
* 4. /auth/callback으로 돌아와서 세션 생성
* 5. 메인 페이지로 이동
* @see login-form.tsx - 구글 로그인 버튼 클릭 시 호출
*/
export async function signInWithGoogle() {
// 호출: features/auth/components/login-form.tsx (Google 로그인 버튼)
return signInWithProvider("google");
}
/**
* [Kakao 로그인 액션]
*
* Kakao 계정으로 로그인합니다.
* "Kakao로 로그인" 버튼에서 호출됩니다.
*
* 처리 과정:
* 1. signInWithOAuth 호출하여 Kakao OAuth URL 받기
* 2. Kakao 로그인 페이지로 리다이렉트
* 3. (사용자가 Kakao에서 로그인 및 권한 동의)
* 4. /auth/callback으로 돌아와서 세션 생성
* 5. 메인 페이지로 이동
* @see login-form.tsx - 카카오 로그인 버튼 클릭 시 호출
*/
export async function signInWithKakao() {
// 호출: features/auth/components/login-form.tsx (Kakao 로그인 버튼)
return signInWithProvider("kakao");
return signInWithProvider("kakao", { queryParams: { prompt: "login" } });
}

View File

@@ -0,0 +1,148 @@
/**
* @file features/auth/components/session-manager.tsx
* @description 사용자 세션 타임아웃 및 자동 로그아웃 관리 컴포넌트
* @remarks
* - [레이어] Components/Infrastructure
* - [사용자 행동] 로그인 -> 활동 감지 -> 비활동 -> (경고) -> 로그아웃
* - [데이터 흐름] Event -> Zustand Store -> Timer -> Logout
* - [연관 파일] stores/session-store.ts, features/auth/constants.ts
*/
"use client";
import { useEffect, useState, useCallback } from "react";
import { createClient } from "@/utils/supabase/client";
import { useRouter, usePathname } from "next/navigation";
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useSessionStore } from "@/stores/session-store";
import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
// import { toast } from "sonner"; // Unused for now
// 설정: 경고 표시 시간 (타임아웃 1분 전) - 현재 미사용 (즉시 로그아웃)
// const WARNING_MS = 60 * 1000;
/**
* 세션 관리자 컴포넌트
* 사용자 활동을 감지하여 세션 연장 및 타임아웃 처리
* @returns 숨겨진 기능성 컴포넌트 (Global Layout에 포함)
* @remarks RootLayout에 포함되어 전역적으로 동작
* @see layout.tsx - RootLayout에서 렌더링
* @see session-store.ts - 마지막 활동 시간 관리
*/
export function SessionManager() {
const router = useRouter();
const pathname = usePathname();
// [State] 타임아웃 경고 모달 표시 여부 (현재 미사용)
const [showWarning, setShowWarning] = useState(false);
// 인증 페이지에서는 동작하지 않음
const isAuthPage = ["/login", "/signup", "/forgot-password"].includes(
pathname,
);
const { setLastActive } = useSessionStore();
/**
* 로그아웃 처리 핸들러
* @see session-timer.tsx - 타임아웃 시 동일한 로직 필요 가능성 있음
*/
const handleLogout = useCallback(async () => {
// [Step 1] Supabase 클라이언트 생성
const supabase = createClient();
// [Step 2] 서버 사이드 로그아웃 요청
await supabase.auth.signOut();
// [Step 3] 로컬 스토어 및 세션 정보 초기화
useSessionStore.persist.clearStorage();
// [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시
router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요.");
router.refresh();
}, [router]);
useEffect(() => {
if (isAuthPage) return;
// 마지막 활동 시간 업데이트 함수
const updateLastActive = () => {
setLastActive(Date.now());
if (showWarning) setShowWarning(false);
};
// [Step 1] 사용자 활동 이벤트 감지 (마우스, 키보드, 스크롤, 터치)
const events = ["mousedown", "keydown", "scroll", "touchstart"];
const handleActivity = () => updateLastActive();
events.forEach((event) => window.addEventListener(event, handleActivity));
// [Step 2] 주기적(1초)으로 세션 만료 여부 확인
const intervalId = setInterval(async () => {
const currentLastActive = useSessionStore.getState().lastActive;
const now = Date.now();
const timeSinceLastActive = now - currentLastActive;
// 타임아웃 초과 시 로그아웃
if (timeSinceLastActive >= SESSION_TIMEOUT_MS) {
await handleLogout();
}
// 경고 로직 (현재 비활성)
// else if (timeSinceLastActive >= TIMEOUT_MS - WARNING_MS) {
// setShowWarning(true);
// }
}, 1000);
// [Step 3] 탭 활성화/컴퓨터 깨어남 감지 (절전 모드 대응)
const handleVisibilityChange = async () => {
if (!document.hidden) {
const currentLastActive = useSessionStore.getState().lastActive;
const now = Date.now();
// 절전 모드 복귀 시 즉시 만료 체크
if (now - currentLastActive >= SESSION_TIMEOUT_MS) {
await handleLogout();
}
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
events.forEach((event) =>
window.removeEventListener(event, handleActivity),
);
clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [pathname, isAuthPage, showWarning, handleLogout, setLastActive]);
return (
<AlertDialog open={showWarning} onOpenChange={setShowWarning}>
<AlertDialogContent>
{/* ========== 헤더: 제목 및 설명 ========== */}
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
1 .
.
</AlertDialogDescription>
</AlertDialogHeader>
{/* ========== 하단: 액션 버튼 ========== */}
<AlertDialogFooter>
<AlertDialogAction onClick={() => setShowWarning(false)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,70 @@
/**
* @file features/auth/components/session-timer.tsx
* @description 헤더에 표시되는 세션 만료 카운트다운 컴포넌트
* @remarks
* - [레이어] Components/UI
* - [사용자 행동] 남은 시간 확인 -> 만료 임박 시 붉은색 경고
* - [데이터 흐름] Zustand Store -> Calculation -> UI
* - [연관 파일] stores/session-store.ts, features/layout/header.tsx
*/
"use client";
import { useEffect, useState } from "react";
import { useSessionStore } from "@/stores/session-store";
import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
/**
* 세션 만료 타이머 컴포넌트
* 남은 시간을 mm:ss 형태로 표시 (10분 미만 시 경고 스타일)
* @returns 시간 표시 배지 (모바일 숨김)
* @remarks 1초마다 리렌더링 발생
* @see header.tsx - 로그인 상태일 때 헤더에 표시
*/
export function SessionTimer() {
const lastActive = useSessionStore((state) => state.lastActive);
// [State] 남은 시간 (밀리초)
const [timeLeft, setTimeLeft] = useState<number>(SESSION_TIMEOUT_MS);
useEffect(() => {
const calculateTimeLeft = () => {
const now = Date.now();
const passed = now - lastActive;
// [Step 1] 남은 시간 계산 (음수 방지)
const remaining = Math.max(0, SESSION_TIMEOUT_MS - passed);
setTimeLeft(remaining);
};
calculateTimeLeft(); // 초기 실행
// [Step 2] 1초마다 남은 시간 갱신
const interval = setInterval(calculateTimeLeft, 1000);
return () => clearInterval(interval);
}, [lastActive]);
// [Step 3] 시간 포맷팅 (mm:ss)
const minutes = Math.floor(timeLeft / 60000);
const seconds = Math.floor((timeLeft % 60000) / 1000);
// [Step 4] 10분 미만일 때 긴급 스타일 적용
const isUrgent = timeLeft < 10 * 60 * 1000;
return (
<div
className={`text-sm font-medium tabular-nums px-3 py-1.5 rounded-md border bg-background/50 backdrop-blur-md hidden md:block transition-colors ${
isUrgent
? "text-red-500 border-red-200 bg-red-50/50 dark:bg-red-900/20 dark:border-red-800"
: "text-muted-foreground border-border/40"
}`}
>
{/* ========== 라벨 ========== */}
<span className="mr-2"> </span>
{/* ========== 시간 표시 ========== */}
{minutes.toString().padStart(2, "0")}:
{seconds.toString().padStart(2, "0")}
</div>
);
}

View File

@@ -1,10 +1,11 @@
/**
* [인증 관련 상수 정의]
*
* 인증 모듈 전체에서 공통으로 사용하는 상수들을 정의합니다.
* - 에러 메시지
* - 라우트 경로
* - 검증 규칙
* @file features/auth/constants.ts
* @description 인증 모듈 전반에서 사용되는 상수, 에러 메시지, 설정값 정의
* @remarks
* - [레이어] Core/Constants
* - [사용자 행동] 로그인/회원가입/비밀번호 찾기 등 인증 전반
* - [데이터 흐름] UI/Service -> Constants -> UI (메시지 표시)
* - [주의사항] 환경 변수(SESSION_TIMEOUT_MINUTES)에 의존하는 상수 포함
*/
// ========================================
@@ -220,6 +221,21 @@ export const PASSWORD_RULES = {
REQUIRE_SPECIAL_CHAR: true,
} as const;
// ========================================
// 세션 관련 상수
// ========================================
/**
* 세션 타임아웃 시간 (밀리초)
* 환경 변수에서 분 단위를 가져와 밀리초로 변환합니다.
* 기본값: 30분
*/
export const SESSION_TIMEOUT_MS =
(Number(process.env.NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES) || 30) * 60 * 1000;
// 경고 표시 시간 (타임아웃 1분 전)
export const SESSION_WARNING_MS = 60 * 1000;
// ========================================
// 타입 정의
// ========================================

View File

@@ -1,45 +1,92 @@
/**
* @file features/layout/components/header.tsx
* @description 애플리케이션 최상단 헤더 컴포넌트 (네비게이션, 테마, 유저 메뉴)
* @remarks
* - [레이어] Components/UI/Layout
* - [사용자 행동] 홈 이동, 테마 변경, 로그인/회원가입 이동, 대시보드 이동
* - [데이터 흐름] User Prop -> UI Conditional Rendering
* - [연관 파일] layout.tsx, session-timer.tsx, user-menu.tsx
*/
import Link from "next/link";
import { User } from "@supabase/supabase-js";
import { AUTH_ROUTES } from "@/features/auth/constants";
import { UserMenu } from "@/features/layout/components/user-menu";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { SessionTimer } from "@/features/auth/components/session-timer";
interface HeaderProps {
/** 현재 로그인한 사용자 정보 (없으면 null) */
user: User | null;
/** 대시보드 링크 표시 여부 */
showDashboardLink?: boolean;
}
/**
* 글로벌 헤더 컴포넌트
* @param user Supabase User 객체
* @param showDashboardLink 대시보드 바로가기 버튼 노출 여부
* @returns Header JSX
* @see layout.tsx - RootLayout에서 데이터 주입하여 호출
*/
export function Header({ user, showDashboardLink = false }: HeaderProps) {
return (
<header className="sticky top-0 z-40 flex h-14 w-full items-center justify-between border-b border-zinc-200 bg-white/75 px-6 backdrop-blur-md transition-all dark:border-zinc-800 dark:bg-black/75">
<div className="flex items-center gap-2">
<Link href={AUTH_ROUTES.HOME} className="flex items-center gap-2">
<div className="h-6 w-6 rounded-md bg-zinc-900 dark:bg-zinc-50" />
<span className="text-lg font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
<header className="fixed top-0 z-40 w-full border-b border-border/40 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/60">
<div className="flex h-16 w-full items-center justify-between px-4 md:px-6">
{/* ========== 좌측: 로고 영역 ========== */}
<Link href={AUTH_ROUTES.HOME} className="flex items-center gap-2 group">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 transition-transform duration-200 group-hover:scale-110">
<div className="h-5 w-5 rounded-lg bg-linear-to-br from-indigo-500 to-purple-600" />
</div>
<span className="text-xl font-bold tracking-tight text-foreground transition-colors group-hover:text-primary">
AutoTrade
</span>
</Link>
</div>
<div className="flex items-center gap-4">
{user ? (
<>
{showDashboardLink && (
<Button asChild variant="ghost" size="sm">
<Link href={AUTH_ROUTES.DASHBOARD}></Link>
{/* ========== 우측: 액션 버튼 영역 ========== */}
<div className="flex items-center gap-4">
{/* 테마 토글 */}
<ThemeToggle />
{user ? (
// [Case 1] 로그인 상태
<>
{/* 세션 타임아웃 타이머 */}
<SessionTimer />
{showDashboardLink && (
<Button
asChild
variant="ghost"
size="sm"
className="hidden sm:inline-flex"
>
<Link href={AUTH_ROUTES.DASHBOARD}></Link>
</Button>
)}
{/* 사용자 드롭다운 메뉴 */}
<UserMenu user={user} />
</>
) : (
// [Case 2] 비로그인 상태
<div className="flex items-center gap-2">
<Button
asChild
variant="ghost"
size="sm"
className="hidden sm:inline-flex"
>
<Link href={AUTH_ROUTES.LOGIN}></Link>
</Button>
)}
<UserMenu user={user} />
</>
) : (
<>
<Button asChild variant="ghost" size="sm">
<Link href={AUTH_ROUTES.LOGIN}></Link>
</Button>
<Button asChild size="sm">
<Link href={AUTH_ROUTES.SIGNUP}> </Link>
</Button>
</>
)}
<Button asChild size="sm" className="rounded-full px-6">
<Link href={AUTH_ROUTES.SIGNUP}></Link>
</Button>
</div>
)}
</div>
</div>
</header>
);

View File

@@ -1,3 +1,12 @@
/**
* @file features/layout/components/user-menu.tsx
* @description 사용자 프로필 드롭다운 메뉴 컴포넌트
* @remarks
* - [레이어] Components/UI
* - [사용자 행동] Avatar 클릭 -> 드롭다운 오픈 -> 프로필/설정 이동 또는 로그아웃
* - [연관 파일] header.tsx, features/auth/actions.ts (로그아웃)
*/
"use client";
import { signout } from "@/features/auth/actions";
@@ -15,16 +24,22 @@ import { LogOut, Settings, User as UserIcon } from "lucide-react";
import { useRouter } from "next/navigation";
interface UserMenuProps {
/** Supabase User 객체 */
user: User | null;
}
/**
* 사용자 메뉴/프로필 컴포넌트 (로그인 시 헤더 노출)
* @param user 로그인한 사용자 정보
* @returns Avatar 버튼 및 드롭다운 메뉴
*/
export function UserMenu({ user }: UserMenuProps) {
const router = useRouter();
if (!user) return null;
return (
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 outline-none">
<Avatar className="h-8 w-8 transition-opacity hover:opacity-80">
@@ -39,7 +54,9 @@ export function UserMenu({ user }: UserMenuProps) {
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.user_metadata?.name || "사용자"}
{user.user_metadata?.full_name ||
user.user_metadata?.name ||
"사용자"}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}