diff --git a/console-webapp/src/app/shared/services/backend.service.ts b/console-webapp/src/app/shared/services/backend.service.ts index c34f2582a65..7afd89f8e37 100644 --- a/console-webapp/src/app/shared/services/backend.service.ts +++ b/console-webapp/src/app/shared/services/backend.service.ts @@ -166,9 +166,9 @@ export class BackendService { .pipe(catchError((err) => this.errorCatcher(err))); } - createUser(registrarId: string): Observable { + createUser(registrarId: string, maybeUser: User | null): Observable { return this.http - .post(`/console-api/users?registrarId=${registrarId}`, {}) + .post(`/console-api/users?registrarId=${registrarId}`, maybeUser) .pipe(catchError((err) => this.errorCatcher(err))); } diff --git a/console-webapp/src/app/shared/services/userData.service.ts b/console-webapp/src/app/shared/services/userData.service.ts index f7492418941..7a4bae8230d 100644 --- a/console-webapp/src/app/shared/services/userData.service.ts +++ b/console-webapp/src/app/shared/services/userData.service.ts @@ -27,6 +27,7 @@ export interface UserData { supportEmail: string; supportPhoneNumber: string; technicalDocsUrl: string; + userRoles?: Map; } @Injectable({ diff --git a/console-webapp/src/app/users/userEdit.component.ts b/console-webapp/src/app/users/userEdit.component.ts index 9c84cd17801..324c89f4ad0 100644 --- a/console-webapp/src/app/users/userEdit.component.ts +++ b/console-webapp/src/app/users/userEdit.component.ts @@ -19,7 +19,7 @@ import { SelectedRegistrarModule } from '../app.module'; import { MaterialModule } from '../material.module'; import { RegistrarService } from '../registrar/registrar.service'; import { SnackBarModule } from '../snackbar.module'; -import { User, UsersService, roleToDescription } from './users.service'; +import { UsersService, roleToDescription } from './users.service'; import { FormsModule } from '@angular/forms'; @Component({ diff --git a/console-webapp/src/app/users/users.component.html b/console-webapp/src/app/users/users.component.html index a193ca13759..44a5ea31e17 100644 --- a/console-webapp/src/app/users/users.component.html +++ b/console-webapp/src/app/users/users.component.html @@ -3,6 +3,62 @@
+ } @else if(selectingExistingUser) { + +
+

Add existing user

+ +

+ +

+

Select registrar from which to add a new user

+

+ + Registrar + + @for (registrar of registrarService.registrars(); track registrar) { + {{ + registrar.registrarId + }} + } + + +

+ @if(usersSelection.length) { + +

+ + +

+ } +
} @else if(usersService.currentlyOpenUserEmail()) { } @else { @@ -10,39 +66,31 @@

Users

- +
+ + +
- - - - {{ column.header }} - - - - - - + } diff --git a/console-webapp/src/app/users/users.component.scss b/console-webapp/src/app/users/users.component.scss index 12f3366e4eb..9aeafbb6fba 100644 --- a/console-webapp/src/app/users/users.component.scss +++ b/console-webapp/src/app/users/users.component.scss @@ -13,26 +13,37 @@ // limitations under the License. .console-app { + &__users { + max-width: 1024px; + overflow-x: auto; + } + &__users-spinner { align-items: center; display: flex; justify-content: center; } - $min-width: 756px; - $max-width: 1024px; - - &__users-table { - min-width: $min-width !important; - max-width: $max-width; - } - &__users-new { margin-left: 20px; } + &__users-add-existing { + margin-top: 20px; + > button { + margin-right: 15px; + } + } &__users-header { display: flex; justify-content: space-between; + flex-wrap: wrap; + &-buttons { + display: flex; + flex-wrap: wrap; + button { + margin: 0 15px 15px 0; + } + } } } diff --git a/console-webapp/src/app/users/users.component.ts b/console-webapp/src/app/users/users.component.ts index f2ae187b36a..6623f66e02c 100644 --- a/console-webapp/src/app/users/users.component.ts +++ b/console-webapp/src/app/users/users.component.ts @@ -14,29 +14,18 @@ import { CommonModule } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; -import { Component, effect, ViewChild } from '@angular/core'; +import { Component, effect } 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 { UserEditComponent } from './userEdit.component'; -import { roleToDescription, 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) => `${roleToDescription(record.role)}`, - }, -]; +import { User, UsersService } from './users.service'; +import { UserDataService } from '../shared/services/userData.service'; +import { FormsModule } from '@angular/forms'; +import { UsersListComponent } from './usersList.component'; +import { MatSelectChange } from '@angular/material/select'; @Component({ selector: 'app-users', @@ -44,41 +33,45 @@ export const columns = [ styleUrls: ['./users.component.scss'], standalone: true, imports: [ + FormsModule, MaterialModule, SnackBarModule, CommonModule, SelectedRegistrarModule, + UsersListComponent, UserEditComponent, ], providers: [UsersService], }) export class UsersComponent { - dataSource: MatTableDataSource; - columns = columns; - displayedColumns = this.columns.map((c) => c.columnDef); isLoading = false; - - @ViewChild(MatSort) sort!: MatSort; + selectingExistingUser = false; + selectedRegistrarId = ''; + usersSelection: User[] = []; + selectedExistingUser: User | undefined; constructor( protected registrarService: RegistrarService, protected usersService: UsersService, + private userDataService: UserDataService, 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; + addExistingUser() { + this.selectingExistingUser = true; + this.selectedRegistrarId = ''; + this.usersSelection = []; + this.selectedExistingUser = undefined; + } + + existingUserSelected(user: User) { + this.selectedExistingUser = user; } loadUsers() { @@ -96,7 +89,7 @@ export class UsersComponent { createNewUser() { this.isLoading = true; - this.usersService.createNewUser().subscribe({ + this.usersService.createOrAddNewUser(null).subscribe({ error: (err: HttpErrorResponse) => { this._snackBar.open(err.error || err.message); this.isLoading = false; @@ -107,7 +100,39 @@ export class UsersComponent { }); } - openDetails(emailAddress: string) { - this.usersService.currentlyOpenUserEmail.set(emailAddress); + openDetails(user: User) { + this.usersService.currentlyOpenUserEmail.set(user.emailAddress); + } + + onRegistrarSelectionChange(e: MatSelectChange) { + if (e.value) { + this.usersService.fetchUsersForRegistrar(e.value).subscribe({ + error: (err) => { + this._snackBar.open(err.error || err.message); + }, + next: (users) => { + this.usersSelection = users; + }, + }); + } + } + + submitExistingUser() { + this.isLoading = true; + if (this.selectedExistingUser) { + this.usersService + .createOrAddNewUser(this.selectedExistingUser) + .subscribe({ + error: (err) => { + this._snackBar.open(err.error || err.message); + this.isLoading = false; + }, + complete: () => { + this.isLoading = false; + this.selectingExistingUser = false; + this.loadUsers(); + }, + }); + } } } diff --git a/console-webapp/src/app/users/users.service.ts b/console-webapp/src/app/users/users.service.ts index 400be8a6f63..0374f101a00 100644 --- a/console-webapp/src/app/users/users.service.ts +++ b/console-webapp/src/app/users/users.service.ts @@ -46,6 +46,10 @@ export class UsersService { private registrarService: RegistrarService ) {} + fetchUsersForRegistrar(registrarId: string) { + return this.backendService.getUsers(registrarId); + } + fetchUsers() { return this.backendService .getUsers(this.registrarService.registrarId()) @@ -56,14 +60,16 @@ export class UsersService { ); } - createNewUser() { + createOrAddNewUser(maybeExistingUser: User | null) { return this.backendService - .createUser(this.registrarService.registrarId()) + .createUser(this.registrarService.registrarId(), maybeExistingUser) .pipe( tap((newUser: User) => { - this.users.set([...this.users(), newUser]); - this.currentlyOpenUserEmail.set(newUser.emailAddress); - this.isNewUser = true; + if (newUser) { + this.users.set([...this.users(), newUser]); + this.currentlyOpenUserEmail.set(newUser.emailAddress); + this.isNewUser = true; + } }) ); } diff --git a/console-webapp/src/app/users/usersList.component.html b/console-webapp/src/app/users/usersList.component.html new file mode 100644 index 00000000000..b3d08fbee9e --- /dev/null +++ b/console-webapp/src/app/users/usersList.component.html @@ -0,0 +1,24 @@ +
+ + + + {{ column.header }} + + + + + + +
diff --git a/console-webapp/src/app/users/usersList.component.scss b/console-webapp/src/app/users/usersList.component.scss new file mode 100644 index 00000000000..1bbccc6a2ec --- /dev/null +++ b/console-webapp/src/app/users/usersList.component.scss @@ -0,0 +1,14 @@ +.console-app { + &__users-table { + min-width: 616px; + .rowSelected { + background-color: var(--light-highlight); + font-weight: bold; + } + } + + &__users-table-wrapper { + width: 100%; + overflow: auto; + } +} diff --git a/console-webapp/src/app/users/usersList.component.ts b/console-webapp/src/app/users/usersList.component.ts new file mode 100644 index 00000000000..3de4377b775 --- /dev/null +++ b/console-webapp/src/app/users/usersList.component.ts @@ -0,0 +1,78 @@ +// 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 { + Component, + effect, + EventEmitter, + input, + Output, + ViewChild, +} from '@angular/core'; +import { MaterialModule } from '../material.module'; +import { User, roleToDescription } from './users.service'; +import { MatTableDataSource } from '@angular/material/table'; +import { MatSort } from '@angular/material/sort'; + +export const columns = [ + { + columnDef: 'emailAddress', + header: 'User email', + cell: (record: User) => `${record.emailAddress || ''}`, + }, + { + columnDef: 'role', + header: 'User role', + cell: (record: User) => `${roleToDescription(record.role)}`, + }, +]; + +@Component({ + selector: 'app-users-list', + templateUrl: './usersList.component.html', + styleUrls: ['./usersList.component.scss'], + standalone: true, + imports: [MaterialModule, CommonModule], + providers: [], +}) +export class UsersListComponent { + columns = columns; + displayedColumns = this.columns.map((c) => c.columnDef); + dataSource: MatTableDataSource; + selectedRow!: User; + users = input([]); + @Output() onSelect = new EventEmitter(); + @ViewChild(MatSort) sort!: MatSort; + + constructor() { + this.dataSource = new MatTableDataSource(this.users()); + effect(() => { + this.dataSource.data = this.users(); + }); + } + + ngAfterViewInit() { + this.dataSource.sort = this.sort; + } + + onClick(row: User) { + this.selectedRow = row; + this.onSelect.emit(row); + } + + isRowSelected(row: User) { + return row === this.selectedRow; + } +} diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleUserDataAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleUserDataAction.java index cb211e00cd5..f9b703e737f 100644 --- a/core/src/main/java/google/registry/ui/server/console/ConsoleUserDataAction.java +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleUserDataAction.java @@ -82,6 +82,8 @@ protected void getHandler(User user) { // auth checks. "isAdmin", user.getUserRoles().isAdmin(), "globalRole", user.getUserRoles().getGlobalRole(), + // registrar-specific roles + "userRoles", user.getUserRoles().getRegistrarRoles(), // Include static contact resources in this call to minimize round trips "productName", productName, "supportEmail", supportEmail, 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 d0f839a03f8..fd27b35c201 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 @@ -48,6 +48,8 @@ import google.registry.util.StringGenerator; import java.io.IOException; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -100,7 +102,12 @@ protected void postHandler(User user) { // Temporary flag while testing if (user.getUserRoles().isAdmin()) { checkPermission(user, registrarId, ConsolePermission.MANAGE_USERS); - tm().transact(this::runCreateInTransaction); + if (userData.isPresent()) { // Adding existing user to registrar + tm().transact(this::runAppendUserInTransaction); + } else { // Adding new user to registrar + tm().transact(this::runCreateInTransaction); + } + } else { consoleApiParams.response().setStatus(SC_FORBIDDEN); } @@ -111,7 +118,7 @@ protected void putHandler(User user) { // Temporary flag while testing if (user.getUserRoles().isAdmin()) { checkPermission(user, registrarId, ConsolePermission.MANAGE_USERS); - tm().transact(() -> runUpdateInTransaction()); + tm().transact(this::runUpdateInTransaction); } else { consoleApiParams.response().setStatus(SC_FORBIDDEN); } @@ -145,21 +152,46 @@ protected void deleteHandler(User user) { } } + private void runAppendUserInTransaction() { + if (!isModifyingRequestValid(false)) { + return; + } + ImmutableList allRegistrarUsers = getAllRegistrarUsers(registrarId); + if (allRegistrarUsers.size() >= 4) + throw new BadRequestException("Total users amount per registrar is limited to 4"); + + updateUserRegistrarRoles( + this.userData.get().emailAddress, + registrarId, + RegistrarRole.valueOf(this.userData.get().role), + false); + consoleApiParams.response().setStatus(SC_OK); + } + private void runDeleteInTransaction() throws IOException { - if (!isModifyingRequestValid()) { + if (!isModifyingRequestValid(true)) { return; } + String email = this.userData.get().emailAddress; - try { - directory.users().delete(email).execute(); - } catch (IOException e) { - setFailedResponse("Failed to delete the user workspace account", SC_INTERNAL_SERVER_ERROR); - throw e; + User updatedUser = + updateUserRegistrarRoles( + email, registrarId, RegistrarRole.valueOf(this.userData.get().role), true); + + // User has no registrars assigned + if (updatedUser.getUserRoles().getRegistrarRoles().size() == 0) { + try { + directory.users().delete(email).execute(); + } catch (IOException e) { + setFailedResponse("Failed to delete the user workspace account", SC_INTERNAL_SERVER_ERROR); + throw e; + } + + VKey key = VKey.create(User.class, email); + tm().delete(key); + User.revokeIapPermission(email, maybeGroupEmailAddress, cloudTasksUtils, null, iamClient); } - VKey key = VKey.create(User.class, email); - tm().delete(key); - User.revokeIapPermission(email, maybeGroupEmailAddress, cloudTasksUtils, null, iamClient); consoleApiParams.response().setStatus(SC_OK); } @@ -220,27 +252,19 @@ private void runCreateInTransaction() throws IOException { } private void runUpdateInTransaction() { - if (!isModifyingRequestValid()) { + if (!isModifyingRequestValid(true)) { return; } - UserData userData = this.userData.get(); - UserRoles userRoles = - new UserRoles.Builder() - .setRegistrarRoles(ImmutableMap.of(registrarId, RegistrarRole.valueOf(userData.role))) - .build(); - User updatedUser = - tm().loadByKeyIfPresent(VKey.create(User.class, userData.emailAddress)) - .get() - .asBuilder() - .setUserRoles(userRoles) - .build(); - - tm().put(updatedUser); + updateUserRegistrarRoles( + this.userData.get().emailAddress, + registrarId, + RegistrarRole.valueOf(this.userData.get().role), + false); consoleApiParams.response().setStatus(SC_OK); } - private boolean isModifyingRequestValid() { + private boolean isModifyingRequestValid(boolean verifyAccess) { if (userData.isEmpty() || isNullOrEmpty(userData.get().emailAddress) || isNullOrEmpty(userData.get().role)) { @@ -252,7 +276,7 @@ private boolean isModifyingRequestValid() { .orElseThrow( () -> new BadRequestException(String.format("User %s doesn't exist", email))); - if (!userToUpdate.getUserRoles().getRegistrarRoles().containsKey(registrarId)) { + if (verifyAccess && !userToUpdate.getUserRoles().getRegistrarRoles().containsKey(registrarId)) { setFailedResponse( String.format("Can't update user not associated with registrarId %s", registrarId), SC_FORBIDDEN); @@ -261,6 +285,36 @@ private boolean isModifyingRequestValid() { return true; } + private User updateUserRegistrarRoles( + String email, String registrarId, RegistrarRole newRole, boolean isDelete) { + User userToUpdate = tm().loadByKeyIfPresent(VKey.create(User.class, email)).get(); + Map updatedRegistrarRoles; + if (isDelete) { + updatedRegistrarRoles = + userToUpdate.getUserRoles().getRegistrarRoles().entrySet().stream() + .filter(entry -> !Objects.equals(entry.getKey(), registrarId)) + .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, Map.Entry::getValue)); + } else { + updatedRegistrarRoles = + ImmutableMap.builder() + .putAll(userToUpdate.getUserRoles().getRegistrarRoles()) + .put(registrarId, newRole) + .buildKeepingLast(); + } + var updatedUser = + userToUpdate + .asBuilder() + .setUserRoles( + userToUpdate + .getUserRoles() + .asBuilder() + .setRegistrarRoles(updatedRegistrarRoles) + .build()) + .build(); + tm().put(updatedUser); + return updatedUser; + } + private ImmutableList getAllRegistrarUsers(String registrarId) { return tm().transact( () -> diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleUserDataActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleUserDataActionTest.java index b1938d7a36d..d3054aaad89 100644 --- a/core/src/test/java/google/registry/ui/server/console/ConsoleUserDataActionTest.java +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleUserDataActionTest.java @@ -20,6 +20,7 @@ import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; import static org.mockito.Mockito.when; +import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import google.registry.model.console.User; import google.registry.persistence.transaction.JpaTestExtensions; @@ -71,6 +72,8 @@ void testSuccess_getContactInfo() throws IOException { GSON.fromJson(((FakeResponse) consoleApiParams.response()).getPayload(), Map.class); assertThat(jsonObject) .containsExactly( + "userRoles", + ImmutableMap.of(), "isAdmin", true, "technicalDocsUrl", 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 24c6767ce7f..dbe13c4a324 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 @@ -246,6 +246,50 @@ void testSuccess_deletesUser() throws IOException { .isEmpty(); } + @Test + void testSuccess_removesRole() throws IOException { + User user1 = DatabaseHelper.loadByKey(VKey.create(User.class, "test1@test.com")); + AuthResult authResult = + AuthResult.createUser( + user1 + .asBuilder() + .setUserRoles(user1.getUserRoles().asBuilder().setIsAdmin(true).build()) + .build()); + DatabaseHelper.persistResource( + new User.Builder() + .setEmailAddress("test4@test.com") + .setUserRoles( + new UserRoles() + .asBuilder() + .setRegistrarRoles( + ImmutableMap.of( + "TheRegistrar", + RegistrarRole.PRIMARY_CONTACT, + "SomeRegistrar", + RegistrarRole.PRIMARY_CONTACT)) + .build()) + .build()); + + ConsoleUsersAction action = + createAction( + Optional.of(ConsoleApiParamsUtils.createFake(authResult)), + Optional.of("DELETE"), + Optional.of( + new UserData("test4@test.com", RegistrarRole.ACCOUNT_MANAGER.toString(), null))); + + action.cloudTasksUtils = cloudTasksHelper.getTestCloudTasksUtils(); + when(directory.users()).thenReturn(users); + when(users.delete(any(String.class))).thenReturn(delete); + action.run(); + var response = ((FakeResponse) consoleApiParams.response()); + assertThat(response.getStatus()).isEqualTo(SC_OK); + Optional actualUser = + DatabaseHelper.loadByKeyIfPresent(VKey.create(User.class, "test4@test.com")); + assertThat(actualUser).isPresent(); + assertThat(actualUser.get().getUserRoles().getRegistrarRoles().containsKey("TheRegistrar")) + .isFalse(); + } + @Test void testFailure_limitedTo4UsersPerRegistrar() throws IOException { User user1 = DatabaseHelper.loadByKey(VKey.create(User.class, "test1@test.com"));