diff --git a/src/main/java/apu/saerok_admin/infra/CurrentAdminClient.java b/src/main/java/apu/saerok_admin/infra/CurrentAdminClient.java index 58d2a88..3831466 100644 --- a/src/main/java/apu/saerok_admin/infra/CurrentAdminClient.java +++ b/src/main/java/apu/saerok_admin/infra/CurrentAdminClient.java @@ -6,7 +6,6 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Optional; import java.util.Set; import org.slf4j.Logger; @@ -22,12 +21,6 @@ public class CurrentAdminClient { private static final Logger log = LoggerFactory.getLogger(CurrentAdminClient.class); - private static final Map ROLE_DESCRIPTION_MAP = Map.of( - "ADMIN_VIEWER", "열람자", - "ADMIN_EDITOR", "운영자" - ); - private static final String UNKNOWN_ROLE_DESCRIPTION = "알 수 없는 관리자 권한"; - private final RestClient saerokRestClient; private final List missingPrefixSegments; private final LoginSessionManager loginSessionManager; @@ -57,14 +50,19 @@ public Optional fetchCurrentAdminProfile() { return Optional.empty(); } - List roles = response.roles() != null ? List.copyOf(response.roles()) : List.of(); + List backendRoles = response.roles() != null ? List.copyOf(response.roles()) : List.of(); + AdminRoleInfo roleInfo = fetchAdminRoleInfo(); + List roleCodes = !roleInfo.roleCodes().isEmpty() + ? roleInfo.roleCodes() + : normalizeRoleCodes(backendRoles); return Optional.of(new CurrentAdminProfile( response.nickname(), response.email(), response.profileImageUrl(), - toRoleDescriptions(roles), - normalizeRoleCodes(roles) + roleInfo.roleDisplayNames(), + roleCodes, + roleInfo.permissionKeys() )); } catch (RestClientResponseException exception) { log.warn( @@ -96,30 +94,79 @@ private record BackendUserProfileResponse( ) { } - private List toRoleDescriptions(List roles) { - if (roles == null || roles.isEmpty()) { - return List.of(); - } - - Set descriptions = new LinkedHashSet<>(); - for (String role : roles) { - if (!StringUtils.hasText(role)) { - continue; - } + private AdminRoleInfo fetchAdminRoleInfo() { + try { + AdminMyRoleResponse response = saerokRestClient.get() + .uri(uriBuilder -> buildUri(uriBuilder, "admin", "role", "me")) + .retrieve() + .body(AdminMyRoleResponse.class); - String normalized = role.toUpperCase(Locale.ROOT); - String description = ROLE_DESCRIPTION_MAP.get(normalized); - if (description != null) { - descriptions.add(description); - continue; + if (response == null) { + return AdminRoleInfo.empty(); } - if (normalized.startsWith("ADMIN_")) { - descriptions.add(UNKNOWN_ROLE_DESCRIPTION); - } + List roleDisplayNames = response.roles() == null + ? List.of() + : response.roles().stream() + .map(RoleSummaryResponse::displayName) + .filter(StringUtils::hasText) + .map(String::trim) + .toList(); + List roleCodes = response.roles() == null + ? List.of() + : response.roles().stream() + .map(RoleSummaryResponse::code) + .filter(StringUtils::hasText) + .toList(); + List permissionKeys = response.permissions() == null + ? List.of() + : response.permissions().stream() + .map(PermissionSummaryResponse::key) + .filter(StringUtils::hasText) + .toList(); + return new AdminRoleInfo(roleDisplayNames, roleCodes, permissionKeys); + } catch (RestClientResponseException exception) { + log.warn( + "Failed to fetch current admin roles. status={}, body={}", + exception.getStatusCode(), + exception.getResponseBodyAsString(), + exception + ); + } catch (RestClientException exception) { + log.warn("Failed to fetch current admin roles.", exception); } + return AdminRoleInfo.empty(); + } + + private record AdminMyRoleResponse( + List roles, + List permissions + ) { + } - return descriptions.isEmpty() ? List.of() : List.copyOf(descriptions); + private record RoleSummaryResponse( + Long id, + String code, + String displayName, + String description, + Boolean builtin + ) { + } + + private record PermissionSummaryResponse( + String key, + String description + ) { + } + + private record AdminRoleInfo( + List roleDisplayNames, + List roleCodes, + List permissionKeys + ) { + private static AdminRoleInfo empty() { + return new AdminRoleInfo(List.of(), List.of(), List.of()); + } } private List normalizeRoleCodes(List roles) { diff --git a/src/main/java/apu/saerok_admin/infra/auth/BackendAuthClient.java b/src/main/java/apu/saerok_admin/infra/auth/BackendAuthClient.java index bf820c7..551c0c4 100644 --- a/src/main/java/apu/saerok_admin/infra/auth/BackendAuthClient.java +++ b/src/main/java/apu/saerok_admin/infra/auth/BackendAuthClient.java @@ -25,7 +25,6 @@ public class BackendAuthClient { private static final Logger log = LoggerFactory.getLogger(BackendAuthClient.class); - private static final String ADMIN_CHANNEL = "admin"; private final RestClient authRestClient; private final List missingPrefixSegments; @@ -39,10 +38,10 @@ public BackendAuthClient( } public LoginSuccess kakaoLogin(String authorizationCode) { - KakaoLoginPayload payload = new KakaoLoginPayload(authorizationCode, ADMIN_CHANNEL); + KakaoLoginPayload payload = new KakaoLoginPayload(authorizationCode); log.info("Requesting Kakao login from backend with authorization code length {}", authorizationCode == null ? 0 : authorizationCode.length()); ResponseEntity response = authRestClient.post() - .uri(uriBuilder -> buildUri(uriBuilder, "auth", "kakao", "login")) + .uri(uriBuilder -> buildUri(uriBuilder, "admin", "auth", "kakao", "login")) .contentType(MediaType.APPLICATION_JSON) .body(payload) .retrieve() @@ -113,6 +112,10 @@ private URI buildUri(UriBuilder builder, String... segments) { } private record KakaoLoginPayload(String authorizationCode, String channel) { + + private KakaoLoginPayload(String authorizationCode) { + this(authorizationCode, "admin"); + } } private record AppleLoginPayload(String authorizationCode) { diff --git a/src/main/java/apu/saerok_admin/infra/role/AdminRoleClient.java b/src/main/java/apu/saerok_admin/infra/role/AdminRoleClient.java new file mode 100644 index 0000000..9e53351 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/AdminRoleClient.java @@ -0,0 +1,128 @@ +package apu.saerok_admin.infra.role; + +import apu.saerok_admin.infra.SaerokApiProps; +import apu.saerok_admin.infra.role.dto.AdminMyRoleResponse; +import apu.saerok_admin.infra.role.dto.AdminRoleListResponse; +import apu.saerok_admin.infra.role.dto.AdminRoleUserListResponse; +import apu.saerok_admin.infra.role.dto.AdminUserRoleResponse; +import apu.saerok_admin.infra.role.dto.AssignRoleRequest; +import apu.saerok_admin.infra.role.dto.CreateRoleRequest; +import apu.saerok_admin.infra.role.dto.RoleDetailResponse; +import apu.saerok_admin.infra.role.dto.UpdateRolePermissionsRequest; +import java.net.URI; +import java.util.List; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilder; + +@Component +public class AdminRoleClient { + + private static final String[] ADMIN_ROLE_SEGMENTS = {"admin", "role"}; + + private final RestClient saerokRestClient; + private final String[] missingPrefixSegments; + + public AdminRoleClient(RestClient saerokRestClient, SaerokApiProps saerokApiProps) { + this.saerokRestClient = saerokRestClient; + List missing = saerokApiProps.missingPrefixSegments(); + this.missingPrefixSegments = missing.toArray(new String[0]); + } + + public AdminMyRoleResponse getMyRoles() { + return get(AdminMyRoleResponse.class, "me"); + } + + public AdminRoleUserListResponse listAdminUsers() { + return get(AdminRoleUserListResponse.class, "users"); + } + + public AdminRoleListResponse listRoles() { + return get(AdminRoleListResponse.class); + } + + public RoleDetailResponse createRole(CreateRoleRequest request) { + return post(RoleDetailResponse.class, request); + } + + public RoleDetailResponse updateRolePermissions(String roleCode, UpdateRolePermissionsRequest request) { + return put(RoleDetailResponse.class, request, roleCode, "permissions"); + } + + public void deleteRole(String roleCode) { + deleteVoid(roleCode); + } + + public AdminUserRoleResponse grantRole(Long userId, AssignRoleRequest request) { + return post(AdminUserRoleResponse.class, request, "users", userId.toString(), "roles"); + } + + public AdminUserRoleResponse revokeRole(Long userId, String roleCode) { + return delete(AdminUserRoleResponse.class, "users", userId.toString(), "roles", roleCode); + } + + private T get(Class responseType, String... segments) { + T response = saerokRestClient.get() + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .retrieve() + .body(responseType); + if (response == null) { + throw new IllegalStateException("Empty response from admin role API"); + } + return response; + } + + private T post(Class responseType, Object body, String... segments) { + T response = saerokRestClient.post() + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .body(body) + .retrieve() + .body(responseType); + if (response == null) { + throw new IllegalStateException("Empty response from admin role API"); + } + return response; + } + + private T put(Class responseType, Object body, String... segments) { + T response = saerokRestClient.method(HttpMethod.PUT) + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .body(body) + .retrieve() + .body(responseType); + if (response == null) { + throw new IllegalStateException("Empty response from admin role API"); + } + return response; + } + + private T delete(Class responseType, String... segments) { + T response = saerokRestClient.delete() + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .retrieve() + .body(responseType); + if (response == null) { + throw new IllegalStateException("Empty response from admin role API"); + } + return response; + } + + private void deleteVoid(String... segments) { + saerokRestClient.delete() + .uri(uriBuilder -> buildUri(uriBuilder, segments)) + .retrieve() + .toBodilessEntity(); + } + + private URI buildUri(UriBuilder builder, String... segments) { + if (missingPrefixSegments.length > 0) { + builder.pathSegment(missingPrefixSegments); + } + builder.pathSegment(ADMIN_ROLE_SEGMENTS); + if (segments.length > 0) { + builder.pathSegment(segments); + } + return builder.build(); + } +} diff --git a/src/main/java/apu/saerok_admin/infra/role/dto/AdminMyRoleResponse.java b/src/main/java/apu/saerok_admin/infra/role/dto/AdminMyRoleResponse.java new file mode 100644 index 0000000..767199d --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/dto/AdminMyRoleResponse.java @@ -0,0 +1,9 @@ +package apu.saerok_admin.infra.role.dto; + +import java.util.List; + +public record AdminMyRoleResponse( + List roles, + List permissions +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/role/dto/AdminRoleListResponse.java b/src/main/java/apu/saerok_admin/infra/role/dto/AdminRoleListResponse.java new file mode 100644 index 0000000..c24bb41 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/dto/AdminRoleListResponse.java @@ -0,0 +1,8 @@ +package apu.saerok_admin.infra.role.dto; + +import java.util.List; + +public record AdminRoleListResponse( + List roles +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/role/dto/AdminRoleUserListResponse.java b/src/main/java/apu/saerok_admin/infra/role/dto/AdminRoleUserListResponse.java new file mode 100644 index 0000000..0f934f0 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/dto/AdminRoleUserListResponse.java @@ -0,0 +1,8 @@ +package apu.saerok_admin.infra.role.dto; + +import java.util.List; + +public record AdminRoleUserListResponse( + List users +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/role/dto/AdminUserRoleResponse.java b/src/main/java/apu/saerok_admin/infra/role/dto/AdminUserRoleResponse.java new file mode 100644 index 0000000..3689da4 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/dto/AdminUserRoleResponse.java @@ -0,0 +1,13 @@ +package apu.saerok_admin.infra.role.dto; + +import java.util.List; + +public record AdminUserRoleResponse( + Long userId, + String nickname, + String email, + boolean superAdmin, + List roles, + List permissions +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/role/dto/AssignRoleRequest.java b/src/main/java/apu/saerok_admin/infra/role/dto/AssignRoleRequest.java new file mode 100644 index 0000000..726e28e --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/dto/AssignRoleRequest.java @@ -0,0 +1,6 @@ +package apu.saerok_admin.infra.role.dto; + +public record AssignRoleRequest( + String roleCode +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/role/dto/CreateRoleRequest.java b/src/main/java/apu/saerok_admin/infra/role/dto/CreateRoleRequest.java new file mode 100644 index 0000000..daff53e --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/dto/CreateRoleRequest.java @@ -0,0 +1,8 @@ +package apu.saerok_admin.infra.role.dto; + +public record CreateRoleRequest( + String code, + String displayName, + String description +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/role/dto/PermissionSummaryResponse.java b/src/main/java/apu/saerok_admin/infra/role/dto/PermissionSummaryResponse.java new file mode 100644 index 0000000..6f79803 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/dto/PermissionSummaryResponse.java @@ -0,0 +1,7 @@ +package apu.saerok_admin.infra.role.dto; + +public record PermissionSummaryResponse( + String key, + String description +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/role/dto/RoleDetailResponse.java b/src/main/java/apu/saerok_admin/infra/role/dto/RoleDetailResponse.java new file mode 100644 index 0000000..69e869c --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/dto/RoleDetailResponse.java @@ -0,0 +1,13 @@ +package apu.saerok_admin.infra.role.dto; + +import java.util.List; + +public record RoleDetailResponse( + Long id, + String code, + String displayName, + String description, + boolean builtin, + List permissions +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/role/dto/RoleSummaryResponse.java b/src/main/java/apu/saerok_admin/infra/role/dto/RoleSummaryResponse.java new file mode 100644 index 0000000..dcc0c10 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/dto/RoleSummaryResponse.java @@ -0,0 +1,10 @@ +package apu.saerok_admin.infra.role.dto; + +public record RoleSummaryResponse( + Long id, + String code, + String displayName, + String description, + boolean builtin +) { +} diff --git a/src/main/java/apu/saerok_admin/infra/role/dto/UpdateRolePermissionsRequest.java b/src/main/java/apu/saerok_admin/infra/role/dto/UpdateRolePermissionsRequest.java new file mode 100644 index 0000000..fcba190 --- /dev/null +++ b/src/main/java/apu/saerok_admin/infra/role/dto/UpdateRolePermissionsRequest.java @@ -0,0 +1,8 @@ +package apu.saerok_admin.infra.role.dto; + +import java.util.List; + +public record UpdateRolePermissionsRequest( + List permissions +) { +} diff --git a/src/main/java/apu/saerok_admin/web/AdController.java b/src/main/java/apu/saerok_admin/web/AdController.java index 510d4c2..2e2c442 100644 --- a/src/main/java/apu/saerok_admin/web/AdController.java +++ b/src/main/java/apu/saerok_admin/web/AdController.java @@ -58,6 +58,8 @@ public class AdController { private static final Logger log = LoggerFactory.getLogger(AdController.class); + private static final String PERMISSION_ADMIN_AD_WRITE = "ADMIN_AD_WRITE"; + private static final String PERMISSION_ADMIN_SLOT_DELETE = "ADMIN_SLOT_DELETE"; private final AdminAdClient adminAdClient; private final Clock clock; @@ -168,7 +170,7 @@ public String index(@RequestParam(name = "tab", defaultValue = "ads") String tab public String newAdForm(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, Model model, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고를 등록할 권한이 없습니다."); return "redirect:/ads"; @@ -194,7 +196,7 @@ public String createAd(@ModelAttribute("currentAdminProfile") CurrentAdminProfil @RequestParam(name = "objectKey") String objectKey, @RequestParam(name = "contentType") String contentType, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고를 등록할 권한이 없습니다."); return "redirect:/ads?tab=ads"; @@ -229,7 +231,7 @@ public String editAdForm(@ModelAttribute("currentAdminProfile") CurrentAdminProf @RequestParam("id") Long adId, Model model, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고를 수정할 권한이 없습니다."); return "redirect:/ads"; @@ -282,7 +284,7 @@ public String updateAd(@ModelAttribute("currentAdminProfile") CurrentAdminProfil @RequestParam(name = "objectKey", required = false) String objectKey, @RequestParam(name = "contentType", required = false) String contentType, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고를 수정할 권한이 없습니다."); return "redirect:/ads?tab=ads"; @@ -317,7 +319,7 @@ public String updateAd(@ModelAttribute("currentAdminProfile") CurrentAdminProfil public String deleteAd(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, @RequestParam("id") Long adId, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고를 삭제할 권한이 없습니다."); return "redirect:/ads?tab=ads"; @@ -344,7 +346,7 @@ public String deleteAd(@ModelAttribute("currentAdminProfile") CurrentAdminProfil public String newSlotForm(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, Model model, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 등록할 권한이 없습니다."); return "redirect:/ads?tab=schedule"; @@ -369,7 +371,7 @@ public String createSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProf @RequestParam("fallbackRatio") Double fallbackRatioPercent, @RequestParam("ttlSeconds") Integer ttlSeconds, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 등록할 권한이 없습니다."); return "redirect:/ads?tab=schedule"; @@ -405,7 +407,7 @@ public String editSlotForm(@ModelAttribute("currentAdminProfile") CurrentAdminPr @RequestParam("id") Long slotId, Model model, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 수정할 권한이 없습니다."); return "redirect:/ads?tab=schedule"; @@ -472,7 +474,7 @@ public String updateSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProf failureRedirect = "redirect:/ads/slots/edit?id=" + slotId; } - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 수정할 권한이 없습니다."); return successRedirect; @@ -507,7 +509,7 @@ public String updateSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProf public String deleteSlot(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, @RequestParam("id") Long slotId, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_SLOT_DELETE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 위치를 삭제할 권한이 없습니다."); return "redirect:/ads?tab=schedule"; @@ -535,7 +537,7 @@ public String newPlacementForm(@ModelAttribute("currentAdminProfile") CurrentAdm @RequestParam(name = "slotId", required = false) Long preselectedSlotId, Model model, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 등록할 권한이 없습니다."); return "redirect:/ads?tab=schedule"; @@ -567,7 +569,7 @@ public String createPlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi @RequestParam("weight") Short weight, @RequestParam(name = "enabled", defaultValue = "false") boolean enabled, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 등록할 권한이 없습니다."); return "redirect:/ads?tab=schedule"; @@ -600,7 +602,7 @@ public String editPlacementForm(@ModelAttribute("currentAdminProfile") CurrentAd @RequestParam("id") Long placementId, Model model, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 수정할 권한이 없습니다."); return "redirect:/ads?tab=schedule"; @@ -663,7 +665,7 @@ public String updatePlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi @RequestParam("weight") Short weight, @RequestParam(name = "enabled", defaultValue = "false") boolean enabled, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 수정할 권한이 없습니다."); return "redirect:/ads?tab=schedule"; @@ -694,7 +696,7 @@ public String updatePlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi public String deletePlacement(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, @RequestParam("id") Long placementId, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 스케줄을 삭제할 권한이 없습니다."); return "redirect:/ads?tab=schedule"; @@ -722,7 +724,7 @@ public String togglePlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi @RequestParam("id") Long placementId, @RequestParam("enabled") boolean enabled, RedirectAttributes redirectAttributes) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { redirectAttributes.addFlashAttribute("flashStatus", "error"); redirectAttributes.addFlashAttribute("flashMessage", "광고 노출 상태를 변경할 권한이 없습니다."); return "redirect:/ads?tab=schedule"; @@ -770,7 +772,7 @@ public String togglePlacement(@ModelAttribute("currentAdminProfile") CurrentAdmi @ResponseBody public ResponseEntity presignImage(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, @RequestBody Map payload) { - if (!currentAdminProfile.isAdminEditor()) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_AD_WRITE)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(Map.of("message", "이미지 업로드 권한이 없습니다.")); } diff --git a/src/main/java/apu/saerok_admin/web/RoleManagementController.java b/src/main/java/apu/saerok_admin/web/RoleManagementController.java new file mode 100644 index 0000000..c0e1b59 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/RoleManagementController.java @@ -0,0 +1,688 @@ +package apu.saerok_admin.web; + +import apu.saerok_admin.infra.role.AdminRoleClient; +import apu.saerok_admin.infra.role.dto.AdminMyRoleResponse; +import apu.saerok_admin.infra.role.dto.AdminRoleListResponse; +import apu.saerok_admin.infra.role.dto.AdminRoleUserListResponse; +import apu.saerok_admin.infra.role.dto.AdminUserRoleResponse; +import apu.saerok_admin.infra.role.dto.AssignRoleRequest; +import apu.saerok_admin.infra.role.dto.CreateRoleRequest; +import apu.saerok_admin.infra.role.dto.PermissionSummaryResponse; +import apu.saerok_admin.infra.role.dto.RoleDetailResponse; +import apu.saerok_admin.infra.role.dto.RoleSummaryResponse; +import apu.saerok_admin.infra.role.dto.UpdateRolePermissionsRequest; +import apu.saerok_admin.web.view.Breadcrumb; +import apu.saerok_admin.web.view.CurrentAdminProfile; +import apu.saerok_admin.web.view.role.PermissionCatalog; +import apu.saerok_admin.web.view.role.PermissionOptionView; +import apu.saerok_admin.web.view.role.PermissionView; +import apu.saerok_admin.web.view.role.RoleDisplay; +import apu.saerok_admin.web.view.role.RolePermissionGroupView; +import apu.saerok_admin.web.view.role.RoleTemplateView; +import apu.saerok_admin.web.view.role.TeamMemberView; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import org.springframework.web.util.UriComponentsBuilder; + +@Controller +@RequestMapping("/admin/roles") +public class RoleManagementController { + + private static final Logger log = LoggerFactory.getLogger(RoleManagementController.class); + private static final String TAB_MY = "my"; + private static final String TAB_TEAM = "team"; + private static final String TAB_MANAGE = "manage"; + private static final String PERMISSION_ADMIN_ROLE_MY_READ = "ADMIN_ROLE_MY_READ"; + private static final String PERMISSION_ADMIN_ROLE_READ = "ADMIN_ROLE_READ"; + private static final String PERMISSION_ADMIN_ROLE_WRITE = "ADMIN_ROLE_WRITE"; + + private final AdminRoleClient adminRoleClient; + + public RoleManagementController(AdminRoleClient adminRoleClient) { + this.adminRoleClient = adminRoleClient; + } + + @GetMapping + public String index(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam(name = "tab", required = false) String tab, + @RequestParam(name = "selectedRoleCode", required = false) String selectedRoleCode, + Model model) { + model.addAttribute("pageTitle", "운영 권한"); + model.addAttribute("activeMenu", "adminRoles"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.active("운영 권한") + )); + model.addAttribute("toastMessages", List.of()); + + boolean canViewMyRoles = currentAdminProfile.hasPermission(PERMISSION_ADMIN_ROLE_MY_READ); + boolean canViewTeamRoles = currentAdminProfile.hasPermission(PERMISSION_ADMIN_ROLE_READ); + boolean canManageRoles = currentAdminProfile.hasPermission(PERMISSION_ADMIN_ROLE_WRITE); + + List roleTemplates = List.of(); + Map roleTemplatesByCode = Map.of(); + List permissionOptions = List.of(); + String roleTemplatesLoadError = null; + + if (canViewTeamRoles || canManageRoles) { + try { + AdminRoleListResponse response = adminRoleClient.listRoles(); + roleTemplates = Optional.ofNullable(response) + .map(AdminRoleListResponse::roles) + .orElseGet(List::of) + .stream() + .map(this::toRoleTemplateView) + .sorted(roleTemplateComparator()) + .toList(); + roleTemplatesByCode = roleTemplates.stream() + .collect(Collectors.toMap( + RoleTemplateView::code, + template -> template, + (left, right) -> left, + LinkedHashMap::new + )); + permissionOptions = buildPermissionOptions(roleTemplates); + } catch (RestClientResponseException exception) { + log.warn("Failed to load role templates. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + roleTemplatesLoadError = "ROLE 템플릿을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load role templates.", exception); + roleTemplatesLoadError = "ROLE 템플릿을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."; + } + } + + String normalizedRoleCode = normalizeRoleCode(selectedRoleCode); + RoleTemplateView selectedRoleTemplate = roleTemplatesByCode.get(normalizedRoleCode); + if (selectedRoleTemplate == null && !roleTemplates.isEmpty()) { + selectedRoleTemplate = roleTemplates.get(0); + normalizedRoleCode = selectedRoleTemplate.code(); + } + + List myRoles = List.of(); + List myRolePermissionGroups = List.of(); + String myRolesLoadError = null; + + if (canViewMyRoles) { + try { + AdminMyRoleResponse myRoleResponse = adminRoleClient.getMyRoles(); + myRoles = Optional.ofNullable(myRoleResponse) + .map(AdminMyRoleResponse::roles) + .orElseGet(List::of) + .stream() + .map(this::toRoleDisplay) + .toList(); + Map mapping = roleTemplatesByCode; + myRolePermissionGroups = myRoles.stream() + .map(role -> new RolePermissionGroupView(role, resolvePermissions(role.code(), mapping))) + .toList(); + } catch (RestClientResponseException exception) { + log.warn("Failed to load my roles. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + myRolesLoadError = "내 권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load my roles.", exception); + myRolesLoadError = "내 권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."; + } + } else { + myRolesLoadError = "내 권한을 조회할 권한이 없습니다."; + } + + List teamMembers = List.of(); + String teamMembersLoadError = null; + + if (canViewTeamRoles) { + try { + AdminRoleUserListResponse userResponse = adminRoleClient.listAdminUsers(); + teamMembers = Optional.ofNullable(userResponse) + .map(AdminRoleUserListResponse::users) + .orElseGet(List::of) + .stream() + .map(this::toTeamMemberView) + .sorted(teamMemberComparator()) + .toList(); + } catch (RestClientResponseException exception) { + log.warn("Failed to load team members. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + teamMembersLoadError = "팀원 권한 정보를 불러오지 못했습니다."; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load team members.", exception); + teamMembersLoadError = "팀원 권한 정보를 불러오지 못했습니다."; + } + } else { + teamMembersLoadError = "팀원 권한을 조회할 권한이 없습니다."; + } + + String normalizedTab = normalizeTab(tab, canViewTeamRoles, canManageRoles); + + model.addAttribute("tab", normalizedTab); + model.addAttribute("canViewMyRoles", canViewMyRoles); + model.addAttribute("canViewTeamTab", canViewTeamRoles); + model.addAttribute("canManageRoles", canManageRoles); + model.addAttribute("hasRoleStructureAccess", canViewTeamRoles || canManageRoles); + + model.addAttribute("myRoles", myRoles); + model.addAttribute("myRolePermissionGroups", myRolePermissionGroups); + model.addAttribute("myRolesLoadError", myRolesLoadError); + + model.addAttribute("teamMembers", teamMembers); + model.addAttribute("teamMemberCount", teamMembers.size()); + model.addAttribute("teamMembersLoadError", teamMembersLoadError); + model.addAttribute("teamMemberRoleGroupsById", buildTeamMemberRoleGroups(teamMembers, roleTemplatesByCode)); + + model.addAttribute("roleTemplates", roleTemplates); + model.addAttribute("roleTemplateCount", roleTemplates.size()); + model.addAttribute("selectedRoleTemplate", selectedRoleTemplate); + model.addAttribute("selectedRoleCode", normalizedRoleCode); + model.addAttribute("roleTemplatesLoadError", roleTemplatesLoadError); + model.addAttribute("permissionOptions", permissionOptions); + + return "admin-role/index"; + } + + @GetMapping("/team-members/{userId}/edit") + public String editTeamMember(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @PathVariable Long userId, + Model model, + RedirectAttributes redirectAttributes) { + boolean canViewTeamRoles = currentAdminProfile.hasPermission(PERMISSION_ADMIN_ROLE_READ); + boolean canManageRoles = currentAdminProfile.hasPermission(PERMISSION_ADMIN_ROLE_WRITE); + + if (!canViewTeamRoles) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "팀원 권한을 조회할 권한이 없습니다."); + return redirectToTeamTab(); + } + + List roleTemplates = List.of(); + Map roleTemplatesByCode = Map.of(); + String roleTemplatesLoadError = null; + + try { + AdminRoleListResponse response = adminRoleClient.listRoles(); + roleTemplates = Optional.ofNullable(response) + .map(AdminRoleListResponse::roles) + .orElseGet(List::of) + .stream() + .map(this::toRoleTemplateView) + .sorted(roleTemplateComparator()) + .toList(); + roleTemplatesByCode = roleTemplates.stream() + .collect(Collectors.toMap( + RoleTemplateView::code, + template -> template, + (left, right) -> left, + LinkedHashMap::new + )); + } catch (RestClientResponseException exception) { + log.warn("Failed to load role templates for team member edit. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + roleTemplatesLoadError = "권한 템플릿을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load role templates for team member edit.", exception); + roleTemplatesLoadError = "권한 템플릿을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."; + } + + TeamMemberView targetMember = null; + String teamMemberLoadError = null; + + try { + AdminRoleUserListResponse response = adminRoleClient.listAdminUsers(); + targetMember = Optional.ofNullable(response) + .map(AdminRoleUserListResponse::users) + .orElseGet(List::of) + .stream() + .map(this::toTeamMemberView) + .filter(member -> Objects.equals(member.id(), userId)) + .findFirst() + .orElse(null); + if (targetMember == null) { + teamMemberLoadError = "선택한 팀원을 찾을 수 없습니다."; + } + } catch (RestClientResponseException exception) { + log.warn("Failed to load team member. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + teamMemberLoadError = "팀원 정보를 불러오지 못했습니다."; + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to load team member.", exception); + teamMemberLoadError = "팀원 정보를 불러오지 못했습니다."; + } + + if (targetMember == null) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", teamMemberLoadError != null + ? teamMemberLoadError + : "선택한 팀원을 찾을 수 없습니다."); + return redirectToTeamTab(); + } + + List assignableRoles = roleTemplates.stream() + .map(RoleTemplateView::role) + .toList(); + boolean teamRoleEditorAvailable = canManageRoles && !assignableRoles.isEmpty(); + + List memberRoleGroups = buildRolePermissionGroups(targetMember, roleTemplatesByCode); + + model.addAttribute("pageTitle", targetMember.nickname() + " 권한 수정"); + model.addAttribute("activeMenu", "adminRoles"); + model.addAttribute("breadcrumbs", List.of( + Breadcrumb.of("대시보드", "/"), + Breadcrumb.of("운영 권한", "/admin/roles?tab=" + TAB_TEAM), + Breadcrumb.active(targetMember.nickname() + " 권한 수정") + )); + model.addAttribute("toastMessages", List.of()); + + model.addAttribute("teamMember", targetMember); + model.addAttribute("teamMemberRoleGroups", memberRoleGroups); + model.addAttribute("assignableRoles", assignableRoles); + model.addAttribute("teamRoleEditorAvailable", teamRoleEditorAvailable); + model.addAttribute("roleTemplatesLoadError", roleTemplatesLoadError); + model.addAttribute("canManageRoles", canManageRoles); + + return "admin-role/team-member-edit"; + } + + @PostMapping("/team-members/{userId}/roles") + public String updateTeamMemberRoles(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @PathVariable Long userId, + @RequestParam(name = "roleCodes", required = false) List roleCodes, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_ROLE_WRITE)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "권한을 편집할 권한이 없습니다."); + return redirectToTeamMemberEditor(userId); + } + + Set desiredRoles = new LinkedHashSet<>(normalizeRoleCodes(roleCodes)); + + try { + AdminRoleUserListResponse response = adminRoleClient.listAdminUsers(); + Optional targetUser = Optional.ofNullable(response) + .map(AdminRoleUserListResponse::users) + .orElseGet(List::of) + .stream() + .filter(user -> Objects.equals(user.userId(), userId)) + .findFirst(); + if (targetUser.isEmpty()) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "선택한 팀원을 찾을 수 없습니다."); + return redirectToTeamMemberEditor(userId); + } + + Set currentRoles = Optional.of(targetUser.get()) + .map(AdminUserRoleResponse::roles) + .orElseGet(List::of) + .stream() + .map(RoleSummaryResponse::code) + .map(this::normalizeRoleCode) + .filter(StringUtils::hasText) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + Set toGrant = new LinkedHashSet<>(desiredRoles); + toGrant.removeAll(currentRoles); + Set toRevoke = new LinkedHashSet<>(currentRoles); + toRevoke.removeAll(desiredRoles); + + for (String code : toGrant) { + adminRoleClient.grantRole(userId, new AssignRoleRequest(code)); + } + for (String code : toRevoke) { + adminRoleClient.revokeRole(userId, code); + } + + String message = toGrant.isEmpty() && toRevoke.isEmpty() + ? "변경 사항이 없어 기존 구성을 유지했습니다." + : "팀원 권한 구성을 업데이트했습니다."; + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", message); + } catch (RestClientResponseException exception) { + log.warn("Failed to update team member roles. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "팀원 권한을 수정하지 못했습니다. 잠시 후 다시 시도해 주세요."); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to update team member roles.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "팀원 권한을 수정하지 못했습니다. 잠시 후 다시 시도해 주세요."); + } + + return redirectToTeamMemberEditor(userId); + } + + @PostMapping("/new") + public String createRole(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @RequestParam String code, + @RequestParam String displayName, + @RequestParam String description, + @RequestParam(name = "permissions", required = false) List permissions, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_ROLE_WRITE)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "권한을 생성할 권한이 없습니다."); + return redirectToManage(null); + } + + String normalizedCode = normalizeRoleCode(code); + String normalizedDisplayName = StringUtils.hasText(displayName) ? displayName.trim() : ""; + String normalizedDescription = StringUtils.hasText(description) ? description.trim() : ""; + + if (!StringUtils.hasText(normalizedCode) || !StringUtils.hasText(normalizedDisplayName) + || !StringUtils.hasText(normalizedDescription)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "권한 코드, 이름, 설명을 모두 입력해 주세요."); + return redirectToManage(normalizedCode); + } + + try { + adminRoleClient.createRole(new CreateRoleRequest(normalizedCode, normalizedDisplayName, normalizedDescription)); + List permissionKeys = normalizePermissionKeys(permissions); + if (!permissionKeys.isEmpty()) { + adminRoleClient.updateRolePermissions(normalizedCode, new UpdateRolePermissionsRequest(permissionKeys)); + } + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "새 권한을 생성했습니다."); + } catch (RestClientResponseException exception) { + log.warn("Failed to create role. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "새 권한을 생성하지 못했습니다. 입력값을 확인해 주세요."); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to create role.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "새 권한을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요."); + } + + return redirectToManage(normalizedCode); + } + + @PostMapping("/{roleCode}/permissions") + public String updateRolePermissions(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @PathVariable String roleCode, + @RequestParam(name = "permissions", required = false) List permissions, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_ROLE_WRITE)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "권한을 편집할 권한이 없습니다."); + return redirectToManage(roleCode); + } + + String normalizedCode = normalizeRoleCode(roleCode); + List permissionKeys = normalizePermissionKeys(permissions); + + try { + adminRoleClient.updateRolePermissions(normalizedCode, new UpdateRolePermissionsRequest(permissionKeys)); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "권한 구성을 업데이트했습니다."); + } catch (RestClientResponseException exception) { + log.warn("Failed to update role permissions. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "권한 세부 사항을 업데이트하지 못했습니다."); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to update role permissions.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "권한 세부 사항을 업데이트하지 못했습니다."); + } + + return redirectToManage(normalizedCode); + } + + @PostMapping("/{roleCode}/delete") + public String deleteRole(@ModelAttribute("currentAdminProfile") CurrentAdminProfile currentAdminProfile, + @PathVariable String roleCode, + RedirectAttributes redirectAttributes) { + if (!currentAdminProfile.hasPermission(PERMISSION_ADMIN_ROLE_WRITE)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "권한을 삭제할 권한이 없습니다."); + return redirectToManage(roleCode); + } + + String normalizedCode = normalizeRoleCode(roleCode); + if (!StringUtils.hasText(normalizedCode)) { + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "삭제할 권한 코드를 확인해 주세요."); + return redirectToManage(null); + } + + try { + adminRoleClient.deleteRole(normalizedCode); + redirectAttributes.addFlashAttribute("flashStatus", "success"); + redirectAttributes.addFlashAttribute("flashMessage", "권한을 삭제했습니다."); + } catch (RestClientResponseException exception) { + log.warn("Failed to delete role. status={}, body={}", + exception.getStatusCode(), exception.getResponseBodyAsString(), exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "권한을 삭제하지 못했습니다."); + } catch (RestClientException | IllegalStateException exception) { + log.warn("Failed to delete role.", exception); + redirectAttributes.addFlashAttribute("flashStatus", "error"); + redirectAttributes.addFlashAttribute("flashMessage", "권한을 삭제하지 못했습니다."); + } + + return redirectToManage(null); + } + + private Comparator roleTemplateComparator() { + return Comparator + .comparing(RoleTemplateView::displayName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)) + .thenComparing(RoleTemplateView::code, String.CASE_INSENSITIVE_ORDER); + } + + private Comparator teamMemberComparator() { + return Comparator + .comparing(TeamMemberView::nickname, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)) + .thenComparing(TeamMemberView::id, Comparator.nullsLast(Long::compareTo)); + } + + private List resolvePermissions(String roleCode, Map mapping) { + if (!StringUtils.hasText(roleCode) || mapping.isEmpty()) { + return List.of(); + } + String normalized = normalizeRoleCode(roleCode); + RoleTemplateView template = mapping.get(normalized); + if (template == null || template.permissions() == null) { + return List.of(); + } + return template.permissions(); + } + + private RoleTemplateView toRoleTemplateView(RoleDetailResponse response) { + RoleDisplay display = new RoleDisplay( + response.id(), + response.code(), + response.displayName(), + response.description(), + response.builtin() + ); + List permissions = Optional.ofNullable(response.permissions()) + .orElseGet(List::of) + .stream() + .map(this::toPermissionView) + .toList(); + return new RoleTemplateView(display, permissions); + } + + private RoleDisplay toRoleDisplay(RoleSummaryResponse response) { + if (response == null) { + return new RoleDisplay(null, "", "", "", false); + } + return new RoleDisplay( + response.id(), + response.code(), + response.displayName(), + response.description(), + response.builtin() + ); + } + + private PermissionView toPermissionView(PermissionSummaryResponse response) { + if (response == null) { + return new PermissionView("", ""); + } + return new PermissionView(response.key(), response.description()); + } + + private TeamMemberView toTeamMemberView(AdminUserRoleResponse response) { + List roles = Optional.ofNullable(response.roles()) + .orElseGet(List::of) + .stream() + .map(this::toRoleDisplay) + .toList(); + List permissions = Optional.ofNullable(response.permissions()) + .orElseGet(List::of) + .stream() + .map(this::toPermissionView) + .toList(); + return new TeamMemberView( + response.userId(), + response.nickname(), + response.email(), + response.superAdmin(), + roles, + permissions + ); + } + + private List buildPermissionOptions(List templates) { + Map deduplicated = new LinkedHashMap<>(); + for (RoleTemplateView template : templates) { + for (PermissionView permission : template.permissions()) { + deduplicated.put(permission.key(), new PermissionOptionView(permission.key(), permission.description())); + } + } + for (PermissionOptionView builtin : PermissionCatalog.builtinPermissions()) { + deduplicated.putIfAbsent(builtin.key(), builtin); + } + Comparator comparator = Comparator + .comparing(PermissionOptionView::label, String.CASE_INSENSITIVE_ORDER) + .thenComparing(PermissionOptionView::key, String.CASE_INSENSITIVE_ORDER); + return deduplicated.values().stream().sorted(comparator).toList(); + } + + private String normalizeTab(String requestedTab, boolean canViewTeamRoles, boolean canManageRoles) { + List order = new ArrayList<>(); + order.add(TAB_MY); + if (canViewTeamRoles) { + order.add(TAB_TEAM); + } + if (canManageRoles) { + order.add(TAB_MANAGE); + } + String normalized = StringUtils.hasText(requestedTab) ? requestedTab.trim().toLowerCase(Locale.ROOT) : TAB_MY; + if (!order.contains(normalized)) { + normalized = order.get(0); + } + return normalized; + } + + private String normalizeRoleCode(String code) { + if (!StringUtils.hasText(code)) { + return ""; + } + return code.trim().toUpperCase(Locale.ROOT); + } + + private List normalizeRoleCodes(List rawCodes) { + if (rawCodes == null || rawCodes.isEmpty()) { + return List.of(); + } + Set normalized = new LinkedHashSet<>(); + for (String raw : rawCodes) { + String value = normalizeRoleCode(raw); + if (StringUtils.hasText(value)) { + normalized.add(value); + } + } + if (normalized.isEmpty()) { + return List.of(); + } + return List.copyOf(normalized); + } + + private List normalizePermissionKeys(List rawKeys) { + if (rawKeys == null || rawKeys.isEmpty()) { + return List.of(); + } + Set normalized = new LinkedHashSet<>(); + for (String raw : rawKeys) { + if (!StringUtils.hasText(raw)) { + continue; + } + normalized.add(raw.trim().toUpperCase(Locale.ROOT)); + } + if (normalized.isEmpty()) { + return List.of(); + } + return List.copyOf(normalized); + } + + private String redirectToTeamMemberEditor(Long userId) { + if (userId == null) { + return redirectToTeamTab(); + } + return "redirect:/admin/roles/team-members/" + userId + "/edit"; + } + + private String redirectToTeamTab() { + UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/admin/roles") + .queryParam("tab", TAB_TEAM); + return "redirect:" + builder.toUriString(); + } + + private Map> buildTeamMemberRoleGroups(List members, + Map mapping) { + if (members.isEmpty()) { + return Map.of(); + } + return members.stream() + .filter(member -> member.id() != null) + .collect(Collectors.toMap( + TeamMemberView::id, + member -> buildRolePermissionGroups(member, mapping), + (left, right) -> left, + LinkedHashMap::new + )); + } + + private List buildRolePermissionGroups(TeamMemberView member, + Map mapping) { + if (member == null) { + return List.of(); + } + return member.roles().stream() + .map(role -> new RolePermissionGroupView(role, resolvePermissions(role.code(), mapping))) + .toList(); + } + + private String redirectToManage(String selectedRoleCode) { + UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/admin/roles") + .queryParam("tab", TAB_MANAGE); + if (StringUtils.hasText(selectedRoleCode)) { + builder.queryParam("selectedRoleCode", normalizeRoleCode(selectedRoleCode)); + } + return "redirect:" + builder.toUriString(); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/CurrentAdminProfile.java b/src/main/java/apu/saerok_admin/web/view/CurrentAdminProfile.java index d87da27..f03b2dd 100644 --- a/src/main/java/apu/saerok_admin/web/view/CurrentAdminProfile.java +++ b/src/main/java/apu/saerok_admin/web/view/CurrentAdminProfile.java @@ -10,8 +10,9 @@ public record CurrentAdminProfile( String nickname, String email, String profileImageUrl, - List roleDescriptions, - List roleCodes + List roleDisplayNames, + List roleCodes, + List permissionKeys ) { private static final String DEFAULT_PROFILE_IMAGE_URL = @@ -23,12 +24,13 @@ public record CurrentAdminProfile( nickname = StringUtils.hasText(nickname) ? nickname : DEFAULT_NICKNAME; email = StringUtils.hasText(email) ? email : DEFAULT_EMAIL; profileImageUrl = StringUtils.hasText(profileImageUrl) ? profileImageUrl : DEFAULT_PROFILE_IMAGE_URL; - roleDescriptions = roleDescriptions != null ? List.copyOf(roleDescriptions) : List.of(); + roleDisplayNames = normalizeRoleDisplayNames(roleDisplayNames); roleCodes = normalizeRoleCodes(roleCodes); + permissionKeys = normalizePermissionKeys(permissionKeys); } - public boolean hasRoleDescriptions() { - return !roleDescriptions.isEmpty(); + public boolean hasRoleDisplayNames() { + return !roleDisplayNames.isEmpty(); } public boolean hasRole(String roleCode) { @@ -48,7 +50,15 @@ public boolean isAdminViewerOnly() { } public static CurrentAdminProfile placeholder() { - return new CurrentAdminProfile(null, null, null, List.of(), List.of()); + return new CurrentAdminProfile(null, null, null, List.of(), List.of(), List.of()); + } + + public boolean hasPermission(String permissionKey) { + if (!StringUtils.hasText(permissionKey)) { + return false; + } + String normalized = permissionKey.toUpperCase(Locale.ROOT); + return permissionKeys.contains(normalized); } private static List normalizeRoleCodes(List rawRoles) { @@ -67,4 +77,38 @@ private static List normalizeRoleCodes(List rawRoles) { } return List.copyOf(normalized); } + + private static List normalizePermissionKeys(List rawKeys) { + if (rawKeys == null || rawKeys.isEmpty()) { + return List.of(); + } + Set normalized = new LinkedHashSet<>(); + for (String key : rawKeys) { + if (!StringUtils.hasText(key)) { + continue; + } + normalized.add(key.toUpperCase(Locale.ROOT)); + } + if (normalized.isEmpty()) { + return List.of(); + } + return List.copyOf(normalized); + } + + private static List normalizeRoleDisplayNames(List names) { + if (names == null || names.isEmpty()) { + return List.of(); + } + Set normalized = new LinkedHashSet<>(); + for (String name : names) { + if (!StringUtils.hasText(name)) { + continue; + } + normalized.add(name.trim()); + } + if (normalized.isEmpty()) { + return List.of(); + } + return List.copyOf(normalized); + } } diff --git a/src/main/java/apu/saerok_admin/web/view/role/PermissionCatalog.java b/src/main/java/apu/saerok_admin/web/view/role/PermissionCatalog.java new file mode 100644 index 0000000..bb35e36 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/role/PermissionCatalog.java @@ -0,0 +1,32 @@ +package apu.saerok_admin.web.view.role; + +import java.util.List; + +public final class PermissionCatalog { + + private static final List BUILTIN_PERMISSIONS = List.of( + option("ADMIN_LOGIN", "어드민에 로그인"), + option("ADMIN_REPORT_READ", "신고된 콘텐츠 내용 조회"), + option("ADMIN_REPORT_WRITE", "신고된 콘텐츠에 대한 모든 조치"), + option("ADMIN_AUDIT_READ", "관리자 활동 로그 조회"), + option("ADMIN_STAT_READ", "서비스 통계 조회"), + option("ADMIN_STAT_WRITE", "서비스 통계 수동 집계"), + option("ADMIN_AD_READ", "광고, 광고 위치, 광고 스케줄 조회"), + option("ADMIN_AD_WRITE", "광고, 광고 위치, 광고 스케줄 생성/수정/삭제 (단, 광고 위치 삭제는 불가)"), + option("ADMIN_SLOT_DELETE", "광고 위치 삭제"), + option("ADMIN_ROLE_MY_READ", "로그인한 관리자의 역할/권한 조회"), + option("ADMIN_ROLE_READ", "모든 관리자(TEAM_MEMBER 기준)의 역할과 권한 조회"), + option("ADMIN_ROLE_WRITE", "역할 생성/삭제, 권한 편집 및 사용자 역할 부여/회수") + ); + + private PermissionCatalog() { + } + + public static List builtinPermissions() { + return BUILTIN_PERMISSIONS; + } + + private static PermissionOptionView option(String key, String description) { + return new PermissionOptionView(key, description); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/role/PermissionOptionView.java b/src/main/java/apu/saerok_admin/web/view/role/PermissionOptionView.java new file mode 100644 index 0000000..58d8eb3 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/role/PermissionOptionView.java @@ -0,0 +1,26 @@ +package apu.saerok_admin.web.view.role; + +import java.util.Locale; +import org.springframework.util.StringUtils; + +public record PermissionOptionView( + String key, + String description +) { + + public PermissionOptionView { + key = normalizeKey(key); + description = StringUtils.hasText(description) ? description.trim() : ""; + } + + public String label() { + return StringUtils.hasText(description) ? description : key; + } + + private static String normalizeKey(String value) { + if (!StringUtils.hasText(value)) { + return ""; + } + return value.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/role/PermissionView.java b/src/main/java/apu/saerok_admin/web/view/role/PermissionView.java new file mode 100644 index 0000000..0ff967c --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/role/PermissionView.java @@ -0,0 +1,26 @@ +package apu.saerok_admin.web.view.role; + +import java.util.Locale; +import org.springframework.util.StringUtils; + +public record PermissionView( + String key, + String description +) { + + public PermissionView { + key = normalizeKey(key); + description = StringUtils.hasText(description) ? description.trim() : ""; + } + + public String label() { + return StringUtils.hasText(description) ? description : key; + } + + private static String normalizeKey(String value) { + if (!StringUtils.hasText(value)) { + return ""; + } + return value.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/role/RoleDisplay.java b/src/main/java/apu/saerok_admin/web/view/role/RoleDisplay.java new file mode 100644 index 0000000..7203b70 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/role/RoleDisplay.java @@ -0,0 +1,34 @@ +package apu.saerok_admin.web.view.role; + +import java.util.Locale; +import org.springframework.util.StringUtils; + +public record RoleDisplay( + Long id, + String code, + String displayName, + String description, + boolean builtin +) { + + public RoleDisplay { + code = normalizeCode(code); + displayName = StringUtils.hasText(displayName) ? displayName.trim() : code; + description = StringUtils.hasText(description) ? description.trim() : ""; + } + + public String label() { + return StringUtils.hasText(displayName) ? displayName : code; + } + + public boolean hasDescription() { + return StringUtils.hasText(description); + } + + private static String normalizeCode(String value) { + if (!StringUtils.hasText(value)) { + return ""; + } + return value.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/role/RolePermissionGroupView.java b/src/main/java/apu/saerok_admin/web/view/role/RolePermissionGroupView.java new file mode 100644 index 0000000..4815701 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/role/RolePermissionGroupView.java @@ -0,0 +1,17 @@ +package apu.saerok_admin.web.view.role; + +import java.util.List; + +public record RolePermissionGroupView( + RoleDisplay role, + List permissions +) { + + public RolePermissionGroupView { + permissions = permissions == null ? List.of() : List.copyOf(permissions); + } + + public boolean hasPermissions() { + return !permissions.isEmpty(); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/role/RoleTemplateView.java b/src/main/java/apu/saerok_admin/web/view/role/RoleTemplateView.java new file mode 100644 index 0000000..54aec92 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/role/RoleTemplateView.java @@ -0,0 +1,47 @@ +package apu.saerok_admin.web.view.role; + +import java.util.List; +import java.util.Locale; +import org.springframework.util.StringUtils; + +public record RoleTemplateView( + RoleDisplay role, + List permissions +) { + + public RoleTemplateView { + permissions = permissions == null ? List.of() : List.copyOf(permissions); + } + + public Long id() { + return role.id(); + } + + public String code() { + return role.code(); + } + + public String displayName() { + return role.displayName(); + } + + public String description() { + return role.description(); + } + + public boolean builtin() { + return role.builtin(); + } + + public boolean hasPermissions() { + return !permissions.isEmpty(); + } + + public boolean hasPermission(String permissionKey) { + if (!StringUtils.hasText(permissionKey)) { + return false; + } + String normalized = permissionKey.trim().toUpperCase(Locale.ROOT); + return permissions.stream().anyMatch(permission -> normalized.equals(permission.key())); + } +} diff --git a/src/main/java/apu/saerok_admin/web/view/role/TeamMemberView.java b/src/main/java/apu/saerok_admin/web/view/role/TeamMemberView.java new file mode 100644 index 0000000..83b90e8 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/view/role/TeamMemberView.java @@ -0,0 +1,40 @@ +package apu.saerok_admin.web.view.role; + +import java.util.List; +import java.util.Locale; +import org.springframework.util.StringUtils; + +public record TeamMemberView( + Long id, + String nickname, + String email, + boolean superAdmin, + List roles, + List permissions +) { + + public TeamMemberView { + roles = roles == null ? List.of() : List.copyOf(roles); + permissions = permissions == null ? List.of() : List.copyOf(permissions); + } + + public String initials() { + if (!StringUtils.hasText(nickname)) { + return "팀"; + } + String trimmed = nickname.trim(); + return trimmed.substring(0, Math.min(2, trimmed.length())); + } + + public boolean hasRole(String roleCode) { + if (!StringUtils.hasText(roleCode)) { + return false; + } + String normalized = roleCode.trim().toUpperCase(Locale.ROOT); + return roles.stream().anyMatch(role -> normalized.equals(role.code())); + } + + public boolean hasPermissions() { + return !permissions.isEmpty(); + } +} diff --git a/src/main/resources/static/css/admin-theme.css b/src/main/resources/static/css/admin-theme.css index ddde8d9..6b9b8aa 100644 --- a/src/main/resources/static/css/admin-theme.css +++ b/src/main/resources/static/css/admin-theme.css @@ -446,3 +446,520 @@ a:hover, a:focus { color: var(--accent-primary-strong); } display: flex; align-items: center; justify-content: center; color: var(--text-muted); pointer-events: none; } + +/* ===== Role management ===== */ +.role-tabs { + border: 0; + background: rgba(226, 232, 240, 0.6); + padding: 0.4rem; + border-radius: 999px; + display: inline-flex; + gap: 0.25rem; +} + +.role-tabs .nav-link { + border: 0; + border-radius: 999px; + color: var(--text-muted); + font-weight: 500; + padding: 0.55rem 1.4rem; + transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.role-tabs .nav-link:hover { + color: var(--text-primary); +} + +.role-tabs .nav-link.active { + background: linear-gradient(135deg, #6366f1, #22d3ee); + color: #ffffff; + box-shadow: 0 12px 30px rgba(14, 165, 233, 0.35); +} + +.role-tabs .nav-link.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.role-token-grid, +.role-chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.role-token-button, +.role-token { + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: 0.45rem 1.1rem; + background: #fff; + font-size: 0.95rem; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.35rem; + line-height: 1.2; + color: var(--text-primary); +} + +.role-token-button { + cursor: pointer; + background: var(--surface-primary); + border-color: rgba(59, 130, 246, 0.24); + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08); + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.role-token-button:hover, +.role-token-button:focus-visible { + border-color: var(--accent-primary); + box-shadow: 0 12px 30px rgba(59, 130, 246, 0.16); + transform: translateY(-1px); + outline: none; +} + +.role-chip { + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: 0.35rem 0.85rem; + background: var(--surface-muted); + font-size: 0.9rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; + line-height: 1.2; +} + +.role-chip--action { + cursor: pointer; + background: var(--surface-primary); + border-color: rgba(59, 130, 246, 0.24); + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08); + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.role-chip--action:hover, +.role-chip--action:focus-visible { + border-color: var(--accent-primary); + box-shadow: 0 12px 30px rgba(59, 130, 246, 0.16); + transform: translateY(-1px); + outline: none; +} + +.role-chip__cta { + font-size: 0.75rem; + color: var(--accent-primary); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.15rem; +} + +.role-permission-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.role-team-table { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-xl); + border-collapse: separate; + border-spacing: 0; + overflow: hidden; + background: #fff; +} + +.role-team-table thead th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + background: var(--surface-muted); + border-bottom: 1px solid var(--border-subtle); +} + +.role-team-table th, +.role-team-table td { + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; +} + +.role-team-table tbody tr:last-child td { + border-bottom: 0; +} + +.role-team-table tbody tr.table-active { + background: rgba(99, 102, 241, 0.08); +} + +.role-permission-modal__dialog { + max-width: 460px; +} + +.role-permission-modal { + border-radius: var(--radius-xl); + border: none; + box-shadow: var(--shadow-overlay); + overflow: hidden; +} +.role-permission-modal__eyebrow { + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--accent-primary-strong); + margin-bottom: 0.35rem; +} +.role-permission-modal__description { + font-size: 0.95rem; +} +.permission-carousel { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.permission-carousel__viewport { + border: 1px solid rgba(59, 130, 246, 0.25); + border-radius: var(--radius-lg); + padding: 1rem 1.25rem; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(15, 23, 42, 0.03)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4); + min-height: 110px; + display: flex; + align-items: center; +} + +.permission-carousel__slide { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.permission-carousel__label { + font-weight: 600; + font-size: 1rem; +} + +.permission-carousel__key { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + background: rgba(15, 23, 42, 0.05); + border-radius: 999px; + padding: 0.15rem 0.85rem; + align-self: flex-start; +} + +.permission-carousel__controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.permission-carousel__nav { + width: 40px; + height: 40px; + border-radius: 999px; + border: 1px solid var(--border-subtle); + background: #fff; + color: var(--text-primary); + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.permission-carousel__nav:disabled { + opacity: 0.5; + box-shadow: none; +} + +.permission-carousel__nav:not(:disabled):hover, +.permission-carousel__nav:not(:disabled):focus-visible { + transform: translateY(-1px); + box-shadow: 0 12px 30px rgba(59, 130, 246, 0.2); + border-color: rgba(59, 130, 246, 0.3); +} + +.permission-carousel__counter { + flex: 1; + text-align: center; + font-weight: 600; + color: var(--text-muted); +} +.permission-modal-empty { + border: 1px dashed var(--border-subtle); + border-radius: var(--radius-lg); + padding: 1rem; + color: var(--text-muted); + text-align: center; +} +.team-member-grid { + display: grid; + gap: 0.85rem; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); +} +.team-member-card { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 0.75rem; + display: flex; + gap: 0.85rem; + text-decoration: none; + color: inherit; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} +.team-member-card.is-active { + border-color: var(--accent-primary); + box-shadow: var(--shadow-soft); +} +.team-member-card__avatar { + width: 48px; + height: 48px; + border-radius: 999px; + background: var(--surface-muted); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 600; +} +.team-member-card__body { + flex: 1; + min-width: 0; +} +.team-role-card-grid { + display: grid; + gap: 1.25rem; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); +} +.team-role-card { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 1.25rem; + background: var(--surface-primary); + box-shadow: var(--shadow-soft); + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.team-role-card__avatar { + width: 52px; + height: 52px; + border-radius: 999px; + background: var(--surface-muted); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1.1rem; +} +.team-role-card__roles { + min-height: 88px; +} +.role-editor-panel summary { + cursor: pointer; + font-weight: 600; +} +.permission-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); +} +.role-option, +.permission-option { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 0.65rem; + gap: 0.5rem; + align-items: flex-start; +} +.role-template-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.role-template-item { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 0.75rem; + text-decoration: none; + color: inherit; + display: block; +} +.role-template-item.is-active { + border-color: var(--accent-primary); + background: rgba(59, 130, 246, 0.08); +} + +.role-manage-region { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-xl); + padding: 1.5rem; + background: var(--surface-primary); + box-shadow: var(--shadow-soft); + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.role-manage-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.role-manage-eyebrow { + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; + color: var(--accent-primary); +} + +.role-manage-form { + border: 1px dashed var(--border-subtle); + border-radius: var(--radius-lg); + padding: 1.25rem; + background: var(--surface-muted); +} + +.role-manage-columns { + display: grid; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + gap: 1.25rem; +} + +.role-manage-column { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + background: #fff; + padding: 1rem 1.25rem; + min-height: 320px; +} + +.role-manage-column__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.role-manage-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.role-manage-list-item { + border: 1px solid transparent; + border-radius: var(--radius-md); + padding: 0.65rem 0.75rem; + display: flex; + justify-content: space-between; + align-items: center; + text-decoration: none; + color: inherit; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.role-manage-list-item:hover, +.role-manage-list-item.is-active { + border-color: rgba(59, 130, 246, 0.35); + background: rgba(59, 130, 246, 0.08); +} + +.role-manage-column--detail { + padding: 0; +} + +.role-manage-empty { + height: 100%; + min-height: 320px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + text-align: center; + padding: 1.5rem; +} + +.role-manage-detail { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.25rem; +} + +.role-manage-detail__header { + display: flex; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.role-manage-code { + font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace; + font-size: 0.75rem; + letter-spacing: 0.08em; + background: rgba(15, 23, 42, 0.06); + border-radius: 999px; + padding: 0.2rem 0.85rem; + display: inline-flex; + align-items: center; + text-transform: uppercase; +} + +.role-permission-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.role-permission-chip { + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: 0.4rem 0.85rem; + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: var(--surface-muted); +} + +.role-permission-chip__label { + font-weight: 600; +} + +.role-permission-chip__key { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +@media (max-width: 991.98px) { + .role-manage-columns { + grid-template-columns: 1fr; + } + + .role-manage-column { + min-height: auto; + } +} diff --git a/src/main/resources/templates/admin-role/index.html b/src/main/resources/templates/admin-role/index.html new file mode 100644 index 0000000..8b45379 --- /dev/null +++ b/src/main/resources/templates/admin-role/index.html @@ -0,0 +1,315 @@ + + + +
+ + 알림 +
+ +
+ +
+ + +
+
+
+
+
+

내 권한

+
부여된 권한을 눌러 세부 정보를 확인하세요.
+
+ +
+
로드 오류
+
+
+ 아직 부여된 권한이 없습니다. +
+
+
+ + + + + + +
+
+ 팀 멤버 +
+
+ 권한 템플릿을 불러오지 못해 권한 목록을 표시할 수 없습니다. +
+
+
+
+
+
+ +
+
+ 팀원 권한을 확인하려면 ADMIN_ROLE_READ 권한이 필요합니다. +
+
+
+
+
+
+

팀원들의 권한

+
0명
+
+
+
로드 오류
+
+
+ 관리 대상 팀원이 없습니다. +
+
+
+
+
+
+
+
운영자
+ SUPER +
+
0개 권한
+
+ +
+
+ +
+ + + + + + +
+
+ 팀 멤버 +
+
+ 부여된 권한이 없습니다. +
+
+
+
+ 권한이 없는 팀원입니다. +
+
+
+
+
+
+
+
+
+
+
+ 권한 관리 기능은 ADMIN_ROLE_WRITE 권한을 가진 사용자만 사용할 수 있습니다. +
+
+
+
+
+
Role Templates
+

권한 관리

+

권한 구조를 빠르게 파악하고 필요한 권한만 정돈된 화면에서 조정하세요.

+
+
+ 총 0개 + +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
포함할 권한
+
+ 선택할 수 있는 권한 목록을 불러오지 못했습니다. +
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
등록된 권한
+

선택하여 상세 권한을 살펴보세요.

+
+
+
로드 오류
+
+
등록된 권한이 없습니다.
+ +
+
권한 이름
+
ROLE_CODE
+
+ +
+
+
+
+
+ 왼쪽 목록에서 편집할 권한을 선택하세요. +
+
+
+
+
선택한 권한
+

권한 이름

+

권한 설명

+ ROLE_CODE +
+ 시스템 내장 +
+
+
권한
+
+ + 권한 + KEY + +
+
아직 연결된 권한이 없습니다.
+
+
+
권한 편집
+
+ 선택 가능한 권한 목록이 없어 편집할 수 없습니다. +
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+ 시스템 내장 권한은 삭제할 수 없습니다. +
+
+
+
+
+
+
+
+ +
+ diff --git a/src/main/resources/templates/admin-role/team-member-edit.html b/src/main/resources/templates/admin-role/team-member-edit.html new file mode 100644 index 0000000..c6e2584 --- /dev/null +++ b/src/main/resources/templates/admin-role/team-member-edit.html @@ -0,0 +1,121 @@ + + + +
+
+

권한 편집

+
+
+
현재 이메일
+
email@example.com
+
+
+ +
+ + 알림 +
+ + + +
+
+
+
+
+ +
+
+
+

운영자

+ SUPER +
+
email@example.com
+
+
+
+
보유 권한
+
+
+
+
+
+ + + + + + +
+
+ 팀 멤버 +
+
+ 부여된 권한이 없습니다. +
+
+
+
+ +
+
+
+
+

권한 편집

+
체크박스를 선택해 권한을 부여하거나 해제하세요.
+
+ 팀원 설정 +
+
로드 오류
+
+
+
+ +
+
+
체크 변경 즉시 저장 버튼을 눌러 반영합니다.
+ +
+
+
+
+
권한 편집 권한이 없어 구성을 변경할 수 없습니다.
+
편집 가능한 권한 템플릿이 없습니다.
+
+
+
+ + +
+ diff --git a/src/main/resources/templates/ads/list.html b/src/main/resources/templates/ads/list.html index e45fbfc..f1b955a 100644 --- a/src/main/resources/templates/ads/list.html +++ b/src/main/resources/templates/ads/list.html @@ -67,7 +67,7 @@

광고

광고 목록

-
+ -
+
수정
@@ -162,16 +162,19 @@

HOME_TOP

광고 위치 설명

-
+
새 스케줄 등록 광고 위치 설정 - +
+ th:if="${currentAdminProfile.hasPermission('ADMIN_AD_WRITE') && group.slotId() != null && group.slotCode() != null}">
-
+
@@ -290,7 +293,7 @@

HOME_TOP

-
+