Added API keys. Minor modifications for other views.

This commit is contained in:
Tom 2025-03-27 16:31:07 +00:00
parent b1bac758e3
commit 298d351e5d
31 changed files with 499 additions and 24 deletions

View File

@ -116,6 +116,7 @@
(click)="deleteAction(action)">Delete</button> (click)="deleteAction(action)">Delete</button>
} }
<button mat-raised-button <button mat-raised-button
disabled="{{waitForResponse}}"
(click)="dialogRef.close()">Cancel</button> (click)="dialogRef.close()">Cancel</button>
<button mat-raised-button <button mat-raised-button
disabled="{{!formsDirty || !formsValidity || waitForResponse}}" disabled="{{!formsDirty || !formsValidity || waitForResponse}}"

View File

@ -70,7 +70,7 @@ export class AppComponent implements OnInit, OnDestroy {
})); }));
this.addSubscription(this.events.listen('login', () => { this.addSubscription(this.events.listen('login', () => {
this.keyService.fetch(true) this.keyService.fetch()
.pipe(timeout(3000), first()) .pipe(timeout(3000), first())
.subscribe(async (d: ApiKey[]) => { .subscribe(async (d: ApiKey[]) => {
if (d.length > 0) if (d.length > 0)

View File

@ -23,6 +23,7 @@ import PermissionResolver from './shared/resolvers/permission-resolver';
import { ConnectionsComponent } from './connections/connections/connections.component'; import { ConnectionsComponent } from './connections/connections/connections.component';
import ConnectionResolver from './shared/resolvers/connection-resolver'; import ConnectionResolver from './shared/resolvers/connection-resolver';
import { ConnectionCallbackComponent } from './connections/callback/callback.component'; import { ConnectionCallbackComponent } from './connections/callback/callback.component';
import { KeysComponent } from './keys/keys/keys.component';
export const routes: Routes = [ export const routes: Routes = [
{ {
@ -80,6 +81,14 @@ export const routes: Routes = [
permissions: PermissionResolver, permissions: PermissionResolver,
} }
}, },
{
path: 'keys',
component: KeysComponent,
canActivate: [AuthUserGuard],
resolve: {
keys: ApiKeyResolver,
}
},
{ {
path: 'login', path: 'login',
component: LoginComponent, component: LoginComponent,

View File

@ -52,7 +52,7 @@ export class ImpersonationComponent implements OnInit {
.subscribe(async _ => .subscribe(async _ =>
await setTimeout(async () => await setTimeout(async () =>
await this.router.navigate([url.substring(1)]), 500)); await this.router.navigate([url.substring(1)]), 500));
this.keyService.fetch(true) this.keyService.fetch()
.pipe(timeout(3000), first()) .pipe(timeout(3000), first())
.subscribe(async (d: ApiKey[]) => { .subscribe(async (d: ApiKey[]) => {
if (d.length > 0) if (d.length > 0)

View File

@ -39,7 +39,7 @@ export class TtsLoginComponent implements OnInit, OnDestroy {
this.subscriptions.push(this.events.listen('impersonation', _ => { this.subscriptions.push(this.events.listen('impersonation', _ => {
this.selected_api_key = undefined; this.selected_api_key = undefined;
this.keyService.fetch(true) this.keyService.fetch()
.pipe(timeout(3000), first()) .pipe(timeout(3000), first())
.subscribe(d => this.api_keys = d); .subscribe(d => this.api_keys = d);
})); }));

View File

@ -45,10 +45,11 @@
align="end"> align="end">
<button mat-raised-button <button mat-raised-button
class="warning" class="warning"
disabled="{{waitForResponse}}"
(click)="dialogRef.close()">Cancel</button> (click)="dialogRef.close()">Cancel</button>
<button mat-raised-button <button mat-raised-button
class="confirm" class="confirm"
disabled="{{form.invalid || waitForResponse}}" disabled="{{!form.dirty || form.invalid || waitForResponse}}"
(click)="submit()">Add</button> (click)="submit()">Add</button>
</mat-card-actions> </mat-card-actions>

View File

@ -7,8 +7,6 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { ActionItemEditComponent } from '../../actions/action-item-edit/action-item-edit.component';
import { HermesClientService } from '../../hermes-client.service';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
@ -27,10 +25,10 @@ import { DOCUMENT } from '@angular/common';
styleUrl: './connection-item-edit.component.scss' styleUrl: './connection-item-edit.component.scss'
}) })
export class ConnectionItemEditComponent { export class ConnectionItemEditComponent {
private readonly client = inject(HermesClientService);
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly data = inject<{ name: string }>(MAT_DIALOG_DATA);
readonly dialogRef = inject(MatDialogRef<ConnectionItemEditComponent>);
readonly data = inject<{ name: string }>(MAT_DIALOG_DATA);
readonly nameControl = new FormControl<string>('', [Validators.required]); readonly nameControl = new FormControl<string>('', [Validators.required]);
readonly clientIdControl = new FormControl<string>('', [Validators.required]); readonly clientIdControl = new FormControl<string>('', [Validators.required]);
readonly typeControl = new FormControl<string>('', [Validators.required]); readonly typeControl = new FormControl<string>('', [Validators.required]);
@ -40,7 +38,6 @@ export class ConnectionItemEditComponent {
type: this.typeControl, type: this.typeControl,
}); });
readonly dialogRef = inject(MatDialogRef<ActionItemEditComponent>);
responseError: string | undefined; responseError: string | undefined;
waitForResponse = false; waitForResponse = false;

View File

@ -5,13 +5,13 @@
<article class="right"> <article class="right">
<button mat-button <button mat-button
class="neutral" class="neutral"
(click)="renew(connection())"> (click)="renew()">
<mat-icon>refresh</mat-icon> <mat-icon>refresh</mat-icon>
Renew Renew
</button> </button>
<button mat-button <button mat-button
class="danger" class="danger"
(click)="delete(connection())"> (click)="delete()">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
Delete Delete
</button> </button>

View File

@ -5,7 +5,6 @@ import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { HermesClientService } from '../../hermes-client.service'; import { HermesClientService } from '../../hermes-client.service';
@ -21,19 +20,19 @@ import { HermesClientService } from '../../hermes-client.service';
styleUrl: './connection-item.component.scss' styleUrl: './connection-item.component.scss'
}) })
export class ConnectionItemComponent { export class ConnectionItemComponent {
router = inject(Router); private readonly http = inject(HttpClient);
http = inject(HttpClient); private readonly client = inject(HermesClientService);
client = inject(HermesClientService);
connection = input.required<Connection>(); connection = input.required<Connection>();
constructor(@Inject(DOCUMENT) private document: Document) { } constructor(@Inject(DOCUMENT) private document: Document) { }
delete(conn: Connection) { delete() {
this.client.deleteConnection(conn.name); this.client.deleteConnection(this.connection().name);
} }
renew(conn: Connection) { renew() {
const conn = this.connection();
this.http.post('/api/auth/connections', { this.http.post('/api/auth/connections', {
name: conn.name, name: conn.name,
type: conn.type, type: conn.type,

View File

@ -0,0 +1,39 @@
<mat-card>
<mat-card-header>
<mat-card-title-group>
<mat-card-title>Add API Key</mat-card-title>
<mat-card-subtitle></mat-card-subtitle>
</mat-card-title-group>
</mat-card-header>
<mat-card-content>
<mat-form-field>
<mat-label>Key Label</mat-label>
<input matInput
[formControl]="labelControl" />
@if (labelControl.invalid && (labelControl.dirty || labelControl.touched)) {
@if (labelControl.hasError('required')) {
<small class="error">This field is required.</small>
}
}
</mat-form-field>
</mat-card-content>
<mat-card-actions class="actions"
align="end">
<button mat-raised-button
class="warning"
disabled="{{waitForResponse}}"
(click)="dialogRef.close()">Cancel</button>
<button mat-raised-button
class="confirm"
disabled="{{!form.dirty || form.invalid || waitForResponse}}"
(click)="submit()">Add</button>
</mat-card-actions>
@if (responseError) {
<mat-card-footer>
<small class="error below">{{responseError}}</small>
</mat-card-footer>
}
</mat-card>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { KeyItemEditComponent } from './key-item-edit.component';
describe('KeyItemEditComponent', () => {
let component: KeyItemEditComponent;
let fixture: ComponentFixture<KeyItemEditComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [KeyItemEditComponent]
})
.compileComponents();
fixture = TestBed.createComponent(KeyItemEditComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,67 @@
import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import EventService from '../../shared/services/EventService';
@Component({
selector: 'key-item-edit',
imports: [
MatButtonModule,
MatCardModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule,
],
templateUrl: './key-item-edit.component.html',
styleUrl: './key-item-edit.component.scss'
})
export class KeyItemEditComponent {
private readonly http = inject(HttpClient);
private readonly events = inject(EventService);
readonly data = inject<{ name: string }>(MAT_DIALOG_DATA);
readonly labelControl = new FormControl<string>('', [Validators.required]);
readonly form = new FormGroup({
name: this.labelControl,
});
readonly dialogRef = inject(MatDialogRef<KeyItemEditComponent>);
responseError: string | undefined;
waitForResponse = false;
ngOnInit(): void {
this.labelControl.setValue(this.data.name);
}
submit(): void {
if (this.form.invalid || this.waitForResponse) {
return;
}
const label = this.labelControl.value;
this.http.post('/api/keys', { label },
{
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe(async (d: any) => {
this.events.emit('add_api_key', {
id: d.key,
label: d.label,
});
this.dialogRef.close();
});
}
}

View File

@ -0,0 +1,20 @@
<section>
{{(isVisible ? key().id : key().label)}}
<article class="right">
<button mat-button
class="neutral"
[disabled]="waitForResponse"
(click)="isVisible = !isVisible">
<mat-icon>{{(isVisible ? "visibility_off" : "visibility")}}</mat-icon>
{{(isVisible ? "Hide" : "View")}} Key
</button>
<button mat-button
class="danger"
[disabled]="waitForResponse"
(click)="delete()">
<mat-icon>delete</mat-icon>
Delete
</button>
</article>
</section>

View File

@ -0,0 +1,11 @@
section {
padding: 1em;
}
.right {
float: right;
}
button {
margin: 0 0.5em;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { KeyItemComponent } from './key-item.component';
describe('KeyItemComponent', () => {
let component: KeyItemComponent;
let fixture: ComponentFixture<KeyItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [KeyItemComponent]
})
.compileComponents();
fixture = TestBed.createComponent(KeyItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,48 @@
import { Component, inject, input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import ApiKey from '../../shared/models/api-key';
import EventService from '../../shared/services/EventService';
@Component({
selector: 'key-item',
imports: [
MatButtonModule,
MatIconModule,
MatFormFieldModule,
ReactiveFormsModule,
],
templateUrl: './key-item.component.html',
styleUrl: './key-item.component.scss'
})
export class KeyItemComponent {
private readonly http = inject(HttpClient);
private readonly events = inject(EventService);
key = input.required<ApiKey>();
isVisible: boolean = false;
waitForResponse = false;
delete() {
if (this.waitForResponse) {
return;
}
const key_id = this.key().id;
this.http.delete('/api/keys',
{
body: {
key: key_id,
},
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe(async (d: any) => {
this.events.emit('delete_api_key', key_id);
this.waitForResponse = false;
});
}
}

View File

@ -0,0 +1,28 @@
<ul>
<li class="header">
<mat-form-field appearance="outline"
subscriptSizing="dynamic">
<mat-label>Label Filter</mat-label>
<input matInput
placeholder="Filter keys by label"
[formControl]="searchControl" />
</mat-form-field>
<button mat-icon-button
(click)="add()">
<mat-icon>add</mat-icon>
</button>
</li>
@for (key of keys; track key.id) {
<li>
<key-item [key]="key" />
</li>
}
@if (!keys.length) {
@if (searchControl.value) {
<p class="notice">No API keys match the filter.</p>
} @else {
<p class="notice">No API keys available.</p>
}
}
</ul>

View File

@ -0,0 +1,38 @@
@use '@angular/material' as mat;
ul {
@include mat.all-component-densities(-5);
@include mat.form-field-overrides((
outlined-outline-color: rgb(167, 88, 199),
outlined-focus-label-text-color: rgb(155, 57, 194),
outlined-focus-outline-color: rgb(155, 57, 194),
));
background-color: rgb(202, 68, 255);
border-radius: 15px;
margin: 0 0;
padding: 0;
max-width: 600px;
overflow: hidden;
}
ul li {
margin: 0;
padding: 0;
list-style: none;
background-color: rgb(240, 165, 255);
}
ul li.header {
background-color: rgb(215, 115, 255);
display: flex;
align-items: center;
justify-content: space-around;
flex-direction: row;
padding: 8px;
}
ul .notice {
text-align: center;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { KeyListComponent } from './key-list.component';
describe('KeyListComponent', () => {
let component: KeyListComponent;
let fixture: ComponentFixture<KeyListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [KeyListComponent]
})
.compileComponents();
fixture = TestBed.createComponent(KeyListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,61 @@
import { Component, inject, Input } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatDialog } from '@angular/material/dialog';
import { MatSelectModule } from '@angular/material/select';
import { containsLettersInOrder } from '../../shared/utils/string-compare';
import { KeyItemComponent } from '../key-item/key-item.component';
import { KeyItemEditComponent } from '../key-item-edit/key-item-edit.component';
import ApiKey from '../../shared/models/api-key';
@Component({
selector: 'key-list',
imports: [
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule,
KeyItemComponent,
],
templateUrl: './key-list.component.html',
styleUrl: './key-list.component.scss'
})
export class KeyListComponent {
private readonly _dialog = inject(MatDialog);
private _keys: ApiKey[] = [];
readonly searchControl = new FormControl<string>('');
opened = false;
get keys() {
return this._keys.filter(c => containsLettersInOrder(c.label, this.searchControl.value));
}
@Input({ required: true })
set keys(value: ApiKey[]) {
this._keys = value;
}
add() {
if (this.opened)
return;
this.opened = true;
const dialogRef = this._dialog.open(KeyItemEditComponent, {
data: { name: this.searchControl.value },
});
dialogRef.afterClosed().subscribe((_: any) => this.opened = false);
}
}

View File

@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({
declarations: [],
imports: [
CommonModule
]
})
export class KeysModule { }

View File

@ -0,0 +1,2 @@
<h3>API Keys</h3>
<key-list [keys]="keys" />

View File

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { KeysComponent } from './keys.component';
describe('KeysComponent', () => {
let component: KeysComponent;
let fixture: ComponentFixture<KeysComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [KeysComponent]
})
.compileComponents();
fixture = TestBed.createComponent(KeysComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,39 @@
import { Component, inject, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { KeyListComponent } from '../key-list/key-list.component';
import ApiKey from '../../shared/models/api-key';
import EventService from '../../shared/services/EventService';
import { ApiKeyService } from '../../shared/services/api/api-key.service';
@Component({
selector: 'keys',
imports: [KeyListComponent],
templateUrl: './keys.component.html',
styleUrl: './keys.component.scss'
})
export class KeysComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly events = inject(EventService);
private readonly keyService = inject(ApiKeyService);
subscriptions: (Subscription | undefined)[] = [];
keys: ApiKey[] = [];
constructor() {
this.route.data.subscribe(payload => {
this.keys = payload['keys'] ?? [];
});
this.subscriptions.push(this.events.listen('add_api_key', _ => this.keyService.fetch().subscribe(keys => this.keys = keys)));
this.subscriptions.push(this.events.listen('delete_api_key', _ => this.keyService.fetch().subscribe(keys => this.keys = keys)));
}
ngOnDestroy(): void {
for (let subscription of this.subscriptions) {
if (subscription)
subscription.unsubscribe();
}
}
}

View File

@ -54,6 +54,12 @@
Connections Connections
</a> </a>
</li> </li>
<li>
<a routerLink="/keys"
routerLinkActive="active">
API Keys
</a>
</li>
} }
</ul> </ul>

View File

@ -24,10 +24,11 @@
<mat-card-actions class="actions"> <mat-card-actions class="actions">
<button mat-raised-button <button mat-raised-button
disabled="{{waitForResponse}}"
(click)="dialogRef.close()">Cancel</button> (click)="dialogRef.close()">Cancel</button>
<button mat-raised-button <button mat-raised-button
disabled="{{pathControl.invalid || waitForResponse}}" disabled="{{!pathControl.dirty || pathControl.invalid || waitForResponse}}"
(click)="submit()">{{data.isNew ? "Add" : "Save"}}</button> (click)="submit()">{{data.isNew ? "Add" : "Save"}}</button>
</mat-card-actions> </mat-card-actions>

View File

@ -20,10 +20,13 @@ export class ApiKeyService {
this.keys = []; this.keys = [];
this.loaded = false; this.loaded = false;
}); });
this.events.listen('delete_api_key', payload => this.keys = this.keys.filter(k => k.id != payload));
this.events.listen('add_api_key', payload => this.keys.push(payload));
} }
fetch(force: boolean = false) { fetch() {
if (!force && this.loaded) if (this.loaded)
return of(this.keys); return of(this.keys);
const $ = this.http.get<ApiKey[]>(environment.API_HOST + '/keys', { const $ = this.http.get<ApiKey[]>(environment.API_HOST + '/keys', {

View File

@ -22,8 +22,8 @@ export default class TwitchRedemptionService {
}); });
} }
fetch(force: boolean = false) { fetch() {
if (!force && this.loaded) if (this.loaded)
return of(this.twitchRedemptions); return of(this.twitchRedemptions);
const $ = this.http.get<TwitchRedemption[]>(environment.API_HOST + '/twitch/redemptions', { const $ = this.http.get<TwitchRedemption[]>(environment.API_HOST + '/twitch/redemptions', {

View File

@ -16,9 +16,10 @@
<mat-card-actions class="actions"> <mat-card-actions class="actions">
<button mat-raised-button <button mat-raised-button
disabled="{{waitForResponse}}"
(click)="dialogRef.close()">Cancel</button> (click)="dialogRef.close()">Cancel</button>
<button mat-raised-button <button mat-raised-button
disabled="{{usernameControl.invalid || waitForResponse}}" disabled="{{!usernameControl.dirty || usernameControl.invalid || waitForResponse}}"
(click)="submit()">Add</button> (click)="submit()">Add</button>
</mat-card-actions> </mat-card-actions>