diff --git a/apps/admin/src/routes/_GNBLayout/admin-user/index.tsx b/apps/admin/src/routes/_GNBLayout/admin-user/index.tsx
new file mode 100644
index 000000000..7dd27d277
--- /dev/null
+++ b/apps/admin/src/routes/_GNBLayout/admin-user/index.tsx
@@ -0,0 +1,728 @@
+import { useEffect, useState } from 'react';
+import { createFileRoute } from '@tanstack/react-router';
+import { useForm } from 'react-hook-form';
+import { useQueryClient } from '@tanstack/react-query';
+import { Header, Input, Modal, OneButtonModalTemplate } from '@components';
+import { $api, deleteUser, getUserById, getUserList, postUser, putUser } from '@apis';
+import { useModal } from '@hooks';
+import { components } from '@schema';
+import {
+ AlertCircle,
+ Eye,
+ EyeOff,
+ Mail,
+ Pencil,
+ Plus,
+ ShieldCheck,
+ Trash2,
+ UserCircle,
+} from 'lucide-react';
+
+export const Route = createFileRoute('/_GNBLayout/admin-user/')({
+ component: RouteComponent,
+});
+
+type AdminResp = components['schemas']['AdminResp'];
+
+interface UserFormValues {
+ name: string;
+ email: string;
+ password: string;
+}
+
+interface ApiErrorShape {
+ message?: string;
+ code?: string;
+ error?: {
+ message?: string;
+ code?: string;
+ };
+ data?: {
+ message?: string;
+ code?: string;
+ };
+}
+
+const getErrorMeta = (error: unknown) => {
+ const fallback = {
+ code: '',
+ message: '요청 처리 중 오류가 발생했습니다.',
+ };
+
+ if (!error || typeof error !== 'object') {
+ return fallback;
+ }
+
+ const typedError = error as ApiErrorShape;
+
+ return {
+ code: typedError.code ?? typedError.error?.code ?? typedError.data?.code ?? '',
+ message:
+ typedError.message ??
+ typedError.error?.message ??
+ typedError.data?.message ??
+ fallback.message,
+ };
+};
+
+const getCreateOrUpdateErrorMessage = (error: unknown) => {
+ const { code, message } = getErrorMeta(error);
+
+ if (code === 'DUPLICATED_EMAIL') {
+ return '이미 사용 중인 이메일입니다. 다른 이메일을 입력해주세요.';
+ }
+
+ return message;
+};
+
+const getDeleteErrorMessage = (error: unknown) => {
+ const { code, message } = getErrorMeta(error);
+
+ if (code === 'ADMIN_002') {
+ return '본인 계정은 삭제할 수 없습니다.';
+ }
+
+ return message;
+};
+
+const getAdminTypeLabel = (adminType: AdminResp['adminType']) => {
+ if (adminType === 'SUPER') {
+ return '슈퍼 관리자';
+ }
+
+ return '역할 기반';
+};
+
+const UserFormModal = ({
+ mode,
+ userId,
+ initialUser,
+ isLoading,
+ loadErrorMessage,
+ isOpen,
+ onClose,
+ onSuccess,
+}: {
+ mode: 'create' | 'edit';
+ userId: number | null;
+ initialUser: AdminResp | null;
+ isLoading: boolean;
+ loadErrorMessage: string;
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: (message: string) => void;
+}) => {
+ const [submitError, setSubmitError] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const isEditMode = mode === 'edit' && userId !== null;
+ const { mutate: createUser, isPending: isCreatePending } = postUser();
+ const { mutate: updateUser, isPending: isUpdatePending } = putUser();
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors },
+ } = useForm
({
+ defaultValues: {
+ name: '',
+ email: '',
+ password: '',
+ },
+ });
+
+ useEffect(() => {
+ if (!isOpen) {
+ reset({
+ name: '',
+ email: '',
+ password: '',
+ });
+ setSubmitError('');
+ setShowPassword(false);
+ return;
+ }
+
+ if (isEditMode && initialUser) {
+ reset({
+ name: initialUser.name,
+ email: initialUser.email,
+ password: '',
+ });
+ setSubmitError('');
+ return;
+ }
+
+ if (mode === 'create') {
+ reset({
+ name: '',
+ email: '',
+ password: '',
+ });
+ setSubmitError('');
+ }
+ }, [initialUser, isEditMode, isOpen, mode, reset]);
+
+ const isPending = isCreatePending || isUpdatePending;
+
+ const onSubmit = handleSubmit((values) => {
+ setSubmitError('');
+
+ if (mode === 'create') {
+ createUser(
+ {
+ body: {
+ name: values.name,
+ email: values.email,
+ password: values.password,
+ },
+ },
+ {
+ onSuccess: () => {
+ onSuccess('관리자 계정 등록이 완료되었습니다.');
+ onClose();
+ },
+ onError: (error) => {
+ setSubmitError(getCreateOrUpdateErrorMessage(error));
+ },
+ }
+ );
+ return;
+ }
+
+ if (!userId) {
+ return;
+ }
+
+ const body: components['schemas']['AdminUpdateRequest'] = {
+ name: values.name.trim() || undefined,
+ email: values.email,
+ };
+
+ if (values.password.trim()) {
+ body.password = values.password;
+ }
+
+ updateUser(
+ {
+ params: {
+ path: {
+ id: userId,
+ },
+ },
+ body,
+ },
+ {
+ onSuccess: () => {
+ onSuccess('관리자 계정 수정이 완료되었습니다.');
+ onClose();
+ },
+ onError: (error) => {
+ setSubmitError(getCreateOrUpdateErrorMessage(error));
+ },
+ }
+ );
+ });
+
+ return (
+
+
+
+
+ {mode === 'create' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {mode === 'create' ? '관리자 등록' : '관리자 수정'}
+
+
+ {mode === 'create'
+ ? '이메일과 비밀번호를 입력해 관리자 계정을 생성합니다.'
+ : '관리자 계정 정보를 수정합니다.'}
+
+
+
+
+ {isEditMode && isLoading ? (
+
+ 관리자 정보를 불러오는 중입니다.
+
+ ) : loadErrorMessage ? (
+
+ {loadErrorMessage}
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+const AdminUserModal = ({
+ mode,
+ userId,
+ isOpen,
+ onClose,
+ onSuccess,
+}: {
+ mode: 'create' | 'edit';
+ userId: number | null;
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: (message: string) => void;
+}) => {
+ if (mode === 'edit' && userId !== null) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+const EditAdminUserModal = ({
+ userId,
+ isOpen,
+ onClose,
+ onSuccess,
+}: {
+ userId: number;
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: (message: string) => void;
+}) => {
+ const { data, isLoading, error } = getUserById(userId);
+
+ return (
+
+ );
+};
+
+const DeleteUserModal = ({
+ user,
+ isOpen,
+ onClose,
+ onConfirm,
+ isPending,
+ errorMessage,
+}: {
+ user: AdminResp | null;
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ isPending: boolean;
+ errorMessage: string;
+}) => {
+ return (
+
+
+
+
+
+
+
+
관리자 삭제
+
삭제 후에는 계정을 복구할 수 없습니다.
+
+
+
+
+
{user?.name}
+
{user?.email}
+
이 관리자 계정을 삭제하시겠습니까?
+
+
+ {errorMessage && (
+
+ )}
+
+
+
+
+
+
+
+ );
+};
+
+function RouteComponent() {
+ const queryClient = useQueryClient();
+ const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
+ const [selectedUserId, setSelectedUserId] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleteErrorMessage, setDeleteErrorMessage] = useState('');
+ const [successMessage, setSuccessMessage] = useState('');
+ const {
+ isOpen: isUserModalOpen,
+ openModal: openUserModal,
+ closeModal: closeUserModal,
+ } = useModal();
+ const {
+ isOpen: isDeleteModalOpen,
+ openModal: openDeleteModal,
+ closeModal: closeDeleteModal,
+ } = useModal();
+ const {
+ isOpen: isSuccessModalOpen,
+ openModal: openSuccessModal,
+ closeModal: closeSuccessModal,
+ } = useModal();
+ const { data: userListResponse, isLoading } = getUserList();
+ const { mutate: removeUser, isPending: isDeletePending } = deleteUser();
+
+ const userList = userListResponse ?? [];
+ const userListQueryKey = $api.queryOptions('get', '/api/admin/user').queryKey;
+
+ const refreshUserList = () => {
+ queryClient.invalidateQueries({
+ queryKey: userListQueryKey,
+ });
+ };
+
+ const handleSuccess = (message: string) => {
+ refreshUserList();
+ setSuccessMessage(message);
+ openSuccessModal();
+ };
+
+ const handleOpenCreateModal = () => {
+ setModalMode('create');
+ setSelectedUserId(null);
+ openUserModal();
+ };
+
+ const handleOpenEditModal = (userId: number) => {
+ setModalMode('edit');
+ setSelectedUserId(userId);
+ openUserModal();
+ };
+
+ const handleCloseUserModal = () => {
+ setSelectedUserId(null);
+ closeUserModal();
+ };
+
+ const handleOpenDeleteModal = (user: AdminResp) => {
+ setDeleteTarget(user);
+ setDeleteErrorMessage('');
+ openDeleteModal();
+ };
+
+ const handleCloseDeleteModal = () => {
+ setDeleteTarget(null);
+ setDeleteErrorMessage('');
+ closeDeleteModal();
+ };
+
+ const handleConfirmDelete = () => {
+ if (!deleteTarget) {
+ return;
+ }
+
+ setDeleteErrorMessage('');
+
+ removeUser(
+ {
+ params: {
+ path: {
+ id: deleteTarget.id,
+ },
+ },
+ },
+ {
+ onSuccess: () => {
+ queryClient.setQueryData(userListQueryKey, (oldData) => {
+ if (!oldData) {
+ return oldData;
+ }
+
+ return oldData.filter((user) => user.id !== deleteTarget.id);
+ });
+ handleCloseDeleteModal();
+ handleSuccess('관리자 계정 삭제가 완료되었습니다.');
+ },
+ onError: (error) => {
+ setDeleteErrorMessage(getDeleteErrorMessage(error));
+ },
+ }
+ );
+ };
+
+ useEffect(() => {
+ if (!isDeleteModalOpen) {
+ setDeleteErrorMessage('');
+ }
+ }, [isDeleteModalOpen]);
+
+ return (
+
+
+
+
+
+
+
계정 목록
+
+ 전체 관리자 계정을 조회하고 생성, 수정, 삭제할 수 있습니다.
+
+
+
+ 총 {userList.length}명
+
+
+
+
+
+
+
+
+ | 이메일 |
+
+ 관리자 유형
+ |
+ 역할 |
+ 관리 |
+
+
+
+ {isLoading ? (
+
+ |
+ 관리자 목록을 불러오는 중입니다.
+ |
+
+ ) : userList.length === 0 ? (
+
+ |
+ 등록된 관리자 계정이 없습니다.
+ |
+
+ ) : (
+ userList.map((user) => {
+ return (
+
+ |
+ {user.email}
+ {user.name}
+ |
+
+
+ {getAdminTypeLabel(user.adminType)}
+
+ |
+
+ {user.adminType === 'SUPER' ? '-' : user.roleName || '-'}
+ |
+
+
+
+
+
+ |
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
+
+
+
+
{
+ setSuccessMessage('');
+ closeSuccessModal();
+ }}>
+ {
+ setSuccessMessage('');
+ closeSuccessModal();
+ }}
+ />
+
+
+ );
+}
diff --git a/apps/admin/src/types/api/schema.d.ts b/apps/admin/src/types/api/schema.d.ts
index 673e15b12..238af5095 100644
--- a/apps/admin/src/types/api/schema.d.ts
+++ b/apps/admin/src/types/api/schema.d.ts
@@ -1357,7 +1357,8 @@ export interface paths {
path?: never;
cookie?: never;
};
- get?: never;
+ /** 관리자 계정 목록 조회 */
+ get: operations['getUserList'];
put?: never;
/** 관리자 계정 생성 */
post: operations['create_2'];
@@ -1367,6 +1368,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/api/admin/user/{id}': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** 관리자 계정 단건 조회 */
+ get: operations['getUserById'];
+ put?: never;
+ post?: never;
+ /** 관리자 계정 삭제 */
+ delete: operations['deleteUser'];
+ options?: never;
+ head?: never;
+ /** 관리자 계정 수정 */
+ patch?: never;
+ trace?: never;
+ put: operations['putUser'];
+ };
'/api/admin/teacher': {
parameters: {
query?: never;
@@ -3954,9 +3975,25 @@ export interface components {
clientEventId?: string;
};
AdminCreateRequest: {
+ name: string;
email: string;
password: string;
};
+ AdminUpdateRequest: {
+ name?: string;
+ email?: string;
+ password?: string;
+ };
+ AdminResp: {
+ /** Format: int64 */
+ id: number;
+ name: string;
+ email: string;
+ adminType: 'SUPER' | 'ROLE_BASED';
+ /** Format: int64 */
+ roleId?: number | null;
+ roleName?: string | null;
+ };
TeacherCreateRequest: {
name: string;
email: string;
@@ -7460,6 +7497,95 @@ export interface operations {
};
};
};
+ getUserList: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['AdminResp'][];
+ };
+ };
+ };
+ };
+ getUserById: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** Format: int64 */
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ '*/*': components['schemas']['AdminResp'];
+ };
+ };
+ };
+ };
+ putUser: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** Format: int64 */
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['AdminUpdateRequest'];
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ deleteUser: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** Format: int64 */
+ id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
search: {
parameters: {
query?: {