From 74b282ccfdbacde03332bde1ead74f749fcc9429 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 18 Mar 2025 12:55:00 +0000 Subject: [PATCH] Added groups - missing user management. Fixed several issues. --- .gitignore | 3 +- angular.json | 13 +- package-lock.json | 25 +- package.json | 4 +- .../action-dropdown.component.ts | 2 +- .../action-item-edit.component.ts | 11 +- .../action-list/action-list.component.scss | 1 - .../action-list/action-list.component.ts | 2 +- src/app/actions/actions.module.ts | 1 - .../actions/actions/actions.component.html | 2 +- .../actions/actions/actions.component.scss | 7 +- src/app/actions/actions/actions.component.ts | 2 +- src/app/app.routes.ts | 30 +++ .../impersonation/impersonation.component.ts | 25 +- .../group-dropdown.component.html | 26 +++ .../group-dropdown.component.scss | 3 + .../group-dropdown.component.spec.ts | 23 ++ .../group-dropdown.component.ts | 99 ++++++++ .../group-item-edit.component.html | 50 ++++ .../group-item-edit.component.scss | 8 + .../group-item-edit.component.spec.ts | 23 ++ .../group-item-edit.component.ts | 67 ++++++ .../group-item/group-item.component.html | 21 ++ .../group-item/group-item.component.scss | 45 ++++ .../group-item/group-item.component.spec.ts | 23 ++ .../groups/group-item/group-item.component.ts | 35 +++ .../group-list/group-list.component.html | 7 + .../group-list/group-list.component.scss | 9 + .../group-list/group-list.component.spec.ts | 23 ++ .../groups/group-list/group-list.component.ts | 36 +++ .../group-page/group-page.component.html | 38 ++++ .../group-page/group-page.component.scss | 21 ++ .../group-page/group-page.component.spec.ts | 23 ++ .../groups/group-page/group-page.component.ts | 86 +++++++ src/app/groups/groups.module.ts | 18 ++ src/app/groups/groups/groups.component.html | 15 ++ src/app/groups/groups/groups.component.scss | 7 + .../groups/groups/groups.component.spec.ts | 23 ++ src/app/groups/groups/groups.component.ts | 119 ++++++++++ src/app/hermes-client.service.ts | 56 ++++- src/app/hermes-socket.service.ts | 13 +- src/app/navigation/navigation.component.html | 25 +- src/app/navigation/navigation.component.scss | 1 + src/app/navigation/navigation.component.ts | 3 +- src/app/policies/policies.module.ts | 8 +- .../policy-add-button.component.html | 4 + .../policy-add-button.component.scss | 3 + .../policy-add-button.component.spec.ts | 23 ++ .../policy-add-button.component.ts | 44 ++++ .../policy-item-edit.component.html | 73 ++++++ .../policy-item-edit.component.scss | 10 + .../policy-item-edit.component.spec.ts | 23 ++ .../policy-item-edit.component.ts | 96 ++++++++ .../policy-table/policy-table.component.html | 87 +++---- .../policy-table/policy-table.component.scss | 12 + .../policy-table/policy-table.component.ts | 215 ++++++------------ src/app/policies/policy/policy.component.html | 6 +- src/app/policies/policy/policy.component.scss | 6 + src/app/policies/policy/policy.component.ts | 73 +++--- .../redemption-item-edit.component.ts | 2 +- .../redemption-list.component.html | 88 +++---- .../redemption-list.component.scss | 41 ++-- .../redemption-list.component.ts | 11 +- src/app/redemptions/redemptions.module.ts | 3 +- src/app/shared/models/group-chatter.ts | 6 + src/app/shared/models/group.ts | 3 +- src/app/shared/models/policy.ts | 19 +- ...eemable_action.ts => redeemable-action.ts} | 0 .../resolvers/group-chatter-resolver.ts | 14 ++ src/app/shared/resolvers/group-resolver.ts | 14 ++ src/app/shared/resolvers/policy-resolver.ts | 14 ++ .../resolvers/redeemable-action-resolver.ts | 2 +- src/app/shared/services/group.service.spec.ts | 16 ++ src/app/shared/services/group.service.ts | 84 +++++++ .../shared/services/policy.service.spec.ts | 16 ++ src/app/shared/services/policy.service.ts | 56 +++++ .../services/redeemable-action.service.ts | 2 +- 77 files changed, 1771 insertions(+), 377 deletions(-) create mode 100644 src/app/groups/group-dropdown/group-dropdown.component.html create mode 100644 src/app/groups/group-dropdown/group-dropdown.component.scss create mode 100644 src/app/groups/group-dropdown/group-dropdown.component.spec.ts create mode 100644 src/app/groups/group-dropdown/group-dropdown.component.ts create mode 100644 src/app/groups/group-item-edit/group-item-edit.component.html create mode 100644 src/app/groups/group-item-edit/group-item-edit.component.scss create mode 100644 src/app/groups/group-item-edit/group-item-edit.component.spec.ts create mode 100644 src/app/groups/group-item-edit/group-item-edit.component.ts create mode 100644 src/app/groups/group-item/group-item.component.html create mode 100644 src/app/groups/group-item/group-item.component.scss create mode 100644 src/app/groups/group-item/group-item.component.spec.ts create mode 100644 src/app/groups/group-item/group-item.component.ts create mode 100644 src/app/groups/group-list/group-list.component.html create mode 100644 src/app/groups/group-list/group-list.component.scss create mode 100644 src/app/groups/group-list/group-list.component.spec.ts create mode 100644 src/app/groups/group-list/group-list.component.ts create mode 100644 src/app/groups/group-page/group-page.component.html create mode 100644 src/app/groups/group-page/group-page.component.scss create mode 100644 src/app/groups/group-page/group-page.component.spec.ts create mode 100644 src/app/groups/group-page/group-page.component.ts create mode 100644 src/app/groups/groups.module.ts create mode 100644 src/app/groups/groups/groups.component.html create mode 100644 src/app/groups/groups/groups.component.scss create mode 100644 src/app/groups/groups/groups.component.spec.ts create mode 100644 src/app/groups/groups/groups.component.ts create mode 100644 src/app/policies/policy-add-button/policy-add-button.component.html create mode 100644 src/app/policies/policy-add-button/policy-add-button.component.scss create mode 100644 src/app/policies/policy-add-button/policy-add-button.component.spec.ts create mode 100644 src/app/policies/policy-add-button/policy-add-button.component.ts create mode 100644 src/app/policies/policy-item-edit/policy-item-edit.component.html create mode 100644 src/app/policies/policy-item-edit/policy-item-edit.component.scss create mode 100644 src/app/policies/policy-item-edit/policy-item-edit.component.spec.ts create mode 100644 src/app/policies/policy-item-edit/policy-item-edit.component.ts create mode 100644 src/app/shared/models/group-chatter.ts rename src/app/shared/models/{redeemable_action.ts => redeemable-action.ts} (100%) create mode 100644 src/app/shared/resolvers/group-chatter-resolver.ts create mode 100644 src/app/shared/resolvers/group-resolver.ts create mode 100644 src/app/shared/resolvers/policy-resolver.ts create mode 100644 src/app/shared/services/group.service.spec.ts create mode 100644 src/app/shared/services/group.service.ts create mode 100644 src/app/shared/services/policy.service.spec.ts create mode 100644 src/app/shared/services/policy.service.ts diff --git a/.gitignore b/.gitignore index b9d1bac..43eb6f0 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,6 @@ testem.log .DS_Store Thumbs.db -src/environments/* +src/environments/ +src/index.*.html *.code-workspace \ No newline at end of file diff --git a/angular.json b/angular.json index 2c425b7..1aaf3e5 100644 --- a/angular.json +++ b/angular.json @@ -47,7 +47,7 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kB", + "maximumWarning": "1024kB", "maximumError": "1MB" }, { @@ -56,6 +56,15 @@ "maximumError": "4kB" } ], + "optimization": true, + "sourceMap": false, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "index": { + "input": "src/index.prod.html", + "output": "index.html" + }, "outputHashing": "all" }, "development": { @@ -112,4 +121,4 @@ } } } -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 133ceb9..b585675 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "angular-oauth2-oidc": "^17.0.2", "express": "^4.18.2", "ngx-socket-io": "^4.7.0", - "randomstring": "^1.3.1", "rxjs": "~7.8.0", "rxjs-websockets": "^9.0.0", "tslib": "^2.3.0", @@ -10353,6 +10352,14 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true, "license": "MIT" + }, + "peerDependencies": { + "@angular/animations": "^19.0.1", + "@angular/common": "^19.0.1", + "@angular/core": "^19.0.1", + "@angular/forms": "^19.0.1", + "rxjs": "^6.5.3 || ^7.4.0" + } }, "node_modules/ngx-socket-io": { "version": "4.8.2", @@ -11534,26 +11541,12 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" } }, - "node_modules/randomstring": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/randomstring/-/randomstring-1.3.1.tgz", - "integrity": "sha512-lgXZa80MUkjWdE7g2+PZ1xDLzc7/RokXVEQOv5NN2UOTChW1I8A9gha5a9xYBOqgaSoI6uJikDmCU8PyRdArRQ==", - "license": "MIT", - "dependencies": { - "randombytes": "2.1.0" - }, - "bin": { - "randomstring": "bin/randomstring" - }, - "engines": { - "node": "*" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", diff --git a/package.json b/package.json index 7e548f2..1d4df2c 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve", + "start": "ng serve -c production --host 0.0.0.0 --watch false", "build": "ng build", - "watch": "ng build --watch --configuration development", + "watch": "ng serve -c development --host 0.0.0.0", "test": "ng test", "serve:ssr:hermes-web-angular": "node dist/hermes-web-angular/server/server.mjs" }, diff --git a/src/app/actions/action-dropdown/action-dropdown.component.ts b/src/app/actions/action-dropdown/action-dropdown.component.ts index 36df2d7..88d1ffb 100644 --- a/src/app/actions/action-dropdown/action-dropdown.component.ts +++ b/src/app/actions/action-dropdown/action-dropdown.component.ts @@ -4,7 +4,7 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { ActivatedRoute } from '@angular/router'; -import RedeemableAction from '../../shared/models/redeemable_action'; +import RedeemableAction from '../../shared/models/redeemable-action'; @Component({ selector: 'action-dropdown', diff --git a/src/app/actions/action-item-edit/action-item-edit.component.ts b/src/app/actions/action-item-edit/action-item-edit.component.ts index 538c868..61d824f 100644 --- a/src/app/actions/action-item-edit/action-item-edit.component.ts +++ b/src/app/actions/action-item-edit/action-item-edit.component.ts @@ -1,6 +1,6 @@ import { Component, inject, OnInit } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import RedeemableAction from '../../shared/models/redeemable_action'; +import RedeemableAction from '../../shared/models/redeemable-action'; import { MatCardModule } from '@angular/material/card'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; @@ -235,9 +235,10 @@ export class ActionItemEditComponent implements OnInit { } deleteAction(action: RedeemableAction): void { - if (this.isNew) + if (this.isNew || this.waitForResponse) return; + this.waitForResponse = true; this.client.first((d: any) => d.op == 4 && d.d.request.type == 'delete_redeemable_action' && d.d.request.data.name == this.action.name) .subscribe({ next: () => this.dialogRef.close(), @@ -248,12 +249,15 @@ export class ActionItemEditComponent implements OnInit { } save(): void { - if (this.formGroup.invalid) { + if (this.formGroup.invalid || this.waitForResponse) { return; } + this.waitForResponse = true; + const fields = this.actionEntries[this.action.type]; if (fields.some(f => f.control.invalid)) { + this.waitForResponse = false; return; } @@ -265,6 +269,7 @@ export class ActionItemEditComponent implements OnInit { } if (!(this.action.type in this.actionEntries)) { + this.waitForResponse = false; return; } diff --git a/src/app/actions/action-list/action-list.component.scss b/src/app/actions/action-list/action-list.component.scss index b7ccfb8..894a345 100644 --- a/src/app/actions/action-list/action-list.component.scss +++ b/src/app/actions/action-list/action-list.component.scss @@ -7,7 +7,6 @@ main { justify-content: center; text-align: center; justify-self: center; - background-color: #fafafa; width: 80%; & .container { diff --git a/src/app/actions/action-list/action-list.component.ts b/src/app/actions/action-list/action-list.component.ts index c85345d..87bc474 100644 --- a/src/app/actions/action-list/action-list.component.ts +++ b/src/app/actions/action-list/action-list.component.ts @@ -1,6 +1,6 @@ import { Component, EventEmitter, inject, Input, Output } from '@angular/core'; import { MatListModule } from '@angular/material/list'; -import RedeemableAction from '../../shared/models/redeemable_action'; +import RedeemableAction from '../../shared/models/redeemable-action'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; diff --git a/src/app/actions/actions.module.ts b/src/app/actions/actions.module.ts index 562f7a0..da7deb7 100644 --- a/src/app/actions/actions.module.ts +++ b/src/app/actions/actions.module.ts @@ -1,5 +1,4 @@ import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; import { ActionsComponent } from './actions/actions.component'; import { ActionListComponent } from './action-list/action-list.component'; import { ActionItemEditComponent } from './action-item-edit/action-item-edit.component'; diff --git a/src/app/actions/actions/actions.component.html b/src/app/actions/actions/actions.component.html index 3752838..1c13110 100644 --- a/src/app/actions/actions/actions.component.html +++ b/src/app/actions/actions/actions.component.html @@ -27,5 +27,5 @@ - + \ No newline at end of file diff --git a/src/app/actions/actions/actions.component.scss b/src/app/actions/actions/actions.component.scss index a1c2761..e7e3ed2 100644 --- a/src/app/actions/actions/actions.component.scss +++ b/src/app/actions/actions/actions.component.scss @@ -1,5 +1,4 @@ body, h3 { - background-color: #fafafa; padding: 0; margin: 0; } @@ -12,6 +11,7 @@ body { h3 { margin-bottom: 2em; + text-align: center; } section { @@ -30,4 +30,9 @@ section { display: flex; justify-content:space-around; } +} + +.center { + display: flex; + justify-content: space-around; } \ No newline at end of file diff --git a/src/app/actions/actions/actions.component.ts b/src/app/actions/actions/actions.component.ts index ef4ef4d..7d9d008 100644 --- a/src/app/actions/actions/actions.component.ts +++ b/src/app/actions/actions/actions.component.ts @@ -5,7 +5,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatIconModule } from '@angular/material/icon'; import { HermesClientService } from '../../hermes-client.service'; -import RedeemableAction from '../../shared/models/redeemable_action'; +import RedeemableAction from '../../shared/models/redeemable-action'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import RedeemableActionService from '../../shared/services/redeemable-action.service'; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index b674b9e..974df3c 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -14,12 +14,42 @@ import TwitchRedemptionResolver from './shared/resolvers/twitch-redemption-resol import RedeemableActionResolver from './shared/resolvers/redeemable-action-resolver'; import TtsFilterResolver from './shared/resolvers/tts-filter-resolver'; import ApiKeyResolver from './shared/resolvers/api-key-resolver'; +import GroupResolver from './shared/resolvers/group-resolver'; +import PolicyResolver from './shared/resolvers/policy-resolver'; +import { GroupsComponent } from './groups/groups/groups.component'; +import { GroupPageComponent } from './groups/group-page/group-page.component'; +import GroupChatterResolver from './shared/resolvers/group-chatter-resolver'; export const routes: Routes = [ { path: 'policies', component: PolicyComponent, canActivate: [AuthUserGuard], + resolve: { + groups: GroupResolver, + chatters: GroupChatterResolver, + policies: PolicyResolver, + } + }, + { + path: 'groups', + component: GroupsComponent, + canActivate: [AuthAdminGuard], + resolve: { + groups: GroupResolver, + chatters: GroupChatterResolver, + policies: PolicyResolver, + } + }, + { + path: 'groups/:id', + component: GroupPageComponent, + canActivate: [AuthAdminGuard], + resolve: { + groups: GroupResolver, + chatters: GroupChatterResolver, + policies: PolicyResolver, + } }, { path: 'filters', diff --git a/src/app/auth/impersonation/impersonation.component.ts b/src/app/auth/impersonation/impersonation.component.ts index 0cbb242..2ef05b8 100644 --- a/src/app/auth/impersonation/impersonation.component.ts +++ b/src/app/auth/impersonation/impersonation.component.ts @@ -21,7 +21,7 @@ import { ApiKeyService } from '../../shared/services/api/api-key.service'; }) export class ImpersonationComponent implements OnInit { private readonly keyService = inject(ApiKeyService); - + impersonated: string | undefined; users: { id: string, name: string }[]; @@ -46,15 +46,17 @@ export class ImpersonationComponent implements OnInit { } }); - this.events.listen('impersonation', (userId) => { - this.keyService.fetch(true) - .pipe(timeout(3000), first()) - .subscribe(async (d: ApiKey[]) => { - if (d.length > 0) - this.hermes.login(d[0].id); + if (this.auth.isAdmin()) { + this.events.listen('impersonation', (userId) => { + this.keyService.fetch(true) + .pipe(timeout(3000), first()) + .subscribe(async (d: ApiKey[]) => { + if (d.length > 0) + this.hermes.login(d[0].id); await this.router.navigate([this.router.url.substring(1)]); - }); - }); + }); + }); + } } public isAdmin() { @@ -66,6 +68,9 @@ export class ImpersonationComponent implements OnInit { } public onChange(e: any) { + if (!this.auth.isAdmin()) + return; + if (!e.value) { this.http.delete(environment.API_HOST + '/admin/impersonate', { headers: { @@ -92,4 +97,4 @@ export class ImpersonationComponent implements OnInit { }); } } -} +} \ No newline at end of file diff --git a/src/app/groups/group-dropdown/group-dropdown.component.html b/src/app/groups/group-dropdown/group-dropdown.component.html new file mode 100644 index 0000000..7e00490 --- /dev/null +++ b/src/app/groups/group-dropdown/group-dropdown.component.html @@ -0,0 +1,26 @@ + + Group + + + @for (group of filteredGroups; track group.id) { + {{group.name}} + } + + @if (!search && formControl.invalid && (formControl.dirty || formControl.touched)) { + @for (error of errorMessageKeys; track $index) { + @if (formControl.hasError(error)) { + {{errorMessages[error]}} + } + } + } + \ No newline at end of file diff --git a/src/app/groups/group-dropdown/group-dropdown.component.scss b/src/app/groups/group-dropdown/group-dropdown.component.scss new file mode 100644 index 0000000..16c425e --- /dev/null +++ b/src/app/groups/group-dropdown/group-dropdown.component.scss @@ -0,0 +1,3 @@ +.error { + color: #ba1a1a; +} \ No newline at end of file diff --git a/src/app/groups/group-dropdown/group-dropdown.component.spec.ts b/src/app/groups/group-dropdown/group-dropdown.component.spec.ts new file mode 100644 index 0000000..486f163 --- /dev/null +++ b/src/app/groups/group-dropdown/group-dropdown.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GroupDropdownComponent } from './group-dropdown.component'; + +describe('GroupDropdownComponent', () => { + let component: GroupDropdownComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GroupDropdownComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GroupDropdownComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/groups/group-dropdown/group-dropdown.component.ts b/src/app/groups/group-dropdown/group-dropdown.component.ts new file mode 100644 index 0000000..b8e1195 --- /dev/null +++ b/src/app/groups/group-dropdown/group-dropdown.component.ts @@ -0,0 +1,99 @@ +import { Component, EventEmitter, inject, Input, OnInit, Output } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { ActivatedRoute } from '@angular/router'; +import { Group } from '../../shared/models/group'; + +@Component({ + selector: 'group-dropdown', + imports: [ + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + ], + templateUrl: './group-dropdown.component.html', + styleUrl: './group-dropdown.component.scss' +}) +export class GroupDropdownComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + + @Input() formControl = new FormControl(undefined); + @Input() errorMessages: { [errorKey: string]: string } = {}; + @Input({ required: true }) groups: Group[] = []; + @Input() group: string | undefined; + @Input() groupDisabled: boolean | undefined; + @Input() search: boolean = false; + @Output() readonly groupChange = new EventEmitter(); + + errorMessageKeys: string[] = []; + + + constructor() { + this.route.data.subscribe(data => { + if (!data['groups']) + return; + + this.groups = data['groups']; + }); + + if (this.groupDisabled) + this.formControl.disable(); + } + + ngOnInit(): void { + this.errorMessageKeys = Object.keys(this.errorMessages); + + if (!this.group) + return; + + const group = this.groups.find(r => r.id == this.group); + this.formControl.setValue(group); + } + + get filteredGroups() { + const value = this.formControl.value; + if (typeof value == 'string') { + return this.groups.filter(r => r.name.toLowerCase().includes(value.toLowerCase())); + } + return this.groups; + } + + select(event: Group) { + this.groupChange.emit(event.id); + } + + input() { + if (this.search && typeof this.formControl.value == 'string') { + this.groupChange.emit(this.formControl.value); + } + } + + blur() { + if (!this.search && typeof this.formControl.value == 'string') { + const name = this.formControl.value; + const nameLower = name.toLowerCase(); + let newValue: Group | undefined = undefined; + const insenstiveGroups = this.filteredGroups.filter(a => a.name.toLowerCase() == nameLower); + if (insenstiveGroups.length > 1) { + const sensitiveGroup = insenstiveGroups.find(a => a.name == name); + newValue = sensitiveGroup ?? undefined; + } else if (insenstiveGroups.length == 1) { + newValue = insenstiveGroups[0]; + } + + if (newValue) { + this.formControl.setValue(newValue); + //this.groupChange.emit(newValue.name); + } else if (!newValue) + this.formControl.setValue(undefined); + //this.groupChange.emit(undefined); + } + } + + displayFn(value: Group) { + return value?.name; + } +} diff --git a/src/app/groups/group-item-edit/group-item-edit.component.html b/src/app/groups/group-item-edit/group-item-edit.component.html new file mode 100644 index 0000000..70d6986 --- /dev/null +++ b/src/app/groups/group-item-edit/group-item-edit.component.html @@ -0,0 +1,50 @@ + + + + Edit Group + + + + + Group Name + + @if (nameForm.invalid && (nameForm.dirty || nameForm.touched)) { + @if (nameForm.hasError('required')) { + This field is required. + } + } + + + TTS Priority + + @if (priorityForm.invalid && (priorityForm.dirty || priorityForm.touched)) { + @if (priorityForm.hasError('required')) { + This field is required. + } + @if (priorityForm.hasError('min')) { + This field must be greater than -2147483649. + } + @if (priorityForm.hasError('max')) { + This field must be smaller than 2147483648. + } + @if (priorityForm.hasError('integer') && !priorityForm.hasError('min') && !priorityForm.hasError('max')) { + This field must be an integer. + } + } + + + + + + + \ No newline at end of file diff --git a/src/app/groups/group-item-edit/group-item-edit.component.scss b/src/app/groups/group-item-edit/group-item-edit.component.scss new file mode 100644 index 0000000..d88a62a --- /dev/null +++ b/src/app/groups/group-item-edit/group-item-edit.component.scss @@ -0,0 +1,8 @@ +.mat-mdc-form-field { + display: block; + margin: 1em; +} + +.mat-mdc-card-actions { + align-self: center; +} \ No newline at end of file diff --git a/src/app/groups/group-item-edit/group-item-edit.component.spec.ts b/src/app/groups/group-item-edit/group-item-edit.component.spec.ts new file mode 100644 index 0000000..3ec0b35 --- /dev/null +++ b/src/app/groups/group-item-edit/group-item-edit.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GroupItemEditComponent } from './group-item-edit.component'; + +describe('GroupItemEditComponent', () => { + let component: GroupItemEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GroupItemEditComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GroupItemEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/groups/group-item-edit/group-item-edit.component.ts b/src/app/groups/group-item-edit/group-item-edit.component.ts new file mode 100644 index 0000000..3456dd5 --- /dev/null +++ b/src/app/groups/group-item-edit/group-item-edit.component.ts @@ -0,0 +1,67 @@ +import { Component, inject, Input, OnInit } from '@angular/core'; +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 { Group } from '../../shared/models/group'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatInputModule } from '@angular/material/input'; +import { HermesClientService } from '../../hermes-client.service'; +import { integerValidator } from '../../shared/validators/integer'; + +@Component({ + selector: 'group-item-edit', + imports: [ + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatIconModule, + ReactiveFormsModule, + ], + templateUrl: './group-item-edit.component.html', + styleUrl: './group-item-edit.component.scss' +}) +export class GroupItemEditComponent implements OnInit { + private readonly _client = inject(HermesClientService); + private readonly _dialogRef = inject(MatDialogRef); + private readonly _data = inject(MAT_DIALOG_DATA); + + group: Group = { id: '', user_id: '', name: '', priority: 0 }; + isSpecial: boolean = false; + waitForResponse: boolean = false; + + nameForm = new FormControl('', [Validators.required]); + priorityForm = new FormControl(0, [Validators.required, Validators.min(-2147483648), Validators.max(2147483647), integerValidator]); + formGroup = new FormGroup({ + name: this.nameForm, + priority: this.priorityForm, + }); + + ngOnInit() { + this.group = this._data.group; + this.isSpecial = this._data.isSpecial; + this.nameForm.setValue(this.group.name); + if (this.isSpecial) + this.nameForm.disable(); + this.priorityForm.setValue(this.group.priority); + } + + add() { + if (this.formGroup.invalid || this.waitForResponse) + return; + + this._client.first((d: any) => d.op == 4 && d.d.request.type == 'create_group' && d.d.data.name == this.nameForm.value) + .subscribe({ + next: (d) => this._dialogRef.close(d.d.data), + error: () => this.waitForResponse = false, + complete: () => this.waitForResponse = false, + }); + this._client.createGroup(this.nameForm.value!, this.priorityForm.value!); + } + + cancel() { + this._dialogRef.close(); + } +} diff --git a/src/app/groups/group-item/group-item.component.html b/src/app/groups/group-item/group-item.component.html new file mode 100644 index 0000000..60a8716 --- /dev/null +++ b/src/app/groups/group-item/group-item.component.html @@ -0,0 +1,21 @@ +
+
{{item().group.name}} + @if (special) { + auto-generated + } +
+
{{item().group.priority}}
+
+ {{item().chatters.length}} + user{{item().chatters.length == 1 ? '' : 's'}} +
+
+ {{item().policies.length}} + polic{{item().chatters.length == 1 ? 'y' : 'ies'}} +
+
+ +
+
\ No newline at end of file diff --git a/src/app/groups/group-item/group-item.component.scss b/src/app/groups/group-item/group-item.component.scss new file mode 100644 index 0000000..3d98a1d --- /dev/null +++ b/src/app/groups/group-item/group-item.component.scss @@ -0,0 +1,45 @@ +article { + background-color: #f0f0f0; + display: flex; + flex-direction: row; + justify-content: space-between; + border-radius: 15px; + padding: 1em; + + & :first-child { + min-width: 180px; + } + + & :not(:first-child) { + text-align: center; + align-self: center; + } +} + +.title { + font-size: 1.5em; + word-break: keep-all; +} + +section { + padding: 0.5em; +} + +.block { + display: block; +} + +.tag { + font-size: 11px; + background-color: white; + color: rgb(204, 51, 204); + padding: 4px; + margin: 0 5px; + border-radius: 10px; + vertical-align: middle; +} + +.muted { + color: grey; + margin: 5px 0; +} \ No newline at end of file diff --git a/src/app/groups/group-item/group-item.component.spec.ts b/src/app/groups/group-item/group-item.component.spec.ts new file mode 100644 index 0000000..270f5e9 --- /dev/null +++ b/src/app/groups/group-item/group-item.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GroupItemComponent } from './group-item.component'; + +describe('GroupItemComponent', () => { + let component: GroupItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GroupItemComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GroupItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/groups/group-item/group-item.component.ts b/src/app/groups/group-item/group-item.component.ts new file mode 100644 index 0000000..2a325a3 --- /dev/null +++ b/src/app/groups/group-item/group-item.component.ts @@ -0,0 +1,35 @@ +import { Component, inject, input, Input, OnInit } from '@angular/core'; +import { Group } from '../../shared/models/group'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { Policy } from '../../shared/models/policy'; +import { GroupItemEditComponent } from '../group-item-edit/group-item-edit.component'; +import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { GroupChatter } from '../../shared/models/group-chatter'; + +@Component({ + selector: 'group-item', + standalone: true, + imports: [ + MatButtonModule, + MatCardModule, + MatIconModule, + ], + templateUrl: './group-item.component.html', + styleUrl: './group-item.component.scss' +}) +export class GroupItemComponent implements OnInit { + readonly router = inject(Router); + item = input.required<{ group: Group, chatters: GroupChatter[], policies: Policy[] }>(); + link: string = ''; + + + special: boolean = true; + + ngOnInit() { + this.special = ['everyone', 'subscribers', 'moderators', 'vip', 'broadcaster'].includes(this.item().group.name); + this.link = 'groups/' + this.item().group.id; + } +} diff --git a/src/app/groups/group-list/group-list.component.html b/src/app/groups/group-list/group-list.component.html new file mode 100644 index 0000000..92239f6 --- /dev/null +++ b/src/app/groups/group-list/group-list.component.html @@ -0,0 +1,7 @@ +
    +@for (group of groups; track $index) { +
  • + +
  • +} +
\ No newline at end of file diff --git a/src/app/groups/group-list/group-list.component.scss b/src/app/groups/group-list/group-list.component.scss new file mode 100644 index 0000000..ba8d37f --- /dev/null +++ b/src/app/groups/group-list/group-list.component.scss @@ -0,0 +1,9 @@ +ul { + margin: 0; + padding: 0; +} + +li { + list-style: none; + margin: 1em; +} \ No newline at end of file diff --git a/src/app/groups/group-list/group-list.component.spec.ts b/src/app/groups/group-list/group-list.component.spec.ts new file mode 100644 index 0000000..1060823 --- /dev/null +++ b/src/app/groups/group-list/group-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GroupListComponent } from './group-list.component'; + +describe('GroupListComponent', () => { + let component: GroupListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GroupListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GroupListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/groups/group-list/group-list.component.ts b/src/app/groups/group-list/group-list.component.ts new file mode 100644 index 0000000..8a3dca8 --- /dev/null +++ b/src/app/groups/group-list/group-list.component.ts @@ -0,0 +1,36 @@ +import { Component, Input } from '@angular/core'; +import { Group } from '../../shared/models/group'; +import { GroupItemComponent } from "../group-item/group-item.component"; +import { Policy } from '../../shared/models/policy'; +import { GroupChatter } from '../../shared/models/group-chatter'; + +@Component({ + selector: 'group-list', + standalone: true, + imports: [GroupItemComponent], + templateUrl: './group-list.component.html', + styleUrl: './group-list.component.scss' +}) +export class GroupListComponent { + private _groups: { group: Group, chatters: GroupChatter[], policies: Policy[] }[] = []; + private _filter: (item: { group: Group, chatters: GroupChatter[], policies: Policy[] }) => boolean = _ => true; + + + get filter(): (item: { group: Group, chatters: GroupChatter[], policies: Policy[] }) => boolean { + return this._filter; + } + + @Input({ alias: 'filter', required: false }) + set filter(value: (item: { group: Group, chatters: GroupChatter[], policies: Policy[] }) => boolean) { + this._filter = value; + } + + get groups() { + return this._groups.filter(this._filter); + } + + @Input({ alias: 'groups', required: true }) + set groups(value: { group: Group, chatters: GroupChatter[], policies: Policy[] }[]) { + this._groups = value; + } +} \ No newline at end of file diff --git a/src/app/groups/group-page/group-page.component.html b/src/app/groups/group-page/group-page.component.html new file mode 100644 index 0000000..500b4e4 --- /dev/null +++ b/src/app/groups/group-page/group-page.component.html @@ -0,0 +1,38 @@ +
+

{{group?.name}}

+ + + + Policies + + {{policies.length}} polic{{policies.length == 1 ? 'y' : 'ies'}} + + + + @if (policies.length > 0) { + + } + + + + + Danger Zone + + Dangerous actions + + +
+
+
+

Deletion

+

Deleting this group will delete everything that is part of it, including policies and permissions.

+
+
+ +
+
+
+
+
\ No newline at end of file diff --git a/src/app/groups/group-page/group-page.component.scss b/src/app/groups/group-page/group-page.component.scss new file mode 100644 index 0000000..2b202a3 --- /dev/null +++ b/src/app/groups/group-page/group-page.component.scss @@ -0,0 +1,21 @@ +.mat-expansion-panel ~ .mat-expansion-panel { + margin-top: 4em; +} + +.delete { + justify-content: space-around; + color: red; +} + +.muted { + color: grey; + margin: 5px 0; +} + +.left { + float: left; +} + +.right { + float: right; +} \ No newline at end of file diff --git a/src/app/groups/group-page/group-page.component.spec.ts b/src/app/groups/group-page/group-page.component.spec.ts new file mode 100644 index 0000000..3006846 --- /dev/null +++ b/src/app/groups/group-page/group-page.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GroupPageComponent } from './group-page.component'; + +describe('GroupPageComponent', () => { + let component: GroupPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GroupPageComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GroupPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/groups/group-page/group-page.component.ts b/src/app/groups/group-page/group-page.component.ts new file mode 100644 index 0000000..e6c85c7 --- /dev/null +++ b/src/app/groups/group-page/group-page.component.ts @@ -0,0 +1,86 @@ +import { Component, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Group } from '../../shared/models/group'; +import { Policy } from '../../shared/models/policy'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { PoliciesModule } from '../../policies/policies.module'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { PolicyTableComponent } from "../../policies/policy-table/policy-table.component"; +import { PolicyAddButtonComponent } from '../../policies/policy-add-button/policy-add-button.component'; +import { HermesClientService } from '../../hermes-client.service'; +import { GroupChatter } from '../../shared/models/group-chatter'; + +@Component({ + selector: 'group-page', + imports: [ + MatButtonModule, + MatExpansionModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + PoliciesModule, + PolicyAddButtonComponent, + ReactiveFormsModule, + PolicyTableComponent + ], + templateUrl: './group-page.component.html', + styleUrl: './group-page.component.scss' +}) +export class GroupPageComponent { + private readonly _router = inject(Router); + private readonly _route = inject(ActivatedRoute); + private readonly _client = inject(HermesClientService); + private _group: Group | undefined; + private _chatters: GroupChatter[]; + private _policies: Policy[]; + + groups: Group[] = []; + + constructor() { + this._chatters = []; + this._policies = []; + + this._route.params.subscribe((p: any) => { + const group_id = p.id; + + this._route.data.subscribe(async (data: any) => { + this.groups = [...data['groups']]; + const group = this.groups.find((g: Group) => g.id == group_id); + + if (!group) { + await this._router.navigate(['groups']); + return; + } + + this._group = group; + this._chatters = [...data['chatters'].filter((c: GroupChatter) => c.group_id == group_id)]; + this._policies = [...data['policies'].filter((p: Policy) => p.group_id == group_id)]; + }); + }); + } + + get group() { + return this._group; + } + + get chatters() { + return this._chatters; + } + + get policies() { + return this._policies; + } + + delete() { + if (!this.group) + return; + + this._client.first(d => d.d.request.type == 'delete_group' && d.d.request.data.id == this.group!.id) + .subscribe(async () => await this._router.navigate(['groups'])); + this._client.deleteGroup(this.group.id); + } +} \ No newline at end of file diff --git a/src/app/groups/groups.module.ts b/src/app/groups/groups.module.ts new file mode 100644 index 0000000..75917d4 --- /dev/null +++ b/src/app/groups/groups.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { GroupDropdownComponent } from './group-dropdown/group-dropdown.component'; +import { GroupsComponent } from './groups/groups.component'; +import { GroupListComponent } from './group-list/group-list.component'; +import { GroupItemComponent } from './group-item/group-item.component'; + + + +@NgModule({ + declarations: [], + imports: [ + GroupDropdownComponent, + GroupListComponent, + GroupItemComponent, + GroupsComponent, + ] +}) +export class GroupsModule { } diff --git a/src/app/groups/groups/groups.component.html b/src/app/groups/groups/groups.component.html new file mode 100644 index 0000000..afb8134 --- /dev/null +++ b/src/app/groups/groups/groups.component.html @@ -0,0 +1,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/app/groups/groups/groups.component.scss b/src/app/groups/groups/groups.component.scss new file mode 100644 index 0000000..5a3ebaf --- /dev/null +++ b/src/app/groups/groups/groups.component.scss @@ -0,0 +1,7 @@ +button { + width: 100%; +} + +.delete { + color: red; +} \ No newline at end of file diff --git a/src/app/groups/groups/groups.component.spec.ts b/src/app/groups/groups/groups.component.spec.ts new file mode 100644 index 0000000..0b01681 --- /dev/null +++ b/src/app/groups/groups/groups.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { GroupsComponent } from './groups.component'; + +describe('GroupsComponent', () => { + let component: GroupsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GroupsComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GroupsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/groups/groups/groups.component.ts b/src/app/groups/groups/groups.component.ts new file mode 100644 index 0000000..603b8e0 --- /dev/null +++ b/src/app/groups/groups/groups.component.ts @@ -0,0 +1,119 @@ +import { Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { Group } from '../../shared/models/group'; +import GroupService from '../../shared/services/group.service'; +import { MatTableModule } from '@angular/material/table'; +import { GroupListComponent } from "../group-list/group-list.component"; +import { Policy } from '../../shared/models/policy'; +import { MatDialog } from '@angular/material/dialog'; +import { GroupItemEditComponent } from '../group-item-edit/group-item-edit.component'; +import { MatMenuModule } from '@angular/material/menu'; +import { HermesClientService } from '../../hermes-client.service'; +import { GroupChatter } from '../../shared/models/group-chatter'; + +@Component({ + selector: 'groups', + imports: [ + MatButtonModule, + MatIconModule, + MatMenuModule, + MatTableModule, + RouterModule, + GroupListComponent, + ], + templateUrl: './groups.component.html', + styleUrl: './groups.component.scss' +}) +export class GroupsComponent { + private readonly _groupService = inject(GroupService); + private readonly _client = inject(HermesClientService); + private readonly _route = inject(ActivatedRoute); + private readonly _dialog = inject(MatDialog); + + items: { group: Group, chatters: GroupChatter[], policies: Policy[] }[] = []; + + constructor() { + this._route.data.subscribe(payload => { + const groups = payload['groups']; + const chatters = payload['chatters']; + const policies = payload['policies']; + const elements: { group: Group, chatters: GroupChatter[], policies: Policy[] }[] = []; + + for (let group of groups) { + elements.push({ + group: group, + chatters: chatters.filter((c: GroupChatter) => c.group_id == group.id), + policies: policies.filter((p: Policy) => p.group_id == group.id), + }); + } + + this.items = elements; + }); + + this._groupService.createGroup$?.subscribe(d => { + if (d.error || !d.data || d.request.nounce != null && d.request.nounce.startsWith(this._client.session_id)) + return; + + let index = -1; + for (let i = 0; i < this.items.length; i++) { + const comp = this.compare(d.data, this.items[i].group); + if (comp < 0) { + index = i; + break; + } + } + this.items.splice(index >= 0 ? index : this.items.length, 0, { group: d.data, chatters: [], policies: [] }); + }); + + this._groupService.updateGroup$?.subscribe(d => { + if (d.error || !d.data || d.request.nounce != null && d.request.nounce.startsWith(this._client.session_id)) + return; + + const group = this.items.find(r => r.group.id = d.data.id)?.group; + if (group) { + group.id = d.data.id; + group.name = d.data.name; + group.priority = d.data.priority; + } + }); + + this._groupService.deleteGroup$?.subscribe(d => { + if (d.error || d.request.nounce != null && d.request.nounce.startsWith(this._client.session_id)) + return; + + this.items = this.items.filter(r => r.group.id != d.request.data.id); + }); + } + + + openDialog(groupName: string): void { + const group = { id: '', user_id: '', name: groupName, priority: 0 }; + const dialogRef = this._dialog.open(GroupItemEditComponent, { + data: { group, isSpecial: groupName.length > 0 }, + }); + + const isNewGroup = group.id.length <= 0; + dialogRef.afterClosed().subscribe((result: Group | undefined) => { + if (!result) + return; + + + if (isNewGroup) { + this.items.push({ group: result, chatters: [], policies: [] }); + } else { + const same = this.items.find(i => i.group.id == group.id); + if (same == null) + return; + + same.group.name = result.name; + same.group.priority = result.priority; + } + }); + } + + compare(a: Group, b: Group) { + return a.name.localeCompare(b.name); + } +} \ No newline at end of file diff --git a/src/app/hermes-client.service.ts b/src/app/hermes-client.service.ts index 57e085d..eb3a704 100644 --- a/src/app/hermes-client.service.ts +++ b/src/app/hermes-client.service.ts @@ -22,10 +22,6 @@ export class HermesClientService { constructor(private socket: HermesSocketService, private events: EventService) { this.connected = false; this.logged_in = false; - - this.events.listen('tts_login', (payload) => { - this.login(payload); - }); } public connect() { @@ -86,7 +82,18 @@ export class HermesClientService { api_key, web_login: true, major_version: 0, - minor_version: 1 + minor_version: 4 + }); + } + + public createGroup(name: string, priority: number) { + if (!this.logged_in) + return; + + this.send(3, { + request_id: null, + type: "create_group", + data: { name, priority }, }); } @@ -139,6 +146,17 @@ export class HermesClientService { }); } + public deleteGroup(id: string) { + if (!this.logged_in) + return; + + this.send(3, { + request_id: null, + type: "delete_group", + data: { id }, + }); + } + public deletePolicy(id: string) { if (!this.logged_in) return; @@ -197,6 +215,17 @@ export class HermesClientService { }); } + public fetchGroups() { + if (!this.logged_in) + return; + + this.send(3, { + request_id: null, + type: "get_groups", + data: null, + }); + } + public fetchPermissionsAndGroups() { if (!this.logged_in) return; @@ -265,6 +294,17 @@ export class HermesClientService { }); } + public updateGroup(id: string, name: string, priority: number) { + if (!this.logged_in) + return; + + this.send(3, { + request_id: null, + type: "update_group", + data: { id, name, priority }, + }); + } + public updatePolicy(id: string, groupId: string, path: string, usage: number, timespan: number) { if (!this.logged_in) return; @@ -320,13 +360,13 @@ export class HermesClientService { console.log("RX:", message); switch (message.op) { case 0: // Heartbeat - console.log("Heartbeat received. Potential connection problem?"); + console.log("TTS Heartbeat received. Potential connection problem?"); break; case 2: // Login Ack - console.log("Login successful."); + console.log("TTS Login successful."); this.logged_in = true; this.session_id = message.d.session_id; - this.events.emit('tts_login_ack', null); + this.events.emit('tts_login_ack', message.d); break; } }, diff --git a/src/app/hermes-socket.service.ts b/src/app/hermes-socket.service.ts index 9d7d81d..b126864 100644 --- a/src/app/hermes-socket.service.ts +++ b/src/app/hermes-socket.service.ts @@ -1,8 +1,8 @@ import { OnInit, Injectable } from '@angular/core'; import { webSocket, WebSocketSubject } from 'rxjs/webSocket'; -import { catchError, filter, first, timeout } from 'rxjs/operators'; +import { catchError, first, timeout } from 'rxjs/operators'; import { environment } from '../environments/environment'; -import { Observable, throwError } from 'rxjs'; +import { EMPTY, Observable, Observer, throwError } from 'rxjs'; @Injectable({ @@ -42,15 +42,16 @@ export class HermesSocketService implements OnInit { this.socket.next(msg); } - public get$(): Observable|undefined { - return this.socket?.asObservable(); + public get$(): Observable | undefined { + return this.socket?.asObservable().pipe(catchError(_ => EMPTY)); } - public subscribe(subscriptions: any) { + public subscribe(subscriptions: Partial> | ((value: any) => void)) { if (!this.socket || this.socket.closed) return; - return this.socket.subscribe(subscriptions); + return this.socket.pipe(catchError(_ => EMPTY)) + .subscribe(subscriptions) } public close() { diff --git a/src/app/navigation/navigation.component.html b/src/app/navigation/navigation.component.html index 0b89ec6..c982b67 100644 --- a/src/app/navigation/navigation.component.html +++ b/src/app/navigation/navigation.component.html @@ -1,35 +1,48 @@ \ No newline at end of file diff --git a/src/app/navigation/navigation.component.scss b/src/app/navigation/navigation.component.scss index d7a0f62..56d2089 100644 --- a/src/app/navigation/navigation.component.scss +++ b/src/app/navigation/navigation.component.scss @@ -25,6 +25,7 @@ a { font-size: large; text-decoration: none; color: $primary_font_color; + border-radius: 10px; } a:hover { diff --git a/src/app/navigation/navigation.component.ts b/src/app/navigation/navigation.component.ts index 731f46f..0cdbbf0 100644 --- a/src/app/navigation/navigation.component.ts +++ b/src/app/navigation/navigation.component.ts @@ -1,6 +1,5 @@ import { Component } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { CommonModule } from '@angular/common'; import { HermesClientService } from '../hermes-client.service'; import { ApiAuthenticationService } from '../shared/services/api/api-authentication.service'; import { MatCardModule } from '@angular/material/card'; @@ -10,7 +9,7 @@ import { UserCardComponent } from "../auth/user-card/user-card.component"; @Component({ selector: 'navigation', standalone: true, - imports: [CommonModule, RouterModule, MatCardModule, AuthModule, UserCardComponent], + imports: [RouterModule, MatCardModule, AuthModule, UserCardComponent], templateUrl: './navigation.component.html', styleUrl: './navigation.component.scss' }) diff --git a/src/app/policies/policies.module.ts b/src/app/policies/policies.module.ts index a579740..0188c1b 100644 --- a/src/app/policies/policies.module.ts +++ b/src/app/policies/policies.module.ts @@ -1,13 +1,17 @@ import { NgModule } from '@angular/core'; import { PolicyComponent } from './policy/policy.component'; import { PolicyTableComponent } from './policy-table/policy-table.component'; -import { PolicyAddFormComponent } from './policy-add-form/policy-add-form.component'; +import { PolicyItemEditComponent } from './policy-item-edit/policy-item-edit.component'; +import { PolicyAddButtonComponent } from './policy-add-button/policy-add-button.component'; @NgModule({ declarations: [], imports: [ - PolicyComponent, PolicyTableComponent, PolicyAddFormComponent + PolicyComponent, + PolicyTableComponent, + PolicyAddButtonComponent, + PolicyItemEditComponent, ] }) export class PoliciesModule { } \ No newline at end of file diff --git a/src/app/policies/policy-add-button/policy-add-button.component.html b/src/app/policies/policy-add-button/policy-add-button.component.html new file mode 100644 index 0000000..8e200cf --- /dev/null +++ b/src/app/policies/policy-add-button/policy-add-button.component.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/src/app/policies/policy-add-button/policy-add-button.component.scss b/src/app/policies/policy-add-button/policy-add-button.component.scss new file mode 100644 index 0000000..356a0df --- /dev/null +++ b/src/app/policies/policy-add-button/policy-add-button.component.scss @@ -0,0 +1,3 @@ +button { + width: 100%; +} \ No newline at end of file diff --git a/src/app/policies/policy-add-button/policy-add-button.component.spec.ts b/src/app/policies/policy-add-button/policy-add-button.component.spec.ts new file mode 100644 index 0000000..040fb58 --- /dev/null +++ b/src/app/policies/policy-add-button/policy-add-button.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PolicyAddButtonComponent } from './policy-add-button.component'; + +describe('PolicyAddButtonComponent', () => { + let component: PolicyAddButtonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PolicyAddButtonComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PolicyAddButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/policies/policy-add-button/policy-add-button.component.ts b/src/app/policies/policy-add-button/policy-add-button.component.ts new file mode 100644 index 0000000..5b6cbe9 --- /dev/null +++ b/src/app/policies/policy-add-button/policy-add-button.component.ts @@ -0,0 +1,44 @@ +import { Component, EventEmitter, inject, Input, Output } from '@angular/core'; +import { Policy } from '../../shared/models/policy'; +import { PolicyItemEditComponent } from '../policy-item-edit/policy-item-edit.component'; +import { MatDialog } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { Group } from '../../shared/models/group'; + +@Component({ + selector: 'policy-add-button', + imports: [ + MatButtonModule, + MatIconModule, + ], + templateUrl: './policy-add-button.component.html', + styleUrl: './policy-add-button.component.scss' +}) +export class PolicyAddButtonComponent { + private readonly dialog = inject(MatDialog); + @Input({ required: true }) policies: Policy[] = []; + @Input({ required: true }) groups: Group[] = []; + @Input() group: string|undefined = undefined; + @Output() policy = new EventEmitter(); + + + openDialog(): void { + const dialogRef = this.dialog.open(PolicyItemEditComponent, { + data: { + policies: this.policies, + groups: this.groups, + group_id: this.group, + groupDisabled: !!this.group, + isNew: true, + } + }); + + dialogRef.afterClosed().subscribe((result: Policy) => { + if (!result) + return; + + this.policy.emit(result); + }); + } +} \ No newline at end of file diff --git a/src/app/policies/policy-item-edit/policy-item-edit.component.html b/src/app/policies/policy-item-edit/policy-item-edit.component.html new file mode 100644 index 0000000..e8420cc --- /dev/null +++ b/src/app/policies/policy-item-edit/policy-item-edit.component.html @@ -0,0 +1,73 @@ + + + {{isNew ? 'Add' : 'Edit'}} Policy + + + + + Path + + @if (pathControl.invalid && (pathControl.dirty || pathControl.touched)) { + @if (pathControl.hasError('required')) { + This field is required. + } + } + + + Usage + + @if (usageControl.invalid && (usageControl.dirty || usageControl.touched)) { + @if (usageControl.hasError('required')) { + This field is required. + } + @if (usageControl.hasError('min')) { + The value needs to be positive. + } + @if (usageControl.hasError('max')) { + The value needs to be lower than 100. + } + @if (usageControl.hasError('integer')) { + The value needs to be an integer. + } + } + + + Span + + @if (spanControl.invalid && (spanControl.dirty || spanControl.touched)) { + @if (spanControl.hasError('required')) { + This field is required. + } + @if (spanControl.hasError('min')) { + The value needs to be at least 1000. + } + @if (spanControl.hasError('max')) { + The value needs to be lower than 86401. + } + @if (spanControl.hasError('integer')) { + The value needs to be an integer. + } + } + + + + @if (isNew) { + + } @else { + + } + + + \ No newline at end of file diff --git a/src/app/policies/policy-item-edit/policy-item-edit.component.scss b/src/app/policies/policy-item-edit/policy-item-edit.component.scss new file mode 100644 index 0000000..edb4734 --- /dev/null +++ b/src/app/policies/policy-item-edit/policy-item-edit.component.scss @@ -0,0 +1,10 @@ +.mat-mdc-card-content { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-auto-flow: row dense; + grid-gap: 0 1em; +} + +.error { + color: #ba1a1a; +} \ No newline at end of file diff --git a/src/app/policies/policy-item-edit/policy-item-edit.component.spec.ts b/src/app/policies/policy-item-edit/policy-item-edit.component.spec.ts new file mode 100644 index 0000000..dbe7495 --- /dev/null +++ b/src/app/policies/policy-item-edit/policy-item-edit.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PolicyItemEditComponent } from './policy-item-edit.component'; + +describe('PolicyItemEditComponent', () => { + let component: PolicyItemEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PolicyItemEditComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(PolicyItemEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/policies/policy-item-edit/policy-item-edit.component.ts b/src/app/policies/policy-item-edit/policy-item-edit.component.ts new file mode 100644 index 0000000..dbbe7be --- /dev/null +++ b/src/app/policies/policy-item-edit/policy-item-edit.component.ts @@ -0,0 +1,96 @@ +import { Component, inject, OnInit } 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 { HermesClientService } from '../../hermes-client.service'; +import { GroupDropdownComponent } from '../../groups/group-dropdown/group-dropdown.component'; +import { Group } from '../../shared/models/group'; +import { Policy } from '../../shared/models/policy'; + +@Component({ + selector: 'policy-item-edit', + imports: [ + GroupDropdownComponent, + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + ReactiveFormsModule, + ], + templateUrl: './policy-item-edit.component.html', + styleUrl: './policy-item-edit.component.scss' +}) +export class PolicyItemEditComponent implements OnInit { + private readonly client = inject(HermesClientService); + readonly data = inject(MAT_DIALOG_DATA); + readonly dialogRef = inject(MatDialogRef); + + readonly groupControl = new FormControl(undefined, [Validators.required]); + readonly pathControl = new FormControl('', [Validators.required]); + readonly usageControl = new FormControl(1, [Validators.required, Validators.min(1), Validators.max(99)]); + readonly spanControl = new FormControl(5000, [Validators.required, Validators.min(1000), Validators.max(86400)]); + + readonly groupErrorMessages = { + 'required': 'This field is required.' + }; + + readonly formGroup = new FormGroup({ + group: this.groupControl, + path: this.pathControl, + usage: this.usageControl, + span: this.spanControl, + }); + + isNew: boolean = false; + waitForResponse: boolean = false; + + ngOnInit(): void { + this.isNew = this.data.isNew; + + if (!this.data.policy_id) + return; + + const policy = this.data.policies.find((p: Policy) => p.id == this.data.policy_id); + if (!policy) + return; + + this.groupControl.setValue(policy.group_id); + this.pathControl.setValue(policy.path); + this.usageControl.setValue(policy.usage); + this.spanControl.setValue(policy.span); + } + + save() { + if (this.formGroup.invalid || this.waitForResponse) + return; + + this.waitForResponse = true; + const group_id = (this.groupControl.value as Group)!.id; + const path = this.pathControl.value!; + const usage = this.usageControl.value!; + const span = this.spanControl.value!; + + if (this.isNew) { + this.client.first((d: any) => d.op == 4 && d.d.request.type == 'create_policy' && d.d.data.group_id == group_id && d.d.data.path == path && d.d.data.usage == usage && d.d.data.span == span) + .subscribe({ + next: (d) => this.dialogRef.close(d.d.data), + error: () => this.waitForResponse = false, + complete: () => this.waitForResponse = false, + }); + this.client.createPolicy(group_id, path, usage, span); + } else { + this.client.first((d: any) => d.op == 4 && d.d.request.type == 'update_policy' && d.d.data.id == this.data.policy_id && d.d.data.group_id == group_id && d.d.data.path == path && d.d.data.usage == usage && d.d.data.span == span) + .subscribe({ + next: (d) => this.dialogRef.close(d.d.data), + error: () => this.waitForResponse = false, + complete: () => this.waitForResponse = false, + }); + this.client.updatePolicy(this.data.policy_id, group_id, path, usage, span); + } + } +} diff --git a/src/app/policies/policy-table/policy-table.component.html b/src/app/policies/policy-table/policy-table.component.html index 079c856..9a83b89 100644 --- a/src/app/policies/policy-table/policy-table.component.html +++ b/src/app/policies/policy-table/policy-table.component.html @@ -1,61 +1,40 @@ - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - + +
Path - {{policy.path}} - Path + {{policy.path}} + Group - @if (policy.editing) { - - } - @if (!policy.editing && groups[policy.group_id]) { - {{groups[policy.group_id].name}} - } - Group + {{getGroupById(policy.group_id)?.name || '\'}} + Usage Rate - @if (policy.editing) { - - } - @if (!policy.editing) { - {{policy.usage}} - } - Usage Rate + {{policy.usage}} + Span (ms) - @if (policy.editing) { - - } - @if (!policy.editing) { - {{policy.span}} - } - Span (ms) + {{policy.span}} + Actions - @if (!policy.editing) { - - - } - @if (policy.editing) { - - - } - Actions + + +
\ No newline at end of file diff --git a/src/app/policies/policy-table/policy-table.component.scss b/src/app/policies/policy-table/policy-table.component.scss index e69de29..1b339e9 100644 --- a/src/app/policies/policy-table/policy-table.component.scss +++ b/src/app/policies/policy-table/policy-table.component.scss @@ -0,0 +1,12 @@ +table { + border-radius: 15px; + overflow: hidden !important; +} + +.delete { + color: red; +} + +button ~ button { + margin-left: 1em; +} \ No newline at end of file diff --git a/src/app/policies/policy-table/policy-table.component.ts b/src/app/policies/policy-table/policy-table.component.ts index d63cb0d..0d84225 100644 --- a/src/app/policies/policy-table/policy-table.component.ts +++ b/src/app/policies/policy-table/policy-table.component.ts @@ -1,119 +1,83 @@ -import { Component, ElementRef, Input, isDevMode, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; import { MatTable, MatTableModule } from '@angular/material/table'; import { MatIconModule } from '@angular/material/icon'; import EventService from '../../shared/services/EventService'; import { Policy } from '../../shared/models/policy'; -import { Subscription } from 'rxjs'; import { FormsModule } from '@angular/forms'; import { HermesClientService } from '../../hermes-client.service'; +import { MatButtonModule } from '@angular/material/button'; +import { ActivatedRoute } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { PolicyItemEditComponent } from '../policy-item-edit/policy-item-edit.component'; +import { Group } from '../../shared/models/group'; @Component({ selector: 'policy-table', - imports: [FormsModule, MatTableModule, MatIconModule], + imports: [FormsModule, MatButtonModule, MatTableModule, MatIconModule], templateUrl: './policy-table.component.html', styleUrl: './policy-table.component.scss' }) -export class PolicyTableComponent implements OnInit, OnDestroy { - @Input() policies: Policy[] = [] - displayedColumns = ['path', 'group', 'usage', 'span', 'actions'] - groups: { [id: string]: { id: string, name: string, priority: number } } - private readonly _subscriptions: Subscription[] = []; +export class PolicyTableComponent implements OnInit, OnDestroy, AfterViewInit { + private readonly route = inject(ActivatedRoute); + private readonly hermes = inject(HermesClientService); + private readonly events = inject(EventService); + private readonly dialog = inject(MatDialog); + @Input() policies: Policy[] = []; @ViewChild(MatTable) table: MatTable; - private subscription: Subscription | undefined; - constructor(private events: EventService, private hermes: HermesClientService) { + readonly displayedColumns = ['path', 'group', 'usage', 'span', 'actions']; + private readonly _subscriptions: any[] = []; + + groups: Group[] = []; + + + constructor() { this.table = {} as MatTable; - this.groups = {}; } ngOnInit(): void { - this._subscriptions.push(this.events.listen('addPolicy', (payload) => { - if (!payload) - return; - if (this.policies.map(p => p.path).includes(payload)) { - return; - } + this.route.data.subscribe(r => { + this.groups = [...r['groups']]; + }); - this.policies.push(new Policy("", "", payload, 1, 5000, "", true, true)); + this._subscriptions.push(this.events.listen('addPolicy', (payload) => { + if (!payload || this.policies.map(p => p.path).includes(payload)) + return; + + this.policies.push(payload); this.table.renderRows(); })); - const subscription = this.hermes.subscribe(4, (response: any) => { - if (response.request.type == "get_policies") { - for (let policy of response.data) { - this.policies.push(new Policy(policy.id, policy.group_id, policy.path, policy.usage, policy.span, "", false, false)); - } - this.table.renderRows(); - } else if (response.request.type == "create_policy") { - const policy = this.policies.find(p => this.groups[response.data.group_id].name == p.temp_group_name && p.path == response.data.path); - if (policy == null) { - this.policies.push(new Policy(response.data.id, response.data.group_id, response.data.path, response.data.usage, response.data.span)); - } else { - policy.id = response.data.id; - policy.group_id = response.data.group_id; - policy.editing = false; - policy.isNew = false; - } - this.table.renderRows(); - } else if (response.request.type == "update_policy") { - const policy = this.policies.find(p => p.id == response.data.id); - if (policy == null) { - this.policies.push(new Policy(response.data.id, response.data.group_id, response.data.path, response.data.usage, response.data.span)); - } else { - policy.id = response.data.id; - policy.group_id = response.data.group_id; - policy.editing = false; - policy.isNew = false; - } - this.table.renderRows(); - } else if (response.request.type == "delete_policy") { - const policy = this.policies.find(p => p.id == response.request.data.id); - if (!policy) { - return; - } - const index = this.policies.indexOf(policy); - if (index >= 0) { - this.policies.splice(index, 1); - this.table.renderRows(); - } - } else if (response.request.type == "get_permissions") { - this.groups = Object.assign({}, ...response.data.groups.map((g: any) => ({ [g.id]: g }))); + this._subscriptions.push(this.hermes.subscribeToRequests('create_policy', response => { + const policy = this.policies.find(p => p.path == response.data.path); + if (policy == null) { + this.policies.push(response.data); } - }); + this.table.renderRows(); + })); - if (subscription) { - this._subscriptions.push(subscription); - } + this._subscriptions.push(this.hermes.subscribeToRequests('update_policy', response => { + const policy = this.policies.find(p => p.id == response.data.id); + if (policy != null) { + policy.id = response.data.id; + policy.group_id = response.data.group_id; + } + this.table.renderRows(); + })); - this.hermes.fetchPolicies(); - this.hermes.fetchPermissionsAndGroups(); + this._subscriptions.push(this.hermes.subscribeToRequests('delete_policy', response => { + this.policies = this.policies.filter(p => p.id != response.request.data.id); + this.table.renderRows(); + })); + } + + ngAfterViewInit(): void { + this.table.renderRows(); } ngOnDestroy(): void { - if (this._subscriptions.length > 0) - this._subscriptions.forEach(s => s.unsubscribe()); - } - - cancel(policy: Policy) { - if (!policy.editing) - return; - - if (policy.isNew) { - const index = this.policies.indexOf(policy); - if (index >= 0) { - this.policies.splice(index, 1); - this.table.renderRows(); - } - } else { - policy.path = policy.old_path ?? ''; - policy.usage = policy.old_usage ?? 1; - policy.span = policy.old_span ?? 5000; - policy.old_path = undefined; - policy.old_span = undefined; - policy.old_usage = undefined; - policy.editing = false; - } + this._subscriptions.filter(s => !!s).forEach(s => s.unsubscribe()); } delete(policy: Policy) { @@ -121,62 +85,29 @@ export class PolicyTableComponent implements OnInit, OnDestroy { } edit(policy: Policy) { - policy.old_path = policy.path; - policy.old_span = policy.span; - policy.old_usage = policy.usage; - policy.temp_group_name = this.groups[policy.group_id].name - policy.editing = true; - } - - save(policy: Policy) { - if (!policy.temp_group_name) { - console.log('group must be valid.'); - return; - } - const group = Object.values(this.groups).find(g => g.name == policy.temp_group_name); - if (group == null) { - console.log('group does not exist.'); - return; - } - - if (policy.isNew) { - const match = this.policies.find(p => p.group_id == group.id && p.path == policy.path); - if (match) { - console.log('policy already exists'); - return; + const dialogRef = this.dialog.open(PolicyItemEditComponent, { + data: { + policies: this.policies, + groups: this.groups, + policy_id: policy.id, + group_id: policy.group_id, + groupDisabled: true, + isNew: false, } - } + }); - if (isNaN(policy.usage)) { - console.log('usage must be a whole number.'); - return; - } - if (policy.usage < 1 || policy.usage > 99) { - console.error('usage must be between 1 and 99.'); - return; - } - if (policy.usage % 1.0 != 0) { - console.error('usage must be a whole number.'); - return; - } + dialogRef.afterClosed().subscribe((result: Policy) => { + if (!result) + return; - if (isNaN(policy.span)) { - console.log('span must be a whole number.'); - return; - } - if (policy.span < 1000 || policy.span > 1800000) { - console.error('span must be between 1 and 1800000.'); - return; - } - if (policy.span % 1.0 != 0) { - console.error('span must be a whole number.'); - return; - } - - if (policy.isNew) { - this.hermes.createPolicy(group.id, policy.path, policy.usage, policy.span); - } else { - this.hermes.updatePolicy(policy.id, group.id, policy.path, policy.usage, policy.span); - } + policy.group_id = result.group_id; + policy.path = result.path; + policy.usage = result.usage; + policy.span = result.span; + }); } -} + + getGroupById(group_id: string) { + return this.groups.find((g: Group) => g.id == group_id); + } +} \ No newline at end of file diff --git a/src/app/policies/policy/policy.component.html b/src/app/policies/policy/policy.component.html index c111081..6831c57 100644 --- a/src/app/policies/policy/policy.component.html +++ b/src/app/policies/policy/policy.component.html @@ -1,8 +1,8 @@

Policies

-
- +
+
- +
\ No newline at end of file diff --git a/src/app/policies/policy/policy.component.scss b/src/app/policies/policy/policy.component.scss index 61c6793..463f250 100644 --- a/src/app/policies/policy/policy.component.scss +++ b/src/app/policies/policy/policy.component.scss @@ -1,3 +1,9 @@ h4 { text-align: center; +} + +.add { + margin-top: 1em; + margin-bottom: 2em; + margin: 1em 2em 2em; } \ No newline at end of file diff --git a/src/app/policies/policy/policy.component.ts b/src/app/policies/policy/policy.component.ts index 5d7203a..1279010 100644 --- a/src/app/policies/policy/policy.component.ts +++ b/src/app/policies/policy/policy.component.ts @@ -1,42 +1,51 @@ -import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; -import { PolicyAddFormComponent } from "../policy-add-form/policy-add-form.component"; +import { Component, inject } from '@angular/core'; import { PolicyTableComponent } from "../policy-table/policy-table.component"; -import { Policy, PolicyScope } from '../../shared/models/policy'; -import { isPlatformBrowser } from '@angular/common'; -import { HermesClientService } from '../../hermes-client.service'; -import { Router, RouterModule } from '@angular/router'; +import { Policy } from '../../shared/models/policy'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { PolicyAddButtonComponent } from "../policy-add-button/policy-add-button.component"; +import { Group } from '../../shared/models/group'; @Component({ - selector: 'policy', - imports: [RouterModule, PolicyAddFormComponent, PolicyTableComponent], - templateUrl: './policy.component.html', - styleUrl: './policy.component.scss' + selector: 'policy', + imports: [MatButtonModule, MatIconModule, PolicyTableComponent, RouterModule, PolicyAddButtonComponent], + templateUrl: './policy.component.html', + styleUrl: './policy.component.scss' }) -export class PolicyComponent implements OnInit, OnDestroy { - private isBrowser: boolean; - items: Policy[]; +export class PolicyComponent { + private readonly route = inject(ActivatedRoute); + private _policies: Policy[] = []; + groups: Group[] = []; - constructor(private client: HermesClientService, private router: Router, @Inject(PLATFORM_ID) private platformId: Object) { - this.isBrowser = isPlatformBrowser(this.platformId) + constructor() { + this.route.data.subscribe((data) => { + const policies = [...data['policies']]; + policies.sort(this.compare); + this._policies = policies; - this.items = [] + this.groups = [...data['groups']]; + }); + } + + get policies() { + return this._policies; + } + + addPolicy(policy: Policy) { + let index = -1; + for (let i = 0; i < this._policies.length; i++) { + const comp = this.compare(policy, this._policies[i]); + if (comp < 0) { + index = i; + break; + } } + this._policies.splice(index >= 0 ? index : this._policies.length, 0, policy); + } - get policies() { - return this.items; - } - - ngOnInit(): void { - if (!this.isBrowser) - return; - - if (!this.client.logged_in) { - this.router.navigate(["tts-login"]); - return; - } - } - - ngOnDestroy() { - } + compare(a: Policy, b: Policy) { + return a.path.localeCompare(b.path); + } } \ No newline at end of file diff --git a/src/app/redemptions/redemption-item-edit/redemption-item-edit.component.ts b/src/app/redemptions/redemption-item-edit/redemption-item-edit.component.ts index bb98329..3a4404e 100644 --- a/src/app/redemptions/redemption-item-edit/redemption-item-edit.component.ts +++ b/src/app/redemptions/redemption-item-edit/redemption-item-edit.component.ts @@ -11,7 +11,7 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula import { HermesClientService } from '../../hermes-client.service'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import TwitchRedemption from '../../shared/models/twitch-redemption'; -import RedeemableAction from '../../shared/models/redeemable_action'; +import RedeemableAction from '../../shared/models/redeemable-action'; import { integerValidator } from '../../shared/validators/integer'; import { createTypeValidator } from '../../shared/validators/of-type'; import RedemptionService from '../../shared/services/redemption.service'; diff --git a/src/app/redemptions/redemption-list/redemption-list.component.html b/src/app/redemptions/redemption-list/redemption-list.component.html index b69b93b..23c7a5e 100644 --- a/src/app/redemptions/redemption-list/redemption-list.component.html +++ b/src/app/redemptions/redemption-list/redemption-list.component.html @@ -1,46 +1,50 @@ - +
+ - - - Filters - - Expand for filtering options - - + + + Filters + + {{!panelOpenState() ? 'Expand for filtering options' : ''}} + + -
- - +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
Twitch Redemption Name{{getTwitchRedemptionNameById(redemption.redemption_id) || 'Unknown + Twitch Redemption'}}Action Name{{redemption.action_name}}Order{{redemption.order}} + +
- - -
- - - - - - - - - - - - - - - - - - - - - - - -
Twitch Redemption Name{{getTwitchRedemptionNameById(redemption.redemption_id) || 'Unknown Twitch Redemption'}}Action Name{{redemption.action_name}}Order{{redemption.order}} - -
\ No newline at end of file diff --git a/src/app/redemptions/redemption-list/redemption-list.component.scss b/src/app/redemptions/redemption-list/redemption-list.component.scss index c5d8764..472fd0d 100644 --- a/src/app/redemptions/redemption-list/redemption-list.component.scss +++ b/src/app/redemptions/redemption-list/redemption-list.component.scss @@ -1,22 +1,5 @@ -.filters-expander { - margin-top: 1em; - margin-bottom: 2em; -} - -.filters { - display: flex; - flex-direction: row; - justify-content: space-between; -} - -.mat-mdc-table { - overflow: auto; -} - -.table-container { - min-width: 555px; - height: 60vh; - overflow: auto; +.content { + height: 100vh; display: flex; flex-direction: column; } @@ -24,4 +7,24 @@ .add { width: 100%; margin-top: 3em; +} + +.filters { + display: flex; + flex-direction: row; + justify-content: space-around; +} + +.filters-expander { + margin-top: 1em; + margin-bottom: 2em; +} + +.table-container { + min-width: 800px; + flex: 1; + height: 60vh; + overflow: auto; + margin-bottom: 2em; + border-radius: 15px; } \ No newline at end of file diff --git a/src/app/redemptions/redemption-list/redemption-list.component.ts b/src/app/redemptions/redemption-list/redemption-list.component.ts index 8c362b5..3dfcfd7 100644 --- a/src/app/redemptions/redemption-list/redemption-list.component.ts +++ b/src/app/redemptions/redemption-list/redemption-list.component.ts @@ -13,7 +13,7 @@ import { MatTableModule } from '@angular/material/table'; import TwitchRedemption from '../../shared/models/twitch-redemption'; import { RedemptionItemEditComponent } from '../redemption-item-edit/redemption-item-edit.component'; import { MatDialog } from '@angular/material/dialog'; -import RedeemableAction from '../../shared/models/redeemable_action'; +import RedeemableAction from '../../shared/models/redeemable-action'; import { MatExpansionModule } from '@angular/material/expansion'; import { ScrollingModule } from '@angular/cdk/scrolling'; import { Subscription } from 'rxjs'; @@ -48,7 +48,7 @@ export class RedemptionListComponent implements OnDestroy { displayedColumns: string[] = ['twitch-redemption', 'action-name', 'order', 'misc']; filter_redemption: string | undefined; filter_action_name: string | undefined; - readonly panelOpenState = signal(true); + readonly panelOpenState = signal(false); private _subscriptions: Subscription[] = [] @@ -64,9 +64,8 @@ export class RedemptionListComponent implements OnDestroy { }); let subscription = this.redemptionService.create$?.subscribe(d => { - if (d.error || !d.data || d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id)) { + if (d.error || !d.data || d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id)) return; - } let index = -1; for (let i = 0; i < this._redemptions.length; i++) { @@ -106,6 +105,10 @@ export class RedemptionListComponent implements OnDestroy { this._subscriptions.push(subscription); } + ngOnInit() { + this.panelOpenState.set(false); + } + ngOnDestroy(): void { this._subscriptions.forEach(s => s.unsubscribe()); } diff --git a/src/app/redemptions/redemptions.module.ts b/src/app/redemptions/redemptions.module.ts index d436547..2023c6f 100644 --- a/src/app/redemptions/redemptions.module.ts +++ b/src/app/redemptions/redemptions.module.ts @@ -1,9 +1,8 @@ import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; import { RedemptionListComponent } from './redemption-list/redemption-list.component'; import { RedemptionsComponent } from './redemptions/redemptions.component'; import { TwitchRedemptionDropdownComponent } from './twitch-redemption-dropdown/twitch-redemption-dropdown.component'; -import { RedemptionService } from '../shared/services/redemption.service'; +import RedemptionService from '../shared/services/redemption.service'; diff --git a/src/app/shared/models/group-chatter.ts b/src/app/shared/models/group-chatter.ts new file mode 100644 index 0000000..d273a36 --- /dev/null +++ b/src/app/shared/models/group-chatter.ts @@ -0,0 +1,6 @@ +export interface GroupChatter { + group_id: string; + user_id: string; + chatter_id: number; + chatter_label: string; +} \ No newline at end of file diff --git a/src/app/shared/models/group.ts b/src/app/shared/models/group.ts index 7d4880c..a7c381c 100644 --- a/src/app/shared/models/group.ts +++ b/src/app/shared/models/group.ts @@ -1,5 +1,6 @@ -export default interface Group { +export interface Group { id: string; + user_id: string; name: string; priority: number; } \ No newline at end of file diff --git a/src/app/shared/models/policy.ts b/src/app/shared/models/policy.ts index 5cb6ce9..587561c 100644 --- a/src/app/shared/models/policy.ts +++ b/src/app/shared/models/policy.ts @@ -1,14 +1,13 @@ export enum PolicyScope { - Global, - Local + Global, + Local } -export class Policy { - public old_path: string|undefined; - public old_usage: number|undefined; - public old_span: number|undefined; - - constructor(public id: string, public group_id: string, public path: string, public usage: number, public span: number, public temp_group_name: string = "", public editing: boolean = false, public isNew: boolean = false) { - - } +export interface Policy { + id: string; + group_id: string; + user_id: string; + path: string; + usage: number; + span: number; } \ No newline at end of file diff --git a/src/app/shared/models/redeemable_action.ts b/src/app/shared/models/redeemable-action.ts similarity index 100% rename from src/app/shared/models/redeemable_action.ts rename to src/app/shared/models/redeemable-action.ts diff --git a/src/app/shared/resolvers/group-chatter-resolver.ts b/src/app/shared/resolvers/group-chatter-resolver.ts new file mode 100644 index 0000000..463422d --- /dev/null +++ b/src/app/shared/resolvers/group-chatter-resolver.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { map, Observable } from 'rxjs'; +import GroupService from '../services/group.service'; +import { GroupChatter } from '../models/group-chatter'; + +@Injectable({ providedIn: 'root' }) +export default class GroupChatterResolver implements Resolve { + constructor(private service: GroupService) { } + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.service.fetch().pipe(map((i: any) => i.chatters)); + } +} \ No newline at end of file diff --git a/src/app/shared/resolvers/group-resolver.ts b/src/app/shared/resolvers/group-resolver.ts new file mode 100644 index 0000000..b249533 --- /dev/null +++ b/src/app/shared/resolvers/group-resolver.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { map, Observable } from 'rxjs'; +import { Group } from '../models/group'; +import GroupService from '../services/group.service'; + +@Injectable({ providedIn: 'root' }) +export default class GroupResolver implements Resolve { + constructor(private service: GroupService) { } + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.service.fetch().pipe(map((i: any) => i.groups)); + } +} \ No newline at end of file diff --git a/src/app/shared/resolvers/policy-resolver.ts b/src/app/shared/resolvers/policy-resolver.ts new file mode 100644 index 0000000..0be2afb --- /dev/null +++ b/src/app/shared/resolvers/policy-resolver.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; +import { Policy } from '../models/policy'; +import PolicyService from '../services/policy.service'; + +@Injectable({ providedIn: 'root' }) +export default class PolicyResolver implements Resolve { + constructor(private service: PolicyService) { } + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.service.fetch(); + } +} \ No newline at end of file diff --git a/src/app/shared/resolvers/redeemable-action-resolver.ts b/src/app/shared/resolvers/redeemable-action-resolver.ts index 5c05fd3..58aa175 100644 --- a/src/app/shared/resolvers/redeemable-action-resolver.ts +++ b/src/app/shared/resolvers/redeemable-action-resolver.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; import { Observable } from 'rxjs'; -import RedeemableAction from '../models/redeemable_action'; +import RedeemableAction from '../models/redeemable-action'; import RedeemableActionService from '../services/redeemable-action.service'; @Injectable({ providedIn: 'root' }) diff --git a/src/app/shared/services/group.service.spec.ts b/src/app/shared/services/group.service.spec.ts new file mode 100644 index 0000000..e3216be --- /dev/null +++ b/src/app/shared/services/group.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { GroupService } from './group.service'; + +describe('GroupService', () => { + let service: GroupService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(GroupService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/shared/services/group.service.ts b/src/app/shared/services/group.service.ts new file mode 100644 index 0000000..d46a294 --- /dev/null +++ b/src/app/shared/services/group.service.ts @@ -0,0 +1,84 @@ +import { inject, Injectable } from '@angular/core'; +import { Observable, of, map } from 'rxjs'; +import { HermesClientService } from '../../hermes-client.service'; +import EventService from './EventService'; +import { Group } from '../models/group'; +import { GroupChatter } from '../models/group-chatter'; + +@Injectable({ + providedIn: 'root' +}) +export default class GroupService { + private readonly client = inject(HermesClientService); + private readonly events = inject(EventService); + private groups: Group[] = []; + private chatters: GroupChatter[] = []; + private loaded = false; + createGroup$: Observable | undefined; + updateGroup$: Observable | undefined; + deleteGroup$: Observable | undefined; + + createChatter$: Observable | undefined; + updateChatter$: Observable | undefined; + deleteChatter$: Observable | undefined; + + constructor() { + this.createGroup$ = this.client.filterByRequestType('create_group'); + this.updateGroup$ = this.client.filterByRequestType('update_group'); + this.deleteGroup$ = this.client.filterByRequestType('delete_group'); + + this.createChatter$ = this.client.filterByRequestType('create_group_chatter'); + this.updateChatter$ = this.client.filterByRequestType('update_group_chatter'); + this.deleteChatter$ = this.client.filterByRequestType('delete_group_chatter'); + + // Groups + this.createGroup$?.subscribe(d => this.groups.push(d.data)); + this.updateGroup$?.subscribe(d => { + const group = this.groups.find(g => g.id == d.data.id); + if (group) { + group.name = d.data.name; + group.priority = d.data.priority; + } + }); + this.deleteGroup$?.subscribe(d => this.groups = this.groups.filter(r => r.id != d.request.data.id)); + + // Chatters + this.createChatter$?.subscribe(d => this.chatters.push(d.data)); + this.updateChatter$?.subscribe(d => { + const chatter = this.chatters.find(g => g.group_id == d.data.group_id && g.chatter_id == d.data.chatter_id); + if (chatter) { + chatter.chatter_label = d.data.chatter_label; + chatter.group_id = d.data.group_id; + } + }); + this.deleteChatter$?.subscribe(d => this.chatters = this.chatters.filter(r => r.group_id != d.request.data.group_id && r.chatter_id != d.request.data.chatter_id)); + + this.events.listen('tts_logoff', () => { + this.groups = []; + this.chatters = []; + this.loaded = false; + }); + } + + + fetch() { + if (this.loaded) { + return of({ + groups: this.groups, + chatters: this.chatters, + }); + } + + const $ = this.client.first(d => d.d.request.type == 'get_groups')!.pipe(map(d => d.d.data)); + $.subscribe(d => { + this.groups = d.map((item: any) => item.group); + this.chatters = d.map((item: any) => item.chatters).flat(); + this.loaded = true; + }); + this.client.fetchGroups(); + return $.pipe(map((d: any) => ({ + groups: d.map((item: any) => item.group), + chatters: d.map((item: any) => item.chatters).flat(), + }))); + } +} diff --git a/src/app/shared/services/policy.service.spec.ts b/src/app/shared/services/policy.service.spec.ts new file mode 100644 index 0000000..e77d608 --- /dev/null +++ b/src/app/shared/services/policy.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { PolicyService } from './policy.service'; + +describe('PolicyService', () => { + let service: PolicyService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(PolicyService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/shared/services/policy.service.ts b/src/app/shared/services/policy.service.ts new file mode 100644 index 0000000..79c55fd --- /dev/null +++ b/src/app/shared/services/policy.service.ts @@ -0,0 +1,56 @@ +import { inject, Injectable } from '@angular/core'; +import { Policy } from '../models/policy'; +import { Observable, of, map } from 'rxjs'; +import { HermesClientService } from '../../hermes-client.service'; +import EventService from './EventService'; + +@Injectable({ + providedIn: 'root' +}) +export default class PolicyService { + private readonly client = inject(HermesClientService); + private readonly events = inject(EventService); + private data: Policy[] = [] + private loaded = false; + create$: Observable | undefined; + update$: Observable | undefined; + delete$: Observable | undefined; + + constructor() { + this.create$ = this.client.filterByRequestType('create_policy'); + this.update$ = this.client.filterByRequestType('update_policy'); + this.delete$ = this.client.filterByRequestType('delete_policy'); + + this.create$?.subscribe(d => this.data.push(d.data)); + this.update$?.subscribe(d => { + const policy = this.data.find(p => p.id == d.data.id); + if (policy) { + policy.group_id = d.data.group_id; + policy.path = d.data.path; + policy.span = d.data.span; + policy.usage = d.data.usage; + } + }); + this.delete$?.subscribe(d => this.data = this.data.filter(r => r.id != d.request.data.id)); + + this.events.listen('tts_logoff', () => { + this.data = []; + this.loaded = false; + }); + } + + + fetch() { + if (this.loaded) { + return of(this.data); + } + + const $ = this.client.first(d => d.d.request.type == 'get_policies')!.pipe(map(d => d.d.data)); + $.subscribe(d => { + this.data = d; + this.loaded = true; + }); + this.client.fetchPolicies(); + return $; + } +} diff --git a/src/app/shared/services/redeemable-action.service.ts b/src/app/shared/services/redeemable-action.service.ts index 3004470..b1e33e2 100644 --- a/src/app/shared/services/redeemable-action.service.ts +++ b/src/app/shared/services/redeemable-action.service.ts @@ -1,7 +1,7 @@ import { inject, Injectable } from '@angular/core'; import { HermesClientService } from '../../hermes-client.service'; import { map, Observable, of } from 'rxjs'; -import RedeemableAction from '../models/redeemable_action'; +import RedeemableAction from '../models/redeemable-action'; import EventService from './EventService'; @Injectable({