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

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