File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'use server';
import { revalidatePath } from 'next/cache';
import { verifySession } from '@/lib/auth/dal';
import {
createAdminMember,
deleteAdminMember,
isAdminLoginIdTaken,
updateAdminMember,
} from '@/lib/data/repositories/admin-member-repository';
import { ADMIN_MENU_OPTIONS } from '@/lib/domain/admin-member';
import {
joinPhoneNumber,
validateAdminLoginId,
validateAdminMemberCreate,
validateAdminMemberUpdate,
type AdminMemberEditableValues,
type AdminMemberFormErrors,
} from '@/lib/domain/admin-member-form';
import { ADMIN_MEMBERS_PATH } from '@/lib/domain/admin-member-query';
/**
* 관리자 회원 등록/수정/삭제 Server Action.
*
* **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접
* POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §8).
*
* 검증은 화면이 아니라 여기서 확정한다(`lib/domain/admin-member-form.ts`의 규칙을 호출) —
* 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다.
*
* 실제 저장은 Repository에 맡긴다. 백엔드에 등록/수정/삭제 API가 아직 없어 Repository가 mock
* 저장소로 위임하고 있지만, **이 파일은 그 사실을 알지 못한다** — 백엔드 API가 생겨도 이 파일은
* 바뀌지 않는다.
*/
export type AdminMemberFormState =
| { status: 'idle' }
| { status: 'error'; message?: string; errors?: AdminMemberFormErrors }
| { status: 'success' };
export const INITIAL_ADMIN_MEMBER_FORM_STATE: AdminMemberFormState = {
status: 'idle',
};
const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.';
const DUPLICATE_LOGIN_ID_MESSAGE = '이미 사용 중인 ID입니다.';
function readString(formData: FormData, key: string): string {
const value = formData.get(key);
return typeof value === 'string' ? value : '';
}
/** 체크박스처럼 같은 이름으로 여러 번 오는 값 — 허용 목록에 있는 것만 남긴다. */
function readMenuCodes(formData: FormData): string[] {
return formData
.getAll('menuCodes')
.filter((value): value is string => typeof value === 'string')
.filter((value) =>
ADMIN_MENU_OPTIONS.some((option) => option.value === value)
);
}
/**
* 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 시안대로 3칸으로 나뉘어 오므로 여기서
* 하나의 값으로 합친다 — 형식이 어긋나면 빈 문자열이 되어 검증에서 걸린다.
*/
function readEditableValues(formData: FormData): AdminMemberEditableValues {
const phoneNumber = joinPhoneNumber([
readString(formData, 'phoneNumber1'),
readString(formData, 'phoneNumber2'),
readString(formData, 'phoneNumber3'),
]);
return {
password: readString(formData, 'password'),
phoneNumber: phoneNumber ?? '',
email: readString(formData, 'email'),
roleCode: readString(formData, 'roleCode'),
menuCodes: readMenuCodes(formData),
};
}
/** 시안 ADM_ADM_102_p — 관리자 등록. */
export async function createAdminMemberAction(
_prevState: AdminMemberFormState,
formData: FormData
): Promise<AdminMemberFormState> {
await verifySession();
const validation = validateAdminMemberCreate({
...readEditableValues(formData),
name: readString(formData, 'name'),
loginId: readString(formData, 'loginId'),
});
if (!validation.ok) {
return { status: 'error', errors: validation.errors };
}
const { name, loginId, phoneNumber, email, roleCode, menuCodes } =
validation.values;
// 화면의 [중복 확인]은 편의 기능일 뿐이라 저장 직전에 다시 확인한다 — 확인을 누르지 않고
// 직접 제출하는 경로가 열려 있고, 확인 후 저장까지의 사이에 선점될 수도 있다.
if (await isAdminLoginIdTaken(loginId)) {
return { status: 'error', errors: { loginId: DUPLICATE_LOGIN_ID_MESSAGE } };
}
// 비밀번호는 형식만 검증하고 값은 넘기지 않는다 — 저장할 백엔드 API가 아직 없고, mock
// 저장소에 평문 비밀번호를 보관하지 않기 때문이다(`admin-member-store.ts` 주석 참조).
await createAdminMember({
name,
loginId,
phoneNumber,
email,
roleCode,
menuCodes,
});
revalidatePath(ADMIN_MEMBERS_PATH);
return { status: 'success' };
}
/** 시안 ADM_ADM_103_p — 관리자 수정. 이름·ID는 읽기 전용이라 변경 대상이 아니다. */
export async function updateAdminMemberAction(
_prevState: AdminMemberFormState,
formData: FormData
): Promise<AdminMemberFormState> {
await verifySession();
const id = readString(formData, 'id');
if (!id) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
const validation = validateAdminMemberUpdate(readEditableValues(formData));
if (!validation.ok) {
return { status: 'error', errors: validation.errors };
}
const { phoneNumber, email, roleCode, menuCodes } = validation.values;
await updateAdminMember(id, {
phoneNumber,
email,
roleCode,
menuCodes,
// 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다.
isLocked: readString(formData, 'isLocked') === 'true',
});
revalidatePath(ADMIN_MEMBERS_PATH);
return { status: 'success' };
}
/**
* 시안 ADM_ADM_101 ⑤ — 삭제. 확인 얼럿은 화면이 띄우고, 여기서는 권한과 자기 계정만 확인한다.
*
* 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의
* (prevState, formData) 규약 대신 id를 직접 받는다.
*/
export async function deleteAdminMemberAction(
id: string
): Promise<AdminMemberFormState> {
const admin = await verifySession();
if (!id) {
return { status: 'error', message: INVALID_REQUEST_MESSAGE };
}
// 시안의 "최고관리자 본인 계정 삭제 방지". 세션의 adminId는 백엔드 accessToken의 `adminId`
// 클레임이고 그 값이 곧 목록의 `admUserId`라(EgovJwtTokenUtil.generateAccessToken) 그대로
// 비교할 수 있다. 역할과 무관하게 "로그인한 본인"을 막는다 — 자기 계정을 지워 스스로
// 로그인 불가 상태가 되는 것은 어떤 역할이든 사고이기 때문이다.
if (id === admin.id) {
return { status: 'error', message: SELF_DELETE_MESSAGE };
}
await deleteAdminMember(id);
revalidatePath(ADMIN_MEMBERS_PATH);
return { status: 'success' };
}
export type LoginIdCheckResult =
| { status: 'idle' }
| { status: 'available'; loginId: string }
| { status: 'unavailable'; message: string };
/**
* 시안 ADM_ADM_102_p ① — ID 중복 확인.
*
* 폼 제출이 아니라 버튼 클릭에 반응하는 단발 호출이라 `useActionState`의 (prevState, formData)
* 규약 대신 값을 직접 받는다 — 등록 폼 안에 또 다른 폼을 중첩할 수 없기 때문이다(HTML 제약).
* 클라이언트에서 일반 함수처럼 `await` 한다.
*
* 형식이 맞지 않는 ID는 중복 여부를 물을 필요도 없이 되돌린다. 확인에 성공하면 **검사한 ID를 함께**
* 돌려주는데, 화면이 "확인한 ID"와 "지금 입력창의 ID"를 비교해 확인 후 값을 고친 경우를 잡아내기
* 위해서다.
*/
export async function checkAdminLoginId(
loginId: string
): Promise<LoginIdCheckResult> {
await verifySession();
const normalized = loginId.trim();
const formatError = validateAdminLoginId(normalized);
if (formatError) {
return { status: 'unavailable', message: formatError };
}
if (await isAdminLoginIdTaken(normalized)) {
return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
}
return { status: 'available', loginId: normalized };
}