diff options
Diffstat (limited to 'app')
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 |
