1
0
mirror of https://github.com/google/nomulus synced 2026-01-04 04:04:22 +00:00

Allow adding existing users to registrar (#2616)

This commit is contained in:
Pavlo Tkach
2024-11-27 17:40:32 -05:00
committed by GitHub
parent 21950f7d82
commit fa377733be
14 changed files with 419 additions and 109 deletions

View File

@@ -166,9 +166,9 @@ export class BackendService {
.pipe(catchError((err) => this.errorCatcher<User[]>(err)));
}
createUser(registrarId: string): Observable<User> {
createUser(registrarId: string, maybeUser: User | null): Observable<User> {
return this.http
.post<User>(`/console-api/users?registrarId=${registrarId}`, {})
.post<User>(`/console-api/users?registrarId=${registrarId}`, maybeUser)
.pipe(catchError((err) => this.errorCatcher<User>(err)));
}

View File

@@ -27,6 +27,7 @@ export interface UserData {
supportEmail: string;
supportPhoneNumber: string;
technicalDocsUrl: string;
userRoles?: Map<string, string>;
}
@Injectable({

View File

@@ -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({

View File

@@ -3,6 +3,62 @@
<div class="console-app__users-spinner">
<mat-spinner />
</div>
} @else if(selectingExistingUser) {
<div class="console-app__users">
<h1 class="mat-headline-4">Add existing user</h1>
<p>
<button
mat-icon-button
aria-label="Back to users list"
(click)="selectingExistingUser = false"
>
<mat-icon>arrow_back</mat-icon>
</button>
</p>
<h1>Select registrar from which to add a new user</h1>
<p>
<mat-form-field appearance="outline">
<mat-label>Registrar</mat-label>
<mat-select
[(ngModel)]="selectedRegistrarId"
name="selectedRegistrarId"
(selectionChange)="onRegistrarSelectionChange($event)"
>
@for (registrar of registrarService.registrars(); track registrar) {
<mat-option [value]="registrar.registrarId">{{
registrar.registrarId
}}</mat-option>
}
</mat-select>
</mat-form-field>
</p>
@if(usersSelection.length) {
<app-users-list
[users]="usersSelection"
(onSelect)="existingUserSelected($event)"
/>
<p class="console-app__users-add-existing">
<button
mat-flat-button
color="primary"
aria-label="Add user"
(click)="submitExistingUser()"
[disabled]="!selectedExistingUser"
>
Add user
</button>
<button
mat-stroked-button
aria-label="Cancel adding existing user"
(click)="selectingExistingUser = false"
>
Cancel
</button>
</p>
}
</div>
} @else if(usersService.currentlyOpenUserEmail()) {
<app-user-edit></app-user-edit>
} @else {
@@ -10,39 +66,31 @@
<div class="console-app__users-header">
<h1 class="mat-headline-4">Users</h1>
<div class="spacer"></div>
<button
mat-flat-button
(click)="createNewUser()"
aria-label="Create new user"
color="primary"
>
Create a Viewer User
</button>
<div class="console-app__users-header-buttons">
<button
class="console-app__users-header-add"
mat-stroked-button
(click)="addExistingUser()"
aria-label="Create new user"
color="primary"
>
<mat-icon>add</mat-icon>
Add existing user
</button>
<button
mat-flat-button
(click)="createNewUser()"
aria-label="Create new user"
color="primary"
>
Create a Viewer User
</button>
</div>
</div>
<mat-table
[dataSource]="dataSource"
class="mat-elevation-z0"
class="console-app__users-table"
matSort
>
<ng-container
*ngFor="let column of columns"
[matColumnDef]="column.columnDef"
>
<mat-header-cell *matHeaderCellDef>
{{ column.header }}
</mat-header-cell>
<mat-cell
*matCellDef="let row"
[innerHTML]="column.cell(row)"
></mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row
*matRowDef="let row; columns: displayedColumns"
(click)="openDetails(row.emailAddress)"
></mat-row>
</mat-table>
<app-users-list
[users]="usersService.users()"
(onSelect)="openDetails($event)"
/>
</div>
}
</app-selected-registrar-wrapper>

View File

@@ -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;
}
}
}
}

View File

@@ -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<User>;
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<User>(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();
},
});
}
}
}

View File

@@ -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;
}
})
);
}

View File

@@ -0,0 +1,24 @@
<div class="console-app__users-table-wrapper">
<mat-table
[dataSource]="dataSource"
class="mat-elevation-z0"
class="console-app__users-table"
matSort
>
<ng-container
*ngFor="let column of columns"
[matColumnDef]="column.columnDef"
>
<mat-header-cell *matHeaderCellDef>
{{ column.header }}
</mat-header-cell>
<mat-cell *matCellDef="let row" [innerHTML]="column.cell(row)"></mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row
*matRowDef="let row; columns: displayedColumns"
[class.rowSelected]="isRowSelected(row)"
(click)="onClick(row)"
></mat-row>
</mat-table>
</div>

View File

@@ -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;
}
}

View File

@@ -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<User>;
selectedRow!: User;
users = input<User[]>([]);
@Output() onSelect = new EventEmitter<User>();
@ViewChild(MatSort) sort!: MatSort;
constructor() {
this.dataSource = new MatTableDataSource<User>(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;
}
}

View File

@@ -82,6 +82,8 @@ public class ConsoleUserDataAction extends ConsoleApiAction {
// 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,

View File

@@ -48,6 +48,8 @@ import google.registry.tools.IamClient;
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 @@ public class ConsoleUsersAction extends ConsoleApiAction {
// 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 @@ public class ConsoleUsersAction extends ConsoleApiAction {
// 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 @@ public class ConsoleUsersAction extends ConsoleApiAction {
}
}
private void runDeleteInTransaction() throws IOException {
if (!isModifyingRequestValid()) {
private void runAppendUserInTransaction() {
if (!isModifyingRequestValid(false)) {
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;
ImmutableList<User> 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(true)) {
return;
}
String email = this.userData.get().emailAddress;
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<User> key = VKey.create(User.class, email);
tm().delete(key);
User.revokeIapPermission(email, maybeGroupEmailAddress, cloudTasksUtils, null, iamClient);
}
VKey<User> 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 @@ public class ConsoleUsersAction extends ConsoleApiAction {
}
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 @@ public class ConsoleUsersAction extends ConsoleApiAction {
.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 @@ public class ConsoleUsersAction extends ConsoleApiAction {
return true;
}
private User updateUserRegistrarRoles(
String email, String registrarId, RegistrarRole newRole, boolean isDelete) {
User userToUpdate = tm().loadByKeyIfPresent(VKey.create(User.class, email)).get();
Map<String, RegistrarRole> 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.<String, RegistrarRole>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<User> getAllRegistrarUsers(String registrarId) {
return tm().transact(
() ->

View File

@@ -20,6 +20,7 @@ import static jakarta.servlet.http.HttpServletResponse.SC_OK;
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 @@ class ConsoleUserDataActionTest {
GSON.fromJson(((FakeResponse) consoleApiParams.response()).getPayload(), Map.class);
assertThat(jsonObject)
.containsExactly(
"userRoles",
ImmutableMap.of(),
"isAdmin",
true,
"technicalDocsUrl",

View File

@@ -246,6 +246,50 @@ class ConsoleUsersActionTest {
.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<User> 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"));