변경/해지 신청 개선 및 사전검사 추가
- 변경/해지 상태 표시 및 취소 기능 추가 - 해지 신청 사전검사 및 경고 메시지 처리 구현 - 관리자 승인 이전 허용되는 취소 정책 지원
This commit is contained in:
+10
-1
@@ -129,7 +129,16 @@ bootRun {
|
||||
// jvmArgs '-Xdebug', '-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005'
|
||||
|
||||
sourceResources sourceSets.main // processResources 필터 우회 (dev yml 직접 참조)
|
||||
args = ["--spring.profiles.active=dev"]
|
||||
|
||||
// 로컬 기동 기본 프로파일.
|
||||
// dev/stage/prod 는 리버스프록시(OHS) 뒤 WAR 배포용이라 server.forward-headers-strategy=framework 가
|
||||
// 걸려 있다. framework 는 신뢰 프록시 목록 없이 X-Forwarded-* 를 그대로 신뢰하므로 앞단이 없는
|
||||
// 로컬 기동에는 쓰지 않는다(로컬은 공통 기본값 native = Tomcat RemoteIpValve, 사설대역만 신뢰).
|
||||
// 다른 프로파일로 띄우려면: gradle bootRun -PbootProfile=dev
|
||||
// ('profile' 이 아니라 'bootProfile' 인 이유: 위 ext 블록이 profile='local' 을 이미 점유하고 있어
|
||||
// findProperty('profile') 은 -P 지정 여부와 무관하게 항상 'local' 을 돌려준다.)
|
||||
def bootRunProfile = (project.findProperty('bootProfile') ?: 'local_rinjaemac').toString()
|
||||
args = ["--spring.profiles.active=" + bootRunProfile]
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import com.eactive.apim.portal.file.service.FileTypeDetector;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import javax.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -102,8 +104,28 @@ public class MyAppController {
|
||||
List<AppRequest> appRequests = appServiceFacade.getPendingApiKeyList(user.getPortalOrg());
|
||||
List<ClientDTO> apikeys = appServiceFacade.getApikeyList(user.getPortalOrg());
|
||||
|
||||
model.addAttribute("appRequests", appRequests);
|
||||
// 기존 클라이언트에 걸린 변경/해지 신청은 별도 카드로 내지 않고 해당 클라이언트 카드의 배지로 흡수한다.
|
||||
// (같은 이름의 카드가 둘로 보여 클라이언트가 두 개인 것처럼 읽히기 때문)
|
||||
// 신규(NEW) 신청처럼 아직 클라이언트가 없는 건만 신청 카드로 남긴다.
|
||||
Set<String> ownedClientIds = apikeys.stream()
|
||||
.map(ClientDTO::getClientid)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<String, AppRequest> openRequestByClient = new java.util.HashMap<>();
|
||||
List<AppRequest> standaloneRequests = new java.util.ArrayList<>();
|
||||
for (AppRequest request : appRequests) {
|
||||
String clientId = request.getClientId();
|
||||
if (StringUtils.isNotBlank(clientId) && ownedClientIds.contains(clientId)
|
||||
&& openRequestByClient.putIfAbsent(clientId, request) == null) {
|
||||
continue;
|
||||
}
|
||||
standaloneRequests.add(request);
|
||||
}
|
||||
|
||||
model.addAttribute("appRequests", standaloneRequests);
|
||||
model.addAttribute("apiKeys", apikeys);
|
||||
model.addAttribute("openRequestByClient", openRequestByClient);
|
||||
return new ModelAndView(API_KEY_LIST);
|
||||
}
|
||||
|
||||
@@ -184,6 +206,10 @@ public class MyAppController {
|
||||
model.addAttribute("apiKey", apiKey);
|
||||
model.addAttribute("secretAvailable", secretAvailable);
|
||||
model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id));
|
||||
// 해지 불가 사유(변경 신청 진행 중 등) — 클릭 시 사전검사가 이 사유를 안내하고 2FA 로 넘어가지 않는다
|
||||
model.addAttribute("deleteBlockReason", appServiceFacade.resolveDeleteBlockReason(id));
|
||||
// 걸려 있는 미완료 변경/해지 신청 — 상태 표시 + 취소 버튼용(결재 미개시 건도 포함)
|
||||
model.addAttribute("openRequest", appServiceFacade.findOpenRequest(id));
|
||||
|
||||
return new ModelAndView(CREDENTIAL_DETAIL);
|
||||
}
|
||||
@@ -247,6 +273,36 @@ public class MyAppController {
|
||||
* @param requestData 요청 데이터 (clientId, reason)
|
||||
* @return 성공/실패 결과를 담은 Map
|
||||
*/
|
||||
/**
|
||||
* 해지 신청 사전검사. 진행 중인 변경/해지 결재가 있어 신청이 불가한지 알려줍니다.
|
||||
*
|
||||
* <p>step-up 2FA 가 걸린 {@code /clients/api_key_delete} <b>이전</b>에 호출해,
|
||||
* 어차피 거절될 요청으로 2FA 를 반복 유도하지 않도록 한다(2FA 가드 대상 경로가 아니다).
|
||||
* 최종 판정은 {@code createDeleteRequest} 가 다시 수행하므로 이 검사는 안내 목적이다.</p>
|
||||
*/
|
||||
@GetMapping("/credential/delete-precheck")
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
@ResponseBody
|
||||
public Map<String, Object> deleteApiKeyPrecheck(@RequestParam("clientId") String clientId) {
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
String orgId = user.getPortalOrg().getId();
|
||||
|
||||
// 소유권 확인 (다른 조직 인증키의 결재 진행 상태 노출 방지)
|
||||
if (clientId == null || clientId.trim().isEmpty()
|
||||
|| appServiceFacade.getApiKey(orgId, clientId) == null) {
|
||||
result.put("blocked", true);
|
||||
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
String blockReason = appServiceFacade.resolveDeleteBlockReason(clientId);
|
||||
result.put("blocked", blockReason != null);
|
||||
result.put("msg", blockReason);
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping("/api_key_delete")
|
||||
@Secured("ROLE_API_KEY_REQUEST")
|
||||
@ResponseBody
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||
import com.eactive.apim.portal.apprequest.entity.AppRequestType;
|
||||
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
|
||||
import com.eactive.apim.portal.approval.entity.Approval;
|
||||
import com.eactive.apim.portal.approval.statemachine.CreatedState;
|
||||
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
|
||||
import com.eactive.apim.portal.approval.statemachine.RequestedState;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
@@ -169,21 +170,9 @@ public class AppServiceFacade {
|
||||
*/
|
||||
public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) {
|
||||
// 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드
|
||||
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
|
||||
for (AppRequest r : related) {
|
||||
if (r.getApproval() == null) {
|
||||
continue;
|
||||
}
|
||||
boolean inProgress = r.getApproval().getApprovalStatus() instanceof RequestedState
|
||||
|| r.getApproval().getApprovalStatus() instanceof ProcessingState;
|
||||
if (!inProgress) {
|
||||
continue;
|
||||
}
|
||||
if (AppRequestType.DELETE.equals(r.getType())) {
|
||||
throw new IllegalStateException("이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.");
|
||||
}
|
||||
throw new IllegalStateException("해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.");
|
||||
String blockReason = resolveDeleteBlockReason(clientId);
|
||||
if (blockReason != null) {
|
||||
throw new IllegalStateException(blockReason);
|
||||
}
|
||||
|
||||
// 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다)
|
||||
@@ -203,6 +192,58 @@ public class AppServiceFacade {
|
||||
beginApproval(saved.getApproval().getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 해지 신청을 막는 사유가 있으면 안내 메시지를, 없으면 {@code null} 을 반환합니다.
|
||||
*
|
||||
* <p>미완료 해지·변경 신청이 있으면 해지를 받을 수 없다. {@link #createDeleteRequest} 의 최종 가드와
|
||||
* 화면/사전검사가 <b>같은 판정</b>({@link #findOpenRequestEntity})을 쓰도록 이 메서드 하나로 모은다.
|
||||
* (사전검사가 없으면 사용자가 step-up 2FA 를 통과한 뒤에야 차단 사실을 알게 되고,
|
||||
* 재시도할 때마다 2FA 가 반복된다.)</p>
|
||||
*/
|
||||
public String resolveDeleteBlockReason(String clientId) {
|
||||
AppRequest open = findOpenRequestEntity(clientId);
|
||||
if (open == null) {
|
||||
return null;
|
||||
}
|
||||
if (AppRequestType.DELETE.equals(open.getType())) {
|
||||
return "이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.";
|
||||
}
|
||||
return "해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 인증키에 걸려 있는 <b>미완료</b> 변경/해지 신청 1건. 없으면 {@code null}.
|
||||
* 상세 화면에서 진행 상태를 표시하고 신청을 취소할 수 있게 하는 데 사용한다.
|
||||
*/
|
||||
public AppRequestDTO findOpenRequest(String clientId) {
|
||||
AppRequest open = findOpenRequestEntity(clientId);
|
||||
return open == null ? null : appRequestMapper.toVo(open);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미완료(=결재가 끝나지 않은) 변경/해지 신청 1건.
|
||||
*
|
||||
* <p>결재가 개시되지 않아 {@code approval} 이 없는 신청도 포함한다. 이런 건은 관리자 결재함에
|
||||
* 뜨지 않아 사용자가 상태를 알 수도, 정리할 수도 없는 채로 남아 이후 신청을 계속 막는다.
|
||||
* 화면에 노출해 취소할 수 있게 하려면 여기서 잡아야 한다.</p>
|
||||
*/
|
||||
private AppRequest findOpenRequestEntity(String clientId) {
|
||||
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
|
||||
for (AppRequest r : related) {
|
||||
if (r.getApproval() == null) {
|
||||
return r;
|
||||
}
|
||||
Object status = r.getApproval().getApprovalStatus();
|
||||
if (status instanceof CreatedState
|
||||
|| status instanceof RequestedState
|
||||
|| status instanceof ProcessingState) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다.
|
||||
* 상세 화면의 해지 버튼 비활성화에 사용됩니다.
|
||||
|
||||
@@ -51,9 +51,14 @@
|
||||
|
||||
<!-- App Info -->
|
||||
<div class="app-card-info">
|
||||
<div class="app-card-header">
|
||||
<div class="app-card-header"
|
||||
th:with="openReq=${openRequestByClient != null ? openRequestByClient.get(apikey.clientid) : null}">
|
||||
<!-- App Name -->
|
||||
<h3 class="app-card-title" th:text="${apikey.clientname}">앱 이름</h3>
|
||||
<!-- 진행 중인 해지/변경 신청 배지 — 클라이언트 자체 상태(승인)만으로는 결재 진행이 드러나지 않는다 -->
|
||||
<span class="app-card-badge badge-requested" th:if="${openReq != null}"
|
||||
th:text="|${openReq.type != null and openReq.type.name() == 'DELETE' ? '해지' : '변경'} 신청 ${openReq.approval != null and openReq.approval.approvalStatus != null ? openReq.approval.approvalStatus.description : '접수'}|">해지
|
||||
신청 진행중</span>
|
||||
<!-- Status Badge -->
|
||||
<span class="app-card-badge"
|
||||
th:classappend="${apikey.appstatus == '1' ? 'badge-approved' : apikey.appstatus == '0' ? 'badge-inactive' : 'badge-pending'}"
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 내부 결재 진행중: 취소 불가 안내 -->
|
||||
<!-- 심사중 안내. 결재자가 아직 아무도 승인하지 않았으면 취소되고, 승인 이력이 있으면 서버가 거절한다 -->
|
||||
<div class="info-notice-box danger" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
|
||||
appRequest.approval.approvalStatus.toString() == 'PROCESSING'}">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f4253c"
|
||||
@@ -218,7 +218,7 @@
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<p>내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.</p>
|
||||
<p>관리자가 심사 중인 신청입니다. 결재자가 이미 승인한 경우에는 취소되지 않으며, 이때는 관리자에게 문의해 주세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation Actions -->
|
||||
@@ -226,7 +226,8 @@
|
||||
<!-- Cancel Request Button (danger red) -->
|
||||
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
|
||||
th:if="${appRequest.approval == null or (appRequest.approval.approvalStatus != null and
|
||||
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED'))}"
|
||||
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED'
|
||||
or appRequest.approval.approvalStatus.toString() == 'CREATED' or appRequest.approval.approvalStatus.toString() == 'PROCESSING'))}"
|
||||
th:data-request-id="${appRequest.id}" onclick="cancelRequestById(this)">
|
||||
신청 취소
|
||||
</button>
|
||||
|
||||
@@ -186,13 +186,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 걸려 있는 변경/해지 신청 안내. 결재가 개시되지 않아 관리자 결재함에 뜨지 않는 신청도 여기서 보인다 -->
|
||||
<div class="info-notice-box" th:if="${openRequest != null}" style="margin-top: 24px;">
|
||||
<p>
|
||||
<span
|
||||
th:text="|${openRequest.type != null and openRequest.type.name() == 'DELETE' ? '해지' : '변경'} 신청이 접수되어 있습니다. (상태: ${openRequest.approval == null ? '결재 미개시' : openRequest.approval.approvalStatus.description})|">신청이
|
||||
접수되어 있습니다.</span>
|
||||
<a th:href="@{/clients/app_request_detail(id=${openRequest.id})}"
|
||||
style="margin-left: 8px; text-decoration: underline;">신청 상세 보기</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation Actions -->
|
||||
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
|
||||
<a th:href="@{/clients}" class="dt-btn-gray">목록</a>
|
||||
<!-- 진행 중 신청 취소. 결재 미개시 건도 대상이라 사용자가 직접 정리할 수 있다 -->
|
||||
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
|
||||
th:if="${openRequest != null}" th:data-request-id="${openRequest.id}"
|
||||
onclick="cancelOpenRequest(this)"
|
||||
th:text="${openRequest.type != null and openRequest.type.name() == 'DELETE'} ? '해지 신청 취소' : '변경 신청 취소'">해지
|
||||
신청 취소</button>
|
||||
<!-- 해지 신청 중이면 비활성(라벨로 상태 표시). 그 외 불가 사유(변경 결재 진행 중 등)는
|
||||
버튼을 살려두고 클릭 시 사전검사가 사유를 안내한다 — 눌러도 반응 없는 화면을 만들지 않기 위함 -->
|
||||
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
|
||||
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)"
|
||||
th:disabled="${pendingDeleteRequest}"
|
||||
th:text="${pendingDeleteRequest} ? '해지 승인 대기중' : 'API 이용 해지'">API 이용 해지</button>
|
||||
th:disabled="${pendingDeleteRequest}" th:title="${deleteBlockReason}"
|
||||
th:text="${pendingDeleteRequest} ? '해지 승인 대기중' : '이용 해지 신청'">이용 해지 신청</button>
|
||||
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:unless="${pendingDeleteRequest}" class="dt-btn-blue"
|
||||
th:href="@{/clients/modify/step1(clientId=${apiKey.clientid})}">변경 신청</a>
|
||||
</div>
|
||||
@@ -297,19 +316,74 @@
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
// API 이용 해지 신청 진입 - 경고 + 사유 모달 (본인 확인은 step-up 2FA가 담당)
|
||||
// API 이용 해지 신청 진입 - 사전검사(진행 중 결재 확인) 후 경고 + 사유 모달
|
||||
// (본인 확인은 step-up 2FA가 담당. 사전검사를 먼저 하는 이유: 어차피 거절될 요청으로
|
||||
// 2FA를 통과시킨 뒤 실패시키면, 재시도할 때마다 2FA가 반복된다)
|
||||
function deleteApiKeyFromButton(button) {
|
||||
var clientId = $(button).data('client-id');
|
||||
|
||||
customPopups.showTerminateRequest({
|
||||
onConfirm: function (reason) {
|
||||
doDeleteApiKey(clientId, reason);
|
||||
$.ajax({
|
||||
url: /*[[@{/clients/credential/delete-precheck}]]*/ '/clients/credential/delete-precheck',
|
||||
type: 'GET',
|
||||
data: { clientId: clientId }
|
||||
}).done(function (res) {
|
||||
if (res && res.blocked) {
|
||||
customPopups.showAlert(res.msg || '현재 해지를 신청할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
customPopups.showTerminateRequest({
|
||||
onConfirm: function (reason) {
|
||||
doDeleteApiKey(clientId, reason);
|
||||
}
|
||||
});
|
||||
}).fail(function () {
|
||||
customPopups.showAlert('해지 가능 여부 확인 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.');
|
||||
});
|
||||
}
|
||||
|
||||
// 진행 중 변경/해지 신청 취소 — 결재가 개시되지 않아 관리자 결재함에 뜨지 않는 신청도 여기서 정리한다
|
||||
function cancelOpenRequest(button) {
|
||||
var requestId = $(button).data('request-id');
|
||||
|
||||
customPopups.showConfirm('진행 중인 신청을 취소하시겠습니까?', function (ok) {
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
$('.loading-overlay').show();
|
||||
$.ajax({
|
||||
url: /*[[@{/clients/api_key_request/cancel}]]*/ '/clients/api_key_request/cancel',
|
||||
type: 'POST',
|
||||
data: { id: requestId },
|
||||
dataType: 'json',
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function (response) {
|
||||
if (response && response.success) {
|
||||
customPopups.showAlert(response.message || '신청이 취소되었습니다.', function () {
|
||||
window.location.reload();
|
||||
});
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert((response && response.message) || '신청 취소 중 오류가 발생했습니다.');
|
||||
}).fail(function () {
|
||||
customPopups.showAlert('신청 취소 중 오류가 발생했습니다.');
|
||||
}).always(function () {
|
||||
$('.loading-overlay').hide();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 해지 신청 진행 중 플래그 — 중복 클릭 시 두 번째 요청이 통과권(1회용) 없이 나가
|
||||
// 2FA가 다시 뜨는 것을 막는다
|
||||
var deleteRequestInFlight = false;
|
||||
|
||||
// 해지 신청 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
||||
function doDeleteApiKey(clientId, reason) {
|
||||
if (deleteRequestInFlight) {
|
||||
return;
|
||||
}
|
||||
deleteRequestInFlight = true;
|
||||
$('.loading-overlay').show();
|
||||
|
||||
$.ajax({
|
||||
@@ -322,7 +396,10 @@
|
||||
}
|
||||
}).done(function (response) {
|
||||
if (response && response.success === false) {
|
||||
customPopups.showTerminateError(response.msg || '해지 신청에 실패했습니다.');
|
||||
// 실패 사유(결재 진행 중·승인라인 미등록 등)는 재시도해도 그대로다.
|
||||
// 모달을 열어둔 채 인라인 문구만 보여주면 사용자가 다시 확인을 눌러 2FA만 반복되므로 닫고 안내한다.
|
||||
customPopups.hideTerminateRequest();
|
||||
customPopups.showAlert(response.msg || '해지 신청에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
customPopups.hideTerminateRequest();
|
||||
@@ -336,6 +413,7 @@
|
||||
}
|
||||
customPopups.showTerminateError('해지 신청 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
}).always(function () {
|
||||
deleteRequestInFlight = false;
|
||||
$('.loading-overlay').hide();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user