From 011a68e5f249c033ff7f6b96c8b88e2998c574b1 Mon Sep 17 00:00:00 2001 From: bicochan Date: Wed, 1 Apr 2026 15:00:55 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[cs]=20refactor:=20auth=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20(refresh=20token)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/app/api/proxy/[...url]/route.ts | 24 ++- apps/cs/src/app/providers/app-provider.tsx | 3 +- .../cs/src/entities/auth/api/login-service.ts | 183 +++++++++--------- apps/cs/src/entities/auth/index.ts | 2 +- .../cs/src/entities/auth/lib/auth-wrapper.tsx | 89 ++++++--- apps/cs/src/entities/auth/lib/jwt-utils.ts | 32 +++ .../cs/src/entities/auth/lib/token-manager.ts | 134 ++++++------- .../entities/auth/model/login-interface.ts | 5 - .../cs/src/pages/auth/login/ui/login-page.tsx | 4 +- apps/cs/src/shared/config/env.ts | 1 + 10 files changed, 266 insertions(+), 211 deletions(-) create mode 100644 apps/cs/src/entities/auth/lib/jwt-utils.ts diff --git a/apps/cs/app/api/proxy/[...url]/route.ts b/apps/cs/app/api/proxy/[...url]/route.ts index abd3ae007..0929ca57c 100644 --- a/apps/cs/app/api/proxy/[...url]/route.ts +++ b/apps/cs/app/api/proxy/[...url]/route.ts @@ -2,6 +2,7 @@ import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, + COOKIE_DOMAIN, REFRESH_TOKEN, SERVER_API_URL, SITE_ID, @@ -60,18 +61,11 @@ async function handler( [AUTH_USER_ID]: userId, }); - const forwardedHost = request.headers.get("x-forwarded-host"); - const host = request.headers.get("host"); - const actualHost = forwardedHost || host || new URL(request.url).hostname.toString(); - - // 운영 환경에서 domain 설정 - const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost; - if (refreshToken) { - cookieStore.set(REFRESH_TOKEN, refreshToken, { + response.cookies.set(REFRESH_TOKEN, refreshToken, { httpOnly: true, sameSite: "strict", - domain: domain, + domain: getCookieDomain(), path: "/", }); } @@ -91,7 +85,6 @@ async function handler( } } - export async function POST( request: Request, props: { params: Promise<{ url: string[] }> }, @@ -107,3 +100,14 @@ export async function PUT( const params = await props.params; return handler(request, params, "PUT"); } + +function getCookieDomain(): string | undefined { + if (typeof window === "undefined") return undefined; + + if (COOKIE_DOMAIN) return COOKIE_DOMAIN; + + const { hostname } = window.location; + if (hostname === "localhost" || hostname === "127.0.0.1") return undefined; + + return hostname; +} diff --git a/apps/cs/src/app/providers/app-provider.tsx b/apps/cs/src/app/providers/app-provider.tsx index efd1f308e..31d14ac79 100644 --- a/apps/cs/src/app/providers/app-provider.tsx +++ b/apps/cs/src/app/providers/app-provider.tsx @@ -14,8 +14,9 @@ import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog"; import { PromptProvider, StoreProvider } from "@/shared/providers"; +const queryClient = new QueryClient(); + const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { - const queryClient = new QueryClient(); return ( diff --git a/apps/cs/src/entities/auth/api/login-service.ts b/apps/cs/src/entities/auth/api/login-service.ts index 671c8bb4c..6922efbbe 100644 --- a/apps/cs/src/entities/auth/api/login-service.ts +++ b/apps/cs/src/entities/auth/api/login-service.ts @@ -1,96 +1,105 @@ -import { AxiosError } from "axios"; import { ENDPOINT, - JWT_EXPIRATION_TIME, + CLAIM_NAME, ACCESS_TOKEN, AUTH_USER_ID, - CLAIM_NAME, } from "@/shared/config"; -import type { LoginService, ILogin } from "../model/login-interface"; -import { tokenManager } from "../lib/token-manager"; +import type { ILogin } from "../model/login-interface"; +import { + setToken, + clearAuthToken, + setupRefreshTimer, +} from "../lib/token-manager"; +import { getRemainingMs } from "../lib/jwt-utils"; -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(); - console.log('data ', data) - const accessToken = data[ACCESS_TOKEN]; - const tokenId = data[AUTH_USER_ID]; - - if (!accessToken || !tokenId) { - if (data.message == 'Invalid Credentials' ) { - throw new Error('아이디와 비밀번호가 일치하지 않습니다.'); - } else if (data.message == 'User not found') { - throw new Error('존재하지 않는 아이디입니다.'); - } else if (data.message == 'User is not active') { - throw new Error('활성화되지 않은 계정입니다.'); - } else if (data.message == 'User is not verified') { - throw new Error('이메일 인증이 필요합니다.'); - } else { - throw new Error(data.message); - } - } - - 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 실패 ${error.response?.data?.message}`); - } - throw new Error(`Login 실패 ${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}`); - } +function resolveLoginError(message: string | undefined): string { + switch (message) { + case "Invalid Credentials": + return "아이디와 비밀번호가 일치하지 않습니다."; + case "User not found": + return "존재하지 않는 아이디입니다."; + case "User is not active": + return "활성화되지 않은 계정입니다."; + case "User is not verified": + return "이메일 인증이 필요합니다."; + default: + return message ?? "로그인에 실패했습니다."; } } -export const LoginApi = LoginApiService.getInstance(); +export async function login(payload: ILogin): Promise { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(resolveLoginError(data?.message)); + } + + const accessToken = data[ACCESS_TOKEN] as string | undefined; + const tokenId = data[AUTH_USER_ID] as string | undefined; + + if (!accessToken || !tokenId) { + throw new Error("인증 토큰을 받지 못했습니다."); + } + + setToken(CLAIM_NAME, accessToken); + setToken(AUTH_USER_ID, tokenId); + + const remainingMs = getRemainingMs(accessToken); + if (remainingMs > 0) { + setupRefreshTimer(silentRefresh, remainingMs); + } +} + +export async function silentRefresh(): Promise { + console.log(`[silentRefresh] Called at ${new Date().toISOString()}`); + try { + const response = await fetch( + `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, + { method: "PUT" }, + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data?.message ?? "토큰 갱신에 실패했습니다."); + } + + const accessToken = data[ACCESS_TOKEN] as string | undefined; + const tokenId = data[AUTH_USER_ID] as string | undefined; + + if (!accessToken || !tokenId) { + throw new Error("갱신된 인증 토큰을 받지 못했습니다."); + } + + setToken(CLAIM_NAME, accessToken); + setToken(AUTH_USER_ID, tokenId); + + const remainingMs = getRemainingMs(accessToken); + if (remainingMs > 0) { + setupRefreshTimer(silentRefresh, remainingMs); + } + } catch (error) { + console.error( + `[silentRefresh] Failed at ${new Date().toISOString()}`, + error, + ); + clearAuthToken(); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } + // re-throw하지 않음: setupRefreshTimer 콜백의 catch와 이중 처리 방지 + } +} + +export async function logout(): Promise { + clearAuthToken(); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } +} diff --git a/apps/cs/src/entities/auth/index.ts b/apps/cs/src/entities/auth/index.ts index 97e142225..b4cb7d202 100644 --- a/apps/cs/src/entities/auth/index.ts +++ b/apps/cs/src/entities/auth/index.ts @@ -1,2 +1,2 @@ -export { LoginApi } from "./api/login-service"; +export { login, silentRefresh, logout } from "./api/login-service"; export { AuthWrapper } from "./lib/auth-wrapper"; diff --git a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx index 57ae75c34..ba6627d8a 100644 --- a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx @@ -1,47 +1,80 @@ "use client"; import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { isAxiosError } from "axios"; import { client } from "@/shared/api"; import type { IUserInfo } from "@/shared/store"; -import { getCookie, useUserStore } from "@/shared/lib"; +import { useUserStore } from "@/shared/lib"; import { isAtLoginRedirectUrl } from "@common/network"; import { ENDPOINT, AUTH_USER_ID, AUTH_LOGIN_REDIRECT_URL, + CLAIM_NAME, } from "@/shared/config"; +import { getToken, setupRefreshTimer } from "./token-manager"; +import { getRemainingMs } from "./jwt-utils"; +import { silentRefresh } from "../api/login-service"; const AuthWrapper = ({ children }: { children: React.ReactNode }) => { - const { userInfo, setUserInfo } = useUserStore((state) => state); - - async function authCheck() { - if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { - return; - } - - 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); - } + const router = useRouter(); + const userInfo = useUserStore((state) => state.userInfo); + const setUserInfo = useUserStore((state) => state.setUserInfo); useEffect(() => { - (async () => { - try { - await authCheck(); - } catch (error) { - console.error(error); - setUserInfo(null); - if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { - window.location.assign(AUTH_LOGIN_REDIRECT_URL); - } + // 페이지 새로고침 시 silentRefresh 타이머 복구 + const token = getToken(CLAIM_NAME); + if (token) { + const remainingMs = getRemainingMs(token); + if (remainingMs > 0) { + console.log( + `[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`, + ); + setupRefreshTimer(silentRefresh, remainingMs); } - })(); + } + + if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return; + + async function authCheck() { + const userId = getToken(AUTH_USER_ID); + if (!userId) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + return; + } + + if (userInfo) return; + + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + setUserInfo(response.data); + } + + authCheck().catch((error) => { + // 401은 @common/network error-interceptor에서 이미 리다이렉트 처리됨 + if (isAxiosError(error) && error.response?.status === 401) return; + + console.error(error); + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + }); + }, []); + + // silentRefresh 실패 시 auth:logout 이벤트를 수신해 로그인으로 이동 + useEffect(() => { + function handleAuthLogout() { + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + } + + window.addEventListener("auth:logout", handleAuthLogout); + return () => window.removeEventListener("auth:logout", handleAuthLogout); }, []); return <>{children}; diff --git a/apps/cs/src/entities/auth/lib/jwt-utils.ts b/apps/cs/src/entities/auth/lib/jwt-utils.ts new file mode 100644 index 000000000..05d2b0ff8 --- /dev/null +++ b/apps/cs/src/entities/auth/lib/jwt-utils.ts @@ -0,0 +1,32 @@ +interface JwtPayload { + exp?: number; + [key: string]: unknown; +} + +/** + * JWT 토큰의 exp 클레임(Unix timestamp, 초 단위)을 반환합니다. + * 파싱 실패 시 null을 반환합니다. + */ +export function getJwtExpiry(token: string): number | null { + try { + const base64Payload = token.split(".")[1]; + if (!base64Payload) return null; + + const payload = JSON.parse(atob(base64Payload)) as JwtPayload; + return typeof payload.exp === "number" ? payload.exp : null; + } catch { + return null; + } +} + +/** + * 토큰 만료 1분 전까지 남은 시간(ms)을 반환합니다. + * 이미 만료됐거나 파싱 실패 시 0을 반환합니다. + */ +export function getRemainingMs(token: string): number { + const exp = getJwtExpiry(token); + if (!exp) return 0; + + const remainingMs = exp * 1000 - Date.now() - 60_000; + return Math.max(0, remainingMs); +} diff --git a/apps/cs/src/entities/auth/lib/token-manager.ts b/apps/cs/src/entities/auth/lib/token-manager.ts index dd9aea352..bef544141 100644 --- a/apps/cs/src/entities/auth/lib/token-manager.ts +++ b/apps/cs/src/entities/auth/lib/token-manager.ts @@ -1,82 +1,62 @@ -import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; +import { AUTH_USER_ID, CLAIM_NAME, COOKIE_DOMAIN } from "@/shared/config"; import { getCookie, setCookie, removeCookie } from "@/shared/lib"; -export class TokenManager { - private static instance: TokenManager; - private refreshTimer: NodeJS.Timeout | null = null; +let refreshTimer: ReturnType | 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 { - const domain = this.getCookieDomain(); - - setCookie(key, value, { - secure: window.location.protocol === "https:", - sameSite: "lax", - domain: domain, - path: "/", // 모든 경로에서 접근 가능 - }); - } - - - private getCookieDomain(): string | undefined { - const hostname = window.location.hostname; - - // 개발 환경에서는 localhost 도메인 사용 - if (hostname === "localhost" || hostname === "127.0.0.1") { - return undefined; // localhost에서는 도메인 설정하지 않음 - } - - // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 - if (hostname.includes("hasuon.or.kr")) { - return ".hasuon.or.kr"; - } - - // 기타 환경에서는 현재 호스트명 사용 - return hostname; - } - - public removeToken(key: string): void { - const domain = this.getCookieDomain(); - removeCookie(key, { domain, path: "/" }); - } - - 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 function getToken(key: string): string | undefined { + return getCookie(key); } -export const tokenManager = TokenManager.getInstance(); +export function setToken(key: string, value: string): void { + const domain = getCookieDomain(); + const isHttps = + typeof window !== "undefined" && window.location.protocol === "https:"; + + setCookie(key, value, { + secure: isHttps, + sameSite: "lax", + domain, + path: "/", + }); +} + +export function removeToken(key: string): void { + const domain = getCookieDomain(); + removeCookie(key, { domain, path: "/" }); +} + +export function setupRefreshTimer( + callback: () => Promise, + delayMs: number, +): void { + if (refreshTimer) { + clearTimeout(refreshTimer); + } + + refreshTimer = setTimeout(() => { + callback().catch((error) => { + console.error("Silent refresh timer callback failed:", error); + }); + }, delayMs); +} + +export function clearAuthToken(): void { + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = null; + } + + removeToken(CLAIM_NAME); + removeToken(AUTH_USER_ID); +} + +function getCookieDomain(): string | undefined { + if (typeof window === "undefined") return undefined; + + if (COOKIE_DOMAIN) return COOKIE_DOMAIN; + + const { hostname } = window.location; + if (hostname === "localhost" || hostname === "127.0.0.1") return undefined; + + return hostname; +} diff --git a/apps/cs/src/entities/auth/model/login-interface.ts b/apps/cs/src/entities/auth/model/login-interface.ts index 5aaf53044..012f81457 100644 --- a/apps/cs/src/entities/auth/model/login-interface.ts +++ b/apps/cs/src/entities/auth/model/login-interface.ts @@ -2,8 +2,3 @@ export interface ILogin { userId: string; password: string; } - -export interface LoginService { - login(payload: ILogin): Promise; - silentRefresh(): Promise; -} diff --git a/apps/cs/src/pages/auth/login/ui/login-page.tsx b/apps/cs/src/pages/auth/login/ui/login-page.tsx index fd1e2f809..90de72f4c 100644 --- a/apps/cs/src/pages/auth/login/ui/login-page.tsx +++ b/apps/cs/src/pages/auth/login/ui/login-page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Image from "next/image"; import { images } from "@common/assets"; -import { LoginApi } from "@/entities/auth"; +import { login } from "@/entities/auth"; import "./login.css"; function LoginPage() { @@ -18,7 +18,7 @@ function LoginPage() { const password = formData.get("password") as string; try { - await LoginApi.login({ userId, password }); + await login({ userId, password }); window.location.href = "/dashboard"; } catch (error) { const message = diff --git a/apps/cs/src/shared/config/env.ts b/apps/cs/src/shared/config/env.ts index 95d1a4477..5e1392b4d 100644 --- a/apps/cs/src/shared/config/env.ts +++ b/apps/cs/src/shared/config/env.ts @@ -11,3 +11,4 @@ export const 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"; +export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN; From 2f7d860b8925b466bccdc1e5c2d87ebf4c65b7ad Mon Sep 17 00:00:00 2001 From: bicochan Date: Wed, 1 Apr 2026 17:06:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[cs]=20refactor:=20auth=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20(@common/auth)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/package.json | 3 +- .../cs/src/entities/auth/api/login-service.ts | 2 +- .../cs/src/entities/auth/lib/auth-wrapper.tsx | 4 +- .../cs/src/entities/auth/lib/token-manager.ts | 73 +-- apps/cs/src/shared/config/auth.ts | 16 + packages/common/auth/README.md | 492 ++++++++++++++++++ packages/common/auth/cookie.ts | 29 ++ packages/common/auth/index.ts | 7 + .../lib => packages/common/auth}/jwt-utils.ts | 0 packages/common/auth/package.json | 18 + packages/common/auth/token-manager.ts | 77 +++ packages/common/auth/tsconfig.json | 23 + packages/common/auth/types.ts | 15 + pnpm-lock.yaml | 132 +++-- 14 files changed, 798 insertions(+), 93 deletions(-) create mode 100644 apps/cs/src/shared/config/auth.ts create mode 100644 packages/common/auth/README.md create mode 100644 packages/common/auth/cookie.ts create mode 100644 packages/common/auth/index.ts rename {apps/cs/src/entities/auth/lib => packages/common/auth}/jwt-utils.ts (100%) create mode 100644 packages/common/auth/package.json create mode 100644 packages/common/auth/token-manager.ts create mode 100644 packages/common/auth/tsconfig.json create mode 100644 packages/common/auth/types.ts diff --git a/apps/cs/package.json b/apps/cs/package.json index 03ec06fcf..832581735 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@common/auth": "workspace:*", "@common/assets": "workspace:*", "@common/components": "workspace:*", "@common/config": "workspace:*", @@ -60,4 +61,4 @@ "tailwindcss": "^3.4.14", "typescript": "^5.7.2" } -} \ No newline at end of file +} diff --git a/apps/cs/src/entities/auth/api/login-service.ts b/apps/cs/src/entities/auth/api/login-service.ts index 6922efbbe..2181de239 100644 --- a/apps/cs/src/entities/auth/api/login-service.ts +++ b/apps/cs/src/entities/auth/api/login-service.ts @@ -1,3 +1,4 @@ +import { getRemainingMs } from "@common/auth"; import { ENDPOINT, CLAIM_NAME, @@ -10,7 +11,6 @@ import { clearAuthToken, setupRefreshTimer, } from "../lib/token-manager"; -import { getRemainingMs } from "../lib/jwt-utils"; function resolveLoginError(message: string | undefined): string { switch (message) { diff --git a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx index ba6627d8a..c4cede0d9 100644 --- a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx @@ -3,10 +3,11 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { isAxiosError } from "axios"; +import { getRemainingMs } from "@common/auth"; +import { isAtLoginRedirectUrl } from "@common/network"; import { client } from "@/shared/api"; import type { IUserInfo } from "@/shared/store"; import { useUserStore } from "@/shared/lib"; -import { isAtLoginRedirectUrl } from "@common/network"; import { ENDPOINT, AUTH_USER_ID, @@ -14,7 +15,6 @@ import { CLAIM_NAME, } from "@/shared/config"; import { getToken, setupRefreshTimer } from "./token-manager"; -import { getRemainingMs } from "./jwt-utils"; import { silentRefresh } from "../api/login-service"; const AuthWrapper = ({ children }: { children: React.ReactNode }) => { diff --git a/apps/cs/src/entities/auth/lib/token-manager.ts b/apps/cs/src/entities/auth/lib/token-manager.ts index bef544141..44aa888aa 100644 --- a/apps/cs/src/entities/auth/lib/token-manager.ts +++ b/apps/cs/src/entities/auth/lib/token-manager.ts @@ -1,62 +1,15 @@ -import { AUTH_USER_ID, CLAIM_NAME, COOKIE_DOMAIN } from "@/shared/config"; -import { getCookie, setCookie, removeCookie } from "@/shared/lib"; +import { createTokenManager } from "@common/auth"; +import { authConfig } from "@/shared/config/auth"; -let refreshTimer: ReturnType | null = null; +export const tokenManager = createTokenManager(authConfig); -export function getToken(key: string): string | undefined { - return getCookie(key); -} - -export function setToken(key: string, value: string): void { - const domain = getCookieDomain(); - const isHttps = - typeof window !== "undefined" && window.location.protocol === "https:"; - - setCookie(key, value, { - secure: isHttps, - sameSite: "lax", - domain, - path: "/", - }); -} - -export function removeToken(key: string): void { - const domain = getCookieDomain(); - removeCookie(key, { domain, path: "/" }); -} - -export function setupRefreshTimer( - callback: () => Promise, - delayMs: number, -): void { - if (refreshTimer) { - clearTimeout(refreshTimer); - } - - refreshTimer = setTimeout(() => { - callback().catch((error) => { - console.error("Silent refresh timer callback failed:", error); - }); - }, delayMs); -} - -export function clearAuthToken(): void { - if (refreshTimer) { - clearTimeout(refreshTimer); - refreshTimer = null; - } - - removeToken(CLAIM_NAME); - removeToken(AUTH_USER_ID); -} - -function getCookieDomain(): string | undefined { - if (typeof window === "undefined") return undefined; - - if (COOKIE_DOMAIN) return COOKIE_DOMAIN; - - const { hostname } = window.location; - if (hostname === "localhost" || hostname === "127.0.0.1") return undefined; - - return hostname; -} +export const { + getToken, + setToken, + removeToken, + setupRefreshTimer, + clearAuthToken, + getAccessToken, + getRefreshToken, + getUserId, +} = tokenManager; diff --git a/apps/cs/src/shared/config/auth.ts b/apps/cs/src/shared/config/auth.ts new file mode 100644 index 000000000..d903525af --- /dev/null +++ b/apps/cs/src/shared/config/auth.ts @@ -0,0 +1,16 @@ +import type { AuthConfig } from "@common/auth"; +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + COOKIE_DOMAIN, + REFRESH_TOKEN, +} from "./env"; + +export const authConfig: AuthConfig = { + authUserId: AUTH_USER_ID || "token-id", + claimName: CLAIM_NAME || "Authorization", + cookieDomain: COOKIE_DOMAIN, + accessToken: ACCESS_TOKEN || "access-token", + refreshToken: REFRESH_TOKEN || "refresh-token", +}; diff --git a/packages/common/auth/README.md b/packages/common/auth/README.md new file mode 100644 index 000000000..5cd54d787 --- /dev/null +++ b/packages/common/auth/README.md @@ -0,0 +1,492 @@ +# @common/auth + +인증 관련 공통 기능을 제공하는 독립 패키지입니다. 의존성 주입 패턴을 통해 각 앱의 환경변수를 주입받아 재사용 가능한 토큰 관리 기능을 제공합니다. + +## 특징 + +- **의존성 주입**: 환경변수를 직접 참조하지 않고 설정 객체를 주입받음 +- **재사용성**: 모든 앱에서 동일한 로직 사용 가능 +- **타입 안정성**: TypeScript 인터페이스로 계약 보장 +- **테스트 가능성**: Mock 설정 주입으로 단위 테스트 용이 + +## 패키지 구조 + +``` +packages/common/auth/ +├── package.json # 패키지 정의 +├── tsconfig.json # TypeScript 설정 +├── index.ts # 진입점 (Public API) +├── types.ts # 타입 정의 +├── token-manager.ts # 토큰 관리 로직 +├── cookie.ts # 쿠키 유틸리티 +├── jwt-utils.ts # JWT 유틸리티 +└── README.md # 문서 +``` + +## 설치 + +각 앱의 `package.json`에 의존성을 추가합니다: + +```json +{ + "dependencies": { + "@common/auth": "workspace:*" + } +} +``` + +## 사용 방법 + +### 1. 설정 파일 생성 + +각 앱에서 자신의 환경변수로 `AuthConfig` 객체를 생성합니다. + +```typescript +// apps/cs/src/shared/config/auth.ts +import type { AuthConfig } from "@common/auth/types"; + +export const authConfig: AuthConfig = { + authUserId: process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id", + claimName: process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization", + cookieDomain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN, + accessToken: process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token", + refreshToken: process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token", +}; +``` + +### 2. TokenManager 인스턴스 생성 + +설정을 주입하여 토큰 매니저 인스턴스를 생성합니다. + +```typescript +// apps/cs/src/entities/auth/lib/token-manager.ts +import { createTokenManager } from "@common/auth"; +import { authConfig } from "@/shared/config/auth"; + +export const tokenManager = createTokenManager(authConfig); + +// 편의를 위한 re-export +export const { + getToken, + setToken, + removeToken, + setupRefreshTimer, + clearAuthToken, + getAccessToken, + getRefreshToken, + getUserId, +} = tokenManager; +``` + +### 3. 컴포넌트에서 사용 + +```typescript +// apps/cs/src/features/auth/ui/LoginForm.tsx +import { tokenManager } from "@/entities/auth/lib/token-manager"; +import { authConfig } from "@/shared/config/auth"; + +export function LoginForm() { + const handleLogin = async (credentials: LoginCredentials) => { + const response = await loginApi(credentials); + + // 토큰 저장 + tokenManager.setToken(authConfig.claimName, response.accessToken); + tokenManager.setToken(authConfig.refreshToken, response.refreshToken); + + // 또는 편의 메서드 사용 + // tokenManager.getAccessToken(); + // tokenManager.getRefreshToken(); + // tokenManager.getUserId(); + }; + + return ( + // ... JSX + ); +} +``` + +## API 문서 + +### `createTokenManager(config: AuthConfig): TokenManager` + +토큰 매니저 인스턴스를 생성하는 팩토리 함수입니다. + +**Parameters:** +- `config: AuthConfig` - 인증 설정 객체 + +**Returns:** +- `TokenManager` - 토큰 관리 메서드를 포함한 객체 + +### TokenManager 메서드 + +#### `getToken(key: string): string | undefined` + +지정된 키의 쿠키 값을 가져옵니다. + +```typescript +const token = tokenManager.getToken("access-token"); +``` + +#### `setToken(key: string, value: string): void` + +토큰을 쿠키에 저장합니다. 자동으로 도메인, secure, sameSite 설정을 적용합니다. + +```typescript +tokenManager.setToken("access-token", "eyJhbGc..."); +``` + +#### `removeToken(key: string): void` + +지정된 키의 쿠키를 삭제합니다. + +```typescript +tokenManager.removeToken("access-token"); +``` + +#### `setupRefreshTimer(callback: () => Promise, delayMs: number): void` + +토큰 갱신 타이머를 설정합니다. + +```typescript +tokenManager.setupRefreshTimer(async () => { + await refreshAccessToken(); +}, 3600000); // 1시간 후 +``` + +#### `clearAuthToken(): void` + +모든 인증 토큰을 삭제하고 타이머를 정리합니다. + +```typescript +tokenManager.clearAuthToken(); +``` + +#### `getAccessToken(): string | undefined` + +액세스 토큰을 가져오는 편의 메서드입니다. + +```typescript +const accessToken = tokenManager.getAccessToken(); +``` + +#### `getRefreshToken(): string | undefined` + +리프레시 토큰을 가져오는 편의 메서드입니다. + +```typescript +const refreshToken = tokenManager.getRefreshToken(); +``` + +#### `getUserId(): string | undefined` + +사용자 ID 토큰을 가져오는 편의 메서드입니다. + +```typescript +const userId = tokenManager.getUserId(); +``` + +### JWT 유틸리티 + +#### `getJwtExpiry(token: string): number | null` + +JWT 토큰의 만료 시간(Unix timestamp, 초 단위)을 반환합니다. + +```typescript +import { getJwtExpiry } from "@common/auth"; + +const expiry = getJwtExpiry(token); +if (expiry) { + console.log("Token expires at:", new Date(expiry * 1000)); +} +``` + +#### `getRemainingMs(token: string): number` + +토큰 만료 1분 전까지 남은 시간(밀리초)을 반환합니다. + +```typescript +import { getRemainingMs } from "@common/auth"; + +const remaining = getRemainingMs(token); +if (remaining > 0) { + console.log("Token valid for:", remaining, "ms"); +} +``` + +### 쿠키 유틸리티 + +#### `getCookie(name: string): string | undefined` + +쿠키 값을 가져옵니다. + +```typescript +import { getCookie } from "@common/auth"; + +const value = getCookie("my-cookie"); +``` + +#### `setCookie(name: string, value: string, options?: CookieOptions): void` + +쿠키를 설정합니다. + +```typescript +import { setCookie } from "@common/auth"; + +setCookie("my-cookie", "value", { + secure: true, + sameSite: "lax", + path: "/", +}); +``` + +#### `removeCookie(name: string, options?: CookieOptions): void` + +쿠키를 삭제합니다. + +```typescript +import { removeCookie } from "@common/auth"; + +removeCookie("my-cookie", { path: "/" }); +``` + +## 타입 정의 + +### `AuthConfig` + +```typescript +interface AuthConfig { + authUserId: string; // 사용자 ID 토큰 키 + claimName: string; // 액세스 토큰 키 + cookieDomain?: string; // 쿠키 도메인 (선택) + accessToken?: string; // 액세스 토큰 키 (선택) + refreshToken?: string; // 리프레시 토큰 키 (선택) +} +``` + +### `CookieOptions` + +```typescript +interface CookieOptions { + secure?: boolean; + sameSite?: "strict" | "lax" | "none"; + domain?: string; + path?: string; + expires?: number | Date; +} +``` + +## 마이그레이션 가이드 + +### 기존 앱에서 @common/auth로 마이그레이션 + +#### 1. 의존성 추가 + +```bash +# 앱 디렉토리에서 +cd apps/your-app +``` + +`package.json`에 추가: +```json +{ + "dependencies": { + "@common/auth": "workspace:*" + } +} +``` + +```bash +pnpm install +``` + +#### 2. 설정 파일 생성 + +```typescript +// apps/your-app/src/shared/config/auth.ts +import type { AuthConfig } from "@common/auth/types"; + +export const authConfig: AuthConfig = { + authUserId: process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id", + claimName: process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization", + cookieDomain: process.env.NEXT_PUBLIC_COOKIE_DOMAIN, + accessToken: process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token", + refreshToken: process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token", +}; +``` + +#### 3. TokenManager 수정 + +**Before (기존 코드):** +```typescript +// apps/your-app/src/entities/auth/lib/token-manager.ts +import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; +import { getCookie, setCookie, removeCookie } from "@/shared/lib"; + +export function getToken(key: string): string | undefined { + return getCookie(key); +} + +export function clearAuthToken(): void { + removeToken(CLAIM_NAME); + removeToken(AUTH_USER_ID); +} +``` + +**After (@common/auth 사용):** +```typescript +// apps/your-app/src/entities/auth/lib/token-manager.ts +import { createTokenManager } from "@common/auth"; +import { authConfig } from "@/shared/config/auth"; + +export const tokenManager = createTokenManager(authConfig); + +// 기존 코드와의 호환성을 위한 re-export +export const { + getToken, + setToken, + removeToken, + setupRefreshTimer, + clearAuthToken, + getAccessToken, + getRefreshToken, + getUserId, +} = tokenManager; +``` + +#### 4. 사용처 업데이트 + +**클래스 기반 (st, cm 앱):** +```typescript +// Before +import { TokenManager } from "@/entities/auth/lib/token-manager"; +const tokenManager = TokenManager.getInstance(); + +// After +import { tokenManager } from "@/entities/auth/lib/token-manager"; +// 그대로 사용 가능 +``` + +**함수형 (cs 앱):** +```typescript +// Before +import { getToken, setToken, clearAuthToken } from "@/entities/auth/lib/token-manager"; + +// After +import { getToken, setToken, clearAuthToken } from "@/entities/auth/lib/token-manager"; +// 그대로 사용 가능 (re-export 덕분) +``` + +## 다른 앱에서 사용 예시 + +### AS 앱 + +```typescript +// apps/as/src/shared/config/auth.ts +import type { AuthConfig } from "@common/auth/types"; + +export const authConfig: AuthConfig = { + authUserId: process.env.NEXT_PUBLIC_AS_AUTH_USER_ID || "as-token-id", + claimName: process.env.NEXT_PUBLIC_AS_CLAIM_NAME || "X-AS-Auth", + cookieDomain: process.env.NEXT_PUBLIC_AS_COOKIE_DOMAIN, + accessToken: process.env.NEXT_PUBLIC_AS_ACCESS_TOKEN || "as-access-token", + refreshToken: process.env.NEXT_PUBLIC_AS_REFRESH_TOKEN || "as-refresh-token", +}; +``` + +### ST 앱 + +```typescript +// apps/st/src/shared/config/auth.ts +import type { AuthConfig } from "@common/auth/types"; + +export const authConfig: AuthConfig = { + authUserId: process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id", + claimName: process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization", + cookieDomain: ".hasuon.or.kr", // 하드코딩된 도메인도 설정으로 이동 + accessToken: process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token", + refreshToken: process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token", +}; +``` + +## 환경변수 설정 + +각 앱의 `.env.local` 파일: + +```bash +# CS 앱 +NEXT_PUBLIC_CLAIM_NAME=Authorization +NEXT_PUBLIC_ACCESS_TOKEN=access-token +NEXT_PUBLIC_REFRESH_TOKEN=refresh-token +NEXT_PUBLIC_AUTH_USER_ID=token-id +NEXT_PUBLIC_COOKIE_DOMAIN=.example.com + +# AS 앱 (다른 설정 사용 가능) +NEXT_PUBLIC_AS_CLAIM_NAME=X-AS-Auth +NEXT_PUBLIC_AS_ACCESS_TOKEN=as-access-token +NEXT_PUBLIC_AS_REFRESH_TOKEN=as-refresh-token +NEXT_PUBLIC_AS_AUTH_USER_ID=as-token-id +NEXT_PUBLIC_AS_COOKIE_DOMAIN=.as.example.com +``` + +## 테스트 예시 + +```typescript +import { createTokenManager } from "@common/auth"; +import type { AuthConfig } from "@common/auth/types"; + +describe("TokenManager", () => { + const mockConfig: AuthConfig = { + authUserId: "test-user-id", + claimName: "test-claim", + cookieDomain: ".test.com", + accessToken: "test-access", + refreshToken: "test-refresh", + }; + + it("should create token manager with config", () => { + const tokenManager = createTokenManager(mockConfig); + expect(tokenManager).toBeDefined(); + expect(tokenManager.getToken).toBeDefined(); + }); + + it("should use injected config", () => { + const tokenManager = createTokenManager(mockConfig); + // 테스트 로직... + }); +}); +``` + +## 주의사항 + +1. **환경변수는 빌드 타임에 주입됩니다**: `process.env.NEXT_PUBLIC_*` 값은 빌드 시점에 정적으로 치환됩니다. + +2. **쿠키 도메인 자동 감지**: `cookieDomain`을 설정하지 않으면 자동으로 현재 호스트명을 사용합니다. + - `localhost` / `127.0.0.1`: 도메인 설정 안 함 + - 기타: 현재 호스트명 사용 + +3. **HTTPS 자동 감지**: `secure` 옵션은 현재 프로토콜에 따라 자동 설정됩니다. + +4. **타이머 관리**: `setupRefreshTimer`는 이전 타이머를 자동으로 정리합니다. + +## 문제 해결 + +### TypeScript 오류: "Cannot find module '@common/auth'" + +1. `pnpm install` 실행 +2. TypeScript 서버 재시작 +3. `tsconfig.json`의 `paths` 설정 확인 + +### 쿠키가 저장되지 않음 + +1. 브라우저 개발자 도구에서 쿠키 확인 +2. `cookieDomain` 설정 확인 +3. HTTPS 환경에서 `secure: true` 확인 + +### 토큰이 다른 앱에서 공유되지 않음 + +1. `cookieDomain` 설정이 공통 도메인(예: `.example.com`)인지 확인 +2. 각 앱의 토큰 키 이름이 다른지 확인 (충돌 방지) + +## 라이선스 + +ISC diff --git a/packages/common/auth/cookie.ts b/packages/common/auth/cookie.ts new file mode 100644 index 000000000..8f5d0011b --- /dev/null +++ b/packages/common/auth/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/packages/common/auth/index.ts b/packages/common/auth/index.ts new file mode 100644 index 000000000..04fb01bb4 --- /dev/null +++ b/packages/common/auth/index.ts @@ -0,0 +1,7 @@ +export { createTokenManager } from "./token-manager"; +export type { TokenManager } from "./token-manager"; + +export { getCookie, setCookie, removeCookie } from "./cookie"; +export { getJwtExpiry, getRemainingMs } from "./jwt-utils"; + +export type { AuthConfig, CookieOptions } from "./types"; diff --git a/apps/cs/src/entities/auth/lib/jwt-utils.ts b/packages/common/auth/jwt-utils.ts similarity index 100% rename from apps/cs/src/entities/auth/lib/jwt-utils.ts rename to packages/common/auth/jwt-utils.ts diff --git a/packages/common/auth/package.json b/packages/common/auth/package.json new file mode 100644 index 000000000..4baa26d35 --- /dev/null +++ b/packages/common/auth/package.json @@ -0,0 +1,18 @@ +{ + "name": "@common/auth", + "version": "1.0.0", + "private": true, + "main": "./index.ts", + "types": "./index.ts", + "exports": { + ".": "./index.ts" + }, + "dependencies": { + "js-cookie": "^3.0.5" + }, + "devDependencies": { + "@types/js-cookie": "^3.0.6", + "@types/node": "^22.13.1", + "typescript": "^5.7.2" + } +} diff --git a/packages/common/auth/token-manager.ts b/packages/common/auth/token-manager.ts new file mode 100644 index 000000000..ff516aa16 --- /dev/null +++ b/packages/common/auth/token-manager.ts @@ -0,0 +1,77 @@ +import type { AuthConfig } from "./types"; +import { getCookie, setCookie, removeCookie } from "./cookie"; + +export function createTokenManager(config: AuthConfig) { + let refreshTimer: ReturnType | null = null; + + const getCookieDomain = (): string | undefined => { + if (typeof window === "undefined") return undefined; + + if (config.cookieDomain) return config.cookieDomain; + + const { hostname } = window.location; + if (hostname === "localhost" || hostname === "127.0.0.1") return undefined; + + return hostname; + }; + + return { + getToken(key: string): string | undefined { + return getCookie(key); + }, + + setToken(key: string, value: string): void { + const domain = getCookieDomain(); + const isHttps = + typeof window !== "undefined" && window.location.protocol === "https:"; + + setCookie(key, value, { + secure: isHttps, + sameSite: "lax", + domain, + path: "/", + }); + }, + + removeToken(key: string): void { + const domain = getCookieDomain(); + removeCookie(key, { domain, path: "/" }); + }, + + setupRefreshTimer(callback: () => Promise, delayMs: number): void { + if (refreshTimer) { + clearTimeout(refreshTimer); + } + + refreshTimer = setTimeout(() => { + callback().catch((error) => { + console.error("Silent refresh timer callback failed:", error); + }); + }, delayMs); + }, + + clearAuthToken(): void { + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = null; + } + + this.removeToken(config.claimName); + this.removeToken(config.authUserId); + }, + + getAccessToken(): string | undefined { + return this.getToken(config.claimName); + }, + + getRefreshToken(): string | undefined { + return config.refreshToken ? this.getToken(config.refreshToken) : undefined; + }, + + getUserId(): string | undefined { + return this.getToken(config.authUserId); + }, + }; +} + +export type TokenManager = ReturnType; diff --git a/packages/common/auth/tsconfig.json b/packages/common/auth/tsconfig.json new file mode 100644 index 000000000..04a0208c5 --- /dev/null +++ b/packages/common/auth/tsconfig.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "strictNullChecks": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/packages/common/auth/types.ts b/packages/common/auth/types.ts new file mode 100644 index 000000000..077eb821f --- /dev/null +++ b/packages/common/auth/types.ts @@ -0,0 +1,15 @@ +export interface AuthConfig { + authUserId: string; + claimName: string; + cookieDomain?: string; + accessToken?: string; + refreshToken?: string; +} + +export interface CookieOptions { + secure?: boolean; + sameSite?: "strict" | "lax" | "none"; + domain?: string; + path?: string; + expires?: number | Date; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e39d4b41f..2b497a297 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,10 +101,10 @@ importers: devDependencies: eslint: specifier: ^9.0.0 - version: 9.39.2(jiti@2.6.1) + version: 9.39.2(jiti@1.21.7) eslint-config-next: specifier: 15.4.0 - version: 15.4.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 15.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) postcss: specifier: ^8.4.49 version: 8.5.6 @@ -407,6 +407,9 @@ importers: '@common/assets': specifier: workspace:* version: link:../../packages/common/assets + '@common/auth': + specifier: workspace:* + version: link:../../packages/common/auth '@common/components': specifier: workspace:* version: link:../../packages/common/components @@ -1190,13 +1193,13 @@ importers: version: 5.28.5 eslint: specifier: ^9 - version: 9.39.2(jiti@1.21.7) + version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 15.1.7 - version: 15.1.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 15.1.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-react-hooks: specifier: ^5.0.0 - version: 5.2.0(eslint@9.39.2(jiti@1.21.7)) + version: 5.2.0(eslint@9.39.2(jiti@2.6.1)) postcss: specifier: ^8 version: 8.5.6 @@ -1358,6 +1361,22 @@ importers: specifier: ^5.3.3 version: 5.9.3 + packages/common/auth: + dependencies: + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 + devDependencies: + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 + '@types/node': + specifier: ^22.13.1 + version: 22.19.2 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + packages/common/business_components: dependencies: '@common/components': @@ -10729,26 +10748,6 @@ snapshots: optionalDependencies: source-map: 0.7.6 - eslint-config-next@15.1.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 15.1.7 - '@rushstack/eslint-patch': 1.15.0 - '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@1.21.7)) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - eslint-config-next@15.1.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 15.1.7 @@ -10757,8 +10756,8 @@ snapshots: '@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@2.6.1)) @@ -10769,6 +10768,26 @@ snapshots: - eslint-plugin-import-x - supports-color + eslint-config-next@15.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 15.4.0 + '@rushstack/eslint-patch': 1.15.0 + '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@1.21.7)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + eslint-config-next@15.4.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 15.4.0 @@ -10797,6 +10816,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + get-tsconfig: 4.13.0 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -10808,7 +10842,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color @@ -10838,6 +10872,17 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 @@ -10849,7 +10894,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10878,6 +10923,35 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 From 6c17c12384614f24e56f1f1e953da8a905f81656 Mon Sep 17 00:00:00 2001 From: bicochan Date: Wed, 8 Apr 2026 09:56:58 +0900 Subject: [PATCH 3/4] rename: "login-*" -> "auth-*" --- .../src/entities/auth/api/{login-service.ts => auth-service.ts} | 2 +- apps/cs/src/entities/auth/index.ts | 2 +- apps/cs/src/entities/auth/lib/auth-wrapper.tsx | 2 +- .../auth/model/{login-interface.ts => auth-interface.ts} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename apps/cs/src/entities/auth/api/{login-service.ts => auth-service.ts} (98%) rename apps/cs/src/entities/auth/model/{login-interface.ts => auth-interface.ts} (100%) diff --git a/apps/cs/src/entities/auth/api/login-service.ts b/apps/cs/src/entities/auth/api/auth-service.ts similarity index 98% rename from apps/cs/src/entities/auth/api/login-service.ts rename to apps/cs/src/entities/auth/api/auth-service.ts index 2181de239..e48569677 100644 --- a/apps/cs/src/entities/auth/api/login-service.ts +++ b/apps/cs/src/entities/auth/api/auth-service.ts @@ -5,7 +5,7 @@ import { ACCESS_TOKEN, AUTH_USER_ID, } from "@/shared/config"; -import type { ILogin } from "../model/login-interface"; +import type { ILogin } from "../model/auth-interface"; import { setToken, clearAuthToken, diff --git a/apps/cs/src/entities/auth/index.ts b/apps/cs/src/entities/auth/index.ts index b4cb7d202..49cedf06b 100644 --- a/apps/cs/src/entities/auth/index.ts +++ b/apps/cs/src/entities/auth/index.ts @@ -1,2 +1,2 @@ -export { login, silentRefresh, logout } from "./api/login-service"; +export { login, silentRefresh, logout } from "./api/auth-service"; export { AuthWrapper } from "./lib/auth-wrapper"; diff --git a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx index c4cede0d9..3c4dea9c7 100644 --- a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx @@ -15,7 +15,7 @@ import { CLAIM_NAME, } from "@/shared/config"; import { getToken, setupRefreshTimer } from "./token-manager"; -import { silentRefresh } from "../api/login-service"; +import { silentRefresh } from "../api/auth-service"; const AuthWrapper = ({ children }: { children: React.ReactNode }) => { const router = useRouter(); diff --git a/apps/cs/src/entities/auth/model/login-interface.ts b/apps/cs/src/entities/auth/model/auth-interface.ts similarity index 100% rename from apps/cs/src/entities/auth/model/login-interface.ts rename to apps/cs/src/entities/auth/model/auth-interface.ts From 07e842ecb210716931c5aa2296819c699af6d95f Mon Sep 17 00:00:00 2001 From: bicochan Date: Wed, 8 Apr 2026 17:11:52 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[cs]=20feat:=20=EC=95=8C=EB=A6=BC=ED=86=A1?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=EA=B4=80=EB=A6=AC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/docs/spec/ntftalk/SPEC.md | 157 ++++++++++++++++++ .../ntftalk/api/ntftalk-temp-service.ts | 56 +++++++ .../ntftalk/api/ntftalk-var-service.ts | 56 +++++++ apps/cs/src/entities/ntftalk/index.ts | 20 +++ .../ntftalk/model/ntftalk-temp-interface.ts | 106 ++++++++++++ .../ntftalk/model/ntftalk-var-interface.ts | 42 +++++ apps/cs/src/pages/ntftalk-temp/page.tsx | 0 apps/cs/src/shared/config/api.ts | 4 + 8 files changed, 441 insertions(+) create mode 100644 apps/cs/docs/spec/ntftalk/SPEC.md create mode 100644 apps/cs/src/entities/ntftalk/api/ntftalk-temp-service.ts create mode 100644 apps/cs/src/entities/ntftalk/api/ntftalk-var-service.ts create mode 100644 apps/cs/src/entities/ntftalk/index.ts create mode 100644 apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts create mode 100644 apps/cs/src/entities/ntftalk/model/ntftalk-var-interface.ts create mode 100644 apps/cs/src/pages/ntftalk-temp/page.tsx diff --git a/apps/cs/docs/spec/ntftalk/SPEC.md b/apps/cs/docs/spec/ntftalk/SPEC.md new file mode 100644 index 000000000..ec8a7455e --- /dev/null +++ b/apps/cs/docs/spec/ntftalk/SPEC.md @@ -0,0 +1,157 @@ +# 화면설계서: 알림톡템플릿관리 + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | -------------------------------------------- | +| 버전 | 1.0 | +| 프로세스명 | UI설계 | +| 화면명 | 알림톡템플릿관리 | +| 화면 유형 | [x] 조회 [x] 입력 [x] 삭제 [ ] 수정 [ ] 출력 | +| 메뉴 경로 | home > 시스템관리 > 알림톡템플릿관리 | + +--- + +## 2. 화면 목적 + +카카오 알림톡(또는 동일 채널) **템플릿 목록을 조회·관리**하고, 선택한 템플릿에 연결된 **치환 변수 목록**을 확인·관리한다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ 페이지 제목: 알림톡템플릿관리 브레드크럼 (우측 상단) │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ [영역 A] 검색 필터 │ +│ · 1행: 템플릿코드, 템플릿명, 템플릿내용 (텍스트) │ +│ · 2행: 채널, 검수상태, 메시지유형 (드롭다운, 기본값: 전체) | 조회, 초기화 │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ [영역 B] 알림톡 템플릿 관리 │ +│ · 섹션 제목 + 툴바(템플릿갱신, 템플릿등록, 선택 검수요청, 선택삭제, 저장) │ +│ · 템플릿 그리드 (체크박스 다중 선택) │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ [영역 C] 변수관리 │ +│ · 섹션 제목 + 툴바(변수추가, 선택삭제, 저장) │ +│ · 변수 그리드 │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 영역 A: 검색 필터 + +### 4-1. 필드 정의 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ---------- | --------- | ---- | ------ | ------------------ | --------------------------------------------------------- | +| 템플릿코드 | 텍스트 | N | 공백 | `templateCode` | 부분/완전일치 여부는 백엔드 정책 확인 필요 | +| 템플릿명 | 텍스트 | N | 공백 | `templateName` | 부분/완전일치 여부는 백엔드 정책 확인 필요 | +| 템플릿내용 | 텍스트 | N | 공백 | - | 현재 `NtftalkTempSearchParams`에 미정의(백엔드 확장 필요) | +| 채널 | 드롭다운 | N | 전체 | `senderKey` | 화면명 `@채널`과 `senderKey` 매핑 | +| 검수상태 | 드롭다운 | N | 전체 | `inspectionStatus` | 예: 승인/반려/요청 등 코드값 | +| 메시지유형 | 드롭다운 | N | 전체 | - | 현재 `NtftalkTempSearchParams`에 미정의(백엔드 확장 필요) | + +### 4-2. 버튼 + +| 버튼명 | 동작 | +| ------ | ----------------------------------------------------------------------- | +| 조회 | 현재 필터 조건으로 영역 B 목록을 재조회한다. 페이지는 1페이지로 초기화. | +| 초기화 | 검색 필드를 기본값(공백·전체)으로 되돌린다. | + +--- + +## 5. 영역 B: 알림톡 템플릿 관리 + +### 5-1. 툴바 버튼 + +| 버튼명 | 동작(요구사항) | +| ------------- | ------------------------------------------------------- | +| 템플릿갱신 | API를 재조회하여 템플릿 목록을 갱신한다. | +| 템플릿등록 | 신규 템플릿 등록 다이얼로그를 오픈한다. | +| 선택 검수요청 | 체크된 행에 대해 검수 요청 API를 호출한다. | +| 선택삭제 | 체크된 행을 삭제한다. 데이터베이스에는 삭제되지 않는다. | +| 저장 | 그리드에서 변경된 내용을 일괄 저장한다. | + +※ 실제 API·권한은 백엔드 정책에 따른다. + +### 5-2. 그리드 컬럼 + +| 표시명 | Field | 비고 | +| ---------- | ----------------------- | ----------------------------------------- | +| (선택) | — | 체크박스, 다중 선택 | +| @채널 | `senderKey` | | +| 템플릿코드 | `templateCode` | | +| 템플릿명 | `templateName` | | +| 메시지유형 | `templateMessageType` | | +| 템플릿내용 | `templateContent` | 긴 텍스트, 말줄임 처리 | +| 강조유형 | `templateEmphasizeType` | | +| 버튼정보 | `buttons` | | +| 검수상태 | `inspectionStatus` | | +| 상태 | `status` | | +| 등록일자 | `createdAt` | ISO datetime 문자열 (YYYY-MM-DD HH:mm:ss) | + +### 5-3. 행 상호작용 + +- 행 **클릭** 시: 해당 템플릿의 상세 다이얼로그를 오픈한다. +- 다중 체크는 **삭제·검수요청** 등 일괄 작업용으로 사용한다. + +--- + +## 6. 영역 C: 변수관리 + +알림톡 템플릿에 사용되는 공통 변수 목록을 관리한다. + +### 6-1. 툴바 버튼 + +| 버튼명 | 동작 | +| -------- | ----------------------------- | +| 변수추가 | 변수 행 추가(인라인). | +| 선택삭제 | 선택된 변수 행 삭제. | +| 저장 | 추가·수정·삭제 반영 API 호출. | + +### 6-2. 그리드 컬럼 + +| 표시명 | Field | 비고 | +| -------- | --------- | ----------------------------------------- | +| 순번 | — | 화면 표시용 row index(서버 필드 없음) | +| 변수코드 | `varCd` | | +| 변수명 | `varNm` | | +| 설명 | `varDesc` | | +| 등록일자 | `regDt` | ISO datetime 문자열 (YYYY-MM-DD HH:mm:ss) | +| 등록자 | `regr` | | + +--- + +## 7. API/DTO 명세 + +### 7-1. 공통 + +- Base Endpoint Prefix: `/ntftalk-service/api/v1` + +### 7-2. 템플릿 API + +Base: `ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP`(`/ntftalk-service/api/v1/ntftalk-temp`) + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ---------------- | ------ | ------------------------------ | ------------------------------------------------------ | --------------------- | -------------------- | +| 템플릿 목록 조회 | GET | `/ntftalk-temp` | `NtftalkTempSearchParams` | `NtftalkTempPageList` | `getNtftalkTempPage` | +| 템플릿 상세 조회 | GET | `/ntftalk-temp/{templateCode}` | Path: `templateCode` | `NtftalkTemp` | `getNtftalkTemp` | +| 템플릿 등록 | POST | `/ntftalk-temp` | `NtftalkTempSavePayload` | `NtftalkTemp` | `saveNtftalkTemp` | +| 템플릿 수정 | PUT | `/ntftalk-temp/{templateCode}` | Path: `templateCode`, Body: `NtftalkTempUpdatePayload` | `NtftalkTemp` | `updateNtftalkTemp` | +| 템플릿 삭제 | DELETE | `/ntftalk-temp/{templateCode}` | Path: `templateCode` | 없음 | `deleteNtftalkTemp` | + +### 7-3. 변수 API + +Base: `ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR`(`/ntftalk-service/api/v1/ntftalk-var`) + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| -------------- | ------ | ---------------------- | ---------------------------------------------- | -------------------- | ------------------- | +| 변수 목록 조회 | GET | `/ntftalk-var` | `NtftalkVarSearchParams` | `NtftalkVarPageList` | `getNtftalkVarPage` | +| 변수 상세 조회 | GET | `/ntftalk-var/{varCd}` | Path: `varCd` | `NtftalkVar` | `getNtftalkVar` | +| 변수 등록 | POST | `/ntftalk-var` | `NtftalkVarSavePayload` | `NtftalkVar` | `saveNtftalkVar` | +| 변수 수정 | PUT | `/ntftalk-var/{varCd}` | Path: `varCd`, Body: `NtftalkVarUpdatePayload` | `NtftalkVar` | `updateNtftalkVar` | +| 변수 삭제 | DELETE | `/ntftalk-var/{varCd}` | Path: `varCd` | 없음 | `deleteNtftalkVar` | diff --git a/apps/cs/src/entities/ntftalk/api/ntftalk-temp-service.ts b/apps/cs/src/entities/ntftalk/api/ntftalk-temp-service.ts new file mode 100644 index 000000000..190e9964a --- /dev/null +++ b/apps/cs/src/entities/ntftalk/api/ntftalk-temp-service.ts @@ -0,0 +1,56 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + type NtftalkTemp, + type NtftalkTempPageList, + type NtftalkTempSavePayload, + type NtftalkTempSearchParams, + type NtftalkTempService, + type NtftalkTempUpdatePayload, +} from "../model/ntftalk-temp-interface"; + +class NtftalkTempApiService implements NtftalkTempService { + async getNtftalkTempPage( + params: NtftalkTempSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}`, + { params }, + ); + return response.data; + } + + async getNtftalkTemp(templateCode: string): Promise { + const response = await client.get( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/${encodeURIComponent(templateCode)}`, + ); + return response.data; + } + + async saveNtftalkTemp(payload: NtftalkTempSavePayload): Promise { + const response = await client.post( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}`, + payload, + ); + return response.data; + } + + async updateNtftalkTemp( + templateCode: string, + payload: NtftalkTempUpdatePayload, + ): Promise { + const response = await client.put( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/${encodeURIComponent(templateCode)}`, + payload, + ); + return response.data; + } + + async deleteNtftalkTemp(templateCode: string): Promise { + await client.delete( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/${encodeURIComponent(templateCode)}`, + ); + } +} + +export const NtftalkTempApi = new NtftalkTempApiService(); diff --git a/apps/cs/src/entities/ntftalk/api/ntftalk-var-service.ts b/apps/cs/src/entities/ntftalk/api/ntftalk-var-service.ts new file mode 100644 index 000000000..972ca0208 --- /dev/null +++ b/apps/cs/src/entities/ntftalk/api/ntftalk-var-service.ts @@ -0,0 +1,56 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + type NtftalkVar, + type NtftalkVarPageList, + type NtftalkVarSavePayload, + type NtftalkVarSearchParams, + type NtftalkVarService, + type NtftalkVarUpdatePayload, +} from "../model/ntftalk-var-interface"; + +class NtftalkVarApiService implements NtftalkVarService { + async getNtftalkVarPage( + params: NtftalkVarSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}`, + { params }, + ); + return response.data; + } + + async getNtftalkVar(varCd: string): Promise { + const response = await client.get( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}/${encodeURIComponent(varCd)}`, + ); + return response.data; + } + + async saveNtftalkVar(payload: NtftalkVarSavePayload): Promise { + const response = await client.post( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}`, + payload, + ); + return response.data; + } + + async updateNtftalkVar( + varCd: string, + payload: NtftalkVarUpdatePayload, + ): Promise { + const response = await client.put( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}/${encodeURIComponent(varCd)}`, + payload, + ); + return response.data; + } + + async deleteNtftalkVar(varCd: string): Promise { + await client.delete( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}/${encodeURIComponent(varCd)}`, + ); + } +} + +export const NtftalkVarApi = new NtftalkVarApiService(); diff --git a/apps/cs/src/entities/ntftalk/index.ts b/apps/cs/src/entities/ntftalk/index.ts new file mode 100644 index 000000000..87c3dfe2c --- /dev/null +++ b/apps/cs/src/entities/ntftalk/index.ts @@ -0,0 +1,20 @@ +export { NtftalkVarApi } from "./api/ntftalk-var-service"; +export { NtftalkTempApi } from "./api/ntftalk-temp-service"; +export type { + NtftalkVar, + NtftalkVarPageList, + NtftalkVarSavePayload, + NtftalkVarSearchParams, + NtftalkVarUpdatePayload, +} from "./model/ntftalk-var-interface"; +export type { + NtftalkBtnTemp, + NtftalkBtnTempSavePayload, + NtftalkQuickTemp, + NtftalkQuickTempSavePayload, + NtftalkTemp, + NtftalkTempPageList, + NtftalkTempSavePayload, + NtftalkTempSearchParams, + NtftalkTempUpdatePayload, +} from "./model/ntftalk-temp-interface"; diff --git a/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts b/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts new file mode 100644 index 000000000..79d537b28 --- /dev/null +++ b/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts @@ -0,0 +1,106 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface NtftalkTempSearchParams extends PageableParams { + templateCode?: string; + templateName?: string; + inspectionStatus?: string; + senderKey?: string; + status?: string; +} + +export interface NtftalkBtnTemp { + templateCode: string; + name: string; + ordering: number; + linkType: string; + linkMo: string; + linkPc: string; + linkAnd: string; + linkIos: string; +} + +export interface NtftalkQuickTemp { + templateCode: string; + name: string; + linkType: string; + linkMo: string; + linkPc: string; + linkAnd: string; + linkIos: string; +} + +export interface NtftalkTemp { + templateCode: string; + senderKey: string; + senderKeyType: string; + templateName: string; + templateContent: string; + templateMessageType: string; + templateEmphasizeType: string; + templateImageUrl: string; + inspectionStatus: string; + status: string; + createdAt: string; + modifiedAt: string; + buttons?: NtftalkBtnTemp[]; + quickReplies?: NtftalkQuickTemp[]; +} + +export interface NtftalkBtnTempSavePayload { + templateCode?: string; + name: string; + ordering: number; + linkType: string; + linkMo?: string; + linkPc?: string; + linkAnd?: string; + linkIos?: string; +} + +export interface NtftalkQuickTempSavePayload { + templateCode?: string; + name: string; + linkType: string; + linkMo?: string; + linkPc?: string; + linkAnd?: string; + linkIos?: string; +} + +export interface NtftalkTempSavePayload { + templateCode: string; + senderKey: string; + senderKeyType: string; + templateName: string; + templateContent: string; + templateMessageType: string; + templateEmphasizeType: string; + templateImageUrl?: string; + inspectionStatus?: string; + buttons?: NtftalkBtnTempSavePayload[]; + quickReplies?: NtftalkQuickTempSavePayload[]; +} + +export interface NtftalkTempUpdatePayload { + templateName: string; + templateContent: string; + inspectionStatus?: string; + status?: string; + buttons?: NtftalkBtnTempSavePayload[]; + quickReplies?: NtftalkQuickTempSavePayload[]; +} + +export type NtftalkTempPageList = PageableList; + +export interface NtftalkTempService { + getNtftalkTempPage( + params: NtftalkTempSearchParams, + ): Promise; + getNtftalkTemp(templateCode: string): Promise; + saveNtftalkTemp(payload: NtftalkTempSavePayload): Promise; + updateNtftalkTemp( + templateCode: string, + payload: NtftalkTempUpdatePayload, + ): Promise; + deleteNtftalkTemp(templateCode: string): Promise; +} diff --git a/apps/cs/src/entities/ntftalk/model/ntftalk-var-interface.ts b/apps/cs/src/entities/ntftalk/model/ntftalk-var-interface.ts new file mode 100644 index 000000000..7a52a7a89 --- /dev/null +++ b/apps/cs/src/entities/ntftalk/model/ntftalk-var-interface.ts @@ -0,0 +1,42 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface NtftalkVarSearchParams extends PageableParams { + varCd?: string; + varNm?: string; + regr?: string; +} + +export interface NtftalkVar { + varCd: string; + sender: string; + varNm: string; + varDesc: string; + regDt: string; + regr: string; +} + +export interface NtftalkVarSavePayload { + varCd: string; + sender?: string; + varNm: string; + varDesc?: string; + regr?: string; +} + +export interface NtftalkVarUpdatePayload { + varNm: string; + varDesc?: string; +} + +export type NtftalkVarPageList = PageableList; + +export interface NtftalkVarService { + getNtftalkVarPage(params: NtftalkVarSearchParams): Promise; + getNtftalkVar(varCd: string): Promise; + saveNtftalkVar(payload: NtftalkVarSavePayload): Promise; + updateNtftalkVar( + varCd: string, + payload: NtftalkVarUpdatePayload, + ): Promise; + deleteNtftalkVar(varCd: string): Promise; +} diff --git a/apps/cs/src/pages/ntftalk-temp/page.tsx b/apps/cs/src/pages/ntftalk-temp/page.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index db8eda72c..5d1cb6bcd 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -66,6 +66,10 @@ export const ENDPOINT = { POST_EVALUATION: "/board-service/api/v1/pstEvl", STATISTICS: "/board-service/api/v1/stat/bbs", }, + NTFTALK_SERVICE: { + NTFTALK_VAR: "/ntftalk-service/api/v1/ntftalk-var", + NTFTALK_TEMP: "/ntftalk-service/api/v1/ntftalk-temp", + }, }; export const JWT_EXPIRATION_TIME = 1800000;