From 6e77c89cd649e58bfdd96cac4059e639fc1e59ee Mon Sep 17 00:00:00 2001
From: Pavlo Tkach <3469726+ptkach@users.noreply.github.com>
Date: Tue, 8 Oct 2024 12:00:47 -0400
Subject: [PATCH] Add console users screen (#2576)
---
console-webapp/src/app/app-routing.module.ts | 15 +--
console-webapp/src/app/app.module.ts | 13 ++-
.../app/navigation/navigation.component.ts | 12 +-
.../app/settings/users/users.component.html | 1 -
.../app/settings/users/users.component.scss | 13 ---
.../settings/users/users.component.spec.ts | 36 ------
.../userLevelVisiblity.directive.ts | 9 +-
.../app/shared/services/backend.service.ts | 13 +++
.../src/app/users/users.component.html | 43 +++++++
.../users.component.scss} | 30 +++--
.../src/app/users/users.component.ts | 105 ++++++++++++++++++
console-webapp/src/app/users/users.service.ts | 60 ++++++++++
.../registry/config/files/default-config.yaml | 2 +
.../ui/server/console/ConsoleUsersAction.java | 17 ++-
.../console/ConsoleUsersActionTest.java | 11 +-
15 files changed, 298 insertions(+), 82 deletions(-)
delete mode 100644 console-webapp/src/app/settings/users/users.component.html
delete mode 100644 console-webapp/src/app/settings/users/users.component.scss
delete mode 100644 console-webapp/src/app/settings/users/users.component.spec.ts
create mode 100644 console-webapp/src/app/users/users.component.html
rename console-webapp/src/app/{settings/users/users.component.ts => users/users.component.scss} (62%)
create mode 100644 console-webapp/src/app/users/users.component.ts
create mode 100644 console-webapp/src/app/users/users.service.ts
diff --git a/console-webapp/src/app/app-routing.module.ts b/console-webapp/src/app/app-routing.module.ts
index 8602f8f0c..d2e2a98c1 100644
--- a/console-webapp/src/app/app-routing.module.ts
+++ b/console-webapp/src/app/app-routing.module.ts
@@ -18,15 +18,12 @@ import { BillingInfoComponent } from './billingInfo/billingInfo.component';
import { DomainListComponent } from './domains/domainList.component';
import { HomeComponent } from './home/home.component';
import { RegistryLockVerifyComponent } from './lock/registryLockVerify.component';
-import { NewOteComponent } from './ote/newOte.component';
-import { OteStatusComponent } from './ote/oteStatus.component';
import { RegistrarDetailsComponent } from './registrar/registrarDetails.component';
import { RegistrarComponent } from './registrar/registrarsTable.component';
import { ResourcesComponent } from './resources/resources.component';
import ContactComponent from './settings/contact/contact.component';
import SecurityComponent from './settings/security/security.component';
import { SettingsComponent } from './settings/settings.component';
-import UsersComponent from './settings/users/users.component';
import WhoisComponent from './settings/whois/whois.component';
import { SupportComponent } from './support/support.component';
@@ -37,6 +34,7 @@ export interface RouteWithIcon extends Route {
export const PATHS = {
NewOteComponent: 'new-ote',
OteStatusComponent: 'ote-status/:registrarId',
+ UsersComponent: 'users',
};
export const routes: RouteWithIcon[] = [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
@@ -94,10 +92,6 @@ export const routes: RouteWithIcon[] = [
component: SecurityComponent,
title: 'Security',
},
- {
- path: UsersComponent.PATH,
- component: UsersComponent,
- },
],
},
// {
@@ -128,6 +122,13 @@ export const routes: RouteWithIcon[] = [
title: 'Resources',
iconName: 'description',
},
+ {
+ path: PATHS.UsersComponent,
+ title: 'Users',
+ iconName: 'manage_accounts',
+ loadComponent: () =>
+ import('./users/users.component').then((mod) => mod.UsersComponent),
+ },
{
path: SupportComponent.PATH,
component: SupportComponent,
diff --git a/console-webapp/src/app/app.module.ts b/console-webapp/src/app/app.module.ts
index 34ca074c7..8f2fd2755 100644
--- a/console-webapp/src/app/app.module.ts
+++ b/console-webapp/src/app/app.module.ts
@@ -32,8 +32,6 @@ import { HeaderComponent } from './header/header.component';
import { HomeComponent } from './home/home.component';
import { RegistryLockVerifyComponent } from './lock/registryLockVerify.component';
import { NavigationComponent } from './navigation/navigation.component';
-import { NewOteComponent } from './ote/newOte.component';
-import { OteStatusComponent } from './ote/oteStatus.component';
import NewRegistrarComponent from './registrar/newRegistrar.component';
import { RegistrarDetailsComponent } from './registrar/registrarDetails.component';
import { RegistrarSelectorComponent } from './registrar/registrarSelector.component';
@@ -58,6 +56,14 @@ import { SnackBarModule } from './snackbar.module';
import { SupportComponent } from './support/support.component';
import { TldsComponent } from './tlds/tlds.component';
+@NgModule({
+ declarations: [SelectedRegistrarWrapper],
+ imports: [MaterialModule],
+ exports: [SelectedRegistrarWrapper],
+ providers: [],
+})
+export class SelectedRegistrarModule {}
+
@NgModule({
declarations: [
AppComponent,
@@ -80,7 +86,6 @@ import { TldsComponent } from './tlds/tlds.component';
ResourcesComponent,
SecurityComponent,
SecurityEditComponent,
- SelectedRegistrarWrapper,
SettingsComponent,
SettingsContactComponent,
SupportComponent,
@@ -96,8 +101,8 @@ import { TldsComponent } from './tlds/tlds.component';
FormsModule,
MaterialModule,
SnackBarModule,
+ SelectedRegistrarModule,
],
- exports: [SelectedRegistrarWrapper],
providers: [
BackendService,
BreakPointObserverService,
diff --git a/console-webapp/src/app/navigation/navigation.component.ts b/console-webapp/src/app/navigation/navigation.component.ts
index 65601f6cb..fb36c53d3 100644
--- a/console-webapp/src/app/navigation/navigation.component.ts
+++ b/console-webapp/src/app/navigation/navigation.component.ts
@@ -17,8 +17,9 @@ import { Component } from '@angular/core';
import { MatTreeNestedDataSource } from '@angular/material/tree';
import { NavigationEnd, Router } from '@angular/router';
import { Subscription } from 'rxjs';
-import { RouteWithIcon, routes } from '../app-routing.module';
+import { RouteWithIcon, routes, PATHS } from '../app-routing.module';
import { RESTRICTED_ELEMENTS } from '../shared/directives/userLevelVisiblity.directive';
+import { RegistrarComponent } from '../registrar/registrarsTable.component';
interface NavMenuNode extends RouteWithIcon {
parentRoute?: RouteWithIcon;
@@ -59,9 +60,12 @@ export class NavigationComponent {
}
getElementId(node: RouteWithIcon) {
- return node.path === 'registrars'
- ? RESTRICTED_ELEMENTS.REGISTRAR_ELEMENT
- : null;
+ if (node.path === RegistrarComponent.PATH) {
+ return RESTRICTED_ELEMENTS.REGISTRAR_ELEMENT;
+ } else if (node.path === PATHS.UsersComponent) {
+ return RESTRICTED_ELEMENTS.USERS;
+ }
+ return null;
}
syncExpandedNavigationWithRoute(url: string) {
diff --git a/console-webapp/src/app/settings/users/users.component.html b/console-webapp/src/app/settings/users/users.component.html
deleted file mode 100644
index 065c5c6ed..000000000
--- a/console-webapp/src/app/settings/users/users.component.html
+++ /dev/null
@@ -1 +0,0 @@
-
users works!
diff --git a/console-webapp/src/app/settings/users/users.component.scss b/console-webapp/src/app/settings/users/users.component.scss
deleted file mode 100644
index c5e3ba122..000000000
--- a/console-webapp/src/app/settings/users/users.component.scss
+++ /dev/null
@@ -1,13 +0,0 @@
-// Copyright 2024 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.
diff --git a/console-webapp/src/app/settings/users/users.component.spec.ts b/console-webapp/src/app/settings/users/users.component.spec.ts
deleted file mode 100644
index 6ab79ebe6..000000000
--- a/console-webapp/src/app/settings/users/users.component.spec.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright 2024 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 { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import UsersComponent from './users.component';
-
-describe('UsersComponent', () => {
- let component: UsersComponent;
- let fixture: ComponentFixture;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- declarations: [UsersComponent],
- }).compileComponents();
-
- fixture = TestBed.createComponent(UsersComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-});
diff --git a/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts b/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts
index 43789b7bc..2f608bf84 100644
--- a/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts
+++ b/console-webapp/src/app/shared/directives/userLevelVisiblity.directive.ts
@@ -18,10 +18,17 @@ import { UserDataService } from '../services/userData.service';
export enum RESTRICTED_ELEMENTS {
REGISTRAR_ELEMENT,
OTE,
+ USERS,
}
export const DISABLED_ELEMENTS_PER_ROLE = {
- NONE: [RESTRICTED_ELEMENTS.REGISTRAR_ELEMENT, RESTRICTED_ELEMENTS.OTE],
+ NONE: [
+ RESTRICTED_ELEMENTS.REGISTRAR_ELEMENT,
+ RESTRICTED_ELEMENTS.OTE,
+ RESTRICTED_ELEMENTS.USERS,
+ ],
+ SUPPORT_LEAD: [RESTRICTED_ELEMENTS.USERS],
+ SUPPORT_AGENT: [RESTRICTED_ELEMENTS.USERS],
};
@Directive({
diff --git a/console-webapp/src/app/shared/services/backend.service.ts b/console-webapp/src/app/shared/services/backend.service.ts
index 44e0125e9..5b2c2b93d 100644
--- a/console-webapp/src/app/shared/services/backend.service.ts
+++ b/console-webapp/src/app/shared/services/backend.service.ts
@@ -21,6 +21,7 @@ import { DomainLocksResult } from 'src/app/domains/registryLock.service';
import { RegistryLockVerificationResponse } from 'src/app/lock/registryLockVerify.service';
import { OteCreateResponse } from 'src/app/ote/newOte.component';
import { OteStatusResponse } from 'src/app/ote/oteStatus.component';
+import { User } from 'src/app/users/users.service';
import {
Registrar,
SecuritySettingsBackendModel,
@@ -159,6 +160,18 @@ export class BackendService {
);
}
+ getUsers(registrarId: string): Observable {
+ return this.http
+ .get(`/console-api/users?registrarId=${registrarId}`)
+ .pipe(catchError((err) => this.errorCatcher(err)));
+ }
+
+ createUser(registrarId: string): Observable {
+ return this.http
+ .post(`/console-api/users?registrarId=${registrarId}`, {})
+ .pipe(catchError((err) => this.errorCatcher(err)));
+ }
+
getUserData(): Observable {
return this.http
.get('/console-api/userdata')
diff --git a/console-webapp/src/app/users/users.component.html b/console-webapp/src/app/users/users.component.html
new file mode 100644
index 000000000..226a51587
--- /dev/null
+++ b/console-webapp/src/app/users/users.component.html
@@ -0,0 +1,43 @@
+
+ @if (usersService.isLoaded) {
+
+
+
+
+
+ {{ column.header }}
+
+
+
+
+
+
+
+ } @else {
+
+
+
+ }
+
diff --git a/console-webapp/src/app/settings/users/users.component.ts b/console-webapp/src/app/users/users.component.scss
similarity index 62%
rename from console-webapp/src/app/settings/users/users.component.ts
rename to console-webapp/src/app/users/users.component.scss
index 6c97691b6..12f3366e4 100644
--- a/console-webapp/src/app/settings/users/users.component.ts
+++ b/console-webapp/src/app/users/users.component.scss
@@ -12,13 +12,27 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import { Component } from '@angular/core';
+.console-app {
+ &__users-spinner {
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ }
-@Component({
- selector: 'app-users',
- templateUrl: './users.component.html',
- styleUrls: ['./users.component.scss'],
-})
-export default class UsersComponent {
- public static PATH = 'users';
+ $min-width: 756px;
+ $max-width: 1024px;
+
+ &__users-table {
+ min-width: $min-width !important;
+ max-width: $max-width;
+ }
+
+ &__users-new {
+ margin-left: 20px;
+ }
+
+ &__users-header {
+ display: flex;
+ justify-content: space-between;
+ }
}
diff --git a/console-webapp/src/app/users/users.component.ts b/console-webapp/src/app/users/users.component.ts
new file mode 100644
index 000000000..b38af2c88
--- /dev/null
+++ b/console-webapp/src/app/users/users.component.ts
@@ -0,0 +1,105 @@
+// Copyright 2024 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 { CommonModule } from '@angular/common';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Component, effect, ViewChild } from '@angular/core';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { MatSort } from '@angular/material/sort';
+import { MatTableDataSource } from '@angular/material/table';
+import { SelectedRegistrarModule } from '../app.module';
+import { MaterialModule } from '../material.module';
+import { RegistrarService } from '../registrar/registrar.service';
+import { SnackBarModule } from '../snackbar.module';
+import { User, UsersService } from './users.service';
+
+export const columns = [
+ {
+ columnDef: 'emailAddress',
+ header: 'User email',
+ cell: (record: User) => `${record.emailAddress || ''}`,
+ },
+ {
+ columnDef: 'role',
+ header: 'User role',
+ cell: (record: User) => `${record.role || ''}`,
+ },
+];
+
+@Component({
+ selector: 'app-users',
+ templateUrl: './users.component.html',
+ styleUrls: ['./users.component.scss'],
+ standalone: true,
+ imports: [
+ MaterialModule,
+ SnackBarModule,
+ CommonModule,
+ SelectedRegistrarModule,
+ ],
+ providers: [UsersService],
+})
+export class UsersComponent {
+ dataSource: MatTableDataSource;
+ columns = columns;
+ displayedColumns = this.columns.map((c) => c.columnDef);
+
+ @ViewChild(MatSort) sort!: MatSort;
+
+ constructor(
+ protected registrarService: RegistrarService,
+ protected usersService: UsersService,
+ private _snackBar: MatSnackBar
+ ) {
+ this.dataSource = new MatTableDataSource(usersService.users());
+
+ effect(() => {
+ if (registrarService.registrarId()) {
+ this.loadUsers();
+ }
+ });
+ effect(() => {
+ this.dataSource.data = usersService.users();
+ });
+ }
+
+ ngAfterViewInit() {
+ this.dataSource.sort = this.sort;
+ }
+
+ loadUsers() {
+ this.usersService.fetchUsers().subscribe({
+ error: (err: HttpErrorResponse) => {
+ this._snackBar.open(err.error || err.message);
+ },
+ });
+ }
+
+ createNewUser() {
+ this.usersService.createNewUser().subscribe({
+ next: (newUser) => {
+ this._snackBar.open(
+ `New user with email ${newUser.emailAddress} has been created.`,
+ '',
+ {
+ duration: 2000,
+ }
+ );
+ },
+ error: (err: HttpErrorResponse) => {
+ this._snackBar.open(err.error || err.message);
+ },
+ });
+ }
+}
diff --git a/console-webapp/src/app/users/users.service.ts b/console-webapp/src/app/users/users.service.ts
new file mode 100644
index 000000000..e1b2e488b
--- /dev/null
+++ b/console-webapp/src/app/users/users.service.ts
@@ -0,0 +1,60 @@
+// Copyright 2024 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 { Injectable, signal } from '@angular/core';
+import { tap } from 'rxjs';
+import { RegistrarService } from '../registrar/registrar.service';
+import { BackendService } from '../shared/services/backend.service';
+
+export interface CreateAutoTimestamp {
+ creationTime: string;
+}
+
+export interface User {
+ emailAddress: String;
+ role: String;
+ password?: String;
+}
+
+@Injectable()
+export class UsersService {
+ isLoaded = false;
+ users = signal([]);
+
+ constructor(
+ private backendService: BackendService,
+ private registrarService: RegistrarService
+ ) {}
+
+ fetchUsers() {
+ return this.backendService
+ .getUsers(this.registrarService.registrarId())
+ .pipe(
+ tap((users: User[]) => {
+ this.isLoaded = true;
+ this.users.set(users);
+ })
+ );
+ }
+
+ createNewUser() {
+ return this.backendService
+ .createUser(this.registrarService.registrarId())
+ .pipe(
+ tap((newUser: User) => {
+ this.users.set([...this.users(), newUser]);
+ })
+ );
+ }
+}
diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml
index 04456514f..2ca2405ff 100644
--- a/core/src/main/java/google/registry/config/files/default-config.yaml
+++ b/core/src/main/java/google/registry/config/files/default-config.yaml
@@ -372,6 +372,8 @@ credentialOAuth:
delegatedCredentialOauthScopes:
# View and manage groups on your domain in Directory API.
- https://www.googleapis.com/auth/admin.directory.group
+ # View and manage users in Google Workspace
+ - https://www.googleapis.com/auth/admin.directory.user
# View and manage group settings in Group Settings API.
- https://www.googleapis.com/auth/apps.groups.settings
# Send email through Gmail.
diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleUsersAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleUsersAction.java
index 1b5790e70..3e3782fa0 100644
--- a/core/src/main/java/google/registry/ui/server/console/ConsoleUsersAction.java
+++ b/core/src/main/java/google/registry/ui/server/console/ConsoleUsersAction.java
@@ -95,10 +95,18 @@ public class ConsoleUsersAction extends ConsoleApiAction {
@Override
protected void getHandler(User user) {
checkPermission(user, registrarId, ConsolePermission.MANAGE_USERS);
- List users =
+ List users =
getAllUsers().stream()
.filter(u -> u.getUserRoles().getRegistrarRoles().containsKey(registrarId))
+ .map(
+ u ->
+ ImmutableMap.of(
+ "emailAddress",
+ u.getEmailAddress(),
+ "role",
+ u.getUserRoles().getRegistrarRoles().get(registrarId)))
.collect(Collectors.toList());
+
consoleApiParams.response().setPayload(gson.toJson(users));
consoleApiParams.response().setStatus(SC_OK);
}
@@ -141,7 +149,12 @@ public class ConsoleUsersAction extends ConsoleApiAction {
.setPayload(
gson.toJson(
ImmutableMap.of(
- "password", newUser.getPassword(), "email", newUser.getPrimaryEmail())));
+ "password",
+ newUser.getPassword(),
+ "emailAddress",
+ newUser.getPrimaryEmail(),
+ "role",
+ ACCOUNT_MANAGER)));
}
private ImmutableList getAllUsers() {
diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleUsersActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleUsersActionTest.java
index 0b33b32c8..dc5e28ade 100644
--- a/core/src/test/java/google/registry/ui/server/console/ConsoleUsersActionTest.java
+++ b/core/src/test/java/google/registry/ui/server/console/ConsoleUsersActionTest.java
@@ -41,9 +41,7 @@ import google.registry.testing.FakeResponse;
import google.registry.util.StringGenerator;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
-import java.util.Arrays;
import java.util.Optional;
-import java.util.stream.Collectors;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -117,9 +115,9 @@ class ConsoleUsersActionTest {
createAction(Optional.of(ConsoleApiParamsUtils.createFake(authResult)), Optional.of("GET"));
action.run();
var response = ((FakeResponse) consoleApiParams.response());
- User[] users = GSON.fromJson(response.getPayload(), User[].class);
- assertThat(Arrays.stream(users).map(u -> u.getEmailAddress()).collect(Collectors.toList()))
- .containsExactlyElementsIn(ImmutableList.of("test1@test.com", "test2@test.com"));
+ assertThat(response.getPayload())
+ .isEqualTo(
+ "[{\"emailAddress\":\"test1@test.com\",\"role\":\"PRIMARY_CONTACT\"},{\"emailAddress\":\"test2@test.com\",\"role\":\"PRIMARY_CONTACT\"}]");
}
@Test
@@ -155,7 +153,8 @@ class ConsoleUsersActionTest {
var response = ((FakeResponse) consoleApiParams.response());
assertThat(response.getStatus()).isEqualTo(SC_OK);
assertThat(response.getPayload())
- .contains("{\"password\":\"abcdefghijklmnop\",\"email\":\"email-1@email.com\"}");
+ .contains(
+ "{\"password\":\"abcdefghijklmnop\",\"emailAddress\":\"email-1@email.com\",\"role\":\"ACCOUNT_MANAGER\"}");
}
@Test