Merge pull request #4289 from FinnStutzenstein/resetPassword
Rework the reset password component (fixes #4079)
This commit is contained in:
commit
b5294cc5fd
@ -132,11 +132,15 @@ export class UserRepositoryService extends BaseRepository<ViewUser, User> {
|
|||||||
*
|
*
|
||||||
* @param user The user to update
|
* @param user The user to update
|
||||||
* @param password The password to set
|
* @param password The password to set
|
||||||
|
* @param updateDefaultPassword Control, if the default password should be updated.
|
||||||
*/
|
*/
|
||||||
public async resetPassword(user: ViewUser, password: string): Promise<void> {
|
public async resetPassword(
|
||||||
|
user: ViewUser,
|
||||||
|
password: string,
|
||||||
|
updateDefaultPassword: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
const path = `/rest/users/user/${user.id}/reset_password/`;
|
const path = `/rest/users/user/${user.id}/reset_password/`;
|
||||||
await this.httpService.post(path, { password: password });
|
await this.httpService.post(path, { password: password, update_default_password: updateDefaultPassword });
|
||||||
await this.update({ default_password: password }, user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -11,24 +11,37 @@
|
|||||||
<!-- can Manage, but not own Page (a.k.a. Admin) -->
|
<!-- can Manage, but not own Page (a.k.a. Admin) -->
|
||||||
<div *ngIf="user">
|
<div *ngIf="user">
|
||||||
<h1><span translate>Change password for</span> {{ user.full_name }}</h1>
|
<h1><span translate>Change password for</span> {{ user.full_name }}</h1>
|
||||||
<mat-icon>warning</mat-icon> <span translate>You override the personally set password!</span>
|
<mat-icon>warning</mat-icon>
|
||||||
|
<span translate>You override the personally set password!</span>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
<form [formGroup]="adminPasswordForm" (keydown)="onKeyDown($event)">
|
<form [formGroup]="adminPasswordForm" (keydown)="onKeyDown($event)">
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<input
|
<input
|
||||||
[type]="hide_admin_newPassword ? 'password' : 'text'"
|
[type]="hidePassword ? 'password' : 'text'"
|
||||||
matInput
|
matInput
|
||||||
formControlName="admin_newPassword"
|
formControlName="newPassword"
|
||||||
placeholder="{{ 'New password' | translate }}"
|
placeholder="{{ 'New password' | translate }}"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<mat-icon
|
<mat-icon
|
||||||
class="pointer"
|
class="pointer"
|
||||||
matSuffix
|
matSuffix
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
(click)="admin_generatePassword()">
|
matTooltip="{{ hidePassword ? ('Show password' | translate) : ('Hide password' | translate) }}"
|
||||||
sync_problem
|
(click)="hidePassword = !hidePassword">
|
||||||
|
{{ hidePassword ? 'visibility' : 'visibility_off' }}
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
|
<mat-icon
|
||||||
|
class="pointer spacer-left-10"
|
||||||
|
matSuffix
|
||||||
|
mat-icon-button
|
||||||
|
matTooltip="{{ 'Generate password' | translate }}"
|
||||||
|
(click)="generatePassword()">
|
||||||
|
settings
|
||||||
|
</mat-icon>
|
||||||
|
|
||||||
|
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</form>
|
</form>
|
||||||
<br>
|
<br>
|
||||||
@ -36,62 +49,45 @@
|
|||||||
<span translate>Initial password</span>: {{ user.default_password }}<br>
|
<span translate>Initial password</span>: {{ user.default_password }}<br>
|
||||||
<span translate>Username</span>: {{ user.username }}
|
<span translate>Username</span>: {{ user.username }}
|
||||||
</div>
|
</div>
|
||||||
<br>
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
(click)="hide_admin_newPassword = !hide_admin_newPassword">
|
|
||||||
<mat-icon
|
|
||||||
matSuffix
|
|
||||||
mat-icon-button>
|
|
||||||
{{hide_admin_newPassword ? 'visibility_off' : 'visibility'}}
|
|
||||||
</mat-icon>
|
|
||||||
<span translate>Show password</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="this.ownPage">
|
<div *ngIf="this.ownPage">
|
||||||
<!-- can not Manage, but own Page (a.k.a. User) -->
|
<!-- can not Manage, but own Page (a.k.a. User) -->
|
||||||
<form [formGroup]="userPasswordForm" (keydown)="onKeyDown($event)">
|
<form [formGroup]="userPasswordForm" (keydown)="onKeyDown($event)">
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<input
|
<input
|
||||||
[type]="hide_user_password ? 'password' : 'text'"
|
|
||||||
matInput
|
matInput
|
||||||
formControlName="user_oldPassword"
|
formControlName="oldPassword"
|
||||||
placeholder="{{ 'Old password' | translate }}"
|
placeholder="{{ 'Old password' | translate }}"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</mat-form-field><br>
|
</mat-form-field><br>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<input
|
<input
|
||||||
[type]="hide_user_password ? 'password' : 'text'"
|
[type]="hidePassword ? 'password' : 'text'"
|
||||||
matInput
|
matInput
|
||||||
formControlName="user_newPassword1"
|
formControlName="newPassword1"
|
||||||
placeholder="{{ 'New password' | translate }}"
|
placeholder="{{ 'New password' | translate }}"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<mat-icon
|
<mat-icon
|
||||||
mat-button matSuffix mat-icon-button
|
mat-button matSuffix mat-icon-button
|
||||||
(click)="user_generatePassword()">
|
(click)="hidePassword = !hidePassword">
|
||||||
sync_problem
|
{{ hidePassword ? 'visibility' : 'visibility_off' }}
|
||||||
</mat-icon>
|
</mat-icon>
|
||||||
</mat-form-field><br>
|
</mat-form-field><br>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<input
|
<input
|
||||||
[type]="hide_user_password ? 'password' : 'text'"
|
[type]="hidePassword ? 'password' : 'text'"
|
||||||
matInput
|
matInput
|
||||||
formControlName="user_newPassword2"
|
formControlName="newPassword2"
|
||||||
placeholder="{{ 'Confirm new password' | translate }}"
|
placeholder="{{ 'Confirm new password' | translate }}"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</form>
|
</form>
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
(click)="hide_user_password = !hide_user_password">
|
|
||||||
<mat-icon
|
|
||||||
matSuffix
|
|
||||||
mat-icon-button>
|
|
||||||
{{ hide_user_password ? 'visibility_off' : 'visibility' }}
|
|
||||||
</mat-icon>
|
|
||||||
<span translate>Show password</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
|
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
|
||||||
import { MatSnackBar } from '@angular/material';
|
import { MatSnackBar } from '@angular/material';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
import { ViewUser } from '../../models/view-user';
|
import { ViewUser } from '../../models/view-user';
|
||||||
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
|
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
|
||||||
import { OperatorService } from 'app/core/core-services/operator.service';
|
import { OperatorService } from 'app/core/core-services/operator.service';
|
||||||
@ -29,11 +30,6 @@ export class PasswordComponent extends BaseViewComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
public ownPage: boolean;
|
public ownPage: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* user id from url parameter
|
|
||||||
*/
|
|
||||||
public userId: number;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* if current user has the "can_manage" permission
|
* if current user has the "can_manage" permission
|
||||||
*/
|
*/
|
||||||
@ -50,14 +46,11 @@ export class PasswordComponent extends BaseViewComponent implements OnInit {
|
|||||||
public userPasswordForm: FormGroup;
|
public userPasswordForm: FormGroup;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* if the new password in userform is hidden
|
* if all password inputs is hidden
|
||||||
*/
|
*/
|
||||||
public hide_user_password = true;
|
public hidePassword = true;
|
||||||
|
|
||||||
/**
|
private urlUserId: number | null;
|
||||||
* If the new Password in the adminform is hidden
|
|
||||||
*/
|
|
||||||
public hide_admin_newPassword = true;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
@ -82,39 +75,48 @@ export class PasswordComponent extends BaseViewComponent implements OnInit {
|
|||||||
private formBuilder: FormBuilder
|
private formBuilder: FormBuilder
|
||||||
) {
|
) {
|
||||||
super(title, translate, matSnackBar);
|
super(title, translate, matSnackBar);
|
||||||
this.route.params.subscribe(params => {
|
|
||||||
if (params.id) {
|
|
||||||
this.userId = params.id;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (this.userId === undefined) {
|
|
||||||
this.operator.getUserObservable().subscribe(user => {
|
|
||||||
if (user) {
|
|
||||||
this.userId = user.id;
|
|
||||||
this.router.navigate([`./users/password/${this.userId}`]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the forms and some of the frontend options
|
* Initializes the forms and some of the frontend options
|
||||||
*/
|
*/
|
||||||
public ngOnInit(): void {
|
public ngOnInit(): void {
|
||||||
this.setViewUser(this.userId);
|
this.route.params.subscribe(params => {
|
||||||
this.setOpOwnsPage(this.userId);
|
if (params.id) {
|
||||||
|
this.urlUserId = +params.id;
|
||||||
|
this.repo.getViewModelObservable(this.urlUserId).subscribe(() => {
|
||||||
|
this.updateUser();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.updateUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.operator.getUserObservable().subscribe(() => {
|
||||||
|
this.updateUser();
|
||||||
|
});
|
||||||
|
|
||||||
this.adminPasswordForm = this.formBuilder.group({
|
this.adminPasswordForm = this.formBuilder.group({
|
||||||
admin_newPassword: ['', Validators.required]
|
newPassword: ['', Validators.required]
|
||||||
});
|
});
|
||||||
|
|
||||||
this.userPasswordForm = this.formBuilder.group({
|
this.userPasswordForm = this.formBuilder.group({
|
||||||
user_newPassword1: ['', Validators.required],
|
newPassword1: ['', Validators.required],
|
||||||
user_newPassword2: ['', Validators.required],
|
newPassword2: ['', Validators.required],
|
||||||
user_oldPassword: ['', Validators.required]
|
oldPassword: ['', Validators.required]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateUser(): void {
|
||||||
|
const operator = this.operator.user;
|
||||||
|
this.ownPage = this.urlUserId ? operator.id === this.urlUserId : true;
|
||||||
|
if (this.ownPage) {
|
||||||
|
this.user = this.operator.viewUser;
|
||||||
|
} else {
|
||||||
|
this.user = this.repo.getViewModel(this.urlUserId);
|
||||||
|
}
|
||||||
|
this.canManage = this.operator.hasPerms('users.can_manage');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggered by the "x" Button of the Form
|
* Triggered by the "x" Button of the Form
|
||||||
*/
|
*/
|
||||||
@ -126,34 +128,6 @@ export class PasswordComponent extends BaseViewComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* sets the current user that should be worked on
|
|
||||||
*
|
|
||||||
* @param userId user id from the route
|
|
||||||
*/
|
|
||||||
private setViewUser(userId: number): void {
|
|
||||||
this.repo.getViewModelObservable(userId).subscribe(newViewUser => {
|
|
||||||
if (newViewUser) {
|
|
||||||
this.user = newViewUser;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* sets the parameters if the pw-page is our own and if the current
|
|
||||||
* user has the can_manage permission
|
|
||||||
*
|
|
||||||
* @param userId user id from the route
|
|
||||||
*/
|
|
||||||
private setOpOwnsPage(userId: number): void {
|
|
||||||
this.operator.getUserObservable().subscribe(user => {
|
|
||||||
if (user) {
|
|
||||||
this.ownPage = +userId === +user.id;
|
|
||||||
this.canManage = this.operator.hasPerms('users.can_manage');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the whole save routine for every possible event
|
* Handles the whole save routine for every possible event
|
||||||
*/
|
*/
|
||||||
@ -161,46 +135,32 @@ export class PasswordComponent extends BaseViewComponent implements OnInit {
|
|||||||
// can Manage, but not own Page (a.k.a. Admin)
|
// can Manage, but not own Page (a.k.a. Admin)
|
||||||
try {
|
try {
|
||||||
if (this.canManage && !this.ownPage) {
|
if (this.canManage && !this.ownPage) {
|
||||||
const pw = this.adminPasswordForm.get('admin_newPassword').value;
|
if (!this.adminPasswordForm.valid) {
|
||||||
this.adminNewPassword(pw);
|
return;
|
||||||
|
}
|
||||||
|
const password = this.adminPasswordForm.value.newPassword;
|
||||||
|
await this.repo.resetPassword(this.user, password);
|
||||||
this.router.navigate([`./users/${this.user.id}`]);
|
this.router.navigate([`./users/${this.user.id}`]);
|
||||||
}
|
} else if (this.ownPage) {
|
||||||
// can not Manage, but own Page (a.k.a. User)
|
if (!this.userPasswordForm.valid) {
|
||||||
if (this.ownPage) {
|
return;
|
||||||
const oldPw = this.userPasswordForm.get('user_oldPassword').value;
|
}
|
||||||
const newPw1 = this.userPasswordForm.get('user_newPassword1').value;
|
const oldPassword = this.userPasswordForm.value.oldPassword;
|
||||||
const newPw2 = this.userPasswordForm.get('user_newPassword2').value;
|
const newPassword1 = this.userPasswordForm.value.newPassword1;
|
||||||
await this.userNewPassword(newPw1, newPw2, oldPw);
|
const newPassword2 = this.userPasswordForm.value.newPassword2;
|
||||||
this.router.navigate(['./']);
|
|
||||||
|
if (newPassword1 !== newPassword2) {
|
||||||
|
this.raiseError(this.translate.instant('New passwords do not match'));
|
||||||
|
} else {
|
||||||
|
await this.repo.setNewPassword(oldPassword, newPassword1);
|
||||||
|
this.router.navigate(['./']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.raiseError(e);
|
this.raiseError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends new Password entered in the new password field to server
|
|
||||||
*
|
|
||||||
* @param password the password that should be set
|
|
||||||
*/
|
|
||||||
private adminNewPassword(password: string): void {
|
|
||||||
this.repo.resetPassword(this.user, password).catch(this.raiseError);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* sets the new password for a user and sends it to the server
|
|
||||||
*
|
|
||||||
* @param newPassword1 the new password
|
|
||||||
* @param newPassword2 confirmation of the new password
|
|
||||||
* @param oldPassword the old password
|
|
||||||
*/
|
|
||||||
private userNewPassword(newPassword1: string, newPassword2: string, oldPassword: string): void {
|
|
||||||
if (newPassword1 !== newPassword2) {
|
|
||||||
this.raiseError(this.translate.instant('Passwords do not match'));
|
|
||||||
}
|
|
||||||
this.repo.setNewPassword(oldPassword, newPassword1).catch(this.raiseError);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* clicking Shift and Enter will save automatically
|
* clicking Shift and Enter will save automatically
|
||||||
*
|
*
|
||||||
@ -216,49 +176,15 @@ export class PasswordComponent extends BaseViewComponent implements OnInit {
|
|||||||
* Takes generated password and puts it into admin PW field
|
* Takes generated password and puts it into admin PW field
|
||||||
* and displays it
|
* and displays it
|
||||||
*/
|
*/
|
||||||
public admin_generatePassword(): void {
|
public generatePassword(): void {
|
||||||
|
const randomPassword = this.repo.getRandomPassword();
|
||||||
this.adminPasswordForm.patchValue({
|
this.adminPasswordForm.patchValue({
|
||||||
admin_newPassword: this.repo.getRandomPassword()
|
newPassword: randomPassword
|
||||||
});
|
});
|
||||||
this.admin_hidePassword(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Takes generated password and puts it into user PW fields
|
|
||||||
* and displays them
|
|
||||||
*/
|
|
||||||
public user_generatePassword(): void {
|
|
||||||
const newPW = this.repo.getRandomPassword();
|
|
||||||
this.userPasswordForm.patchValue({
|
this.userPasswordForm.patchValue({
|
||||||
user_newPassword1: newPW,
|
newPassword1: randomPassword,
|
||||||
user_newPassword2: newPW
|
newPassword2: randomPassword
|
||||||
});
|
});
|
||||||
this.user_hidePassword(false);
|
this.hidePassword = false;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to hide or display the pw in cleartext for admin form
|
|
||||||
*
|
|
||||||
* @param hide optional - states if it should be shown or not
|
|
||||||
*/
|
|
||||||
public admin_hidePassword(hide?: boolean): void {
|
|
||||||
if (hide !== null) {
|
|
||||||
this.hide_admin_newPassword = hide;
|
|
||||||
} else {
|
|
||||||
this.hide_admin_newPassword = !this.hide_admin_newPassword;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to hide or display new pw in clearext for user form
|
|
||||||
*
|
|
||||||
* @param hide optional - states if it should be shown or not
|
|
||||||
*/
|
|
||||||
public user_hidePassword(hide?: boolean): void {
|
|
||||||
if (hide !== null) {
|
|
||||||
this.hide_user_password = hide;
|
|
||||||
} else {
|
|
||||||
this.hide_user_password = !this.hide_user_password;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -273,7 +273,7 @@ export class UserListComponent extends ListViewBaseComponent<ViewUser> implement
|
|||||||
public async resetPasswordsSelected(): Promise<void> {
|
public async resetPasswordsSelected(): Promise<void> {
|
||||||
for (const user of this.selectedRows) {
|
for (const user of this.selectedRows) {
|
||||||
const password = this.repo.getRandomPassword();
|
const password = this.repo.getRandomPassword();
|
||||||
this.repo.resetPassword(user, password);
|
this.repo.resetPassword(user, password, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -398,6 +398,11 @@ button.mat-menu-item.selected {
|
|||||||
.spacer-bottom-20 {
|
.spacer-bottom-20 {
|
||||||
margin-bottom: 20px !important;
|
margin-bottom: 20px !important;
|
||||||
}
|
}
|
||||||
|
.spacer-left-10 {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.button24 {
|
.button24 {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
width: 24px !important;
|
width: 24px !important;
|
||||||
|
@ -141,18 +141,29 @@ class UserViewSet(ModelViewSet):
|
|||||||
def reset_password(self, request, pk=None):
|
def reset_password(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
View to reset the password using the requested password.
|
View to reset the password using the requested password.
|
||||||
|
If update_defualt_password=True is given, the new password will also be set
|
||||||
|
as the default_password.
|
||||||
"""
|
"""
|
||||||
user = self.get_object()
|
user = self.get_object()
|
||||||
if isinstance(request.data.get("password"), str):
|
password = request.data.get("password")
|
||||||
try:
|
if not isinstance(password, str):
|
||||||
validate_password(request.data.get("password"), user=request.user)
|
raise ValidationError({"detail": "Password has to be a string."})
|
||||||
except DjangoValidationError as errors:
|
|
||||||
raise ValidationError({"detail": " ".join(errors)})
|
|
||||||
user.set_password(request.data.get("password"))
|
|
||||||
user.save()
|
|
||||||
return Response({"detail": "Password successfully reset."})
|
|
||||||
|
|
||||||
raise ValidationError({"detail": "Password has to be a string."})
|
update_default_password = request.data.get("update_default_password", False)
|
||||||
|
if not isinstance(update_default_password, bool):
|
||||||
|
raise ValidationError(
|
||||||
|
{"detail": "update_default_password has to be a boolean."}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_password(password, user=request.user)
|
||||||
|
except DjangoValidationError as errors:
|
||||||
|
raise ValidationError({"detail": " ".join(errors)})
|
||||||
|
user.set_password(password)
|
||||||
|
if update_default_password:
|
||||||
|
user.default_password = password
|
||||||
|
user.save()
|
||||||
|
return Response({"detail": "Password successfully reset."})
|
||||||
|
|
||||||
@list_route(methods=["post"])
|
@list_route(methods=["post"])
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
Loading…
Reference in New Issue
Block a user