Added groups - missing user management. Fixed several issues.

This commit is contained in:
Tom 2025-03-18 12:55:00 +00:00
parent 2f88840ef6
commit 74b282ccfd
77 changed files with 1771 additions and 377 deletions

3
.gitignore vendored
View File

@ -41,5 +41,6 @@ testem.log
.DS_Store
Thumbs.db
src/environments/*
src/environments/
src/index.*.html
*.code-workspace

View File

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

25
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,6 @@ main {
justify-content: center;
text-align: center;
justify-self: center;
background-color: #fafafa;
width: 80%;
& .container {

View File

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

View File

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

View File

@ -27,5 +27,5 @@
</mat-form-field>
</article>
</section>
<action-list [actions]="actions" (actionsChange)="items.push($event)" />
<action-list class="center" [actions]="actions" (actionsChange)="items.push($event)" />
</body>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,26 @@
<mat-form-field>
<mat-label>Group</mat-label>
<input
matInput
type="text"
placeholder="Pick a group"
aria-label="group"
[formControl]="formControl"
[matAutocomplete]="auto"
[disabled]="!!groupDisabled"
[readonly]="!!groupDisabled"
(blur)="blur()"
(input)="input()">
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn" (optionSelected)="select($event.option.value)">
@for (group of filteredGroups; track group.id) {
<mat-option [value]="group">{{group.name}}</mat-option>
}
</mat-autocomplete>
@if (!search && formControl.invalid && (formControl.dirty || formControl.touched)) {
@for (error of errorMessageKeys; track $index) {
@if (formControl.hasError(error)) {
<small class="error">{{errorMessages[error]}}</small>
}
}
}
</mat-form-field>

View File

@ -0,0 +1,3 @@
.error {
color: #ba1a1a;
}

View File

@ -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<GroupDropdownComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GroupDropdownComponent]
})
.compileComponents();
fixture = TestBed.createComponent(GroupDropdownComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

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

View File

@ -0,0 +1,50 @@
<mat-card>
<mat-card-header>
<mat-card-title-group>
<mat-card-title>Edit Group</mat-card-title>
</mat-card-title-group>
</mat-card-header>
<mat-card-content>
<mat-form-field>
<mat-label>Group Name</mat-label>
<input matInput type="text" [formControl]="nameForm" [disabled]="isSpecial" />
@if (nameForm.invalid && (nameForm.dirty || nameForm.touched)) {
@if (nameForm.hasError('required')) {
<small class="error">This field is required.</small>
}
}
</mat-form-field>
<mat-form-field>
<mat-label>TTS Priority</mat-label>
<input matInput type="number" [formControl]="priorityForm" />
@if (priorityForm.invalid && (priorityForm.dirty || priorityForm.touched)) {
@if (priorityForm.hasError('required')) {
<small class="error">This field is required.</small>
}
@if (priorityForm.hasError('min')) {
<small class="error">This field must be greater than -2147483649.</small>
}
@if (priorityForm.hasError('max')) {
<small class="error">This field must be smaller than 2147483648.</small>
}
@if (priorityForm.hasError('integer') && !priorityForm.hasError('min') && !priorityForm.hasError('max')) {
<small class="error">This field must be an integer.</small>
}
}
</mat-form-field>
</mat-card-content>
<mat-card-actions>
<button
mat-button
[disabled]="waitForResponse || formGroup.invalid"
(click)="add()">
<mat-icon>add</mat-icon>Add
</button>
<button
mat-button
[disabled]="waitForResponse"
(click)="cancel()">
<mat-icon>cancel</mat-icon>Cancel
</button>
</mat-card-actions>
</mat-card>

View File

@ -0,0 +1,8 @@
.mat-mdc-form-field {
display: block;
margin: 1em;
}
.mat-mdc-card-actions {
align-self: center;
}

View File

@ -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<GroupItemEditComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GroupItemEditComponent]
})
.compileComponents();
fixture = TestBed.createComponent(GroupItemEditComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

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

View File

@ -0,0 +1,21 @@
<article>
<section class="title">{{item().group.name}}
@if (special) {
<small class="tag">auto-generated</small>
}
</section>
<section class="">{{item().group.priority}}</section>
<section>
{{item().chatters.length}}
<small class="muted block">user{{item().chatters.length == 1 ? '' : 's'}}</small>
</section>
<section>
{{item().policies.length}}
<small class="muted block">polic{{item().chatters.length == 1 ? 'y' : 'ies'}}</small>
</section>
<section>
<button mat-button (click)="router.navigate([link])">
<mat-icon>pageview</mat-icon>View
</button>
</section>
</article>

View File

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

View File

@ -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<GroupItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GroupItemComponent]
})
.compileComponents();
fixture = TestBed.createComponent(GroupItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

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

View File

@ -0,0 +1,7 @@
<ul>
@for (group of groups; track $index) {
<li>
<group-item [item]="group" />
</li>
}
</ul>

View File

@ -0,0 +1,9 @@
ul {
margin: 0;
padding: 0;
}
li {
list-style: none;
margin: 1em;
}

View File

@ -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<GroupListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GroupListComponent]
})
.compileComponents();
fixture = TestBed.createComponent(GroupListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

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

View File

@ -0,0 +1,38 @@
<div>
<h2>{{group?.name}}</h2>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>Policies</mat-panel-title>
<mat-panel-description class="muted">
{{policies.length}} polic{{policies.length == 1 ? 'y' : 'ies'}}
</mat-panel-description>
</mat-expansion-panel-header>
<policy-add-button class="add" [groups]="groups" [policies]="policies" [group]="group?.id" />
@if (policies.length > 0) {
<policy-table [policies]="policies" />
}
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title class="danger">Danger Zone</mat-panel-title>
<mat-panel-description class="muted">
Dangerous actions
</mat-panel-description>
</mat-expansion-panel-header>
<div class="content">
<section>
<article class="left">
<h4>Deletion</h4>
<p>Deleting this group will delete everything that is part of it, including policies and permissions.</p>
</article>
<article class="right">
<button mat-raised-button class="delete" (click)="delete()">
<mat-icon>delete</mat-icon>Delete this group.
</button>
</article>
</section>
</div>
</mat-expansion-panel>
</div>

View File

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

View File

@ -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<GroupPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GroupPageComponent]
})
.compileComponents();
fixture = TestBed.createComponent(GroupPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

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

View File

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

View File

@ -0,0 +1,15 @@
<button mat-button [mat-menu-trigger-for]="menu">
<mat-icon>add</mat-icon>
Add a group
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="openDialog('')">Custom Group</button>
<button mat-menu-item (click)="openDialog('everyone')">Everyone Group</button>
<button mat-menu-item (click)="openDialog('subscribers')">Subscriber Group</button>
<button mat-menu-item (click)="openDialog('moderators')">Moderator Group</button>
<button mat-menu-item (click)="openDialog('vip')">VIP Group</button>
<button mat-menu-item (click)="openDialog('broadcaster')">Broadcaster Group</button>
</mat-menu>
<group-list
class="groups"
[groups]="items" />

View File

@ -0,0 +1,7 @@
button {
width: 100%;
}
.delete {
color: red;
}

View File

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

View File

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

View File

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

View File

@ -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<any>|undefined {
return this.socket?.asObservable();
public get$(): Observable<any> | undefined {
return this.socket?.asObservable().pipe(catchError(_ => EMPTY));
}
public subscribe(subscriptions: any) {
public subscribe(subscriptions: Partial<Observer<any>> | ((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() {

View File

@ -1,35 +1,48 @@
<nav>
<user-card class="card" />
<ul>
@if (!isLoggedIn()) {
<li>
<a routerLink="/login" routerLinkActive="active" *ngIf="!isLoggedIn()">
<a routerLink="/login" routerLinkActive="active">
Login
</a>
</li>
}
@if (isLoggedIn() && !isTTSLoggedIn()) {
<li>
<a routerLink="/tts-login" routerLinkActive="active" *ngIf="isLoggedIn() && !isTTSLoggedIn()">
<a routerLink="/tts-login" routerLinkActive="active">
TTS Login
</a>
</li>
}
@if (isLoggedIn() && isTTSLoggedIn()) {
<li>
<a routerLink="/policies" routerLinkActive="active" *ngIf="isLoggedIn() && isTTSLoggedIn()">
<a routerLink="/policies" routerLinkActive="active">
Policies
</a>
</li>
<li>
<a routerLink="/filters" routerLinkActive="active" *ngIf="isLoggedIn() && isTTSLoggedIn()">
<a routerLink="/filters" routerLinkActive="active">
Filters
</a>
</li>
<li>
<a routerLink="/actions" routerLinkActive="active" *ngIf="isLoggedIn() && isTTSLoggedIn()">
<a routerLink="/actions" routerLinkActive="active">
Actions
</a>
</li>
<li>
<a routerLink="/redemptions" routerLinkActive="active" *ngIf="isLoggedIn() && isTTSLoggedIn()">
<a routerLink="/redemptions" routerLinkActive="active">
Redemptions
</a>
</li>
@if (isAdmin()) {
<li>
<a routerLink="/groups" routerLinkActive="active">
Groups
</a>
</li>
}
}
</ul>
</nav>

View File

@ -25,6 +25,7 @@ a {
font-size: large;
text-decoration: none;
color: $primary_font_color;
border-radius: 10px;
}
a:hover {

View File

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

View File

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

View File

@ -0,0 +1,4 @@
<button mat-button (click)="openDialog()">
<mat-icon>add</mat-icon>
Add a policy
</button>

View File

@ -0,0 +1,3 @@
button {
width: 100%;
}

View File

@ -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<PolicyAddButtonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PolicyAddButtonComponent]
})
.compileComponents();
fixture = TestBed.createComponent(PolicyAddButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

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

View File

@ -0,0 +1,73 @@
<mat-card>
<mat-card-header>
<mat-card-title>{{isNew ? 'Add' : 'Edit'}} Policy</mat-card-title>
</mat-card-header>
<mat-card-content>
<group-dropdown
ngDefaultControl
[formControl]="groupControl"
[groups]="data.groups"
[group]="data.group_id"
[groupDisabled]="data.groupDisabled"
[errorMessages]="groupErrorMessages" />
<mat-form-field>
<mat-label>Path</mat-label>
<input matInput placeholder="Path" [formControl]="pathControl" />
@if (pathControl.invalid && (pathControl.dirty || pathControl.touched)) {
@if (pathControl.hasError('required')) {
<small class="error">This field is required.</small>
}
}
</mat-form-field>
<mat-form-field>
<mat-label>Usage</mat-label>
<input matInput type="number" [formControl]="usageControl" />
@if (usageControl.invalid && (usageControl.dirty || usageControl.touched)) {
@if (usageControl.hasError('required')) {
<small class="error">This field is required.</small>
}
@if (usageControl.hasError('min')) {
<small class="error">The value needs to be positive.</small>
}
@if (usageControl.hasError('max')) {
<small class="error">The value needs to be lower than 100.</small>
}
@if (usageControl.hasError('integer')) {
<small class="error">The value needs to be an integer.</small>
}
}
</mat-form-field>
<mat-form-field>
<mat-label>Span</mat-label>
<input matInput type="number" [formControl]="spanControl" />
@if (spanControl.invalid && (spanControl.dirty || spanControl.touched)) {
@if (spanControl.hasError('required')) {
<small class="error">This field is required.</small>
}
@if (spanControl.hasError('min')) {
<small class="error">The value needs to be at least 1000.</small>
}
@if (spanControl.hasError('max')) {
<small class="error">The value needs to be lower than 86401.</small>
}
@if (spanControl.hasError('integer')) {
<small class="error">The value needs to be an integer.</small>
}
}
</mat-form-field>
</mat-card-content>
<mat-card-actions>
@if (isNew) {
<button mat-button (click)="save()">
<mat-icon>add</mat-icon>Add
</button>
} @else {
<button mat-button (click)="save()">
<mat-icon>save</mat-icon>Save
</button>
}
<button mat-button (click)="dialogRef.close()">
<mat-icon>cancel</mat-icon>Cancel
</button>
</mat-card-actions>
</mat-card>

View File

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

View File

@ -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<PolicyItemEditComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PolicyItemEditComponent]
})
.compileComponents();
fixture = TestBed.createComponent(PolicyItemEditComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<PolicyItemEditComponent>);
readonly groupControl = new FormControl<Group | string | undefined>(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);
}
}
}

View File

@ -1,61 +1,40 @@
<table mat-table [dataSource]="policies" class="mat-elevation-z8">
<ng-container matColumnDef="path">
<th mat-header-cell *matHeaderCellDef>Path</th>
<td mat-cell *matCellDef="let policy">
{{policy.path}}
</td>
</ng-container>
<ng-container matColumnDef="path">
<th mat-header-cell *matHeaderCellDef>Path</th>
<td mat-cell *matCellDef="let policy">
{{policy.path}}
</td>
</ng-container>
<ng-container matColumnDef="group">
<th mat-header-cell *matHeaderCellDef>Group</th>
<td mat-cell *matCellDef="let policy">
@if (policy.editing) {
<input type="text" [(ngModel)]="policy.temp_group_name" />
}
@if (!policy.editing && groups[policy.group_id]) {
{{groups[policy.group_id].name}}
}
</td>
</ng-container>
<ng-container matColumnDef="group">
<th mat-header-cell *matHeaderCellDef>Group</th>
<td mat-cell *matCellDef="let policy">
{{getGroupById(policy.group_id)?.name || '\<unknown group\>'}}
</td>
</ng-container>
<ng-container matColumnDef="usage">
<th mat-header-cell *matHeaderCellDef>Usage Rate</th>
<td mat-cell *matCellDef="let policy">
@if (policy.editing) {
<input type="number" [(ngModel)]="policy.usage" (keypress)="($event.charCode >= 48 && $event.charCode < 58)" />
}
@if (!policy.editing) {
{{policy.usage}}
}
</td>
</ng-container>
<ng-container matColumnDef="usage">
<th mat-header-cell *matHeaderCellDef>Usage Rate</th>
<td mat-cell class="center" *matCellDef="let policy">
{{policy.usage}}
</td>
</ng-container>
<ng-container matColumnDef="span">
<th mat-header-cell *matHeaderCellDef>Span (ms)</th>
<td mat-cell *matCellDef="let policy">
@if (policy.editing) {
<input type="number" [(ngModel)]="policy.span" (keypress)="($event.charCode >= 48 && $event.charCode < 58)" />
}
@if (!policy.editing) {
{{policy.span}}
}
</td>
</ng-container>
<ng-container matColumnDef="span">
<th mat-header-cell *matHeaderCellDef>Span (ms)</th>
<td mat-cell class="center" *matCellDef="let policy">
{{policy.span}}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef> Actions </th>
<td mat-cell *matCellDef="let policy">
@if (!policy.editing) {
<button mat-mini-fab (click)="edit(policy)"><mat-icon>edit</mat-icon></button>
<button mat-mini-fab (click)="delete(policy)"><mat-icon>delete</mat-icon></button>
}
@if (policy.editing) {
<button mat-mini-fab (click)="save(policy)"><mat-icon>save</mat-icon></button>
<button mat-mini-fab (click)="cancel(policy)"><mat-icon>cancel</mat-icon></button>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef> Actions </th>
<td mat-cell *matCellDef="let policy">
<button mat-button (click)="edit(policy)"><mat-icon>edit</mat-icon>Edit</button>
<button mat-button class="delete" (click)="delete(policy)"><mat-icon>delete</mat-icon>Delete</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>

View File

@ -0,0 +1,12 @@
table {
border-radius: 15px;
overflow: hidden !important;
}
.delete {
color: red;
}
button ~ button {
margin-left: 1em;
}

View File

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

View File

@ -1,8 +1,8 @@
<h4>Policies</h4>
<div>
<policy-add-form />
<div class="add">
<policy-add-button [policies]="policies" [groups]="groups" (policy)="addPolicy($event)" />
</div>
<div>
<policy-table [policies]="policies" />
<policy-table [policies]="policies" />
</div>

View File

@ -1,3 +1,9 @@
h4 {
text-align: center;
}
.add {
margin-top: 1em;
margin-bottom: 2em;
margin: 1em 2em 2em;
}

View File

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

View File

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

View File

@ -1,46 +1,50 @@
<button mat-button class="add" (click)="add()"><mat-icon>add</mat-icon> Add Redemption</button>
<div class="content">
<button mat-button class="add" (click)="add()"><mat-icon>add</mat-icon> Add Redemption</button>
<mat-expansion-panel class="filters-expander" (opened)="panelOpenState.set(true)" (closed)="panelOpenState.set(false)">
<mat-expansion-panel-header>
<mat-panel-title>Filters</mat-panel-title>
<mat-panel-description>
Expand for filtering options
</mat-panel-description>
</mat-expansion-panel-header>
<mat-expansion-panel class="filters-expander" (opened)="panelOpenState.set(true)"
(closed)="panelOpenState.set(false)">
<mat-expansion-panel-header>
<mat-panel-title>Filters</mat-panel-title>
<mat-panel-description>
{{!panelOpenState() ? 'Expand for filtering options' : ''}}
</mat-panel-description>
</mat-expansion-panel-header>
<div class="filters">
<twitch-redemption-dropdown [(twitchRedemptionId)]="filter_redemption" [search]="true" />
<action-dropdown [(action)]="filter_action_name" [search]="true" />
<div class="filters">
<twitch-redemption-dropdown [(twitchRedemptionId)]="filter_redemption" [search]="true" />
<action-dropdown [(action)]="filter_action_name" [search]="true" />
</div>
</mat-expansion-panel>
<div class="table-container">
<table mat-table [dataSource]="redemptions" class="mat-elevation-z8">
<ng-container matColumnDef="twitch-redemption">
<th mat-header-cell *matHeaderCellDef>Twitch Redemption Name</th>
<td mat-cell *matCellDef="let redemption">{{getTwitchRedemptionNameById(redemption.redemption_id) || 'Unknown
Twitch Redemption'}}</td>
</ng-container>
<ng-container matColumnDef="action-name">
<th mat-header-cell *matHeaderCellDef>Action Name</th>
<td mat-cell *matCellDef="let redemption">{{redemption.action_name}}</td>
</ng-container>
<ng-container matColumnDef="order">
<th mat-header-cell *matHeaderCellDef>Order</th>
<td mat-cell *matCellDef="let redemption">{{redemption.order}}</td>
</ng-container>
<ng-container matColumnDef="misc">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let redemption">
<button mat-icon-button (click)="openDialog(redemption)">
<mat-icon>edit</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
</mat-expansion-panel>
<div class="table-container">
<table mat-table [dataSource]="redemptions" class="mat-elevation-z8">
<ng-container matColumnDef="twitch-redemption">
<th mat-header-cell *matHeaderCellDef>Twitch Redemption Name</th>
<td mat-cell *matCellDef="let redemption">{{getTwitchRedemptionNameById(redemption.redemption_id) || 'Unknown Twitch Redemption'}}</td>
</ng-container>
<ng-container matColumnDef="action-name">
<th mat-header-cell *matHeaderCellDef>Action Name</th>
<td mat-cell *matCellDef="let redemption">{{redemption.action_name}}</td>
</ng-container>
<ng-container matColumnDef="order">
<th mat-header-cell *matHeaderCellDef>Order</th>
<td mat-cell *matCellDef="let redemption">{{redemption.order}}</td>
</ng-container>
<ng-container matColumnDef="misc">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let redemption">
<button mat-icon-button (click)="openDialog(redemption)">
<mat-icon>edit</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
export interface GroupChatter {
group_id: string;
user_id: string;
chatter_id: number;
chatter_label: string;
}

View File

@ -1,5 +1,6 @@
export default interface Group {
export interface Group {
id: string;
user_id: string;
name: string;
priority: number;
}

View File

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

View File

@ -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<GroupChatter[]> {
constructor(private service: GroupService) { }
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<GroupChatter[]> {
return this.service.fetch().pipe(map((i: any) => i.chatters));
}
}

View File

@ -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<Group[]> {
constructor(private service: GroupService) { }
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Group[]> {
return this.service.fetch().pipe(map((i: any) => i.groups));
}
}

View File

@ -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<Policy[]> {
constructor(private service: PolicyService) { }
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Policy[]> {
return this.service.fetch();
}
}

View File

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

View File

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

View File

@ -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<any> | undefined;
updateGroup$: Observable<any> | undefined;
deleteGroup$: Observable<any> | undefined;
createChatter$: Observable<any> | undefined;
updateChatter$: Observable<any> | undefined;
deleteChatter$: Observable<any> | 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(),
})));
}
}

View File

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

View File

@ -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<any> | undefined;
update$: Observable<any> | undefined;
delete$: Observable<any> | 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 $;
}
}

View File

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