summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/auth/reset-password/page.tsx47
-rw-r--r--app/[lng]/evcp/(evcp)/b-rfq/page.tsx3
-rw-r--r--app/[lng]/evcp/(evcp)/evaluation-check-list/page.tsx18
-rw-r--r--app/[lng]/evcp/(evcp)/evaluation-target-list/page.tsx65
-rw-r--r--app/[lng]/evcp/(evcp)/evaluation/page.tsx19
-rw-r--r--app/[lng]/evcp/(evcp)/system/layout.tsx11
-rw-r--r--app/[lng]/evcp/(evcp)/system/password-policy/page.tsx63
-rw-r--r--app/[lng]/partners/(partners)/document-list/layout.tsx5
-rw-r--r--app/[lng]/partners/(partners)/settings/layout.tsx68
-rw-r--r--app/[lng]/partners/(partners)/settings/page.tsx224
-rw-r--r--app/[lng]/partners/(partners)/settings/preferences/page.tsx17
-rw-r--r--app/[lng]/partners/(partners)/system/layout.tsx71
-rw-r--r--app/[lng]/partners/(partners)/system/page.tsx62
-rw-r--r--app/[lng]/partners/(partners)/system/permissions/page.tsx17
-rw-r--r--app/[lng]/partners/(partners)/system/roles/page.tsx68
-rw-r--r--app/[lng]/privacy/page.tsx5
-rw-r--r--app/api/auth/[...nextauth]/route.ts238
-rw-r--r--app/api/auth/send-sms/route.ts75
-rw-r--r--app/api/auth/verify-mfa/route.ts65
-rw-r--r--app/api/cron/cleanup-users/route.ts40
-rw-r--r--app/api/revision-upload-ship/route.ts2
-rw-r--r--app/layout.tsx75
22 files changed, 1074 insertions, 184 deletions
diff --git a/app/[lng]/auth/reset-password/page.tsx b/app/[lng]/auth/reset-password/page.tsx
new file mode 100644
index 00000000..f49e5d86
--- /dev/null
+++ b/app/[lng]/auth/reset-password/page.tsx
@@ -0,0 +1,47 @@
+// app/[lng]/auth/reset-password/page.tsx
+
+import { redirect } from 'next/navigation';
+import { validateResetTokenAction } from '@/lib/users/auth/partners-auth';
+import InvalidTokenPage from '@/components/login/InvalidTokenPage';
+import ResetPasswordForm from '@/components/login/reset-password';
+import { getPasswordPolicy } from '@/lib/users/auth/passwordUtil';
+
+interface Props {
+ searchParams: { token?: string };
+}
+
+export default async function ResetPasswordPage({ searchParams }: Props) {
+ const token = searchParams.token;
+
+ // 토큰이 없는 경우 로그인 페이지로 리다이렉트
+ if (!token) {
+ redirect('/partners');
+ }
+
+ // 서버에서 토큰 검증
+ const tokenValidation = await validateResetTokenAction(token);
+
+ // 토큰이 유효하지 않은 경우
+ if (!tokenValidation.valid) {
+ return (
+ <InvalidTokenPage
+ expired={tokenValidation.expired || false}
+ error={tokenValidation.error}
+ />
+ );
+ }
+
+ // 패스워드 정책 로드
+ const passwordPolicy = await getPasswordPolicy();
+
+ // 유효한 토큰인 경우 폼 표시
+ return (
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
+ <ResetPasswordForm
+ token={token}
+ userId={tokenValidation.userId!}
+ passwordPolicy={passwordPolicy}
+ />
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/b-rfq/page.tsx b/app/[lng]/evcp/(evcp)/b-rfq/page.tsx
index 213e9127..a66d7b58 100644
--- a/app/[lng]/evcp/(evcp)/b-rfq/page.tsx
+++ b/app/[lng]/evcp/(evcp)/b-rfq/page.tsx
@@ -46,6 +46,8 @@ export default async function PQReviewPage(props: PQReviewPageProps) {
})
])
+ console.log(search, "견적")
+
return (
<Shell className="gap-4">
<div className="flex items-center justify-between space-y-2">
@@ -60,7 +62,6 @@ export default async function PQReviewPage(props: PQReviewPageProps) {
{/* Items처럼 직접 테이블 렌더링 */}
<React.Suspense
- key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링
fallback={
<DataTableSkeleton
columnCount={8}
diff --git a/app/[lng]/evcp/(evcp)/evaluation-check-list/page.tsx b/app/[lng]/evcp/(evcp)/evaluation-check-list/page.tsx
index 398005fa..a660c492 100644
--- a/app/[lng]/evcp/(evcp)/evaluation-check-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/evaluation-check-list/page.tsx
@@ -32,6 +32,24 @@ async function EvaluationCriteriaPage(props: EvaluationCriteriaPageProps) {
return (
<Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 협력업체 평가기준표
+ </h2>
+ <p className="text-muted-foreground">
+ 협력업체 평가에 사용되는 평가기준표를 관리{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
<Suspense fallback={<Skeleton className="h-7 w-52" />}>
{/* <DateRangePicker
triggerSize="sm"
diff --git a/app/[lng]/evcp/(evcp)/evaluation-target-list/page.tsx b/app/[lng]/evcp/(evcp)/evaluation-target-list/page.tsx
index d60f695a..088ae75b 100644
--- a/app/[lng]/evcp/(evcp)/evaluation-target-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/evaluation-target-list/page.tsx
@@ -26,66 +26,7 @@ interface EvaluationTargetsPageProps {
searchParams: Promise<SearchParams>
}
-// 프로세스 안내 팝오버 컴포넌트
-function ProcessGuidePopover() {
- return (
- <Popover>
- <PopoverTrigger asChild>
- <Button variant="ghost" size="icon" className="h-6 w-6">
- <HelpCircle className="h-4 w-4 text-muted-foreground" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-96" align="start">
- <div className="space-y-3">
- <div className="space-y-1">
- <h4 className="font-medium">평가 대상 확정 프로세스</h4>
- <p className="text-sm text-muted-foreground">
- 발주실적을 기반으로 평가 대상을 확정하는 절차입니다.
- </p>
- </div>
- <div className="space-y-3 text-sm">
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 1
- </div>
- <div>
- <p className="font-medium">발주실적 기반 자동 추출</p>
- <p className="text-muted-foreground">전년도 10월 ~ 해당년도 9월 발주실적에서 업체 목록을 자동으로 생성합니다.</p>
- </div>
- </div>
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 2
- </div>
- <div>
- <p className="font-medium">담당자 지정</p>
- <p className="text-muted-foreground">각 평가 대상별로 5개 부서(발주/조달/품질/설계/CS)의 담당자를 지정합니다.</p>
- </div>
- </div>
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 3
- </div>
- <div>
- <p className="font-medium">검토 및 의견 수렴</p>
- <p className="text-muted-foreground">모든 담당자가 평가 대상 적합성을 검토하고 의견을 제출합니다.</p>
- </div>
- </div>
- <div className="flex gap-3">
- <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
- 4
- </div>
- <div>
- <p className="font-medium">최종 확정</p>
- <p className="text-muted-foreground">모든 담당자 의견이 일치하면 평가 대상으로 최종 확정됩니다.</p>
- </div>
- </div>
- </div>
- </div>
- </PopoverContent>
- </Popover>
- )
-}
+
export default async function EvaluationTargetsPage(props: EvaluationTargetsPageProps) {
const searchParams = await props.searchParams
@@ -131,7 +72,7 @@ export default async function EvaluationTargetsPage(props: EvaluationTargetsPage
<Badge variant="outline" className="text-sm">
{currentEvaluationYear}년도
</Badge>
- <ProcessGuidePopover />
+
</div>
</div>
</div>
@@ -162,10 +103,12 @@ export default async function EvaluationTargetsPage(props: EvaluationTargetsPage
/>
}
>
+ {currentEvaluationYear &&
<EvaluationTargetsTable
promises={promises}
evaluationYear={currentEvaluationYear}
/>
+}
</React.Suspense>
</Shell>
)
diff --git a/app/[lng]/evcp/(evcp)/evaluation/page.tsx b/app/[lng]/evcp/(evcp)/evaluation/page.tsx
index 3ae3272a..ead61077 100644
--- a/app/[lng]/evcp/(evcp)/evaluation/page.tsx
+++ b/app/[lng]/evcp/(evcp)/evaluation/page.tsx
@@ -17,6 +17,8 @@ import {
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { PeriodicEvaluationsTable } from "@/lib/evaluation/table/evaluation-table"
+import { getPeriodicEvaluations } from "@/lib/evaluation/service"
+import { searchParamsEvaluationsCache } from "@/lib/evaluation/validation"
export const metadata: Metadata = {
title: "협력업체 정기평가",
@@ -93,25 +95,11 @@ function getDefaultEvaluationYear() {
return new Date().getFullYear()
}
-function searchParamsPeriodicEvaluationsCache() {
- // TODO: 실제 파서 구현
- return {
- parse: (params: any) => params
- }
-}
-async function getPeriodicEvaluations(params: any) {
- // TODO: 실제 API 호출 구현
- return {
- data: [],
- total: 0,
- pageCount: 0
- }
-}
export default async function PeriodicEvaluationsPage(props: PeriodicEvaluationsPageProps) {
const searchParams = await props.searchParams
- const search = searchParamsPeriodicEvaluationsCache().parse(searchParams)
+ const search = searchParamsEvaluationsCache.parse(searchParams)
const validFilters = getValidFilters(search.filters || [])
// 기본 필터 처리
@@ -150,7 +138,6 @@ export default async function PeriodicEvaluationsPage(props: PeriodicEvaluations
<Badge variant="outline" className="text-sm">
{currentEvaluationYear}년도
</Badge>
- <ProcessGuidePopover />
</div>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/system/layout.tsx b/app/[lng]/evcp/(evcp)/system/layout.tsx
index 62f3e845..7e8f69d0 100644
--- a/app/[lng]/evcp/(evcp)/system/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/system/layout.tsx
@@ -28,7 +28,7 @@ export default async function SettingsLayout({
const sidebarNavItems = [
{
- title: "SHI Users",
+ title: "삼성중공업 사용자",
href: `/${lng}/evcp/system`,
},
{
@@ -36,13 +36,18 @@ export default async function SettingsLayout({
href: `/${lng}/evcp/system/roles`,
},
{
- title: "Permissions",
+ title: "권한 통제",
href: `/${lng}/evcp/system/permissions`,
},
{
- title: "Vendor Users",
+ title: "협력업체 사용자",
href: `/${lng}/evcp/system/admin-users`,
},
+
+ {
+ title: "비밀번호 정책",
+ href: `/${lng}/evcp/system/password-policy`,
+ },
]
diff --git a/app/[lng]/evcp/(evcp)/system/password-policy/page.tsx b/app/[lng]/evcp/(evcp)/system/password-policy/page.tsx
new file mode 100644
index 00000000..0f14fefe
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/system/password-policy/page.tsx
@@ -0,0 +1,63 @@
+// app/admin/password-policy/page.tsx
+
+import * as React from "react"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Separator } from "@/components/ui/separator"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { AlertTriangle } from "lucide-react"
+import SecuritySettingsTable from "@/components/system/passwordPolicy"
+import { getSecuritySettings } from "@/lib/password-policy/service"
+
+
+export default async function PasswordPolicyPage() {
+ try {
+ // 보안 설정 데이터 로드
+ const securitySettings = await getSecuritySettings()
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={4}
+ searchableColumnCount={0}
+ filterableColumnCount={0}
+ cellWidths={["20rem", "30rem", "15rem", "10rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3>
+ <p className="text-sm text-muted-foreground">
+ 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <SecuritySettingsTable initialSettings={securitySettings} />
+ </div>
+ </React.Suspense>
+ )
+ } catch (error) {
+ console.error('Failed to load security settings:', error)
+
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3>
+ <p className="text-sm text-muted-foreground">
+ 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <Alert variant="destructive">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ 보안 설정을 불러오는 중 오류가 발생했습니다. 페이지를 새로고침하거나 관리자에게 문의하세요.
+ </AlertDescription>
+ </Alert>
+ </div>
+ )
+ }
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/document-list/layout.tsx b/app/[lng]/partners/(partners)/document-list/layout.tsx
index 0eb9d27b..8d486113 100644
--- a/app/[lng]/partners/(partners)/document-list/layout.tsx
+++ b/app/[lng]/partners/(partners)/document-list/layout.tsx
@@ -22,7 +22,8 @@ export default async function VendorDocuments({
// const vendorId = "17"
const idAsNumber = Number(vendorId)
- const projects = await getVendorProjectsAndContracts(idAsNumber)
+ const projects = await getVendorProjectsAndContracts(idAsNumber);
+ const filteredProjects = projects.filter(v=>v.projectType === "plant")
// 레이아웃 설정 쿠키 가져오기
@@ -39,7 +40,7 @@ export default async function VendorDocuments({
return (
<Shell className="gap-2">
- <VendorDocumentListClient projects={projects}>
+ <VendorDocumentListClient projects={filteredProjects}>
{children}
</VendorDocumentListClient>
</Shell>
diff --git a/app/[lng]/partners/(partners)/settings/layout.tsx b/app/[lng]/partners/(partners)/settings/layout.tsx
new file mode 100644
index 00000000..6f373567
--- /dev/null
+++ b/app/[lng]/partners/(partners)/settings/layout.tsx
@@ -0,0 +1,68 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+
+export const metadata: Metadata = {
+ title: "Settings",
+ // description: "Advanced form example using react-hook-form and Zod.",
+}
+
+
+interface SettingsLayoutProps {
+ children: React.ReactNode
+ params: { lng: string }
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string }
+}) {
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+
+
+ const sidebarNavItems = [
+
+ {
+ title: "Account",
+ href: `/${lng}/evcp/settings`,
+ },
+ {
+ title: "Preferences",
+ href: `/${lng}/evcp/settings/preferences`,
+ }
+
+
+ ]
+
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">Settings</h2>
+ <p className="text-muted-foreground">
+ Manage your account settings and preferences.
+ </p>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1 ">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+
+
+ </>
+ )
+}
diff --git a/app/[lng]/partners/(partners)/settings/page.tsx b/app/[lng]/partners/(partners)/settings/page.tsx
new file mode 100644
index 00000000..d831f0f4
--- /dev/null
+++ b/app/[lng]/partners/(partners)/settings/page.tsx
@@ -0,0 +1,224 @@
+// app/settings/page.tsx (인증 방식 기반 개선된 버전)
+"use client"
+
+import { Separator } from "@/components/ui/separator"
+import { AccountForm } from "@/components/settings/account-form"
+import { SimpleReAuthModal } from "@/components/auth/simple-reauth-modal"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { useSettingsAccess } from "@/hooks/use-settings-access"
+import { Shield, User, Building, ArrowLeft, Mail, Key, Smartphone } from "lucide-react"
+import { useRouter } from "next/navigation"
+import React from "react"
+
+// 인증 방식별 아이콘과 라벨
+const authMethodConfig = {
+ otp: { icon: Smartphone, label: "OTP Authentication", color: "bg-blue-50 text-blue-700 border-blue-300" },
+ email: { icon: Mail, label: "Email Authentication", color: "bg-gray-50 text-gray-700 border-gray-300" },
+ sgips: { icon: Building, label: "S-Gips Enterprise", color: "bg-purple-50 text-purple-700 border-purple-300" },
+ saml: { icon: Key, label: "SAML SSO", color: "bg-green-50 text-green-700 border-green-300" },
+}
+
+export default function SettingsAccountPage() {
+ const router = useRouter()
+ const {
+ accessType,
+ showReAuthModal,
+ isAuthenticated,
+ userEmail,
+ userId,
+ userDomain,
+ authMethod,
+ handleReAuthSuccess,
+ forceReAuth,
+ } = useSettingsAccess({
+ validDuration: 5 * 60 * 1000, // 5분
+ sgipsRedirectPath: "/partners/dashboard",
+ })
+
+ // 로딩 상태
+ if (accessType === 'loading') {
+ return (
+ <div className="space-y-6">
+ <div>
+ <Skeleton className="h-7 w-24" />
+ <Skeleton className="h-4 w-96 mt-2" />
+ </div>
+ <Separator />
+ <div className="space-y-4">
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ </div>
+ </div>
+ )
+ }
+
+ // 인증되지 않은 상태
+ if (accessType === 'unauthenticated') {
+ return (
+ <div className="text-center py-12">
+ <div className="mx-auto h-12 w-12 rounded-full bg-red-100 flex items-center justify-center mb-4">
+ <User className="h-6 w-6 text-red-600" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">Authentication Required</h3>
+ <p className="text-muted-foreground mb-4">
+ Please sign in to access account settings.
+ </p>
+ <Button onClick={() => router.push("/auth/login")}>
+ Sign In
+ </Button>
+ </div>
+ )
+ }
+
+ // S-Gips 사용자 접근 차단
+ if (accessType === 'blocked_sgips') {
+ return (
+ <div className="text-center py-12">
+ <div className="mx-auto h-12 w-12 rounded-full bg-purple-100 flex items-center justify-center mb-4">
+ <Building className="h-6 w-6 text-purple-600" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">Enterprise Account</h3>
+ <p className="text-muted-foreground mb-2">
+ Your account is managed through S-Gips enterprise system.
+ </p>
+ <p className="text-sm text-muted-foreground mb-4">
+ Domain: <span className="font-medium">{userDomain}</span>
+ </p>
+ <Button
+ onClick={() => router.push("/dashboard")}
+ className="flex items-center gap-2"
+ >
+ <ArrowLeft className="h-4 w-4" />
+ Back to Dashboard
+ </Button>
+ </div>
+ )
+ }
+
+ // 재인증 대기 상태
+ if (accessType === 'reauth_required') {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Account Settings</h3>
+ <p className="text-sm text-muted-foreground">
+ Update your account settings and manage your profile information.
+ </p>
+ </div>
+ <Separator />
+
+ <div className="text-center py-12">
+ <div className="mx-auto h-16 w-16 rounded-full bg-amber-100 flex items-center justify-center mb-4">
+ <Shield className="h-8 w-8 text-amber-600 animate-pulse" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">Security Verification Required</h3>
+ <p className="text-muted-foreground mb-4">
+ Please verify your password to access account settings.
+ </p>
+
+ {/* 인증 방식 표시 */}
+ {authMethod && authMethodConfig[authMethod] && (
+ <div className="flex items-center justify-center gap-2 mb-4">
+ {React.createElement(authMethodConfig[authMethod].icon, {
+ className: "h-4 w-4 text-muted-foreground"
+ })}
+ <Badge variant="outline" className={authMethodConfig[authMethod].color}>
+ {authMethodConfig[authMethod].label}
+ </Badge>
+ </div>
+ )}
+ </div>
+
+ <SimpleReAuthModal
+ isOpen={showReAuthModal}
+ onSuccess={handleReAuthSuccess}
+ userEmail={userEmail}
+ />
+ </div>
+ )
+ }
+
+ // 접근 허용 상태
+ const currentAuthConfig = authMethod && authMethodConfig[authMethod]
+
+ return (
+ <div className="space-y-6">
+ <div>
+ <div className="flex items-center justify-between">
+ <div>
+ <h3 className="text-lg font-medium">Account Settings</h3>
+ <p className="text-sm text-muted-foreground">
+ Update your account settings and manage your profile information.
+ </p>
+ </div>
+
+ {/* 보안 상태 표시 */}
+ <div className="flex items-center gap-2">
+ <div className="flex items-center gap-2 px-3 py-1 bg-green-50 text-green-700 rounded-full text-sm">
+ <Shield className="h-4 w-4" />
+ <span>Verified</span>
+ </div>
+ </div>
+ </div>
+
+ {/* 사용자 정보 및 인증 방식 표시 */}
+ <div className="mt-4 p-4 bg-slate-50 border border-slate-200 rounded-lg">
+ <div className="flex items-center justify-between">
+ <div className="space-y-2">
+ <div className="flex items-center gap-3">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-green-500 rounded-full"></div>
+ <span className="text-sm font-medium text-slate-900">
+ {userEmail}
+ </span>
+ </div>
+
+ {/* 인증 방식 뱃지 */}
+ {currentAuthConfig && (
+ <div className="flex items-center gap-1">
+ {React.createElement(currentAuthConfig.icon, {
+ className: "h-3 w-3"
+ })}
+ <Badge variant="outline" className={`text-xs ${currentAuthConfig.color}`}>
+ {currentAuthConfig.label}
+ {userDomain && authMethod === 'saml' && ` (${userDomain})`}
+ </Badge>
+ </div>
+ )}
+ </div>
+
+ {/* 도메인 정보 */}
+ {userDomain && (
+ <div className="text-xs text-slate-600">
+ Domain: <span className="font-medium">{userDomain}</span>
+ </div>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2">
+ {/* 이메일 인증 사용자만 재인증 버튼 표시 */}
+ {authMethod === 'email' && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={forceReAuth}
+ className="text-amber-700 border-amber-300 hover:bg-amber-50"
+ >
+ Re-verify
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 메인 콘텐츠 */}
+ <AccountForm />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/settings/preferences/page.tsx b/app/[lng]/partners/(partners)/settings/preferences/page.tsx
new file mode 100644
index 00000000..e2a88021
--- /dev/null
+++ b/app/[lng]/partners/(partners)/settings/preferences/page.tsx
@@ -0,0 +1,17 @@
+import { Separator } from "@/components/ui/separator"
+import { AppearanceForm } from "@/components/settings/appearance-form"
+
+export default function SettingsAppearancePage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Preference</h3>
+ <p className="text-sm text-muted-foreground">
+ Customize the preference of the app.
+ </p>
+ </div>
+ <Separator />
+ <AppearanceForm />
+ </div>
+ )
+}
diff --git a/app/[lng]/partners/(partners)/system/layout.tsx b/app/[lng]/partners/(partners)/system/layout.tsx
new file mode 100644
index 00000000..504570bb
--- /dev/null
+++ b/app/[lng]/partners/(partners)/system/layout.tsx
@@ -0,0 +1,71 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+
+export const metadata: Metadata = {
+ title: "System Setting",
+ // description: "Advanced form example using react-hook-form and Zod.",
+}
+
+
+interface SettingsLayoutProps {
+ children: React.ReactNode
+ params: { lng: string }
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string }
+}) {
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+
+
+ const sidebarNavItems = [
+
+ {
+ title: "사용자",
+ href: `/${lng}/evcp/system`,
+ },
+ {
+ title: "Roles",
+ href: `/${lng}/evcp/system/roles`,
+ },
+ {
+ title: "권한 통제",
+ href: `/${lng}/evcp/system/permissions`,
+ },
+
+ ]
+
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">시스템 설정</h2>
+ <p className="text-muted-foreground">
+ 사용자, 롤, 접근 권한을 관리하세요.
+ </p>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1 ">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+
+
+ </>
+ )
+}
diff --git a/app/[lng]/partners/(partners)/system/page.tsx b/app/[lng]/partners/(partners)/system/page.tsx
index a1e9f8be..1224851b 100644
--- a/app/[lng]/partners/(partners)/system/page.tsx
+++ b/app/[lng]/partners/(partners)/system/page.tsx
@@ -1,8 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import * as React from "react"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsCache } from "@/lib/admin-users/validations"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { AdmUserTable } from "@/lib/admin-users/table/ausers-table"
+import { getAllRolesbyVendor, getUserCountGroupByRoleAndVendor, getVendorUsers } from "@/lib/vendor-users/service"
+import { VendorUserTable } from "@/lib/vendor-users/table/ausers-table"
-export default function Pages() {
- return (
- <>
- test
- </>
- )
- } \ No newline at end of file
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SystemUserPage(props: IndexPageProps) {
+
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getVendorUsers({
+ ...search,
+ filters: validFilters,
+ }),
+ getUserCountGroupByRoleAndVendor(),
+ getAllRolesbyVendor()
+ ])
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "12rem", "12rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Users</h3>
+ <p className="text-sm text-muted-foreground">
+ 시스템 전체 사용자들을 조회하고 관리할 수 있는 페이지입니다. 사용자에게 롤을 할당하는 것으로 메뉴별 권한을 관리할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <VendorUserTable promises={promises} />
+ </div>
+ </React.Suspense>
+
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/system/permissions/page.tsx b/app/[lng]/partners/(partners)/system/permissions/page.tsx
new file mode 100644
index 00000000..fe33f920
--- /dev/null
+++ b/app/[lng]/partners/(partners)/system/permissions/page.tsx
@@ -0,0 +1,17 @@
+import PermissionsTreeVendor from "@/components/system/permissionsTreeVendor"
+import { Separator } from "@/components/ui/separator"
+
+export default function PermissionsPage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Permissions</h3>
+ <p className="text-sm text-muted-foreground">
+ Set permissions to the menu by Role
+ </p>
+ </div>
+ <Separator />
+ <PermissionsTreeVendor/>
+ </div>
+ )
+}
diff --git a/app/[lng]/partners/(partners)/system/roles/page.tsx b/app/[lng]/partners/(partners)/system/roles/page.tsx
new file mode 100644
index 00000000..fe074600
--- /dev/null
+++ b/app/[lng]/partners/(partners)/system/roles/page.tsx
@@ -0,0 +1,68 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Separator } from "@/components/ui/separator"
+
+import { searchParamsCache } from "@/lib/roles/validations"
+import { searchParamsCache as searchParamsCache2 } from "@/lib/admin-users/validations"
+import { RolesTable } from "@/lib/roles/table/roles-table"
+import { getRolesWithCount } from "@/lib/roles/services"
+import { getUsersAll } from "@/lib/users/service"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function UserTable(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+ const search2 = searchParamsCache2.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRolesWithCount({
+ ...search,
+ filters: validFilters,
+ }),
+
+
+ ])
+
+
+ const promises2 = Promise.all([
+ getUsersAll({
+ ...search2,
+ filters: validFilters,
+ }, "evcp"),
+ ])
+
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Role Management</h3>
+ <p className="text-sm text-muted-foreground">
+ 역할을 생성하고 역할에 유저를 할당할 수 있는 페이지입니다. 역할에 메뉴의 접근 권한 역시 할당할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <RolesTable promises={promises} promises2={promises2} />
+ </div>
+ </React.Suspense>
+
+ )
+}
diff --git a/app/[lng]/privacy/page.tsx b/app/[lng]/privacy/page.tsx
new file mode 100644
index 00000000..28881db2
--- /dev/null
+++ b/app/[lng]/privacy/page.tsx
@@ -0,0 +1,5 @@
+import { PrivacyPolicyPage } from "@/components/login/privacy-policy-page"
+
+export default function Privacy() {
+ return <PrivacyPolicyPage />
+} \ No newline at end of file
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
index 969263ea..f5d49f77 100644
--- a/app/api/auth/[...nextauth]/route.ts
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -1,51 +1,50 @@
-// (1) next-auth에서 필요한 타입들을 import
+// Updated NextAuth configuration with dynamic session timeout from database
+
import NextAuth, {
- NextAuthOptions, // authOptions에 쓸 타입
+ NextAuthOptions,
Session,
- User
+ User,
+ Account
} from 'next-auth'
import { JWT } from "next-auth/jwt"
-
import CredentialsProvider from 'next-auth/providers/credentials'
-
-import { verifyExternalCredentials, verifyOtp, verifyOtpTemp } from '@/lib/users/verifyOtp'
import { SAMLProvider } from './saml/provider'
+import { 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'
-// 1) 모듈 보강 선언
+// 인증 방식 타입 정의
+type AuthMethod = 'otp' | 'email' | 'sgips' | 'saml'
+
+// 모듈 보강 선언 (인증 방식 추가)
declare module "next-auth" {
- /**
- * Session 객체를 확장
- */
interface Session {
user: {
- /** 우리가 필요로 하는 user id */
id: string
-
- // 기본적으로 NextAuth가 제공하는 name/email/image 필드
name?: string | null
email?: string | null
image?: string | null
companyId?: number | null
techCompanyId?: number | null
domain?: string | null
-
+ reAuthTime?: number | null
+ authMethod?: AuthMethod
+ sessionExpiredAt?: number | null // 세션 만료 시간 추가
}
}
- /**
- * User 객체를 확장
- */
interface User {
id: string
imageUrl?: string | null
companyId?: number | null
techCompanyId?: number | null
domain?: string | null
- // 필요한 필드를 추가로 선언 가능
+ reAuthTime?: number | null
+ authMethod?: AuthMethod
}
}
-// JWT 타입 확장
declare module "next-auth/jwt" {
interface JWT {
id?: string
@@ -53,13 +52,47 @@ declare module "next-auth/jwt" {
companyId?: number | null
techCompanyId?: number | null
domain?: string | null
+ reAuthTime?: number | null
+ authMethod?: AuthMethod
+ sessionExpiredAt?: number | null // 세션 만료 시간 추가
}
}
+// 보안 설정 캐시 (성능 최적화)
+let securitySettingsCache: {
+ data: any | null
+ lastFetch: number
+ ttl: number
+} = {
+ data: null,
+ lastFetch: 0,
+ ttl: 5 * 60 * 1000 // 5분 캐시
+}
+
+// 보안 설정을 가져오는 함수 (캐시 적용)
+async function getCachedSecuritySettings() {
+ const now = Date.now()
+
+ if (!securitySettingsCache.data ||
+ (now - securitySettingsCache.lastFetch) > securitySettingsCache.ttl) {
+ try {
+ securitySettingsCache.data = await getSecuritySettings()
+ securitySettingsCache.lastFetch = now
+ } catch (error) {
+ console.error('Failed to fetch security settings:', error)
+ // 기본값 사용
+ securitySettingsCache.data = {
+ sessionTimeoutMinutes: 480 // 8시간 기본값
+ }
+ }
+ }
+
+ return securitySettingsCache.data
+}
-// (2) authOptions에 NextAuthOptions 타입 지정
export const authOptions: NextAuthOptions = {
providers: [
+ // OTP provider
CredentialsProvider({
name: 'Credentials',
credentials: {
@@ -69,69 +102,90 @@ export const authOptions: NextAuthOptions = {
async authorize(credentials, req) {
const { email, code } = credentials ?? {}
- // OTP 검증
const user = await verifyOtpTemp(email ?? '')
if (!user) {
return null
}
+ // 보안 설정에서 세션 타임아웃 가져오기
+ const securitySettings = await getCachedSecuritySettings()
+ const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000
+ const reAuthTime = Date.now()
+
return {
id: String(user.id ?? email ?? "dts"),
email: user.email,
imageUrl: user.imageUrl ?? null,
- name: user.name, // DB에서 가져온 실제 이름
- companyId: user.companyId, // DB에서 가져온 실제 이름
- techCompanyId: user.techCompanyId as number | undefined, // techVendor ID
- domain: user.domain, // DB에서 가져온 실제 이름
+ name: user.name,
+ companyId: user.companyId,
+ techCompanyId: user.techCompanyId as number | undefined,
+ domain: user.domain,
+ reAuthTime,
+ authMethod: 'otp' as AuthMethod,
}
},
}),
- // 새로 추가할 ID/비밀번호 provider
+
+ // ID/패스워드 provider (S-Gips와 일반 이메일 구분)
CredentialsProvider({
id: 'credentials-password',
name: 'Username Password',
credentials: {
username: { label: "Username", type: "text" },
- password: { label: "Password", type: "password" }
+ password: { label: "Password", type: "password" },
+ provider: { label: "Provider", type: "text" },
},
- async authorize(credentials, req) { // req 매개변수 추가
+ async authorize(credentials, req) {
if (!credentials?.username || !credentials?.password) {
return null;
}
try {
- // 여기서 외부 서비스 API를 호출하여 사용자 인증
- const user = await verifyExternalCredentials(
- credentials.username,
- credentials.password
- );
+ let authResult;
+ const isSSgips = credentials.provider === 'sgips';
- if (user) {
+ if (isSSgips) {
+ authResult = await authenticateWithSGips(
+ credentials.username,
+ credentials.password
+ );
+ } else {
+ authResult = await verifyExternalCredentials(
+ credentials.username,
+ credentials.password
+ );
+ }
+
+ if (authResult.success && authResult.user) {
return {
- id: String(user.id), // id를 string으로 변환
- name: user.name,
- email: user.email,
- // 첫 번째 provider와 동일한 필드 구조 유지
- imageUrl: user.imageUrl ?? null,
- companyId: user.companyId,
- techCompanyId: user.techCompanyId,
- domain: user.domain
+ id: authResult.user.id,
+ name: authResult.user.name,
+ email: authResult.user.email,
+ imageUrl: authResult.user.imageUrl ?? null,
+ companyId: authResult.user.companyId,
+ techCompanyId: authResult.user.techCompanyId,
+ domain: authResult.user.domain,
+ reAuthTime: Date.now(),
+ authMethod: isSSgips ? 'sgips' as AuthMethod : 'email' as AuthMethod,
};
}
+
return null;
+
} catch (error) {
console.error("Authentication error:", error);
return null;
}
}
}),
- // SAML Provider 추가 (CredentialsProvider 기반)
+
+ // SAML Provider
SAMLProvider({
id: "credentials-saml",
name: "SAML SSO",
idp: {
sso_login_url: process.env.SAML_IDP_SSO_URL!,
- sso_logout_url: process.env.SAML_IDP_SLO_URL || '', // 선택적
+ sso_logout_url: process.env.SAML_IDP_SLO_URL || '',
certificates: [process.env.SAML_IDP_CERT!]
},
sp: {
@@ -142,17 +196,23 @@ export const authOptions: NextAuthOptions = {
}
})
],
- // (3) session.strategy는 'jwt'가 되도록 선언
- // 필요하다면 as SessionStrategy 라고 명시해줄 수도 있음
- // 예) strategy: 'jwt' as SessionStrategy
+
session: {
strategy: 'jwt',
+ // JWT 기본 maxAge는 30일로 설정하되, 실제 세션 만료는 콜백에서 처리
+ maxAge: 30 * 24 * 60 * 60, // 30일
},
callbacks: {
- // (4) 콜백에서 token, user, session 등의 타입을 좀 더 명시해주고 싶다면 아래처럼 destructuring에 제네릭/타입 지정
- async jwt({ token, user }: { token: JWT; user?: User }) {
+ // JWT 콜백 - 세션 타임아웃 설정 (만료 체크는 session 콜백에서)
+ async jwt({ token, user, account, trigger, session }) {
+ // 보안 설정 가져오기
+ const securitySettings = await getCachedSecuritySettings()
+ const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000
+
+ // 최초 로그인 시
if (user) {
+ const reAuthTime = Date.now()
token.id = user.id
token.email = user.email
token.name = user.name
@@ -160,10 +220,61 @@ export const authOptions: NextAuthOptions = {
token.techCompanyId = user.techCompanyId
token.domain = user.domain
token.imageUrl = user.imageUrl
+ token.reAuthTime = reAuthTime
+ token.authMethod = user.authMethod
+ token.sessionExpiredAt = reAuthTime + sessionTimeoutMs
+ }
+
+ // 인증 방식 결정 (account 정보 기반)
+ if (account && !token.authMethod) {
+ const reAuthTime = Date.now()
+ if (account.provider === 'credentials-saml') {
+ token.authMethod = 'saml'
+ token.reAuthTime = reAuthTime
+ token.sessionExpiredAt = reAuthTime + sessionTimeoutMs
+ } else if (account.provider === 'credentials') {
+ // OTP는 이미 user.authMethod에서 설정됨
+ if (!token.sessionExpiredAt) {
+ token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs
+ }
+ } else if (account.provider === 'credentials-password') {
+ // credentials-password는 이미 user.authMethod에서 설정됨
+ if (!token.sessionExpiredAt) {
+ token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs
+ }
+ }
}
+
+ // 세션 업데이트 시 (재인증 시간 업데이트)
+ if (trigger === "update" && session) {
+ if (session.reAuthTime !== undefined) {
+ token.reAuthTime = session.reAuthTime
+ // 재인증 시간 업데이트 시 세션 만료 시간도 연장
+ token.sessionExpiredAt = session.reAuthTime + sessionTimeoutMs
+ }
+
+ if (session.user) {
+ if (session.user.name !== undefined) token.name = session.user.name
+ if (session.user.email !== undefined) token.email = session.user.email
+ if (session.user.image !== undefined) token.imageUrl = session.user.image
+ }
+ }
+
return token
},
+
+ // Session 콜백 - 세션 만료 체크 및 정보 포함
async session({ session, token }: { session: Session; token: JWT }) {
+ // 세션 만료 체크
+ if (token.sessionExpiredAt && Date.now() > token.sessionExpiredAt) {
+ console.log(`Session expired for user ${token.email}. Expired at: ${new Date(token.sessionExpiredAt)}`)
+ // 만료된 세션 처리 - 빈 세션 반환하여 로그아웃 유도
+ return {
+ expires: new Date(0).toISOString(), // 즉시 만료
+ user: null as any
+ }
+ }
+
if (token) {
session.user = {
id: token.id as string,
@@ -172,27 +283,44 @@ export const authOptions: NextAuthOptions = {
domain: token.domain as string,
companyId: token.companyId as number,
techCompanyId: token.techCompanyId as number,
- image: token.imageUrl ?? null
+ image: token.imageUrl ?? null,
+ reAuthTime: token.reAuthTime as number | null,
+ authMethod: token.authMethod as AuthMethod,
+ sessionExpiredAt: token.sessionExpiredAt as number | null,
}
}
return session
},
- // redirect 콜백 추가
+
+ // Redirect 콜백
async redirect({ url, baseUrl }) {
- // 상대 경로인 경우 baseUrl을 기준으로 함
if (url.startsWith("/")) {
return `${baseUrl}${url}`;
}
- // 같은 도메인인 경우 그대로 사용
else if (new URL(url).origin === baseUrl) {
return url;
}
- // 그 외에는 baseUrl로 리다이렉트
return baseUrl;
},
},
+
+ pages: {
+ signIn: '/auth/login',
+ error: '/auth/error',
+ },
+
+ // 디버깅을 위한 이벤트 로깅
+ events: {
+ async signIn({ user, account, profile }) {
+ const securitySettings = await getCachedSecuritySettings()
+ console.log(`User ${user.email} signed in via ${account?.provider} (authMethod: ${user.authMethod}), session timeout: ${securitySettings.sessionTimeoutMinutes} minutes`);
+ },
+ async signOut({ session, token }) {
+ console.log(`User ${session?.user?.email || token?.email} signed out`);
+ }
+ }
}
const handler = NextAuth(authOptions)
+export { handler as GET, handler as POST }
-export { handler as GET, handler as POST } \ No newline at end of file
diff --git a/app/api/auth/send-sms/route.ts b/app/api/auth/send-sms/route.ts
new file mode 100644
index 00000000..3d51d445
--- /dev/null
+++ b/app/api/auth/send-sms/route.ts
@@ -0,0 +1,75 @@
+// app/api/auth/send-sms/route.ts
+
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { getUserById } from '@/lib/users/repository';
+import { generateAndSendSmsToken } from '@/lib/users/auth/passwordUtil';
+
+const sendSmsSchema = z.object({
+ userId: z.string(),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: '인증이 필요합니다' },
+ { status: 401 }
+ );
+ }
+
+ const body = await request.json();
+ const { userId } = sendSmsSchema.parse(body);
+
+ // 본인 확인
+ if (session.user.id !== userId) {
+ return NextResponse.json(
+ { error: '권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ // 사용자 정보 조회
+ const user = await getUserById(Number(userId));
+ if (!user || !user.phone) {
+ return NextResponse.json(
+ { error: '전화번호가 등록되지 않았습니다' },
+ { status: 400 }
+ );
+ }
+
+ // SMS 전송
+ const result = await generateAndSendSmsToken(parseInt(userId), user.phone);
+
+ if (result.success) {
+ return NextResponse.json({
+ success: true,
+ message: 'SMS가 전송되었습니다'
+ });
+ } else {
+ return NextResponse.json(
+ { error: result.error },
+ { status: 400 }
+ );
+ }
+
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: '잘못된 요청입니다' },
+ { status: 400 }
+ );
+ }
+
+ console.error('SMS send API error:', error);
+ return NextResponse.json(
+ { error: '서버 오류가 발생했습니다' },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/app/api/auth/verify-mfa/route.ts b/app/api/auth/verify-mfa/route.ts
new file mode 100644
index 00000000..f9d1b51e
--- /dev/null
+++ b/app/api/auth/verify-mfa/route.ts
@@ -0,0 +1,65 @@
+// app/api/auth/verify-mfa/route.ts
+
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { verifySmsToken } from '@/lib/users/auth/passwordUtil';
+
+const verifyMfaSchema = z.object({
+ userId: z.string(),
+ token: z.string().length(6, '6자리 인증번호를 입력해주세요'),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: '인증이 필요합니다' },
+ { status: 401 }
+ );
+ }
+
+ const body = await request.json();
+ const { userId, token } = verifyMfaSchema.parse(body);
+
+ // 본인 확인
+ if (session.user.id !== userId) {
+ return NextResponse.json(
+ { error: '권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ // MFA 토큰 검증
+ const result = await verifySmsToken(parseInt(userId), token);
+
+ if (result.success) {
+ return NextResponse.json({
+ success: true,
+ message: 'MFA 인증이 완료되었습니다'
+ });
+ } else {
+ return NextResponse.json(
+ { error: result.error },
+ { status: 400 }
+ );
+ }
+
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: error.errors[0]?.message || '잘못된 요청입니다' },
+ { status: 400 }
+ );
+ }
+
+ console.error('MFA verify API error:', error);
+ return NextResponse.json(
+ { error: '서버 오류가 발생했습니다' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/cron/cleanup-users/route.ts b/app/api/cron/cleanup-users/route.ts
new file mode 100644
index 00000000..d3b7a203
--- /dev/null
+++ b/app/api/cron/cleanup-users/route.ts
@@ -0,0 +1,40 @@
+// app/api/cron/cleanup-users/route.ts
+import { deactivateInactiveUsers } from '@/lib/users/auth/partners-auth';
+import { NextRequest } from 'next/server';
+
+export async function GET(request: NextRequest) {
+ try {
+ // 보안: 헤더 또는 쿼리 파라미터로 인증
+ const authToken = request.headers.get('x-cron-secret') ||
+ request.nextUrl.searchParams.get('secret');
+
+ if (authToken !== process.env.CRON_SECRET) {
+ console.log('Unauthorized cron request');
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ console.log(`[${new Date().toISOString()}] Starting user cleanup job...`);
+
+ const result = await deactivateInactiveUsers(90); // 90일
+
+ const message = `User cleanup completed: ${result.deactivatedCount} users deactivated`;
+ console.log(`[${new Date().toISOString()}] ${message}`);
+
+ return Response.json({
+ success: true,
+ message,
+ deactivatedCount: result.deactivatedCount,
+ timestamp: new Date().toISOString()
+ });
+
+ } catch (error) {
+ const errorMessage = `User cleanup job failed: ${error.message}`;
+ console.error(`[${new Date().toISOString()}] ${errorMessage}`);
+
+ return Response.json({
+ success: false,
+ error: errorMessage,
+ timestamp: new Date().toISOString()
+ }, { status: 500 });
+ }
+} \ No newline at end of file
diff --git a/app/api/revision-upload-ship/route.ts b/app/api/revision-upload-ship/route.ts
index c68d405e..549d15bd 100644
--- a/app/api/revision-upload-ship/route.ts
+++ b/app/api/revision-upload-ship/route.ts
@@ -31,7 +31,7 @@ export async function POST(request: NextRequest) {
const docId = Number(formData.get("documentId"))
const uploaderName = formData.get("uploaderName") as string | null
const comment = formData.get("comment") as string | null
- const targetSystem = (formData.get("targetSystem") as string | null) ?? "DOLCE"
+ const targetSystem = "DOLCE"
const attachmentFiles = formData.getAll("attachments") as File[]
/* ------- 검증 ------- */
diff --git a/app/layout.tsx b/app/layout.tsx
index 4923aa22..256e0edc 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,9 +1,8 @@
import type { Metadata } from "next";
-// import { Inter } from "next/font/google";
import "./globals.css";
import { languages } from "@/i18n/settings";
import { Toaster } from "@/components/ui/toaster"
-import { ThemeProvider } from "@/components/layout/providers";
+import { ThemeProvider } from "@/components/layout/providers"; // 기존 ThemeProvider 사용
import { cn } from "@/lib/utils"
import { META_THEME_COLORS, siteConfig } from "@/config/site"
import { LicenseInfo } from '@mui/x-license';
@@ -59,47 +58,47 @@ export default async function RootLayout({
children: React.ReactNode;
params: { lng: string };
}) {
-
-
return (
<html lang={lng} suppressHydrationWarning>
- <head>
- <script
- dangerouslySetInnerHTML={{
- __html: `
- try {
- if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
- document.querySelector('meta[name="theme-color"]').setAttribute('content', '${META_THEME_COLORS.dark}')
- }
- } catch (_) {}
- `,
- }}
- />
- </head>
-
+ <head>
+ <script
+ dangerouslySetInnerHTML={{
+ __html: `
+ try {
+ if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
+ document.querySelector('meta[name="theme-color"]').setAttribute('content', '${META_THEME_COLORS.dark}')
+ }
+ } catch (_) {}
+ `,
+ }}
+ />
+ </head>
<body
- className={cn(
- "min-h-svh bg-slate-100 font-sans antialiased",
- inter.className,
- )}
+ className={cn(
+ "min-h-svh bg-slate-100 font-sans antialiased",
+ inter.className,
+ )}
+ >
+ {/* ✅ 기존 ThemeProvider에 lng prop 전달 */}
+ <ThemeProvider
+ attribute="class"
+ defaultTheme="system"
+ enableSystem
+ disableTransitionOnChange
+ enableColorScheme
+ lng={lng} // ✅ lng 전달
>
- <ThemeProvider
- attribute="class"
- defaultTheme="system"
- enableSystem
- disableTransitionOnChange
- enableColorScheme
- >
- <div vaul-drawer-wrapper="">
- {/* <div className="relative flex min-h-svh flex-col bg-slate-100"> */}
- <div className="relative flex min-h-svh flex-col bg-background">
- {children}
- </div>
+ <div vaul-drawer-wrapper="">
+ <div className="relative flex min-h-svh flex-col bg-background">
+ {children}
</div>
- <Toaster />
- <ToasterSonner/>
- </ThemeProvider>
- </body>
+ </div>
+
+ {/* Toast Notifications */}
+ <Toaster />
+ <ToasterSonner />
+ </ThemeProvider>
+ </body>
</html>
);
} \ No newline at end of file