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) { +
+
+

Users

+
+ +
+ + + + {{ 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