feat(router): guard returning UrlTree cancels current navigation and redirects (#26521)
Fixes #24618 FW-153 #resolve PR Close #26521
This commit is contained in:
parent
081f95c812
commit
4e9f2e5895
@ -3066,11 +3066,13 @@ A guard's return value controls the router's behavior:
|
|||||||
|
|
||||||
* If it returns `true`, the navigation process continues.
|
* If it returns `true`, the navigation process continues.
|
||||||
* If it returns `false`, the navigation process stops and the user stays put.
|
* If it returns `false`, the navigation process stops and the user stays put.
|
||||||
|
* If it returns a `UrlTree`, the current navigation cancels and a new navigation is initiated to the `UrlTree` returned.
|
||||||
|
|
||||||
|
|
||||||
<div class="alert is-helpful">
|
<div class="alert is-helpful">
|
||||||
|
|
||||||
**Note:** The guard can also tell the router to navigate elsewhere, effectively canceling the current navigation.
|
**Note:** The guard can also tell the router to navigate elsewhere, effectively canceling the current navigation. When
|
||||||
|
doing so inside a guard, the guard should return `false`;
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -17,6 +17,10 @@ import {UrlSegment, UrlTree} from './url_tree';
|
|||||||
* @description
|
* @description
|
||||||
*
|
*
|
||||||
* Interface that a class can implement to be a guard deciding if a route can be activated.
|
* Interface that a class can implement to be a guard deciding if a route can be activated.
|
||||||
|
* If all guards return `true`, navigation will continue. If any guard returns `false`,
|
||||||
|
* navigation will be cancelled. If any guard returns a `UrlTree`, current navigation will
|
||||||
|
* be cancelled and a new navigation will be kicked off to the `UrlTree` returned from the
|
||||||
|
* guard.
|
||||||
*
|
*
|
||||||
* ```
|
* ```
|
||||||
* class UserToken {}
|
* class UserToken {}
|
||||||
@ -33,7 +37,7 @@ import {UrlSegment, UrlTree} from './url_tree';
|
|||||||
* canActivate(
|
* canActivate(
|
||||||
* route: ActivatedRouteSnapshot,
|
* route: ActivatedRouteSnapshot,
|
||||||
* state: RouterStateSnapshot
|
* state: RouterStateSnapshot
|
||||||
* ): Observable<boolean>|Promise<boolean>|boolean|UrlTree {
|
* ): Observable<boolean|UrlTree>|Promise<boolean|UrlTree>|boolean|UrlTree {
|
||||||
* return this.permissions.canActivate(this.currentUser, route.params.id);
|
* return this.permissions.canActivate(this.currentUser, route.params.id);
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
@ -90,6 +94,10 @@ export type CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSn
|
|||||||
* @description
|
* @description
|
||||||
*
|
*
|
||||||
* Interface that a class can implement to be a guard deciding if a child route can be activated.
|
* Interface that a class can implement to be a guard deciding if a child route can be activated.
|
||||||
|
* If all guards return `true`, navigation will continue. If any guard returns `false`,
|
||||||
|
* navigation will be cancelled. If any guard returns a `UrlTree`, current navigation will
|
||||||
|
* be cancelled and a new navigation will be kicked off to the `UrlTree` returned from the
|
||||||
|
* guard.
|
||||||
*
|
*
|
||||||
* ```
|
* ```
|
||||||
* class UserToken {}
|
* class UserToken {}
|
||||||
@ -106,7 +114,7 @@ export type CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSn
|
|||||||
* canActivateChild(
|
* canActivateChild(
|
||||||
* route: ActivatedRouteSnapshot,
|
* route: ActivatedRouteSnapshot,
|
||||||
* state: RouterStateSnapshot
|
* state: RouterStateSnapshot
|
||||||
* ): Observable<boolean>|Promise<boolean>|boolean|UrlTree {
|
* ): Observable<boolean|UrlTree>|Promise<boolean|UrlTree>|boolean|UrlTree {
|
||||||
* return this.permissions.canActivate(this.currentUser, route.params.id);
|
* return this.permissions.canActivate(this.currentUser, route.params.id);
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
@ -173,6 +181,10 @@ export type CanActivateChildFn = (childRoute: ActivatedRouteSnapshot, state: Rou
|
|||||||
* @description
|
* @description
|
||||||
*
|
*
|
||||||
* Interface that a class can implement to be a guard deciding if a route can be deactivated.
|
* Interface that a class can implement to be a guard deciding if a route can be deactivated.
|
||||||
|
* If all guards return `true`, navigation will continue. If any guard returns `false`,
|
||||||
|
* navigation will be cancelled. If any guard returns a `UrlTree`, current navigation will
|
||||||
|
* be cancelled and a new navigation will be kicked off to the `UrlTree` returned from the
|
||||||
|
* guard.
|
||||||
*
|
*
|
||||||
* ```
|
* ```
|
||||||
* class UserToken {}
|
* class UserToken {}
|
||||||
@ -191,7 +203,7 @@ export type CanActivateChildFn = (childRoute: ActivatedRouteSnapshot, state: Rou
|
|||||||
* currentRoute: ActivatedRouteSnapshot,
|
* currentRoute: ActivatedRouteSnapshot,
|
||||||
* currentState: RouterStateSnapshot,
|
* currentState: RouterStateSnapshot,
|
||||||
* nextState: RouterStateSnapshot
|
* nextState: RouterStateSnapshot
|
||||||
* ): Observable<boolean>|Promise<boolean>|boolean|UrlTree {
|
* ): Observable<boolean|UrlTree>|Promise<boolean|UrlTree>|boolean|UrlTree {
|
||||||
* return this.permissions.canDeactivate(this.currentUser, route.params.id);
|
* return this.permissions.canDeactivate(this.currentUser, route.params.id);
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
|
@ -17,7 +17,7 @@ import {ActivatedRouteSnapshot, RouterStateSnapshot} from '../router_state';
|
|||||||
import {UrlTree} from '../url_tree';
|
import {UrlTree} from '../url_tree';
|
||||||
import {wrapIntoObservable} from '../utils/collection';
|
import {wrapIntoObservable} from '../utils/collection';
|
||||||
import {CanActivate, CanDeactivate, getCanActivateChild, getToken} from '../utils/preactivation';
|
import {CanActivate, CanDeactivate, getCanActivateChild, getToken} from '../utils/preactivation';
|
||||||
import {isCanActivate, isCanActivateChild, isCanDeactivate, isFunction, isBoolean} from '../utils/type_guards';
|
import {isBoolean, isCanActivate, isCanActivateChild, isCanDeactivate, isFunction} from '../utils/type_guards';
|
||||||
|
|
||||||
import {prioritizedGuardValue} from './prioritized_guard_value';
|
import {prioritizedGuardValue} from './prioritized_guard_value';
|
||||||
|
|
||||||
|
@ -7,9 +7,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {Observable, OperatorFunction, combineLatest} from 'rxjs';
|
import {Observable, OperatorFunction, combineLatest} from 'rxjs';
|
||||||
import {filter, scan, startWith, switchMap, take} from 'rxjs/operators';
|
import {filter, map, scan, startWith, switchMap, take} from 'rxjs/operators';
|
||||||
|
|
||||||
import {UrlTree} from '../url_tree';
|
import {UrlTree} from '../url_tree';
|
||||||
|
import {isUrlTree} from '../utils/type_guards';
|
||||||
|
|
||||||
const INITIAL_VALUE = Symbol('INITIAL_VALUE');
|
const INITIAL_VALUE = Symbol('INITIAL_VALUE');
|
||||||
declare type INTERIM_VALUES = typeof INITIAL_VALUE | boolean | UrlTree;
|
declare type INTERIM_VALUES = typeof INITIAL_VALUE | boolean | UrlTree;
|
||||||
@ -38,7 +39,7 @@ export function prioritizedGuardValue():
|
|||||||
// navigation
|
// navigation
|
||||||
if (val === false) return val;
|
if (val === false) return val;
|
||||||
|
|
||||||
if (i === list.length - 1 || val instanceof UrlTree) {
|
if (i === list.length - 1 || isUrlTree(val)) {
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,6 +48,8 @@ export function prioritizedGuardValue():
|
|||||||
}, acc);
|
}, acc);
|
||||||
},
|
},
|
||||||
INITIAL_VALUE),
|
INITIAL_VALUE),
|
||||||
filter(item => item !== INITIAL_VALUE), take(1)) as Observable<boolean|UrlTree>;
|
filter(item => item !== INITIAL_VALUE),
|
||||||
|
map(item => isUrlTree(item) ? item : item === true), //
|
||||||
|
take(1)) as Observable<boolean|UrlTree>;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import {Location} from '@angular/common';
|
import {Location} from '@angular/common';
|
||||||
import {Compiler, Injector, NgModuleFactoryLoader, NgModuleRef, NgZone, Type, isDevMode, ɵConsole as Console} from '@angular/core';
|
import {Compiler, Injector, NgModuleFactoryLoader, NgModuleRef, NgZone, Type, isDevMode, ɵConsole as Console} from '@angular/core';
|
||||||
import {BehaviorSubject, EMPTY, Observable, Subject, Subscription, of } from 'rxjs';
|
import {BehaviorSubject, EMPTY, Observable, Subject, Subscription, defer, of } from 'rxjs';
|
||||||
import {catchError, filter, finalize, map, switchMap, tap} from 'rxjs/operators';
|
import {catchError, filter, finalize, map, switchMap, tap} from 'rxjs/operators';
|
||||||
|
|
||||||
import {QueryParamsHandling, Route, Routes, standardizeConfig, validateConfig} from './config';
|
import {QueryParamsHandling, Route, Routes, standardizeConfig, validateConfig} from './config';
|
||||||
@ -25,10 +25,11 @@ import {DefaultRouteReuseStrategy, RouteReuseStrategy} from './route_reuse_strat
|
|||||||
import {RouterConfigLoader} from './router_config_loader';
|
import {RouterConfigLoader} from './router_config_loader';
|
||||||
import {ChildrenOutletContexts} from './router_outlet_context';
|
import {ChildrenOutletContexts} from './router_outlet_context';
|
||||||
import {ActivatedRoute, RouterState, RouterStateSnapshot, createEmptyState} from './router_state';
|
import {ActivatedRoute, RouterState, RouterStateSnapshot, createEmptyState} from './router_state';
|
||||||
import {Params, isNavigationCancelingError} from './shared';
|
import {Params, isNavigationCancelingError, navigationCancelingError} from './shared';
|
||||||
import {DefaultUrlHandlingStrategy, UrlHandlingStrategy} from './url_handling_strategy';
|
import {DefaultUrlHandlingStrategy, UrlHandlingStrategy} from './url_handling_strategy';
|
||||||
import {UrlSerializer, UrlTree, containsTree, createEmptyUrlTree} from './url_tree';
|
import {UrlSerializer, UrlTree, containsTree, createEmptyUrlTree} from './url_tree';
|
||||||
import {Checks, getAllRouteGuards} from './utils/preactivation';
|
import {Checks, getAllRouteGuards} from './utils/preactivation';
|
||||||
|
import {isUrlTree} from './utils/type_guards';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -487,6 +488,14 @@ export class Router {
|
|||||||
})),
|
})),
|
||||||
|
|
||||||
checkGuards(this.ngModule.injector, (evt: Event) => this.triggerEvent(evt)),
|
checkGuards(this.ngModule.injector, (evt: Event) => this.triggerEvent(evt)),
|
||||||
|
tap(t => {
|
||||||
|
if (isUrlTree(t.guardsResult)) {
|
||||||
|
const error: Error&{url?: UrlTree} = navigationCancelingError(
|
||||||
|
`Redirecting to "${this.serializeUrl(t.guardsResult)}"`);
|
||||||
|
error.url = t.guardsResult;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
tap(t => {
|
tap(t => {
|
||||||
const guardsEnd = new GuardsCheckEnd(
|
const guardsEnd = new GuardsCheckEnd(
|
||||||
@ -602,11 +611,19 @@ export class Router {
|
|||||||
* rather than an error. */
|
* rather than an error. */
|
||||||
if (isNavigationCancelingError(e)) {
|
if (isNavigationCancelingError(e)) {
|
||||||
this.navigated = true;
|
this.navigated = true;
|
||||||
|
const redirecting = isUrlTree(e.url);
|
||||||
|
if (!redirecting) {
|
||||||
this.resetStateAndUrl(t.currentRouterState, t.currentUrlTree, t.rawUrl);
|
this.resetStateAndUrl(t.currentRouterState, t.currentUrlTree, t.rawUrl);
|
||||||
|
}
|
||||||
const navCancel =
|
const navCancel =
|
||||||
new NavigationCancel(t.id, this.serializeUrl(t.extractedUrl), e.message);
|
new NavigationCancel(t.id, this.serializeUrl(t.extractedUrl), e.message);
|
||||||
eventsSubject.next(navCancel);
|
eventsSubject.next(navCancel);
|
||||||
t.resolve(false);
|
t.resolve(false);
|
||||||
|
|
||||||
|
if (redirecting) {
|
||||||
|
this.navigateByUrl(e.url);
|
||||||
|
}
|
||||||
|
|
||||||
/* All other errors should reset to the router's internal URL reference to the
|
/* All other errors should reset to the router's internal URL reference to the
|
||||||
* pre-error state. */
|
* pre-error state. */
|
||||||
} else {
|
} else {
|
||||||
@ -815,7 +832,7 @@ export class Router {
|
|||||||
`Navigation triggered outside Angular zone, did you forget to call 'ngZone.run()'?`);
|
`Navigation triggered outside Angular zone, did you forget to call 'ngZone.run()'?`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const urlTree = url instanceof UrlTree ? url : this.parseUrl(url);
|
const urlTree = isUrlTree(url) ? url : this.parseUrl(url);
|
||||||
const mergedTree = this.urlHandlingStrategy.merge(urlTree, this.rawUrlTree);
|
const mergedTree = this.urlHandlingStrategy.merge(urlTree, this.rawUrlTree);
|
||||||
|
|
||||||
return this.scheduleNavigation(mergedTree, 'imperative', null, extras);
|
return this.scheduleNavigation(mergedTree, 'imperative', null, extras);
|
||||||
@ -867,7 +884,7 @@ export class Router {
|
|||||||
|
|
||||||
/** Returns whether the url is activated */
|
/** Returns whether the url is activated */
|
||||||
isActive(url: string|UrlTree, exact: boolean): boolean {
|
isActive(url: string|UrlTree, exact: boolean): boolean {
|
||||||
if (url instanceof UrlTree) {
|
if (isUrlTree(url)) {
|
||||||
return containsTree(this.currentUrlTree, url, exact);
|
return containsTree(this.currentUrlTree, url, exact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {CanActivate, CanActivateChild, CanDeactivate, CanLoad} from '../interfaces';
|
import {CanActivate, CanActivateChild, CanDeactivate, CanLoad} from '../interfaces';
|
||||||
|
import {UrlTree} from '../url_tree';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple function check, but generic so type inference will flow. Example:
|
* Simple function check, but generic so type inference will flow. Example:
|
||||||
@ -29,6 +30,10 @@ export function isBoolean(v: any): v is boolean {
|
|||||||
return typeof v === 'boolean';
|
return typeof v === 'boolean';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUrlTree(v: any): v is UrlTree {
|
||||||
|
return v instanceof UrlTree;
|
||||||
|
}
|
||||||
|
|
||||||
export function isCanLoad(guard: any): guard is CanLoad {
|
export function isCanLoad(guard: any): guard is CanLoad {
|
||||||
return guard && isFunction<CanLoad>(guard.canLoad);
|
return guard && isFunction<CanLoad>(guard.canLoad);
|
||||||
}
|
}
|
||||||
@ -38,7 +43,7 @@ export function isCanActivate(guard: any): guard is CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isCanActivateChild(guard: any): guard is CanActivateChild {
|
export function isCanActivateChild(guard: any): guard is CanActivateChild {
|
||||||
return guard && isFunction<CanActivateChild>(guard.canActivate);
|
return guard && isFunction<CanActivateChild>(guard.canActivateChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCanDeactivate<T>(guard: any): guard is CanDeactivate<T> {
|
export function isCanDeactivate<T>(guard: any): guard is CanDeactivate<T> {
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
import {CommonModule, Location} from '@angular/common';
|
import {CommonModule, Location} from '@angular/common';
|
||||||
import {SpyLocation} from '@angular/common/testing';
|
import {SpyLocation} from '@angular/common/testing';
|
||||||
import {ChangeDetectionStrategy, Component, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef, NgZone, OnDestroy, ɵConsole as Console, ɵNoopNgZone as NoopNgZone} from '@angular/core';
|
import {ChangeDetectionStrategy, Component, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef, NgZone, OnDestroy, ɵConsole as Console, ɵNoopNgZone as NoopNgZone} from '@angular/core';
|
||||||
import {ComponentFixture, TestBed, fakeAsync, inject, tick} from '@angular/core/testing';
|
import {ComponentFixture, TestBed, fakeAsync, flush, inject, tick} from '@angular/core/testing';
|
||||||
import {By} from '@angular/platform-browser/src/dom/debug/by';
|
import {By} from '@angular/platform-browser/src/dom/debug/by';
|
||||||
import {expect} from '@angular/platform-browser/testing/src/matchers';
|
import {expect} from '@angular/platform-browser/testing/src/matchers';
|
||||||
import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, CanActivate, CanDeactivate, ChildActivationEnd, ChildActivationStart, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, PRIMARY_OUTLET, ParamMap, Params, PreloadAllModules, PreloadingStrategy, Resolve, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouteReuseStrategy, Router, RouterEvent, RouterModule, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlSerializer, UrlTree} from '@angular/router';
|
import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, CanActivate, CanDeactivate, ChildActivationEnd, ChildActivationStart, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, PRIMARY_OUTLET, ParamMap, Params, PreloadAllModules, PreloadingStrategy, Resolve, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouteReuseStrategy, Router, RouterEvent, RouterModule, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlSerializer, UrlTree} from '@angular/router';
|
||||||
@ -2017,6 +2017,59 @@ describe('Integration', () => {
|
|||||||
})));
|
})));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('should redirect when guard returns UrlTree', () => {
|
||||||
|
beforeEach(() => TestBed.configureTestingModule({
|
||||||
|
providers: [{
|
||||||
|
provide: 'returnUrlTree',
|
||||||
|
useFactory: (router: Router) => () => { return router.parseUrl('/redirected'); },
|
||||||
|
deps: [Router]
|
||||||
|
}]
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('works', fakeAsync(inject([Router, Location], (router: Router, location: Location) => {
|
||||||
|
const recordedEvents: any[] = [];
|
||||||
|
let cancelEvent: NavigationCancel = null !;
|
||||||
|
router.events.forEach((e: any) => {
|
||||||
|
recordedEvents.push(e);
|
||||||
|
if (e instanceof NavigationCancel) cancelEvent = e;
|
||||||
|
});
|
||||||
|
router.resetConfig([
|
||||||
|
{path: '', component: SimpleCmp},
|
||||||
|
{path: 'one', component: RouteCmp, canActivate: ['returnUrlTree']},
|
||||||
|
{path: 'redirected', component: SimpleCmp}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(RootCmp);
|
||||||
|
router.navigateByUrl('/one');
|
||||||
|
|
||||||
|
advance(fixture);
|
||||||
|
|
||||||
|
expect(location.path()).toEqual('/redirected');
|
||||||
|
expect(fixture.nativeElement).toHaveText('simple');
|
||||||
|
expect(cancelEvent && cancelEvent.reason)
|
||||||
|
.toBe('NavigationCancelingError: Redirecting to "/redirected"');
|
||||||
|
expectEvents(recordedEvents, [
|
||||||
|
[NavigationStart, '/one'],
|
||||||
|
[RoutesRecognized, '/one'],
|
||||||
|
[GuardsCheckStart, '/one'],
|
||||||
|
[ChildActivationStart, undefined],
|
||||||
|
[ActivationStart, undefined],
|
||||||
|
[NavigationCancel, '/one'],
|
||||||
|
[NavigationStart, '/redirected'],
|
||||||
|
[RoutesRecognized, '/redirected'],
|
||||||
|
[GuardsCheckStart, '/redirected'],
|
||||||
|
[ChildActivationStart, undefined],
|
||||||
|
[ActivationStart, undefined],
|
||||||
|
[GuardsCheckEnd, '/redirected'],
|
||||||
|
[ResolveStart, '/redirected'],
|
||||||
|
[ResolveEnd, '/redirected'],
|
||||||
|
[ActivationEnd, undefined],
|
||||||
|
[ChildActivationEnd, undefined],
|
||||||
|
[NavigationEnd, '/redirected'],
|
||||||
|
]);
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
describe('runGuardsAndResolvers', () => {
|
describe('runGuardsAndResolvers', () => {
|
||||||
let guardRunCount = 0;
|
let guardRunCount = 0;
|
||||||
let resolverRunCount = 0;
|
let resolverRunCount = 0;
|
||||||
|
@ -672,10 +672,11 @@ function checkGuards(
|
|||||||
guards: getAllRouteGuards(future, curr, new ChildrenOutletContexts())
|
guards: getAllRouteGuards(future, curr, new ChildrenOutletContexts())
|
||||||
} as Partial<NavigationTransition>)
|
} as Partial<NavigationTransition>)
|
||||||
.pipe(checkGuardsOperator(injector))
|
.pipe(checkGuardsOperator(injector))
|
||||||
.subscribe(
|
.subscribe({
|
||||||
t => {
|
next(t) {
|
||||||
if (t.guardsResult === null) throw new Error('Guard result expected');
|
if (t.guardsResult === null) throw new Error('Guard result expected');
|
||||||
return check(t.guardsResult);
|
return check(t.guardsResult);
|
||||||
},
|
},
|
||||||
(e) => { throw e; });
|
error(e) { throw e; }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user