Added group permissions. Added some global styles. Made groups rely on services' data.

This commit is contained in:
Tom 2025-03-22 21:58:30 +00:00
parent d19c5445d6
commit 9de4424736
38 changed files with 936 additions and 137 deletions

View File

@ -1,5 +1,5 @@
<main>
@for (action of actions; track $index) {
@for (action of actions; track action.name) {
<button type="button"
class="container"
(click)="modify(action)">

View File

@ -10,7 +10,7 @@
<mat-select-trigger>
<mat-icon matPrefix>filter_list</mat-icon>&nbsp;{{filter.name}}
</mat-select-trigger>
@for (item of filters; track $index) {
@for (item of filters; track item.name) {
<mat-option value="{{$index}}">{{item.name}}</mat-option>
}
</mat-select>

View File

@ -19,6 +19,7 @@ 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';
import PermissionResolver from './shared/resolvers/permission-resolver';
export const routes: Routes = [
{
@ -27,7 +28,6 @@ export const routes: Routes = [
canActivate: [AuthUserGuard],
resolve: {
groups: GroupResolver,
chatters: GroupChatterResolver,
policies: PolicyResolver,
}
},
@ -39,6 +39,7 @@ export const routes: Routes = [
groups: GroupResolver,
chatters: GroupChatterResolver,
policies: PolicyResolver,
permissions: PermissionResolver,
}
},
{
@ -49,6 +50,7 @@ export const routes: Routes = [
groups: GroupResolver,
chatters: GroupChatterResolver,
policies: PolicyResolver,
permissions: PermissionResolver,
}
},
{

View File

@ -1,24 +1,28 @@
<article>
<section class="title">{{item().group.name}}
<section class="title">{{group().name}}
@if (special) {
<small class="tag">auto-generated</small>
}
</section>
<section class="">
{{item().group.priority}}
{{group().priority}}
<small class="muted block">priority</small>
</section>
<section>
@if (special) {
<p class="muted">Unknown</p>
} @else {
{{item().chatters.length}}
<small class="muted block">user{{item().chatters.length == 1 ? '' : 's'}}</small>
{{chatters().length}}
<small class="muted block">user{{chatters().length == 1 ? '' : 's'}}</small>
}
</section>
<section>
{{item().policies.length}}
<small class="muted block">polic{{item().chatters.length == 1 ? 'y' : 'ies'}}</small>
{{permissions().length}}
<small class="muted block">permission{{permissions().length == 1 ? '' : 's'}}</small>
</section>
<section>
{{policies().length}}
<small class="muted block">polic{{policies().length == 1 ? 'y' : 'ies'}}</small>
</section>
<section>
<button mat-button

View File

@ -7,6 +7,7 @@ import { Policy } from '../../shared/models/policy';
import { Router } from '@angular/router';
import { GroupChatter } from '../../shared/models/group-chatter';
import { SpecialGroups } from '../../shared/utils/groups';
import { Permission } from '../../shared/models/permission';
@Component({
selector: 'group-item',
@ -21,12 +22,15 @@ import { SpecialGroups } from '../../shared/utils/groups';
})
export class GroupItemComponent implements OnInit {
readonly router = inject(Router);
item = input.required<{ group: Group, chatters: GroupChatter[], policies: Policy[] }>();
group = input.required<Group>();
chatters = input.required<GroupChatter[]>();
permissions = input.required<Permission[]>();
policies = input.required<Policy[]>();
link: string = '';
special: boolean = true;
ngOnInit() {
this.special = SpecialGroups.includes(this.item().group.name);
this.link = 'groups/' + this.item().group.id;
this.special = SpecialGroups.includes(this.group().name);
this.link = 'groups/' + this.group().id;
}
}

View File

@ -1,7 +1,10 @@
<ul>
@for (group of groups; track $index) {
@for (group of groups; track group.id) {
<li>
<group-item [item]="group" />
<group-item [group]="group"
[chatters]="getChattersByGroup(group.id)"
[permissions]="getPermissionsByGroup(group.id)"
[policies]="getPoliciesByGroup(group.id)" />
</li>
}
</ul>

View File

@ -1,8 +1,9 @@
import { Component, Input } from '@angular/core';
import { Component, input, 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';
import { Permission } from '../../shared/models/permission';
@Component({
selector: 'group-list',
@ -12,25 +13,35 @@ import { GroupChatter } from '../../shared/models/group-chatter';
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;
_groups = input.required<Group[]>({ alias: 'groups' });
chatters = input.required<GroupChatter[]>();
permissions = input.required<Permission[]>();
policies = input.required<Policy[]>();
private _filter: (item: Group) => boolean = _ => true;
get filter(): (item: { group: Group, chatters: GroupChatter[], policies: Policy[] }) => boolean {
get filter(): (group: Group) => boolean {
return this._filter;
}
@Input({ alias: 'filter', required: false })
set filter(value: (item: { group: Group, chatters: GroupChatter[], policies: Policy[] }) => boolean) {
set filter(value: (item: Group) => boolean) {
this._filter = value;
}
get groups() {
return this._groups.filter(this._filter);
return this._groups().filter(g => this._filter(g));
}
@Input({ alias: 'groups', required: true })
set groups(value: { group: Group, chatters: GroupChatter[], policies: Policy[] }[]) {
this._groups = value;
getChattersByGroup(groupId: string) {
return this.chatters().filter(c => c.group_id == groupId);
}
getPermissionsByGroup(groupId: string) {
return this.permissions().filter(c => c.group_id == groupId);
}
getPoliciesByGroup(groupId: string) {
return this.policies().filter(c => c.group_id == groupId);
}
}

View File

@ -14,6 +14,18 @@
</mat-expansion-panel>
}
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>Permissions</mat-panel-title>
<mat-panel-description class="muted">
{{permissions.length}} permission{{permissions.length == 1 ? '' : 's'}}
</mat-panel-description>
</mat-expansion-panel-header>
<permission-list [permissions]="permissions"
[groups]="groups"
[group]="group" />
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>Policies</mat-panel-title>
@ -43,7 +55,7 @@
</article>
<article class="right">
<button mat-raised-button
class="delete"
class="danger"
(click)="delete()">
<mat-icon>delete</mat-icon>Delete this group.
</button>

View File

@ -1,4 +1,4 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, inject, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Group } from '../../shared/models/group';
import { Policy } from '../../shared/models/policy';
@ -15,6 +15,12 @@ import { HermesClientService } from '../../hermes-client.service';
import { GroupChatter } from '../../shared/models/group-chatter';
import { TwitchUsersModule } from "../../twitch-users/twitch-users.module";
import { SpecialGroups } from '../../shared/utils/groups';
import { PermissionListComponent } from "../../permissions/permission-list/permission-list.component";
import { Permission } from '../../shared/models/permission';
import { Subscription } from 'rxjs';
import { PermissionService } from '../../shared/services/permission.service';
import GroupService from '../../shared/services/group.service';
import PolicyService from '../../shared/services/policy.service';
@Component({
imports: [
@ -29,30 +35,42 @@ import { SpecialGroups } from '../../shared/utils/groups';
PolicyTableComponent,
PolicyTableComponent,
TwitchUsersModule,
PermissionListComponent
],
templateUrl: './group-page.component.html',
styleUrl: './group-page.component.scss'
})
export class GroupPageComponent {
export class GroupPageComponent implements OnDestroy {
private readonly _router = inject(Router);
private readonly _route = inject(ActivatedRoute);
private readonly _groupService = inject(GroupService);
private readonly _permissionService = inject(PermissionService);
private readonly _policyService = inject(PolicyService);
private readonly _client = inject(HermesClientService);
private _group: Group | undefined;
private _chatters: GroupChatter[];
private _policies: Policy[];
private _permissions: Permission[];
isSpecialGroup: boolean;
_groups: Group[];
private readonly subscriptions: (Subscription | undefined)[] = [];
isSpecialGroup = false;
groups: Group[] = [];
constructor() {
this.isSpecialGroup = false
this._groups = [];
this._chatters = [];
this._permissions = [];
this._policies = [];
this._route.params.subscribe((p: any) => {
const group_id = p.id;
this._route.params.subscribe((params: any) => {
// Fetch the group id from the query params.
const group_id = params['id'];
this._route.data.subscribe(async (data: any) => {
this.groups = [...data['groups']];
this._groups = data['groups'];
const group = this.groups.find((g: Group) => g.id == group_id);
if (!group) {
@ -62,29 +80,88 @@ export class GroupPageComponent {
this._group = group;
this.isSpecialGroup = SpecialGroups.includes(this.group!.name);
this._chatters = [...data['chatters'].filter((c: GroupChatter) => c.group_id == group_id)];
this._policies = [...data['policies'].filter((p: Policy) => p.group_id == group_id)];
this._chatters = data['chatters'];
this._permissions = data['permissions'];
this._policies = data['policies'];
});
});
this.subscriptions.push(this._permissionService.delete$?.subscribe(d => {
if (d.error) {
return;
}
this._permissionService.fetch().subscribe(permissions => this._permissions = permissions);
}));
this.subscriptions.push(this._groupService.deleteGroup$?.subscribe(d => {
if (d.error) {
return;
}
this._groupService.fetch().subscribe(data => this._groups = data.groups);
}));
this.subscriptions.push(this._groupService.deleteChatter$?.subscribe(d => {
if (d.error) {
return;
}
this._groupService.fetch().subscribe(data => this._chatters = data.chatters);
}));
this.subscriptions.push(this._policyService.delete$?.subscribe(d => {
if (d.error) {
return;
}
this._policyService.fetch().subscribe(policies => this._policies = policies);
}));
}
ngOnDestroy(): void {
if (this.subscriptions) {
for (let subscription of this.subscriptions) {
if (subscription)
subscription.unsubscribe();
}
}
}
get group() {
return this._group;
}
get groups() {
return this._groups;
}
get chatters() {
return this._chatters;
if (!this._group) {
return [];
}
return this._chatters.filter((c: GroupChatter) => c.group_id == this._group!.id);
}
get permissions() {
if (!this._group) {
return [];
}
return this._permissions.filter((p: Permission) => p.group_id == this._group!.id);
}
get policies() {
return this._policies;
if (!this._group) {
return [];
}
return this._policies.filter((p: Policy) => p.group_id == this._group!.id);
}
delete() {
if (!this.group)
return;
this._client.first(d => d.d.request.type == 'delete_group' && d.d.request.data.group == this.group!.id)
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

@ -14,4 +14,7 @@
}
</mat-menu>
<group-list class="groups"
[groups]="items" />
[groups]="groups"
[chatters]="chatters"
[permissions]="permissions"
[policies]="policies" />

View File

@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core';
import { Component, inject, OnDestroy } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, RouterModule } from '@angular/router';
@ -13,6 +13,10 @@ import { MatMenuModule } from '@angular/material/menu';
import { HermesClientService } from '../../hermes-client.service';
import { GroupChatter } from '../../shared/models/group-chatter';
import { SpecialGroups } from '../../shared/utils/groups';
import { Permission } from '../../shared/models/permission';
import { Subscription } from 'rxjs';
import { PermissionService } from '../../shared/services/permission.service';
import PolicyService from '../../shared/services/policy.service';
@Component({
selector: 'groups',
@ -27,100 +31,108 @@ import { SpecialGroups } from '../../shared/utils/groups';
templateUrl: './groups.component.html',
styleUrl: './groups.component.scss'
})
export class GroupsComponent {
private readonly _groupService = inject(GroupService);
export class GroupsComponent implements OnDestroy {
private readonly _client = inject(HermesClientService);
private readonly _route = inject(ActivatedRoute);
private readonly _dialog = inject(MatDialog);
private readonly _groupService = inject(GroupService);
private readonly _permissionService = inject(PermissionService);
private readonly _policyService = inject(PolicyService);
private readonly subscriptions: (Subscription | undefined)[] = [];
readonly specialGroups = SpecialGroups;
items: { group: Group, chatters: GroupChatter[], policies: Policy[] }[] = [];
private _groups: Group[] = [];
private _chatters: GroupChatter[] = [];
private _permissions: Permission[] = [];
private _policies: Policy[] = [];
opened = false;
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._groups = payload['groups'];
this._chatters = payload['chatters'];
this._permissions = payload['permissions'];
this._policies = payload['policies'];
});
this._groupService.createGroup$?.subscribe(d => {
if (d.error || !d.data || d.request.nounce != null && d.request.nounce.startsWith(this._client.session_id))
this.subscriptions.push(this._permissionService.delete$?.subscribe(d => {
if (d.error) {
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))
this._permissionService.fetch().subscribe(permissions => this._permissions = permissions);
}));
this.subscriptions.push(this._groupService.deleteGroup$?.subscribe(d => {
if (d.error) {
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))
this._groupService.fetch().subscribe(data => this._groups = data.groups);
}));
this.subscriptions.push(this._groupService.deleteChatter$?.subscribe(d => {
if (d.error) {
return;
this.items = this.items.filter(r => r.group.id != d.request.data.id);
});
}
this._groupService.fetch().subscribe(data => this._chatters = data.chatters);
}));
this.subscriptions.push(this._policyService.delete$?.subscribe(d => {
if (d.error) {
return;
}
this._policyService.fetch().subscribe(policies => this._policies = policies);
}));
}
ngOnDestroy(): void {
if (this.subscriptions) {
for (let subscription of this.subscriptions) {
if (subscription)
subscription.unsubscribe();
}
}
}
get groups() {
return this._groups;
}
get chatters() {
return this._chatters;
}
get permissions() {
return this._permissions;
}
get policies() {
return this._policies;
}
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)
if (this.opened) {
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;
}
this.opened = true;
const dialogRef = this._dialog.open(GroupItemEditComponent, {
data: { group: { id: '', user_id: '', name: groupName, priority: 0 }, isSpecial: groupName.length > 0 },
});
dialogRef.afterClosed().subscribe((result: Group | undefined) => this.opened = false);
}
compare(a: Group, b: Group) {
return a.name.localeCompare(b.name);
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}
exists(groupName: string) {
return this.items.some(g => g.group.name == groupName);
return this._groups.some(g => g.name == groupName);
}
}

View File

@ -112,6 +112,17 @@ export class HermesClientService {
});
}
public createGroupPermission(groupId: string, path: string, allow: boolean | null) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "create_group_permission",
data: { group: groupId, path, allow },
});
}
public createPolicy(groupId: string, path: string, usage: number, timespan: number) {
if (!this.logged_in)
return;
@ -119,9 +130,7 @@ export class HermesClientService {
this.send(3, {
request_id: null,
type: "create_policy",
data: {
groupId, path, count: usage, span: timespan
},
data: { groupId, path, count: usage, span: timespan },
});
}
@ -183,6 +192,17 @@ export class HermesClientService {
});
}
public deleteGroupPermission(id: string) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "delete_group_permission",
data: { id },
});
}
public deletePolicy(id: string) {
if (!this.logged_in)
return;
@ -252,13 +272,13 @@ export class HermesClientService {
});
}
public fetchPermissionsAndGroups() {
public fetchPermissions() {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "get_permissions",
type: "get_group_permissions",
data: null,
});
}
@ -331,6 +351,17 @@ export class HermesClientService {
});
}
public updateGroupPermission(id: string, groupId: string, path: string, allow: boolean | null) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "update_group_permission",
data: { id, group: groupId, path, allow },
});
}
public updatePolicy(id: string, groupId: string, path: string, usage: number, timespan: number) {
if (!this.logged_in)
return;
@ -338,9 +369,7 @@ export class HermesClientService {
this.send(3, {
request_id: null,
type: "update_policy",
data: {
id, groupId, path, count: usage, span: timespan
},
data: { id, groupId, path, count: usage, span: timespan },
});
}

View File

@ -0,0 +1,39 @@
<mat-card>
<mat-card-header>
<mat-card-title-group>
<mat-card-title>{{data.isNew ? "Add" : "Edit"}} Group Permission</mat-card-title>
@if (data.group) {
<mat-card-subtitle>in {{data.group.name}}</mat-card-subtitle>
}
</mat-card-title-group>
</mat-card-header>
<mat-card-content>
<policy-dropdown [control]="pathControl" />
<mat-form-field>
<mat-label>Permission</mat-label>
<mat-select matInput
[formControl]="stateControl">
@for (item of states; track $index) {
<mat-option [value]="item.value">{{item.label}}</mat-option>
}
</mat-select>
</mat-form-field>
</mat-card-content>
<mat-card-actions class="actions">
<button mat-raised-button
(click)="dialogRef.close()">Cancel</button>
<button mat-raised-button
disabled="{{pathControl.invalid || waitForResponse}}"
(click)="submit()">{{data.isNew ? "Add" : "Save"}}</button>
</mat-card-actions>
@if (responseError) {
<mat-card-footer>
<small class="error below">{{responseError}}</small>
</mat-card-footer>
}
</mat-card>

View File

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

View File

@ -0,0 +1,99 @@
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 { HermesClientService } from '../../hermes-client.service';
import { Group } from '../../shared/models/group';
import { Permission } from '../../shared/models/permission';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { PolicyDropdownComponent } from "../../policies/policy-dropdown/policy-dropdown.component";
@Component({
selector: 'permission-item-edit',
imports: [
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule,
PolicyDropdownComponent,
],
templateUrl: './permission-item-edit.component.html',
styleUrl: './permission-item-edit.component.scss'
})
export class PermissionItemEditComponent implements OnInit {
private readonly client = inject(HermesClientService);
readonly data = inject<{ permission: Permission, group: Group, groups: Group[], isNew: boolean }>(MAT_DIALOG_DATA);
readonly dialogRef = inject(MatDialogRef<PermissionItemEditComponent>);
readonly pathControl = new FormControl('', [Validators.required]);
readonly stateControl = new FormControl<boolean | null>(null, [Validators.required]);
private form = new FormGroup({
path: this.pathControl,
state: this.stateControl,
});
readonly states: { label: string, value: boolean | null }[] = [
{
label: 'Allow', value: true
},
{
label: 'Deny', value: false
},
]
responseError: string | undefined;
waitForResponse = false;
ngOnInit() {
this.pathControl.setValue(this.data.permission.path);
this.stateControl.setValue(this.data.permission.allow);
}
submit() {
if (this.form.invalid || this.waitForResponse) {
return;
}
this.waitForResponse = true;
this.responseError = undefined;
if (this.data.isNew) {
this.client.first((d: any) => d.op == 4 && d.d.request.type == 'create_group_permission' && d.d.request.data.path == this.pathControl.value)
.subscribe({
next: (d) => {
console.log('sdifhsdiofs data', d);
if (d.d.error) {
this.responseError = d.d.error;
} else {
this.dialogRef.close(d.d.data);
}
},
error: () => this.responseError = 'Something went wrong.',
complete: () => this.waitForResponse = false,
});
this.client.createGroupPermission(this.data.group.id, this.pathControl.value!, this.stateControl.value);
} else {
this.client.first((d: any) => d.op == 4 && d.d.request.type == 'update_group_permission' && d.d.request.data.id == this.data.permission.id)
.subscribe({
next: (d) => {
if (d.d.error) {
this.responseError = d.d.error;
} else {
this.dialogRef.close(d.d.data);
}
},
error: () => this.responseError = 'Something went wrong.',
complete: () => this.waitForResponse = false,
});
this.client.updateGroupPermission(this.data.permission.id, this.data.group.id, this.pathControl.value!, this.stateControl.value);
}
}
}

View File

@ -0,0 +1,20 @@
<section [class.enabled]="permission.allow == true"
[class.disabled]="permission.allow == false"
[class.inherited]="permission.allow == null">
<p>{{permission.path}}</p>
<div class="right">
<button mat-button
class="neutral"
[disabled]="opened || waitForResponse"
(click)="edit()">
<mat-icon>edit</mat-icon>Edit
</button>
<button mat-button
class="danger"
[disabled]="opened || waitForResponse"
(click)="delete()">
<mat-icon>delete</mat-icon>Delete
</button>
</div>
</section>

View File

@ -0,0 +1,39 @@
section {
padding: 1em;
}
.enabled {
border: 0.5em solid rgb(41, 255, 41);
border-top: 0;
border-right: 0;
border-bottom: 0;
}
.disabled {
border: 0.5em solid rgb(255, 41, 41);
border-top: 0;
border-right: 0;
border-bottom: 0;
}
.inherited {
border: 0.5em solid rgb(255, 255, 255);
border-top: 0;
border-right: 0;
border-bottom: 0;
}
p {
margin-left: 0.5em;
display: inline;
}
.right {
float: right;
display: inline;
padding-right: 1em;
}
.right .mat-mdc-button {
align-items: center;
}

View File

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

View File

@ -0,0 +1,79 @@
import { Component, inject, Input } from '@angular/core';
import { Permission } from '../../shared/models/permission';
import { Group } from '../../shared/models/group';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatDialog } from '@angular/material/dialog';
import { HermesClientService } from '../../hermes-client.service';
import EventService from '../../shared/services/EventService';
import { PermissionItemEditComponent } from '../permission-item-edit/permission-item-edit.component';
@Component({
selector: 'permission-item',
imports: [
MatButtonModule,
MatIconModule,
],
templateUrl: './permission-item.component.html',
styleUrl: './permission-item.component.scss'
})
export class PermissionItemComponent {
@Input({ required: true }) permission: Permission = { id: '', group_id: '', user_id: '', path: '', allow: null };
@Input({ required: true }) group: Group | undefined;
readonly dialog = inject(MatDialog);
readonly client = inject(HermesClientService);
readonly events = inject(EventService);
responseError: string | undefined;
waitForResponse = false;
opened = false;
delete() {
if (this.opened || this.waitForResponse) {
return;
}
this.waitForResponse = true;
this.responseError = undefined;
const permissionId = this.permission.id;
this.client.first((d: any) => d.op == 4 && d.d.request.type == 'delete_group_permission' && d.d.request.data.id == permissionId)
.subscribe({
next: (d) => {
if (d.d.error) {
this.responseError = d.d.error;
} else {
this.events.emit('delete_group_permission', permissionId);
}
},
error: () => this.responseError = 'Something went wrong.',
complete: () => this.waitForResponse = false,
});
this.client.deleteGroupPermission(this.permission.id);
}
edit() {
if (this.opened || this.waitForResponse) {
return;
}
this.opened = true;
const dialogRef = this.dialog.open(PermissionItemEditComponent, {
data: { permission: this.permission, group: this.group, groups: [], isNew: false },
});
dialogRef.afterClosed().subscribe((permission: Permission) => {
this.opened = false;
if (!permission)
return;
this.permission.group_id = permission.group_id;
this.permission.path = permission.path;
this.permission.allow = permission.allow;
});
}
}

View File

@ -0,0 +1,29 @@
<ul>
<li class="header">
<mat-form-field appearance="outline"
subscriptSizing="dynamic">
<mat-label>Filter</mat-label>
<input matInput
placeholder="Filter group permissions"
[formControl]="searchControl" />
</mat-form-field>
<button mat-icon-button
(click)="add()">
<mat-icon>add</mat-icon>
</button>
</li>
@for (permission of permissions; track permission.id) {
<li>
<permission-item [permission]="permission"
[group]="group || getGroupById(permission.group_id)" />
</li>
}
@if (!permissions.length) {
@if (searchControl.value) {
<p class="notice">No permission matches the filter.</p>
} @else {
<p class="notice">This group has no permissions. Cannot do anything.</p>
}
}
</ul>

View File

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

View File

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

View File

@ -0,0 +1,65 @@
import { Component, inject, Input } from '@angular/core';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { containsLettersInOrder } from '../../shared/utils/string-compare';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { HermesClientService } from '../../hermes-client.service';
import { Group } from '../../shared/models/group';
import EventService from '../../shared/services/EventService';
import { PermissionItemComponent } from '../permission-item/permission-item.component';
import { Permission } from '../../shared/models/permission';
import { PermissionItemEditComponent } from '../permission-item-edit/permission-item-edit.component';
@Component({
selector: 'permission-list',
imports: [
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
ReactiveFormsModule,
PermissionItemComponent,
],
templateUrl: './permission-list.component.html',
styleUrl: './permission-list.component.scss'
})
export class PermissionListComponent {
@Input({ required: true }) groups: Group[] = [];
@Input({ alias: 'permissions', required: true }) _permissions: Permission[] = [];
@Input() group: Group | undefined;
readonly dialog = inject(MatDialog);
readonly client = inject(HermesClientService);
readonly events = inject(EventService);
readonly searchControl = new FormControl<string>('');
opened = false;
add() {
if (!this.group || this.opened) {
return;
}
this.opened = true;
const groupId = this.group.id;
const dialogRef = this.dialog.open(PermissionItemEditComponent, {
data: { permission: { id: '', user_id: '', group_id: groupId, path: '', allow: null }, group: this.group, groups: [], isNew: true },
});
dialogRef.afterClosed().subscribe((permission: Permission) => this.opened = false);
}
get permissions() {
return this._permissions.filter(p => containsLettersInOrder(p.path, this.searchControl.value));
}
getGroupById(groupId: string) {
return this.groups.find(g => g.id == groupId);
}
}

View File

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

View File

@ -25,7 +25,7 @@ const Policies = [
{ path: "tts.commands.version", description: "To use !version command" },
{ path: "tts.commands.voice", description: "To use !voice command" },
{ path: "tts.commands.voice.admin", description: "To use !voice command on others" },
]
];
@Component({
selector: 'policy-dropdown',
@ -42,7 +42,7 @@ const Policies = [
})
export class PolicyDropdownComponent {
@Input() policy: string | null = '';
policyControl = new FormControl('', [Validators.required]);
@Input({ alias: 'control' }) policyControl = new FormControl('', [Validators.required]);
filteredPolicies: Observable<string[]>;
constructor() {

View File

@ -45,9 +45,10 @@
<td mat-cell
*matCellDef="let policy">
<button mat-button
class="neutral"
(click)="edit(policy)"><mat-icon>edit</mat-icon>Edit</button>
<button mat-button
class="delete"
class="danger"
(click)="delete(policy)"><mat-icon>delete</mat-icon>Delete</button>
</td>
</ng-container>

View File

@ -0,0 +1,7 @@
export interface Permission {
id: string;
user_id: string;
group_id: string;
path: string;
allow: boolean | null;
}

View File

@ -0,0 +1,14 @@
import { Injectable } from '@angular/core';
import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { Permission } from '../models/permission';
import { PermissionService } from '../services/permission.service';
@Injectable({ providedIn: 'root' })
export default class PermissionResolver implements Resolve<Permission[]> {
constructor(private service: PermissionService) { }
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Permission[]> {
return this.service.fetch();
}
}

View File

@ -51,7 +51,7 @@ export default class GroupService {
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.deleteChatter$?.subscribe(d => this.chatters = this.chatters.filter(r => r.group_id != d.request.data.group && r.chatter_id != d.request.data.chatter));
this.events.listen('tts_logoff', () => {
this.groups = [];

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { PermissionService } from './permission.service';
describe('PermissionService', () => {
let service: PermissionService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(PermissionService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,72 @@
import { inject, Injectable } from '@angular/core';
import { HermesClientService } from '../../hermes-client.service';
import EventService from './EventService';
import { Permission } from '../models/permission';
import { map, Observable, of } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class PermissionService {
private readonly client = inject(HermesClientService);
private readonly events = inject(EventService);
private data: Permission[] = [];
private loaded = false;
create$: Observable<any> | undefined;
update$: Observable<any> | undefined;
delete$: Observable<any> | undefined;
constructor() {
this.create$ = this.client.filterByRequestType('create_group_permission');
this.update$ = this.client.filterByRequestType('update_group_permission');
this.delete$ = this.client.filterByRequestType('delete_group_permission');
this.create$?.subscribe(d => {
if (d.error) {
return;
}
this.data.push(d.data);
});
this.update$?.subscribe(d => {
if (d.error) {
return;
}
const permission = this.data.find(p => p.id == d.data.id);
if (permission) {
permission.group_id = d.data.group_id;
permission.path = d.data.path;
permission.allow = d.data.allow;
permission.user_id = d.data.user_id;
}
});
this.delete$?.subscribe(d => {
if (d.error) {
return;
}
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_group_permissions')!.pipe(map(d => d.d.data));
$.subscribe(d => {
this.data = d;
this.loaded = true;
});
this.client.fetchPermissions();
return $;
}
}

View File

@ -1,6 +1,10 @@
export function containsLettersInOrder(value: string, inside: string): boolean {
export function containsLettersInOrder(value: string | null, inside: string | null): boolean {
if (!inside)
return true;
if (!value)
return false;
return containsLettersInOrderInternal(value, inside, 0, 0);
}

View File

@ -74,7 +74,7 @@ export class TwitchUserItemAddComponent implements OnInit {
error: () => this.responseError = 'Something went wrong.',
complete: () => this.waitForResponse = false,
});
this.client.createGroupChatter(this.data.group.id, response.user.id, response.user.login)
this.client.createGroupChatter(this.data.group.id, response.user.id, response.user.login);
});
}
}

View File

@ -1,5 +1,6 @@
<div>
<button mat-icon-button
[disabled]="waitForResponse"
(click)="delete()">
<mat-icon>remove</mat-icon>
</button>

View File

@ -21,18 +21,28 @@ export class TwitchUserItemComponent {
private readonly _client = inject(HermesClientService);
private readonly _events = inject(EventService);
private _deleted = false;
waitForResponse = false;
responseError: string | undefined;
delete() {
if (this._deleted)
if (this.waitForResponse)
return;
this._deleted = true;
this.waitForResponse = true;
this.responseError = undefined;
this._client.first(d => d.d.request.type == 'delete_group_chatter' && d.d.request.data.group == this.user.group_id && d.d.request.data.chatter == this.user.chatter_id)
.subscribe(async (response) => {
console.log('delete group chatter', response)
.subscribe({
next: (d) => {
if (d.d.error) {
this.responseError = d.d.error;
} else {
this._events.emit('delete_group_chatter', this.user);
}
},
error: () => this.responseError = 'Something went wrong.',
complete: () => this.waitForResponse = false,
});
this._client.deleteGroupChatter(this.user.group_id, this.user.chatter_id.toString());
}

View File

@ -8,10 +8,8 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { containsLettersInOrder } from '../../shared/utils/string-compare';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { HermesClientService } from '../../hermes-client.service';
import { Group } from '../../shared/models/group';
import { TwitchUserItemAddComponent } from '../twitch-user-item-add/twitch-user-item-add.component';
import EventService from '../../shared/services/EventService';
@Component({
selector: 'twitch-user-list',
@ -31,17 +29,10 @@ export class TwitchUserListComponent {
@Input() group: Group | undefined;
readonly dialog = inject(MatDialog);
readonly client = inject(HermesClientService);
readonly events = inject(EventService);
readonly searchControl: FormControl = new FormControl('');
opened = false;
constructor() {
this.events.listen('delete_group_chatter', (chatter: GroupChatter) => {
this.twitchUsers.splice(this.twitchUsers.findIndex(c => c.group_id == chatter.group_id && c.chatter_id == chatter.chatter_id), 1);
});
}
get users(): GroupChatter[] {
return this.twitchUsers.filter(u => containsLettersInOrder(u.chatter_label, this.searchControl.value));
@ -57,12 +48,6 @@ export class TwitchUserListComponent {
data: { username: this.searchControl.value, group: this.group },
});
dialogRef.afterClosed().subscribe((chatter: GroupChatter) => {
this.opened = false;
if (!chatter)
return;
this.twitchUsers.push(chatter);
});
dialogRef.afterClosed().subscribe((chatter: GroupChatter) => this.opened = false);
}
}

View File

@ -1,5 +1,7 @@
/* You can add global styles to this file, and also import other style files */
@use '@angular/material' as mat;
html,
body {
height: 100%;
@ -35,3 +37,44 @@ body {
::-webkit-scrollbar-thumb:hover {
background: rgb(122, 122, 122);
}
.mat-small {
@include mat.all-component-densities(-5);
}
.mat-large {
@include mat.all-component-densities(5);
}
.confirm {
@include mat.button-overrides((text-state-layer-color: rgb(52, 255, 62),
text-label-text-color: rgb(71, 218, 78),
text-disabled-label-text-color: rgb(71, 218, 78),
));
}
.neutral {
@include mat.button-overrides((text-state-layer-color: rgb(64, 141, 255),
text-label-text-color: rgb(52, 106, 255),
text-disabled-label-text-color: rgb(52, 106, 255),
));
}
.warning {
@include mat.button-overrides((text-state-layer-color: rgb(255, 172, 63),
text-label-text-color: rgb(255, 145, 19),
text-disabled-label-text-color: rgb(255, 145, 19),
));
}
.danger {
@include mat.button-overrides((text-state-layer-color: rgb(255, 48, 48),
text-label-text-color: rgb(255, 52, 52),
text-disabled-label-text-color: rgb(255, 52, 52),
filled-label-text-color: rgb(255, 52, 52),
outlined-label-text-color: rgb(255, 52, 52),
protected-label-text-color: rgb(255, 52, 52),
protected-state-layer-color: rgb(255, 75, 75),
protected-ripple-color: rgb(255, 154, 154),
));
}