From c5a39bccc52440b7e5ac1eac72ab7d2016ae7d9e Mon Sep 17 00:00:00 2001 From: Pavlo Tkach <3469726+ptkach@users.noreply.github.com> Date: Mon, 12 May 2025 16:14:56 -0400 Subject: [PATCH] Add Console POC reminder front-end (#2754) --- console-webapp/src/app/app.component.spec.ts | 108 ++++++++++++++++-- console-webapp/src/app/app.component.ts | 28 ++++- console-webapp/src/app/app.module.ts | 2 + .../app/navigation/navigation.component.ts | 2 +- .../src/app/registrar/registrar.service.ts | 1 + .../pocReminder/pocReminder.component.html | 14 +++ .../pocReminder/pocReminder.component.scss | 5 + .../pocReminder/pocReminder.component.ts | 53 +++++++++ .../console/ConsoleUpdateRegistrarAction.java | 35 ++++-- .../ConsoleUpdateRegistrarActionTest.java | 49 ++++++-- .../webdriver/ConsoleScreenshotTest.java | 8 ++ 11 files changed, 273 insertions(+), 32 deletions(-) create mode 100644 console-webapp/src/app/shared/components/pocReminder/pocReminder.component.html create mode 100644 console-webapp/src/app/shared/components/pocReminder/pocReminder.component.scss create mode 100644 console-webapp/src/app/shared/components/pocReminder/pocReminder.component.ts diff --git a/console-webapp/src/app/app.component.spec.ts b/console-webapp/src/app/app.component.spec.ts index 235724aea..ed224c7bd 100644 --- a/console-webapp/src/app/app.component.spec.ts +++ b/console-webapp/src/app/app.component.spec.ts @@ -14,30 +14,71 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { AppComponent } from './app.component'; -import { MaterialModule } from './material.module'; -import { BackendService } from './shared/services/backend.service'; -import { AppRoutingModule } from './app-routing.module'; +import { routes } from './app-routing.module'; import { AppModule } from './app.module'; +import { PocReminderComponent } from './shared/components/pocReminder/pocReminder.component'; +import { RouterModule } from '@angular/router'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { UserData, UserDataService } from './shared/services/userData.service'; +import { Registrar, RegistrarService } from './registrar/registrar.service'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { signal, WritableSignal } from '@angular/core'; describe('AppComponent', () => { + let component: AppComponent; + let fixture: ComponentFixture; + let mockRegistrarService: { + registrar: WritableSignal | null | undefined>; + registrarId: WritableSignal; + registrars: WritableSignal>>; + }; + let mockUserDataService: { userData: WritableSignal> }; + let mockSnackBar: jasmine.SpyObj; + + const dummyPocReminderComponent = class {}; // Dummy class for type checking + beforeEach(async () => { + mockRegistrarService = { + registrar: signal(undefined), + registrarId: signal('123'), + registrars: signal([]), + }; + + mockUserDataService = { + userData: signal({ + globalRole: 'NONE', + }), + }; + + mockSnackBar = jasmine.createSpyObj('MatSnackBar', ['openFromComponent']); + await TestBed.configureTestingModule({ - declarations: [AppComponent], imports: [ - MaterialModule, - BrowserAnimationsModule, - AppRoutingModule, + MatSidenavModule, + NoopAnimationsModule, + MatSnackBarModule, AppModule, + RouterModule.forRoot(routes), ], providers: [ - BackendService, + { provide: RegistrarService, useValue: mockRegistrarService }, + { provide: UserDataService, useValue: mockUserDataService }, + { provide: MatSnackBar, useValue: mockSnackBar }, + { provide: PocReminderComponent, useClass: dummyPocReminderComponent }, provideHttpClient(), provideHttpClientTesting(), ], }).compileComponents(); + + fixture = TestBed.createComponent(AppComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jasmine.clock().uninstall(); }); it('should create the app', () => { @@ -46,4 +87,51 @@ describe('AppComponent', () => { const app = fixture.componentInstance; expect(app).toBeTruthy(); }); + + describe('PoC Verification Reminder', () => { + beforeEach(() => { + jasmine.clock().install(); + }); + + it('should open snackbar if lastPocVerificationDate is older than one year', fakeAsync(() => { + const MOCK_TODAY = new Date('2024-07-15T10:00:00.000Z'); + jasmine.clock().mockDate(MOCK_TODAY); + + const twoYearsAgo = new Date(MOCK_TODAY); + twoYearsAgo.setFullYear(MOCK_TODAY.getFullYear() - 2); + + mockRegistrarService.registrar.set({ + lastPocVerificationDate: twoYearsAgo.toISOString(), + }); + + fixture.detectChanges(); + TestBed.flushEffects(); + + expect(mockSnackBar.openFromComponent).toHaveBeenCalledWith( + PocReminderComponent, + { + horizontalPosition: 'center', + verticalPosition: 'top', + duration: 1000000000, + } + ); + })); + + it('should NOT open snackbar if lastPocVerificationDate is within last year', fakeAsync(() => { + const MOCK_TODAY = new Date('2024-07-15T10:00:00.000Z'); + jasmine.clock().mockDate(MOCK_TODAY); + + const sixMonthsAgo = new Date(MOCK_TODAY); + sixMonthsAgo.setMonth(MOCK_TODAY.getMonth() - 6); + + mockRegistrarService.registrar.set({ + lastPocVerificationDate: sixMonthsAgo.toISOString(), + }); + + fixture.detectChanges(); + TestBed.flushEffects(); + + expect(mockSnackBar.openFromComponent).not.toHaveBeenCalled(); + })); + }); }); diff --git a/console-webapp/src/app/app.component.ts b/console-webapp/src/app/app.component.ts index 6433260c5..429544ff4 100644 --- a/console-webapp/src/app/app.component.ts +++ b/console-webapp/src/app/app.component.ts @@ -12,13 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { AfterViewInit, Component, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, effect, ViewChild } from '@angular/core'; import { MatSidenav } from '@angular/material/sidenav'; import { NavigationEnd, Router } from '@angular/router'; import { RegistrarService } from './registrar/registrar.service'; import { BreakPointObserverService } from './shared/services/breakPoint.service'; import { GlobalLoaderService } from './shared/services/globalLoader.service'; import { UserDataService } from './shared/services/userData.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { PocReminderComponent } from './shared/components/pocReminder/pocReminder.component'; @Component({ selector: 'app-root', @@ -35,8 +37,28 @@ export class AppComponent implements AfterViewInit { protected userDataService: UserDataService, protected globalLoader: GlobalLoaderService, protected breakpointObserver: BreakPointObserverService, - private router: Router - ) {} + private router: Router, + private _snackBar: MatSnackBar + ) { + effect(() => { + const registrar = this.registrarService.registrar(); + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + oneYearAgo.setHours(0, 0, 0, 0); + if ( + registrar && + registrar.lastPocVerificationDate && + new Date(registrar.lastPocVerificationDate) < oneYearAgo && + this.userDataService?.userData()?.globalRole === 'NONE' + ) { + this._snackBar.openFromComponent(PocReminderComponent, { + horizontalPosition: 'center', + verticalPosition: 'top', + duration: 1000000000, + }); + } + }); + } ngAfterViewInit() { this.router.events.subscribe((event) => { diff --git a/console-webapp/src/app/app.module.ts b/console-webapp/src/app/app.module.ts index 6e4341f83..45cc67cd7 100644 --- a/console-webapp/src/app/app.module.ts +++ b/console-webapp/src/app/app.module.ts @@ -60,6 +60,7 @@ import { TldsComponent } from './tlds/tlds.component'; import { ForceFocusDirective } from './shared/directives/forceFocus.directive'; import RdapComponent from './settings/rdap/rdap.component'; import RdapEditComponent from './settings/rdap/rdapEdit.component'; +import { PocReminderComponent } from './shared/components/pocReminder/pocReminder.component'; @NgModule({ declarations: [SelectedRegistrarWrapper], @@ -86,6 +87,7 @@ export class SelectedRegistrarModule {} RdapComponent, RdapEditComponent, ReasonDialogComponent, + PocReminderComponent, RegistrarComponent, RegistrarDetailsComponent, RegistrarSelectorComponent, diff --git a/console-webapp/src/app/navigation/navigation.component.ts b/console-webapp/src/app/navigation/navigation.component.ts index 0315da596..9562eaf06 100644 --- a/console-webapp/src/app/navigation/navigation.component.ts +++ b/console-webapp/src/app/navigation/navigation.component.ts @@ -57,7 +57,7 @@ export class NavigationComponent { } ngOnDestroy() { - this.subscription.unsubscribe(); + this.subscription && this.subscription.unsubscribe(); } getElementId(node: RouteWithIcon) { diff --git a/console-webapp/src/app/registrar/registrar.service.ts b/console-webapp/src/app/registrar/registrar.service.ts index 27bd70525..e3a0708d8 100644 --- a/console-webapp/src/app/registrar/registrar.service.ts +++ b/console-webapp/src/app/registrar/registrar.service.ts @@ -71,6 +71,7 @@ export interface Registrar registrarName: string; registryLockAllowed?: boolean; type?: string; + lastPocVerificationDate?: string; } @Injectable({ diff --git a/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.html b/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.html new file mode 100644 index 000000000..d9915cc9b --- /dev/null +++ b/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.html @@ -0,0 +1,14 @@ +
+

+ Please take a moment to complete annual review of + contacts. +

+ + + + +
diff --git a/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.scss b/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.scss new file mode 100644 index 000000000..39194098d --- /dev/null +++ b/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.scss @@ -0,0 +1,5 @@ +.console-app__pocReminder { + a { + color: white !important; + } +} diff --git a/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.ts b/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.ts new file mode 100644 index 000000000..1e3339ccf --- /dev/null +++ b/console-webapp/src/app/shared/components/pocReminder/pocReminder.component.ts @@ -0,0 +1,53 @@ +// 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. + +import { Component } from '@angular/core'; +import { MatSnackBar, MatSnackBarRef } from '@angular/material/snack-bar'; +import { RegistrarService } from '../../../registrar/registrar.service'; +import { HttpErrorResponse } from '@angular/common/http'; + +@Component({ + selector: 'app-poc-reminder', + templateUrl: './pocReminder.component.html', + styleUrls: ['./pocReminder.component.scss'], + standalone: false, +}) +export class PocReminderComponent { + constructor( + public snackBarRef: MatSnackBarRef, + private registrarService: RegistrarService, + private _snackBar: MatSnackBar + ) {} + + confirmReviewed() { + if (this.registrarService.registrar()) { + const todayMidnight = new Date(); + todayMidnight.setHours(0, 0, 0, 0); + this.registrarService + // @ts-ignore - if check above won't allow empty object to be submitted + .updateRegistrar({ + ...this.registrarService.registrar(), + lastPocVerificationDate: todayMidnight.toISOString(), + }) + .subscribe({ + error: (err: HttpErrorResponse) => { + this._snackBar.open(err.error || err.message); + }, + next: () => { + this.snackBarRef.dismiss(); + }, + }); + } + } +} diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleUpdateRegistrarAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleUpdateRegistrarAction.java index c3d5cfdba..6f72372eb 100644 --- a/core/src/main/java/google/registry/ui/server/console/ConsoleUpdateRegistrarAction.java +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleUpdateRegistrarAction.java @@ -17,6 +17,7 @@ package google.registry.ui.server.console; import static com.google.common.base.Preconditions.checkArgument; import static google.registry.persistence.transaction.TransactionManagerFactory.tm; import static google.registry.request.Action.Method.POST; +import static google.registry.util.DateTimeUtils.START_OF_TIME; import static google.registry.util.PreconditionsUtils.checkArgumentPresent; import static org.apache.http.HttpStatus.SC_OK; @@ -37,6 +38,7 @@ import google.registry.util.RegistryEnvironment; import jakarta.inject.Inject; import java.util.Optional; import java.util.stream.Collectors; +import org.joda.time.DateTime; @Action( service = GaeService.DEFAULT, @@ -88,18 +90,35 @@ public class ConsoleUpdateRegistrarAction extends ConsoleApiAction { } } - Registrar updatedRegistrar = + DateTime now = tm().getTransactionTime(); + DateTime newLastPocVerificationDate = + registrarParam.getLastPocVerificationDate() == null + ? START_OF_TIME + : registrarParam.getLastPocVerificationDate(); + + checkArgument( + newLastPocVerificationDate.isBefore(now), + "Invalid value of LastPocVerificationDate - value is in the future"); + + var updatedRegistrarBuilder = existingRegistrar .get() .asBuilder() - .setAllowedTlds( - registrarParam.getAllowedTlds().stream() - .map(DomainNameUtils::canonicalizeHostname) - .collect(Collectors.toSet())) - .setRegistryLockAllowed(registrarParam.isRegistryLockAllowed()) - .setLastPocVerificationDate(registrarParam.getLastPocVerificationDate()) - .build(); + .setLastPocVerificationDate(newLastPocVerificationDate); + if (user.getUserRoles() + .hasGlobalPermission(ConsolePermission.EDIT_REGISTRAR_DETAILS)) { + updatedRegistrarBuilder = + updatedRegistrarBuilder + .setAllowedTlds( + registrarParam.getAllowedTlds().stream() + .map(DomainNameUtils::canonicalizeHostname) + .collect(Collectors.toSet())) + .setRegistryLockAllowed(registrarParam.isRegistryLockAllowed()) + .setLastPocVerificationDate(newLastPocVerificationDate); + } + + var updatedRegistrar = updatedRegistrarBuilder.build(); tm().put(updatedRegistrar); finishAndPersistConsoleUpdateHistory( new ConsoleUpdateHistory.Builder() diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java index 56f125772..3ff070684 100644 --- a/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleUpdateRegistrarActionTest.java @@ -40,6 +40,7 @@ import google.registry.request.Action; import google.registry.request.RequestModule; import google.registry.request.auth.AuthResult; import google.registry.testing.ConsoleApiParamsUtils; +import google.registry.testing.FakeClock; import google.registry.testing.FakeResponse; import google.registry.testing.SystemPropertyExtension; import google.registry.tools.GsonUtils; @@ -51,6 +52,7 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.StringReader; import java.util.Optional; +import org.joda.time.DateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; @@ -59,7 +61,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; /** Tests for {@link google.registry.ui.server.console.ConsoleUpdateRegistrarAction}. */ class ConsoleUpdateRegistrarActionTest { private static final Gson GSON = GsonUtils.provideGson(); - + private final FakeClock clock = new FakeClock(DateTime.parse("2025-01-01T00:00:00.000Z")); private ConsoleApiParams consoleApiParams; private FakeResponse response; @@ -75,6 +77,10 @@ class ConsoleUpdateRegistrarActionTest { @Order(Integer.MAX_VALUE) final SystemPropertyExtension systemPropertyExtension = new SystemPropertyExtension(); + @RegisterExtension + final JpaTestExtensions.JpaIntegrationTestExtension jpa = + new JpaTestExtensions.Builder().withClock(clock).buildIntegrationTestExtension(); + @BeforeEach void beforeEach() throws Exception { createTlds("app", "dev"); @@ -95,10 +101,6 @@ class ConsoleUpdateRegistrarActionTest { consoleApiParams = createParams(); } - @RegisterExtension - final JpaTestExtensions.JpaIntegrationTestExtension jpa = - new JpaTestExtensions.Builder().buildIntegrationTestExtension(); - @Test void testSuccess_updatesRegistrar() throws IOException { var action = @@ -108,7 +110,7 @@ class ConsoleUpdateRegistrarActionTest { "TheRegistrar", "app, dev", false, - "\"2025-01-01T00:00:00.000Z\"")); + "\"2024-12-12T00:00:00.000Z\"")); action.run(); Registrar newRegistrar = Registrar.loadByRegistrarId("TheRegistrar").get(); assertThat(newRegistrar.getAllowedTlds()).containsExactly("app", "dev"); @@ -119,6 +121,33 @@ class ConsoleUpdateRegistrarActionTest { assertThat(history.getDescription()).hasValue("TheRegistrar"); } + @Test + void testSuccess_updatesNullPocVerificationDate() throws IOException { + var action = + createAction( + String.format(registrarPostData, "TheRegistrar", "app, dev", false, "\"null\"")); + action.run(); + Registrar newRegistrar = Registrar.loadByRegistrarId("TheRegistrar").get(); + assertThat(newRegistrar.getLastPocVerificationDate()) + .isEqualTo(DateTime.parse("1970-01-01T00:00:00.000Z")); + } + + @Test + void testFailure_pocVerificationInTheFuture() throws IOException { + var action = + createAction( + String.format( + registrarPostData, + "TheRegistrar", + "app, dev", + false, + "\"2025-02-01T00:00:00.000Z\"")); + action.run(); + assertThat(((FakeResponse) consoleApiParams.response()).getStatus()).isEqualTo(SC_BAD_REQUEST); + assertThat((String) ((FakeResponse) consoleApiParams.response()).getPayload()) + .contains("Invalid value of LastPocVerificationDate - value is in the future"); + } + @Test void testFails_missingWhoisContact() throws IOException { RegistryEnvironment.PRODUCTION.setup(systemPropertyExtension); @@ -129,7 +158,7 @@ class ConsoleUpdateRegistrarActionTest { "TheRegistrar", "app, dev", false, - "\"2025-01-01T00:00:00.000Z\"")); + "\"2024-12-12T00:00:00.000Z\"")); action.run(); assertThat(((FakeResponse) consoleApiParams.response()).getStatus()).isEqualTo(SC_BAD_REQUEST); assertThat((String) ((FakeResponse) consoleApiParams.response()).getPayload()) @@ -159,7 +188,7 @@ class ConsoleUpdateRegistrarActionTest { "TheRegistrar", "app, dev", false, - "\"2025-01-01T00:00:00.000Z\"")); + "\"2024-12-12T00:00:00.000Z\"")); action.run(); Registrar newRegistrar = Registrar.loadByRegistrarId("TheRegistrar").get(); assertThat(newRegistrar.getAllowedTlds()).containsExactly("app", "dev"); @@ -176,7 +205,7 @@ class ConsoleUpdateRegistrarActionTest { "TheRegistrar", "app, dev", false, - "\"2025-01-01T00:00:00.000Z\"")); + "\"2024-12-12T00:00:00.000Z\"")); action.run(); verify(consoleApiParams.sendEmailUtils().gmailClient, times(1)) .sendEmail( @@ -190,7 +219,7 @@ class ConsoleUpdateRegistrarActionTest { + "\n" + "allowedTlds: null -> [app, dev]\n" + "lastPocVerificationDate: 1970-01-01T00:00:00.000Z ->" - + " 2025-01-01T00:00:00.000Z\n") + + " 2024-12-12T00:00:00.000Z\n") .setRecipients(ImmutableList.of(new InternetAddress("notification@test.example"))) .build()); } diff --git a/core/src/test/java/google/registry/webdriver/ConsoleScreenshotTest.java b/core/src/test/java/google/registry/webdriver/ConsoleScreenshotTest.java index 679eeb038..b6981c51e 100644 --- a/core/src/test/java/google/registry/webdriver/ConsoleScreenshotTest.java +++ b/core/src/test/java/google/registry/webdriver/ConsoleScreenshotTest.java @@ -16,12 +16,16 @@ package google.registry.webdriver; import static com.google.common.truth.Truth.assertThat; import static google.registry.server.Fixture.BASIC; +import static google.registry.testing.DatabaseHelper.persistResource; import com.google.common.collect.ImmutableMap; import google.registry.model.console.GlobalRole; import google.registry.model.console.RegistrarRole; +import google.registry.model.registrar.Registrar; import google.registry.server.RegistryTestServer; import java.util.List; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.condition.EnabledIfSystemProperty; @@ -74,6 +78,10 @@ public class ConsoleScreenshotTest { @BeforeEach void beforeEach() throws Exception { server.setRegistrarRoles(ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER)); + Registrar registrar = Registrar.loadByRegistrarId("TheRegistrar").get(); + registrar = + registrar.asBuilder().setLastPocVerificationDate(DateTime.now(DateTimeZone.UTC)).build(); + persistResource(registrar); loadHomePage(); }