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:
Tom 2025-04-05 02:06:31 +00:00
parent 70e0e9bf71
commit 3e9a9f9dc5
18 changed files with 129 additions and 71 deletions

View File

@ -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
View File

@ -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",

View File

@ -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",

View File

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

View File

@ -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']);

View File

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

View File

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

View File

@ -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,

View File

@ -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"

View File

@ -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,

View File

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

View File

@ -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')

View File

@ -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,

View File

@ -5,8 +5,8 @@
</button>
<span>Tom-to-Speech</span>
@if (isLoggedIn) {
<span class="spacer"></span>
@if (isLoggedIn) {
<div class="links">
@if (showImpersonation) {
<impersonation />

View File

@ -51,7 +51,7 @@ export class Topbar implements OnDestroy {
}
get isAdminLoggedIn() {
return this.auth.isAuthenticated() && this.auth.isAdmin();
return this.auth.isAdmin();
}
get username() {

View File

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

View File

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

View File

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