diff --git a/console-webapp/src/app/domains/domainList.component.html b/console-webapp/src/app/domains/domainList.component.html index 4961d4601..d2c1d8900 100644 --- a/console-webapp/src/app/domains/domainList.component.html +++ b/console-webapp/src/app/domains/domainList.component.html @@ -24,7 +24,11 @@ } @else { - + + +
more_horiz diff --git a/console-webapp/src/app/domains/domainList.component.ts b/console-webapp/src/app/domains/domainList.component.ts index 8089cfaf0..4f8eefe81 100644 --- a/console-webapp/src/app/domains/domainList.component.ts +++ b/console-webapp/src/app/domains/domainList.component.ts @@ -14,13 +14,17 @@ import { SelectionModel } from '@angular/cdk/collections'; import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http'; -import { Component, ViewChild, effect, Inject } from '@angular/core'; +import { Component, effect, Inject, ViewChild } from '@angular/core'; import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTableDataSource } from '@angular/material/table'; -import { Subject, debounceTime, take, filter } from 'rxjs'; +import { debounceTime, filter, Subject, take } from 'rxjs'; import { RegistrarService } from '../registrar/registrar.service'; -import { Domain, DomainListService } from './domainList.service'; +import { + BULK_ACTION_NAME, + Domain, + DomainListService, +} from './domainList.service'; import { RegistryLockComponent } from './registryLock.component'; import { RegistryLockService } from './registryLock.service'; import { @@ -62,6 +66,12 @@ export class ResponseDialogComponent { } } +enum Operation { + deleting = 'deleting', + suspending = 'suspending', + unsuspending = 'unsuspending', +} + @Component({ selector: 'app-reason-dialog', template: ` @@ -75,8 +85,8 @@ export class ResponseDialogComponent { - `, @@ -84,14 +94,13 @@ export class ResponseDialogComponent { }) export class ReasonDialogComponent { reason: string = ''; - constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) - public data: { operation: 'deleting' | 'suspending' } + public data: { operation: Operation } ) {} - onDelete(): void { + onSave(): void { this.dialogRef.close(this.reason); } @@ -108,6 +117,13 @@ export class ReasonDialogComponent { }) export class DomainListComponent { public static PATH = 'domain-list'; + private static SUSPENDED_STATUSES = [ + 'SERVER_RENEW_PROHIBITED', + 'SERVER_TRANSFER_PROHIBITED', + 'SERVER_UPDATE_PROHIBITED', + 'SERVER_DELETE_PROHIBITED', + 'SERVER_HOLD', + ]; private readonly DEBOUNCE_MS = 500; isAllSelected = false; @@ -258,19 +274,30 @@ export class DomainListComponent { return RESTRICTED_ELEMENTS.BULK_DELETE; } + getElementIdForSuspendUnsuspend() { + return RESTRICTED_ELEMENTS.SUSPEND; + } + getOperationMessage(domain: string) { if (this.operationResult && this.operationResult[domain]) return this.operationResult[domain].message; return ''; } + isDomainUnsuspendable(domain: Domain) { + return DomainListComponent.SUSPENDED_STATUSES.every((s) => + domain.statuses.includes(s) + ); + } + sendDeleteRequest(reason: string) { this.isLoading = true; this.domainListService - .deleteDomains( - this.selection.selected, + .bulkDomainAction( + this.selection.selected.map((d) => d.domainName), reason, - this.registrarService.registrarId() + this.registrarService.registrarId(), + BULK_ACTION_NAME.DELETE ) .pipe(take(1)) .subscribe({ @@ -294,15 +321,17 @@ export class DomainListComponent { this.operationResult = result; this.reloadData(); }, - error: (err: HttpErrorResponse) => - this._snackBar.open(err.error || err.message), + error: (err: HttpErrorResponse) => { + this.isLoading = false; + this._snackBar.open(err.error || err.message); + }, }); } deleteSelectedDomains() { const dialogRef = this.dialog.open(ReasonDialogComponent, { data: { - operation: 'deleting', + operation: Operation.deleting, }, }); @@ -314,4 +343,77 @@ export class DomainListComponent { ) .subscribe(this.sendDeleteRequest.bind(this)); } + + sendSuspendUnsuspendRequest( + domainName: string, + reason: string, + actionName: BULK_ACTION_NAME + ) { + this.isLoading = true; + this.domainListService + .bulkDomainAction( + [domainName], + reason, + this.registrarService.registrarId(), + actionName + ) + .pipe(take(1)) + .subscribe({ + next: (result: DomainData) => { + this.isLoading = false; + if (result[domainName].responseCode.toString().startsWith('2')) { + this._snackBar.open(result[domainName].message); + } else { + this.reloadData(); + } + }, + error: (err: HttpErrorResponse) => { + this.isLoading = false; + this._snackBar.open(err.error || err.message); + }, + }); + } + onSuspendClick(domainName: string) { + const dialogRef = this.dialog.open(ReasonDialogComponent, { + data: { + operation: Operation.suspending, + }, + }); + + dialogRef + .afterClosed() + .pipe( + take(1), + filter((reason) => !!reason) + ) + .subscribe((reason) => { + this.sendSuspendUnsuspendRequest( + domainName, + reason, + BULK_ACTION_NAME.SUSPEND + ); + }); + } + + onUnsuspendClick(domainName: string) { + const dialogRef = this.dialog.open(ReasonDialogComponent, { + data: { + operation: Operation.unsuspending, + }, + }); + + dialogRef + .afterClosed() + .pipe( + take(1), + filter((reason) => !!reason) + ) + .subscribe((reason) => { + this.sendSuspendUnsuspendRequest( + domainName, + reason, + BULK_ACTION_NAME.UNSUSPEND + ); + }); + } } diff --git a/console-webapp/src/app/domains/domainList.service.ts b/console-webapp/src/app/domains/domainList.service.ts index 6e86cbfbc..d277e0f58 100644 --- a/console-webapp/src/app/domains/domainList.service.ts +++ b/console-webapp/src/app/domains/domainList.service.ts @@ -35,6 +35,12 @@ export interface DomainListResult { totalResults: number; } +export enum BULK_ACTION_NAME { + DELETE = 'DELETE', + SUSPEND = 'SUSPEND', + UNSUSPEND = 'UNSUSPEND', +} + @Injectable({ providedIn: 'root', }) @@ -71,11 +77,16 @@ export class DomainListService { ); } - deleteDomains(domains: Domain[], reason: string, registrarId: string) { + bulkDomainAction( + domains: string[], + reason: string, + registrarId: string, + actionName: BULK_ACTION_NAME + ) { return this.backendService.bulkDomainAction( - domains.map((d) => d.domainName), + domains, reason, - 'DELETE', + actionName, registrarId ); } diff --git a/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts b/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts index af4cbebc3..17ed60583 100644 --- a/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts +++ b/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts @@ -20,6 +20,7 @@ export enum RESTRICTED_ELEMENTS { OTE, USERS, BULK_DELETE, + SUSPEND, } export const DISABLED_ELEMENTS_PER_ROLE = { @@ -28,6 +29,7 @@ export const DISABLED_ELEMENTS_PER_ROLE = { RESTRICTED_ELEMENTS.OTE, RESTRICTED_ELEMENTS.USERS, RESTRICTED_ELEMENTS.BULK_DELETE, + RESTRICTED_ELEMENTS.SUSPEND, ], SUPPORT_LEAD: [RESTRICTED_ELEMENTS.USERS], SUPPORT_AGENT: [RESTRICTED_ELEMENTS.USERS], diff --git a/core/src/main/java/google/registry/ui/server/console/domains/ConsoleBulkDomainUnsuspendActionType.java b/core/src/main/java/google/registry/ui/server/console/domains/ConsoleBulkDomainUnsuspendActionType.java new file mode 100644 index 000000000..0759f05b1 --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/console/domains/ConsoleBulkDomainUnsuspendActionType.java @@ -0,0 +1,71 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.ui.server.console.domains; + +import com.google.common.collect.ImmutableMap; +import com.google.gson.JsonElement; +import google.registry.model.console.ConsolePermission; + +/** An action that will unsuspend the given domain, removing all 5 server*Prohibited statuses. */ +public class ConsoleBulkDomainUnsuspendActionType implements ConsoleDomainActionType { + + private static final String DOMAIN_SUSPEND_XML = + """ + + + + + + %DOMAIN_NAME% + + + + + + + + + + + + + Console unsuspension: %REASON% + false + + + RegistryConsole + +"""; + + private final String reason; + + public ConsoleBulkDomainUnsuspendActionType(JsonElement jsonElement) { + this.reason = jsonElement.getAsJsonObject().get("reason").getAsString(); + } + + @Override + public String getXmlContentsToRun(String domainName) { + return ConsoleDomainActionType.fillSubstitutions( + DOMAIN_SUSPEND_XML, ImmutableMap.of("DOMAIN_NAME", domainName, "REASON", reason)); + } + + @Override + public ConsolePermission getNecessaryPermission() { + return ConsolePermission.SUSPEND_DOMAIN; + } +} diff --git a/core/src/main/java/google/registry/ui/server/console/domains/ConsoleDomainActionType.java b/core/src/main/java/google/registry/ui/server/console/domains/ConsoleDomainActionType.java index 0f49c11a7..35e4e7017 100644 --- a/core/src/main/java/google/registry/ui/server/console/domains/ConsoleDomainActionType.java +++ b/core/src/main/java/google/registry/ui/server/console/domains/ConsoleDomainActionType.java @@ -31,7 +31,8 @@ public interface ConsoleDomainActionType { enum BulkAction { DELETE(ConsoleBulkDomainDeleteActionType.class), - SUSPEND(ConsoleBulkDomainSuspendActionType.class); + SUSPEND(ConsoleBulkDomainSuspendActionType.class), + UNSUSPEND(ConsoleBulkDomainUnsuspendActionType.class); private final Class actionClass; diff --git a/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java b/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java index a299c95cc..e808d948e 100644 --- a/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java @@ -30,6 +30,7 @@ import static org.mockito.Mockito.when; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedMap; import com.google.gson.Gson; import com.google.gson.JsonElement; @@ -61,6 +62,14 @@ public class ConsoleBulkDomainActionTest { private static final Gson GSON = GsonUtils.provideGson(); + private static ImmutableSet serverSuspensionStatuses = + ImmutableSet.of( + StatusValue.SERVER_RENEW_PROHIBITED, + StatusValue.SERVER_TRANSFER_PROHIBITED, + StatusValue.SERVER_UPDATE_PROHIBITED, + StatusValue.SERVER_DELETE_PROHIBITED, + StatusValue.SERVER_HOLD); + private final FakeClock clock = new FakeClock(DateTime.parse("2024-05-13T00:00:00.000Z")); @RegisterExtension @@ -135,12 +144,34 @@ public class ConsoleBulkDomainActionTest { """ {"example.tld":{"message":"Command completed successfully","responseCode":1000}}"""); assertThat(loadByEntity(domain).getStatusValues()) - .containsAtLeast( - StatusValue.SERVER_RENEW_PROHIBITED, - StatusValue.SERVER_TRANSFER_PROHIBITED, - StatusValue.SERVER_UPDATE_PROHIBITED, - StatusValue.SERVER_DELETE_PROHIBITED, - StatusValue.SERVER_HOLD); + .containsAtLeastElementsIn(serverSuspensionStatuses); + } + + @Test + void testSuccess_unsuspend() throws Exception { + User adminUser = + persistResource( + new User.Builder() + .setEmailAddress("email@email.com") + .setUserRoles( + new UserRoles.Builder().setGlobalRole(GlobalRole.FTE).setIsAdmin(true).build()) + .build()); + persistResource(domain.asBuilder().addStatusValues(serverSuspensionStatuses).build()); + ConsoleBulkDomainAction action = + createAction( + "UNSUSPEND", + GSON.toJsonTree( + ImmutableMap.of("domainList", ImmutableList.of("example.tld"), "reason", "test")), + adminUser); + assertThat(loadByEntity(domain).getStatusValues()) + .containsAtLeastElementsIn(serverSuspensionStatuses); + action.run(); + assertThat(fakeResponse.getStatus()).isEqualTo(SC_OK); + assertThat(fakeResponse.getPayload()) + .isEqualTo( + """ + {"example.tld":{"message":"Command completed successfully","responseCode":1000}}"""); + assertThat(loadByEntity(domain).getStatusValues()).containsNoneIn(serverSuspensionStatuses); } @Test diff --git a/core/src/test/resources/google/registry/webdriver/goldens/chrome-linux/ConsoleScreenshotTest_dums_mainPage_actionsButtonClicked.png b/core/src/test/resources/google/registry/webdriver/goldens/chrome-linux/ConsoleScreenshotTest_dums_mainPage_actionsButtonClicked.png index 3bc1d166d..24ae8ca93 100644 Binary files a/core/src/test/resources/google/registry/webdriver/goldens/chrome-linux/ConsoleScreenshotTest_dums_mainPage_actionsButtonClicked.png and b/core/src/test/resources/google/registry/webdriver/goldens/chrome-linux/ConsoleScreenshotTest_dums_mainPage_actionsButtonClicked.png differ