From 298d351e5d1544966b8c21bd9c7938e00ddd6247 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 27 Mar 2025 16:31:07 +0000 Subject: [PATCH] Added API keys. Minor modifications for other views. --- .../action-item-edit.component.html | 1 + src/app/app.component.ts | 2 +- src/app/app.routes.ts | 9 +++ .../impersonation/impersonation.component.ts | 2 +- src/app/auth/tts-login/tts-login.component.ts | 2 +- .../connection-item-edit.component.html | 3 +- .../connection-item-edit.component.ts | 7 +- .../connection-item.component.html | 4 +- .../connection-item.component.ts | 13 ++-- .../key-item-edit.component.html | 39 +++++++++++ .../key-item-edit.component.scss | 0 .../key-item-edit.component.spec.ts | 23 +++++++ .../key-item-edit/key-item-edit.component.ts | 67 +++++++++++++++++++ src/app/keys/key-item/key-item.component.html | 20 ++++++ src/app/keys/key-item/key-item.component.scss | 11 +++ .../keys/key-item/key-item.component.spec.ts | 23 +++++++ src/app/keys/key-item/key-item.component.ts | 48 +++++++++++++ src/app/keys/key-list/key-list.component.html | 28 ++++++++ src/app/keys/key-list/key-list.component.scss | 38 +++++++++++ .../keys/key-list/key-list.component.spec.ts | 23 +++++++ src/app/keys/key-list/key-list.component.ts | 61 +++++++++++++++++ src/app/keys/keys.module.ts | 12 ++++ src/app/keys/keys/keys.component.html | 2 + src/app/keys/keys/keys.component.scss | 0 src/app/keys/keys/keys.component.spec.ts | 23 +++++++ src/app/keys/keys/keys.component.ts | 39 +++++++++++ src/app/navigation/navigation.component.html | 6 ++ .../permission-item-edit.component.html | 3 +- .../shared/services/api/api-key.service.ts | 7 +- .../services/twitch-redemption.service.ts | 4 +- .../twitch-user-item-add.component.html | 3 +- 31 files changed, 499 insertions(+), 24 deletions(-) create mode 100644 src/app/keys/key-item-edit/key-item-edit.component.html create mode 100644 src/app/keys/key-item-edit/key-item-edit.component.scss create mode 100644 src/app/keys/key-item-edit/key-item-edit.component.spec.ts create mode 100644 src/app/keys/key-item-edit/key-item-edit.component.ts create mode 100644 src/app/keys/key-item/key-item.component.html create mode 100644 src/app/keys/key-item/key-item.component.scss create mode 100644 src/app/keys/key-item/key-item.component.spec.ts create mode 100644 src/app/keys/key-item/key-item.component.ts create mode 100644 src/app/keys/key-list/key-list.component.html create mode 100644 src/app/keys/key-list/key-list.component.scss create mode 100644 src/app/keys/key-list/key-list.component.spec.ts create mode 100644 src/app/keys/key-list/key-list.component.ts create mode 100644 src/app/keys/keys.module.ts create mode 100644 src/app/keys/keys/keys.component.html create mode 100644 src/app/keys/keys/keys.component.scss create mode 100644 src/app/keys/keys/keys.component.spec.ts create mode 100644 src/app/keys/keys/keys.component.ts diff --git a/src/app/actions/action-item-edit/action-item-edit.component.html b/src/app/actions/action-item-edit/action-item-edit.component.html index ff8f03a..bb3c0e7 100644 --- a/src/app/actions/action-item-edit/action-item-edit.component.html +++ b/src/app/actions/action-item-edit/action-item-edit.component.html @@ -116,6 +116,7 @@ (click)="deleteAction(action)">Delete } diff --git a/src/app/connections/connection-item-edit/connection-item-edit.component.ts b/src/app/connections/connection-item-edit/connection-item-edit.component.ts index cd7931e..75d3176 100644 --- a/src/app/connections/connection-item-edit/connection-item-edit.component.ts +++ b/src/app/connections/connection-item-edit/connection-item-edit.component.ts @@ -7,8 +7,6 @@ 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 { ActionItemEditComponent } from '../../actions/action-item-edit/action-item-edit.component'; -import { HermesClientService } from '../../hermes-client.service'; import { MatSelectModule } from '@angular/material/select'; import { DOCUMENT } from '@angular/common'; @@ -27,10 +25,10 @@ import { DOCUMENT } from '@angular/common'; styleUrl: './connection-item-edit.component.scss' }) export class ConnectionItemEditComponent { - private readonly client = inject(HermesClientService); private readonly http = inject(HttpClient); + private readonly data = inject<{ name: string }>(MAT_DIALOG_DATA); + readonly dialogRef = inject(MatDialogRef); - readonly data = inject<{ name: string }>(MAT_DIALOG_DATA); readonly nameControl = new FormControl('', [Validators.required]); readonly clientIdControl = new FormControl('', [Validators.required]); readonly typeControl = new FormControl('', [Validators.required]); @@ -40,7 +38,6 @@ export class ConnectionItemEditComponent { type: this.typeControl, }); - readonly dialogRef = inject(MatDialogRef); responseError: string | undefined; waitForResponse = false; diff --git a/src/app/connections/connection-item/connection-item.component.html b/src/app/connections/connection-item/connection-item.component.html index 1faf2b6..93da90f 100644 --- a/src/app/connections/connection-item/connection-item.component.html +++ b/src/app/connections/connection-item/connection-item.component.html @@ -5,13 +5,13 @@
diff --git a/src/app/connections/connection-item/connection-item.component.ts b/src/app/connections/connection-item/connection-item.component.ts index de4c47e..2fb62e9 100644 --- a/src/app/connections/connection-item/connection-item.component.ts +++ b/src/app/connections/connection-item/connection-item.component.ts @@ -5,7 +5,6 @@ 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 { Router } from '@angular/router'; import { DOCUMENT } from '@angular/common'; import { HermesClientService } from '../../hermes-client.service'; @@ -21,19 +20,19 @@ import { HermesClientService } from '../../hermes-client.service'; styleUrl: './connection-item.component.scss' }) export class ConnectionItemComponent { - router = inject(Router); - http = inject(HttpClient); - client = inject(HermesClientService); + private readonly http = inject(HttpClient); + private readonly client = inject(HermesClientService); connection = input.required(); constructor(@Inject(DOCUMENT) private document: Document) { } - delete(conn: Connection) { - this.client.deleteConnection(conn.name); + delete() { + this.client.deleteConnection(this.connection().name); } - renew(conn: Connection) { + renew() { + const conn = this.connection(); this.http.post('/api/auth/connections', { name: conn.name, type: conn.type, diff --git a/src/app/keys/key-item-edit/key-item-edit.component.html b/src/app/keys/key-item-edit/key-item-edit.component.html new file mode 100644 index 0000000..26647c9 --- /dev/null +++ b/src/app/keys/key-item-edit/key-item-edit.component.html @@ -0,0 +1,39 @@ + + + + Add API Key + + + + + + + Key Label + + @if (labelControl.invalid && (labelControl.dirty || labelControl.touched)) { + @if (labelControl.hasError('required')) { + This field is required. + } + } + + + + + + + + + @if (responseError) { + + {{responseError}} + + } + \ No newline at end of file diff --git a/src/app/keys/key-item-edit/key-item-edit.component.scss b/src/app/keys/key-item-edit/key-item-edit.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/keys/key-item-edit/key-item-edit.component.spec.ts b/src/app/keys/key-item-edit/key-item-edit.component.spec.ts new file mode 100644 index 0000000..23cf2b1 --- /dev/null +++ b/src/app/keys/key-item-edit/key-item-edit.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [KeyItemEditComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(KeyItemEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/keys/key-item-edit/key-item-edit.component.ts b/src/app/keys/key-item-edit/key-item-edit.component.ts new file mode 100644 index 0000000..4959b3a --- /dev/null +++ b/src/app/keys/key-item-edit/key-item-edit.component.ts @@ -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('', [Validators.required]); + readonly form = new FormGroup({ + name: this.labelControl, + }); + + readonly dialogRef = inject(MatDialogRef); + + 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(); + }); + } +} diff --git a/src/app/keys/key-item/key-item.component.html b/src/app/keys/key-item/key-item.component.html new file mode 100644 index 0000000..b2bfc9d --- /dev/null +++ b/src/app/keys/key-item/key-item.component.html @@ -0,0 +1,20 @@ +
+ {{(isVisible ? key().id : key().label)}} + +
+ + +
+
\ No newline at end of file diff --git a/src/app/keys/key-item/key-item.component.scss b/src/app/keys/key-item/key-item.component.scss new file mode 100644 index 0000000..22a0489 --- /dev/null +++ b/src/app/keys/key-item/key-item.component.scss @@ -0,0 +1,11 @@ +section { + padding: 1em; +} + +.right { + float: right; +} + +button { + margin: 0 0.5em; +} \ No newline at end of file diff --git a/src/app/keys/key-item/key-item.component.spec.ts b/src/app/keys/key-item/key-item.component.spec.ts new file mode 100644 index 0000000..21c97f7 --- /dev/null +++ b/src/app/keys/key-item/key-item.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [KeyItemComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(KeyItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/keys/key-item/key-item.component.ts b/src/app/keys/key-item/key-item.component.ts new file mode 100644 index 0000000..e0dd409 --- /dev/null +++ b/src/app/keys/key-item/key-item.component.ts @@ -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(); + 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; + }); + } +} diff --git a/src/app/keys/key-list/key-list.component.html b/src/app/keys/key-list/key-list.component.html new file mode 100644 index 0000000..cd5d798 --- /dev/null +++ b/src/app/keys/key-list/key-list.component.html @@ -0,0 +1,28 @@ +
    +
  • + + Label Filter + + + + +
  • + @for (key of keys; track key.id) { +
  • + +
  • + } + @if (!keys.length) { + @if (searchControl.value) { +

    No API keys match the filter.

    + } @else { +

    No API keys available.

    + } + } +
\ No newline at end of file diff --git a/src/app/keys/key-list/key-list.component.scss b/src/app/keys/key-list/key-list.component.scss new file mode 100644 index 0000000..7d3fc2c --- /dev/null +++ b/src/app/keys/key-list/key-list.component.scss @@ -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; +} \ No newline at end of file diff --git a/src/app/keys/key-list/key-list.component.spec.ts b/src/app/keys/key-list/key-list.component.spec.ts new file mode 100644 index 0000000..ab0b40f --- /dev/null +++ b/src/app/keys/key-list/key-list.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [KeyListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(KeyListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/keys/key-list/key-list.component.ts b/src/app/keys/key-list/key-list.component.ts new file mode 100644 index 0000000..de6ab30 --- /dev/null +++ b/src/app/keys/key-list/key-list.component.ts @@ -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(''); + + 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); + } +} diff --git a/src/app/keys/keys.module.ts b/src/app/keys/keys.module.ts new file mode 100644 index 0000000..56fd02c --- /dev/null +++ b/src/app/keys/keys.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + + + +@NgModule({ + declarations: [], + imports: [ + CommonModule + ] +}) +export class KeysModule { } diff --git a/src/app/keys/keys/keys.component.html b/src/app/keys/keys/keys.component.html new file mode 100644 index 0000000..2f0593a --- /dev/null +++ b/src/app/keys/keys/keys.component.html @@ -0,0 +1,2 @@ +

API Keys

+ \ No newline at end of file diff --git a/src/app/keys/keys/keys.component.scss b/src/app/keys/keys/keys.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/keys/keys/keys.component.spec.ts b/src/app/keys/keys/keys.component.spec.ts new file mode 100644 index 0000000..b7938ed --- /dev/null +++ b/src/app/keys/keys/keys.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KeysComponent } from './keys.component'; + +describe('KeysComponent', () => { + let component: KeysComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [KeysComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(KeysComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/keys/keys/keys.component.ts b/src/app/keys/keys/keys.component.ts new file mode 100644 index 0000000..8ff4c51 --- /dev/null +++ b/src/app/keys/keys/keys.component.ts @@ -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(); + } + } +} diff --git a/src/app/navigation/navigation.component.html b/src/app/navigation/navigation.component.html index e0251ea..83ce2c5 100644 --- a/src/app/navigation/navigation.component.html +++ b/src/app/navigation/navigation.component.html @@ -54,6 +54,12 @@ Connections +
  • + + API Keys + +
  • } diff --git a/src/app/permissions/permission-item-edit/permission-item-edit.component.html b/src/app/permissions/permission-item-edit/permission-item-edit.component.html index fff6e30..7f4e9bc 100644 --- a/src/app/permissions/permission-item-edit/permission-item-edit.component.html +++ b/src/app/permissions/permission-item-edit/permission-item-edit.component.html @@ -24,10 +24,11 @@ diff --git a/src/app/shared/services/api/api-key.service.ts b/src/app/shared/services/api/api-key.service.ts index 96a4c0d..d87a07e 100644 --- a/src/app/shared/services/api/api-key.service.ts +++ b/src/app/shared/services/api/api-key.service.ts @@ -20,10 +20,13 @@ export class ApiKeyService { this.keys = []; 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) { - if (!force && this.loaded) + fetch() { + if (this.loaded) return of(this.keys); const $ = this.http.get(environment.API_HOST + '/keys', { diff --git a/src/app/shared/services/twitch-redemption.service.ts b/src/app/shared/services/twitch-redemption.service.ts index 567b175..7e542c7 100644 --- a/src/app/shared/services/twitch-redemption.service.ts +++ b/src/app/shared/services/twitch-redemption.service.ts @@ -22,8 +22,8 @@ export default class TwitchRedemptionService { }); } - fetch(force: boolean = false) { - if (!force && this.loaded) + fetch() { + if (this.loaded) return of(this.twitchRedemptions); const $ = this.http.get(environment.API_HOST + '/twitch/redemptions', { diff --git a/src/app/twitch-users/twitch-user-item-add/twitch-user-item-add.component.html b/src/app/twitch-users/twitch-user-item-add/twitch-user-item-add.component.html index 373b52e..b900a99 100644 --- a/src/app/twitch-users/twitch-user-item-add/twitch-user-item-add.component.html +++ b/src/app/twitch-users/twitch-user-item-add/twitch-user-item-add.component.html @@ -16,9 +16,10 @@