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 extends ConsoleDomainActionType> 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