Merge branch 'claude/confident-bell-efa28b' into hub
@ff064bced3ad39992f343d01e6a568d9d95abc41
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import { BackendRequestError } from '@/lib/http/backend-fetch';
|
... | ... | @@ -48,6 +49,8 @@ |
| 48 | 49 |
await write(); |
| 49 | 50 |
return null; |
| 50 | 51 |
} catch (error) {
|
| 52 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 53 |
+ unstable_rethrow(error); |
|
| 51 | 54 |
if (error instanceof BackendRequestError) {
|
| 52 | 55 |
return { status: 'error', message: error.message };
|
| 53 | 56 |
} |
... | ... | @@ -166,6 +169,7 @@ |
| 166 | 169 |
return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
|
| 167 | 170 |
} |
| 168 | 171 |
} catch (error) {
|
| 172 |
+ unstable_rethrow(error); |
|
| 169 | 173 |
if (error instanceof BackendRequestError) {
|
| 170 | 174 |
return { status: 'failed', message: error.message };
|
| 171 | 175 |
} |
--- app/(protected)/(basic)/boards/_actions.ts
+++ app/(protected)/(basic)/boards/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
... | ... | @@ -80,6 +81,8 @@ |
| 80 | 81 |
await write(); |
| 81 | 82 |
return null; |
| 82 | 83 |
} catch (error) {
|
| 84 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 85 |
+ unstable_rethrow(error); |
|
| 83 | 86 |
return {
|
| 84 | 87 |
status: 'error', |
| 85 | 88 |
message: error instanceof Error ? error.message : fallback, |
... | ... | @@ -161,6 +164,7 @@ |
| 161 | 164 |
try {
|
| 162 | 165 |
attachmentId = await resolveAttachmentId(formData); |
| 163 | 166 |
} catch (error) {
|
| 167 |
+ unstable_rethrow(error); |
|
| 164 | 168 |
return {
|
| 165 | 169 |
status: 'error', |
| 166 | 170 |
message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, |
... | ... | @@ -208,6 +212,7 @@ |
| 208 | 212 |
try {
|
| 209 | 213 |
attachmentId = await resolveAttachmentId(formData); |
| 210 | 214 |
} catch (error) {
|
| 215 |
+ unstable_rethrow(error); |
|
| 211 | 216 |
return {
|
| 212 | 217 |
status: 'error', |
| 213 | 218 |
message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, |
... | ... | @@ -254,6 +259,7 @@ |
| 254 | 259 |
try {
|
| 255 | 260 |
attachmentId = await resolveAttachmentId(formData); |
| 256 | 261 |
} catch (error) {
|
| 262 |
+ unstable_rethrow(error); |
|
| 257 | 263 |
return {
|
| 258 | 264 |
status: 'error', |
| 259 | 265 |
message: error instanceof Error ? error.message : UPLOAD_FAILED_MESSAGE, |
... | ... | @@ -312,7 +318,8 @@ |
| 312 | 318 |
return { status: 'error', message: GONE_MESSAGE };
|
| 313 | 319 |
} |
| 314 | 320 |
return { status: 'success', post };
|
| 315 |
- } catch {
|
|
| 321 |
+ } catch (error) {
|
|
| 322 |
+ unstable_rethrow(error); |
|
| 316 | 323 |
return { status: 'error', message: LOAD_FAILED_MESSAGE };
|
| 317 | 324 |
} |
| 318 | 325 |
} |
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 | 3 |
import { revalidatePath } from 'next/cache';
|
| 4 |
-import { redirect } from 'next/navigation';
|
|
| 4 |
+import { redirect, unstable_rethrow } from 'next/navigation';
|
|
| 5 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 6 |
import { BackendRequestError } from '@/lib/http/backend-fetch';
|
| 7 | 7 |
import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
|
... | ... | @@ -122,6 +122,8 @@ |
| 122 | 122 |
try {
|
| 123 | 123 |
return await run(); |
| 124 | 124 |
} catch (error) {
|
| 125 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 126 |
+ unstable_rethrow(error); |
|
| 125 | 127 |
// 진짜 원인은 여기에만 남는다 — 화면에는 사람이 고칠 수 있는 사유만 나간다. |
| 126 | 128 |
console.error('[contents] 저장 실패', error);
|
| 127 | 129 |
return failure(error, SAVE_FAILED_MESSAGE); |
... | ... | @@ -448,6 +450,7 @@ |
| 448 | 450 |
await deleteContent(kind, id); |
| 449 | 451 |
} |
| 450 | 452 |
} catch (error) {
|
| 453 |
+ unstable_rethrow(error); |
|
| 451 | 454 |
return failure(error, DELETE_FAILED_MESSAGE); |
| 452 | 455 |
} |
| 453 | 456 |
|
--- app/(protected)/(basic)/decoration-items/_actions.ts
+++ app/(protected)/(basic)/decoration-items/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import {
|
... | ... | @@ -154,13 +155,16 @@ |
| 154 | 155 |
try {
|
| 155 | 156 |
imageFileId = await uploadDecorationItemImage(newFile); |
| 156 | 157 |
} catch (error) {
|
| 158 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 159 |
+ unstable_rethrow(error); |
|
| 157 | 160 |
return { status: 'error', errors: { imageFileId: describeUploadFailure(error) } };
|
| 158 | 161 |
} |
| 159 | 162 |
} |
| 160 | 163 |
|
| 161 | 164 |
try {
|
| 162 | 165 |
await createDecorationItem({ ...validation.values, imageFileId });
|
| 163 |
- } catch {
|
|
| 166 |
+ } catch (error) {
|
|
| 167 |
+ unstable_rethrow(error); |
|
| 164 | 168 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 165 | 169 |
} |
| 166 | 170 |
|
... | ... | @@ -204,13 +208,15 @@ |
| 204 | 208 |
try {
|
| 205 | 209 |
imageFileId = await uploadDecorationItemImage(newFile); |
| 206 | 210 |
} catch (error) {
|
| 211 |
+ unstable_rethrow(error); |
|
| 207 | 212 |
return { status: 'error', errors: { imageFileId: describeUploadFailure(error) } };
|
| 208 | 213 |
} |
| 209 | 214 |
} |
| 210 | 215 |
|
| 211 | 216 |
try {
|
| 212 | 217 |
await updateDecorationItem(itemSn, { ...validation.values, imageFileId });
|
| 213 |
- } catch {
|
|
| 218 |
+ } catch (error) {
|
|
| 219 |
+ unstable_rethrow(error); |
|
| 214 | 220 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 215 | 221 |
} |
| 216 | 222 |
|
... | ... | @@ -236,7 +242,8 @@ |
| 236 | 242 |
|
| 237 | 243 |
try {
|
| 238 | 244 |
await deleteDecorationItem(itemSn); |
| 239 |
- } catch {
|
|
| 245 |
+ } catch (error) {
|
|
| 246 |
+ unstable_rethrow(error); |
|
| 240 | 247 |
return { status: 'error', message: DELETE_FAILED_MESSAGE };
|
| 241 | 248 |
} |
| 242 | 249 |
|
--- app/(protected)/(basic)/points/standards/_actions.ts
+++ app/(protected)/(basic)/points/standards/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
|
... | ... | @@ -60,7 +61,9 @@ |
| 60 | 61 |
|
| 61 | 62 |
try {
|
| 62 | 63 |
await createPointStandard(validation.values); |
| 63 |
- } catch {
|
|
| 64 |
+ } catch (error) {
|
|
| 65 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 66 |
+ unstable_rethrow(error); |
|
| 64 | 67 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 65 | 68 |
} |
| 66 | 69 |
|
... | ... | @@ -84,7 +87,8 @@ |
| 84 | 87 |
|
| 85 | 88 |
try {
|
| 86 | 89 |
await updatePointStandard(validation.values); |
| 87 |
- } catch {
|
|
| 90 |
+ } catch (error) {
|
|
| 91 |
+ unstable_rethrow(error); |
|
| 88 | 92 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 89 | 93 |
} |
| 90 | 94 |
|
... | ... | @@ -99,7 +103,8 @@ |
| 99 | 103 |
|
| 100 | 104 |
try {
|
| 101 | 105 |
await deletePointStandard(id); |
| 102 |
- } catch {
|
|
| 106 |
+ } catch (error) {
|
|
| 107 |
+ unstable_rethrow(error); |
|
| 103 | 108 |
return { status: 'error', message: DELETE_FAILED_MESSAGE };
|
| 104 | 109 |
} |
| 105 | 110 |
|
--- app/(protected)/(basic)/system/banned-words/_actions.ts
+++ app/(protected)/(basic)/system/banned-words/_actions.ts
... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 | 3 |
import { revalidatePath } from 'next/cache';
|
| 4 |
-import { redirect } from 'next/navigation';
|
|
| 4 |
+import { redirect, unstable_rethrow } from 'next/navigation';
|
|
| 5 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 | 6 |
import { BackendRequestError } from '@/lib/http/backend-fetch';
|
| 7 | 7 |
import {
|
... | ... | @@ -47,6 +47,8 @@ |
| 47 | 47 |
try {
|
| 48 | 48 |
result = await createBannedWord(validation.value); |
| 49 | 49 |
} catch (error) {
|
| 50 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 51 |
+ unstable_rethrow(error); |
|
| 50 | 52 |
return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
|
| 51 | 53 |
} |
| 52 | 54 |
|
... | ... | @@ -66,6 +68,7 @@ |
| 66 | 68 |
try {
|
| 67 | 69 |
await deleteBannedWord(id); |
| 68 | 70 |
} catch (error) {
|
| 71 |
+ unstable_rethrow(error); |
|
| 69 | 72 |
return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
|
| 70 | 73 |
} |
| 71 | 74 |
|
... | ... | @@ -90,6 +93,7 @@ |
| 90 | 93 |
try {
|
| 91 | 94 |
result = await uploadBannedWordExcel(file); |
| 92 | 95 |
} catch (error) {
|
| 96 |
+ unstable_rethrow(error); |
|
| 93 | 97 |
return { status: 'error', message: failureMessage(error, UPLOAD_FAILED_MESSAGE) };
|
| 94 | 98 |
} |
| 95 | 99 |
|
--- app/(protected)/(basic)/system/codes/_actions.ts
+++ app/(protected)/(basic)/system/codes/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import {
|
... | ... | @@ -68,7 +69,9 @@ |
| 68 | 69 |
|
| 69 | 70 |
try {
|
| 70 | 71 |
await createCodeGroup(validation.values); |
| 71 |
- } catch {
|
|
| 72 |
+ } catch (error) {
|
|
| 73 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 74 |
+ unstable_rethrow(error); |
|
| 72 | 75 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 73 | 76 |
} |
| 74 | 77 |
|
... | ... | @@ -99,7 +102,8 @@ |
| 99 | 102 |
|
| 100 | 103 |
try {
|
| 101 | 104 |
await updateCodeGroup(targetComCd, validation.values); |
| 102 |
- } catch {
|
|
| 105 |
+ } catch (error) {
|
|
| 106 |
+ unstable_rethrow(error); |
|
| 103 | 107 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 104 | 108 |
} |
| 105 | 109 |
|
... | ... | @@ -124,7 +128,8 @@ |
| 124 | 128 |
|
| 125 | 129 |
try {
|
| 126 | 130 |
await deleteCodeGroup(comCd); |
| 127 |
- } catch {
|
|
| 131 |
+ } catch (error) {
|
|
| 132 |
+ unstable_rethrow(error); |
|
| 128 | 133 |
return { status: 'error', message: DELETE_FAILED_MESSAGE };
|
| 129 | 134 |
} |
| 130 | 135 |
|
... | ... | @@ -154,7 +159,8 @@ |
| 154 | 159 |
|
| 155 | 160 |
try {
|
| 156 | 161 |
await createCodeDetail(validation.values); |
| 157 |
- } catch {
|
|
| 162 |
+ } catch (error) {
|
|
| 163 |
+ unstable_rethrow(error); |
|
| 158 | 164 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 159 | 165 |
} |
| 160 | 166 |
|
... | ... | @@ -188,7 +194,8 @@ |
| 188 | 194 |
|
| 189 | 195 |
try {
|
| 190 | 196 |
await updateCodeDetail(targetComCd, targetComDtlCd, validation.values); |
| 191 |
- } catch {
|
|
| 197 |
+ } catch (error) {
|
|
| 198 |
+ unstable_rethrow(error); |
|
| 192 | 199 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 193 | 200 |
} |
| 194 | 201 |
|
... | ... | @@ -208,7 +215,8 @@ |
| 208 | 215 |
|
| 209 | 216 |
try {
|
| 210 | 217 |
await deleteCodeDetail(comCd, comDtlCd); |
| 211 |
- } catch {
|
|
| 218 |
+ } catch (error) {
|
|
| 219 |
+ unstable_rethrow(error); |
|
| 212 | 220 |
return { status: 'error', message: DELETE_FAILED_MESSAGE };
|
| 213 | 221 |
} |
| 214 | 222 |
|
--- app/(protected)/(basic)/system/merchants/_actions.ts
+++ app/(protected)/(basic)/system/merchants/_actions.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { unstable_rethrow } from 'next/navigation';
|
|
| 3 | 4 |
import { revalidatePath } from 'next/cache';
|
| 4 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 | 6 |
import { createMerchant } from '@/lib/data/repositories/merchant-repository';
|
... | ... | @@ -45,7 +46,9 @@ |
| 45 | 46 |
// TODO(백엔드): 수정 API가 없다(`/api/v1/mngr/voc`는 목록·등록 둘뿐). 생기면 여기서 가른다. |
| 46 | 47 |
try {
|
| 47 | 48 |
await createMerchant(validation.values); |
| 48 |
- } catch {
|
|
| 49 |
+ } catch (error) {
|
|
| 50 |
+ // 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
|
| 51 |
+ unstable_rethrow(error); |
|
| 49 | 52 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 50 | 53 |
} |
| 51 | 54 |
|
--- app/(public)/login/_actions.ts
+++ app/(public)/login/_actions.ts
... | ... | @@ -2,11 +2,17 @@ |
| 2 | 2 |
|
| 3 | 3 |
import { redirect } from 'next/navigation';
|
| 4 | 4 |
import { revalidatePath } from 'next/cache';
|
| 5 |
-import { requestAdminLogin } from '@/lib/data/repositories/auth-repository';
|
|
| 5 |
+import {
|
|
| 6 |
+ requestAdminLogin, |
|
| 7 |
+ type DuplicateLoginNotice, |
|
| 8 |
+} from '@/lib/data/repositories/auth-repository'; |
|
| 6 | 9 |
import { createSession } from '@/lib/auth/session';
|
| 10 |
+import { FORCE_LOGIN_FIELD } from '@/lib/constants/login';
|
|
| 7 | 11 |
|
| 8 | 12 |
export type LoginFormState = {
|
| 9 | 13 |
error?: string; |
| 14 |
+ /** 다른 곳에 살아 있는 세션이 있어 확인이 필요하다. 화면이 안내 후 강제 로그인으로 재요청한다. */ |
|
| 15 |
+ duplicate?: DuplicateLoginNotice; |
|
| 10 | 16 |
}; |
| 11 | 17 |
|
| 12 | 18 |
const GENERIC_LOGIN_ERROR = '아이디 또는 비밀번호가 올바르지 않습니다.'; |
... | ... | @@ -25,8 +31,6 @@ |
| 25 | 31 |
|
| 26 | 32 |
/** |
| 27 | 33 |
* 로그인 Server Action — 인증의 진입점이라 사전 인증 확인은 성립하지 않는다(예외로 명시). |
| 28 |
- * 입력 검증 → 자격 검증 → 실패 시 계정 존재 여부를 드러내지 않는 일반화 메시지 → |
|
| 29 |
- * 성공 시 세션 생성 → 클라이언트 라우터 캐시 무효화 → 리다이렉트. |
|
| 30 | 34 |
*/ |
| 31 | 35 |
export async function login( |
| 32 | 36 |
_prevState: LoginFormState, |
... | ... | @@ -44,18 +48,28 @@ |
| 44 | 48 |
return { error: GENERIC_LOGIN_ERROR };
|
| 45 | 49 |
} |
| 46 | 50 |
|
| 47 |
- const result = await requestAdminLogin({ loginId, password });
|
|
| 48 |
- if (!result) {
|
|
| 49 |
- return { error: GENERIC_LOGIN_ERROR };
|
|
| 51 |
+ const outcome = await requestAdminLogin({
|
|
| 52 |
+ loginId, |
|
| 53 |
+ password, |
|
| 54 |
+ forceLogin: formData.get(FORCE_LOGIN_FIELD) === '1', |
|
| 55 |
+ }); |
|
| 56 |
+ |
|
| 57 |
+ if (outcome.status === 'failure') {
|
|
| 58 |
+ return { error: outcome.message };
|
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 61 |
+ if (outcome.status === 'duplicate') {
|
|
| 62 |
+ return { duplicate: outcome.notice };
|
|
| 50 | 63 |
} |
| 51 | 64 |
|
| 52 | 65 |
await createSession({
|
| 53 |
- adminId: result.admin.id, |
|
| 54 |
- loginId: result.admin.loginId, |
|
| 55 |
- admRoleCd: result.admin.roleCode, |
|
| 56 |
- accessToken: result.accessToken, |
|
| 57 |
- refreshToken: result.refreshToken, |
|
| 58 |
- exp: result.accessTokenExpiresAt, |
|
| 66 |
+ userId: outcome.admin.id, |
|
| 67 |
+ loginId: outcome.admin.loginId, |
|
| 68 |
+ userNm: outcome.admin.name, |
|
| 69 |
+ roleId: outcome.admin.roleCode, |
|
| 70 |
+ accessToken: outcome.accessToken, |
|
| 71 |
+ refreshToken: outcome.refreshToken, |
|
| 72 |
+ exp: outcome.accessTokenExpiresAt, |
|
| 59 | 73 |
}); |
| 60 | 74 |
revalidatePath('/', 'layout');
|
| 61 | 75 |
redirect('/');
|
+++ app/(public)/login/_components/duplicate-login-modal.tsx
... | ... | @@ -0,0 +1,78 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 4 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 5 | +import { FoxDescriptionList } from '@fox/core/components/fox-description-list'; | |
| 6 | +import { FoxModal } from '@fox/core/components/fox-modal'; | |
| 7 | +import type { DuplicateLoginNotice } from '@/lib/data/repositories/auth-repository'; | |
| 8 | +import styles from '../login.module.scss'; | |
| 9 | + | |
| 10 | +interface DuplicateLoginModalProps { | |
| 11 | + notice: DuplicateLoginNotice; | |
| 12 | + isPending: boolean; | |
| 13 | + onCancel: () => void; | |
| 14 | + onConfirm: () => void; | |
| 15 | +} | |
| 16 | + | |
| 17 | +const EMPTY_VALUE = '-'; | |
| 18 | + | |
| 19 | +/** 백엔드가 DB DATETIME을 그대로 문자열로 내려준다 — 소수점 이하와 T 구분자만 다듬는다. */ | |
| 20 | +function formatLastLoginAt(value: string | null): string { | |
| 21 | + if (!value) { | |
| 22 | + return EMPTY_VALUE; | |
| 23 | + } | |
| 24 | + return value.replace('T', ' ').replace(/\.\d+$/, ''); | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function DuplicateLoginModal({ | |
| 28 | + notice, | |
| 29 | + isPending, | |
| 30 | + onCancel, | |
| 31 | + onConfirm, | |
| 32 | +}: DuplicateLoginModalProps) { | |
| 33 | + return ( | |
| 34 | + <FoxModal | |
| 35 | + open | |
| 36 | + onClose={onCancel} | |
| 37 | + size="sm" | |
| 38 | + title="이미 로그인된 접속이 있습니다" | |
| 39 | + actions={ | |
| 40 | + <FoxButtonGroup size="md"> | |
| 41 | + <FoxButton type="default" size="md" label="취소" onAction={onCancel} /> | |
| 42 | + <FoxButton | |
| 43 | + type="primary" | |
| 44 | + size="md" | |
| 45 | + label="로그인" | |
| 46 | + loading={isPending} | |
| 47 | + onAction={onConfirm} | |
| 48 | + /> | |
| 49 | + </FoxButtonGroup> | |
| 50 | + } | |
| 51 | + > | |
| 52 | + <div className={styles.duplicate}> | |
| 53 | + <FoxDescriptionList | |
| 54 | + items={[ | |
| 55 | + { | |
| 56 | + key: 'lastLoginAt', | |
| 57 | + term: '최근 접속 일시', | |
| 58 | + description: formatLastLoginAt(notice.lastLoginAt), | |
| 59 | + }, | |
| 60 | + { | |
| 61 | + key: 'lastLoginIp', | |
| 62 | + term: '최근 접속 IP', | |
| 63 | + description: notice.lastLoginIp ?? EMPTY_VALUE, | |
| 64 | + }, | |
| 65 | + { | |
| 66 | + key: 'currentIp', | |
| 67 | + term: '현재 접속 IP', | |
| 68 | + description: notice.currentIp ?? EMPTY_VALUE, | |
| 69 | + }, | |
| 70 | + ]} | |
| 71 | + /> | |
| 72 | + <p className={styles.duplicateNotice}> | |
| 73 | + 계속 로그인하면 기존 접속은 로그아웃됩니다. | |
| 74 | + </p> | |
| 75 | + </div> | |
| 76 | + </FoxModal> | |
| 77 | + ); | |
| 78 | +} |
--- app/(public)/login/_components/login-form.tsx
+++ app/(public)/login/_components/login-form.tsx
... | ... | @@ -1,11 +1,12 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useActionState, useId, useState } from 'react';
|
|
| 3 |
+import { startTransition, useActionState, useId, useRef, useState } from 'react';
|
|
| 4 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 5 |
import { FoxInput } from '@fox/core/components/fox-input';
|
| 6 |
+import { FORCE_LOGIN_FIELD } from '@/lib/constants/login';
|
|
| 6 | 7 |
import { login, type LoginFormState } from '../_actions';
|
| 7 | 8 |
import styles from '../login.module.scss'; |
| 8 |
-import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
|
| 9 |
+import { DuplicateLoginModal } from './duplicate-login-modal';
|
|
| 9 | 10 |
|
| 10 | 11 |
const initialState: LoginFormState = {};
|
| 11 | 12 |
|
... | ... | @@ -32,10 +33,35 @@ |
| 32 | 33 |
export function LoginForm() {
|
| 33 | 34 |
const [state, formAction, isPending] = useActionState(login, initialState); |
| 34 | 35 |
const [isPasswordVisible, setIsPasswordVisible] = useState(false); |
| 36 |
+ const [isDuplicateDismissed, setIsDuplicateDismissed] = useState(false); |
|
| 37 |
+ const formRef = useRef<HTMLFormElement>(null); |
|
| 35 | 38 |
const passwordId = useId(); |
| 36 | 39 |
|
| 40 |
+ // 이슈: `<form action={...}>`은 액션이 끝나면 성공·실패를 가리지 않고 폼을 리셋한다.
|
|
| 41 |
+ // 중복 로그인 안내에서 [확인]을 눌러 재요청할 때 아이디·비밀번호가 남아 있어야 하므로 |
|
| 42 |
+ // 액션을 직접 transition으로 부른다(app/_hooks/submit-form-action.ts와 같은 이유). |
|
| 43 |
+ function submit(force: boolean) {
|
|
| 44 |
+ const form = formRef.current; |
|
| 45 |
+ if (!form) {
|
|
| 46 |
+ return; |
|
| 47 |
+ } |
|
| 48 |
+ const formData = new FormData(form); |
|
| 49 |
+ if (force) {
|
|
| 50 |
+ formData.set(FORCE_LOGIN_FIELD, '1'); |
|
| 51 |
+ } |
|
| 52 |
+ setIsDuplicateDismissed(false); |
|
| 53 |
+ startTransition(() => formAction(formData)); |
|
| 54 |
+ } |
|
| 55 |
+ |
|
| 37 | 56 |
return ( |
| 38 |
- <form onSubmit={(event) => submitFormAction(event, formAction)} className={styles.fields}>
|
|
| 57 |
+ <form |
|
| 58 |
+ ref={formRef}
|
|
| 59 |
+ onSubmit={(event) => {
|
|
| 60 |
+ event.preventDefault(); |
|
| 61 |
+ submit(false); |
|
| 62 |
+ }} |
|
| 63 |
+ className={styles.fields}
|
|
| 64 |
+ > |
|
| 39 | 65 |
<FoxInput |
| 40 | 66 |
size="lg" |
| 41 | 67 |
label="ID" |
... | ... | @@ -88,6 +114,15 @@ |
| 88 | 114 |
disabled={isPending}
|
| 89 | 115 |
className={styles.submit}
|
| 90 | 116 |
/> |
| 117 |
+ |
|
| 118 |
+ {state.duplicate && !isDuplicateDismissed && (
|
|
| 119 |
+ <DuplicateLoginModal |
|
| 120 |
+ notice={state.duplicate}
|
|
| 121 |
+ isPending={isPending}
|
|
| 122 |
+ onCancel={() => setIsDuplicateDismissed(true)}
|
|
| 123 |
+ onConfirm={() => submit(true)}
|
|
| 124 |
+ /> |
|
| 125 |
+ )} |
|
| 91 | 126 |
</form> |
| 92 | 127 |
); |
| 93 | 128 |
} |
--- app/(public)/login/_components/logout-notice.tsx
+++ app/(public)/login/_components/logout-notice.tsx
... | ... | @@ -7,10 +7,14 @@ |
| 7 | 7 |
interface LogoutNoticeProps {
|
| 8 | 8 |
/** 로그아웃 Server Action이 백엔드 호출 실패를 알리려고 실어 보낸 쿼리 파라미터가 있었는가. */ |
| 9 | 9 |
showError: boolean; |
| 10 |
+ /** 다른 곳에서 로그인되어 세션이 밀렸는가. */ |
|
| 11 |
+ showSessionRevoked: boolean; |
|
| 10 | 12 |
} |
| 11 | 13 |
|
| 12 | 14 |
const LOGOUT_ERROR_MESSAGE = |
| 13 | 15 |
'로그아웃 처리 중 일부 오류가 있었지만 로그아웃되었습니다.'; |
| 16 |
+const SESSION_REVOKED_MESSAGE = |
|
| 17 |
+ '다른 곳에서 로그인되어 로그아웃되었습니다. 다시 로그인해 주세요.'; |
|
| 14 | 18 |
|
| 15 | 19 |
/** |
| 16 | 20 |
* 화면에 그릴 UI가 없는 알림 트리거 컴포넌트 — `app/(protected)/_actions.ts`의 `logout()`이 |
... | ... | @@ -31,21 +35,29 @@ |
| 31 | 35 |
* 그대로 true이기 때문이다. 프로덕션은 원래 1회지만, 사용자에게 실제로 보이는 부수효과(알림)는 |
| 32 | 36 |
* Strict Mode 유무와 무관하게 항상 1회여야 한다. |
| 33 | 37 |
*/ |
| 34 |
-export function LogoutNotice({ showError }: LogoutNoticeProps) {
|
|
| 38 |
+export function LogoutNotice({
|
|
| 39 |
+ showError, |
|
| 40 |
+ showSessionRevoked, |
|
| 41 |
+}: LogoutNoticeProps) {
|
|
| 35 | 42 |
const router = useRouter(); |
| 36 | 43 |
const { showToast } = useFeedback();
|
| 37 | 44 |
const hasNotifiedRef = useRef(false); |
| 38 | 45 |
|
| 39 | 46 |
useEffect(() => {
|
| 40 |
- if (!showError || hasNotifiedRef.current) {
|
|
| 47 |
+ if ((!showError && !showSessionRevoked) || hasNotifiedRef.current) {
|
|
| 41 | 48 |
return; |
| 42 | 49 |
} |
| 43 | 50 |
hasNotifiedRef.current = true; |
| 44 | 51 |
|
| 45 | 52 |
// 내부 정보(백엔드 message 원문 등)는 노출하지 않는다 — 항상 이 고정 문구만 보여준다. |
| 46 |
- showToast({ variant: 'warning', message: LOGOUT_ERROR_MESSAGE });
|
|
| 53 |
+ showToast({
|
|
| 54 |
+ variant: 'warning', |
|
| 55 |
+ message: showSessionRevoked |
|
| 56 |
+ ? SESSION_REVOKED_MESSAGE |
|
| 57 |
+ : LOGOUT_ERROR_MESSAGE, |
|
| 58 |
+ }); |
|
| 47 | 59 |
router.replace('/login');
|
| 48 |
- }, [showError, showToast, router]); |
|
| 60 |
+ }, [showError, showSessionRevoked, showToast, router]); |
|
| 49 | 61 |
|
| 50 | 62 |
return null; |
| 51 | 63 |
} |
--- app/(public)/login/login.module.scss
+++ app/(public)/login/login.module.scss
... | ... | @@ -105,3 +105,19 @@ |
| 105 | 105 |
.submit {
|
| 106 | 106 |
inline-size: 100%; |
| 107 | 107 |
} |
| 108 |
+ |
|
| 109 |
+// 중복 로그인 안내 팝업 — 조회 목록과 경고 문구 사이 간격만 준다. |
|
| 110 |
+.duplicate {
|
|
| 111 |
+ display: flex; |
|
| 112 |
+ flex-direction: column; |
|
| 113 |
+ gap: fox.gap(4); |
|
| 114 |
+} |
|
| 115 |
+ |
|
| 116 |
+.duplicateNotice {
|
|
| 117 |
+ margin: 0; |
|
| 118 |
+ color: fox.color(font-neutral-subtle); |
|
| 119 |
+ font-family: fox.font-family(body); |
|
| 120 |
+ font-size: fox.font-size(body-sm); |
|
| 121 |
+ line-height: 1.5; |
|
| 122 |
+ letter-spacing: -0.025em; |
|
| 123 |
+} |
--- app/(public)/login/page.tsx
+++ app/(public)/login/page.tsx
... | ... | @@ -1,5 +1,8 @@ |
| 1 | 1 |
import { getSessionAdmin } from '@/lib/auth/dal';
|
| 2 |
-import { LOGOUT_ERROR_QUERY_PARAM } from '@/lib/constants/logout';
|
|
| 2 |
+import {
|
|
| 3 |
+ LOGOUT_ERROR_QUERY_PARAM, |
|
| 4 |
+ SESSION_REVOKED_QUERY_PARAM, |
|
| 5 |
+} from '@/lib/constants/logout'; |
|
| 3 | 6 |
import { redirect } from 'next/navigation';
|
| 4 | 7 |
import styles from './login.module.scss'; |
| 5 | 8 |
import { LoginForm } from './_components/login-form';
|
... | ... | @@ -22,10 +25,16 @@ |
| 22 | 25 |
const resolvedSearchParams = await searchParams; |
| 23 | 26 |
const logoutError = resolvedSearchParams[LOGOUT_ERROR_QUERY_PARAM]; |
| 24 | 27 |
const showLogoutError = typeof logoutError === 'string' && logoutError.length > 0; |
| 28 |
+ const sessionRevoked = resolvedSearchParams[SESSION_REVOKED_QUERY_PARAM]; |
|
| 29 |
+ const showSessionRevoked = |
|
| 30 |
+ typeof sessionRevoked === 'string' && sessionRevoked.length > 0; |
|
| 25 | 31 |
|
| 26 | 32 |
return ( |
| 27 | 33 |
<div className={styles.page}>
|
| 28 |
- <LogoutNotice showError={showLogoutError} />
|
|
| 34 |
+ <LogoutNotice |
|
| 35 |
+ showError={showLogoutError}
|
|
| 36 |
+ showSessionRevoked={showSessionRevoked}
|
|
| 37 |
+ /> |
|
| 29 | 38 |
<div className={styles.card}>
|
| 30 | 39 |
<h1 className={styles.title}>관리자 로그인</h1>
|
| 31 | 40 |
<LoginForm /> |
+++ app/logout/revoked/route.ts
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +import { redirect } from 'next/navigation'; | |
| 2 | +import { deleteSession } from '@/lib/auth/session'; | |
| 3 | +import { SESSION_REVOKED_QUERY_PARAM } from '@/lib/constants/logout'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 다른 곳에서 로그인되어 세션이 밀렸을 때 도착하는 곳 — 쿠키를 지우고 `/login`으로 보낸다. | |
| 7 | + * 렌더 중에는 쿠키를 지울 수 없어 라우트 핸들러로 분리했다(lib/auth/session-revoked.ts 참고). | |
| 8 | + */ | |
| 9 | +export async function GET(): Promise<never> { | |
| 10 | + await deleteSession(); | |
| 11 | + redirect(`/login?${SESSION_REVOKED_QUERY_PARAM}=1`); | |
| 12 | +} |
--- lib/auth/dal.ts
+++ lib/auth/dal.ts
... | ... | @@ -14,15 +14,14 @@ |
| 14 | 14 |
* `verifySession()`(또는 `getSessionAdmin()`)을 거쳐야 한다 — Server Action은 UI를 거치지 않고 |
| 15 | 15 |
* 직접 호출될 수 있으므로 이 검증이 유일한 최종 방어선이다. |
| 16 | 16 |
* |
| 17 |
- * 백엔드에 관리자 프로필 조회 API가 없어(2026-08 기준) 세션 payload에 이미 실려 있는 클레임 |
|
| 18 |
- * (adminId/loginId/admRoleCd)만으로 AdminUser를 구성한다 — Repository 왕복이 없다. |
|
| 17 |
+ * 백엔드에 관리자 프로필 조회 API가 없어(2026-08 기준) 세션 payload에 이미 실려 있는 값 |
|
| 18 |
+ * (userId/loginId/userNm/roleId)만으로 AdminUser를 구성한다 — Repository 왕복이 없다. |
|
| 19 | 19 |
*/ |
| 20 | 20 |
|
| 21 | 21 |
/** 백엔드 accessToken(JWT) payload에서 신선도 검사에 필요한 클레임만 최소로 본다. */ |
| 22 | 22 |
type AccessTokenFreshnessClaims = {
|
| 23 | 23 |
exp: unknown; |
| 24 |
- adminId: unknown; |
|
| 25 |
- loginId: unknown; |
|
| 24 |
+ userId: unknown; |
|
| 26 | 25 |
}; |
| 27 | 26 |
|
| 28 | 27 |
/** |
... | ... | @@ -39,9 +38,9 @@ |
| 39 | 38 |
* (createSession이 백엔드 accessToken의 exp를 그대로 세션 exp로 쓴다) 그 사실이 이 검사의 |
| 40 | 39 |
* 존재 이유를 없애지 않는다: 세션 HMAC의 exp 검사는 "우리 세션 자체가 안 끊겼는지"만 |
| 41 | 40 |
* 보장하고, 이 검사는 "그 세션이 담고 있는 백엔드 토큰이 여전히 유효한지"를 재확인한다. |
| 42 |
- * 3. 유저 일치 — 토큰 클레임의 adminId·loginId가 세션 payload에 저장된 값(로그인 시점에 같은 |
|
| 43 |
- * 토큰에서 읽어 저장해 둔 값, lib/data/repositories/auth-repository.ts 참고)과 같은가. |
|
| 44 |
- * 다르면 토큰 바꿔치기·세션 오염 신호이므로 무효로 취급한다. |
|
| 41 |
+ * 3. 유저 일치 — 토큰 클레임의 userId가 세션 payload에 저장된 값(로그인 시점에 같은 토큰에서 |
|
| 42 |
+ * 읽어 저장해 둔 값, lib/data/repositories/auth-repository.ts 참고)과 같은가. 다르면 토큰 |
|
| 43 |
+ * 바꿔치기·세션 오염 신호이므로 무효로 취급한다. loginId는 토큰에 없어 비교 대상이 아니다. |
|
| 45 | 44 |
* |
| 46 | 45 |
* clock skew 허용치는 두지 않는다 — verifySessionToken()의 기존 exp 검사도 허용치 없이 |
| 47 | 46 |
* `exp <= now`로 엄격하게 판정하고(session-token.ts), 이 서버가 백엔드와 시계를 맞출 별도 |
... | ... | @@ -62,7 +61,7 @@ |
| 62 | 61 |
return false; |
| 63 | 62 |
} |
| 64 | 63 |
|
| 65 |
- return claims.adminId === session.adminId && claims.loginId === session.loginId; |
|
| 64 |
+ return claims.userId === session.userId; |
|
| 66 | 65 |
} |
| 67 | 66 |
|
| 68 | 67 |
/** |
... | ... | @@ -95,11 +94,10 @@ |
| 95 | 94 |
} |
| 96 | 95 |
|
| 97 | 96 |
return {
|
| 98 |
- id: session.adminId, |
|
| 99 |
- // 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항). |
|
| 100 |
- name: session.loginId, |
|
| 97 |
+ id: session.userId, |
|
| 98 |
+ name: session.userNm || session.loginId, |
|
| 101 | 99 |
loginId: session.loginId, |
| 102 |
- roleCode: session.admRoleCd, |
|
| 100 |
+ roleCode: session.roleId, |
|
| 103 | 101 |
}; |
| 104 | 102 |
}); |
| 105 | 103 |
|
+++ lib/auth/session-revoked.ts
... | ... | @@ -0,0 +1,19 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { redirect } from 'next/navigation'; | |
| 3 | +import { SESSION_REVOKED_PATH } from '@/lib/constants/logout'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 다른 곳에서 로그인되면 백엔드가 저장한 refreshToken이 바뀌고, 그 뒤로 이 세션의 accessToken은 | |
| 7 | + * 403 + XPIRED_TOKEN(905)으로 거절된다(edupay-backend JwtAuthenticationFilter). 904는 같은 뜻의 | |
| 8 | + * 예비 코드다. | |
| 9 | + * | |
| 10 | + * 쿠키 삭제는 렌더 중에 할 수 없어(Next.js 제약) 라우트 핸들러로 넘긴다 — 거기서 세션을 지우고 | |
| 11 | + * `/login`으로 안내와 함께 되돌린다. | |
| 12 | + */ | |
| 13 | +const SESSION_REVOKED_CODES: ReadonlySet<number> = new Set([904, 905]); | |
| 14 | + | |
| 15 | +export function redirectIfSessionRevoked(code: number): void { | |
| 16 | + if (SESSION_REVOKED_CODES.has(code)) { | |
| 17 | + redirect(SESSION_REVOKED_PATH); | |
| 18 | + } | |
| 19 | +} |
--- lib/auth/session-token.ts
+++ lib/auth/session-token.ts
... | ... | @@ -12,6 +12,9 @@ |
| 12 | 12 |
* 시크릿이 없다) 공격자가 `exp`를 조작한 위조 토큰으로 라우트 가드를 통과시킬 수 있다. 이 |
| 13 | 13 |
* HMAC 서명이 그 위조를 막는 경계다. |
| 14 | 14 |
* |
| 15 |
+ * loginId는 accessToken 클레임에 없어(백엔드가 싣지 않는다) 로그인 입력값을 그대로 담는다 — |
|
| 16 |
+ * 이 서명 안에 들어가므로 이후 위조는 불가능하다. |
|
| 17 |
+ * |
|
| 15 | 18 |
* 세션 만료(exp)는 고정 TTL 상수가 아니라 호출부(로그인 Server Action)가 백엔드 accessToken의 |
| 16 | 19 |
* `exp` 클레임을 그대로 넘겨받아 정렬시킨다(`CreateSessionTokenInput.exp`) — 세션은 결국 |
| 17 | 20 |
* accessToken을 담는 그릇이므로 수명이 어긋나면 두 가지 문제가 생긴다: (1) 세션이 더 오래 |
... | ... | @@ -22,9 +25,10 @@ |
| 22 | 25 |
*/ |
| 23 | 26 |
|
| 24 | 27 |
export type SessionTokenPayload = {
|
| 25 |
- adminId: string; |
|
| 28 |
+ userId: string; |
|
| 26 | 29 |
loginId: string; |
| 27 |
- admRoleCd: string; |
|
| 30 |
+ userNm: string; |
|
| 31 |
+ roleId: string; |
|
| 28 | 32 |
accessToken: string; |
| 29 | 33 |
refreshToken: string; |
| 30 | 34 |
iat: number; |
... | ... | @@ -32,9 +36,10 @@ |
| 32 | 36 |
}; |
| 33 | 37 |
|
| 34 | 38 |
export type CreateSessionTokenInput = {
|
| 35 |
- adminId: string; |
|
| 39 |
+ userId: string; |
|
| 36 | 40 |
loginId: string; |
| 37 |
- admRoleCd: string; |
|
| 41 |
+ userNm: string; |
|
| 42 |
+ roleId: string; |
|
| 38 | 43 |
accessToken: string; |
| 39 | 44 |
refreshToken: string; |
| 40 | 45 |
/** 백엔드 accessToken의 `exp` 클레임(unix seconds)을 그대로 전달한다. */ |
... | ... | @@ -58,9 +63,10 @@ |
| 58 | 63 |
export function createSessionToken(input: CreateSessionTokenInput): string {
|
| 59 | 64 |
const issuedAt = Math.floor(Date.now() / 1000); |
| 60 | 65 |
const payload: SessionTokenPayload = {
|
| 61 |
- adminId: input.adminId, |
|
| 66 |
+ userId: input.userId, |
|
| 62 | 67 |
loginId: input.loginId, |
| 63 |
- admRoleCd: input.admRoleCd, |
|
| 68 |
+ userNm: input.userNm, |
|
| 69 |
+ roleId: input.roleId, |
|
| 64 | 70 |
accessToken: input.accessToken, |
| 65 | 71 |
refreshToken: input.refreshToken, |
| 66 | 72 |
iat: issuedAt, |
... | ... | @@ -96,13 +102,14 @@ |
| 96 | 102 |
return null; |
| 97 | 103 |
} |
| 98 | 104 |
|
| 105 |
+ // roleId·userNm은 빈 문자열일 수 있다 — 백엔드 TB_ADM_USER에 값이 없으면 클레임도 비어 온다. |
|
| 99 | 106 |
if ( |
| 100 |
- typeof payload.adminId !== 'string' || |
|
| 101 |
- !payload.adminId || |
|
| 107 |
+ typeof payload.userId !== 'string' || |
|
| 108 |
+ !payload.userId || |
|
| 102 | 109 |
typeof payload.loginId !== 'string' || |
| 103 | 110 |
!payload.loginId || |
| 104 |
- typeof payload.admRoleCd !== 'string' || |
|
| 105 |
- !payload.admRoleCd || |
|
| 111 |
+ typeof payload.userNm !== 'string' || |
|
| 112 |
+ typeof payload.roleId !== 'string' || |
|
| 106 | 113 |
typeof payload.accessToken !== 'string' || |
| 107 | 114 |
!payload.accessToken || |
| 108 | 115 |
typeof payload.refreshToken !== 'string' || |
+++ lib/constants/login.ts
... | ... | @@ -0,0 +1,7 @@ |
| 1 | +/** | |
| 2 | + * 중복 로그인 안내에서 [확인]을 눌러 재요청할 때 폼에 싣는 필드 이름. | |
| 3 | + * | |
| 4 | + * `'use server'` 파일은 async 함수만 export할 수 있어 로그인 액션 쪽에 둘 수 없다 — | |
| 5 | + * 액션과 폼이 같은 키를 참조해야 하므로 여기 한 곳에 둔다(logout.ts와 같은 목적). | |
| 6 | + */ | |
| 7 | +export const FORCE_LOGIN_FIELD = 'forceLogin'; |
--- lib/constants/logout.ts
+++ lib/constants/logout.ts
... | ... | @@ -11,3 +11,12 @@ |
| 11 | 11 |
* 하는 설계, app/(protected)/_actions.ts 참고). |
| 12 | 12 |
*/ |
| 13 | 13 |
export const LOGOUT_ERROR_QUERY_PARAM = 'logoutError'; |
| 14 |
+ |
|
| 15 |
+/** |
|
| 16 |
+ * 다른 곳에서 로그인되어 이 세션이 밀렸을 때 `/login` 화면에 알릴 쿼리 파라미터 이름. |
|
| 17 |
+ * LOGOUT_ERROR_QUERY_PARAM과 같은 이유로 상수로 둔다. |
|
| 18 |
+ */ |
|
| 19 |
+export const SESSION_REVOKED_QUERY_PARAM = 'sessionRevoked'; |
|
| 20 |
+ |
|
| 21 |
+/** 세션이 밀린 것을 감지했을 때 보내는 경로 — 쿠키를 지우고 `/login`으로 넘긴다. */ |
|
| 22 |
+export const SESSION_REVOKED_PATH = '/logout/revoked'; |
--- lib/data/repositories/admin-member-repository.ts
+++ lib/data/repositories/admin-member-repository.ts
... | ... | @@ -51,12 +51,12 @@ |
| 51 | 51 |
const loginFailCnt = raw.loginFailCnt; |
| 52 | 52 |
|
| 53 | 53 |
return {
|
| 54 |
- id: readRequiredString(raw, 'admUserId'), |
|
| 55 |
- name: readRequiredString(raw, 'admNm'), |
|
| 54 |
+ id: readRequiredString(raw, 'userId'), |
|
| 55 |
+ name: readRequiredString(raw, 'userNm'), |
|
| 56 | 56 |
loginId: readRequiredString(raw, 'loginId'), |
| 57 |
- phoneNumber: readOptionalString(raw, 'admTelNo'), |
|
| 58 |
- email: readOptionalString(raw, 'admEmlAddr'), |
|
| 59 |
- roleCode: readOptionalString(raw, 'admRoleCd') ?? '', |
|
| 57 |
+ phoneNumber: readOptionalString(raw, 'userTelNo'), |
|
| 58 |
+ email: readOptionalString(raw, 'userEmlAddr'), |
|
| 59 |
+ roleCode: readOptionalString(raw, 'roleId') ?? '', |
|
| 60 | 60 |
createdAt: readOptionalString(raw, 'frstRegDtStr'), |
| 61 | 61 |
isLocked: parseYesNo(raw.acctLockYn), |
| 62 | 62 |
isActive: parseYesNo(raw.useYn), |
... | ... | @@ -208,12 +208,12 @@ |
| 208 | 208 |
method: 'POST', |
| 209 | 209 |
// 이슈: 등록은 form(@ParameterObject), 수정은 JSON(@RequestBody)로 갈려 있다. |
| 210 | 210 |
form: {
|
| 211 |
- admNm: input.name, |
|
| 211 |
+ userNm: input.name, |
|
| 212 | 212 |
loginId: input.loginId, |
| 213 | 213 |
loginPw: input.password, |
| 214 |
- admEmlAddr: input.email, |
|
| 215 |
- admTelNo: input.phoneNumber, |
|
| 216 |
- admRoleCd: input.roleCode, |
|
| 214 |
+ userEmlAddr: input.email, |
|
| 215 |
+ userTelNo: input.phoneNumber, |
|
| 216 |
+ roleId: input.roleCode, |
|
| 217 | 217 |
}, |
| 218 | 218 |
accessToken: accessToken ?? undefined, |
| 219 | 219 |
canHaveNullData: true, |
... | ... | @@ -236,9 +236,9 @@ |
| 236 | 236 |
method: 'PUT', |
| 237 | 237 |
body: {
|
| 238 | 238 |
loginPw: input.password, |
| 239 |
- admEmlAddr: input.email, |
|
| 240 |
- admTelNo: input.phoneNumber, |
|
| 241 |
- admRoleCd: input.roleCode, |
|
| 239 |
+ userEmlAddr: input.email, |
|
| 240 |
+ userTelNo: input.phoneNumber, |
|
| 241 |
+ roleId: input.roleCode, |
|
| 242 | 242 |
acctLockYn: toYesNo(input.isLocked), |
| 243 | 243 |
}, |
| 244 | 244 |
accessToken: accessToken ?? undefined, |
--- lib/data/repositories/auth-repository.ts
+++ lib/data/repositories/auth-repository.ts
... | ... | @@ -4,125 +4,152 @@ |
| 4 | 4 |
import type { AdminUser } from '@/lib/domain/admin-user';
|
| 5 | 5 |
|
| 6 | 6 |
/** |
| 7 |
- * 관리자 인증 Repository — 백엔드(edupay-backend) 실연동. |
|
| 7 |
+ * 관리자 인증 Repository — `POST /api/v1/mngr/auth/login`, `POST /api/v1/mngr/auth/logout`. |
|
| 8 | 8 |
* |
| 9 |
- * 엔드포인트: `POST /api/v1/common/auth/admin/login` (baseURL: lib/env.ts의 getApiBaseUrl()). |
|
| 10 |
- * 요청 필드명은 `loginPw`다(`password`가 아님 — 백엔드 계약 그대로). |
|
| 9 |
+ * 이슈: 로그인은 성공(200)이면서도 토큰 없이 최근 접속 정보만 돌아올 수 있다 — 다른 곳에 |
|
| 10 |
+ * 살아 있는 세션이 있는데 아직 [확인]을 누르지 않은 경우다. 이때 화면이 중복 로그인 안내를 |
|
| 11 |
+ * 띄우고 `forceLogin`으로 재요청해야 실제 토큰이 발급된다(백엔드 MngrAuthServiceImpl). |
|
| 11 | 12 |
* |
| 12 |
- * 백엔드에는 관리자 프로필 조회 API가 없다(2026-08 기준) — 그래서 로그인 응답으로 받은 |
|
| 13 |
- * accessToken(JWT)의 payload를 디코딩해 AdminUser를 구성한다. 서명 검증은 하지 않는다 |
|
| 14 |
- * (백엔드 시크릿이 없어 불가능 — lib/auth/decode-jwt-payload.ts 참고, 위조 방지는 이후 |
|
| 15 |
- * 우리 세션 HMAC 서명이 담당한다). 관리자 "이름" 클레임이 토큰에 없어 loginId로 대체한다 |
|
| 16 |
- * (사용자 확정 사항). |
|
| 13 |
+ * 이슈: accessToken 클레임에 `loginId`가 없다(EgovJwtTokenUtil#generateAccessToken은 userId· |
|
| 14 |
+ * userNm·userType·roleId·tokenVersion만 싣는다). 그래서 loginId는 입력값을 그대로 세션에 |
|
| 15 |
+ * 보관한다 — 백엔드가 그 아이디로 계정을 찾아 인증에 성공한 값이라 신뢰할 수 있다. |
|
| 17 | 16 |
*/ |
| 17 |
+ |
|
| 18 |
+const LOGIN_PATH = '/api/v1/mngr/auth/login'; |
|
| 19 |
+const LOGOUT_PATH = '/api/v1/mngr/auth/logout'; |
|
| 20 |
+ |
|
| 21 |
+/** 백엔드 ResponseCode.NOT_FOUND — "로그인 계정이 없습니다". */ |
|
| 22 |
+const ACCOUNT_NOT_FOUND_CODE = 404; |
|
| 23 |
+ |
|
| 24 |
+const GENERIC_LOGIN_ERROR = '아이디 또는 비밀번호가 올바르지 않습니다.'; |
|
| 18 | 25 |
|
| 19 | 26 |
export type AdminCredentials = {
|
| 20 | 27 |
loginId: string; |
| 21 | 28 |
password: string; |
| 29 |
+ /** 중복 로그인 안내에서 [확인]을 누른 재시도인가. */ |
|
| 30 |
+ forceLogin?: boolean; |
|
| 22 | 31 |
}; |
| 23 | 32 |
|
| 24 |
-export type AdminLoginResult = {
|
|
| 33 |
+export type AdminLoginSuccess = {
|
|
| 34 |
+ status: 'success'; |
|
| 25 | 35 |
admin: AdminUser; |
| 26 | 36 |
accessToken: string; |
| 27 | 37 |
refreshToken: string; |
| 28 |
- /** 백엔드 accessToken의 `exp` 클레임(unix seconds) — 세션 만료를 이 값에 정렬시키는 데 쓴다. */ |
|
| 38 |
+ /** 백엔드 accessToken의 `exp` 클레임(unix seconds) — 세션 만료를 이 값에 정렬시킨다. */ |
|
| 29 | 39 |
accessTokenExpiresAt: number; |
| 30 | 40 |
}; |
| 31 | 41 |
|
| 32 |
-const ADMIN_LOGOUT_PATH = '/api/v1/common/auth/logout'; |
|
| 42 |
+/** 다른 곳에 살아 있는 세션이 있을 때 화면이 보여줄 최근 접속 정보. */ |
|
| 43 |
+export type DuplicateLoginNotice = {
|
|
| 44 |
+ lastLoginAt: string | null; |
|
| 45 |
+ lastLoginIp: string | null; |
|
| 46 |
+ currentIp: string | null; |
|
| 47 |
+}; |
|
| 33 | 48 |
|
| 34 |
-/** 백엔드 accessToken(JWT)의 payload 클레임. */ |
|
| 49 |
+export type AdminLoginOutcome = |
|
| 50 |
+ | AdminLoginSuccess |
|
| 51 |
+ | { status: 'duplicate'; notice: DuplicateLoginNotice }
|
|
| 52 |
+ | { status: 'failure'; message: string };
|
|
| 53 |
+ |
|
| 35 | 54 |
type AdminAccessTokenClaims = {
|
| 36 |
- sub: string; |
|
| 37 |
- loginId: string; |
|
| 38 |
- admRoleCd: string; |
|
| 39 |
- adminId: string; |
|
| 40 |
- userType: string; |
|
| 41 | 55 |
userId: string; |
| 42 |
- iat: number; |
|
| 56 |
+ userNm: string; |
|
| 57 |
+ userType: string; |
|
| 58 |
+ roleId: string; |
|
| 43 | 59 |
exp: number; |
| 44 | 60 |
}; |
| 45 | 61 |
|
| 46 | 62 |
type AdminLoginResponse = {
|
| 47 |
- accessToken: string; |
|
| 48 |
- refreshToken: string; |
|
| 63 |
+ accessToken?: string | null; |
|
| 64 |
+ refreshToken?: string | null; |
|
| 65 |
+ lastLgnDt?: string | null; |
|
| 66 |
+ lastLgnIpAddr?: string | null; |
|
| 67 |
+ currentIpAddr?: string | null; |
|
| 49 | 68 |
}; |
| 50 | 69 |
|
| 51 |
-/** |
|
| 52 |
- * 관리자 로그인을 시도한다. 자격 불일치·존재하지 않는 계정 등 인증 실패는 예외가 아니라 |
|
| 53 |
- * 정상 흐름이므로 null을 반환한다(네트워크·파싱 등 진짜 예외 상황은 lib/http/backend-fetch.ts가 |
|
| 54 |
- * 흡수해 동일하게 `ok:false`로 내려주므로 이 함수 입장에서는 실패 사유를 구분하지 않는다 — |
|
| 55 |
- * 호출부가 항상 일반화된 오류 메시지로 응답하기 때문에 구분할 필요가 없다). |
|
| 56 |
- * |
|
| 57 |
- * 캐시 전략: `no-store` — 인증 요청은 재사용 캐시 대상이 아니다(매 시도가 백엔드에 도달해야 함). |
|
| 58 |
- */ |
|
| 70 |
+function readNullableString(value: unknown): string | null {
|
|
| 71 |
+ return typeof value === 'string' && value.length > 0 ? value : null; |
|
| 72 |
+} |
|
| 73 |
+ |
|
| 59 | 74 |
export async function requestAdminLogin( |
| 60 | 75 |
credentials: AdminCredentials |
| 61 |
-): Promise<AdminLoginResult | null> {
|
|
| 62 |
- const result = await backendFetch<AdminLoginResponse>( |
|
| 63 |
- '/api/v1/common/auth/admin/login', |
|
| 64 |
- {
|
|
| 65 |
- method: 'POST', |
|
| 66 |
- body: { loginId: credentials.loginId, loginPw: credentials.password },
|
|
| 67 |
- cache: 'no-store', |
|
| 68 |
- } |
|
| 69 |
- ); |
|
| 76 |
+): Promise<AdminLoginOutcome> {
|
|
| 77 |
+ const result = await backendFetch<AdminLoginResponse>(LOGIN_PATH, {
|
|
| 78 |
+ method: 'POST', |
|
| 79 |
+ body: {
|
|
| 80 |
+ loginId: credentials.loginId, |
|
| 81 |
+ loginPw: credentials.password, |
|
| 82 |
+ isForceLogin: credentials.forceLogin === true, |
|
| 83 |
+ }, |
|
| 84 |
+ cache: 'no-store', |
|
| 85 |
+ }); |
|
| 70 | 86 |
|
| 71 | 87 |
if (!result.ok) {
|
| 72 |
- return null; |
|
| 88 |
+ // 계정 존재 여부는 드러내지 않는다. 잠김·탈퇴·실패횟수 안내는 백엔드 문구를 그대로 쓴다. |
|
| 89 |
+ return {
|
|
| 90 |
+ status: 'failure', |
|
| 91 |
+ message: |
|
| 92 |
+ result.code === ACCOUNT_NOT_FOUND_CODE || !result.message |
|
| 93 |
+ ? GENERIC_LOGIN_ERROR |
|
| 94 |
+ : result.message, |
|
| 95 |
+ }; |
|
| 73 | 96 |
} |
| 74 | 97 |
|
| 75 |
- const claims = decodeJwtPayload<AdminAccessTokenClaims>(result.data.accessToken); |
|
| 98 |
+ const accessToken = readNullableString(result.data.accessToken); |
|
| 99 |
+ const refreshToken = readNullableString(result.data.refreshToken); |
|
| 100 |
+ |
|
| 101 |
+ if (!accessToken || !refreshToken) {
|
|
| 102 |
+ return {
|
|
| 103 |
+ status: 'duplicate', |
|
| 104 |
+ notice: {
|
|
| 105 |
+ lastLoginAt: readNullableString(result.data.lastLgnDt), |
|
| 106 |
+ lastLoginIp: readNullableString(result.data.lastLgnIpAddr), |
|
| 107 |
+ currentIp: readNullableString(result.data.currentIpAddr), |
|
| 108 |
+ }, |
|
| 109 |
+ }; |
|
| 110 |
+ } |
|
| 111 |
+ |
|
| 112 |
+ const claims = decodeJwtPayload<AdminAccessTokenClaims>(accessToken); |
|
| 76 | 113 |
if ( |
| 77 | 114 |
!claims || |
| 78 |
- typeof claims.adminId !== 'string' || |
|
| 79 |
- !claims.adminId || |
|
| 80 |
- typeof claims.loginId !== 'string' || |
|
| 81 |
- !claims.loginId || |
|
| 82 |
- typeof claims.admRoleCd !== 'string' || |
|
| 83 |
- !claims.admRoleCd || |
|
| 115 |
+ typeof claims.userId !== 'string' || |
|
| 116 |
+ !claims.userId || |
|
| 84 | 117 |
typeof claims.exp !== 'number' |
| 85 | 118 |
) {
|
| 86 |
- return null; |
|
| 119 |
+ return { status: 'failure', message: GENERIC_LOGIN_ERROR };
|
|
| 87 | 120 |
} |
| 88 | 121 |
|
| 89 | 122 |
const admin: AdminUser = {
|
| 90 |
- id: claims.adminId, |
|
| 91 |
- // 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항). |
|
| 92 |
- name: claims.loginId, |
|
| 93 |
- loginId: claims.loginId, |
|
| 94 |
- roleCode: claims.admRoleCd, |
|
| 123 |
+ id: claims.userId, |
|
| 124 |
+ name: |
|
| 125 |
+ typeof claims.userNm === 'string' && claims.userNm |
|
| 126 |
+ ? claims.userNm |
|
| 127 |
+ : credentials.loginId, |
|
| 128 |
+ loginId: credentials.loginId, |
|
| 129 |
+ roleCode: typeof claims.roleId === 'string' ? claims.roleId : '', |
|
| 95 | 130 |
}; |
| 96 | 131 |
|
| 97 | 132 |
return {
|
| 133 |
+ status: 'success', |
|
| 98 | 134 |
admin, |
| 99 |
- accessToken: result.data.accessToken, |
|
| 100 |
- refreshToken: result.data.refreshToken, |
|
| 135 |
+ accessToken, |
|
| 136 |
+ refreshToken, |
|
| 101 | 137 |
accessTokenExpiresAt: claims.exp, |
| 102 | 138 |
}; |
| 103 | 139 |
} |
| 104 | 140 |
|
| 105 | 141 |
/** |
| 106 |
- * 관리자 로그아웃을 백엔드에 알린다 — `GET /api/v1/common/auth/logout`(인증 필요, AUTH_WHITELIST에 |
|
| 107 |
- * 없음을 확인함: edupay-backend SecurityConfig.java). |
|
| 142 |
+ * 로그아웃 — 백엔드가 저장된 refreshToken을 NULL로 만든다. |
|
| 108 | 143 |
* |
| 109 |
- * JWT는 stateless라 이 호출이 accessToken 자체를 무효화하지는 않는다 — 발급된 토큰은 만료까지 |
|
| 110 |
- * 계속 유효하다. 백엔드가 실질적으로 하는 일은 FCM 토큰 삭제뿐이다(edupay-backend |
|
| 111 |
- * CmmAuthApiController#actionLogoutJSON: `deleteFcmToken` + `SecurityContextLogoutHandler.logout`, |
|
| 112 |
- * 후자는 STATELESS 세션 정책상 사실상 무의미). 그래도 정식 절차이므로 호출은 한다. |
|
| 144 |
+ * 이슈: 이 호출이 accessToken 자체를 무효화하지는 않는다. JwtAuthenticationFilter의 중복 로그인 |
|
| 145 |
+ * 판정이 `dbRefreshToken != null && !equals(clientTokenVersion)`이라, NULL이 된 뒤에는 기존 |
|
| 146 |
+ * accessToken이 만료(24h)까지 계속 통과한다(edupay-backend 61d8197 확인). 백엔드 보완 필요. |
|
| 113 | 147 |
* |
| 114 |
- * 성공 응답의 data는 항상 null이다(`ApiResponseVO.success(null)`) — `canHaveNullData: true`로 |
|
| 115 |
- * 이 정상 응답을 실패로 오판하지 않게 한다(lib/http/backend-fetch.ts 참고). |
|
| 116 |
- * |
|
| 117 |
- * 실패(백엔드 다운·accessToken 만료 등)를 예외로 다루지 않고 boolean으로 반환한다 — 로그아웃은 |
|
| 118 |
- * 백엔드 호출이 실패해도 반드시 로컬에서는 진행돼야 하므로, 호출부(Server Action)가 try/catch |
|
| 119 |
- * 없이 결과만 보고 흐름을 결정할 수 있게 한다. |
|
| 120 |
- * |
|
| 121 |
- * 캐시 전략: `no-store` — 상태를 변경하는 요청이라 캐시 대상이 아니다. |
|
| 148 |
+ * 실패해도 로컬 세션은 반드시 파기해야 하므로 예외 대신 boolean을 돌려준다. |
|
| 122 | 149 |
*/ |
| 123 | 150 |
export async function requestAdminLogout(accessToken: string): Promise<boolean> {
|
| 124 |
- const result = await backendFetch<null>(ADMIN_LOGOUT_PATH, {
|
|
| 125 |
- method: 'GET', |
|
| 151 |
+ const result = await backendFetch<null>(LOGOUT_PATH, {
|
|
| 152 |
+ method: 'POST', |
|
| 126 | 153 |
accessToken, |
| 127 | 154 |
cache: 'no-store', |
| 128 | 155 |
canHaveNullData: true, |
--- lib/domain/admin-member.ts
+++ lib/domain/admin-member.ts
... | ... | @@ -4,22 +4,16 @@ |
| 4 | 4 |
* 데이터 출처는 백엔드(edupay-backend)의 `GET /api/v1/mngr/admin/pagination`이며, |
| 5 | 5 |
* `lib/data/repositories/admin-member-repository.ts`가 응답을 이 타입으로 매핑한다. |
| 6 | 6 |
* |
| 7 |
- * **`null`의 의미는 "백엔드가 아직 주지 않는 항목"이다.** 목록 응답 VO(MngrAdminVo)가 담는 값은 |
|
| 8 |
- * 식별자·이름·역할·계정상태 계열뿐이라 시안(ADM_ADM_101)의 휴대전화번호·이메일·생성일은 전부 |
|
| 9 |
- * `null`로 채워지고 화면에서 `-`로 표시된다. 화면 컬럼은 그대로 유지한다 — 백엔드가 필드를 |
|
| 10 |
- * 추가하면 Repository의 매핑만 늘리면 값이 그대로 채워진다. |
|
| 11 |
- * |
|
| 12 |
- * 등록/수정/삭제 API가 아직 없어 그 경로는 mock으로 동작한다(`lib/data/mock/admin-member-store.ts`). |
|
| 13 |
- * mock이 만들어 낸 행만 휴대전화번호·이메일·메뉴 권한 값을 실제로 갖는다. |
|
| 7 |
+ * 값이 없는 항목은 `null`로 채워지고 화면에서 `-`로 표시된다. |
|
| 14 | 8 |
*/ |
| 15 | 9 |
|
| 16 |
-/** 역할 코드 — 백엔드 `admRoleCd`. 코드 체계를 백엔드가 확정하지 않아 문자열로 다룬다. */ |
|
| 10 |
+/** 역할 코드 — 백엔드 `roleId`. 코드 체계를 백엔드가 확정하지 않아 문자열로 다룬다. */ |
|
| 17 | 11 |
export type AdminRoleCode = string; |
| 18 | 12 |
|
| 19 | 13 |
export type AdminMember = {
|
| 20 |
- /** 내부 식별자 — 백엔드 `admUserId`. 목록 행의 key이자 수정/삭제의 입력값이다. */ |
|
| 14 |
+ /** 내부 식별자 — 백엔드 `userId`. 목록 행의 key이자 수정/삭제의 입력값이다. */ |
|
| 21 | 15 |
id: string; |
| 22 |
- /** 백엔드 `admNm`. */ |
|
| 16 |
+ /** 백엔드 `userNm`. */ |
|
| 23 | 17 |
name: string; |
| 24 | 18 |
/** 백엔드 `loginId`. 등록 후에는 변경할 수 없다(시안 ADM_ADM_103_p ①). */ |
| 25 | 19 |
loginId: string; |
... | ... | @@ -27,7 +21,7 @@ |
| 27 | 21 |
phoneNumber: string | null; |
| 28 | 22 |
/** 백엔드 목록 응답에 없다 — mock으로 등록한 행에만 값이 있다. */ |
| 29 | 23 |
email: string | null; |
| 30 |
- /** 백엔드 `admRoleCd`. 표기용 이름은 `formatAdminRoleLabel`이 만든다. */ |
|
| 24 |
+ /** 백엔드 `roleId`. 표기용 이름은 `formatAdminRoleLabel`이 만든다. */ |
|
| 31 | 25 |
roleCode: AdminRoleCode; |
| 32 | 26 |
/** ISO 형식(YYYY-MM-DD) 문자열. 백엔드 목록 응답에 없다(정렬에만 쓰이고 select되지 않는다). */ |
| 33 | 27 |
createdAt: string | null; |
... | ... | @@ -53,7 +47,7 @@ |
| 53 | 47 |
* **백엔드에 역할 코드 목록을 주는 API도, 코드 상수도 없다** — `TB_ADM_USER.ADM_ROLE_CD`를 |
| 54 | 48 |
* 그대로 내려줄 뿐이고 코드값의 정의는 어디에도 없다(백엔드 저장소 전체 검색으로 확인). |
| 55 | 49 |
* 시안(ADM_ADM_101)이 요구하는 표기는 "최고관리자/일반관리자" 둘이고, 실제로 관측된 코드는 |
| 56 |
- * 로그인 토큰의 `admRoleCd` 클레임에서 본 `ROLE_SYSTEM` 하나뿐이다(`lib/domain/admin-user.ts`). |
|
| 50 |
+ * 로그인 토큰의 `roleId` 클레임에서 본 `ROLE_SYSTEM` 하나뿐이다(`lib/domain/admin-user.ts`). |
|
| 57 | 51 |
* |
| 58 | 52 |
* 그래서 아래 표는 **잠정 매핑**이며, 표에 없는 코드는 임의의 이름을 지어내지 않고 코드를 그대로 |
| 59 | 53 |
* 노출한다 — 잘못된 역할 이름을 보여주는 것보다 낯선 코드를 보여주는 편이 낫다(권한 화면이라 |
--- lib/domain/admin-user.ts
+++ lib/domain/admin-user.ts
... | ... | @@ -1,17 +1,17 @@ |
| 1 | 1 |
/** |
| 2 | 2 |
* 관리자 도메인 타입 — 순수 데이터 표현, 외부 의존 없음. |
| 3 |
- * 화면에는 최소 DTO만 노출한다 (비밀번호·토큰 등 민감 필드는 여기 포함하지 않는다 — 토큰은 |
|
| 4 |
- * 세션 payload에만 보관하고 화면까지 내려주지 않는다). |
|
| 3 |
+ * 화면에는 최소 DTO만 노출한다 (비밀번호·토큰 등 민감 필드는 여기 포함하지 않는다). |
|
| 5 | 4 |
*/ |
| 6 | 5 |
export type AdminUser = {
|
| 6 |
+ /** 백엔드 accessToken의 `userId` 클레임. */ |
|
| 7 | 7 |
id: string; |
| 8 |
- /** |
|
| 9 |
- * 백엔드 accessToken(JWT)에 관리자 "이름" 클레임이 없어 loginId를 표시용 이름으로 그대로 |
|
| 10 |
- * 대체한다(사용자 확정 사항, 별도 이름 조회 API 없음). 이름 클레임/API가 추가되면 이 필드를 |
|
| 11 |
- * 실제 이름으로 교체한다. |
|
| 12 |
- */ |
|
| 8 |
+ /** 백엔드 accessToken의 `userNm` 클레임. 비어 있으면 loginId로 대체한다. */ |
|
| 13 | 9 |
name: string; |
| 10 |
+ /** |
|
| 11 |
+ * 로그인 시 입력한 아이디. accessToken 클레임에는 없어(EgovJwtTokenUtil#generateAccessToken) |
|
| 12 |
+ * 세션에 따로 보관한다. |
|
| 13 |
+ */ |
|
| 14 | 14 |
loginId: string; |
| 15 |
- /** 백엔드 accessToken의 admRoleCd 클레임 (예: "ROLE_SYSTEM"). */ |
|
| 15 |
+ /** 백엔드 accessToken의 `roleId` 클레임 (예: "ROLE_SYSTEM"). */ |
|
| 16 | 16 |
roleCode: string; |
| 17 | 17 |
}; |
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 | 2 |
import { getApiBaseUrl } from '@/lib/env';
|
| 3 |
+import { redirectIfSessionRevoked } from '@/lib/auth/session-revoked';
|
|
| 3 | 4 |
|
| 4 | 5 |
/** |
| 5 | 6 |
* 백엔드(edupay-backend) REST 호출 공용 클라이언트 — baseURL 결합, 타임아웃, JSON 헤더, |
... | ... | @@ -331,6 +332,7 @@ |
| 331 | 332 |
logResponse(call, response.status, raw); |
| 332 | 333 |
const envelope = parseEnvelope(raw); |
| 333 | 334 |
if (envelope && typeof envelope.code === 'number') {
|
| 335 |
+ redirectIfSessionRevoked(envelope.code); |
|
| 334 | 336 |
return {
|
| 335 | 337 |
ok: false, |
| 336 | 338 |
code: envelope.code, |
... | ... | @@ -444,6 +446,7 @@ |
| 444 | 446 |
// 호출부가 구분할 수 없으므로, 봉투에 code가 실려 있으면 그것을 살려서 내려준다. |
| 445 | 447 |
const envelope = parseEnvelope(raw); |
| 446 | 448 |
if (envelope && typeof envelope.code === 'number') {
|
| 449 |
+ redirectIfSessionRevoked(envelope.code); |
|
| 447 | 450 |
return {
|
| 448 | 451 |
ok: false, |
| 449 | 452 |
code: envelope.code, |
... | ... | @@ -544,6 +547,7 @@ |
| 544 | 547 |
|
| 545 | 548 |
if (!response.ok || !envelope || envelope.success !== true) {
|
| 546 | 549 |
if (envelope && typeof envelope.code === 'number') {
|
| 550 |
+ redirectIfSessionRevoked(envelope.code); |
|
| 547 | 551 |
return {
|
| 548 | 552 |
ok: false, |
| 549 | 553 |
code: envelope.code, |
--- proxy.ts
+++ proxy.ts
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
import { NextResponse } from 'next/server';
|
| 2 | 2 |
import type { NextRequest } from 'next/server';
|
| 3 | 3 |
import { SESSION_COOKIE_NAME } from '@/lib/auth/session-cookie';
|
| 4 |
+import { SESSION_REVOKED_PATH } from '@/lib/constants/logout';
|
|
| 4 | 5 |
|
| 5 | 6 |
/** |
| 6 | 7 |
* 낙관적 체크 전용 — 세션 쿠키의 "존재 여부"만 확인한다. 최종 판단(서명·만료 검증)은 |
... | ... | @@ -16,7 +17,7 @@ |
| 16 | 17 |
*/ |
| 17 | 18 |
// 이슈: `/dev-test/design`은 모든 환경에서 세션 없이 열린다(사용자 요청). 실데이터를 그리지 |
| 18 | 19 |
// 않고 @fox 컴포넌트만 보여주는 화면이지만, 운영에도 노출되는 경로다. |
| 19 |
-const PUBLIC_PATHS = ['/login', '/dev-test/design']; |
|
| 20 |
+const PUBLIC_PATHS = ['/login', '/dev-test/design', SESSION_REVOKED_PATH]; |
|
| 20 | 21 |
|
| 21 | 22 |
function isPublicPath(pathname: string): boolean {
|
| 22 | 23 |
return PUBLIC_PATHS.includes(pathname); |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?