feat(cross-tenant): SUPER_ADMIN 의 회사별 권한관리 WRITE (Phase 2)

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>
This commit is contained in:
hjjeong
2026-04-29 18:45:55 +09:00
parent 4d19c31440
commit 7d9ec39b5d
5 changed files with 373 additions and 32 deletions
@@ -186,12 +186,12 @@ export default function RolesPage() {
}, [roleGroups, selectedRole]);
// ─────────── 워크스페이스 로드 ───────────
const loadWorkspace = useCallback(async (roleId: number | string) => {
const loadWorkspace = useCallback(async (roleId: number | string, companyCode?: string) => {
setIsLoadingWorkspace(true);
setCheckedMembers(new Set());
setCheckedNonMembers(new Set());
try {
const res = await roleAPI.getWorkspace(roleId);
const res = await roleAPI.getWorkspace(roleId, companyCode);
if (res.success && res.data) {
setMembers(res.data.members || []);
setNonMembers(res.data.nonMembers || []);
@@ -216,7 +216,7 @@ export default function RolesPage() {
useEffect(() => {
if (selectedRole) {
loadWorkspace(selectedRole.objid);
loadWorkspace(selectedRole.objid, selectedRole.company_code);
} else {
setMembers([]);
setNonMembers([]);
@@ -250,7 +250,7 @@ export default function RolesPage() {
try {
for (const userId of ids) {
const res = await roleAPI.addSingleMember(selectedRole.objid, userId);
const res = await roleAPI.addSingleMember(selectedRole.objid, userId, selectedRole.company_code);
if (!res.success) throw new Error(res.message);
}
await refreshMenus();
@@ -258,7 +258,7 @@ export default function RolesPage() {
} catch (err) {
console.error("멤버 추가 오류:", err);
alert("멤버 추가에 실패했습니다. 화면을 새로고침합니다.");
loadWorkspace(selectedRole.objid);
loadWorkspace(selectedRole.objid, selectedRole.company_code);
}
}, [selectedRole, checkedNonMembers, nonMembers, refreshMenus, loadRoleGroups, loadWorkspace]);
@@ -273,7 +273,7 @@ export default function RolesPage() {
try {
for (const userId of ids) {
const res = await roleAPI.removeSingleMember(selectedRole.objid, userId);
const res = await roleAPI.removeSingleMember(selectedRole.objid, userId, selectedRole.company_code);
if (!res.success) throw new Error(res.message);
}
await refreshMenus();
@@ -281,7 +281,7 @@ export default function RolesPage() {
} catch (err) {
console.error("멤버 제거 오류:", err);
alert("멤버 제거에 실패했습니다. 화면을 새로고침합니다.");
loadWorkspace(selectedRole.objid);
loadWorkspace(selectedRole.objid, selectedRole.company_code);
}
}, [selectedRole, checkedMembers, members, refreshMenus, loadRoleGroups, loadWorkspace]);
@@ -393,7 +393,7 @@ export default function RolesPage() {
setPermissions((prev) => ({ ...prev, [menuId]: nextPerm }));
try {
const res = await roleAPI.toggleMenuPermission(selectedRole.objid, menuId, changes);
const res = await roleAPI.toggleMenuPermission(selectedRole.objid, menuId, changes, selectedRole.company_code);
if (!res.success) throw new Error(res.message);
if (res.data) {
@@ -471,14 +471,14 @@ export default function RolesPage() {
try {
for (const id of flatMenuIds) {
const res = await roleAPI.toggleMenuPermission(selectedRole.objid, id, change);
const res = await roleAPI.toggleMenuPermission(selectedRole.objid, id, change, selectedRole.company_code);
if (!res.success) throw new Error(res.message);
}
await refreshMenus();
} catch (err) {
console.error("일괄 변경 오류:", err);
alert("일괄 변경 실패 — 화면을 새로고침합니다.");
loadWorkspace(selectedRole.objid);
loadWorkspace(selectedRole.objid, selectedRole.company_code);
}
},
[selectedRole, flatMenuIds, refreshMenus, loadWorkspace],
@@ -49,7 +49,8 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
setIsLoading(true);
try {
const response = await roleAPI.delete(role.objid);
// cross-tenant 모드에선 role.company_code 가 그 회사 DB 라우팅 키
const response = await roleAPI.delete(role.objid, role.company_code);
if (response.success) {
displayAlert("권한 그룹이 삭제되었습니다.", "success");
+2 -1
View File
@@ -141,11 +141,12 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
let response;
if (isEditMode && editingRole) {
// 수정
// 수정 — cross-tenant 모드에선 editingRole.company_code 가 그 회사 DB 라우팅 키
response = await roleAPI.update(editingRole.objid, {
auth_name: formData.authName,
auth_code: formData.authCode,
status: formData.status,
company_code: editingRole.company_code,
});
} else {
// 생성
+45 -20
View File
@@ -114,10 +114,12 @@ export const roleAPI = {
/**
* 권한 그룹 생성
* cross-tenant 모드: data.company_code 가 가리키는 회사 DB 에 INSERT.
*/
async create(data: { auth_name: string; auth_code: string; company_code: string }): Promise<ApiResponse<RoleGroup>> {
try {
const response = await apiClient.post("/roles", data);
const endpoint = isCrossTenantMode() ? "/admin/cross-tenant/roles" : "/roles";
const response = await apiClient.post(endpoint, data);
return response.data;
} catch (error: any) {
return {
@@ -130,17 +132,20 @@ export const roleAPI = {
/**
* 권한 그룹 수정
* cross-tenant 모드: data.company_code 필수 (그 회사 DB 컨텍스트 라우팅).
*/
async update(
id: number,
id: number | string,
data: {
auth_name?: string;
auth_code?: string;
status?: string;
company_code?: string;
},
): Promise<ApiResponse<RoleGroup>> {
try {
const response = await apiClient.put(`/roles/${id}`, data);
const endpoint = isCrossTenantMode() ? `/admin/cross-tenant/roles/${id}` : `/roles/${id}`;
const response = await apiClient.put(endpoint, data);
return response.data;
} catch (error: any) {
return {
@@ -153,10 +158,14 @@ export const roleAPI = {
/**
* 권한 그룹 삭제
* cross-tenant 모드: companyCode 필수 (어느 회사 DB 의 그룹인지).
*/
async delete(id: number): Promise<ApiResponse<null>> {
async delete(id: number | string, companyCode?: string): Promise<ApiResponse<null>> {
try {
const response = await apiClient.delete(`/roles/${id}`);
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 {
@@ -233,12 +242,15 @@ export const roleAPI = {
/**
* 전체 메뉴 목록 조회 (권한 설정용)
* cross-tenant 모드: companyCode 필수 (어느 회사 메뉴 트리인지).
*/
async getAllMenus(companyCode?: string): Promise<ApiResponse<any[]>> {
try {
console.log("🔍 [roleAPI.getAllMenus] API 호출", { companyCode });
const url = companyCode ? `/roles/menus/all?company_code=${companyCode}` : "/roles/menus/all";
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);
@@ -325,7 +337,7 @@ export const roleAPI = {
* - menus: 전체 메뉴 (트리 원천)
* - permissions: 현재 메뉴 CRUD 권한
*/
async getWorkspace(roleId: number | string): Promise<ApiResponse<{
async getWorkspace(roleId: number | string, companyCode?: string): Promise<ApiResponse<{
group: any;
members: any[];
nonMembers: any[];
@@ -333,7 +345,10 @@ export const roleAPI = {
permissions: any[];
}>> {
try {
const response = await apiClient.get(`/roles/${roleId}/workspace`);
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 {
@@ -345,11 +360,15 @@ export const roleAPI = {
},
/**
* 개별 멤버 추가 (이미지: "<--추가" 체크 즉시 반영)
* 개별 멤버 추가
* cross-tenant 모드: companyCode 필수.
*/
async addSingleMember(roleId: number | string, userId: string): Promise<ApiResponse<any>> {
async addSingleMember(roleId: number | string, userId: string, companyCode?: string): Promise<ApiResponse<any>> {
try {
const response = await apiClient.post(`/roles/${roleId}/members/${encodeURIComponent(userId)}`);
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 {
@@ -361,11 +380,15 @@ export const roleAPI = {
},
/**
* 개별 멤버 제거 (이미지: "-->삭제" 체크 즉시 반영)
* 개별 멤버 제거
* cross-tenant 모드: companyCode 필수.
*/
async removeSingleMember(roleId: number | string, userId: string): Promise<ApiResponse<any>> {
async removeSingleMember(roleId: number | string, userId: string, companyCode?: string): Promise<ApiResponse<any>> {
try {
const response = await apiClient.delete(`/roles/${roleId}/members/${encodeURIComponent(userId)}`);
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 {
@@ -377,8 +400,8 @@ export const roleAPI = {
},
/**
* 개별 메뉴 CRUD 권한 토글 (이미지: 체크 즉시 반영)
* body: { create_yn?, read_yn?, update_yn?, delete_yn? } — 전달된 필드만 업데이트
* 개별 메뉴 CRUD 권한 토글
* body: { create_yn?, read_yn?, update_yn?, delete_yn? } + cross-tenant 시 company_code
*/
async toggleMenuPermission(
roleId: number | string,
@@ -389,12 +412,14 @@ export const roleAPI = {
update_yn?: "Y" | "N";
delete_yn?: "Y" | "N";
},
companyCode?: string,
): Promise<ApiResponse<any>> {
try {
const response = await apiClient.patch(
`/roles/${roleId}/menu-permissions/${encodeURIComponent(String(menuObjid))}`,
changes,
);
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 {