7d9ec39b5d
Phase 1(사용자관리) 패턴을 권한관리에 동일 적용. 권한 그룹 CRUD,
멤버 토글, 메뉴 권한 토글 모두 회사 컨텍스트 임시 전환 후 처리.
신규 백엔드
- crosstenant/CrossTenantRoleController.java
/api/admin/cross-tenant/roles/** — 8개 endpoint
· POST — 권한 그룹 생성 (body.company_code 필수)
· PUT /{id} — 권한 그룹 수정 (body.company_code 필수)
· DELETE /{id}?company_code= — 삭제
· GET /{id}/workspace?company_code= — 그룹 + 멤버 + 메뉴 통합 로드
· GET /menus/all?company_code= — 회사 메뉴 트리 (권한 설정용)
· POST /{id}/members/{userId}?company_code= — 멤버 1명 추가
· DELETE /{id}/members/{userId}?company_code= — 멤버 1명 제거
· PATCH /{id}/menu-permissions/{menuObjid} — 토글
CrossTenantExecutor 재사용. 기존 RoleController 무수정 (회귀 0).
중요: @RequestAttribute("user_id") 가 토큰 없을 때 missing 에러로 500
떨어지는 문제 — required=false 로 가드까지 안전하게 도달하도록.
프론트
- lib/api/role.ts — 7개 메서드(create/update/delete/getWorkspace/
getAllMenus/addSingleMember/removeSingleMember/toggleMenuPermission)에
isCrossTenantMode() 분기 + companyCode 인자 추가
- RoleFormModal — update 시 editingRole.company_code 같이 전달
- RoleDeleteModal — delete 시 role.company_code 같이 전달
- rolesList/page.tsx — loadWorkspace / addSingleMember / removeSingleMember /
toggleMenuPermission 호출 시 selectedRole.company_code 전달
검증 (curl, SUPER_ADMIN 토큰):
- 토큰 없음 → 403 super_admin_required
- POST 권한 그룹 (TEST02) → 201, /roles fan-out 에 by={TEST01:1, TEST02:1}
- DELETE → 200, fan-out by={TEST01:1} 로 복귀
미구현 (Phase 2 후속, 별도 작업):
- 일괄 멤버 추가/제거/diff (PUT/POST /members)
- 메뉴 권한 일괄 설정 (PUT /menu-permissions)
- 사용자별 권한 그룹 조회
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
433 lines
14 KiB
TypeScript
433 lines
14 KiB
TypeScript
import { apiClient } from "./client";
|
|
import { ApiResponse } from "@/types/commonCode";
|
|
import { isCrossTenantMode } from "@/lib/auth/crossTenantMode";
|
|
|
|
/**
|
|
* 권한 그룹 인터페이스
|
|
*/
|
|
export interface RoleGroup {
|
|
objid: number;
|
|
auth_name: string;
|
|
auth_code: string;
|
|
company_code: string;
|
|
status: string;
|
|
writer: string;
|
|
regdate: string;
|
|
member_count?: number;
|
|
menu_count?: number;
|
|
member_names?: string;
|
|
}
|
|
|
|
/**
|
|
* 권한 그룹 멤버 인터페이스
|
|
*/
|
|
export interface RoleMember {
|
|
objid: number;
|
|
master_objid: number;
|
|
user_id: string;
|
|
user_name: string;
|
|
dept_name?: string;
|
|
position_name?: string;
|
|
writer: string;
|
|
regdate: string;
|
|
}
|
|
|
|
/**
|
|
* 메뉴 권한 인터페이스
|
|
*/
|
|
export interface MenuPermission {
|
|
objid: number;
|
|
menu_objid: number;
|
|
auth_objid: number;
|
|
menu_name?: string;
|
|
create_yn: string;
|
|
read_yn: string;
|
|
update_yn: string;
|
|
delete_yn: string;
|
|
writer: string;
|
|
regdate: string;
|
|
}
|
|
|
|
/**
|
|
* 권한 그룹 API
|
|
*/
|
|
export const roleAPI = {
|
|
/**
|
|
* 권한 그룹 목록 조회
|
|
*
|
|
* 분기:
|
|
* - cross-tenant 모드 (SUPER_ADMIN + company_code="*") → /admin/cross-tenant/roles
|
|
* 응답 행마다 company_code 박혀있어 화면에서 회사 컬럼/필터 가능.
|
|
* - 단일 회사 모드 → /roles (기존)
|
|
*
|
|
* 두 응답을 동일 shape `{ success, data: RoleGroup[] }` 로 정규화.
|
|
*/
|
|
async getList(params?: { company_code?: string; search?: string }): Promise<ApiResponse<RoleGroup[]>> {
|
|
try {
|
|
if (isCrossTenantMode()) {
|
|
const response = await apiClient.get("/admin/cross-tenant/roles", { params });
|
|
const ct = response.data;
|
|
if (ct && ct.success && ct.data) {
|
|
return {
|
|
success: true,
|
|
data: (ct.data.rows || []) as RoleGroup[],
|
|
message: ct.message,
|
|
// CrossTenantBanner 메타 — ApiResponse 타입엔 없지만 페이지가 캐스팅으로 접근
|
|
cross_tenant_meta: {
|
|
companies_queried: ct.data.companies_queried,
|
|
companies_failed: ct.data.companies_failed,
|
|
failed_company_codes: ct.data.failed_company_codes ?? [],
|
|
truncated: ct.data.truncated,
|
|
truncated_company_codes: ct.data.truncated_company_codes ?? [],
|
|
per_company_limit: ct.data.per_company_limit,
|
|
},
|
|
} as ApiResponse<RoleGroup[]>;
|
|
}
|
|
return ct;
|
|
}
|
|
const response = await apiClient.get("/roles", { params });
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "권한 그룹 목록 조회 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 권한 그룹 상세 조회
|
|
*/
|
|
async getById(id: number): Promise<ApiResponse<RoleGroup>> {
|
|
try {
|
|
const response = await apiClient.get(`/roles/${id}`);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "권한 그룹 상세 조회 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 권한 그룹 생성
|
|
* cross-tenant 모드: data.company_code 가 가리키는 회사 DB 에 INSERT.
|
|
*/
|
|
async create(data: { auth_name: string; auth_code: string; company_code: string }): Promise<ApiResponse<RoleGroup>> {
|
|
try {
|
|
const endpoint = isCrossTenantMode() ? "/admin/cross-tenant/roles" : "/roles";
|
|
const response = await apiClient.post(endpoint, data);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "권한 그룹 생성 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 권한 그룹 수정
|
|
* cross-tenant 모드: data.company_code 필수 (그 회사 DB 컨텍스트 라우팅).
|
|
*/
|
|
async update(
|
|
id: number | string,
|
|
data: {
|
|
auth_name?: string;
|
|
auth_code?: string;
|
|
status?: string;
|
|
company_code?: string;
|
|
},
|
|
): Promise<ApiResponse<RoleGroup>> {
|
|
try {
|
|
const endpoint = isCrossTenantMode() ? `/admin/cross-tenant/roles/${id}` : `/roles/${id}`;
|
|
const response = await apiClient.put(endpoint, data);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "권한 그룹 수정 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 권한 그룹 삭제
|
|
* cross-tenant 모드: companyCode 필수 (어느 회사 DB 의 그룹인지).
|
|
*/
|
|
async delete(id: number | string, companyCode?: string): Promise<ApiResponse<null>> {
|
|
try {
|
|
const url = isCrossTenantMode()
|
|
? `/admin/cross-tenant/roles/${id}?company_code=${encodeURIComponent(companyCode ?? "")}`
|
|
: `/roles/${id}`;
|
|
const response = await apiClient.delete(url);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "권한 그룹 삭제 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 권한 그룹 멤버 목록 조회
|
|
*/
|
|
async getMembers(roleId: number): Promise<ApiResponse<RoleMember[]>> {
|
|
try {
|
|
const response = await apiClient.get(`/roles/${roleId}/members`);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "멤버 목록 조회 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 권한 그룹 멤버 추가 (여러 명)
|
|
*/
|
|
async addMembers(roleId: number, userIds: string[]): Promise<ApiResponse<null>> {
|
|
try {
|
|
const response = await apiClient.post(`/roles/${roleId}/members`, { user_ids: userIds });
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "멤버 추가 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 권한 그룹 멤버 제거 (여러 명)
|
|
*/
|
|
async removeMembers(roleId: number, userIds: string[]): Promise<ApiResponse<null>> {
|
|
try {
|
|
const response = await apiClient.delete(`/roles/${roleId}/members`, { data: { user_ids: userIds } });
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "멤버 제거 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 권한 그룹 멤버 일괄 업데이트 (기존 멤버 전체 교체)
|
|
*/
|
|
async updateMembers(roleId: number, userIds: string[]): Promise<ApiResponse<null>> {
|
|
try {
|
|
const response = await apiClient.put(`/roles/${roleId}/members`, { user_ids: userIds });
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "멤버 업데이트 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 전체 메뉴 목록 조회 (권한 설정용)
|
|
* cross-tenant 모드: companyCode 필수 (어느 회사 메뉴 트리인지).
|
|
*/
|
|
async getAllMenus(companyCode?: string): Promise<ApiResponse<any[]>> {
|
|
try {
|
|
console.log("🔍 [roleAPI.getAllMenus] API 호출", { companyCode });
|
|
|
|
const url = isCrossTenantMode()
|
|
? `/admin/cross-tenant/roles/menus/all?company_code=${encodeURIComponent(companyCode ?? "")}`
|
|
: (companyCode ? `/roles/menus/all?company_code=${companyCode}` : "/roles/menus/all");
|
|
|
|
const response = await apiClient.get(url);
|
|
|
|
console.log("✅ [roleAPI.getAllMenus] API 응답", {
|
|
success: response.data.success,
|
|
count: response.data.data?.length,
|
|
});
|
|
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("❌ [roleAPI.getAllMenus] API 에러", error);
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "전체 메뉴 목록 조회 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 메뉴 권한 목록 조회
|
|
*/
|
|
async getMenuPermissions(roleId: number): Promise<ApiResponse<MenuPermission[]>> {
|
|
try {
|
|
const response = await apiClient.get(`/roles/${roleId}/menu-permissions`);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "메뉴 권한 조회 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 메뉴 권한 설정
|
|
*/
|
|
async setMenuPermissions(
|
|
roleId: number,
|
|
permissions: Array<{
|
|
menu_objid: number;
|
|
create_yn: string;
|
|
read_yn: string;
|
|
update_yn: string;
|
|
delete_yn: string;
|
|
}>,
|
|
): Promise<ApiResponse<null>> {
|
|
try {
|
|
const response = await apiClient.put(`/roles/${roleId}/menu-permissions`, { permissions });
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "메뉴 권한 설정 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 사용자가 속한 권한 그룹 목록 조회
|
|
*/
|
|
async getUserRoleGroups(userId?: string): Promise<ApiResponse<RoleGroup[]>> {
|
|
try {
|
|
const url = userId ? `/roles/user/${userId}/groups` : "/roles/user/my-groups";
|
|
const response = await apiClient.get(url);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "사용자 권한 그룹 조회 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 권한 그룹 통합 워크스페이스 조회
|
|
* 권한 그룹 선택 시 필요한 모든 정보 한 번에 반환
|
|
* - group: 권한 그룹 정보
|
|
* - members: 권한있는 직원
|
|
* - nonMembers: 권한없는 직원
|
|
* - menus: 전체 메뉴 (트리 원천)
|
|
* - permissions: 현재 메뉴 CRUD 권한
|
|
*/
|
|
async getWorkspace(roleId: number | string, companyCode?: string): Promise<ApiResponse<{
|
|
group: any;
|
|
members: any[];
|
|
nonMembers: any[];
|
|
menus: any[];
|
|
permissions: any[];
|
|
}>> {
|
|
try {
|
|
const url = isCrossTenantMode()
|
|
? `/admin/cross-tenant/roles/${roleId}/workspace?company_code=${encodeURIComponent(companyCode ?? "")}`
|
|
: `/roles/${roleId}/workspace`;
|
|
const response = await apiClient.get(url);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "권한 그룹 워크스페이스 조회 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 개별 멤버 추가
|
|
* cross-tenant 모드: companyCode 필수.
|
|
*/
|
|
async addSingleMember(roleId: number | string, userId: string, companyCode?: string): Promise<ApiResponse<any>> {
|
|
try {
|
|
const url = isCrossTenantMode()
|
|
? `/admin/cross-tenant/roles/${roleId}/members/${encodeURIComponent(userId)}?company_code=${encodeURIComponent(companyCode ?? "")}`
|
|
: `/roles/${roleId}/members/${encodeURIComponent(userId)}`;
|
|
const response = await apiClient.post(url);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "멤버 추가 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 개별 멤버 제거
|
|
* cross-tenant 모드: companyCode 필수.
|
|
*/
|
|
async removeSingleMember(roleId: number | string, userId: string, companyCode?: string): Promise<ApiResponse<any>> {
|
|
try {
|
|
const url = isCrossTenantMode()
|
|
? `/admin/cross-tenant/roles/${roleId}/members/${encodeURIComponent(userId)}?company_code=${encodeURIComponent(companyCode ?? "")}`
|
|
: `/roles/${roleId}/members/${encodeURIComponent(userId)}`;
|
|
const response = await apiClient.delete(url);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "멤버 제거 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 개별 메뉴 CRUD 권한 토글
|
|
* body: { create_yn?, read_yn?, update_yn?, delete_yn? } + cross-tenant 시 company_code
|
|
*/
|
|
async toggleMenuPermission(
|
|
roleId: number | string,
|
|
menuObjid: number | string,
|
|
changes: {
|
|
create_yn?: "Y" | "N";
|
|
read_yn?: "Y" | "N";
|
|
update_yn?: "Y" | "N";
|
|
delete_yn?: "Y" | "N";
|
|
},
|
|
companyCode?: string,
|
|
): Promise<ApiResponse<any>> {
|
|
try {
|
|
const endpoint = isCrossTenantMode()
|
|
? `/admin/cross-tenant/roles/${roleId}/menu-permissions/${encodeURIComponent(String(menuObjid))}`
|
|
: `/roles/${roleId}/menu-permissions/${encodeURIComponent(String(menuObjid))}`;
|
|
const body = isCrossTenantMode() ? { ...changes, company_code: companyCode } : changes;
|
|
const response = await apiClient.patch(endpoint, body);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
message: error.response?.data?.message || "메뉴 권한 토글 실패",
|
|
error: error.response?.data?.error || error.message,
|
|
};
|
|
}
|
|
},
|
|
};
|