File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import 'server-only';
import { backendFetch } from '@/lib/http/backend-fetch';
import { decodeJwtPayload } from '@/lib/auth/decode-jwt-payload';
import type { AdminUser } from '@/lib/domain/admin-user';
/**
* 관리자 인증 Repository — 백엔드(edupay-backend) 실연동.
*
* 엔드포인트: `POST /api/v1/common/auth/admin/login` (baseURL: lib/env.ts의 getApiBaseUrl()).
* 요청 필드명은 `loginPw`다(`password`가 아님 — 백엔드 계약 그대로).
*
* 백엔드에는 관리자 프로필 조회 API가 없다(2026-08 기준) — 그래서 로그인 응답으로 받은
* accessToken(JWT)의 payload를 디코딩해 AdminUser를 구성한다. 서명 검증은 하지 않는다
* (백엔드 시크릿이 없어 불가능 — lib/auth/decode-jwt-payload.ts 참고, 위조 방지는 이후
* 우리 세션 HMAC 서명이 담당한다). 관리자 "이름" 클레임이 토큰에 없어 loginId로 대체한다
* (사용자 확정 사항).
*/
export type AdminCredentials = {
loginId: string;
password: string;
};
export type AdminLoginResult = {
admin: AdminUser;
accessToken: string;
refreshToken: string;
/** 백엔드 accessToken의 `exp` 클레임(unix seconds) — 세션 만료를 이 값에 정렬시키는 데 쓴다. */
accessTokenExpiresAt: number;
};
const ADMIN_LOGOUT_PATH = '/api/v1/common/auth/logout';
/** 백엔드 accessToken(JWT)의 payload 클레임. */
type AdminAccessTokenClaims = {
sub: string;
loginId: string;
admRoleCd: string;
adminId: string;
userType: string;
userId: string;
iat: number;
exp: number;
};
type AdminLoginResponse = {
accessToken: string;
refreshToken: string;
};
/**
* 관리자 로그인을 시도한다. 자격 불일치·존재하지 않는 계정 등 인증 실패는 예외가 아니라
* 정상 흐름이므로 null을 반환한다(네트워크·파싱 등 진짜 예외 상황은 lib/http/backend-fetch.ts가
* 흡수해 동일하게 `ok:false`로 내려주므로 이 함수 입장에서는 실패 사유를 구분하지 않는다 —
* 호출부가 항상 일반화된 오류 메시지로 응답하기 때문에 구분할 필요가 없다).
*
* 캐시 전략: `no-store` — 인증 요청은 재사용 캐시 대상이 아니다(매 시도가 백엔드에 도달해야 함).
*/
export async function requestAdminLogin(
credentials: AdminCredentials
): Promise<AdminLoginResult | null> {
const result = await backendFetch<AdminLoginResponse>(
'/api/v1/common/auth/admin/login',
{
method: 'POST',
body: { loginId: credentials.loginId, loginPw: credentials.password },
cache: 'no-store',
}
);
if (!result.ok) {
return null;
}
const claims = decodeJwtPayload<AdminAccessTokenClaims>(result.data.accessToken);
if (
!claims ||
typeof claims.adminId !== 'string' ||
!claims.adminId ||
typeof claims.loginId !== 'string' ||
!claims.loginId ||
typeof claims.admRoleCd !== 'string' ||
!claims.admRoleCd ||
typeof claims.exp !== 'number'
) {
return null;
}
const admin: AdminUser = {
id: claims.adminId,
// 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항).
name: claims.loginId,
loginId: claims.loginId,
roleCode: claims.admRoleCd,
};
return {
admin,
accessToken: result.data.accessToken,
refreshToken: result.data.refreshToken,
accessTokenExpiresAt: claims.exp,
};
}
/**
* 관리자 로그아웃을 백엔드에 알린다 — `GET /api/v1/common/auth/logout`(인증 필요, AUTH_WHITELIST에
* 없음을 확인함: edupay-backend SecurityConfig.java).
*
* JWT는 stateless라 이 호출이 accessToken 자체를 무효화하지는 않는다 — 발급된 토큰은 만료까지
* 계속 유효하다. 백엔드가 실질적으로 하는 일은 FCM 토큰 삭제뿐이다(edupay-backend
* CmmAuthApiController#actionLogoutJSON: `deleteFcmToken` + `SecurityContextLogoutHandler.logout`,
* 후자는 STATELESS 세션 정책상 사실상 무의미). 그래도 정식 절차이므로 호출은 한다.
*
* 성공 응답의 data는 항상 null이다(`ApiResponseVO.success(null)`) — `canHaveNullData: true`로
* 이 정상 응답을 실패로 오판하지 않게 한다(lib/http/backend-fetch.ts 참고).
*
* 실패(백엔드 다운·accessToken 만료 등)를 예외로 다루지 않고 boolean으로 반환한다 — 로그아웃은
* 백엔드 호출이 실패해도 반드시 로컬에서는 진행돼야 하므로, 호출부(Server Action)가 try/catch
* 없이 결과만 보고 흐름을 결정할 수 있게 한다.
*
* 캐시 전략: `no-store` — 상태를 변경하는 요청이라 캐시 대상이 아니다.
*/
export async function requestAdminLogout(accessToken: string): Promise<boolean> {
const result = await backendFetch<null>(ADMIN_LOGOUT_PATH, {
method: 'GET',
accessToken,
cache: 'no-store',
canHaveNullData: true,
});
return result.ok;
}