Merge remote-tracking branch 'origin/iipbs/cs/dev'

This commit is contained in:
2026-04-08 17:12:41 +09:00
30 changed files with 3629 additions and 2474 deletions
+14 -10
View File
@@ -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;
}
+157
View File
@@ -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` |
+2 -1
View File
@@ -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"
}
}
}
+2 -1
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
export { LoginApi } from "./api/login-service";
export { login, silentRefresh, logout } from "./api/auth-service";
export { AuthWrapper } from "./lib/auth-wrapper";
+62 -29
View File
@@ -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}</>;
+13 -80
View File
@@ -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();
+20
View File
@@ -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 =
+4
View File
@@ -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;
+16
View File
@@ -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",
};
+1
View File
@@ -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;
+492
View File
@@ -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<void>, 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
+29
View File
@@ -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);
}
+7
View File
@@ -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";
+32
View File
@@ -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);
}
+18
View File
@@ -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"
}
}
+77
View File
@@ -0,0 +1,77 @@
import type { AuthConfig } from "./types";
import { getCookie, setCookie, removeCookie } from "./cookie";
export function createTokenManager(config: AuthConfig) {
let refreshTimer: ReturnType<typeof setTimeout> | 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<void>, 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<typeof createTokenManager>;
+23
View File
@@ -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"]
}
+15
View File
@@ -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;
}
+2273 -2245
View File
File diff suppressed because it is too large Load Diff