From a070f833d132e6370311c0bbdad03beb51d595df Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Wed, 15 Oct 2025 21:38:21 +0900 Subject: (김준회) 이메일 화이트리스트 (SMS 우회) 기능 추가 및 기존 로그인 과정 통합 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../evcp/(evcp)/(system)/email-whitelist/page.tsx | 82 +++ app/api/auth/[...nextauth]/route.ts | 25 +- app/api/auth/first-auth/route.ts | 8 +- app/api/auth/send-email-otp/route.ts | 74 +++ components/login/login-form.tsx | 101 +++- config/menuConfig.ts | 523 +++++++++---------- db/schema/emailWhitelist.ts | 27 + db/schema/index.ts | 3 +- i18n/locales/en/menu.json | 1 + i18n/locales/ko/menu.json | 1 + lib/email-whitelist/service.ts | 577 +++++++++++++++++++++ .../table/create-whitelist-dialog.tsx | 179 +++++++ .../table/delete-whitelist-dialog.tsx | 137 +++++ .../table/update-whitelist-dialog.tsx | 193 +++++++ .../table/whitelist-table-columns.tsx | 208 ++++++++ lib/email-whitelist/table/whitelist-table.tsx | 130 +++++ lib/users/auth/passwordUtil.ts | 146 ++++++ lib/users/session/helper.ts | 16 +- 18 files changed, 2146 insertions(+), 285 deletions(-) create mode 100644 app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx create mode 100644 app/api/auth/send-email-otp/route.ts create mode 100644 db/schema/emailWhitelist.ts create mode 100644 lib/email-whitelist/service.ts create mode 100644 lib/email-whitelist/table/create-whitelist-dialog.tsx create mode 100644 lib/email-whitelist/table/delete-whitelist-dialog.tsx create mode 100644 lib/email-whitelist/table/update-whitelist-dialog.tsx create mode 100644 lib/email-whitelist/table/whitelist-table-columns.tsx create mode 100644 lib/email-whitelist/table/whitelist-table.tsx diff --git a/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx b/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx new file mode 100644 index 00000000..95abd556 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx @@ -0,0 +1,82 @@ +/** + * 이메일 화이트리스트 + * + * 이메일 도메인의 화이트리스트를 통해, SMS 인증을 우회할 도메인을 관리 + * + * db schema : db/schema/emailWhitelist.ts + * + * 구현방향: 이메일 화이트리스트 조회 + dialog 기반의 생성/삭제 + * + */ + +import * as React from "react" +import { type Metadata } from "next" +import { getEmailWhitelistList } from "@/lib/email-whitelist/service" +import { type SearchParams } from "@/types/table" +import { WhitelistTable } from "@/lib/email-whitelist/table/whitelist-table" +import { Shell } from "@/components/shell" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" + +// export const metadata: Metadata = { +// title: "이메일 화이트리스트 관리", +// description: "SMS 인증 대신 이메일로 인증번호를 받을 도메인 및 개별 이메일을 관리", +// } + +interface WhitelistPageProps { + searchParams: SearchParams +} + +export default async function WhitelistPage(props: WhitelistPageProps) { + + const searchParams = await props.searchParams + + // 기본 검색 파라미터 처리 + const search = { + page: searchParams.page ? parseInt(searchParams.page as string) : 1, + perPage: searchParams.perPage ? parseInt(searchParams.perPage as string) : 10, + search: searchParams.search as string || "", + sort: searchParams.sort as string || "createdAt.desc", + filters: searchParams.filters ? JSON.parse(searchParams.filters as string) : undefined, + } + + const promises = Promise.all([ + getEmailWhitelistList(search), + ]) + + return ( + + +
+
+
+
+

+ 이메일 화이트리스트 관리 +

+
+

+ SMS 인증 대신 이메일로 인증번호를 받을 도메인 및 개별 이메일을 관리합니다. +

+
+
+
+ + }> + + + } + > + + +
+ ) +} \ No newline at end of file diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 5896fb90..3b0f8c61 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -11,7 +11,7 @@ import { getUserByEmail, getUserById } from '@/lib/users/repository' import { authenticateWithSGips, verifyExternalCredentials } from '@/lib/users/auth/verifyCredentails' import { verifyOtpTemp } from '@/lib/users/verifyOtp' import { getSecuritySettings } from '@/lib/password-policy/service' -import { verifySmsToken } from '@/lib/users/auth/passwordUtil' +import { verifySmsToken, verifyEmailToken } from '@/lib/users/auth/passwordUtil' import { SessionRepository } from '@/lib/users/session/repository' import { getUserRoles } from '@/lib/users/service' @@ -161,14 +161,15 @@ export const authOptions: NextAuthOptions = { }, }), - // ✅ MFA 완료 후 최종 인증 - roles 정보 추가 + // ✅ MFA 완료 후 최종 인증 - roles 정보 추가 (SMS/Email OTP 지원) CredentialsProvider({ id: 'credentials-mfa', name: 'MFA Verification', credentials: { userId: { label: 'User ID', type: 'text' }, - smsToken: { label: 'SMS Token', type: 'text' }, + smsToken: { label: 'SMS Token', type: 'text' }, // SMS 또는 Email OTP 토큰 tempAuthKey: { label: 'Temp Auth Key', type: 'text' }, + mfaType: { label: 'MFA Type', type: 'text' }, // 'sms' 또는 'email' }, async authorize(credentials, req) { if (!credentials?.userId || !credentials?.smsToken || !credentials?.tempAuthKey) { @@ -191,10 +192,20 @@ export const authOptions: NextAuthOptions = { return null } - // SMS 토큰 검증 - const smsVerificationResult = await verifySmsToken(user.id, credentials.smsToken) - if (!smsVerificationResult || !smsVerificationResult.success) { - console.error('SMS token verification failed') + // MFA 타입에 따라 SMS 또는 Email OTP 검증 + const mfaType = credentials.mfaType || 'sms'; // 기본값은 SMS + let verificationResult; + + if (mfaType === 'email') { + verificationResult = await verifyEmailToken(user.id, credentials.smsToken) + console.log(`Email OTP verification for user ${user.email}:`, verificationResult.success) + } else { + verificationResult = await verifySmsToken(user.id, credentials.smsToken) + console.log(`SMS OTP verification for user ${user.email}:`, verificationResult.success) + } + + if (!verificationResult || !verificationResult.success) { + console.error(`${mfaType.toUpperCase()} token verification failed`) return null } diff --git a/app/api/auth/first-auth/route.ts b/app/api/auth/first-auth/route.ts index 6952b472..93daf316 100644 --- a/app/api/auth/first-auth/route.ts +++ b/app/api/auth/first-auth/route.ts @@ -17,6 +17,8 @@ interface FirstAuthResponse { tempAuthKey?: string userId?: number email?: string + mfaType?: 'sms' | 'email' // MFA 타입 추가 + userName?: string // Email OTP 전송 시 필요 otpUsers?: Array<{ id: string name: string @@ -134,12 +136,14 @@ export async function POST(request: NextRequest): Promise(null); const [mfaUserEmail, setMfaUserEmail] = useState(''); + const [mfaUserName, setMfaUserName] = useState(''); // Email OTP 전송 시 필요 const [mfaCountdown, setMfaCountdown] = useState(0); + const [mfaType, setMfaType] = useState<'sms' | 'email'>('sms'); // MFA 타입 // 일반 로그인 폼 데이터 const [username, setUsername] = useState(''); @@ -257,6 +259,52 @@ export function LoginForm() { } }; + // Email OTP 전송 + const handleSendEmail = async (userIdParam?: number, emailParam?: string, userNameParam?: string) => { + const targetUserId = userIdParam || mfaUserId; + const targetEmail = emailParam || mfaUserEmail; + const targetUserName = userNameParam || mfaUserName; + + if (!targetUserId || mfaCountdown > 0) return; + + setIsSmsLoading(true); + try { + const response = await fetch('/api/auth/send-email-otp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: targetUserId, + email: targetEmail, + userName: targetUserName, + }), + }); + + if (response.ok) { + setMfaCountdown(60); + toast({ + title: '이메일 인증번호 전송', + description: `${targetEmail}로 인증번호가 전송되었습니다.`, + }); + } else { + const errorData = await response.json(); + toast({ + title: t('errorTitle'), + description: errorData.error || '이메일 전송에 실패했습니다.', + variant: 'destructive', + }); + } + } catch (error) { + console.error('Email OTP send error:', error); + toast({ + title: t('errorTitle'), + description: '이메일 전송 중 오류가 발생했습니다.', + variant: 'destructive', + }); + } finally { + setIsSmsLoading(false); + } + }; + // MFA 토큰 검증 및 최종 로그인 const handleMfaSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -283,11 +331,12 @@ export function LoginForm() { setIsMfaLoading(true); try { - // NextAuth의 credentials-mfa 프로바이더로 최종 인증 + // NextAuth의 credentials-mfa 프로바이더로 최종 인증 (mfaType 포함) const result = await signIn('credentials-mfa', { userId: mfaUserId, smsToken: mfaToken, tempAuthKey: tempAuthKey, + mfaType: mfaType, // 'sms' 또는 'email' redirect: false, }); @@ -364,25 +413,33 @@ export function LoginForm() { const authResult = await performFirstAuth(username, password, 'email'); if (authResult.success) { + const userMfaType = authResult.mfaType || 'sms'; + toast({ title: t('firstAuthComplete'), - description: t('proceedingSmsAuth'), + description: userMfaType === 'email' ? '이메일 인증을 진행합니다.' : t('proceedingSmsAuth'), }); // MFA 화면으로 전환 setTempAuthKey(authResult.tempAuthKey); setMfaUserId(authResult.userId); setMfaUserEmail(authResult.email); + setMfaUserName(authResult.userName || ''); + setMfaType(userMfaType); setShowMfaForm(true); - // 자동으로 SMS 전송 (userId 직접 전달) + // MFA 타입에 따라 자동으로 OTP 전송 setTimeout(() => { - handleSendSms(authResult.userId); + if (userMfaType === 'email') { + handleSendEmail(authResult.userId, authResult.email, authResult.userName); + } else { + handleSendSms(authResult.userId); + } }, 500); toast({ - title: t('smsAuthRequired'), - description: t('sendingCodeToPhone'), + title: userMfaType === 'email' ? '이메일 인증 필요' : t('smsAuthRequired'), + description: userMfaType === 'email' ? '이메일로 인증번호를 전송하고 있습니다.' : t('sendingCodeToPhone'), }); } } catch (error: unknown) { @@ -526,6 +583,8 @@ export function LoginForm() { setTempAuthKey(''); setMfaUserId(null); setMfaUserEmail(''); + setMfaUserName(''); + setMfaType('sms'); // 기본값으로 초기화 setMfaCountdown(0); setSelectedOtpUser(null); setShowUserSelectionDialog(false); @@ -582,17 +641,23 @@ export function LoginForm() { ) : ( <>
- 🔐 + {mfaType === 'email' ? '📧' : '🔐'}
-

{t('smsVerification')}

+

+ {mfaType === 'email' ? '이메일 인증' : t('smsVerification')} +

{selectedOtpUser ? t('firstAuthCompleteForSgips', { name: selectedOtpUser.name, email: mfaUserEmail }) - : t('firstAuthCompleteFor', { email: mfaUserEmail }) + : mfaType === 'email' + ? `${mfaUserEmail}로 인증번호가 전송되었습니다.` + : t('firstAuthCompleteFor', { email: mfaUserEmail }) }

- {t('enterSixDigitCodeInstructions')} + {mfaType === 'email' + ? '이메일에서 받은 6자리 인증번호를 입력해주세요.' + : t('enterSixDigitCodeInstructions')}

)} @@ -751,16 +816,24 @@ export function LoginForm() { - {/* SMS 재전송 섹션 */} + {/* OTP 재전송 섹션 (SMS/Email) */}

{t('resendCode')}

- {t('didNotReceiveCode')} + {mfaType === 'email' + ? '이메일을 받지 못하셨나요?' + : t('didNotReceiveCode')}

diff --git a/config/menuConfig.ts b/config/menuConfig.ts index 4ac2bdc1..5b5319e7 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -27,38 +27,38 @@ export const domainBrandingKeys = { // 메인 네비게이션 (전체 시스템) export const mainNav: MenuSection[] = [ { - titleKey: "menu.master_data.title", + titleKey: 'menu.master_data.title', useGrouping: true, items: [ { - titleKey: "menu.master_data.bid_projects", - href: "/evcp/bid-projects", - descriptionKey: "menu.master_data.bid_projects_desc", - groupKey: "groups.basic_info" + titleKey: 'menu.master_data.bid_projects', + href: '/evcp/bid-projects', + descriptionKey: 'menu.master_data.bid_projects_desc', + groupKey: 'groups.basic_info', }, { - titleKey: "menu.master_data.projects", - href: "/evcp/projects", - descriptionKey: "menu.master_data.projects_desc", - groupKey: "groups.basic_info" + titleKey: 'menu.master_data.projects', + href: '/evcp/projects', + descriptionKey: 'menu.master_data.projects_desc', + groupKey: 'groups.basic_info', }, { - titleKey: "menu.master_data.package_numbers", - href: "/evcp/items", - descriptionKey: "menu.master_data.package_numbers_desc", - groupKey: "groups.basic_info" + titleKey: 'menu.master_data.package_numbers', + href: '/evcp/items', + descriptionKey: 'menu.master_data.package_numbers_desc', + groupKey: 'groups.basic_info', }, { - titleKey: "menu.master_data.material_master", - href: "/evcp/material-groups", - descriptionKey: "menu.master_data.material_master_desc", - groupKey: "groups.basic_info" + titleKey: 'menu.master_data.material_master', + href: '/evcp/material-groups', + descriptionKey: 'menu.master_data.material_master_desc', + groupKey: 'groups.basic_info', }, { - titleKey: "menu.master_data.object_class", - href: "/evcp/equip-class", - descriptionKey: "menu.master_data.object_class_desc", - groupKey: "groups.design_info" + titleKey: 'menu.master_data.object_class', + href: '/evcp/equip-class', + descriptionKey: 'menu.master_data.object_class_desc', + groupKey: 'groups.design_info', }, // { // titleKey: "menu.master_data.sub_class", @@ -67,165 +67,165 @@ export const mainNav: MenuSection[] = [ // groupKey: "groups.design_info" // }, { - titleKey: "menu.master_data.tag_types", - href: "/evcp/tag-numbering", - descriptionKey: "menu.master_data.tag_types_desc", - groupKey: "groups.design_info" + titleKey: 'menu.master_data.tag_types', + href: '/evcp/tag-numbering', + descriptionKey: 'menu.master_data.tag_types_desc', + groupKey: 'groups.design_info', }, { - titleKey: "menu.master_data.form_register", - href: "/evcp/form-list", - descriptionKey: "menu.master_data.form_register_desc", - groupKey: "groups.design_info" + titleKey: 'menu.master_data.form_register', + href: '/evcp/form-list', + descriptionKey: 'menu.master_data.form_register_desc', + groupKey: 'groups.design_info', }, { - titleKey: "menu.master_data.document_numbering_rule", - href: "/evcp/docu-list-rule", - descriptionKey: "menu.master_data.document_numbering_rule_desc", - groupKey: "groups.design_info" + titleKey: 'menu.master_data.document_numbering_rule', + href: '/evcp/docu-list-rule', + descriptionKey: 'menu.master_data.document_numbering_rule_desc', + groupKey: 'groups.design_info', }, { - titleKey: "menu.master_data.incoterms", - href: "/evcp/incoterms", - descriptionKey: "menu.master_data.incoterms_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.incoterms', + href: '/evcp/incoterms', + descriptionKey: 'menu.master_data.incoterms_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.payment_conditions", - href: "/evcp/payment-conditions", - descriptionKey: "menu.master_data.payment_conditions_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.payment_conditions', + href: '/evcp/payment-conditions', + descriptionKey: 'menu.master_data.payment_conditions_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.vendor_types", - href: "/evcp/vendor-type", - descriptionKey: "menu.master_data.vendor_types_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.vendor_types', + href: '/evcp/vendor-type', + descriptionKey: 'menu.master_data.vendor_types_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.basic_contract_template", - href: "/evcp/basic-contract-template", - descriptionKey: "menu.master_data.basic_contract_template_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.basic_contract_template', + href: '/evcp/basic-contract-template', + descriptionKey: 'menu.master_data.basic_contract_template_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.gtc", - href: "/evcp/gtc", + titleKey: 'menu.master_data.gtc', + href: '/evcp/gtc', // descriptionKey: "menu.master_data.basic_contract_template_desc", - groupKey: "groups.procurement_info" + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.pq_criteria", - href: "/evcp/pq-criteria", - descriptionKey: "menu.master_data.pq_criteria_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.pq_criteria', + href: '/evcp/pq-criteria', + descriptionKey: 'menu.master_data.pq_criteria_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.project_gtc", - href: "/evcp/project-gtc", - descriptionKey: "menu.master_data.project_gtc_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.project_gtc', + href: '/evcp/project-gtc', + descriptionKey: 'menu.master_data.project_gtc_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.evaluation_target", - href: "/evcp/evaluation-target-list", - descriptionKey: "menu.master_data.evaluation_target_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.evaluation_target', + href: '/evcp/evaluation-target-list', + descriptionKey: 'menu.master_data.evaluation_target_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.evaluation_checklist", - href: "/evcp/evaluation-check-list", - descriptionKey: "menu.master_data.evaluation_checklist_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.evaluation_checklist', + href: '/evcp/evaluation-check-list', + descriptionKey: 'menu.master_data.evaluation_checklist_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.vendor_checklist", - href: "/evcp/vendor-check-list", - descriptionKey: "menu.master_data.vendor_checklist_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.vendor_checklist', + href: '/evcp/vendor-check-list', + descriptionKey: 'menu.master_data.vendor_checklist_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.esg_checklist", - href: "/evcp/esg-check-list", - descriptionKey: "menu.master_data.esg_checklist_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.esg_checklist', + href: '/evcp/esg-check-list', + descriptionKey: 'menu.master_data.esg_checklist_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.compliance_survey", - href: "/evcp/compliance", - descriptionKey: "menu.master_data.compliance_survey_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.compliance_survey', + href: '/evcp/compliance', + descriptionKey: 'menu.master_data.compliance_survey_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.general_contract_template", - href: "/evcp/general-contract-template", - descriptionKey: "menu.master_data.general_contract_template_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.general_contract_template', + href: '/evcp/general-contract-template', + descriptionKey: 'menu.master_data.general_contract_template_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.buyer_signature", - href: "/evcp/buyer-signature", - descriptionKey: "menu.master_data.buyer_signaturee_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.buyer_signature', + href: '/evcp/buyer-signature', + descriptionKey: 'menu.master_data.buyer_signaturee_desc', + groupKey: 'groups.procurement_info', }, { - titleKey: "menu.master_data.procurement_items", - href: "/evcp/p-items", - descriptionKey: "menu.master_data.procurement_items_desc", - groupKey: "groups.procurement_info" + titleKey: 'menu.master_data.procurement_items', + href: '/evcp/p-items', + descriptionKey: 'menu.master_data.procurement_items_desc', + groupKey: 'groups.procurement_info', }, ], }, { - titleKey: "menu.vendor_management.title", + titleKey: 'menu.vendor_management.title', useGrouping: true, items: [ { - titleKey: "menu.vendor_management.candidates", - href: "/evcp/vendor-candidates", - descriptionKey: "menu.vendor_management.candidates_desc", + titleKey: 'menu.vendor_management.candidates', + href: '/evcp/vendor-candidates', + descriptionKey: 'menu.vendor_management.candidates_desc', }, { - titleKey: "menu.vendor_management.vendors", - href: "/evcp/vendors", - descriptionKey: "menu.vendor_management.vendors_desc", + titleKey: 'menu.vendor_management.vendors', + href: '/evcp/vendors', + descriptionKey: 'menu.vendor_management.vendors_desc', }, { - titleKey: "menu.vendor_management.investigation", - href: "/evcp/vendor-investigation", - descriptionKey: "menu.vendor_management.investigation_desc", + titleKey: 'menu.vendor_management.investigation', + href: '/evcp/vendor-investigation', + descriptionKey: 'menu.vendor_management.investigation_desc', }, { - titleKey: "menu.vendor_management.evaluation", - href: "/evcp/evaluation", - descriptionKey: "menu.vendor_management.evaluation_desc", + titleKey: 'menu.vendor_management.evaluation', + href: '/evcp/evaluation', + descriptionKey: 'menu.vendor_management.evaluation_desc', }, { - titleKey: "menu.vendor_management.evaluation_input", - href: "/evcp/evaluation-input", - descriptionKey: "menu.vendor_management.evaluation_input_desc", + titleKey: 'menu.vendor_management.evaluation_input', + href: '/evcp/evaluation-input', + descriptionKey: 'menu.vendor_management.evaluation_input_desc', }, { - titleKey: "menu.vendor_management.pq_status", - href: "/evcp/pq_new", - descriptionKey: "menu.vendor_management.pq_status_desc", + titleKey: 'menu.vendor_management.pq_status', + href: '/evcp/pq_new', + descriptionKey: 'menu.vendor_management.pq_status_desc', }, { - titleKey: "menu.vendor_management.basic_contract", - href: "/evcp/basic-contract", - descriptionKey: "menu.vendor_management.basic_contract_desc", + titleKey: 'menu.vendor_management.basic_contract', + href: '/evcp/basic-contract', + descriptionKey: 'menu.vendor_management.basic_contract_desc', }, // 벤더풀 관리 { - titleKey: "menu.vendor_management.vendor_pool", - href: "/evcp/vendor-pool", - descriptionKey: "menu.vendor_management.vendor_pool_desc", + titleKey: 'menu.vendor_management.vendor_pool', + href: '/evcp/vendor-pool', + descriptionKey: 'menu.vendor_management.vendor_pool_desc', }, // avl 관리 { - titleKey: "menu.vendor_management.avl_management", - href: "/evcp/avl", - descriptionKey: "menu.vendor_management.avl_management_desc", + titleKey: 'menu.vendor_management.avl_management', + href: '/evcp/avl', + descriptionKey: 'menu.vendor_management.avl_management_desc', }, // 기존 project avl // { @@ -234,94 +234,93 @@ export const mainNav: MenuSection[] = [ // descriptionKey: "menu.vendor_management.project_avl_desc", // }, { - titleKey: "menu.vendor_management.legalReview", - href: "/evcp/legal-review", + titleKey: 'menu.vendor_management.legalReview', + href: '/evcp/legal-review', // descriptionKey: "menu.vendor_management.legalReview_desc", - groupKey: "groups.legal" - + groupKey: 'groups.legal', }, { - titleKey: "menu.vendor_management.legalResponse", - href: "/evcp/legal-response", + titleKey: 'menu.vendor_management.legalResponse', + href: '/evcp/legal-response', // descriptionKey: "menu.vendor_management.legalResponse_desc", - groupKey: "groups.legal" - + groupKey: 'groups.legal', }, { - titleKey: "menu.vendor_management.risk_by_agency", - href: "/evcp/risk-management", - descriptionKey: "menu.vendor_management.risk_by_agency_desc", - groupKey: "groups.risk_management" + titleKey: 'menu.vendor_management.risk_by_agency', + href: '/evcp/risk-management', + descriptionKey: 'menu.vendor_management.risk_by_agency_desc', + groupKey: 'groups.risk_management', }, { - titleKey: "menu.vendor_management.vendor_regular_registrations", - href: "/evcp/vendor-regular-registrations", - descriptionKey: "menu.vendor_management.vendor_regular_registrations_desc", + titleKey: 'menu.vendor_management.vendor_regular_registrations', + href: '/evcp/vendor-regular-registrations', + descriptionKey: + 'menu.vendor_management.vendor_regular_registrations_desc', }, { - titleKey: "menu.vendor_management.vendor_consent", - href: "/evcp/consent", - descriptionKey: "menu.vendor_management.vendor_consent_desc", + titleKey: 'menu.vendor_management.vendor_consent', + href: '/evcp/consent', + descriptionKey: 'menu.vendor_management.vendor_consent_desc', }, ], }, { - titleKey: "menu.tech_sales.title", + titleKey: 'menu.tech_sales.title', useGrouping: true, items: [ { - titleKey: "menu.tech_sales.items", - href: "/evcp/items-tech", - descriptionKey: "menu.tech_sales.items_desc", - groupKey: "groups.common" + titleKey: 'menu.tech_sales.items', + href: '/evcp/items-tech', + descriptionKey: 'menu.tech_sales.items_desc', + groupKey: 'groups.common', }, { - titleKey: "menu.tech_sales.contact_items", - href: "/evcp/tech-contact-possible-items", - descriptionKey: "menu.tech_sales.contact_items_desc", - groupKey: "groups.common" + titleKey: 'menu.tech_sales.contact_items', + href: '/evcp/tech-contact-possible-items', + descriptionKey: 'menu.tech_sales.contact_items_desc', + groupKey: 'groups.common', }, { - titleKey: "menu.tech_sales.vendors", - href: "/evcp/tech-vendors", - descriptionKey: "menu.tech_sales.vendors_desc", - groupKey: "groups.common" + titleKey: 'menu.tech_sales.vendors', + href: '/evcp/tech-vendors', + descriptionKey: 'menu.tech_sales.vendors_desc', + groupKey: 'groups.common', }, { - titleKey: "menu.tech_sales.result_transmission", - href: "/evcp/tech-project-avl", - descriptionKey: "menu.tech_sales.result_transmission_desc", - groupKey: "groups.common" + titleKey: 'menu.tech_sales.result_transmission', + href: '/evcp/tech-project-avl', + descriptionKey: 'menu.tech_sales.result_transmission_desc', + groupKey: 'groups.common', }, { - titleKey: "menu.tech_sales.budgetary_ship", - href: "/evcp/budgetary-tech-sales-ship", - descriptionKey: "menu.tech_sales.budgetary_ship_desc", - groupKey: "groups.rfq_management" + titleKey: 'menu.tech_sales.budgetary_ship', + href: '/evcp/budgetary-tech-sales-ship', + descriptionKey: 'menu.tech_sales.budgetary_ship_desc', + groupKey: 'groups.rfq_management', }, { - titleKey: "menu.tech_sales.budgetary_top", - href: "/evcp/budgetary-tech-sales-top", - descriptionKey: "menu.tech_sales.budgetary_top_desc", - groupKey: "groups.rfq_management" + titleKey: 'menu.tech_sales.budgetary_top', + href: '/evcp/budgetary-tech-sales-top', + descriptionKey: 'menu.tech_sales.budgetary_top_desc', + groupKey: 'groups.rfq_management', }, { - titleKey: "menu.tech_sales.budgetary_hull", - href: "/evcp/budgetary-tech-sales-hull", - descriptionKey: "menu.tech_sales.budgetary_hull_desc", - groupKey: "groups.rfq_management" + titleKey: 'menu.tech_sales.budgetary_hull', + href: '/evcp/budgetary-tech-sales-hull', + descriptionKey: 'menu.tech_sales.budgetary_hull_desc', + groupKey: 'groups.rfq_management', }, - ] + ], }, { - titleKey: "menu.procurement.title", + titleKey: 'menu.procurement.title', useGrouping: true, items: [ { - titleKey: "menu.procurement.budget_rfq", - href: "/evcp/rfq-last", + titleKey: 'menu.procurement.budget_rfq', + href: '/evcp/rfq-last', // descriptionKey: "menu.procurement.budget_rfq_desc", - groupKey: "groups.quote_bid_management" + groupKey: 'groups.quote_bid_management', }, // { @@ -331,10 +330,10 @@ export const mainNav: MenuSection[] = [ // groupKey: "groups.quote_bid_management" // }, { - titleKey: "menu.procurement.bid_management", - href: "/evcp/bid", - descriptionKey: "menu.procurement.bid_management_desc", - groupKey: "groups.quote_bid_management" + titleKey: 'menu.procurement.bid_management', + href: '/evcp/bid', + descriptionKey: 'menu.procurement.bid_management_desc', + groupKey: 'groups.quote_bid_management', }, // { // titleKey: "menu.procurement.tbe_ship", @@ -349,10 +348,10 @@ export const mainNav: MenuSection[] = [ // groupKey: "groups.evaluation_management" // }, { - titleKey: "menu.procurement.po_issuance", - href: "/evcp/po", - descriptionKey: "menu.procurement.po_issuance_desc", - groupKey: "groups.order_management" + titleKey: 'menu.procurement.po_issuance', + href: '/evcp/po', + descriptionKey: 'menu.procurement.po_issuance_desc', + groupKey: 'groups.order_management', }, // { // titleKey: "menu.procurement.po_amendment", @@ -361,124 +360,124 @@ export const mainNav: MenuSection[] = [ // groupKey: "groups.order_management" // }, { - titleKey: "menu.procurement.pcr", - href: "/evcp/pcr", - descriptionKey: "menu.procurement.pcr_desc", - groupKey: "groups.order_management" + titleKey: 'menu.procurement.pcr', + href: '/evcp/pcr', + descriptionKey: 'menu.procurement.pcr_desc', + groupKey: 'groups.order_management', }, { - titleKey: "menu.procurement.general_contract", - href: "/evcp/general-contracts", - descriptionKey: "menu.procurement.general_contract_desc", - groupKey: "groups.order_management" + titleKey: 'menu.procurement.general_contract', + href: '/evcp/general-contracts', + descriptionKey: 'menu.procurement.general_contract_desc', + groupKey: 'groups.order_management', }, ], }, { - titleKey: "menu.engineering_management.title", + titleKey: 'menu.engineering_management.title', useGrouping: true, items: [ { - titleKey: "menu.engineering_management.tbe", - href: "/evcp/tbe-last", - descriptionKey: "menu.engineering_management.tbe_desc", - groupKey: "groups.engineering_in_procurement" + titleKey: 'menu.engineering_management.tbe', + href: '/evcp/tbe-last', + descriptionKey: 'menu.engineering_management.tbe_desc', + groupKey: 'groups.engineering_in_procurement', }, { - titleKey: "menu.engineering_management.itb", - href: "/evcp/itb-create", - descriptionKey: "menu.engineering_management.itb_desc", - groupKey: "groups.engineering_in_procurement" + titleKey: 'menu.engineering_management.itb', + href: '/evcp/itb-create', + descriptionKey: 'menu.engineering_management.itb_desc', + groupKey: 'groups.engineering_in_procurement', }, { - titleKey: "menu.engineering_management.document_list_ship", - href: "/evcp/document-list-ship", - descriptionKey: "menu.engineering_management.document_list_ship_desc", - groupKey: "groups.engineering_management" + titleKey: 'menu.engineering_management.document_list_ship', + href: '/evcp/document-list-ship', + descriptionKey: 'menu.engineering_management.document_list_ship_desc', + groupKey: 'groups.engineering_management', }, { - titleKey: "menu.engineering_management.document_list_only", - href: "/evcp/document-list-only", - descriptionKey: "menu.engineering_management.document_list_only_desc", - groupKey: "groups.engineering_management" + titleKey: 'menu.engineering_management.document_list_only', + href: '/evcp/document-list-only', + descriptionKey: 'menu.engineering_management.document_list_only_desc', + groupKey: 'groups.engineering_management', }, { - titleKey: "menu.engineering_management.vendor_data", - href: "/evcp/vendor-data", - descriptionKey: "menu.engineering_management.vendor_data_desc", - groupKey: "groups.engineering_management" + titleKey: 'menu.engineering_management.vendor_data', + href: '/evcp/vendor-data', + descriptionKey: 'menu.engineering_management.vendor_data_desc', + groupKey: 'groups.engineering_management', }, { - titleKey: "menu.engineering_management.vendor_progress", - href: "/evcp/edp-progress", - descriptionKey: "menu.engineering_management.vendor_progress_desc", - groupKey: "groups.engineering_management" + titleKey: 'menu.engineering_management.vendor_progress', + href: '/evcp/edp-progress', + descriptionKey: 'menu.engineering_management.vendor_progress_desc', + groupKey: 'groups.engineering_management', }, ], }, { - titleKey: "menu.information_system.title", + titleKey: 'menu.information_system.title', useGrouping: true, items: [ { - titleKey: "menu.information_system.information", - href: "/evcp/information", - groupKey: "groups.menu" + titleKey: 'menu.information_system.information', + href: '/evcp/information', + groupKey: 'groups.menu', }, { - titleKey: "menu.information_system.notice", - href: "/evcp/notice", - groupKey: "groups.menu" + titleKey: 'menu.information_system.notice', + href: '/evcp/notice', + groupKey: 'groups.menu', }, { - titleKey: "menu.information_system.menu_list", - href: "/evcp/menu-list", - groupKey: "groups.menu" + titleKey: 'menu.information_system.menu_list', + href: '/evcp/menu-list', + groupKey: 'groups.menu', }, { - titleKey: "menu.information_system.menu_access", - href: "/evcp/menu-access", - groupKey: "groups.menu" + titleKey: 'menu.information_system.menu_access', + href: '/evcp/menu-access', + groupKey: 'groups.menu', }, { - titleKey: "menu.information_system.menu_access_dept", - href: "/evcp/menu-access-dept", - groupKey: "groups.menu" + titleKey: 'menu.information_system.menu_access_dept', + href: '/evcp/menu-access-dept', + groupKey: 'groups.menu', }, { - titleKey: "menu.information_system.integration_list", - href: "/evcp/integration", - groupKey: "groups.interface" + titleKey: 'menu.information_system.integration_list', + href: '/evcp/integration', + groupKey: 'groups.interface', }, { - titleKey: "menu.information_system.integration_log", - href: "/evcp/integration-log", - groupKey: "groups.interface" + titleKey: 'menu.information_system.integration_log', + href: '/evcp/integration-log', + groupKey: 'groups.interface', }, { - titleKey: "menu.information_system.approval_template", - href: "/evcp/approval/template", - groupKey: "groups.approval" + titleKey: 'menu.information_system.approval_template', + href: '/evcp/approval/template', + groupKey: 'groups.approval', }, { - titleKey: "menu.information_system.approval_log", - href: "/evcp/approval/log", - groupKey: "groups.approval" + titleKey: 'menu.information_system.approval_log', + href: '/evcp/approval/log', + groupKey: 'groups.approval', }, { - titleKey: "menu.information_system.approval_line", - href: "/evcp/approval/line", - groupKey: "groups.approval" + titleKey: 'menu.information_system.approval_line', + href: '/evcp/approval/line', + groupKey: 'groups.approval', }, { - titleKey: "menu.information_system.approval_after", - href: "/evcp/approval/after", - groupKey: "groups.approval" + titleKey: 'menu.information_system.approval_after', + href: '/evcp/approval/after', + groupKey: 'groups.approval', }, { - titleKey: "menu.information_system.email_template", - href: "/evcp/email-template", - groupKey: "groups.email" + titleKey: 'menu.information_system.email_template', + href: '/evcp/email-template', + groupKey: 'groups.email', }, // { // titleKey: "menu.information_system.email_receiver", @@ -486,23 +485,27 @@ export const mainNav: MenuSection[] = [ // groupKey: "groups.email" // }, { - titleKey: "menu.information_system.email_log", - href: "/evcp/email-log", - groupKey: "groups.email" + titleKey: 'menu.information_system.email_log', + href: '/evcp/email-log', + groupKey: 'groups.email', + }, + { + titleKey: 'menu.information_system.email_whitelist', + href: '/evcp/email-whitelist', + groupKey: 'groups.email', }, { - titleKey: "menu.information_system.login_history", - href: "/evcp/login-history", - groupKey: "groups.access_history" + titleKey: 'menu.information_system.login_history', + href: '/evcp/login-history', + groupKey: 'groups.access_history', }, { - titleKey: "menu.information_system.page_visits", - href: "/evcp/page-visits", - groupKey: "groups.access_history" + titleKey: 'menu.information_system.page_visits', + href: '/evcp/page-visits', + groupKey: 'groups.access_history', }, ], }, - ]; // 구매 관리 전용 네비게이션 diff --git a/db/schema/emailWhitelist.ts b/db/schema/emailWhitelist.ts new file mode 100644 index 00000000..485246ee --- /dev/null +++ b/db/schema/emailWhitelist.ts @@ -0,0 +1,27 @@ +/** + * 이메일 화이트리스트 관리 + * + * 도메인(domain) 또는 개별 이메일(email) + * - domain: 도메인 전체 화이트리스트용 (예: company.com) + * - email: 개별 이메일 화이트리스트용 (예: user@company.com) + * 설명(description) + * 생성일(createdAt) + * 생성자(createdBy) + * 수정일(updatedAt) + * 수정자(updatedBy) + * + */ + +import { pgTable, text, integer, serial, varchar, timestamp } from 'drizzle-orm/pg-core'; +import { users } from './users'; + +export const emailWhitelist = pgTable("email_whitelist", { + id: serial("id").primaryKey(), + domain: varchar("domain", { length: 255 }), // 도메인 전체용 (nullable) + email: varchar("email", { length: 255 }), // 개별 이메일용 (nullable) + description: text("description"), + createdAt: timestamp("created_at").defaultNow().notNull(), + createdBy: integer("created_by").references(() => users.id), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + updatedBy: integer("updated_by").references(() => users.id), +}); diff --git a/db/schema/index.ts b/db/schema/index.ts index 384c6e9c..0e3daf80 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -81,4 +81,5 @@ export * from './S_ERP/s_erp'; export * from './avl/avl'; export * from './avl/vendor-pool'; // === Email Logs 스키마 === -export * from './emailLogs'; \ No newline at end of file +export * from './emailLogs'; +export * from './emailWhitelist'; \ No newline at end of file diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json index b0ddeacd..93cc538f 100644 --- a/i18n/locales/en/menu.json +++ b/i18n/locales/en/menu.json @@ -195,6 +195,7 @@ "email_template": "Email Template Management", "email_receiver": "Email Recipient Management", "email_log": "Email Transmission History Inquiry", + "email_whitelist": "Send Email OTP Whitelist Management", "login_history": "Login/Logout History Inquiry", "page_visits": "Page Access History Inquiry" diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json index 467123f2..fb3c4e7a 100644 --- a/i18n/locales/ko/menu.json +++ b/i18n/locales/ko/menu.json @@ -196,6 +196,7 @@ "email_template": "이메일 서식 관리", "email_receiver": "이메일 수신인 관리", "email_log": "이메일 발신 이력 조회", + "email_whitelist": "이메일 OTP 화이트리스트 관리", "login_history": "로그인/아웃 이력 조회", "page_visits": "페이지 접속 이력 조회" }, diff --git a/lib/email-whitelist/service.ts b/lib/email-whitelist/service.ts new file mode 100644 index 00000000..d7e968b0 --- /dev/null +++ b/lib/email-whitelist/service.ts @@ -0,0 +1,577 @@ +'use server'; + +/* IMPORT */ +import { and, asc, count, desc, eq, ilike, or, sql, type SQL, type InferSelectModel, inArray } from 'drizzle-orm'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import db from '@/db/db'; +import { getServerSession } from 'next-auth'; +import { type Filter } from '@/types/table'; +import { + emailWhitelist, +} from '@/db/schema/emailWhitelist'; +import { users } from '@/db/schema/users'; +import { revalidatePath } from 'next/cache'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +export type EmailWhitelist = InferSelectModel & { + createdByName?: string; + updatedByName?: string; + type?: 'domain' | 'email'; // 표시용 타입 + displayValue?: string; // 표시할 값 (domain 또는 email) +} + +// =========================================== +// Helper Functions +// =========================================== + +/** + * 이메일 유효성 검증 + */ +function validateEmail(email: string): { isValid: boolean; error?: string } { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!emailRegex.test(email)) { + return { isValid: false, error: '올바른 이메일 형식이 아닙니다.' }; + } + + // 길이 제한 + if (email.length > 255) { + return { isValid: false, error: '이메일은 255자를 초과할 수 없습니다.' }; + } + + return { isValid: true }; +} + +/** + * 도메인 유효성 검증 + */ +function validateDomain(domain: string): { isValid: boolean; error?: string } { + // 도메인 검증: 최소 하나의 .이 있어야 하고, TLD가 있어야 함 + // 예: company.com, sub.company.com, company.co.kr + const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/; + + if (!domainRegex.test(domain)) { + return { isValid: false, error: '올바른 도메인 형식이 아닙니다. 최소 1개의 TLD가 필요합니다.' }; + } + + // 길이 제한 + if (domain.length > 255) { + return { isValid: false, error: '도메인은 255자를 초과할 수 없습니다.' }; + } + + return { isValid: true }; +} + +/** + * 입력값이 이메일인지 도메인인지 판별 + */ +function determineType(value: string): 'email' | 'domain' { + return value.includes('@') ? 'email' : 'domain'; +} + +/** + * 화이트리스트 항목 중복 검증 + */ +async function checkWhitelistExists(value: string, type: 'email' | 'domain', excludeId?: number): Promise { + let whereCondition; + + if (type === 'email') { + whereCondition = eq(emailWhitelist.email, value); + } else { + whereCondition = eq(emailWhitelist.domain, value); + } + + const existing = await db + .select({ id: emailWhitelist.id }) + .from(emailWhitelist) + .where(whereCondition) + .limit(1); + + if (existing.length === 0) return false; + if (excludeId && existing[0].id === excludeId) return false; + + return true; +} + +// =========================================== +// CRUD Operations +// =========================================== + +/** + * 이메일 화이트리스트 목록 조회 + */ +export async function getEmailWhitelistList({ + page = 1, + perPage = 10, + search, + sort = 'createdAt.desc', + filters, +}: { + page?: number; + perPage?: number; + search?: string; + sort?: string; + filters?: Filter[]; +} = {}) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error('인증이 필요합니다.'); + } + + const offset = (page - 1) * perPage; + + // 정렬 처리 + let orderBy: SQL; + const [sortField, sortOrder] = sort.split('.'); + + // 계산된 필드에 대한 정렬 매핑 + const sortFieldMapping: Record = { + 'displayValue': 'domain', // displayValue는 domain 우선으로 정렬 + 'createdByName': 'createdAt', // 이름 정렬 대신 생성일로 정렬 + 'updatedByName': 'updatedAt', // 이름 정렬 대신 수정일로 정렬 + }; + + const actualSortField = sortFieldMapping[sortField] || sortField; + + if (actualSortField === 'domain') { + orderBy = sortOrder === 'desc' ? desc(emailWhitelist.domain) : asc(emailWhitelist.domain); + } else if (actualSortField === 'email') { + orderBy = sortOrder === 'desc' ? desc(emailWhitelist.email) : asc(emailWhitelist.email); + } else if (actualSortField === 'description') { + orderBy = sortOrder === 'desc' ? desc(emailWhitelist.description) : asc(emailWhitelist.description); + } else if (actualSortField === 'createdAt') { + orderBy = sortOrder === 'desc' ? desc(emailWhitelist.createdAt) : asc(emailWhitelist.createdAt); + } else if (actualSortField === 'updatedAt') { + orderBy = sortOrder === 'desc' ? desc(emailWhitelist.updatedAt) : asc(emailWhitelist.updatedAt); + } else { + orderBy = desc(emailWhitelist.createdAt); + } + + // 필터 조건들 + const filterConditions: SQL[] = []; + + // 검색 필터 + if (search) { + const searchCondition = or( + ilike(emailWhitelist.domain, `%${search}%`), + ilike(emailWhitelist.email, `%${search}%`), + ilike(emailWhitelist.description, `%${search}%`) + ) as SQL; + filterConditions.push(searchCondition); + } + + // 고급 필터 + if (filters && filters.length > 0) { + for (const filter of filters) { + const { id, value, operator = 'iLike' } = filter; + + // string[] 타입은 지원하지 않음 (select 필터용) + if (!value || typeof value !== 'string') continue; + + let condition: SQL | undefined; + + switch (id) { + case 'displayValue': + // displayValue는 domain이나 email 중 하나를 포함하므로 둘 다 검색 + if (operator === 'iLike') { + condition = or( + ilike(emailWhitelist.domain, `%${value}%`), + ilike(emailWhitelist.email, `%${value}%`) + ); + } else if (operator === 'eq') { + condition = or( + eq(emailWhitelist.domain, value), + eq(emailWhitelist.email, value) + ); + } + break; + case 'domain': + if (operator === 'iLike') { + condition = ilike(emailWhitelist.domain, `%${value}%`); + } else if (operator === 'eq') { + condition = eq(emailWhitelist.domain, value); + } + break; + case 'email': + if (operator === 'iLike') { + condition = ilike(emailWhitelist.email, `%${value}%`); + } else if (operator === 'eq') { + condition = eq(emailWhitelist.email, value); + } + break; + case 'description': + if (operator === 'iLike') { + condition = ilike(emailWhitelist.description, `%${value}%`); + } else if (operator === 'eq') { + condition = eq(emailWhitelist.description, value); + } + break; + case 'createdAt': + if (operator === 'gte' && value) { + condition = sql`${emailWhitelist.createdAt} >= ${value}`; + } else if (operator === 'lte' && value) { + condition = sql`${emailWhitelist.createdAt} <= ${value}`; + } + break; + case 'updatedAt': + if (operator === 'gte' && value) { + condition = sql`${emailWhitelist.updatedAt} >= ${value}`; + } else if (operator === 'lte' && value) { + condition = sql`${emailWhitelist.updatedAt} <= ${value}`; + } + break; + } + + if (condition) { + filterConditions.push(condition); + } + } + } + + // 데이터 조회 (기본 데이터) + const query = db + .select() + .from(emailWhitelist) + + if (filterConditions.length > 0) { + query.where(and(...filterConditions)) + } + + const rawData = await query + .orderBy(orderBy) + .limit(perPage) + .offset(offset); + + // 사용자 ID들 수집 + const userIds = new Set(); + rawData.forEach(item => { + if (item.createdBy) userIds.add(item.createdBy); + if (item.updatedBy) userIds.add(item.updatedBy); + }); + + // 사용자 정보 조회 + const userMap = new Map(); + if (userIds.size > 0) { + const userData = await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(inArray(users.id, Array.from(userIds))); + + userData.forEach(user => { + userMap.set(user.id, user.name); + }); + } + + // 데이터에 사용자 이름과 타입 정보 추가 + const data: EmailWhitelist[] = rawData.map(item => { + const displayValue = item.email || item.domain || ''; + const type = displayValue.includes('@') ? 'email' : 'domain'; + + return { + ...item, + createdByName: item.createdBy ? userMap.get(item.createdBy) : undefined, + updatedByName: item.updatedBy ? userMap.get(item.updatedBy) : undefined, + type, + displayValue, + }; + }); + + // 전체 개수 조회 + const countQuery = db + .select({ count: count() }) + .from(emailWhitelist) + + if (filterConditions.length > 0) { + countQuery.where(and(...filterConditions)) + } + + const totalResult = await countQuery; + + const total = totalResult[0].count; + const pageCount = Math.ceil(total / perPage); + + return { + data, + pageCount, + total, + }; + } catch (error) { + throw new Error(error instanceof Error ? error.message : '목록 조회에 실패했습니다.'); + } +} + +/** + * 이메일 화이트리스트 생성 + */ +export async function createEmailWhitelistAction(data: { + value: string; // 이메일 주소 또는 도메인 + description?: string; +}) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { + success: false, + error: '인증이 필요합니다.' + }; + } + + const type = determineType(data.value); + + // 유효성 검증 + let validation; + if (type === 'email') { + validation = validateEmail(data.value); + } else { + validation = validateDomain(data.value); + } + + if (!validation.isValid) { + return { + success: false, + error: validation.error + }; + } + + // 중복 검증 + const exists = await checkWhitelistExists(data.value.toLowerCase(), type); + if (exists) { + return { + success: false, + error: type === 'email' ? '이미 등록된 이메일입니다.' : '이미 등록된 도메인입니다.' + }; + } + + // 생성 + const insertData: { + description?: string; + createdBy: number; + updatedBy: number; + domain?: string | null; + email?: string | null; + } = { + description: data.description, + createdBy: Number(session.user.id), + updatedBy: Number(session.user.id), + domain: null, + email: null, + }; + + if (type === 'email') { + insertData.email = data.value.toLowerCase(); + } else { + insertData.domain = data.value.toLowerCase(); + } + + const [result] = await db + .insert(emailWhitelist) + .values(insertData) + .returning(); + + revalidatePath('/evcp/system/whitelist'); + + return { + success: true, + data: result + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '생성에 실패했습니다.' + }; + } +} + +/** + * 이메일 화이트리스트 수정 + */ +export async function updateEmailWhitelistAction(data: { + id: number; + value: string; // 이메일 주소 또는 도메인 + description?: string; +}) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { + success: false, + error: '인증이 필요합니다.' + }; + } + + const type = determineType(data.value); + + // 유효성 검증 + let validation; + if (type === 'email') { + validation = validateEmail(data.value); + } else { + validation = validateDomain(data.value); + } + + if (!validation.isValid) { + return { + success: false, + error: validation.error + }; + } + + // 중복 검증 + const exists = await checkWhitelistExists(data.value.toLowerCase(), type, data.id); + if (exists) { + return { + success: false, + error: type === 'email' ? '이미 등록된 이메일입니다.' : '이미 등록된 도메인입니다.' + }; + } + + // 수정 + const updateData: { + description?: string; + updatedBy: number; + updatedAt: ReturnType; + domain: string | null; + email: string | null; + } = { + description: data.description, + updatedBy: Number(session.user.id), + updatedAt: sql`NOW()`, + domain: null, + email: null, + }; + + if (type === 'email') { + updateData.email = data.value.toLowerCase(); + } else { + updateData.domain = data.value.toLowerCase(); + } + + const [result] = await db + .update(emailWhitelist) + .set(updateData) + .where(eq(emailWhitelist.id, data.id)) + .returning(); + + if (!result) { + return { + success: false, + error: '수정할 데이터를 찾을 수 없습니다.' + }; + } + + revalidatePath('/evcp/system/whitelist'); + + return { + success: true, + data: result + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '수정에 실패했습니다.' + }; + } +} + +/** + * 이메일 화이트리스트 삭제 + */ +export async function deleteEmailWhitelistAction(ids: number[]) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { + success: false, + error: '인증이 필요합니다.' + }; + } + + // 삭제 + await db + .delete(emailWhitelist) + .where(inArray(emailWhitelist.id, ids)); + + revalidatePath('/evcp/system/whitelist'); + + return { + success: true + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '삭제에 실패했습니다.' + }; + } +} + +/** + * 단일 이메일 화이트리스트 조회 + */ +export async function getEmailWhitelistById(id: number) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error('인증이 필요합니다.'); + } + + const [result] = await db + .select() + .from(emailWhitelist) + .where(eq(emailWhitelist.id, id)) + .limit(1); + + if (!result) { + throw new Error('데이터를 찾을 수 없습니다.'); + } + + return result; + } catch (error) { + throw new Error(error instanceof Error ? error.message : '조회에 실패했습니다.'); + } +} + +/** + * 이메일 주소가 화이트리스트에 등록되어 있는지 확인 + * @param email - 확인할 이메일 주소 + * @returns 화이트리스트에 등록되어 있으면 true, 아니면 false + */ +export async function isEmailWhitelisted(email: string): Promise { + try { + if (!email) return false; + + const normalizedEmail = email.toLowerCase().trim(); + + // 이메일에서 도메인 추출 + const emailParts = normalizedEmail.split('@'); + if (emailParts.length !== 2) return false; + + const domain = emailParts[1]; + + // 1. 개별 이메일이 화이트리스트에 있는지 확인 + const exactEmailMatch = await db + .select({ id: emailWhitelist.id }) + .from(emailWhitelist) + .where(eq(emailWhitelist.email, normalizedEmail)) + .limit(1); + + if (exactEmailMatch.length > 0) { + return true; + } + + // 2. 도메인이 화이트리스트에 있는지 확인 + const domainMatch = await db + .select({ id: emailWhitelist.id }) + .from(emailWhitelist) + .where(eq(emailWhitelist.domain, domain)) + .limit(1); + + return domainMatch.length > 0; + } catch (error) { + console.error('화이트리스트 확인 중 오류:', error); + // 오류 발생 시 안전하게 false 반환 (기본 SMS 인증 사용) + return false; + } +} diff --git a/lib/email-whitelist/table/create-whitelist-dialog.tsx b/lib/email-whitelist/table/create-whitelist-dialog.tsx new file mode 100644 index 00000000..d82ac168 --- /dev/null +++ b/lib/email-whitelist/table/create-whitelist-dialog.tsx @@ -0,0 +1,179 @@ +'use client' + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +import { createEmailWhitelistAction } from "../service" + +// Validation Schema +const createWhitelistSchema = z.object({ + value: z.string() + .min(1, "값은 필수입니다") + .max(255, "값은 255자를 초과할 수 없습니다") + .refine((value) => { + // 이메일 형식 또는 도메인 형식 중 하나여야 함 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + // 도메인 검증: 최소 하나의 .이 있어야 하고, TLD가 있어야 함 + // 예: company.com, sub.company.com, company.co.kr + const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/; + + return emailRegex.test(value) || domainRegex.test(value); + }, "올바른 이메일 주소 또는 도메인 형식이 아닙니다 (도메인은 최소 1개의 TLD가 필요합니다)"), + description: z.string().max(500, "설명은 500자 이하여야 합니다").optional(), +}) + +type CreateWhitelistSchema = z.infer + +interface CreateWhitelistDialogProps + extends Omit, 'children'> { + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export function CreateWhitelistDialog({ ...props }: CreateWhitelistDialogProps) { + const [isCreatePending, startCreateTransition] = React.useTransition() + const { data: session } = useSession(); + + const form = useForm({ + resolver: zodResolver(createWhitelistSchema), + defaultValues: { + value: "", + description: "", + }, + }) + + // 입력값 소문자 변환 + React.useEffect(() => { + const watchedValue = form.watch("value") + if (watchedValue && watchedValue !== watchedValue.toLowerCase()) { + form.setValue("value", watchedValue.toLowerCase()) + } + }, [form]) + + function onSubmit(input: CreateWhitelistSchema) { + startCreateTransition(async () => { + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + const { error } = await createEmailWhitelistAction({ + value: input.value, + description: input.description, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("화이트리스트 도메인이 추가되었습니다") + }) + } + + return ( + + + + 화이트리스트 추가 + + SMS 인증을 우회할 이메일 주소 또는 도메인을 추가합니다. 등록된 이메일이나 도메인의 주소로 로그인 시 SMS 인증을 건너뜁니다. + + +
+ + ( + + 이메일 주소 또는 도메인 + + + + + 이메일 주소나 도메인을 입력하세요. @가 포함되면 개별 이메일로, 그렇지 않으면 도메인 전체로 등록됩니다. 도메인은 최소 1개의 TLD가 필요합니다. + + + + )} + /> + + ( + + 설명 (선택사항) + +