Fixed issues with impersonation. Show warning or error depending on connection's remaining time before expiry. Replaced use of /api/... in urls.
This commit is contained in:
parent
70e0e9bf71
commit
3e9a9f9dc5
@ -47,8 +47,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1024kB",
|
||||
"maximumError": "1MB"
|
||||
"maximumWarning": "3MB",
|
||||
"maximumError": "5MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
@ -93,7 +93,7 @@
|
||||
},
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"allowedHosts": ["*"]
|
||||
"allowedHosts": ["beta.tomtospeech.com"]
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
|
10
package-lock.json
generated
10
package-lock.json
generated
@ -22,6 +22,7 @@
|
||||
"@angular/ssr": "^19.2.5",
|
||||
"angular-oauth2-oidc": "^17.0.2",
|
||||
"express": "^4.18.2",
|
||||
"moment": "^2.30.1",
|
||||
"ngx-socket-io": "^4.7.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"rxjs-websockets": "^9.0.0",
|
||||
@ -10344,6 +10345,15 @@
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
|
@ -2,8 +2,7 @@
|
||||
"name": "hermes-web-angular",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve -c production --host 0.0.0.0 --watch false",
|
||||
"start": "ng serve -c development --host 0.0.0.0 --watch false",
|
||||
"build": "ng build",
|
||||
"watch": "ng serve -c development --host 0.0.0.0 --disable-host-check",
|
||||
"test": "ng test",
|
||||
@ -25,6 +24,7 @@
|
||||
"@angular/ssr": "^19.2.5",
|
||||
"angular-oauth2-oidc": "^17.0.2",
|
||||
"express": "^4.18.2",
|
||||
"moment": "^2.30.1",
|
||||
"ngx-socket-io": "^4.7.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"rxjs-websockets": "^9.0.0",
|
||||
|
@ -8,7 +8,6 @@ import EventService from './shared/services/EventService';
|
||||
import { ApiAuthenticationService } from './shared/services/api/api-authentication.service';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ApiKeyService } from './shared/services/api/api-key.service';
|
||||
import ApiKey from './shared/models/api-key';
|
||||
import { ThemeService } from './shared/services/theme.service';
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@ -16,6 +15,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { SidebarComponent } from "./navigation/sidebar/sidebar.component";
|
||||
import { Topbar as TopbarComponent } from "./navigation/topbar/topbar.component";
|
||||
import ApiKey from './shared/models/api-key';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@ -38,7 +38,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private readonly overlayContainer = inject(OverlayContainer);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
|
||||
private isBrowser: boolean;
|
||||
private ngZone: NgZone;
|
||||
private subscriptions: Subscription[];
|
||||
|
||||
@ -57,7 +56,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
constructor(private auth: ApiAuthenticationService, private client: HermesClientService, private events: EventService, private router: Router, ngZone: NgZone, @Inject(PLATFORM_ID) private platformId: Object) {
|
||||
this.ngZone = ngZone;
|
||||
this.isBrowser = isPlatformBrowser(this.platformId);
|
||||
this.subscriptions = [];
|
||||
|
||||
this.subscriptions.push(this.events.listen('tts_login_ack', async _ => {
|
||||
@ -72,16 +70,27 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}));
|
||||
|
||||
this.addSubscription(this.events.listen('login', () => {
|
||||
this.keyService.fetch()
|
||||
.pipe(timeout(3000), first())
|
||||
.subscribe(async (d: ApiKey[]) => {
|
||||
if (d.length > 0)
|
||||
this.client.login(d[0].id);
|
||||
});
|
||||
}));
|
||||
|
||||
this.subscriptions.push(this.events.listen('tts_logoff', async _ => await this.router.navigate(['tts-login'])));
|
||||
this.subscriptions.push(this.events.listen('toggle_sidebar', () => this.isSidebarOpen = !this.isSidebarOpen))
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.isBrowser)
|
||||
if (!isPlatformBrowser(this.platformId))
|
||||
return;
|
||||
|
||||
this.auth.update();
|
||||
|
||||
this.subscriptions.push(this.events.listen('login', async () => await this.router.navigate(['tts-login'])));
|
||||
|
||||
this.addSubscription(this.events.listen('logoff', async (message) => {
|
||||
localStorage.removeItem('jwt');
|
||||
if (!document.location.href.includes('/login')) {
|
||||
@ -94,17 +103,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}));
|
||||
|
||||
this.addSubscription(this.events.listen('login', () => {
|
||||
this.keyService.fetch()
|
||||
.pipe(timeout(3000), first())
|
||||
.subscribe(async (d: ApiKey[]) => {
|
||||
if (d.length > 0)
|
||||
this.client.login(d[0].id);
|
||||
else if (['/login', '/auth'].some(partial => document.location.href.includes(partial)))
|
||||
await this.router.navigate(['tts-login']);
|
||||
});
|
||||
}));
|
||||
|
||||
let currentTheme = localStorage.getItem('ui-theme') ?? this.themeService.theme;
|
||||
if (currentTheme == 'light' || currentTheme == 'dark') {
|
||||
this.themeService.theme = currentTheme;
|
||||
|
@ -60,9 +60,8 @@ export class ImpersonationComponent implements OnInit {
|
||||
impersonation: impersonationId
|
||||
}
|
||||
}).subscribe(async (data: any) => {
|
||||
this.impersonationControl.setValue(undefined);
|
||||
this.client.disconnect(true);
|
||||
this.events.emit('impersonation', undefined);
|
||||
this.events.emit('impersonation', impersonationId);
|
||||
});
|
||||
} else {
|
||||
this.http.put(environment.API_HOST + '/admin/impersonate', {
|
||||
@ -72,7 +71,6 @@ export class ImpersonationComponent implements OnInit {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
|
||||
}
|
||||
}).subscribe(async (data: any) => {
|
||||
this.impersonationControl.setValue(impersonationId);
|
||||
this.client.disconnect(true);
|
||||
this.events.emit('impersonation', impersonationId);
|
||||
await this.router.navigate(['tts-login']);
|
||||
|
@ -18,6 +18,7 @@
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<button mat-raised-button
|
||||
[disabled]="disabled"
|
||||
(click)="login()">Log In</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
|
@ -32,13 +32,17 @@ export class TtsLoginComponent implements OnInit, OnDestroy {
|
||||
keyControl = new FormControl<string | null>('');
|
||||
api_keys: { id: string, label: string }[] = [];
|
||||
subscriptions: (Subscription | null)[] = [];
|
||||
disabled: boolean = false;
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.data.subscribe(d => this.api_keys = d['keys']);
|
||||
|
||||
this.subscriptions.push(this.eventService.listen('impersonation', _ => this.reset()));
|
||||
this.subscriptions.push(this.eventService.listen('logoff', _ => this.reset()));
|
||||
this.subscriptions.push(this.eventService.listen('logoff', impersonation => {
|
||||
if (!impersonation)
|
||||
this.reset();
|
||||
}));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@ -57,7 +61,11 @@ export class TtsLoginComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.disabled = true;
|
||||
this.api_keys = [];
|
||||
this.keyService.fetch().subscribe(keys => this.api_keys = keys);
|
||||
this.keyService.fetch().subscribe(keys => {
|
||||
this.api_keys = keys;
|
||||
this.disabled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'connection-item-edit',
|
||||
@ -54,7 +55,7 @@ export class ConnectionItemEditComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.http.post('/api/auth/connections', {
|
||||
this.http.post(environment.API_HOST + '/auth/connections', {
|
||||
name: this.nameControl.value,
|
||||
type: this.typeControl.value,
|
||||
client_id: this.clientIdControl.value,
|
||||
|
@ -2,6 +2,14 @@
|
||||
[class.nightbot]="connection().type == 'nightbot'">
|
||||
{{connection().name}}
|
||||
|
||||
@if (isExpired) {
|
||||
<mat-icon matTooltip="Connection has expired."
|
||||
class="danger">error</mat-icon>
|
||||
} @else if (isExpiringSoon) {
|
||||
<mat-icon matTooltip="Connection is soon going to expire."
|
||||
class="warning">warning</mat-icon>
|
||||
}
|
||||
|
||||
<article class="right">
|
||||
<button mat-button
|
||||
class="neutral"
|
||||
|
@ -3,10 +3,13 @@ import { Connection } from '../../shared/models/connection';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import {MatTooltipModule} from '@angular/material/tooltip';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { HermesClientService } from '../../hermes-client.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import moment from 'moment';
|
||||
|
||||
@Component({
|
||||
selector: 'connection-item',
|
||||
@ -14,6 +17,7 @@ import { HermesClientService } from '../../hermes-client.service';
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatFormFieldModule,
|
||||
MatTooltipModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
templateUrl: './connection-item.component.html',
|
||||
@ -27,13 +31,25 @@ export class ConnectionItemComponent {
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) { }
|
||||
|
||||
ngOnInit() {
|
||||
console.log('coonnn', this.connection())
|
||||
}
|
||||
|
||||
delete() {
|
||||
this.client.deleteConnection(this.connection().name);
|
||||
}
|
||||
|
||||
get isExpired() {
|
||||
return moment(this.connection().expires_at).toDate().getTime() < new Date().getTime();
|
||||
}
|
||||
|
||||
get isExpiringSoon() {
|
||||
return moment(this.connection().expires_at).toDate().getTime() < moment.now() + moment.duration(7, 'd').asMilliseconds();
|
||||
}
|
||||
|
||||
renew() {
|
||||
const conn = this.connection();
|
||||
this.http.post('/api/auth/connections', {
|
||||
this.http.post(environment.API_HOST + '/auth/connections', {
|
||||
name: conn.name,
|
||||
type: conn.type,
|
||||
client_id: conn.client_id,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { OnInit, Injectable } from '@angular/core';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
|
||||
import { catchError, first, timeout } from 'rxjs/operators';
|
||||
import { environment } from '../environments/environment';
|
||||
@ -8,13 +8,9 @@ import { EMPTY, Observable, Observer, throwError } from 'rxjs';
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class HermesSocketService implements OnInit {
|
||||
export class HermesSocketService {
|
||||
private socket: WebSocketSubject<any> | undefined = undefined
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
public connect(): void {
|
||||
if (!this.socket || this.socket.closed) {
|
||||
@ -22,9 +18,10 @@ export class HermesSocketService implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public first(predicate: (data: any) => boolean): Observable<any> {
|
||||
if (!this.socket || this.socket.closed)
|
||||
return new Observable().pipe(timeout(3000), catchError((e) => throwError(() => 'No response after 3 seconds.')));
|
||||
public first<T>(predicate: (data: T) => boolean): Observable<T> {
|
||||
if (!this.socket || this.socket.closed) {
|
||||
throw new Error('Socket is ' + (this.socket ? 'closed' : 'null') + '.');
|
||||
}
|
||||
|
||||
return this.socket.pipe(timeout(3000), catchError((e) => throwError(() => 'No response after 3 seconds.')), first(predicate));
|
||||
}
|
||||
@ -43,7 +40,11 @@ export class HermesSocketService implements OnInit {
|
||||
}
|
||||
|
||||
public get$(): Observable<any> | undefined {
|
||||
return this.socket?.asObservable().pipe(catchError(_ => EMPTY));
|
||||
if (!this.socket || this.socket.closed) {
|
||||
throw new Error('Socket is ' + (this.socket ? 'closed' : 'null') + '.');
|
||||
}
|
||||
|
||||
return this.socket.asObservable().pipe(catchError(_ => EMPTY));
|
||||
}
|
||||
|
||||
public subscribe(subscriptions: Partial<Observer<any>> | ((value: any) => void)) {
|
||||
|
@ -9,6 +9,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import EventService from '../../shared/services/EventService';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'key-item-edit',
|
||||
@ -50,7 +51,7 @@ export class KeyItemEditComponent {
|
||||
}
|
||||
|
||||
const label = this.labelControl.value;
|
||||
this.http.post('/api/keys', { label },
|
||||
this.http.post(environment.API_HOST + '/keys', { label },
|
||||
{
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
|
||||
|
@ -6,6 +6,7 @@ import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import ApiKey from '../../shared/models/api-key';
|
||||
import EventService from '../../shared/services/EventService';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'key-item',
|
||||
@ -32,7 +33,7 @@ export class KeyItemComponent {
|
||||
}
|
||||
|
||||
const key_id = this.key().id;
|
||||
this.http.delete('/api/keys',
|
||||
this.http.delete(environment.API_HOST + '/keys',
|
||||
{
|
||||
body: {
|
||||
key: key_id,
|
||||
|
@ -5,8 +5,8 @@
|
||||
</button>
|
||||
|
||||
<span>Tom-to-Speech</span>
|
||||
@if (isLoggedIn) {
|
||||
<span class="spacer"></span>
|
||||
@if (isLoggedIn) {
|
||||
<div class="links">
|
||||
@if (showImpersonation) {
|
||||
<impersonation />
|
||||
|
@ -51,7 +51,7 @@ export class Topbar implements OnDestroy {
|
||||
}
|
||||
|
||||
get isAdminLoggedIn() {
|
||||
return this.auth.isAuthenticated() && this.auth.isAdmin();
|
||||
return this.auth.isAdmin();
|
||||
}
|
||||
|
||||
get username() {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import EventService from '../EventService';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -50,26 +51,28 @@ export class ApiAuthenticationService {
|
||||
return;
|
||||
}
|
||||
|
||||
// /api/auth/validate
|
||||
this.http.get('/api/auth/validate', {
|
||||
this.http.get(environment.API_HOST + '/auth/validate', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + jwt
|
||||
}
|
||||
}).subscribe((data: any) => {
|
||||
this.updateAuthenticated(data?.authenticated, data?.user);
|
||||
},
|
||||
withCredentials: true
|
||||
}).subscribe({
|
||||
next: (data: any) => this.updateAuthenticated(data?.authenticated, data?.user),
|
||||
error: () => this.updateAuthenticated(false, null)
|
||||
});
|
||||
}
|
||||
|
||||
private updateAuthenticated(authenticated: boolean, user: any) {
|
||||
const previous = this.authenticated;
|
||||
this.authenticated = authenticated;
|
||||
this.user = user;
|
||||
this.authenticated = authenticated;
|
||||
this.lastCheck = new Date();
|
||||
|
||||
if (previous != authenticated) {
|
||||
if (authenticated) {
|
||||
this.events.emit('login', null);
|
||||
} else {
|
||||
localStorage.removeItem('jwt');
|
||||
this.events.emit('logoff', null);
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Component, Inject, OnInit, PLATFORM_ID } from '@angular/core';
|
||||
import { Component, inject, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ApiAuthenticationService } from '../shared/services/api/api-authentication.service';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-twitch-auth-callback',
|
||||
@ -12,20 +13,26 @@ import { environment } from '../../environments/environment';
|
||||
templateUrl: './twitch-auth-callback.component.html',
|
||||
styleUrl: './twitch-auth-callback.component.scss'
|
||||
})
|
||||
export class TwitchAuthCallbackComponent implements OnInit {
|
||||
private isBrowser: boolean;
|
||||
export class TwitchAuthCallbackComponent implements OnInit, OnDestroy {
|
||||
private readonly auth = inject(ApiAuthenticationService);
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly subscriptions: (Subscription | null)[] = [];
|
||||
|
||||
constructor(private http: HttpClient, private auth: ApiAuthenticationService, private route: ActivatedRoute, private router: Router, @Inject(PLATFORM_ID) private platformId: Object) {
|
||||
this.isBrowser = isPlatformBrowser(this.platformId)
|
||||
}
|
||||
constructor(@Inject(PLATFORM_ID) private platformId: Object) { }
|
||||
|
||||
async ngOnInit(): Promise<any> {
|
||||
if (!this.isBrowser) {
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.auth.isAuthenticated()) {
|
||||
await this.router.navigate(['tts-login']);
|
||||
if (!this.auth.isAuthenticated() && localStorage.getItem('jwt')) {
|
||||
localStorage.removeItem('jwt');
|
||||
}
|
||||
|
||||
if (this.auth.isAuthenticated() || localStorage.getItem('jwt')) {
|
||||
await this.router.navigate(['policies']);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -43,20 +50,24 @@ export class TwitchAuthCallbackComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.http.post(environment.API_HOST + '/auth/twitch/callback', { code, scope, state })
|
||||
.subscribe(async (response: any) => {
|
||||
if (!response?.authenticated) {
|
||||
await this.router.navigate(['login'], {
|
||||
queryParams: {
|
||||
error: 'callback_issue'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('jwt', response.token);
|
||||
this.auth.update();
|
||||
|
||||
await this.router.navigate(['tts-login']);
|
||||
.subscribe({
|
||||
next: async (response: any) => {
|
||||
console.log('twitch api callback response:', response);
|
||||
localStorage.setItem('jwt', response.token);
|
||||
this.auth.update();
|
||||
},
|
||||
error: async () => await this.router.navigate(['login'], {
|
||||
queryParams: {
|
||||
error: 'callback_issue_twitch'
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
for (let subscription of this.subscriptions) {
|
||||
if (subscription)
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import { Group } from '../../shared/models/group';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-twitch-user-item-add',
|
||||
@ -49,7 +50,7 @@ export class TwitchUserItemAddComponent implements OnInit {
|
||||
this.responseError = undefined;
|
||||
|
||||
const username = this.usernameControl.value!.toLowerCase();
|
||||
this.http.get('/api/auth/twitch/users?login=' + username, {
|
||||
this.http.get(environment.API_HOST + '/auth/twitch/users?login=' + username, {
|
||||
headers: {
|
||||
'x-api-key': this.client.api_key,
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user