mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-22 16:29:09 +01:00
Password reprompt (#1784)
* Add support for password reprompt * Rename passwordPrompt to reprompt. * Move showPasswordDialog to paltformutils * Fix swal2 validation error styling * Group imports * Update src/_locales/en/messages.json Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
This commit is contained in:
parent
2c58dbb344
commit
cdc71dd661
2
jslib
2
jslib
@ -1 +1 @@
|
||||
Subproject commit b6f102938fe7c17631cb1b2e356438c5e4456529
|
||||
Subproject commit a72c8a60c1b7a6980bceee456c53a9ea7b9b3451
|
@ -1703,5 +1703,14 @@
|
||||
},
|
||||
"sendOptionsPolicyInEffect": {
|
||||
"message": "One or more organization policies are affecting your Send options."
|
||||
},
|
||||
"passwordPrompt": {
|
||||
"message": "Master password re-prompt"
|
||||
},
|
||||
"passwordConfirmation": {
|
||||
"message": "Master password confirmation"
|
||||
},
|
||||
"passwordConfirmationDesc": {
|
||||
"message": "This action is protected. To continue, please re-enter your master password to verify your identity."
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { CipherType } from 'jslib/enums';
|
||||
import { CipherRepromptType } from 'jslib/enums/cipherRepromptType';
|
||||
|
||||
import {
|
||||
ApiService,
|
||||
@ -63,6 +64,7 @@ import { PolicyService as PolicyServiceAbstraction } from 'jslib/abstractions/po
|
||||
import { SearchService as SearchServiceAbstraction } from 'jslib/abstractions/search.service';
|
||||
import { SendService as SendServiceAbstraction } from 'jslib/abstractions/send.service';
|
||||
import { SystemService as SystemServiceAbstraction } from 'jslib/abstractions/system.service';
|
||||
import { AutofillService as AutofillServiceAbstraction } from '../services/abstractions/autofill.service';
|
||||
|
||||
import { Utils } from 'jslib/misc/utils';
|
||||
|
||||
@ -86,8 +88,6 @@ import BrowserStorageService from '../services/browserStorage.service';
|
||||
import I18nService from '../services/i18n.service';
|
||||
import VaultTimeoutService from '../services/vaultTimeout.service';
|
||||
|
||||
import { AutofillService as AutofillServiceAbstraction } from '../services/abstractions/autofill.service';
|
||||
|
||||
export default class MainBackground {
|
||||
messagingService: MessagingServiceAbstraction;
|
||||
storageService: StorageServiceAbstraction;
|
||||
@ -583,7 +583,7 @@ export default class MainBackground {
|
||||
}
|
||||
|
||||
private async loadLoginContextMenuOptions(cipher: any) {
|
||||
if (cipher == null || cipher.type !== CipherType.Login) {
|
||||
if (cipher == null || cipher.type !== CipherType.Login || cipher.reprompt !== CipherRepromptType.None) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -33,6 +33,7 @@ import { StorageService } from 'jslib/abstractions/storage.service';
|
||||
|
||||
import { ConstantsService } from 'jslib/services/constants.service';
|
||||
|
||||
import BrowserPlatformUtilsService from 'src/services/browserPlatformUtils.service';
|
||||
import { routerTransition } from './app-routing.animations';
|
||||
|
||||
@Component({
|
||||
@ -105,6 +106,8 @@ export class AppComponent implements OnInit {
|
||||
});
|
||||
} else if (msg.command === 'showDialog') {
|
||||
await this.showDialog(msg);
|
||||
} else if (msg.command === 'showPasswordDialog') {
|
||||
await this.showPasswordDialog(msg);
|
||||
} else if (msg.command === 'showToast') {
|
||||
this.ngZone.run(() => {
|
||||
this.showToast(msg);
|
||||
@ -248,4 +251,30 @@ export class AppComponent implements OnInit {
|
||||
confirmed: confirmed.value,
|
||||
});
|
||||
}
|
||||
|
||||
private async showPasswordDialog(msg: any) {
|
||||
const platformUtils = this.platformUtilsService as BrowserPlatformUtilsService;
|
||||
const result = await Swal.fire({
|
||||
heightAuto: false,
|
||||
title: msg.title,
|
||||
input: 'password',
|
||||
text: msg.body,
|
||||
confirmButtonText: this.i18nService.t('ok'),
|
||||
showCancelButton: true,
|
||||
cancelButtonText: this.i18nService.t('cancel'),
|
||||
inputAttributes: {
|
||||
autocapitalize: 'off',
|
||||
autocorrect: 'off',
|
||||
},
|
||||
inputValidator: async (value: string): Promise<any> => {
|
||||
if (await platformUtils.resolvePasswordDialogPromise(msg.dialogId, false, value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.i18nService.t('invalidMasterPassword');
|
||||
},
|
||||
});
|
||||
|
||||
platformUtils.resolvePasswordDialogPromise(msg.dialogId, true, null);
|
||||
}
|
||||
}
|
||||
|
@ -14,12 +14,11 @@ import { CipherView } from 'jslib/models/view/cipherView';
|
||||
|
||||
import { EventService } from 'jslib/abstractions/event.service';
|
||||
import { I18nService } from 'jslib/abstractions/i18n.service';
|
||||
import { PasswordRepromptService } from 'jslib/abstractions/passwordReprompt.service';
|
||||
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
|
||||
import { TotpService } from 'jslib/abstractions/totp.service';
|
||||
import { UserService } from 'jslib/abstractions/user.service';
|
||||
|
||||
import { PopupUtilsService } from '../services/popup-utils.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-action-buttons',
|
||||
templateUrl: 'action-buttons.component.html',
|
||||
@ -35,7 +34,8 @@ export class ActionButtonsComponent {
|
||||
|
||||
constructor(private toasterService: ToasterService, private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService, private eventService: EventService,
|
||||
private totpService: TotpService, private userService: UserService) { }
|
||||
private totpService: TotpService, private userService: UserService,
|
||||
private passwordRepromptService: PasswordRepromptService) { }
|
||||
|
||||
async ngOnInit() {
|
||||
this.userHasPremiumAccess = await this.userService.canAccessPremium();
|
||||
@ -46,6 +46,10 @@ export class ActionButtonsComponent {
|
||||
}
|
||||
|
||||
async copy(cipher: CipherView, value: string, typeI18nKey: string, aType: string) {
|
||||
if (this.passwordRepromptService.protectedFields().includes(aType) && !await this.passwordRepromptService.showPasswordPrompt()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value == null || aType === 'TOTP' && !this.displayTotpCopyButton(cipher)) {
|
||||
return;
|
||||
} else if (value === cipher.login.totp) {
|
||||
|
@ -211,6 +211,10 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.swal2-validation-message {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
date-input-polyfill {
|
||||
|
@ -16,6 +16,7 @@ import { MessagingService } from 'jslib/abstractions/messaging.service';
|
||||
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
|
||||
import { PolicyService } from 'jslib/abstractions/policy.service';
|
||||
import { SendService } from 'jslib/abstractions/send.service';
|
||||
import { TokenService } from 'jslib/abstractions/token.service';
|
||||
import { UserService } from 'jslib/abstractions/user.service';
|
||||
|
||||
import { PopupUtilsService } from '../services/popup-utils.service';
|
||||
|
@ -31,6 +31,7 @@ import { I18nService } from 'jslib/abstractions/i18n.service';
|
||||
import { MessagingService } from 'jslib/abstractions/messaging.service';
|
||||
import { NotificationsService } from 'jslib/abstractions/notifications.service';
|
||||
import { PasswordGenerationService } from 'jslib/abstractions/passwordGeneration.service';
|
||||
import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from 'jslib/abstractions/passwordReprompt.service';
|
||||
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
|
||||
import { PolicyService } from 'jslib/abstractions/policy.service';
|
||||
import { SearchService as SearchServiceAbstraction } from 'jslib/abstractions/search.service';
|
||||
@ -43,6 +44,7 @@ import { TokenService } from 'jslib/abstractions/token.service';
|
||||
import { TotpService } from 'jslib/abstractions/totp.service';
|
||||
import { UserService } from 'jslib/abstractions/user.service';
|
||||
import { VaultTimeoutService } from 'jslib/abstractions/vaultTimeout.service';
|
||||
import { PasswordRepromptService } from 'jslib/services/passwordReprompt.service';
|
||||
|
||||
import { AutofillService } from '../../services/abstractions/autofill.service';
|
||||
import BrowserMessagingService from '../../services/browserMessaging.service';
|
||||
@ -63,11 +65,13 @@ function getBgService<T>(service: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export const stateService = new StateService();
|
||||
export const messagingService = new BrowserMessagingService();
|
||||
export const searchService = new PopupSearchService(getBgService<SearchService>('searchService')(),
|
||||
const stateService = new StateService();
|
||||
const messagingService = new BrowserMessagingService();
|
||||
const searchService = new PopupSearchService(getBgService<SearchService>('searchService')(),
|
||||
getBgService<CipherService>('cipherService')(), getBgService<ConsoleLogService>('consoleLogService')(),
|
||||
getBgService<I18nService>('i18nService')());
|
||||
const passwordRepromptService = new PasswordRepromptService(getBgService<I18nService>('i18nService')(),
|
||||
getBgService<CryptoService>('cryptoService')(), getBgService<PlatformUtilsService>('platformUtilsService')());
|
||||
|
||||
export function initFactory(platformUtilsService: PlatformUtilsService, i18nService: I18nService, storageService: StorageService,
|
||||
popupUtilsService: PopupUtilsService): Function {
|
||||
@ -174,6 +178,7 @@ export function initFactory(platformUtilsService: PlatformUtilsService, i18nServ
|
||||
useFactory: () => getBgService<I18nService>('i18nService')().translationLocale,
|
||||
deps: [],
|
||||
},
|
||||
{ provide: PasswordRepromptServiceAbstraction, useValue: passwordRepromptService },
|
||||
],
|
||||
})
|
||||
export class ServicesModule {
|
||||
|
@ -83,10 +83,19 @@
|
||||
<input id="cardCardholderName" type="text" name="Card.CardCardholderName"
|
||||
[(ngModel)]="cipher.card.cardholderName">
|
||||
</div>
|
||||
<div class="box-content-row" appBoxRow>
|
||||
<label for="cardNumber">{{'number' | i18n}}</label>
|
||||
<input id="cardNumber" type="text" name="Card.Number" [(ngModel)]="cipher.card.number"
|
||||
appInputVerbatim>
|
||||
<div class="box-content-row box-content-row-flex" appBoxRow>
|
||||
<div class="row-main">
|
||||
<label for="cardNumber">{{'number' | i18n}}</label>
|
||||
<input id="cardNumber" class="monospaced" type="{{showCardNumber ? 'text' : 'password'}}"
|
||||
name="Card.Number" [(ngModel)]="cipher.card.number" appInputVerbatim>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<a class="row-btn" href="#" appStopClick appBlurClick
|
||||
appA11yTitle="{{'toggleVisibility' | i18n}}" (click)="toggleCardNumber()">
|
||||
<i class="fa fa-lg" aria-hidden="true"
|
||||
[ngClass]="{'fa-eye': !showCardNumber, 'fa-eye-slash': showCardNumber}"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-content-row" appBoxRow>
|
||||
<label for="cardBrand">{{'brand' | i18n}}</label>
|
||||
@ -271,6 +280,11 @@
|
||||
<label for="favorite">{{'favorite' | i18n}}</label>
|
||||
<input id="favorite" type="checkbox" name="Favorite" [(ngModel)]="cipher.favorite">
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-checkbox" appBoxRow>
|
||||
<label for="passwordPrompt">{{'passwordPrompt' | i18n}}</label>
|
||||
<input id="passwordPrompt" type="checkbox" name="PasswordPrompt" [ngModel]="reprompt"
|
||||
(change)="repromptChanged()">
|
||||
</div>
|
||||
<a class="box-content-row box-content-row-flex text-default" href="#" appStopClick appBlurClick
|
||||
(click)="attachments()" *ngIf="editMode && showAttachments && !cloneMode">
|
||||
<div class="row-main">{{'attachments' | i18n}}</div>
|
||||
|
@ -14,12 +14,14 @@ import { BrowserApi } from '../../browser/browserApi';
|
||||
|
||||
import { BroadcasterService } from 'jslib/angular/services/broadcaster.service';
|
||||
|
||||
import { CipherRepromptType } from 'jslib/enums/cipherRepromptType';
|
||||
import { CipherType } from 'jslib/enums/cipherType';
|
||||
|
||||
import { CipherView } from 'jslib/models/view/cipherView';
|
||||
|
||||
import { CipherService } from 'jslib/abstractions/cipher.service';
|
||||
import { I18nService } from 'jslib/abstractions/i18n.service';
|
||||
import { PasswordRepromptService } from 'jslib/abstractions/passwordReprompt.service';
|
||||
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
|
||||
import { SearchService } from 'jslib/abstractions/search.service';
|
||||
import { StorageService } from 'jslib/abstractions/storage.service';
|
||||
@ -61,7 +63,8 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
|
||||
private toasterService: ToasterService, private i18nService: I18nService, private router: Router,
|
||||
private ngZone: NgZone, private broadcasterService: BroadcasterService,
|
||||
private changeDetectorRef: ChangeDetectorRef, private syncService: SyncService,
|
||||
private searchService: SearchService, private storageService: StorageService) {
|
||||
private searchService: SearchService, private storageService: StorageService,
|
||||
private passwordRepromptService: PasswordRepromptService) {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
@ -128,6 +131,10 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async fillCipher(cipher: CipherView) {
|
||||
if (cipher.reprompt !== CipherRepromptType.None && !await this.passwordRepromptService.showPasswordPrompt()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.totpCode = null;
|
||||
if (this.totpTimeout != null) {
|
||||
window.clearTimeout(this.totpTimeout);
|
||||
|
@ -99,11 +99,17 @@
|
||||
<div class="box-content-row box-content-row-flex" *ngIf="cipher.card.number">
|
||||
<div class="row-main">
|
||||
<span class="row-label">{{'number' | i18n}}</span>
|
||||
{{cipher.card.number}}
|
||||
<span [hidden]="showCardNumber" class="monospaced">{{cipher.card.maskedNumber}}</span>
|
||||
<span [hidden]="!showCardNumber" class="monospaced">{{cipher.card.number}}</span>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<a class="row-btn" href="#" appStopClick appA11yTitle="{{'toggleVisibility' | i18n}}"
|
||||
(click)="toggleCardNumber()">
|
||||
<i class="fa fa-lg" aria-hidden="true"
|
||||
[ngClass]="{'fa-eye': !showCardNumber, 'fa-eye-slash': showCardNumber}"></i>
|
||||
</a>
|
||||
<a class="row-btn" href="#" appStopClick appA11yTitle="{{'copyNumber' | i18n}}"
|
||||
(click)="copy(cipher.card.number, 'number', 'Number')">
|
||||
(click)="copy(cipher.card.number, 'number', 'Card Number')">
|
||||
<i class="fa fa-lg fa-clone" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -16,6 +16,7 @@ import { CryptoService } from 'jslib/abstractions/crypto.service';
|
||||
import { EventService } from 'jslib/abstractions/event.service';
|
||||
import { I18nService } from 'jslib/abstractions/i18n.service';
|
||||
import { MessagingService } from 'jslib/abstractions/messaging.service';
|
||||
import { PasswordRepromptService } from 'jslib/abstractions/passwordReprompt.service';
|
||||
import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service';
|
||||
import { TokenService } from 'jslib/abstractions/token.service';
|
||||
import { TotpService } from 'jslib/abstractions/totp.service';
|
||||
@ -53,10 +54,12 @@ export class ViewComponent extends BaseViewComponent {
|
||||
private router: Router, private location: Location,
|
||||
broadcasterService: BroadcasterService, ngZone: NgZone,
|
||||
changeDetectorRef: ChangeDetectorRef, userService: UserService,
|
||||
eventService: EventService, private autofillService: AutofillService, apiService: ApiService,
|
||||
private messagingService: MessagingService, private popupUtilsService: PopupUtilsService) {
|
||||
eventService: EventService, private autofillService: AutofillService,
|
||||
private messagingService: MessagingService, private popupUtilsService: PopupUtilsService,
|
||||
apiService: ApiService, passwordRepromptService: PasswordRepromptService) {
|
||||
super(cipherService, totpService, tokenService, i18nService, cryptoService, platformUtilsService,
|
||||
auditService, window, broadcasterService, ngZone, changeDetectorRef, userService, eventService, apiService);
|
||||
auditService, window, broadcasterService, ngZone, changeDetectorRef, userService, eventService,
|
||||
apiService, passwordRepromptService);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@ -112,32 +115,45 @@ export class ViewComponent extends BaseViewComponent {
|
||||
await this.loadPageDetails();
|
||||
}
|
||||
|
||||
edit() {
|
||||
async edit() {
|
||||
if (this.cipher.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
super.edit();
|
||||
if (!await super.edit()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.router.navigate(['/edit-cipher'], { queryParams: { cipherId: this.cipher.id } });
|
||||
return true;
|
||||
}
|
||||
|
||||
clone() {
|
||||
async clone() {
|
||||
if (this.cipher.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
super.clone();
|
||||
|
||||
if (!await super.clone()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.router.navigate(['/clone-cipher'], {
|
||||
queryParams: {
|
||||
cloneMode: true,
|
||||
cipherId: this.cipher.id,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
share() {
|
||||
super.share();
|
||||
async share() {
|
||||
if (!await super.share()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.cipher.organizationId == null) {
|
||||
this.router.navigate(['/share-cipher'], { replaceUrl: true, queryParams: { cipherId: this.cipher.id } });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async fillCipher() {
|
||||
@ -220,6 +236,10 @@ export class ViewComponent extends BaseViewComponent {
|
||||
}
|
||||
|
||||
private async doAutofill() {
|
||||
if (!await this.promptPassword()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.pageDetails == null || this.pageDetails.length === 0) {
|
||||
this.platformUtilsService.showToast('error', null,
|
||||
this.i18nService.t('autofillError'));
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
} from 'jslib/abstractions';
|
||||
|
||||
import { EventService } from 'jslib/abstractions/event.service';
|
||||
import { CipherRepromptType } from 'jslib/enums/cipherRepromptType';
|
||||
import { EventType } from 'jslib/enums/eventType';
|
||||
|
||||
const CardAttributes: string[] = ['autoCompleteType', 'data-stripe', 'htmlName', 'htmlID', 'label-tag',
|
||||
@ -254,6 +255,10 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
if (cipher.reprompt !== CipherRepromptType.None) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totpCode = await this.doAutoFill({
|
||||
cipher: cipher,
|
||||
pageDetails: pageDetails,
|
||||
|
@ -12,6 +12,7 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService
|
||||
identityClientId: string = 'browser';
|
||||
|
||||
private showDialogResolves = new Map<number, { resolve: (value: boolean) => void, date: Date }>();
|
||||
private passwordDialogResolves = new Map<number, { tryResolve: (canceled: boolean, password: string) => Promise<boolean>, date: Date }>();
|
||||
private deviceCache: DeviceType = null;
|
||||
private prefersColorSchemeDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
@ -149,6 +150,33 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService
|
||||
});
|
||||
}
|
||||
|
||||
async showPasswordDialog(title: string, body: string, passwordValidation: (value: string) => Promise<boolean>) {
|
||||
const dialogId = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||
|
||||
this.messagingService.send('showPasswordDialog', {
|
||||
title: title,
|
||||
body: body,
|
||||
dialogId: dialogId,
|
||||
});
|
||||
|
||||
return new Promise<boolean>(resolve => {
|
||||
this.passwordDialogResolves.set(dialogId, {
|
||||
tryResolve: async (canceled: boolean, password: string) => {
|
||||
if (canceled) {
|
||||
resolve(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await passwordValidation(password)) {
|
||||
resolve(true);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
date: new Date(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isDev(): boolean {
|
||||
return process.env.ENV === 'development';
|
||||
}
|
||||
@ -256,16 +284,33 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService
|
||||
}
|
||||
|
||||
// Clean up old promises
|
||||
const deleteIds: number[] = [];
|
||||
this.showDialogResolves.forEach((val, key) => {
|
||||
const age = new Date().getTime() - val.date.getTime();
|
||||
if (age > DialogPromiseExpiration) {
|
||||
deleteIds.push(key);
|
||||
this.showDialogResolves.delete(key);
|
||||
}
|
||||
});
|
||||
deleteIds.forEach(id => {
|
||||
this.showDialogResolves.delete(id);
|
||||
}
|
||||
|
||||
async resolvePasswordDialogPromise(dialogId: number, canceled: boolean, password: string): Promise<boolean> {
|
||||
let result = false;
|
||||
if (this.passwordDialogResolves.has(dialogId)) {
|
||||
const resolveObj = this.passwordDialogResolves.get(dialogId);
|
||||
if (await resolveObj.tryResolve(canceled, password)) {
|
||||
this.passwordDialogResolves.delete(dialogId);
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old promises
|
||||
this.passwordDialogResolves.forEach((val, key) => {
|
||||
const age = new Date().getTime() - val.date.getTime();
|
||||
if (age > DialogPromiseExpiration) {
|
||||
this.passwordDialogResolves.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async supportsBiometric() {
|
||||
|
Loading…
Reference in New Issue
Block a user