diff --git a/README.md b/README.md index e90d891aa..6b69ee90b 100644 --- a/README.md +++ b/README.md @@ -201,3 +201,4 @@ http://dev.onasys.co.kr:8000/webjars/swagger-ui/index.html?urls.primaryName=atta - 사이드바 메뉴 클릭 시 "menuCd" 데이터 쿠키 저장 처리 - 쿠키명 : menu-code-key - 해당 메뉴 쿠키 데이터 가져와 페이지 권한 및 상당 Breadcrumb 데이터 전달 처리 + diff --git a/apps/cm/.env.development b/apps/cm/.env.development new file mode 100644 index 000000000..9f786831b --- /dev/null +++ b/apps/cm/.env.development @@ -0,0 +1,7 @@ +# apps/cs/src/shared/config/env.ts 참고 +NEXT_PUBLIC_PORT=3000 +NEXT_PUBLIC_PROXY_HOST=http://localhost:3000 +NEXT_PUBLIC_SITE_ID=CS +NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000 +NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000 +NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 \ No newline at end of file diff --git a/apps/cm/.env.example b/apps/cm/.env.example new file mode 100644 index 000000000..9f786831b --- /dev/null +++ b/apps/cm/.env.example @@ -0,0 +1,7 @@ +# apps/cs/src/shared/config/env.ts 참고 +NEXT_PUBLIC_PORT=3000 +NEXT_PUBLIC_PROXY_HOST=http://localhost:3000 +NEXT_PUBLIC_SITE_ID=CS +NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000 +NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000 +NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 \ No newline at end of file diff --git a/apps/cm/.env.local b/apps/cm/.env.local new file mode 100644 index 000000000..896025bd6 --- /dev/null +++ b/apps/cm/.env.local @@ -0,0 +1,8 @@ +# apps/cs/src/shared/config/env.ts 참고 +NEXT_PUBLIC_PORT=3000 +NEXT_PUBLIC_PROXY_HOST=http://localhost:3000 +NEXT_PUBLIC_SITE_ID=CS +NEXT_PUBLIC_SUB_DOMAIN=http://localhost:13000 +NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000 +NEXT_PUBLIC_LINK_BACKEND_API_HOST=http://rsone.iptime.org:10014 +NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 \ No newline at end of file diff --git a/apps/cm/.env.production b/apps/cm/.env.production new file mode 100644 index 000000000..9f786831b --- /dev/null +++ b/apps/cm/.env.production @@ -0,0 +1,7 @@ +# apps/cs/src/shared/config/env.ts 참고 +NEXT_PUBLIC_PORT=3000 +NEXT_PUBLIC_PROXY_HOST=http://localhost:3000 +NEXT_PUBLIC_SITE_ID=CS +NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000 +NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000 +NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 \ No newline at end of file diff --git a/apps/cm/.env.zip b/apps/cm/.env.zip new file mode 100644 index 000000000..610032d09 Binary files /dev/null and b/apps/cm/.env.zip differ diff --git a/apps/cm/.gitignore b/apps/cm/.gitignore new file mode 100644 index 000000000..b63718b61 --- /dev/null +++ b/apps/cm/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.env* +!.env.example \ No newline at end of file diff --git a/apps/cm/.prettierrc b/apps/cm/.prettierrc new file mode 100644 index 000000000..b4bfed357 --- /dev/null +++ b/apps/cm/.prettierrc @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/apps/cm/app/api/auth/logout/route.ts b/apps/cm/app/api/auth/logout/route.ts new file mode 100644 index 000000000..fdcc9df7c --- /dev/null +++ b/apps/cm/app/api/auth/logout/route.ts @@ -0,0 +1,17 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + REFRESH_TOKEN, +} from "@/shared/config"; + +export async function POST() { + const cookieStore = await cookies(); + cookieStore.delete(ACCESS_TOKEN); + cookieStore.delete(REFRESH_TOKEN); + cookieStore.delete(AUTH_USER_ID); + cookieStore.delete(CLAIM_NAME); + return NextResponse.json({ message: "Logged out" }, { status: 200 }); +} diff --git a/apps/cm/app/api/proxy/[...url]/route.ts b/apps/cm/app/api/proxy/[...url]/route.ts new file mode 100644 index 000000000..761991e39 --- /dev/null +++ b/apps/cm/app/api/proxy/[...url]/route.ts @@ -0,0 +1,145 @@ +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + REFRESH_TOKEN, + SERVER_API_URL, + SITE_ID, +} from "@/shared/config"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +async function handler( + request: Request, + params: { url: string[] }, + method: "GET" | "POST" | "PUT" | "DELETE", +) { + const cookieStore = await cookies(); + const headers: Record = { + "Content-Type": "application/json", + "X-Site-Id": SITE_ID, + }; + let body = {}; + const isRefreshHandler = params.url.includes("refresh"); + + console.log(`[Proxy] ${method} ${params.url.join("/")}`); + console.log(`[Proxy] Server API URL: ${SERVER_API_URL}`); + + // silent refresh + if (isRefreshHandler) { + const refreshToken = cookieStore.get(REFRESH_TOKEN); + headers[CLAIM_NAME] = refreshToken?.value ?? ""; + + if (!headers[CLAIM_NAME]) { + return NextResponse.json({ message: "Can't refresh" }, { status: 401 }); + } + } + + try { + if (!isRefreshHandler) { + body = await request.json(); + console.log(`[Proxy] Request body:`, body); + } + + const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`; + console.log(`[Proxy] Target URL: ${targetUrl}`); + + const result = await fetch(targetUrl, { + method, + headers, + body: JSON.stringify(body), + }); + + console.log(`[Proxy] Response status: ${result.status}`); + console.log(`[Proxy] Response headers:`, Object.fromEntries(result.headers.entries())); + + // 2FA 관련 엔드포인트인 경우 응답 본문도 확인 + const is2FAEndpoint = params.url.includes("2fa") || params.url.includes("auth"); + let responseData = {}; + + if (is2FAEndpoint) { + try { + responseData = await result.json(); + console.log(`[Proxy] 2FA Response data:`, responseData); + } catch { + // JSON 파싱 실패 시 빈 객체 사용 + console.log(`[Proxy] Failed to parse JSON response`); + } + } + + const accessToken = result.headers.get(ACCESS_TOKEN) || result.headers.get('ss-token'); + const refreshToken = result.headers.get(REFRESH_TOKEN); + const userId = result.headers.get(AUTH_USER_ID); + + console.log(`[Proxy] Tokens - accessToken: ${!!accessToken}, userId: ${!!userId}, refreshToken: ${!!refreshToken}`); + console.log(`[Proxy] Available headers:`, Array.from(result.headers.keys())); + + // 2FA 엔드포인트이고 실패한 경우 실제 오류 메시지 반환 + if (!result.ok && is2FAEndpoint) { + console.log(`[Proxy] 2FA endpoint failed, returning actual error`); + return NextResponse.json(responseData, { status: result.status }); + } + + // 2FA 엔드포인트이고 성공한 경우 (토큰이 헤더에 없을 수 있음) + if (result.ok && is2FAEndpoint) { + console.log(`[Proxy] 2FA endpoint succeeded, returning response data`); + return NextResponse.json(responseData, { status: result.status }); + } + + // 일반적인 실패 처리 + if (!result.ok) { + console.log(`[Proxy] General endpoint failed`); + return NextResponse.json( + { message: "Invalid Credentials" }, + { status: 401 }, + ); + } + + if (accessToken && userId) { + const response = NextResponse.json({ + [ACCESS_TOKEN]: accessToken, + [AUTH_USER_ID]: userId, + ...responseData, // 2FA 관련 추가 데이터 포함 + }); + + if (refreshToken) { + cookieStore.set(REFRESH_TOKEN, refreshToken, { + httpOnly: true, + sameSite: "lax", + path: "/", + }); + } + + console.log(`[Proxy] Success response sent`); + return response; + } + + console.log(`[Proxy] Missing tokens in response`); + return NextResponse.json( + { message: "Invalid Credentials" }, + { status: 401 }, + ); + } catch (error) { + console.error(`[Proxy] Error:`, error); + return NextResponse.json( + { message: "Server Error", error }, + { status: 500 }, + ); + } +} + +export async function POST( + request: Request, + props: { params: Promise<{ url: string[] }> }, +) { + const params = await props.params; + return handler(request, params, "POST"); +} + +export async function PUT( + request: Request, + props: { params: Promise<{ url: string[] }> }, +) { + const params = await props.params; + return handler(request, params, "PUT"); +} diff --git a/apps/cm/app/auth/login/page.tsx b/apps/cm/app/auth/login/page.tsx new file mode 100644 index 000000000..8f9e6adc6 --- /dev/null +++ b/apps/cm/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/pages/auth/login"; +export default LoginPage; diff --git a/apps/cm/app/dashboard/page.tsx b/apps/cm/app/dashboard/page.tsx new file mode 100644 index 000000000..53d564ac6 --- /dev/null +++ b/apps/cm/app/dashboard/page.tsx @@ -0,0 +1,2 @@ +import { DashboardPage } from "@/pages/dashboard"; +export default DashboardPage; diff --git a/apps/cm/app/layout.tsx b/apps/cm/app/layout.tsx new file mode 100644 index 000000000..aa2049dcc --- /dev/null +++ b/apps/cm/app/layout.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { App } from "@/app"; + +export const metadata: Metadata = { + title: "유역하수도 통합 정보플랫폼", +}; + +export default App; diff --git a/apps/cm/eslint.config.mjs b/apps/cm/eslint.config.mjs new file mode 100644 index 000000000..9109635e9 --- /dev/null +++ b/apps/cm/eslint.config.mjs @@ -0,0 +1,21 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + rules: { + "react-hooks/exhaustive-deps": "off", + }, + }, +]; + +export default eslintConfig; diff --git a/apps/cm/middleware.ts b/apps/cm/middleware.ts new file mode 100644 index 000000000..e0316fa9f --- /dev/null +++ b/apps/cm/middleware.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + CLAIM_NAME, + AUTH_USER_ID, + PORT, + PROXY_HOST, + SERVER_API_URL, + SITE_ID, +} from "./src/shared/config"; + +const serverApiUrl = + process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000"; + +export function middleware(req: NextRequest) { + const pathname = req.nextUrl.pathname; + const searchParams = req.nextUrl.searchParams; + + const logMessages: string[] = [ + `[Middleware Request] ${new Date().toISOString()}`, + `[Environment]\n Mode: ${process.env.NODE_ENV}\n Port: ${PORT}\n Proxy Host: ${PROXY_HOST}\n Server API URL: ${SERVER_API_URL}\n Site ID: ${SITE_ID}`, + `[Request]\n Original Pathname: ${pathname}`, + ]; + + if (pathname.startsWith("/server")) { + const queryParams = searchParams.toString(); + const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${ + queryParams ? `?${queryParams}` : "" + }`; + + logMessages.push(` Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + return NextResponse.rewrite(new URL(newUrl)); + } + + if (pathname.startsWith("/attach-service")) { + const requestHeaders = new Headers(req.headers); + const token = req.cookies.get(CLAIM_NAME)?.value; + const userId = req.cookies.get(AUTH_USER_ID)?.value; + const newUrl = `${serverApiUrl}${pathname}`; + + if (token) requestHeaders.set(CLAIM_NAME, token); + if (userId) requestHeaders.set(AUTH_USER_ID, userId); + + logMessages.push(` Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + return NextResponse.rewrite(new URL(newUrl), { + headers: requestHeaders, + }); + } + + if (pathname.startsWith("/user-service")) { + const queryParams = searchParams.toString(); + const newUrl = `${serverApiUrl}${pathname}${ + queryParams ? `?${queryParams}` : "" + }`; + + logMessages.push(` User Service Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + return NextResponse.rewrite(new URL(newUrl)); + } + + if (pathname === "/") { + return NextResponse.redirect(new URL("/dashboard", req.url)); + } + + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + return NextResponse.next(); +} + +export const config = { + matcher: ["/server/:path*", "/attach-service/:path*", "/user-service/:path*", "/"], +}; diff --git a/apps/cm/next-env.d.ts b/apps/cm/next-env.d.ts new file mode 100644 index 000000000..3cd7048ed --- /dev/null +++ b/apps/cm/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/cm/next.config.ts b/apps/cm/next.config.ts new file mode 100644 index 000000000..679528324 --- /dev/null +++ b/apps/cm/next.config.ts @@ -0,0 +1,40 @@ +import type { NextConfig } from "next"; + +const subDomain = + process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000"; + +const backendUrl = process.env.NEXT_PUBLIC_SERVER_API_URL || "http://192.168.0.102:8000"; + +const nextConfig: NextConfig = { + output: "standalone", + async rewrites() { + return { + beforeFiles: [ + { + source: "/_next/static/:path*", + destination: `${subDomain}/_next/static/:path*`, + has: [ + { + type: "header", + key: "referer", + value: "(.*)/cms(.*)", + }, + ], + }, + ], + afterFiles: [ + { + source: "/cms/:path*", + destination: `${subDomain}/cms/:path*`, + }, + { + source: "/user-service/:path*", + destination: `${backendUrl}/user-service/:path*`, + }, + ], + fallback: [], + }; + }, +}; + +export default nextConfig; diff --git a/apps/cm/package.json b/apps/cm/package.json new file mode 100644 index 000000000..81f22f095 --- /dev/null +++ b/apps/cm/package.json @@ -0,0 +1,63 @@ +{ + "name": "cm", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@common/assets": "workspace:*", + "@common/components": "workspace:*", + "@common/config": "workspace:*", + "@fontsource-variable/noto-sans-kr": "^5.2.5", + "@frontend-opensource/use-react-hooks": "^1.1.4", + "@fullcalendar/core": "^6.1.17", + "@fullcalendar/daygrid": "^6.1.17", + "@fullcalendar/interaction": "^6.1.17", + "@fullcalendar/react": "^6.1.17", + "@fullcalendar/timegrid": "^6.1.17", + "@hookform/resolvers": "^4.1.3", + "@minoru/react-dnd-treeview": "3.5.1-alpha.1", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@tanstack/react-query": "^5.67.1", + "@tanstack/react-query-devtools": "^5.75.5", + "@tanstack/react-table": "^8.21.2", + "@tinymce/tinymce-react": "^6.1.0", + "axios": "^1.8.1", + "chart.js": "^4.4.8", + "dayjs": "^1.11.13", + "js-cookie": "^3.0.5", + "lucide-react": "^0.474.0", + "next": "15.3.0", + "next-qrcode": "^2.5.1", + "next-themes": "^0.4.6", + "react-chartjs-2": "^5.3.0", + "react-dnd": "^16.0.1", + "react-hook-form": "^7.54.2", + "sonner": "^2.0.1", + "tailwindcss-animate": "^1.0.7", + "tus-js-client": "^4.3.1", + "zod": "^3.24.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@types/js-cookie": "^3.0.6", + "@types/node": "^22.13.1", + "@types/webpack": "^5.28.5", + "eslint": "^9", + "eslint-config-next": "15.1.7", + "postcss": "^8", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.11", + "steiger": "^0.5.5", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } +} diff --git a/apps/cm/pages/.gitkeep b/apps/cm/pages/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/cm/postcss.config.mjs b/apps/cm/postcss.config.mjs new file mode 100644 index 000000000..1a69fd2a4 --- /dev/null +++ b/apps/cm/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/apps/cm/public/favicon.ico b/apps/cm/public/favicon.ico new file mode 100644 index 000000000..d304b6981 Binary files /dev/null and b/apps/cm/public/favicon.ico differ diff --git a/apps/cm/public/images/bg_login.jpg b/apps/cm/public/images/bg_login.jpg new file mode 100644 index 000000000..b7793d16a Binary files /dev/null and b/apps/cm/public/images/bg_login.jpg differ diff --git a/apps/cm/public/images/img_cc_by.png b/apps/cm/public/images/img_cc_by.png new file mode 100644 index 000000000..628cc2bfd Binary files /dev/null and b/apps/cm/public/images/img_cc_by.png differ diff --git a/apps/cm/public/images/img_cc_by_nc.png b/apps/cm/public/images/img_cc_by_nc.png new file mode 100644 index 000000000..86f60c5db Binary files /dev/null and b/apps/cm/public/images/img_cc_by_nc.png differ diff --git a/apps/cm/public/images/img_cc_by_nc_nd.png b/apps/cm/public/images/img_cc_by_nc_nd.png new file mode 100644 index 000000000..9d8ccb18d Binary files /dev/null and b/apps/cm/public/images/img_cc_by_nc_nd.png differ diff --git a/apps/cm/public/images/img_cc_by_nc_sa.png b/apps/cm/public/images/img_cc_by_nc_sa.png new file mode 100644 index 000000000..00f402c9e Binary files /dev/null and b/apps/cm/public/images/img_cc_by_nc_sa.png differ diff --git a/apps/cm/public/images/img_cc_by_nd.png b/apps/cm/public/images/img_cc_by_nd.png new file mode 100644 index 000000000..0da64acd8 Binary files /dev/null and b/apps/cm/public/images/img_cc_by_nd.png differ diff --git a/apps/cm/public/images/img_cc_by_sa.png b/apps/cm/public/images/img_cc_by_sa.png new file mode 100644 index 000000000..6d94e60b9 Binary files /dev/null and b/apps/cm/public/images/img_cc_by_sa.png differ diff --git a/apps/cm/public/images/img_opentype01.png b/apps/cm/public/images/img_opentype01.png new file mode 100644 index 000000000..456c7c6bb Binary files /dev/null and b/apps/cm/public/images/img_opentype01.png differ diff --git a/apps/cm/public/images/img_opentype02.png b/apps/cm/public/images/img_opentype02.png new file mode 100644 index 000000000..a6c5bfb32 Binary files /dev/null and b/apps/cm/public/images/img_opentype02.png differ diff --git a/apps/cm/public/images/img_opentype03.png b/apps/cm/public/images/img_opentype03.png new file mode 100644 index 000000000..970a6a0d4 Binary files /dev/null and b/apps/cm/public/images/img_opentype03.png differ diff --git a/apps/cm/public/images/img_opentype04.png b/apps/cm/public/images/img_opentype04.png new file mode 100644 index 000000000..41d284cab Binary files /dev/null and b/apps/cm/public/images/img_opentype04.png differ diff --git a/apps/cm/src/app/index.tsx b/apps/cm/src/app/index.tsx new file mode 100644 index 000000000..692b55ef0 --- /dev/null +++ b/apps/cm/src/app/index.tsx @@ -0,0 +1,20 @@ +import "./styles/base.css"; +import "./styles/components.css"; +import "@fontsource-variable/noto-sans-kr"; + +import { AppProvider } from "./providers"; +import { cookies } from "next/headers"; + +const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => { + const theme = (await cookies()).get("site-theme-key"); + + return ( + + + {children} + + + ); +}; + +export { App }; diff --git a/apps/cm/src/app/providers/app-provider.tsx b/apps/cm/src/app/providers/app-provider.tsx new file mode 100644 index 000000000..efd1f308e --- /dev/null +++ b/apps/cm/src/app/providers/app-provider.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { DndProvider } from "react-dnd"; +import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; +import { AuthWrapper } from "@/entities/auth"; +import { MenuWrapper } from "@/entities/menu"; +import { Toaster } from "@/shared/ui/sonner"; +import { AlertDialog } from "@/shared/ui/alert-dialog"; +import { PromptDialog } from "@/shared/ui/prompt-dialog"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog"; +import { PromptProvider, StoreProvider } from "@/shared/providers"; + +const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { + const queryClient = new QueryClient(); + return ( + + + + + + + + {children} + + + + + + + + + + + +
+ +
+
+ ); +}; + +export { AppProvider }; diff --git a/apps/cs/src/app/providers/index.ts b/apps/cm/src/app/providers/index.ts similarity index 100% rename from apps/cs/src/app/providers/index.ts rename to apps/cm/src/app/providers/index.ts diff --git a/apps/cm/src/app/styles/base.css b/apps/cm/src/app/styles/base.css new file mode 100644 index 000000000..7a5263bc4 --- /dev/null +++ b/apps/cm/src/app/styles/base.css @@ -0,0 +1,21 @@ +@import "@common/assets/styles/base.css"; +@import "@common/assets/styles/diveicon.css"; +@import "@common/components/index.css"; + +@tailwind base; +@tailwind utilities; + +@layer base { + :root { + --secondary-background: rgba(0, 0, 0, 0.04); + } + [data-theme="black"] { + --secondary-background: rgba(255, 255, 255, 0.09); + } + + html, + body { + font-family: "Noto Sans KR", sans-serif; + font-size: 10px; + } +} diff --git a/apps/cm/src/app/styles/components.css b/apps/cm/src/app/styles/components.css new file mode 100644 index 000000000..5b9742c0f --- /dev/null +++ b/apps/cm/src/app/styles/components.css @@ -0,0 +1,129 @@ +.card { + position: relative; + margin: 0.6rem; + box-sizing: border-box; + min-height: 2rem; + border-radius: 0.5rem; + padding: 0.6rem; + font-size: 1.3rem; + color: #666; +} + +.card_width10 { + width: calc(10% - 12px); +} + +.card_width20 { + width: calc(20% - 12px); +} + +.card_width25 { + width: calc(25% - 12px); +} + +.card_width30 { + width: calc(30% - 12px); +} + +.card_width33 { + width: calc(33.333% - 12px); +} + +.card_width40 { + width: calc(40% - 12px); +} + +.card_width45 { + width: calc(45% - 12px); +} + +.card_width50 { + width: calc(50% - 12px); +} + +.card_width60 { + width: calc(60% - 12px); +} + +.card_width66 { + width: calc(66.666% - 12px); +} + +.card_width70 { + width: calc(70% - 12px); +} + +.card_width75 { + width: calc(75% - 12px); +} + +.card_width80 { + width: calc(80% - 12px); +} + +.card_width90 { + width: calc(90% - 12px); +} + +.card_width100 { + width: calc(100% - 12px); +} + +.card_nopadding { + padding: 0 !important; +} + +.card_nomargin { + width: 100%; + margin: 0 !important; + padding: 0 !important; +} + +.topline { + border-top: 1px solid #ccc; +} + +.bottomline { + border-bottom: 1px solid #e6eef3; + background: #fff; +} + +.card-border { + border: 1px solid #dbe2e6; + background-color: rgba(255, 255, 255, 0.6); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} + +.card-header { + margin-top: -0.6rem; + margin-left: -0.6rem; + margin-right: -0.6rem; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #e1e7eb; + padding: 1rem; +} + +.card-footer { + margin-left: -0.6rem; + margin-right: -0.6rem; + margin-top: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + border-top: 1px solid #e1e7eb; + padding-top: 1rem; + padding-bottom: 1rem; +} + +/* 블랙 테마 */ +[data-theme="black"] { + .card-border { + background-color: rgba(255, 255, 255, 0.065); + border-color: #38383a; + } + .card-header { + border-bottom-color: #3f4042; + } +} diff --git a/apps/cm/src/entities/auth/api/login-service.ts b/apps/cm/src/entities/auth/api/login-service.ts new file mode 100644 index 000000000..c1968fe36 --- /dev/null +++ b/apps/cm/src/entities/auth/api/login-service.ts @@ -0,0 +1,85 @@ +import { AxiosError } from "axios"; +import { + ENDPOINT, + JWT_EXPIRATION_TIME, + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, +} from "@/shared/config"; +import type { LoginService, ILogin } from "../model/login-interface"; +import { tokenManager } from "../lib/token-manager"; + +export class LoginApiService implements LoginService { + private static instance: LoginApiService; + + private constructor() {} + + public static getInstance(): LoginApiService { + if (!LoginApiService.instance) { + LoginApiService.instance = new LoginApiService(); + } + return LoginApiService.instance; + } + + public async login(payload: ILogin): Promise { + try { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { + method: "POST", + body: JSON.stringify({ + userId: payload.userId, + password: payload.password, + }), + }); + + const data = await response.json(); + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; + + if (!accessToken || !tokenId) { + throw new Error("Missing required authentication tokens"); + } + + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + tokenManager.setupRefreshTimer( + () => this.silentRefresh(), + JWT_EXPIRATION_TIME, + ); + } catch (error) { + if (error instanceof AxiosError) { + throw new Error(`Login failed: ${error.response?.data?.message}`); + } + throw new Error(`Login failed: ${error}`); + } + } + + public async silentRefresh(): Promise { + try { + const response = await fetch( + `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, + { method: "PUT" }, + ); + + const data = await response.json(); + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; + + if (!accessToken || !tokenId) { + throw new Error("Missing required authentication tokens"); + } + + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + } catch (error) { + tokenManager.clearAuthToken(); + if (error instanceof AxiosError) { + throw new Error( + `Silent refresh failed: ${error.response?.data?.message}`, + ); + } + throw new Error(`Silent refresh failed: ${error}`); + } + } +} + +export const LoginApi = LoginApiService.getInstance(); diff --git a/apps/cm/src/entities/auth/index.ts b/apps/cm/src/entities/auth/index.ts new file mode 100644 index 000000000..97e142225 --- /dev/null +++ b/apps/cm/src/entities/auth/index.ts @@ -0,0 +1,2 @@ +export { LoginApi } from "./api/login-service"; +export { AuthWrapper } from "./lib/auth-wrapper"; diff --git a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx new file mode 100644 index 000000000..c0be4c2dd --- /dev/null +++ b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { client } from "@/shared/api"; +import type { IUserInfo } from "@/shared/store"; +import { getCookie, useUserStore } from "@/shared/lib"; +import { ENDPOINT, AUTH_USER_ID } from "@/shared/config"; +import { LoginApi } from "../api/login-service"; + +const AuthWrapper = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); + const { userInfo, setUserInfo } = useUserStore((state) => state); + + async function authCheck() { + const userId = getCookie(AUTH_USER_ID); + if (!userId) { + throw new Error("Missing required authentication tokens"); + } + + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + if (!userInfo) setUserInfo(response.data); + } + + useEffect(() => { + (async () => { + try { + await authCheck(); + } catch (error) { + console.error(error); + + try { + await LoginApi.silentRefresh(); + await authCheck(); + } catch (refreshError) { + console.error("Token refresh failed:", refreshError); + setUserInfo(null); + router.push("/auth/login"); + } + } + })(); + }, []); + + return <>{children}; +}; + +export { AuthWrapper }; diff --git a/apps/cm/src/entities/auth/lib/token-manager.ts b/apps/cm/src/entities/auth/lib/token-manager.ts new file mode 100644 index 000000000..0e761486a --- /dev/null +++ b/apps/cm/src/entities/auth/lib/token-manager.ts @@ -0,0 +1,59 @@ +import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; +import { getCookie, setCookie, removeCookie } from "@/shared/lib"; + +export class TokenManager { + private static instance: TokenManager; + private refreshTimer: NodeJS.Timeout | null = null; + + private constructor() {} + + public static getInstance(): TokenManager { + if (!TokenManager.instance) { + TokenManager.instance = new TokenManager(); + } + return TokenManager.instance; + } + + public getToken(key: string): string | undefined { + return getCookie(key); + } + + public setToken(key: string, value: string): void { + setCookie(key, value, { + secure: window.location.protocol === "https:", + sameSite: "lax", + }); + } + + public removeToken(key: string): void { + removeCookie(key); + } + + public setupRefreshTimer( + callback: () => Promise, + expirationTime: number, + ): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + callback().catch((error) => { + console.error("Silent refresh failed:", error); + this.clearAuthToken(); + }); + }, expirationTime - 60000); + } + + public clearAuthToken(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + this.removeToken(CLAIM_NAME); + this.removeToken(AUTH_USER_ID); + } +} + +export const tokenManager = TokenManager.getInstance(); diff --git a/apps/cm/src/entities/auth/model/login-interface.ts b/apps/cm/src/entities/auth/model/login-interface.ts new file mode 100644 index 000000000..5aaf53044 --- /dev/null +++ b/apps/cm/src/entities/auth/model/login-interface.ts @@ -0,0 +1,9 @@ +export interface ILogin { + userId: string; + password: string; +} + +export interface LoginService { + login(payload: ILogin): Promise; + silentRefresh(): Promise; +} diff --git a/apps/cm/src/entities/menu-authority/@x/menu.ts b/apps/cm/src/entities/menu-authority/@x/menu.ts new file mode 100644 index 000000000..86479f859 --- /dev/null +++ b/apps/cm/src/entities/menu-authority/@x/menu.ts @@ -0,0 +1 @@ +export { MenuAuthorityApi } from "../api/menu-authority-service"; diff --git a/apps/cm/src/entities/menu-authority/@x/posting.ts b/apps/cm/src/entities/menu-authority/@x/posting.ts new file mode 100644 index 000000000..bfe84fa0d --- /dev/null +++ b/apps/cm/src/entities/menu-authority/@x/posting.ts @@ -0,0 +1 @@ +export { AuthorityButton } from "../ui/authority-button"; diff --git a/apps/cm/src/entities/menu-authority/api/menu-authority-service.ts b/apps/cm/src/entities/menu-authority/api/menu-authority-service.ts new file mode 100644 index 000000000..ec07d88ad --- /dev/null +++ b/apps/cm/src/entities/menu-authority/api/menu-authority-service.ts @@ -0,0 +1,50 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + MenuAuthorityList, + MenuAuthorityParams, + MenuAuthorityPayload, + MenuAuthorityService, + PageMenuAuthority, +} from "../model/menu-authority-interface"; + +export class MenuAuthorityApiService implements MenuAuthorityService { + public async getMenuAuthorityList( + params: MenuAuthorityParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`, + { params }, + ); + return response.data; + } + + public async getPageMenuAuthority( + menuCd: number, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/pageinfo/${menuCd}`, + ); + return response.data; + } + + public async saveMenuAuthority(data: MenuAuthorityPayload[]): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`, + data, + ); + return response.data; + } + + public async getGroupMenuAuthorityList( + params: MenuAuthorityParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/group`, + { params }, + ); + return response.data; + } +} + +export const MenuAuthorityApi = new MenuAuthorityApiService(); diff --git a/apps/cm/src/entities/menu-authority/index.ts b/apps/cm/src/entities/menu-authority/index.ts new file mode 100644 index 000000000..5a1b11606 --- /dev/null +++ b/apps/cm/src/entities/menu-authority/index.ts @@ -0,0 +1,9 @@ +export { MenuAuthorityApi } from "./api/menu-authority-service"; +export { + menuAuthorityFormSchema, + type MenuAuthority, + type MenuAuthorityPayload, + type MenuAuthorityFormData, +} from "./model/menu-authority-interface"; +export { AuthorityButton } from "./ui/authority-button"; +export { ManagementAuthorityDialog } from "./ui/management-authority-dialog"; diff --git a/apps/cm/src/entities/menu-authority/model/menu-authority-interface.ts b/apps/cm/src/entities/menu-authority/model/menu-authority-interface.ts new file mode 100644 index 000000000..7f5401872 --- /dev/null +++ b/apps/cm/src/entities/menu-authority/model/menu-authority-interface.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface MenuAuthorityParams extends PageableParams { + groupId?: string; + menuCd?: number; + useYn?: string; +} + +export interface PageMenuAuthority { + mngAuthrtYn: string; + inqAuthrtYn: string; + regAuthrtYn: string; + mdfcnAuthrtYn: string; + delAuthrtYn: string; +} + +export const menuAuthoritySchema = z.object({ + groupId: z.string(), + groupNm: z.string(), + menuCd: z.number(), + mngAuthrtYn: z.string(), + inqAuthrtYn: z.string(), + regAuthrtYn: z.string(), + mdfcnAuthrtYn: z.string(), + delAuthrtYn: z.string(), + useYn: z.string(), + mode: z.string(), +}); + +export const menuAuthorityPayloadSchema = menuAuthoritySchema.omit({ + groupNm: true, +}); + +export const menuAuthorityFormSchema = z.object({ + menuAuthority: z.array(menuAuthorityPayloadSchema), +}); + +export type MenuAuthority = z.infer; +export type MenuAuthorityList = PageableList; +export type MenuAuthorityPayload = z.infer; +export type MenuAuthorityFormData = z.infer; + +export interface MenuAuthorityService { + getMenuAuthorityList(params: MenuAuthorityParams): Promise; + getGroupMenuAuthorityList( + params: MenuAuthorityParams, + ): Promise; + getPageMenuAuthority(menuCd: number): Promise; + saveMenuAuthority(data: MenuAuthorityPayload[]): Promise; +} diff --git a/apps/cm/src/entities/menu-authority/model/menu-authority-table-columns.tsx b/apps/cm/src/entities/menu-authority/model/menu-authority-table-columns.tsx new file mode 100644 index 000000000..6969a85e9 --- /dev/null +++ b/apps/cm/src/entities/menu-authority/model/menu-authority-table-columns.tsx @@ -0,0 +1,117 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import type { UseFormReturn } from "react-hook-form"; +import { Checkbox } from "@common/components"; +import type { + MenuAuthority, + MenuAuthorityFormData, + MenuAuthorityPayload, +} from "./menu-authority-interface"; + +export const createColumns = ( + form: UseFormReturn, +): ColumnDef[] => { + const getCurrentRow = (i: number) => form.watch("menuAuthority")[i]; + + const handleCheckedChange = + (i: number, key: keyof MenuAuthorityPayload) => (checked: boolean) => { + if (getCurrentRow(i)?.mode === "R") { + form.setValue(`menuAuthority.${i}.mode`, "U"); + } + form.setValue(`menuAuthority.${i}.${key}`, checked ? "Y" : "N"); + }; + + return [ + { + accessorKey: "groupNm", + header: "그룹명", + }, + { + accessorKey: "mngAuthrtYn", + header: "관리권한", + cell: ({ row }) => { + return ( + { + if (checked) { + handleCheckedChange( + row.index, + "regAuthrtYn", + )(checked as boolean); + handleCheckedChange( + row.index, + "mdfcnAuthrtYn", + )(checked as boolean); + handleCheckedChange( + row.index, + "delAuthrtYn", + )(checked as boolean); + handleCheckedChange( + row.index, + "inqAuthrtYn", + )(checked as boolean); + } + handleCheckedChange(row.index, "mngAuthrtYn")(checked as boolean); + }} + /> + ); + }, + }, + { + accessorKey: "regAuthrtYn", + header: "등록권한", + cell: ({ row }) => { + return ( + { + if (checked) { + handleCheckedChange( + row.index, + "inqAuthrtYn", + )(checked as boolean); + } + handleCheckedChange(row.index, "regAuthrtYn")(checked as boolean); + }} + /> + ); + }, + }, + { + accessorKey: "mdfcnAuthrtYn", + header: "수정권한 ", + cell: ({ row }) => { + return ( + + ); + }, + }, + { + accessorKey: "delAuthrtYn", + header: "삭제권한", + cell: ({ row }) => { + return ( + + ); + }, + }, + { + accessorKey: "inqAuthrtYn", + header: "조회권한", + cell: ({ row }) => { + return ( + + ); + }, + }, + ]; +}; diff --git a/apps/cm/src/entities/menu-authority/ui/authority-button.tsx b/apps/cm/src/entities/menu-authority/ui/authority-button.tsx new file mode 100644 index 000000000..257ab6e5d --- /dev/null +++ b/apps/cm/src/entities/menu-authority/ui/authority-button.tsx @@ -0,0 +1,26 @@ +import { useMenuStore } from "@/shared/lib"; +import { Button, cn, type ButtonProps } from "@common/components"; +import type { PageMenuAuthority } from "../model/menu-authority-interface"; + +type AuthorityButtonProps = { + authority: keyof PageMenuAuthority; +} & ButtonProps; + +function AuthorityButton({ + authority, + className, + children, + ...props +}: AuthorityButtonProps) { + const pageMenuAuthority = useMenuStore((state) => state.pageMenuAuthority); + + const isAuthority = pageMenuAuthority?.[authority] === "Y"; + + return ( + + ); +} + +export { AuthorityButton }; diff --git a/apps/cm/src/entities/menu-authority/ui/management-authority-dialog.tsx b/apps/cm/src/entities/menu-authority/ui/management-authority-dialog.tsx new file mode 100644 index 000000000..2d4f6a176 --- /dev/null +++ b/apps/cm/src/entities/menu-authority/ui/management-authority-dialog.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { getCoreRowModel } from "@tanstack/react-table"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@common/components"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib"; +import { + menuAuthorityFormSchema, + type MenuAuthorityFormData, + type MenuAuthorityPayload, +} from "../model/menu-authority-interface"; +import { createColumns } from "../model/menu-authority-table-columns"; +import { MenuAuthorityApi } from "../api/menu-authority-service"; + +type ManagementAuthorityDialogProps = { + menuCd?: number; +} & React.ComponentProps; + +function ManagementAuthorityDialog({ menuCd }: ManagementAuthorityDialogProps) { + const { confirm } = useConfirm(); + const alert = useAlertStore((state) => state.setMessage); + const [open, setOpen] = useState(false); + + const query = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.MENU_AUTHRTS, menuCd], + queryFn: () => MenuAuthorityApi.getMenuAuthorityList({ menuCd }), + enabled: !!menuCd, + }); + + const { mutateAsync: saveMenuAuthority } = useMutationWrapper({ + mutationFn: (data: MenuAuthorityPayload[]) => + MenuAuthorityApi.saveMenuAuthority(data), + onSuccess: () => { + alert("저장되었습니다.", () => setOpen(false)); + }, + }); + + const form = useForm({ + resolver: zodResolver(menuAuthorityFormSchema), + values: { + menuAuthority: query.data?.content ?? [], + }, + }); + + const { tableComponent } = useDataTable({ + query, + tableOptions: { + columns: createColumns(form), + getCoreRowModel: getCoreRowModel(), + }, + }); + + const handleSave = async () => { + if (await confirm("저장하시겠습니까?")) { + const data = form.getValues("menuAuthority"); + const filteredData = data.filter((item) => item.mode !== "R"); + await saveMenuAuthority(filteredData); + } + }; + + return ( + + + + + + + 그룹별 메뉴 권한 + +
{tableComponent}
+ + + + + + +
+
+ ); +} + +export { ManagementAuthorityDialog }; diff --git a/apps/cm/src/entities/menu/api/menu-service.ts b/apps/cm/src/entities/menu/api/menu-service.ts new file mode 100644 index 000000000..07e9e552a --- /dev/null +++ b/apps/cm/src/entities/menu/api/menu-service.ts @@ -0,0 +1,79 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + MenuService, + Menu, + MenuItem, + MenuFormData, + MenuTemporarilyStoredData, + SiteMapFormData, +} from "../model/menu-interface"; + +export class MenuApiService implements MenuService { + async getMenuRoles(siteId: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENU_ROLES}/${siteId}`, + ); + return response.data; + } + + public async getMenuInfo(menuCd: number): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`, + ); + return response.data; + } + + public async createMenu(data: MenuFormData): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.MENUS}`, + data, + ); + return response.data; + } + + public async updateMenu( + menuCd: number, + data: Omit, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`, + data, + ); + return response.data; + } + + public async deleteMenu(menuCd: number): Promise { + await client.delete(`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`); + } + + public async menuTemporaryStorage( + menuCd: number, + data: MenuFormData, + ): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.MENU_TEMPORARY_STORAGE}/${menuCd}`, + data, + ); + return response.data; + } + + public async getMenuTemporarilyStoredData( + menuCd: number, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENU_TEMPORARY_STORAGE}/menu/${menuCd}/user`, + ); + return response.data; + } + + public async publishSiteMap(data: SiteMapFormData["siteMap"]): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.MENUS}/sitemap/publish`, + data, + ); + return response.data; + } +} + +export const MenuApi = new MenuApiService(); diff --git a/apps/cm/src/entities/menu/index.ts b/apps/cm/src/entities/menu/index.ts new file mode 100644 index 000000000..e4c61dbb7 --- /dev/null +++ b/apps/cm/src/entities/menu/index.ts @@ -0,0 +1,12 @@ +export { MenuApi } from "./api/menu-service"; +export { + MENU_TYPE, + menuFormSchema, + siteMapFormSchema, + type MenuType, + type MenuItem, + type MenuFormData, + type SiteMapFormData, + type SiteMapPayloadData, +} from "./model/menu-interface"; +export { MenuWrapper } from "./lib/menu-wrapper"; diff --git a/apps/cm/src/entities/menu/lib/menu-wrapper.tsx b/apps/cm/src/entities/menu/lib/menu-wrapper.tsx new file mode 100644 index 000000000..1f1080cac --- /dev/null +++ b/apps/cm/src/entities/menu/lib/menu-wrapper.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu"; +import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib"; +import { SITE_ID } from "@/shared/config"; +import { MenuApi } from "../api/menu-service"; +import { getCurrentMenu } from "./utils"; + +const MenuWrapper = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); + const pathname = usePathname(); + + const alert = useAlertStore((state) => state.setMessage); + const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } = + useMenuStore((state) => state); + + const getCurrentMenuInfo = async () => { + const menuRoles = await MenuApi.getMenuRoles(SITE_ID); + if (!menuRoles) return; + return getCurrentMenu(menuRoles, pathname ?? ""); + }; + + const getPageMenuAuthority = async () => { + const menuCd = getCookie("menu-code-key"); + if (!menuCd) return; + const response = await MenuAuthorityApi.getPageMenuAuthority( + parseInt(menuCd), + ); + return response; + }; + + const checkInquiryAuthority = () => { + return pageMenuAuthority?.inqAuthrtYn === "Y"; + }; + + useEffect(() => { + (async () => { + const currentMenu = await getCurrentMenuInfo(); + if (currentMenu) setCurrentMenuInfo(currentMenu); + + const response = await getPageMenuAuthority(); + if (response?.length) setPageMenuAuthority(response[0]!); + })(); + }, [pathname]); + + useEffect(() => { + if (!pageMenuAuthority) return; + const inquiryAuthority = checkInquiryAuthority(); + if (!inquiryAuthority) + alert("페이지에 대한 조회 권한이 없습니다.", () => router.push("/")); + }, [pageMenuAuthority]); + + return <>{children}; +}; + +export { MenuWrapper }; diff --git a/apps/cm/src/entities/menu/lib/utils.ts b/apps/cm/src/entities/menu/lib/utils.ts new file mode 100644 index 000000000..1cf4514d9 --- /dev/null +++ b/apps/cm/src/entities/menu/lib/utils.ts @@ -0,0 +1,20 @@ +import type { MenuItem } from "../model/menu-interface"; + +export const getCurrentMenu = ( + menus: MenuItem[], + pathname: string, +): MenuItem | null => { + for (const menu of menus) { + if ( + menu.urlPath !== "" && + (menu.urlPath === pathname || pathname.startsWith(menu.urlPath)) + ) + return menu; + + if (menu.children) { + const children = getCurrentMenu(menu.children, pathname); + if (children) return children; + } + } + return null; +}; diff --git a/apps/cm/src/entities/menu/model/menu-interface.ts b/apps/cm/src/entities/menu/model/menu-interface.ts new file mode 100644 index 000000000..0c5f4b54c --- /dev/null +++ b/apps/cm/src/entities/menu/model/menu-interface.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +export const MENU_TYPE = { + EMPTY: "EMPTY", + CONTENTS: "CONTENTS", + BOARD: "BOARD", + INSIDE: "INSIDE", + OUTSIDE: "OUTSIDE", + BOTTOM: "BOTTOM", +} as const; + +export type MenuType = (typeof MENU_TYPE)[keyof typeof MENU_TYPE]; + +export interface MenuItem { + menuCd: number; + menuKornNm: string; + menuEngNm: string; + parentId: number | null; + sortSeq: number; + iconNm?: string; + children: MenuItem[] | null; + levelNo: number; + urlPath: string; + menuLnkgTypeCd: string; + menuLnkgSn: number; + menuPath: string; + qrcd: string; + useYn: string; + otptYn: string; + newWndwYn: string; + requiredUrlPath: boolean; +} + +export interface MenuTemporarilyStoredData { + tmprStrgSn: number; + menuCd: number; + menuTmprStrg: string; + regDt: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; +} + +export const menuFormSchema = z.object({ + siteId: z.string(), + menuKornNm: z.string(), + menuSubKornNm: z.string().nullish(), + menuEngNm: z.string().nullish(), + menuSubEngNm: z.string().nullish(), + levelNo: z.number(), + urlPath: z.string().nullish(), + iconNm: z.string().nullish(), + menuLnkgTypeCd: z.enum([ + MENU_TYPE.EMPTY, + MENU_TYPE.CONTENTS, + MENU_TYPE.BOARD, + MENU_TYPE.INSIDE, + MENU_TYPE.OUTSIDE, + MENU_TYPE.BOTTOM, + ]), + menuLnkgSn: z.number().nullish(), + otptYn: z.string().default("Y"), + prntMenuCd: z.number().nullish(), + sortSeq: z.number(), + evlUseYn: z.string().default("Y"), + qrcd: z.string().nullish(), + useYn: z.string().default("Y"), + newWndwYn: z.string().nullish(), + menuLnkgSnNm: z.string().nullish(), + menuPicUserUnqId: z.string().nullish(), + menuPicUserUnqIdNm: z.string().nullish(), + menuPicDsgnYmd: z.string().nullish(), +}); + +export const siteMapPayloadSchema = z.object({ + menuCd: z.number(), + menuSubKornNm: z.string(), + otptYn: z.string(), +}); + +export const siteMapFormSchema = z.object({ + siteMap: z.array(siteMapPayloadSchema), +}); + +export type MenuFormData = z.infer; +export type SiteMapFormData = z.infer; +export type SiteMapPayloadData = z.infer; +export type Menu = Omit & { + menuCd: number; + iconName?: string; +}; + +export interface MenuService { + getMenuRoles(siteId: string): Promise; + getMenuInfo: (menuCd: number) => Promise; + createMenu: (data: MenuFormData) => Promise; + updateMenu: ( + menuCd: number, + data: Omit, + ) => Promise; + deleteMenu: (menuCd: number) => Promise; + menuTemporaryStorage: (menuCd: number, data: MenuFormData) => Promise; + getMenuTemporarilyStoredData: ( + menuCd: number, + ) => Promise; + publishSiteMap: (data: SiteMapFormData["siteMap"]) => Promise; +} diff --git a/apps/cm/src/pages/auth/login/index.ts b/apps/cm/src/pages/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/cm/src/pages/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/cm/src/pages/auth/login/ui/OtpModal.tsx b/apps/cm/src/pages/auth/login/ui/OtpModal.tsx new file mode 100644 index 000000000..7c6aeb9af --- /dev/null +++ b/apps/cm/src/pages/auth/login/ui/OtpModal.tsx @@ -0,0 +1,153 @@ +import { useState } from "react"; +import { ACCESS_TOKEN, AUTH_USER_ID, REFRESH_TOKEN, CLAIM_NAME } from "@/shared/config"; +import { tokenManager } from "@/entities/auth/lib/token-manager"; +import { + Button, + Dialog, + DialogContent, +} from "@common/components"; + +interface OtpModalProps { + onClose: () => void; + onSuccess: () => void; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function OtpModal({ onClose, onSuccess, open, onOpenChange }: OtpModalProps) { + const [otp, setOtp] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async () => { + try { + setIsLoading(true); + setError(""); + + const tempToken = localStorage.getItem("tempToken"); + console.log("tempToken from localStorage:", tempToken); + + if (!tempToken) { + setError("임시 토큰이 없습니다. 로그인을 다시 시도해주세요."); + return; + } + + // Proxy를 통해 OTP 인증 처리 + const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + otpCode: otp, + tempToken + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + let errorMsg = errorData.message || 'OTP 인증 실패'; + if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { + errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); + } + throw new Error(errorMsg); + } + + const data = await response.json(); + console.log("OTP verification response:", data); + console.log("Response headers:", Object.fromEntries(response.headers.entries())); + console.log("Response data keys:", Object.keys(data)); + console.log("Full response data:", JSON.stringify(data, null, 2)); + + // 2FA 응답에서는 access-token만 있고 token-id는 없을 수 있음 + const accessToken = data[ACCESS_TOKEN] || data.accessToken || data.access_token || data.token || data['ss-token']; + + console.log("Extracted accessToken:", !!accessToken); + console.log("Token value - accessToken:", accessToken?.substring(0, 20) + "..."); + + // access-token이 있는 경우 저장 + if (accessToken) { + console.log("Saving access token from OTP verification"); + tokenManager.setToken(CLAIM_NAME, accessToken); // Authorization으로 저장 + + // 리프레시 토큰도 확인 + const refreshToken = data[REFRESH_TOKEN] || data.refreshToken || data.refresh_token; + if (refreshToken) { + console.log("Saving refresh token"); + tokenManager.setToken(REFRESH_TOKEN, refreshToken); + } + + // userId는 원래 로그인 응답에서 가져온 값 사용 + const originalUserId = data.userId; + if (originalUserId) { + console.log("Saving userId from original login response"); + tokenManager.setToken(AUTH_USER_ID, originalUserId); + } + + console.log("All tokens saved successfully"); + } else { + console.log("No access token in OTP response"); + console.log("Available data keys:", Object.keys(data)); + console.log("Data values:", Object.entries(data).map(([k, v]) => `${k}: ${typeof v === 'string' ? v.substring(0, 20) + '...' : v}`)); + } + + onSuccess(); + } catch (error: unknown) { + console.error("OTP verification error:", error); + if (error instanceof Error) { + setError(error.message); + } else { + setError("OTP 인증 실패"); + } + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + onClose(); + }; + + return ( + + +
+

OTP 인증

+ +
+ setOtp(e.target.value)} + disabled={isLoading} + /> + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/cm/src/pages/auth/login/ui/TwoFAModal.tsx b/apps/cm/src/pages/auth/login/ui/TwoFAModal.tsx new file mode 100644 index 000000000..d66747b0c --- /dev/null +++ b/apps/cm/src/pages/auth/login/ui/TwoFAModal.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { + Button, + Dialog, + DialogContent, +} from "@common/components"; + +interface TwoFAModalProps { + onClose: () => void; + onSuccess: () => void; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function TwoFAModal({ onClose, onSuccess, open, onOpenChange }: TwoFAModalProps) { + const [qrImage, setQrImage] = useState(""); + const [secret, setSecret] = useState(""); + const [otp, setOtp] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isSetupLoading, setIsSetupLoading] = useState(true); + + useEffect(() => { + const fetchQR = async () => { + try { + setIsSetupLoading(true); + setError(""); + + const tempToken = localStorage.getItem("tempToken"); + if (!tempToken) { + setError("임시 토큰이 없습니다"); + return; + } + + // Proxy를 통해 2FA setup 처리 + const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tempToken }), + }); + + if (!response.ok) { + const errorData = await response.json(); + let errorMsg = errorData.message || 'QR코드 발급 실패'; + if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { + errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); + } + throw new Error(errorMsg); + } + + const data = await response.json(); + console.log("2FA setup response:", data); + console.log("Response headers:", Object.fromEntries(response.headers.entries())); + + // Proxy 응답에서 QR 데이터 추출 + setQrImage(data.qrImage); + setSecret(data.secret); + } catch (error: unknown) { + console.error("2FA setup error:", error); + if (error instanceof Error) { + setError(error.message); + } else { + setError("QR코드 발급 실패"); + } + } finally { + setIsSetupLoading(false); + } + }; + + if (open) { + fetchQR(); + } + }, [open]); + + const handleEnable2FA = async () => { + try { + setIsLoading(true); + setError(""); + + const tempToken = localStorage.getItem("tempToken"); + if (!tempToken) { + setError("임시 토큰이 없습니다"); + return; + } + + // Proxy를 통해 2FA enable 처리 + const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/enable`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': tempToken, + }, + body: JSON.stringify({ + otpCode: otp, + tempToken: tempToken, + secret: secret, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + let errorMsg = errorData.message || '2FA 등록 실패'; + if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { + errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); + } + throw new Error(errorMsg); + } + + const data = await response.json(); + console.log("2FA enable response:", data); + console.log("Response headers:", Object.fromEntries(response.headers.entries())); + console.log("Response data keys:", Object.keys(data)); + + // 2FA 설정 완료 후에는 토큰을 저장하지 않음 (실제 로그인은 OTP에서 처리) + console.log("2FA setup completed successfully"); + + onSuccess(); + } catch (error: unknown) { + console.error("2FA enable error:", error); + if (error instanceof Error) { + setError(error.message); + } else { + setError("2FA 등록 실패"); + } + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + onClose(); + }; + + return ( + + +
+

2FA 등록

+ + {isSetupLoading ? ( +
+
QR코드 로딩 중...
+
+ ) : ( + <> + {qrImage && ( +
+ QR코드 +
+ )} + +
+ setOtp(e.target.value)} + disabled={isLoading} + /> + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ + )} +
+
+
+ ); +} diff --git a/apps/cm/src/pages/auth/login/ui/login-page.tsx b/apps/cm/src/pages/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..251cd2819 --- /dev/null +++ b/apps/cm/src/pages/auth/login/ui/login-page.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Image from "next/image"; +import { images } from "@common/assets"; +import { LoginApi } from "@/entities/auth"; +import { ENDPOINT } from "@/shared/config"; +import OtpModal from "./OtpModal"; +import TwoFAModal from "./TwoFAModal"; +import "./login.css"; +import { useRouter } from "next/navigation"; + +function LoginPage() { + const [isOtpEnabled, setIsOtpEnabled] = useState(false); + const [showOtpModal, setShowOtpModal] = useState(false); + const [showTwoFAModal, setShowTwoFAModal] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const router = useRouter(); + + // 모달 상태 디버깅 + useEffect(() => { + console.log("Modal states - OTP:", showOtpModal, "2FA:", showTwoFAModal); + }, [showOtpModal, showTwoFAModal]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(""); + + try { + const formData = new FormData(e.target as HTMLFormElement); + const userId = formData.get("id") as string; + const password = formData.get("password") as string; + + console.log("Login attempt - OTP enabled:", isOtpEnabled); + console.log("User ID:", userId); + + if (isOtpEnabled) { + // 2FA 로그인 - Proxy를 통해 처리 (2FA 전용 엔드포인트 사용) + console.log("Starting 2FA login..."); + + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN_2FA}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId, + password, + provider: 'password' + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + let errorMsg = errorData.message || '로그인 실패'; + if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { + errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); + } + throw new Error(errorMsg); + } + + const data = await response.json(); + console.log("2FA login response:", data); + console.log("Response headers:", Object.fromEntries(response.headers.entries())); + + // 2FA 로그인 응답에는 tempToken과 registered2FA만 포함됨 + if (!data.tempToken) { + throw new Error("Missing tempToken in 2FA login response"); + } + + // 임시 토큰을 localStorage에 저장 (OTP 인증용) + localStorage.setItem('tempToken', data.tempToken); + console.log("tempToken saved:", data.tempToken); + + if (data.registered2FA) { + console.log("User has 2FA registered, showing OTP modal"); + setShowOtpModal(true); + } else { + console.log("User doesn't have 2FA registered, showing 2FA setup modal"); + setShowTwoFAModal(true); + } + } else { + // 기본 로그인 - proxy를 통해 처리 + console.log("Starting basic login..."); + await LoginApi.login({ userId, password }); + window.location.href = "/dashboard"; + } + } catch (error: unknown) { + console.error("Login error:", error); + + // 에러 처리 + if (error instanceof Error) { + setError(error.message); + } else { + setError("로그인에 실패했습니다. 아이디와 비밀번호를 확인해주세요."); + } + } finally { + setIsLoading(false); + } + }; + + const handleOtpSuccess = () => { + console.log("OTP authentication successful, redirecting to dashboard"); + localStorage.removeItem("tempToken"); // 임시 토큰 제거 + router.push("/dashboard"); + }; + + const handleTwoFASuccess = () => { + console.log("2FA setup successful, redirecting to dashboard"); + localStorage.removeItem("tempToken"); // 임시 토큰 제거 + router.push("/dashboard"); + }; + + const handleOtpClose = () => { + console.log("OTP modal closed"); + setShowOtpModal(false); + localStorage.removeItem("tempToken"); + }; + + const handleTwoFAClose = () => { + console.log("2FA modal closed"); + setShowTwoFAModal(false); + localStorage.removeItem("tempToken"); + }; + + return ( + <> +
+
+
+

+ 유역하수도 통합플랫폼 +
+ 통합관리시스템 +

+

유역하수도 통합플랫폼에 오신 것을 환영합니다.

+
+
+ logo +
+
+
+
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

    +
    + + +
    +
  • +
+
+
+ + +
+
+ setIsOtpEnabled(e.target.checked)} + disabled={isLoading} + /> + +
+
+ + {error && ( +
+ {error} +
+ )} +
+
+

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, + 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ + {/* OTP 인증 모달 */} + + + {/* 2FA 등록 모달 */} + + + ); +} + +export { LoginPage }; diff --git a/apps/cm/src/pages/auth/login/ui/login.css b/apps/cm/src/pages/auth/login/ui/login.css new file mode 100644 index 000000000..831cc13a9 --- /dev/null +++ b/apps/cm/src/pages/auth/login/ui/login.css @@ -0,0 +1,311 @@ +/** +* OLYM Cascading Style Sheets +* File Name : login.css +* Description : 로그인 스타일시트 +* Author : Web business Team / Choi Hyun Mi +* Email : desingtj@olym.co.kr +* Date : 2023.07.20 +* Update : 2023.07.26 +* Copyright(c) 2023 OLYM Communications. All Rights Reserved. +*/ +html, +body { + background: #fff; +} +.fx { + display: flex; + -ms-display: flex; + -webkit-display: flex; + box-sizing: border-box; +} +.login { + width: 100%; + height: 100vh; +} +.login .left_box { + width: 100%; + background: url(../../../../../public/images/bg_login.jpg) no-repeat + center/cover; + flex-direction: column; + justify-content: space-between; + padding: 7% 5% 5% 5%; +} +.login .left_box .text h1 { + font-size: 53px; + color: #fff; +} +.login .left_box .text h1 span { + font-weight: 800; +} +.login .left_box .text p { + margin-top: 40px; + font-size: 18px; + color: #fff; +} +.login .login_box { + width: 100%; + flex-direction: column; + justify-content: center; + padding: 4% 12% 4% 6%; +} +.login .login_box > ul { + flex-direction: column; + gap: 40px; +} +.login .login_box > ul > li h2 { + font-size: 22px; + font-weight: 600; + color: #2640a5; + margin-bottom: 14px; +} +.login .login_box > ul > li .input_st { + position: relative; +} +.login .login_box > ul > li .input_st .diveicon { + position: absolute; + top: 50%; + left: 28px; + transform: translateY(-50%); + font-family: "Xeicon"; + font-size: 25px; + color: #003288; +} +.login .login_box > ul > li input { + width: 100%; + padding: 20px 20px 20px 70px; + background: #f7f7f7; + box-sizing: border-box; + border: none; + border-radius: 5px; + font-size: 18px; + font-weight: 700; + color: #000; + height: auto; +} +.login .login_box > ul > li input:focus { + outline: 1px solid #3c5fe5; +} +.login .login_box > ul > li input::placeholder { + color: #afabab; + font-weight: 500; +} +.login .login_box .bottom_box { + justify-content: space-between; + gap: 20px; + padding: 50px 0 45px 0; +} +.login .login_box .bottom_box .checks { + display: flex; + align-items: center; + gap: 8px; +} +.login .login_box .bottom_box .checks input[type="checkbox"] { + width: 18px; + height: 18px; +} +.login .login_box .bottom_box .checks label { + font-size: 14px; +} +.login .login_box .bottom_box .pw_message { + align-items: center; + gap: 5px; + color: #636363; + font-weight: 500; + border-bottom: 1px solid #ccc; + padding-bottom: 5px; +} +.login .login_box .bottom_box .pw_message i { + color: #327af5; + font-size: 20px; +} +.login .login_box .login_btn { + position: relative; + border-radius: 5px; + padding: 24px 15px; + background: linear-gradient( + to left, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + overflow: hidden; + justify-content: center; + align-items: center; + gap: 10px; + color: #fff; + cursor: pointer; +} +.login .login_box .login_btn span { + font-family: "Montserrat"; + font-size: 19px; + font-weight: 700; + position: relative; +} +.login .login_box .login_btn i { + font-size: 22px; + transform: translateY(-2px); +} +.login .login_box .login_btn:before { + display: block; + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + background: linear-gradient( + to right, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + transition: opacity 0.5s; +} +.login .login_box .login_btn:hover::before { + opacity: 1; +} +.login .footer .copy { + margin-top: 36px; + justify-content: space-between; + align-items: center; + gap: 3%; +} +.login .footer .copy p { + font-size: 13px; + color: #d4d4d4; + width: 44%; + text-align: center; +} +.login .footer .copy::before, +.login .login_box .copy::after { + content: ""; + width: 20%; + height: 1px; + background: #d4d4d4; +} +.login .footer .fot_btn { + gap: 20px; + margin: 30px 0 10px 0; +} +.login .footer .fot_btn a { + color: #333; + font-weight: 700; + font-size: 13px; +} +.login .footer .fot_btn a:hover { + color: #003288; +} +.login .footer .addr { + color: #666; + font-size: 13px; + gap: 3px 20px; + flex-wrap: wrap; +} +/* 비밀번호 변경 팝업창 */ +.login .modal .modal_container { + width: 750px; +} +.login .pw_desc { + padding: 10px 10px 30px 10px; + color: #666; +} +.login .pw_desc p { + font-size: 1.25rem; + color: #000; + font-weight: 700; + margin-bottom: 10px; +} +.login .pw_desc span { + color: #f60; + font-weight: 700; +} +.login .modal .form_write { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + padding: 20px 50px; +} +@media (max-width: 1024px) { + .login { + flex-direction: column; + height: auto; + } + .login .left_box { + padding: 30px; + flex-direction: column-reverse; + gap: 20px; + } + .login .left_box .text h1 { + font-size: 30px; + line-height: 1.2em; + } + .login .left_box .text p { + margin-top: 20px; + font-size: 14px; + } + .login .left_box .img img { + height: 40px; + } + .login .login_box { + padding: 30px; + } + .login .login_box > ul { + gap: 20px; + } + .login .login_box > ul > li h2 { + margin-bottom: 7px; + } + .login .login_box > ul > li .input_st::after { + left: 15px; + font-size: 20px; + } + .login .login_box > ul > li input { + padding: 15px 10px 15px 50px; + font-size: 16px; + } + .login .login_box .bottom_box { + flex-direction: column; + align-items: flex-start; + padding: 30px 0; + } + .login .login_box .bottom_box .id_save label { + font-size: 14px; + } + .login .login_box .login_btn { + padding: 15px; + } + .login .login_box .login_btn span { + font-size: 16px; + } + .login .login_box .login_btn i { + font-size: 18px; + transform: translateY(-1px); + } + .login .login_box .copy p { + width: 100%; + } + .login .login_box .copy::before, + .login .login_box .copy::after { + display: none; + } + .login .footer .addr { + font-size: 11px; + gap: 3px 10px; + } + .login .modal .modal_container { + width: 90%; + } + .login .pw_desc { + padding: 0 0 20px 0; + } + .login .modal .form_write { + padding: 10px; + } +} +/* // login */ diff --git a/apps/cm/src/pages/dashboard/index.ts b/apps/cm/src/pages/dashboard/index.ts new file mode 100644 index 000000000..5b787c2e2 --- /dev/null +++ b/apps/cm/src/pages/dashboard/index.ts @@ -0,0 +1 @@ +export { DashboardPage } from "./ui/dashboard-page"; diff --git a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx new file mode 100644 index 000000000..3b59d4a23 --- /dev/null +++ b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx @@ -0,0 +1,94 @@ +"use client"; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { tokenManager } from '@/entities/auth/lib/token-manager'; +import { client } from '@/shared/api'; +import { ENDPOINT } from '@/shared/config'; + +interface UserInfo { + email?: string; + userId?: string; + userName?: string; + twoFactorEnabled?: boolean; +} + +function DashboardPage() { + const [userInfo, setUserInfo] = useState(null); + const [error, setError] = useState(''); + const router = useRouter(); + + useEffect(() => { + const fetchUserInfo = async () => { + console.log('fetchUserInfo called'); + + try { + // tokenManager를 통해 토큰 확인 + const accessToken = tokenManager.getToken('Authorization'); + const userId = tokenManager.getToken('token-id'); + + if (!accessToken || !userId) { + console.log('No tokens found, redirecting to login'); + setError('로그인이 필요합니다.'); + router.push('/login'); + return; + } + + console.log('Fetching user info with userId:', userId); + + // client를 사용하여 API 호출 (자동 토큰 처리) + const response = await client.get(`${ENDPOINT.USER_SERVICE.USERS}/${userId}`); + + if (response.data) { + console.log('User info received:', response.data); + setUserInfo(response.data); + } else { + setError('사용자 정보를 불러올 수 없습니다.'); + } + } catch (error) { + console.error('Error fetching user info:', error); + if (error instanceof Error && error.message.includes('401')) { + setError('인증이 만료되었습니다. 다시 로그인해주세요.'); + tokenManager.clearAuthToken(); + router.push('/login'); + } else { + setError('네트워크 오류가 발생했습니다.'); + } + } + }; + + fetchUserInfo(); + }, [router]); + + const handleLogout = () => { + console.log('Logging out...'); + // localStorage 정리 + localStorage.removeItem('tempToken'); + // tokenManager를 통해 쿠키 정리 + tokenManager.clearAuthToken(); + router.push('/login'); + }; + + const handleChangePassword = () => { + router.push('/otp?next=change-password'); + }; + + return ( +
+

대시보드

+ {error &&
{error}
} + {userInfo ? ( +
+
이메일: {userInfo.email || userInfo.userId}
+
이름: {userInfo.userName || '-'}
+
2FA 활성화: {userInfo.twoFactorEnabled ? '예' : '아니오'}
+
+ ) : ( + !error &&
사용자 정보를 불러오는 중...
+ )} + + +
+ ); +} + +export { DashboardPage }; \ No newline at end of file diff --git a/apps/cm/src/shared/api/client.ts b/apps/cm/src/shared/api/client.ts new file mode 100644 index 000000000..cf37e6671 --- /dev/null +++ b/apps/cm/src/shared/api/client.ts @@ -0,0 +1,11 @@ +import { Client } from "@common/network"; +import { BASE_URL } from "../config/api"; +import { SITE_ID } from "../config/env"; +import { setCookie } from "../lib"; + +setCookie("site-id-key", SITE_ID); + +export const client = Client.getInstance({ + baseURL: BASE_URL, + siteId: SITE_ID, +}); diff --git a/apps/cm/src/shared/api/index.ts b/apps/cm/src/shared/api/index.ts new file mode 100644 index 000000000..b7b9d6121 --- /dev/null +++ b/apps/cm/src/shared/api/index.ts @@ -0,0 +1 @@ +export { client } from "./client"; diff --git a/apps/cm/src/shared/config/api.ts b/apps/cm/src/shared/config/api.ts new file mode 100644 index 000000000..d6065bfc7 --- /dev/null +++ b/apps/cm/src/shared/config/api.ts @@ -0,0 +1,73 @@ +import { PROXY_HOST } from "./env"; + +export const BASE_URL = `${PROXY_HOST}/server`; + +export const ENDPOINT = { + ATTACH_SERVICE: { + TUS_UPLOAD: "/attach-service/api/v1/tus/upload", + EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor", + IMAGES: "/attach-service/api/v1/images", + MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail", + ATTACHMENTS: "/attach-service/api/v1/atfl/attachments", + DOWNLOAD: "/attach-service/api/v1/atflcd/download", + STREAM_VIDEO: "/attach-service/api/v1/stream/video", + }, + USER_SERVICE: { + LOGIN: "/user-service/login", + LOGIN_2FA: "/user-service/api/v1/auth/login", + USERS: "/user-service/api/v1/users", + SILENT_REFRESH: "/user-service/api/v1/users/token/refresh", + AUTHORIZATIONS: "/user-service/api/v1/authorizations", + ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations", + ROLES: "/user-service/api/v1/roles", + LOGINLOG: "/user-service/api/v1/login-logs", + PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs", + OTP_VERIFY: "/user-service/api/v1/auth/2fa/verify", + TWOFA_SETUP: "/user-service/api/v1/auth/2fa/setup", + TWOFA_ENABLE: "/user-service/api/v1/auth/2fa/enable", + }, + CMS_SERVICE: { + DASHBOARD: "/cms-service/api/v1/dashboard", + SITES: "/cms-service/api/v1/sites", + IPS: "/cms-service/api/v1/site-ips", + DOMAINS: "/cms-service/api/v1/site-dmns", + POSTINGS: "/cms-service/api/v1/site-pstgs", + CODE: "/cms-service/api/v1/code/cache", + MENUS: "/cms-service/api/v1/menus", + MENU_ROLES: "/cms-service/api/v1/menu-roles", + MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts", + MENU_TEMPORARY_STORAGE: "/cms-service/api/v1/menu-tmpr-strg", + MENU_HISTORY: "/cms-service/api/v1/menu-hstry", + CONTENTS: "/cms-service/api/v1/contents", + CONTENTS_DETAILS: "/cms-service/api/v1/contents-details", + ORGANIZATIONS: "/cms-service/api/v1/ognzs", + USER_ORGANIZATION: "/cms-service/api/v1/user-ognz", + IMAGES: "/cms-service/api/v1/images", + BANNERS: "/cms-service/api/v1/bnrs", + TEMPLATE: "/cms-service/api/v1/tmplt", + TERMS: "/cms-service/api/v1/trms", + LWPO_CODE: "/cms-service/api/v1/lwpo-cds", + UP_CODE: "/cms-service/api/v1/up-codes", + POPUPS: "/cms-service/api/v1/popups", + SCHEDULES: "/cms-service/api/v1/schdls", + SURVEY: "/cms-service/api/v1/srvy", + SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem", + SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl", + ZIPS: "/cms-service/api/v1/zips", + GROUPS: "/cms-service/api/v1/groups", + GROUP_USERS: "/cms-service/api/v1/group-users", + MANAGER_IP: "/cms-service/api/v1/mgrip", + APILOG: "/cms-service/api/v1/api-logs", + SITE_AUTHORITY: "/cms-service/api/v1/site-authrt", + CONNECTION_LOG: "/cms-service/api/v1/cntnlog", + }, + BOARD_SERVICE: { + BBS: "/board-service/api/v1/bbs", + BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt", + POSTS: "/board-service/api/v1/posts", + POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts", + POST_EVALUATION: "/board-service/api/v1/pstEvl", + }, +}; + +export const JWT_EXPIRATION_TIME = 1800000; diff --git a/apps/cm/src/shared/config/env.ts b/apps/cm/src/shared/config/env.ts new file mode 100644 index 000000000..c6373724f --- /dev/null +++ b/apps/cm/src/shared/config/env.ts @@ -0,0 +1,11 @@ +export const PORT = process.env.NEXT_PUBLIC_PORT || "3000"; +export const PROXY_HOST = + process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`; +export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL; +export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS"; +export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; +export const ACCESS_TOKEN = + process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token"; +export const REFRESH_TOKEN = + process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token"; +export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; diff --git a/apps/cm/src/shared/config/index.ts b/apps/cm/src/shared/config/index.ts new file mode 100644 index 000000000..cd83447d5 --- /dev/null +++ b/apps/cm/src/shared/config/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./env"; diff --git a/apps/cm/src/shared/lib/cookie.ts b/apps/cm/src/shared/lib/cookie.ts new file mode 100644 index 000000000..8f5d0011b --- /dev/null +++ b/apps/cm/src/shared/lib/cookie.ts @@ -0,0 +1,29 @@ +import Cookies from "js-cookie"; + +export function getCookie(name: string): string | undefined { + if (typeof window === "undefined") { + return undefined; + } + return Cookies.get(name); +} + +export function setCookie( + name: string, + value: string, + options?: Cookies.CookieAttributes, +): void { + if (typeof window === "undefined") { + return; + } + Cookies.set(name, value, options); +} + +export function removeCookie( + name: string, + options?: Cookies.CookieAttributes, +): void { + if (typeof window === "undefined") { + return; + } + Cookies.remove(name, options); +} diff --git a/apps/cm/src/shared/lib/index.ts b/apps/cm/src/shared/lib/index.ts new file mode 100644 index 000000000..5ac79bf08 --- /dev/null +++ b/apps/cm/src/shared/lib/index.ts @@ -0,0 +1,8 @@ +export { getCookie, setCookie, removeCookie } from "./cookie"; +export { useMutationWrapper } from "./use-mutation-wrapper"; +export { useAlertStore } from "./use-alert-store"; +export { useDataTable } from "./use-data-table"; +export { useMenuStore } from "./use-menu-store"; +export { useUserStore } from "./use-user-store"; +export { usePrompt } from "./use-prompt"; +export { useSelectPrompt } from "./use-select-prompt"; diff --git a/apps/cm/src/shared/lib/match-query-status.ts b/apps/cm/src/shared/lib/match-query-status.ts new file mode 100644 index 000000000..0e5f45829 --- /dev/null +++ b/apps/cm/src/shared/lib/match-query-status.ts @@ -0,0 +1,65 @@ +import { type UseQueryResult } from "@tanstack/react-query"; +import type { PageableList } from "../model"; + +/** + * Match the state of a query to a set of components. + * + * Useful for rendering different UI based on the state of a query. + */ +export function matchQueryStatus( + query: UseQueryResult>, + options: { + Loading?: JSX.Element; + Errored?: JSX.Element | ((error: unknown) => JSX.Element); + Empty?: JSX.Element; + Success: ( + data: UseQueryResult> & { + data: NonNullable["data"]>; + }, + ) => JSX.Element; + }, +): JSX.Element; +export function matchQueryStatus( + query: UseQueryResult>, + options: { + Loading?: JSX.Element; + Errored?: JSX.Element | ((error: unknown) => JSX.Element); + Success: (data: UseQueryResult>) => JSX.Element; + }, +): JSX.Element; +export function matchQueryStatus( + query: UseQueryResult>, + { + Loading, + Errored, + Empty, + Success, + }: { + Loading?: JSX.Element; + Errored?: JSX.Element | ((error: unknown) => JSX.Element); + Empty?: JSX.Element; + Success: (data: UseQueryResult>) => JSX.Element; + }, +): JSX.Element { + if (query.isLoading && Loading) { + return Loading; + } + + if (query.isError && Errored) { + if (typeof Errored === "function") { + return Errored(query.error); + } + return Errored; + } + + const isEmpty = + query.data?.content === undefined || + query.data?.content === null || + (Array.isArray(query.data?.content) && query.data?.content.length === 0); + + if (isEmpty && Empty) { + return Empty; + } + + return Success(query); +} diff --git a/apps/cm/src/shared/lib/use-alert-store.ts b/apps/cm/src/shared/lib/use-alert-store.ts new file mode 100644 index 000000000..c21a00ed3 --- /dev/null +++ b/apps/cm/src/shared/lib/use-alert-store.ts @@ -0,0 +1,14 @@ +import { useStore } from "zustand"; +import { useContext } from "react"; +import type { AlertStore } from "../store/alert-store"; +import { StoreContext } from "../providers/store-provider"; + +export const useAlertStore = (selector: (store: AlertStore) => T): T => { + const storeContext = useContext(StoreContext); + + if (!storeContext) { + throw new Error("useAlertStore must be used within StoreProvider"); + } + + return useStore(storeContext.alertStore, selector); +}; diff --git a/apps/cm/src/shared/lib/use-attachment-store.ts b/apps/cm/src/shared/lib/use-attachment-store.ts new file mode 100644 index 000000000..285699cc6 --- /dev/null +++ b/apps/cm/src/shared/lib/use-attachment-store.ts @@ -0,0 +1,14 @@ +import { useStore } from "zustand"; +import { useContext } from "react"; +import type { AttachmentStore } from "../store/attachment-store"; +import { StoreContext } from "../providers/store-provider"; + +export const useAttachmentStore = ( + selector: (store: AttachmentStore) => T, +): T => { + const store = useContext(StoreContext); + if (!store) { + throw new Error("useAttachmentStore must be used within StoreProvider"); + } + return useStore(store.attachmentStore, selector); +}; diff --git a/apps/cm/src/shared/lib/use-data-table.tsx b/apps/cm/src/shared/lib/use-data-table.tsx new file mode 100644 index 000000000..51638f746 --- /dev/null +++ b/apps/cm/src/shared/lib/use-data-table.tsx @@ -0,0 +1,42 @@ +import type { HTMLAttributes } from "react"; +import type { UseQueryResult } from "@tanstack/react-query"; +import { useReactTable, type TableOptions } from "@tanstack/react-table"; +import { DataTable } from "@common/components"; +import { NoData } from "../ui/no-data"; +import type { PageableList } from "../model"; +import { matchQueryStatus } from "./match-query-status"; + +type UseDataTableOptions = { + query: UseQueryResult>; + tableOptions: Omit, "data" | "rowCount">; + dataTableOptions?: DataTableOptions; +}; + +type DataTableOptions = { + variant?: "default" | "secondary" | "line"; + className?: HTMLAttributes["className"]; + viewPagination?: boolean; + onRowClick?: (row: T) => void; +}; + +export const useDataTable = ({ + query, + tableOptions, + dataTableOptions, +}: UseDataTableOptions) => { + const table = useReactTable({ + ...tableOptions, + data: query.data?.content ?? [], + rowCount: query.data?.totalElements ?? 0, + }); + + return { + tableComponent: matchQueryStatus(query, { + Empty: , + Success: () => { + return ; + }, + }), + tableInstance: table, + }; +}; diff --git a/apps/cm/src/shared/lib/use-menu-store.ts b/apps/cm/src/shared/lib/use-menu-store.ts new file mode 100644 index 000000000..66e41c76c --- /dev/null +++ b/apps/cm/src/shared/lib/use-menu-store.ts @@ -0,0 +1,14 @@ +import { useStore } from "zustand"; +import { useContext } from "react"; +import type { MenuStore } from "../store/menu-store"; +import { StoreContext } from "../providers/store-provider"; + +export const useMenuStore = (selector: (store: MenuStore) => T): T => { + const storeContext = useContext(StoreContext); + + if (!storeContext) { + throw new Error("useMenuStore must be used within StoreProvider"); + } + + return useStore(storeContext.menuStore, selector); +}; diff --git a/apps/cm/src/shared/lib/use-mutation-wrapper.tsx b/apps/cm/src/shared/lib/use-mutation-wrapper.tsx new file mode 100644 index 000000000..cea1a8f83 --- /dev/null +++ b/apps/cm/src/shared/lib/use-mutation-wrapper.tsx @@ -0,0 +1,29 @@ +import { AxiosError } from "axios"; +import { useMutation, type UseMutationOptions } from "@tanstack/react-query"; +import { useAlertStore } from "./use-alert-store"; + +export const useMutationWrapper = < + TData = unknown, + TError = unknown, + TVariables = void, + TContext = unknown, +>( + mutationOptions: UseMutationOptions, +) => { + const alert = useAlertStore((state) => state.setMessage); + return useMutation({ + ...mutationOptions, + onError: (error) => { + let errorMessage = ""; + + if (error instanceof AxiosError) { + if (error.response?.data?.message) + errorMessage += `${error.response.data.message}\n`; + if (error.response?.data?.errors[0]?.message) + errorMessage += `${error.response.data.errors[0].message}\n`; + } + + alert(`${errorMessage}`); + }, + }); +}; diff --git a/apps/cm/src/shared/lib/use-prompt.tsx b/apps/cm/src/shared/lib/use-prompt.tsx new file mode 100644 index 000000000..f36858eb0 --- /dev/null +++ b/apps/cm/src/shared/lib/use-prompt.tsx @@ -0,0 +1,26 @@ +import { useContext } from "react"; +import { PromptContext } from "../providers/prompt-provider"; + +export const usePrompt = () => { + const { message, resolve, setMessage, setResolve } = + useContext(PromptContext); + + const prompt = (message: string) => { + setMessage(message); + return new Promise<{ resolved: boolean; value?: string }>((resolve) => { + setResolve(() => resolve); + }); + }; + + const onSubmit = (value: string) => { + resolve?.({ resolved: true, value }); + setMessage(""); + }; + + const onCancel = () => { + resolve?.({ resolved: false }); + setMessage(""); + }; + + return { message, isOpen: !!message, prompt, onSubmit, onCancel }; +}; diff --git a/apps/cm/src/shared/lib/use-select-prompt.tsx b/apps/cm/src/shared/lib/use-select-prompt.tsx new file mode 100644 index 000000000..357be7a36 --- /dev/null +++ b/apps/cm/src/shared/lib/use-select-prompt.tsx @@ -0,0 +1,48 @@ +import { useContext, useEffect } from "react"; +import { PromptContext } from "../providers/prompt-provider"; + +type SelectData = { + label: string; + value: string; +}; + +export const useSelectPrompt = (data?: SelectData[]) => { + const { + selectData, + selectionGuide, + resolve, + setResolve, + setSelectData, + setSelectionGuide, + } = useContext(PromptContext); + + const prompt = (message: string) => { + setSelectionGuide(message); + return new Promise<{ resolved: boolean; value?: string }>((resolve) => { + setResolve(() => resolve); + }); + }; + + const onSubmit = (value: string) => { + resolve?.({ resolved: true, value }); + setSelectionGuide(""); + }; + + const onCancel = () => { + resolve?.({ resolved: false }); + setSelectionGuide(""); + }; + + useEffect(() => { + if (data) setSelectData(data); + }, [data]); + + return { + selectData, + selectionGuide, + isOpen: !!selectionGuide, + prompt, + onSubmit, + onCancel, + }; +}; diff --git a/apps/cm/src/shared/lib/use-tus.ts b/apps/cm/src/shared/lib/use-tus.ts new file mode 100644 index 000000000..9d4bc7c00 --- /dev/null +++ b/apps/cm/src/shared/lib/use-tus.ts @@ -0,0 +1,90 @@ +import { useEffect, useState } from "react"; +import { type HttpResponse, Upload } from "tus-js-client"; +import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "../config"; +import { useAttachmentStore } from "./use-attachment-store"; +import { useAlertStore } from "./use-alert-store"; +import { getCookie } from "./cookie"; + +type TusProps = { + accept?: string; +}; + +export const useTus = ({ accept }: TusProps = {}) => { + const alert = useAlertStore((state) => state.setMessage); + const { attachmentFileCode, setAttachmentFileCode } = useAttachmentStore( + (state) => state, + ); + + const [file, setFile] = useState(null); + const [upload, setUpload] = useState(null); + const [response, setResponse] = useState(null); + const [progress, setProgress] = useState(0); + const [isValid, setIsValid] = useState(false); + + const metadata = { + filename: file?.name ?? "", + filetype: file?.type ?? "", + atflCd: attachmentFileCode, + }; + + const validFileType = (file: File) => { + const allowedExtensions = accept?.split(",") ?? []; + const fileName = file.name; + const fileExtension = fileName + .substring(fileName.lastIndexOf(".")) + .toLowerCase(); + + if (!allowedExtensions.includes(fileExtension)) { + alert("파일 형식이 올바르지 않습니다."); + setIsValid(false); + return false; + } + + setIsValid(true); + return true; + }; + + const createUpload = () => { + return new Upload(file!, { + endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`, + chunkSize: 10 * 1024 * 1024, + headers: { + [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", + [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", + }, + metadata, + onError(error) { + throw new Error(`Failed because: ${error}`); + }, + onProgress(bytesUploaded, bytesTotal) { + const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); + setProgress(parseInt(percentage)); + }, + onAfterResponse(req, res) { + const fileInfo = res.getHeader("X-File-Info"); + if (fileInfo === undefined || fileInfo === null) return; + + setAttachmentFileCode(JSON.parse(fileInfo)?.atflCd ?? ""); + setResponse(res); + }, + }); + }; + + useEffect(() => { + if (!file) return; + if (!validFileType(file)) return; + + const upload = createUpload(); + upload.start(); + setUpload(upload); + }, [file]); + + return { + file, + upload, + response, + progress, + isValid, + setFile, + }; +}; diff --git a/apps/cm/src/shared/lib/use-user-store.ts b/apps/cm/src/shared/lib/use-user-store.ts new file mode 100644 index 000000000..748caed4c --- /dev/null +++ b/apps/cm/src/shared/lib/use-user-store.ts @@ -0,0 +1,14 @@ +import { useStore } from "zustand"; +import { useContext } from "react"; +import type { UserStore } from "../store/user-store"; +import { StoreContext } from "../providers/store-provider"; + +export const useUserStore = (selector: (store: UserStore) => T): T => { + const storeContext = useContext(StoreContext); + + if (!storeContext) { + throw new Error("useUserStore must be used within StoreProvider"); + } + + return useStore(storeContext.userStore, selector); +}; diff --git a/apps/cm/src/shared/model/api-interface.ts b/apps/cm/src/shared/model/api-interface.ts new file mode 100644 index 000000000..8218bfb0d --- /dev/null +++ b/apps/cm/src/shared/model/api-interface.ts @@ -0,0 +1,34 @@ +interface SortConfig { + empty: boolean; + sorted: boolean; + unsorted: boolean; +} + +interface PageableConfig { + sort: SortConfig; + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; +} + +export interface PageableList { + content: T[]; + pageable: PageableConfig; + sort: SortConfig; + totalPages: number; + totalElements: number; + number: number; + numberOfElements: number; + size: number; + first: boolean; + last: boolean; + empty: boolean; +} + +export interface PageableParams { + page?: number; + size?: number; + sort?: string; +} diff --git a/apps/cm/src/shared/model/index.ts b/apps/cm/src/shared/model/index.ts new file mode 100644 index 000000000..cf589d200 --- /dev/null +++ b/apps/cm/src/shared/model/index.ts @@ -0,0 +1 @@ +export * from "./api-interface"; diff --git a/apps/cm/src/shared/providers/index.ts b/apps/cm/src/shared/providers/index.ts new file mode 100644 index 000000000..fa8320fe7 --- /dev/null +++ b/apps/cm/src/shared/providers/index.ts @@ -0,0 +1,2 @@ +export { StoreProvider } from "./store-provider"; +export { PromptProvider } from "./prompt-provider"; diff --git a/apps/cm/src/shared/providers/prompt-provider.tsx b/apps/cm/src/shared/providers/prompt-provider.tsx new file mode 100644 index 000000000..71b86388c --- /dev/null +++ b/apps/cm/src/shared/providers/prompt-provider.tsx @@ -0,0 +1,61 @@ +import { createContext, useState } from "react"; + +type SelectData = { + label: string; + value: string; +}; + +interface PromptContext { + message?: string; + selectData?: SelectData[]; + selectionGuide?: string; + resolve?: ({ + resolved, + value, + }: { + resolved: boolean; + value?: string; + }) => void; + setMessage: (message: string) => void; + setSelectData: (selectData: SelectData[]) => void; + setSelectionGuide: (message: string) => void; + setResolve: (resolve: () => void) => void; +} + +export const PromptContext = createContext({ + message: "", + selectData: [], + selectionGuide: "", + resolve() {}, + setMessage() {}, + setSelectData() {}, + setSelectionGuide() {}, + setResolve() {}, +}); + +export const PromptProvider = ({ children }: { children: React.ReactNode }) => { + const [message, setMessage] = useState(); + const [selectData, setSelectData] = useState([]); + const [selectionGuide, setSelectionGuide] = useState(); + const [resolve, setResolve] = + useState< + ({ resolved, value }: { resolved: boolean; value?: string }) => void + >(); + + return ( + + {children} + + ); +}; diff --git a/apps/cm/src/shared/providers/store-provider.tsx b/apps/cm/src/shared/providers/store-provider.tsx new file mode 100644 index 000000000..a0ec17b61 --- /dev/null +++ b/apps/cm/src/shared/providers/store-provider.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { createContext, useRef } from "react"; +import { + createAlertStore, + createAttachmentStore, + createMenuStore, + createUserStore, +} from "../store"; + +type StoreContextType = { + alertStore: ReturnType; + attachmentStore: ReturnType; + menuStore: ReturnType; + userStore: ReturnType; +}; + +export const StoreContext = createContext( + undefined, +); + +export const StoreProvider = ({ children }: { children: React.ReactNode }) => { + const storeRef = useRef(null); + if (storeRef.current === null) { + storeRef.current = { + alertStore: createAlertStore(), + attachmentStore: createAttachmentStore(), + menuStore: createMenuStore(), + userStore: createUserStore(), + }; + } + + return ( + + {children} + + ); +}; diff --git a/apps/cm/src/shared/store/alert-store.ts b/apps/cm/src/shared/store/alert-store.ts new file mode 100644 index 000000000..3bba65848 --- /dev/null +++ b/apps/cm/src/shared/store/alert-store.ts @@ -0,0 +1,25 @@ +import { createStore } from "zustand/vanilla"; + +export type AlertState = { + message: string; + callback?: () => void; +}; + +export type AlertActions = { + setMessage: (message: string, callback?: () => void) => void; +}; + +export type AlertStore = AlertState & AlertActions; + +export const defaultInitState: AlertState = { + message: "", + callback: undefined, +}; + +export const createAlertStore = (initState: AlertState = defaultInitState) => { + return createStore()((set) => ({ + ...initState, + setMessage: (message: string, callback?: () => void) => + set(() => ({ message, callback })), + })); +}; diff --git a/apps/cm/src/shared/store/attachment-store.ts b/apps/cm/src/shared/store/attachment-store.ts new file mode 100644 index 000000000..faa700949 --- /dev/null +++ b/apps/cm/src/shared/store/attachment-store.ts @@ -0,0 +1,25 @@ +import { createStore } from "zustand/vanilla"; + +export type AttachmentState = { + attachmentFileCode: string; +}; + +export type AttachmentActions = { + setAttachmentFileCode: (attachmentFileCode: string) => void; +}; + +export type AttachmentStore = AttachmentState & AttachmentActions; + +export const defaultInitState: AttachmentState = { + attachmentFileCode: "", +}; + +export const createAttachmentStore = ( + initState: AttachmentState = defaultInitState, +) => { + return createStore()((set) => ({ + ...initState, + setAttachmentFileCode: (attachmentFileCode: string) => + set({ attachmentFileCode }), + })); +}; diff --git a/apps/cm/src/shared/store/index.ts b/apps/cm/src/shared/store/index.ts new file mode 100644 index 000000000..a41d6f928 --- /dev/null +++ b/apps/cm/src/shared/store/index.ts @@ -0,0 +1,4 @@ +export { createAlertStore } from "./alert-store"; +export { createAttachmentStore } from "./attachment-store"; +export { createMenuStore, type BbsType } from "./menu-store"; +export { createUserStore, type IUserInfo } from "./user-store"; diff --git a/apps/cm/src/shared/store/menu-store.ts b/apps/cm/src/shared/store/menu-store.ts new file mode 100644 index 000000000..8536011ee --- /dev/null +++ b/apps/cm/src/shared/store/menu-store.ts @@ -0,0 +1,93 @@ +import { createStore } from "zustand/vanilla"; + +export const BBS_TYPE = { + BBSNOTICE: "BBSNOTICE", + BBSNORMAL: "BBSNORMAL", + BBSFAQ: "BBSFAQ", + BBSIMAGE: "BBSIMAGE", + BBSMOVIE: "BBSMOVIE", +} as const; + +export type BbsType = (typeof BBS_TYPE)[keyof typeof BBS_TYPE]; + +export interface IBbs { + bbsId: number; + bbsTypeCd: BbsType; + bbsTypeNm: string; + bbsNm: string; + bbsExpln: string; + rfrncUseYn: string; + atflUseYn: string; + atflMaxCnt: number; + atflMaxFileSz: number; + ansUseYn: string; + ansUseAuthrt: string; + cmntUseYn: string; + dgstfnEvlUseYn: string; + smsUseYn: string; + upndFixUseYn: string; + pstgPrdUseYn: string; + popupUseYn: string; + delYn: string; + useYn: string; + mdfcnDt: string; + mdfrNm: string; +} + +interface IMenu { + menuCd: number; + menuKornNm: string; + menuEngNm: string; + parentId: number | null; + sortSeq: number; + iconNm?: string; + children: IMenu[] | null; + levelNo: number; + urlPath: string; + menuLnkgTypeCd: string; + menuLnkgSn: number; + menuPath: string; + qrcd: string; + useYn: string; + otptYn: string; + newWndwYn: string; + requiredUrlPath: boolean; +} + +interface IAuthority { + mngAuthrtYn: string; + inqAuthrtYn: string; + regAuthrtYn: string; + mdfcnAuthrtYn: string; + delAuthrtYn: string; +} + +export type MenuState = { + bbsInfo: IBbs | null; + currentMenuInfo: IMenu | null; + pageMenuAuthority: IAuthority | null; +}; + +export type MenuActions = { + setBbsInfo: (bbs: IBbs) => void; + setCurrentMenuInfo: (menu: IMenu) => void; + setPageMenuAuthority: (authority: IAuthority) => void; +}; + +export type MenuStore = MenuState & MenuActions; + +export const defaultInitState: MenuState = { + currentMenuInfo: null, + bbsInfo: null, + pageMenuAuthority: null, +}; + +export const createMenuStore = (initState: MenuState = defaultInitState) => { + return createStore()((set) => ({ + ...initState, + setBbsInfo: (bbs: IBbs) => set({ bbsInfo: bbs }), + setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }), + setPageMenuAuthority: (authority: IAuthority) => + set({ pageMenuAuthority: authority }), + })); +}; diff --git a/apps/cm/src/shared/store/user-store.ts b/apps/cm/src/shared/store/user-store.ts new file mode 100644 index 000000000..65d897cf1 --- /dev/null +++ b/apps/cm/src/shared/store/user-store.ts @@ -0,0 +1,57 @@ +import { createStore } from "zustand/vanilla"; + +export interface IUserInfo { + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: string; + adminRoleYn: string; + userUnqId: string; + userId: string; + userName: string; + email: string; + encryptedPassword: string; + refreshToken: string; + userStateCode: string; + lastLoginDate: string; + loginFailCount: number; + pswdHnt: string; + pswdAns: string; + mphn: string; + telno: string; + zip: string; + addr: string; + daddr: string; + laeSeCd: string; + ogdpSeCd: string; + aprvYn: string; + autzrId: string; + expryYmd: string; + lastPswdChgDt: string; + googleId: string; + kakaoId: string; + naverId: string; + isSocialUser: boolean; + hasPassword: boolean; +} + +export type UserState = { + userInfo: IUserInfo | null; +}; + +export type UserActions = { + setUserInfo: (userInfo: IUserInfo | null) => void; +}; + +export type UserStore = UserState & UserActions; + +export const defaultInitState: UserState = { + userInfo: null, +}; + +export const createUserStore = (initState: UserState = defaultInitState) => { + return createStore()((set) => ({ + ...initState, + setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })), + })); +}; diff --git a/apps/cm/src/shared/ui/alert-dialog.tsx b/apps/cm/src/shared/ui/alert-dialog.tsx new file mode 100644 index 000000000..6160d5301 --- /dev/null +++ b/apps/cm/src/shared/ui/alert-dialog.tsx @@ -0,0 +1,34 @@ +import { + Dialog, + DialogContent, + DialogFooter, + DialogClose, + Button, +} from "@common/components"; +import { useAlertStore } from "../lib/use-alert-store"; + +function AlertDialog() { + const { message, callback, setMessage } = useAlertStore((state) => state); + const handleOpenChange = () => { + setMessage(""); + callback?.(); + }; + return ( + + +
+

+ {message} +

+
+ + + + + +
+
+ ); +} + +export { AlertDialog }; diff --git a/apps/cm/src/shared/ui/confirm-dialog.tsx b/apps/cm/src/shared/ui/confirm-dialog.tsx new file mode 100644 index 000000000..6a4996bfb --- /dev/null +++ b/apps/cm/src/shared/ui/confirm-dialog.tsx @@ -0,0 +1,48 @@ +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogClose, + Button, + type ButtonProps, +} from "@common/components"; + +type ConfirmDialogProps = { + isOpen?: boolean; + confirmButtonProps?: ButtonProps & { label?: string }; + cancelButtonProps?: ButtonProps & { label?: string }; +}; + +function ConfirmDialog({ + isOpen, + confirmButtonProps, + cancelButtonProps, +}: ConfirmDialogProps) { + const { message, isOpen: isGlobalOpen, onConfirm, onCancel } = useConfirm(); + return ( + + +
+

+ {message} +

+
+ + + + + + + + +
+
+ ); +} + +export { ConfirmDialog }; diff --git a/apps/cm/src/shared/ui/form.tsx b/apps/cm/src/shared/ui/form.tsx new file mode 100644 index 000000000..bd579d263 --- /dev/null +++ b/apps/cm/src/shared/ui/form.tsx @@ -0,0 +1,175 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { cn, Label } from "@common/components"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +