Merge remote-tracking branch 'origin/iipbs/cs/dev'
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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` |
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StoreProvider>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
import {
|
||||
ENDPOINT,
|
||||
CLAIM_NAME,
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
} from "@/shared/config";
|
||||
import type { ILogin } from "../model/auth-interface";
|
||||
import {
|
||||
setToken,
|
||||
clearAuthToken,
|
||||
setupRefreshTimer,
|
||||
} from "../lib/token-manager";
|
||||
|
||||
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 async function login(payload: ILogin): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
clearAuthToken();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
@@ -1,2 +1,2 @@
|
||||
export { LoginApi } from "./api/login-service";
|
||||
export { login, silentRefresh, logout } from "./api/auth-service";
|
||||
export { AuthWrapper } from "./lib/auth-wrapper";
|
||||
|
||||
@@ -1,47 +1,80 @@
|
||||
"use client";
|
||||
|
||||
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 { getCookie, useUserStore } from "@/shared/lib";
|
||||
import { isAtLoginRedirectUrl } from "@common/network";
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import {
|
||||
ENDPOINT,
|
||||
AUTH_USER_ID,
|
||||
AUTH_LOGIN_REDIRECT_URL,
|
||||
CLAIM_NAME,
|
||||
} from "@/shared/config";
|
||||
import { getToken, setupRefreshTimer } from "./token-manager";
|
||||
import { silentRefresh } from "../api/auth-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<IUserInfo>(
|
||||
`${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<IUserInfo>(
|
||||
`${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}</>;
|
||||
|
||||
@@ -1,82 +1,15 @@
|
||||
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
|
||||
import { getCookie, setCookie, removeCookie } from "@/shared/lib";
|
||||
import { createTokenManager } from "@common/auth";
|
||||
import { authConfig } from "@/shared/config/auth";
|
||||
|
||||
export class TokenManager {
|
||||
private static instance: TokenManager;
|
||||
private refreshTimer: NodeJS.Timeout | null = null;
|
||||
export const tokenManager = createTokenManager(authConfig);
|
||||
|
||||
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<void>,
|
||||
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();
|
||||
export const {
|
||||
getToken,
|
||||
setToken,
|
||||
removeToken,
|
||||
setupRefreshTimer,
|
||||
clearAuthToken,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
getUserId,
|
||||
} = tokenManager;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ILogin {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface ILogin {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginService {
|
||||
login(payload: ILogin): Promise<void>;
|
||||
silentRefresh(): Promise<void>;
|
||||
}
|
||||
@@ -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<NtftalkTempPageList> {
|
||||
const response = await client.get<NtftalkTempPageList>(
|
||||
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getNtftalkTemp(templateCode: string): Promise<NtftalkTemp> {
|
||||
const response = await client.get<NtftalkTemp>(
|
||||
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/${encodeURIComponent(templateCode)}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async saveNtftalkTemp(payload: NtftalkTempSavePayload): Promise<NtftalkTemp> {
|
||||
const response = await client.post<NtftalkTemp>(
|
||||
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateNtftalkTemp(
|
||||
templateCode: string,
|
||||
payload: NtftalkTempUpdatePayload,
|
||||
): Promise<NtftalkTemp> {
|
||||
const response = await client.put<NtftalkTemp>(
|
||||
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/${encodeURIComponent(templateCode)}`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteNtftalkTemp(templateCode: string): Promise<void> {
|
||||
await client.delete(
|
||||
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP}/${encodeURIComponent(templateCode)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const NtftalkTempApi = new NtftalkTempApiService();
|
||||
@@ -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<NtftalkVarPageList> {
|
||||
const response = await client.get<NtftalkVarPageList>(
|
||||
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getNtftalkVar(varCd: string): Promise<NtftalkVar> {
|
||||
const response = await client.get<NtftalkVar>(
|
||||
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}/${encodeURIComponent(varCd)}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async saveNtftalkVar(payload: NtftalkVarSavePayload): Promise<NtftalkVar> {
|
||||
const response = await client.post<NtftalkVar>(
|
||||
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateNtftalkVar(
|
||||
varCd: string,
|
||||
payload: NtftalkVarUpdatePayload,
|
||||
): Promise<NtftalkVar> {
|
||||
const response = await client.put<NtftalkVar>(
|
||||
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}/${encodeURIComponent(varCd)}`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteNtftalkVar(varCd: string): Promise<void> {
|
||||
await client.delete(
|
||||
`${ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR}/${encodeURIComponent(varCd)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const NtftalkVarApi = new NtftalkVarApiService();
|
||||
@@ -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";
|
||||
@@ -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<NtftalkTemp>;
|
||||
|
||||
export interface NtftalkTempService {
|
||||
getNtftalkTempPage(
|
||||
params: NtftalkTempSearchParams,
|
||||
): Promise<NtftalkTempPageList>;
|
||||
getNtftalkTemp(templateCode: string): Promise<NtftalkTemp>;
|
||||
saveNtftalkTemp(payload: NtftalkTempSavePayload): Promise<NtftalkTemp>;
|
||||
updateNtftalkTemp(
|
||||
templateCode: string,
|
||||
payload: NtftalkTempUpdatePayload,
|
||||
): Promise<NtftalkTemp>;
|
||||
deleteNtftalkTemp(templateCode: string): Promise<void>;
|
||||
}
|
||||
@@ -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<NtftalkVar>;
|
||||
|
||||
export interface NtftalkVarService {
|
||||
getNtftalkVarPage(params: NtftalkVarSearchParams): Promise<NtftalkVarPageList>;
|
||||
getNtftalkVar(varCd: string): Promise<NtftalkVar>;
|
||||
saveNtftalkVar(payload: NtftalkVarSavePayload): Promise<NtftalkVar>;
|
||||
updateNtftalkVar(
|
||||
varCd: string,
|
||||
payload: NtftalkVarUpdatePayload,
|
||||
): Promise<NtftalkVar>;
|
||||
deleteNtftalkVar(varCd: string): Promise<void>;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user