Compare commits

..

28 Commits

Author SHA1 Message Date
Tom
9338e7e624 Fixed impersonation not updating the UI correctly. Self is selected and shown by default. Using async pipes & custom pipe. 2025-04-10 12:31:05 +00:00
Tom
daa500111c Cleaned up Redemptions with use of AsyncPipe & input transformers. 2025-04-09 21:16:42 +00:00
Tom
b0f9a2dea8 Cleaned up TTS Filters. Fixed the service when updating a filter. 2025-04-08 13:41:34 +00:00
Tom
931046cbb3 Cleaned up code for redeemable actions. 2025-04-08 12:57:08 +00:00
Tom
01c62bc143 Added noindex,nofollow tags for robots. 2025-04-08 12:53:59 +00:00
Tom
f4511157a5 Improved the code for handling policies. 2025-04-07 21:09:24 +00:00
Tom
f2c5178e82 Fixed storing JWT in local storage. 2025-04-07 18:49:15 +00:00
Tom
7048a7c46c Fixed background styling in specific cases. 2025-04-07 16:51:17 +00:00
Tom
0a511f1424 Minor changes for updating JWT token. 2025-04-07 15:07:36 +00:00
Tom
b8a92534d9 Added login/logout button to topbar. 2025-04-07 14:27:46 +00:00
Tom
3e9a9f9dc5 Fixed issues with impersonation. Show warning or error depending on connection's remaining time before expiry. Replaced use of /api/... in urls. 2025-04-05 02:06:31 +00:00
Tom
70e0e9bf71 Fixed connection coloring. 2025-04-02 20:11:02 +00:00
Tom
b465f0a474 Fixed API keys not swapping properly when impersonating. 2025-04-02 19:56:21 +00:00
Tom
fcf1e9ac03 Added checks for non-dirty forms when saving/adding. 2025-04-02 16:35:41 +00:00
Tom
1e6690ff4b Added theme to local storage for saving/loading. 2025-04-02 16:27:06 +00:00
Tom
5489eb4df6 Fixed styling issues. Fixed dialog themes after changing theme. 2025-04-02 15:57:54 +00:00
Tom
e053529d49 Added theme button in top bar. 2025-04-01 21:29:05 +00:00
Tom
d69fc68ec1 Fixed visibility of top bar's impersonation. 2025-04-01 21:20:01 +00:00
Tom
d011571164 Removed useless console logs. 2025-04-01 21:12:56 +00:00
Tom
055885837c Added top bar on all pages. Simplified TTS login component. Fixed some issues. Removed redirects for now. 2025-04-01 21:12:01 +00:00
Tom
d44ec50a6a Added light & dark theme. Basic theme applied on dialogs/modals. Attempt to apply uniformity to the website. 2025-03-31 16:59:39 +00:00
Tom
ea19375ad2 Updated angular packages. 2025-03-28 13:53:48 +00:00
Tom
298d351e5d Added API keys. Minor modifications for other views. 2025-03-27 16:31:07 +00:00
Tom
b1bac758e3 Added a scuffed redirect to prior page for impersonation. Fixed some conditions for websocket message checks. 2025-03-27 11:31:36 +00:00
Tom
6e5efab5ec Added connections. Added url redirect for login. 2025-03-27 01:25:56 +00:00
Tom
56deb3384c Moved common css into styles. 2025-03-22 22:07:56 +00:00
Tom
9de4424736 Added group permissions. Added some global styles. Made groups rely on services' data. 2025-03-22 21:58:30 +00:00
Tom
d19c5445d6 Removed users module. 2025-03-20 19:31:25 +00:00
199 changed files with 17888 additions and 15605 deletions

View File

@ -47,8 +47,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "1024kB",
"maximumError": "1MB"
"maximumWarning": "3MB",
"maximumError": "5MB"
},
{
"type": "anyComponentStyle",
@ -91,7 +91,10 @@
"buildTarget": "hermes-web-angular:build:development"
}
},
"defaultConfiguration": "development"
"defaultConfiguration": "development",
"options": {
"allowedHosts": ["beta.tomtospeech.com"]
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"

28113
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,50 +1,50 @@
{
"name": "hermes-web-angular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve -c production --host 0.0.0.0 --watch false",
"build": "ng build",
"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"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.0.5",
"@angular/cdk": "^19.0.4",
"@angular/common": "^19.0.5",
"@angular/compiler": "^19.0.5",
"@angular/core": "^19.0.5",
"@angular/forms": "^19.0.5",
"@angular/material": "^19.0.4",
"@angular/platform-browser": "^19.0.5",
"@angular/platform-browser-dynamic": "^19.0.5",
"@angular/platform-server": "^19.0.5",
"@angular/router": "^19.0.5",
"@angular/ssr": "^19.0.6",
"angular-oauth2-oidc": "^17.0.2",
"express": "^4.18.2",
"ngx-socket-io": "^4.7.0",
"rxjs": "~7.8.0",
"rxjs-websockets": "^9.0.0",
"tslib": "^2.3.0",
"uuidv4": "^6.2.13",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.6",
"@angular/cli": "^19.0.6",
"@angular/compiler-cli": "^19.0.5",
"@types/express": "^4.17.17",
"@types/jasmine": "~5.1.0",
"@types/node": "^18.18.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.6.3"
}
}
"name": "hermes-web-angular",
"version": "0.0.0",
"scripts": {
"start": "ng serve -c development --host 0.0.0.0 --watch false",
"build": "ng build",
"watch": "ng serve -c development --host 0.0.0.0 --disable-host-check",
"test": "ng test",
"serve:ssr:hermes-web-angular": "node dist/hermes-web-angular/server/server.mjs"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.4",
"@angular/cdk": "^19.2.7",
"@angular/common": "^19.2.4",
"@angular/compiler": "^19.2.4",
"@angular/core": "^19.2.4",
"@angular/forms": "^19.2.4",
"@angular/material": "^19.2.7",
"@angular/platform-browser": "^19.2.4",
"@angular/platform-browser-dynamic": "^19.2.4",
"@angular/platform-server": "^19.2.4",
"@angular/router": "^19.2.4",
"@angular/ssr": "^19.2.5",
"angular-oauth2-oidc": "^17.0.2",
"express": "^4.18.2",
"moment": "^2.30.1",
"ngx-socket-io": "^4.7.0",
"rxjs": "~7.8.0",
"rxjs-websockets": "^9.0.0",
"tslib": "^2.3.0",
"uuidv4": "^6.2.13",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.5",
"@angular/cli": "^19.2.5",
"@angular/compiler-cli": "^19.2.4",
"@types/express": "^4.17.17",
"@types/jasmine": "~5.1.0",
"@types/node": "^18.18.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.6.3"
}
}

View File

@ -1,4 +0,0 @@
.error {
display: block;
color: #ba1a1a;
}

View File

@ -1,9 +1,8 @@
import { Component, EventEmitter, inject, input, Input, OnInit, Output } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { Component, EventEmitter, 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 RedeemableAction from '../../shared/models/redeemable-action';
@Component({
@ -21,20 +20,9 @@ export class ActionDropdownComponent implements OnInit {
@Input() action: string | undefined;
@Output() readonly actionChange = new EventEmitter<string>();
private readonly route = inject(ActivatedRoute);
errorMessageKeys: string[] = []
constructor() {
this.route.data.subscribe(data => {
if (!data['redeemableActions'])
return;
this.actions = data['redeemableActions'];
});
}
ngOnInit(): void {
this.errorMessageKeys = Object.keys(this.errorMessages);

View File

@ -1,4 +1,4 @@
<body>
<content>
<mat-card>
<mat-card-header>
<mat-card-title-group>
@ -108,18 +108,26 @@
}
</mat-card-content>
<mat-card-actions class="actions"
align="end">
<mat-card-actions class="actions">
@if (!isNew) {
<button mat-raised-button
class="delete"
(click)="deleteAction(action)">Delete</button>
<button mat-button
class="danger"
(click)="deleteAction(action)">
<mat-icon>delete</mat-icon>Delete
</button>
}
<button mat-raised-button
(click)="dialogRef.close()">Cancel</button>
<button mat-raised-button
<button mat-button
class="neutral"
disabled="{{waitForResponse}}"
(click)="dialogRef.close()">
<mat-icon>cancel</mat-icon>Cancel
</button>
<button mat-button
class="confirm"
disabled="{{!formsDirty || !formsValidity || waitForResponse}}"
(click)="save()">Save</button>
(click)="save()">
<mat-icon>save</mat-icon>Save
</button>
</mat-card-actions>
@if (responseError) {
@ -128,4 +136,4 @@
</mat-card-footer>
}
</mat-card>
</body>
</content>

View File

@ -9,25 +9,6 @@
margin: 0;
}
.error {
display: block;
color: #ba1a1a;
}
.below {
display: block;
justify-self: center;
align-items: center;
align-self: center;
text-align: center;
}
.actions {
display: flex;
flex-direction: row;
justify-content: center;
}
.delete {
background-color: #ea5151;
color: #ba1a1a;

View File

@ -9,27 +9,31 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { createItemExistsInArrayValidator } from '../../shared/validators/item-exists-in-array';
import { HermesClientService } from '../../hermes-client.service';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'action-item-edit',
imports: [
ReactiveFormsModule,
MatButtonModule,
MatCardModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule
MatSelectModule,
ReactiveFormsModule,
],
templateUrl: './action-item-edit.component.html',
styleUrl: './action-item-edit.component.scss'
})
export class ActionItemEditComponent implements OnInit {
private readonly client = inject(HermesClientService);
readonly dialogRef = inject(MatDialogRef<ActionItemEditComponent>);
private readonly data = inject<{ action: RedeemableAction, actions: RedeemableAction[] }>(MAT_DIALOG_DATA);
readonly dialogRef = inject(MatDialogRef<ActionItemEditComponent>);
action = this.data.action;
actions = this.data.actions;
readonly actionEntries: ({ [key: string]: any[] }) = {
'SLEEP': [
{
@ -257,7 +261,7 @@ export class ActionItemEditComponent implements OnInit {
}
save(): void {
if (this.formGroup.invalid || this.waitForResponse) {
if (!this.formGroup.dirty || this.formGroup.invalid || this.waitForResponse) {
return;
}
@ -270,18 +274,18 @@ export class ActionItemEditComponent implements OnInit {
return;
}
this.action.name = this.formGroup.get('name')!.value!;
this.action.type = this.formGroup.get('type')!.value!;
this.action.data = {}
for (const entry of this.actionEntries[this.action.type]) {
this.action.data[entry.key] = entry.control.value!.toString();
}
if (!(this.action.type in this.actionEntries)) {
this.waitForResponse = false;
return;
}
this.action.name = this.formGroup.get('name')!.value!;
this.action.type = this.formGroup.get('type')!.value!;
this.action.data = {};
for (const entry of this.actionEntries[this.action.type]) {
this.action.data[entry.key] = entry.control.value!.toString();
}
const isNewAction = !this.action.user_id;
const requestType = isNewAction ? 'create_redeemable_action' : 'update_redeemable_action';
this.client.first((d: any) => d.op == 4 && d.d.request.type == requestType && d.d.data.name == this.action.name)
@ -297,8 +301,8 @@ export class ActionItemEditComponent implements OnInit {
complete: () => this.waitForResponse = false,
});
if (isNewAction)
this.client.createRedeemableAction(this.action.name, this.action.type, this.action.data);
this.client.createRedeemableAction(this.action.name, this.action.type, false, this.action.data);
else
this.client.updateRedeemableAction(this.action.name, this.action.type, this.action.data);
this.client.updateRedeemableAction(this.action.name, this.action.type, false, this.action.data);
}
}

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

@ -1,3 +1,5 @@
@use '@angular/material' as mat;
main {
display: grid;
grid-template-columns: repeat(1, 1fr);
@ -15,7 +17,7 @@ main {
border: 1px solid grey;
padding: 1em;
cursor: pointer;
background-color: white;
background-color: transparent;
& span {
display: block;
@ -27,7 +29,6 @@ main {
& .subtitle {
font-size: smaller;
color: lightgrey;
}
}
}

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, inject, Input, Output } from '@angular/core';
import { Component, inject, input } from '@angular/core';
import { MatListModule } from '@angular/material/list';
import RedeemableAction from '../../shared/models/redeemable-action';
import { MatButtonModule } from '@angular/material/button';
@ -7,23 +7,30 @@ import { MatIconModule } from '@angular/material/icon';
import { MatDialog } from '@angular/material/dialog';
import { ActionItemEditComponent } from '../action-item-edit/action-item-edit.component';
import { HermesClientService } from '../../hermes-client.service';
import { MatSelectModule } from '@angular/material/select';
@Component({
selector: 'action-list',
standalone: true,
imports: [MatButtonModule, MatFormFieldModule, MatIconModule, MatListModule],
imports: [
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatListModule,
MatSelectModule,
],
templateUrl: './action-list.component.html',
styleUrl: './action-list.component.scss'
})
export class ActionListComponent {
@Input() actions: RedeemableAction[] = []
@Output() actionsChange = new EventEmitter<RedeemableAction>();
actions = input.required<RedeemableAction[]>({ alias: 'actions' });
readonly dialog = inject(MatDialog);
readonly client = inject(HermesClientService);
opened = false;
create(): void {
this.openDialog({ user_id: '', name: '', type: '', data: {} });
this.openDialog({ user_id: '', name: '', type: '', has_message: false, data: {} });
}
modify(action: RedeemableAction): void {
@ -31,27 +38,8 @@ export class ActionListComponent {
}
private openDialog(action: RedeemableAction): void {
if (this.opened)
return;
this.opened = true;
const dialogRef = this.dialog.open(ActionItemEditComponent, {
data: { action: { user_id: action.user_id, name: action.name, type: action.type, data: action.data }, actions: this.actions },
});
const isNewAction = action.name.length <= 0;
dialogRef.afterClosed().subscribe((result: RedeemableAction | undefined) => {
this.opened = false;
if (!result)
return;
if (isNewAction) {
this.actionsChange.emit(result);
} else {
action.type = result.type;
action.data = result.data;
}
this.dialog.open(ActionItemEditComponent, {
data: { action: { user_id: action.user_id, name: action.name, type: action.type, data: action.data }, actions: this.actions() },
});
}
}

View File

@ -1,34 +1,32 @@
<body>
<content>
<h3>Redeemable Actions</h3>
<section>
<article>
<mat-form-field>
<mat-form-field subscriptSizing="dynamic">
<mat-label>Filter by type</mat-label>
<mat-select (selectionChange)="onFilterChange($event.value)"
value="0">
<mat-select value="0"
(selectionChange)="filter = filters[$event.value]">
<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>
</mat-form-field>
</article>
<article>
<mat-form-field>
<mat-form-field subscriptSizing="dynamic">
<mat-label>Search</mat-label>
<input matInput
type="text"
placeholder="Name of action"
[formControl]="searchControl"
[(ngModel)]="search">
[formControl]="searchControl" />
<mat-icon matPrefix>search</mat-icon>
</mat-form-field>
</article>
</section>
<action-list class="center"
[actions]="actions"
(actionsChange)="items.push($event)" />
</body>
<action-list class="list center"
[actions]="actions" />
</content>

View File

@ -1,12 +1,12 @@
body,
content,
h3 {
padding: 0;
margin: 0;
}
body {
height: 100vh;
overflow: auto;
content {
display: flex;
flex-direction: column;
margin-top: 3em;
}
@ -19,8 +19,7 @@ section {
display: flex;
justify-content: space-between;
width: 70%;
margin-left: auto;
margin-right: auto;
margin: 0 auto;
@media (max-width:1250px) {
display: block;
@ -30,6 +29,7 @@ section {
article {
display: flex;
justify-content: space-around;
margin: 1em 0;
}
}

View File

@ -1,4 +1,4 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { ActionListComponent } from "../action-list/action-list.component";
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -9,6 +9,9 @@ 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';
import { MatButtonModule } from '@angular/material/button';
import { containsLettersInOrder } from '../../shared/utils/string-compare';
import { Subscription } from 'rxjs';
interface IActionFilter {
name: string
@ -17,19 +20,20 @@ interface IActionFilter {
@Component({
selector: 'actions',
standalone: true,
imports: [
ActionListComponent,
ReactiveFormsModule,
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule
MatSelectModule,
ReactiveFormsModule,
ReactiveFormsModule,
],
templateUrl: './actions.component.html',
styleUrl: './actions.component.scss'
})
export class ActionsComponent implements OnInit {
export class ActionsComponent implements OnInit, OnDestroy {
filters: IActionFilter[] = [
{ name: 'All', filter: _ => true },
{ name: 'Local File', filter: data => data.type.includes('_FILE') },
@ -43,55 +47,29 @@ export class ActionsComponent implements OnInit {
private readonly client = inject(HermesClientService);
private readonly redeemableActionService = inject(RedeemableActionService);
private readonly route = inject(ActivatedRoute);
private readonly subscriptions: (Subscription | undefined)[] = [];
filter = this.filters[0];
searchControl = new FormControl('');
search = '';
items: RedeemableAction[] = [];
searchControl = new FormControl<string>('');
_actions: RedeemableAction[] = [];
ngOnInit(): void {
this.route.data.subscribe(data => {
if (!data['redeemableActions'])
return;
this.route.data.subscribe(data => this._actions = data['redeemableActions'] || []);
this.actions = [...data['redeemableActions']];
});
this.redeemableActionService.create$?.subscribe(d => {
if (d.error || d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id))
return;
this.actions.push(d.data);
});
this.redeemableActionService.update$?.subscribe(d => {
if (d.error || d.request.nounce != null && d.request.nounce.startsWith(this.client.session_id))
return;
const action = this.actions.find(a => a.name == d.data.name);
if (action) {
action.type = d.data.type;
action.data = d.data.data;
}
});
this.redeemableActionService.delete$?.subscribe(d => {
if (d.error)
return;
this.items = this.actions.filter(a => a.name != d.request.data.name);
});
this.subscriptions.push(this.redeemableActionService.changes$?.subscribe(a => this._actions = a));
this.client.fetchRedeemableActions();
}
ngOnDestroy(): void {
this.subscriptions.filter(s => s).forEach(s => s?.unsubscribe());
}
get actions(): RedeemableAction[] {
const searchLower = this.search.toLowerCase();
return this.items.filter(this.filter.filter)
.filter((action) => action.name.toLowerCase().includes(searchLower));
}
set actions(value) {
this.items = value;
}
onFilterChange(event: any): void {
this.filter = this.filters[event];
const searchLower = this.searchControl.value!.toLowerCase();
return this._actions.filter(this.filter.filter)
.filter((action) => containsLettersInOrder(action.name.toLowerCase(), searchLower));
}
}

View File

@ -1,4 +1,11 @@
<main class="main">
<navigation class="navigation" />
<router-outlet class="content" />
<main>
<topbar class="top" />
<div class="below-topbar"
[class.grid]="isSidebarOpen"
[class.full]="!isSidebarOpen">
@if (isSidebarOpen) {
<sidebar class="navigation" />
}
<router-outlet class="content" />
</div>
</main>

View File

@ -1,4 +1,8 @@
.main {
.grid {
display: grid;
grid-template-columns: 20em 0px 1fr;
}
.full {
margin: 0 auto;
}

View File

@ -1,50 +1,95 @@
import { isPlatformBrowser } from '@angular/common';
import { Component, OnInit, Inject, PLATFORM_ID, NgZone, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { Component, OnInit, Inject, PLATFORM_ID, NgZone, OnDestroy, inject, HostBinding } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
import { HermesClientService } from './hermes-client.service';
import { AuthUserGuard } from './shared/auth/auth.user.guard'
import { first, Subscription, timeout } from 'rxjs';
import { NavigationComponent } from "./navigation/navigation.component";
import EventService from './shared/services/EventService';
import { ApiAuthenticationService } from './shared/services/api/api-authentication.service';
import { AuthModule } from './auth/auth.module';
import { ApiKeyService } from './shared/services/api/api-key.service';
import { ThemeService } from './shared/services/theme.service';
import { OverlayContainer } from '@angular/cdk/overlay';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { SidebarComponent } from "./navigation/sidebar/sidebar.component";
import { Topbar as TopbarComponent } from "./navigation/topbar/topbar.component";
import ApiKey from './shared/models/api-key';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, AuthModule, NavigationComponent],
imports: [
AuthModule,
RouterOutlet,
MatButtonModule,
MatIconModule,
MatToolbarModule,
SidebarComponent,
TopbarComponent,
],
providers: [AuthUserGuard],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent implements OnInit, OnDestroy {
private readonly keyService = inject(ApiKeyService);
private readonly overlayContainer = inject(OverlayContainer);
private readonly themeService = inject(ThemeService);
private isBrowser: boolean;
private ngZone: NgZone;
private subscriptions: Subscription[];
authentication = inject(ApiAuthenticationService);
isSidebarOpen: boolean = true
@HostBinding('class.dark-theme')
get isDarkTheme() {
return this.themeService.isDarkTheme();
}
@HostBinding('class.light-theme')
get isLightTheme() {
return this.themeService.isLightTheme();
}
constructor(private auth: ApiAuthenticationService, private client: HermesClientService, private events: EventService, private router: Router, ngZone: NgZone, @Inject(PLATFORM_ID) private platformId: Object) {
this.ngZone = ngZone;
this.isBrowser = isPlatformBrowser(this.platformId);
this.subscriptions = [];
this.subscriptions.push(this.events.listen('tts_login_ack', async _ => {
await this.router.navigate(['policies'])
const url = router.url;
const params = router.parseUrl(url).queryParams;
const redirect = params['rd'];
if (redirect && !(url.startsWith(redirect) || redirect.startsWith(url))) {
await this.router.navigate([redirect]);
} else if (url == '/' || url.startsWith('/login') || url.startsWith('/tts-login')) {
await this.router.navigate(['policies']);
}
}));
this.subscriptions.push(this.events.listen('tts_logoff', async _ => {
await this.router.navigate(['tts-login'])
this.addSubscription(this.events.listen('login', () => {
this.keyService.fetch()
.pipe(timeout(3000), first())
.subscribe(async (d: ApiKey[]) => {
if (d.length > 0)
this.client.login(d[0].id);
});
}));
this.subscriptions.push(this.events.listen('tts_logoff', async _ => await this.router.navigate(['tts-login'])));
this.subscriptions.push(this.events.listen('toggle_sidebar', () => this.isSidebarOpen = !this.isSidebarOpen))
}
ngOnInit(): void {
if (!this.isBrowser)
if (!isPlatformBrowser(this.platformId))
return;
this.auth.update();
this.auth.update(localStorage.getItem('jwt'));
this.subscriptions.push(this.events.listen('login', async () => await this.router.navigate(['tts-login'])));
this.addSubscription(this.events.listen('logoff', async (message) => {
localStorage.removeItem('jwt');
@ -58,15 +103,18 @@ export class AppComponent implements OnInit, OnDestroy {
}
}));
this.addSubscription(this.events.listen('login', () => {
this.keyService.fetch(true)
.pipe(timeout(3000), first())
.subscribe(async (d: ApiKey[]) => {
if (d.length > 0)
this.client.login(d[0].id);
else if (['/login', '/auth'].some(partial => document.location.href.includes(partial)))
await this.router.navigate(['tts-login']);
});
let currentTheme = localStorage.getItem('ui-theme') ?? this.themeService.theme;
if (currentTheme == 'light' || currentTheme == 'dark') {
this.themeService.theme = currentTheme;
} else {
this.themeService.theme = 'dark';
}
this.overlayContainer.getContainerElement().classList.add(this.themeService.theme + '-theme');
this.addSubscription(this.events.listen('theme_change', data => {
const classList = this.overlayContainer.getContainerElement().classList;
classList.remove(data.previous_theme + '-theme');
classList.add(data.current_theme + '-theme');
}));
this.ngZone.runOutsideAngular(() => setInterval(() => this.client.heartbeat(), 15000));
@ -74,7 +122,9 @@ export class AppComponent implements OnInit, OnDestroy {
ngOnDestroy() {
for (let s of this.subscriptions) {
s.unsubscribe();
if (s) {
s.unsubscribe();
}
}
}

View File

@ -17,6 +17,6 @@ export const appConfig: ApplicationConfig = {
}])
),
provideOAuthClient(),
provideClientHydration(), provideAnimationsAsync()
provideClientHydration(), provideAnimationsAsync(), provideAnimationsAsync()
]
};

13
src/app/app.module.ts Normal file
View File

@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatDialogModule } from '@angular/material/dialog';
import { TtsFiltersModule } from './tts-filters/tts-filters.module';
@NgModule({
declarations: [],
imports: [
CommonModule,
]
})
export class AppModule { }

View File

@ -2,7 +2,6 @@ import { Routes } from '@angular/router';
import { PolicyComponent } from './policies/policy/policy.component';
import { AuthUserGuard } from './shared/auth/auth.user.guard';
import { LoginComponent } from './auth/login/login.component';
import { TtsLoginComponent } from './auth/tts-login/tts-login.component';
import { TwitchAuthCallbackComponent } from './twitch-auth-callback/twitch-auth-callback.component';
import { FiltersComponent } from './tts-filters/filters/filters.component';
import { AuthAdminGuard } from './shared/auth/auth.admin.guard';
@ -19,36 +18,48 @@ 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';
import { ConnectionsComponent } from './connections/connections/connections.component';
import ConnectionResolver from './shared/resolvers/connection-resolver';
import { ConnectionCallbackComponent } from './connections/callback/callback.component';
import { KeysComponent } from './keys/keys/keys.component';
import { TtsLoginComponent } from './auth/tts-login/tts-login.component';
export const routes: Routes = [
{
path: 'policies',
component: PolicyComponent,
path: 'actions',
component: ActionsComponent,
canActivate: [AuthUserGuard],
resolve: {
redeemableActions: RedeemableActionResolver,
}
},
{
path: 'auth',
component: TwitchAuthCallbackComponent,
canActivate: [AuthVisitorGuard],
},
{
path: 'connections',
component: ConnectionsComponent,
canActivate: [AuthUserGuard],
resolve: {
connections: ConnectionResolver,
}
},
{
path: 'connections/callback',
component: ConnectionCallbackComponent,
},
{
path: 'groups',
component: GroupsComponent,
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,
permissions: PermissionResolver,
}
},
{
@ -60,11 +71,36 @@ export const routes: Routes = [
}
},
{
path: 'actions',
component: ActionsComponent,
path: 'groups/:id',
component: GroupPageComponent,
canActivate: [AuthUserGuard],
resolve: {
redeemableActions: RedeemableActionResolver,
groups: GroupResolver,
chatters: GroupChatterResolver,
policies: PolicyResolver,
permissions: PermissionResolver,
}
},
{
path: 'keys',
component: KeysComponent,
canActivate: [AuthUserGuard],
resolve: {
keys: ApiKeyResolver,
}
},
{
path: 'login',
component: LoginComponent,
canActivate: [AuthVisitorGuard],
},
{
path: 'policies',
component: PolicyComponent,
canActivate: [AuthUserGuard],
resolve: {
groups: GroupResolver,
policies: PolicyResolver,
}
},
{
@ -77,11 +113,6 @@ export const routes: Routes = [
twitchRedemptions: TwitchRedemptionResolver,
}
},
{
path: 'login',
component: LoginComponent,
canActivate: [AuthVisitorGuard],
},
{
path: 'tts-login',
component: TtsLoginComponent,
@ -90,9 +121,4 @@ export const routes: Routes = [
keys: ApiKeyResolver,
}
},
{
path: 'auth',
component: TwitchAuthCallbackComponent,
canActivate: [AuthVisitorGuard],
}
];

View File

@ -1,6 +1,5 @@
import { NgModule } from '@angular/core';
import { LoginComponent } from './login/login.component';
import { TtsLoginComponent } from './tts-login/tts-login.component';
import { ImpersonationComponent } from './impersonation/impersonation.component';
import { UserCardComponent } from './user-card/user-card.component';
@ -8,7 +7,6 @@ import { UserCardComponent } from './user-card/user-card.component';
declarations: [],
imports: [
LoginComponent,
TtsLoginComponent,
ImpersonationComponent,
UserCardComponent,
]

View File

@ -1,14 +1,12 @@
@if (isAdmin()) {
<main>
<mat-form-field>
<mat-label>User to impersonate</mat-label>
<mat-select (selectionChange)="onChange($event)"
[(value)]="impersonated">
<mat-option>{{getUsername()}}</mat-option>
@for (user of users; track user.id) {
<mat-option [value]="user.id">{{ user.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</main>
<mat-form-field class="mat-small"
subscriptSizing="dynamic">
<mat-label>User to impersonate</mat-label>
<mat-select [formControl]="impersonationControl">
<mat-option [value]="auth.getUserId()">{{getUsername()}}</mat-option>
@for (user of (users$ | async | excludeById : auth.getUserId()); track user.id) {
<mat-option [value]="user.id">{{ user.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}

View File

@ -2,5 +2,4 @@ main {
display: flex;
justify-content: center;
align-items: center;
margin-top: 1em;
}

View File

@ -1,62 +1,81 @@
import { Component, inject, Inject, OnInit, PLATFORM_ID } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import { ApiAuthenticationService } from '../../shared/services/api/api-authentication.service';
import { MatCardModule } from '@angular/material/card';
import { MatSelectModule } from '@angular/material/select';
import { HttpClient } from '@angular/common/http';
import { isPlatformBrowser } from '@angular/common';
import { environment } from '../../../environments/environment';
import EventService from '../../shared/services/EventService';
import { HermesClientService } from '../../hermes-client.service';
import { Router } from '@angular/router';
import { timeout, first } from 'rxjs';
import ApiKey from '../../shared/models/api-key';
import { ApiKeyService } from '../../shared/services/api/api-key.service';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { UserService } from '../../shared/services/user.service';
import { AsyncPipe } from '@angular/common';
import { ExcludeByIdPipe } from '../../shared/pipes/exclude-by-id.pipe';
@Component({
selector: 'impersonation',
standalone: true,
imports: [MatCardModule, MatSelectModule],
imports: [
AsyncPipe,
ExcludeByIdPipe,
MatCardModule,
MatSelectModule,
ReactiveFormsModule,
],
templateUrl: './impersonation.component.html',
styleUrl: './impersonation.component.scss'
})
export class ImpersonationComponent implements OnInit {
private readonly keyService = inject(ApiKeyService);
private readonly client = inject(HermesClientService);
private readonly userService = inject(UserService);
private readonly events = inject(EventService);
private readonly http = inject(HttpClient);
impersonated: string | undefined;
users: { id: string, name: string }[];
readonly auth = inject(ApiAuthenticationService);
constructor(private hermes: HermesClientService, private auth: ApiAuthenticationService, private router: Router, private events: EventService, private http: HttpClient, @Inject(PLATFORM_ID) private platformId: Object) {
this.users = []
}
impersonationControl = new FormControl<string>(this.auth.getUserId());
users$ = this.userService.fetch();
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId) || !this.auth.isAdmin()) {
if (!this.auth.isAdmin()) {
return;
}
this.http.get(environment.API_HOST + '/admin/users', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe((data: any) => {
this.users = data.filter((d: any) => d.name != this.auth.getUsername());
this.users$.subscribe(users => {
const id = this.auth.getImpersonatedId();
if (id && this.users.find(u => u.id == id)) {
this.impersonated = id;
if (id && users.find(u => u.id == id)) {
this.impersonationControl.setValue(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)]);
});
});
}
this.impersonationControl.valueChanges.subscribe((impersonationId) => {
if (impersonationId == this.auth.getImpersonatedId())
return;
if (impersonationId == this.auth.getUserId()) {
this.http.delete(environment.API_HOST + '/admin/impersonate', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
},
body: {
impersonation: impersonationId
}
}).subscribe(async (data: any) => {
this.client.disconnect(true);
this.events.emit('impersonation', impersonationId);
});
} else {
this.http.put(environment.API_HOST + '/admin/impersonate', {
impersonation: impersonationId
}, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe(async (data: any) => {
this.client.disconnect(true);
this.events.emit('impersonation', impersonationId);
});
}
});
}
public isAdmin() {
@ -66,35 +85,4 @@ export class ImpersonationComponent implements OnInit {
public getUsername() {
return this.auth.getUsername();
}
public onChange(e: any) {
if (!this.auth.isAdmin())
return;
if (!e.value) {
this.http.delete(environment.API_HOST + '/admin/impersonate', {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
},
body: {
impersonation: e.value
}
}).subscribe(async (data: any) => {
this.hermes.disconnect();
this.events.emit('impersonation', e.value);
});
} else {
this.http.put(environment.API_HOST + '/admin/impersonate', {
impersonation: e.value
}, {
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe(async (data: any) => {
this.hermes.disconnect();
this.events.emit('impersonation', e.value);
await this.router.navigate(['tts-login']);
});
}
}
}

View File

@ -0,0 +1,6 @@
<button mat-icon-button
class="neutral"
matTooltip="Navigate to the log in page"
(click)="login()">
<mat-icon>login</mat-icon>
</button>

View File

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

View File

@ -0,0 +1,28 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
@Component({
selector: 'login-button',
standalone: true,
imports: [
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatTooltipModule,
ReactiveFormsModule,
],
templateUrl: './login-button.component.html',
styleUrl: './login-button.component.scss'
})
export class LoginButtonComponent {
private readonly router = inject(Router);
login() {
this.router.navigate(['login']);
}
}

View File

@ -18,6 +18,7 @@
.mat-mdc-card-content {
align-self: center;
justify-content: space-between;
}
a {

View File

@ -1,32 +1,15 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { Router, RouterModule } from '@angular/router';
import { Subscription } from 'rxjs';
import { environment } from '../../../environments/environment';
@Component({
selector: 'login',
standalone: true,
imports: [MatCardModule, RouterModule],
imports: [MatCardModule],
templateUrl: './login.component.html',
styleUrl: './login.component.scss'
})
export class LoginComponent implements OnInit, OnDestroy {
subscription: Subscription | null;
constructor(private router: Router) {
this.subscription = null;
}
ngOnInit(): void {
}
ngOnDestroy(): void {
if (this.subscription)
this.subscription.unsubscribe()
}
export class LoginComponent {
login() {
document.location.replace(environment.API_HOST + '/auth');
}

View File

@ -0,0 +1,6 @@
<button mat-icon-button
class="danger"
matTooltip="Log off"
(click)="logoff()">
<mat-icon>logout</mat-icon>
</button>

View File

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

View File

@ -0,0 +1,28 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ReactiveFormsModule } from '@angular/forms';
import { ApiAuthenticationService } from '../../shared/services/api/api-authentication.service';
@Component({
selector: 'logoff-button',
standalone: true,
imports: [
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatTooltipModule,
ReactiveFormsModule,
],
templateUrl: './logoff-button.component.html',
styleUrl: './logoff-button.component.scss'
})
export class LogoffButtonComponent {
private readonly auth = inject(ApiAuthenticationService);
logoff() {
this.auth.logout();
}
}

View File

@ -9,15 +9,16 @@
<mat-card-content class="content">
<mat-form-field>
<mat-label>API Key</mat-label>
<mat-select [(value)]="selected_api_key">
<mat-select [formControl]="keyControl">
@for (key of api_keys; track key.id) {
<mat-option [value]="key.id">{{key.label}}</mat-option>
}
</mat-select>
</mat-form-field>
</mat-card-content>
<mat-card-actions align="end">
<mat-card-actions>
<button mat-raised-button
[disabled]="disabled"
(click)="login()">Log In</button>
</mat-card-actions>
</mat-card>

View File

@ -1,58 +1,71 @@
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatButtonModule } from '@angular/material/button';
import EventService from '../../shared/services/EventService';
import { ActivatedRoute } from '@angular/router';
import { first, Subscription, timeout } from 'rxjs';
import { HermesClientService } from '../../hermes-client.service';
import { MatCardModule } from '@angular/material/card';
import EventService from '../../shared/services/EventService';
import { Subscription } from 'rxjs';
import { ApiKeyService } from '../../shared/services/api/api-key.service';
@Component({
selector: 'tts-login',
standalone: true,
imports: [MatButtonModule, MatCardModule, MatFormFieldModule, MatSelectModule, MatInputModule, FormsModule],
imports: [
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatSelectModule,
ReactiveFormsModule,
],
templateUrl: './tts-login.component.html',
styleUrl: './tts-login.component.scss'
})
export class TtsLoginComponent implements OnInit, OnDestroy {
private readonly client = inject(HermesClientService);
private readonly keyService = inject(ApiKeyService);
private readonly events = inject(EventService);
private readonly route = inject(ActivatedRoute);
private readonly keyService = inject(ApiKeyService);
private readonly eventService = inject(EventService);
keyControl = new FormControl<string | null>('');
api_keys: { id: string, label: string }[] = [];
selected_api_key: string | undefined;
private subscriptions: Subscription[] = [];
subscriptions: (Subscription | null)[] = [];
disabled: boolean = false;
ngOnInit(): void {
this.route.data.subscribe(d => this.api_keys = d['keys']);
this.subscriptions.push(this.events.listen('tts_logoff', async _ => {
this.selected_api_key = undefined;
}));
this.subscriptions.push(this.events.listen('impersonation', _ => {
this.selected_api_key = undefined;
this.keyService.fetch(true)
.pipe(timeout(3000), first())
.subscribe(d => this.api_keys = d);
this.subscriptions.push(this.eventService.listen('impersonation', _ => this.reset()));
this.subscriptions.push(this.eventService.listen('logoff', impersonation => {
if (!impersonation)
this.reset();
}));
}
ngOnDestroy(): void {
this.subscriptions.forEach(s => s.unsubscribe());
for (let subscription of this.subscriptions) {
if (subscription) {
subscription.unsubscribe();
}
}
}
login(): void {
if (!this.selected_api_key)
if (!this.keyControl.value)
return;
this.client.login(this.selected_api_key);
this.client.login(this.keyControl.value);
}
private reset() {
this.disabled = true;
this.api_keys = [];
this.keyService.fetch().subscribe(keys => {
this.api_keys = keys;
this.disabled = false;
});
}
}

View File

@ -8,8 +8,7 @@
<mat-card-content>
<impersonation />
</mat-card-content>
<mat-card-actions class="actions"
align="end">
<mat-card-actions class="actions">
<div>
@if (isTTSLoggedIn) {
<button mat-raised-button

View File

@ -0,0 +1,3 @@
@if (success || failure) {
<p>Automatically going back to the connections page soon...</p>
}

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserItemComponent } from './user-item.component';
import { CallbackComponent } from './callback.component';
describe('UserItemComponent', () => {
let component: UserItemComponent;
let fixture: ComponentFixture<UserItemComponent>;
describe('CallbackComponent', () => {
let component: CallbackComponent;
let fixture: ComponentFixture<CallbackComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserItemComponent]
imports: [CallbackComponent]
})
.compileComponents();
fixture = TestBed.createComponent(UserItemComponent);
fixture = TestBed.createComponent(CallbackComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,52 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { HermesClientService } from '../../hermes-client.service';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../environments/environment';
@Component({
selector: 'connection-callback',
imports: [],
templateUrl: './callback.component.html',
styleUrl: './callback.component.scss'
})
export class ConnectionCallbackComponent implements OnInit {
private readonly client = inject(HermesClientService);
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
success: boolean = false;
failure: boolean = false;
async ngOnInit() {
const url = this.router.parseUrl(this.router.url);
if (!url.fragment) {
this.failure = true;
await this.router.navigate(['connections']);
return;
}
const paramsParts = url.fragment.split('&');
const params = Object.assign({}, ...paramsParts.map((p: string) => ({ [p.split('=')[0]]: p.split('=')[1] })));
if (!params.access_token || !params.scope || !params.state || !params.token_type) {
this.failure = true;
await this.router.navigate(['connections']);
return;
}
this.http.get(`${environment.API_HOST}/auth/connections?token=${params['access_token']}&state=${params['state']}&expires_in=${params['expires_in']}`).subscribe({
next: async (d: any) => {
const data = d.data;
this.success = true;
await setTimeout(async () => {
this.client.createConnection(data.connection.name, data.connection.type, data.connection.clientId, params['access_token'], data.connection.grantType, params['scope'], data.expires_at);
await this.router.navigate(['connections'])
}, 2000)
},
error: async () => await this.router.navigate(['connections'])
});
;
}
}

View File

@ -0,0 +1,60 @@
<mat-card>
<mat-card-header>
<mat-card-title-group>
<mat-card-title>Add Connection</mat-card-title>
<mat-card-subtitle></mat-card-subtitle>
</mat-card-title-group>
</mat-card-header>
<mat-card-content>
<mat-form-field>
<mat-label>Connection Name</mat-label>
<input matInput
[formControl]="nameControl" />
@if (nameControl.invalid && (nameControl.dirty || nameControl.touched)) {
@if (nameControl.hasError('required')) {
<small class="error">This field is required.</small>
}
}
</mat-form-field>
<mat-form-field>
<mat-label>Client Type</mat-label>
<mat-select [formControl]="typeControl">
<mat-option value="nightbot">Nightbot</mat-option>
<mat-option value="twitch">Twitch</mat-option>
</mat-select>
@if (typeControl.invalid && (typeControl.dirty || typeControl.touched)) {
@if (typeControl.hasError('required')) {
<small class="error">This field is required.</small>
}
}
</mat-form-field>
<mat-form-field>
<mat-label>Client Id</mat-label>
<input matInput
[formControl]="clientIdControl" />
@if (clientIdControl.invalid && (clientIdControl.dirty || clientIdControl.touched)) {
@if (clientIdControl.hasError('required')) {
<small class="error">This field is required.</small>
}
}
</mat-form-field>
</mat-card-content>
<mat-card-actions class="actions">
<button mat-button
class="neutral"
disabled="{{waitForResponse}}"
(click)="dialogRef.close()">Cancel</button>
<button mat-button
class="confirm"
disabled="{{!form.dirty || form.invalid || waitForResponse}}"
(click)="submit()">Add</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,12 @@
.mat-mdc-form-field {
display: block;
margin: 1em;
}
.mat-mdc-card-actions {
align-self: center;
}
.mat-mdc-card-actions > button {
margin: 1em;
}

View File

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

View File

@ -0,0 +1,70 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { DOCUMENT } from '@angular/common';
import { environment } from '../../../environments/environment';
@Component({
selector: 'connection-item-edit',
imports: [
MatButtonModule,
MatCardModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule,
],
templateUrl: './connection-item-edit.component.html',
styleUrl: './connection-item-edit.component.scss'
})
export class ConnectionItemEditComponent {
private readonly http = inject(HttpClient);
private readonly data = inject<{ name: string }>(MAT_DIALOG_DATA);
readonly dialogRef = inject(MatDialogRef<ConnectionItemEditComponent>);
readonly nameControl = new FormControl<string>('', [Validators.required]);
readonly clientIdControl = new FormControl<string>('', [Validators.required]);
readonly typeControl = new FormControl<string>('', [Validators.required]);
readonly form = new FormGroup({
name: this.nameControl,
clientId: this.clientIdControl,
type: this.typeControl,
});
responseError: string | undefined;
waitForResponse = false;
constructor(@Inject(DOCUMENT) private document: Document) { }
ngOnInit(): void {
this.nameControl.setValue(this.data.name);
}
submit(): void {
if (!this.form.dirty || this.form.invalid || this.waitForResponse) {
return;
}
this.http.post(environment.API_HOST + '/auth/connections', {
name: this.nameControl.value,
type: this.typeControl.value,
client_id: this.clientIdControl.value,
grant_type: 'bearer',
},
{
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe(async (d: any) => this.document.location.href = d.data);
}
}

View File

@ -0,0 +1,27 @@
<section [class.twitch]="connection().type == 'twitch'"
[class.nightbot]="connection().type == 'nightbot'">
{{connection().name}}
@if (isExpired) {
<mat-icon matTooltip="Connection has expired."
class="danger">error</mat-icon>
} @else if (isExpiringSoon) {
<mat-icon matTooltip="Connection is soon going to expire."
class="warning">warning</mat-icon>
}
<article class="right">
<button mat-button
class="neutral"
(click)="renew()">
<mat-icon>refresh</mat-icon>
Renew
</button>
<button mat-button
class="danger"
(click)="delete()">
<mat-icon>delete</mat-icon>
Delete
</button>
</article>
</section>

View File

@ -0,0 +1,19 @@
section {
padding: 1em;
}
.twitch {
border-left: 1em solid #6441A5;
}
.nightbot {
border-left: 1em solid #3D5D9A;
}
.right {
float: right;
}
button {
margin: 0 0.5em;
}

View File

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

View File

@ -0,0 +1,60 @@
import { Component, Inject, inject, input } from '@angular/core';
import { Connection } from '../../shared/models/connection';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import {MatTooltipModule} from '@angular/material/tooltip';
import { ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { DOCUMENT } from '@angular/common';
import { HermesClientService } from '../../hermes-client.service';
import { environment } from '../../../environments/environment';
import moment from 'moment';
@Component({
selector: 'connection-item',
imports: [
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatTooltipModule,
ReactiveFormsModule,
],
templateUrl: './connection-item.component.html',
styleUrl: './connection-item.component.scss'
})
export class ConnectionItemComponent {
private readonly http = inject(HttpClient);
private readonly client = inject(HermesClientService);
connection = input.required<Connection>();
constructor(@Inject(DOCUMENT) private document: Document) { }
delete() {
this.client.deleteConnection(this.connection().name);
}
get isExpired() {
return moment(this.connection().expires_at).toDate().getTime() < new Date().getTime();
}
get isExpiringSoon() {
return moment(this.connection().expires_at).toDate().getTime() < moment.now() + moment.duration(7, 'd').asMilliseconds();
}
renew() {
const conn = this.connection();
this.http.post(environment.API_HOST + '/auth/connections', {
name: conn.name,
type: conn.type,
client_id: conn.client_id,
grant_type: conn.grant_type,
},
{
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe(async (d: any) => this.document.location.href = d.data);
}
}

View File

@ -0,0 +1,38 @@
<ul>
<li class="header">
<mat-form-field appearance="outline"
subscriptSizing="dynamic">
<mat-label>Name Filter</mat-label>
<input matInput
placeholder="Filter connections by name"
[formControl]="searchControl" />
</mat-form-field>
<mat-form-field appearance="outline"
subscriptSizing="dynamic">
<mat-label>Type Filter</mat-label>
<mat-select [formControl]="typeControl">
<mat-option value="">All</mat-option>
<mat-option value="nightbot">Nightbot</mat-option>
<mat-option value="twitch">Twitch</mat-option>
</mat-select>
</mat-form-field>
<button mat-icon-button
(click)="add()">
<mat-icon>add</mat-icon>
</button>
</li>
@for (connection of connections; track $index) {
<li>
<connection-item [connection]="connection" />
</li>
}
@if (!connections.length) {
@if (searchControl.value) {
<p class="notice">No connections matches the filter.</p>
} @else {
<p class="notice">No connections available.</p>
}
}
</ul>

View File

@ -0,0 +1,38 @@
@use '@angular/material' as mat;
ul {
background-color: rgb(202, 68, 255);
border-radius: 15px;
margin: 0 0;
padding: 0;
max-width: 500px;
overflow: hidden;
@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),
));
}
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 { ConnectionListComponent } from './connection-list.component';
describe('ConnectionListComponent', () => {
let component: ConnectionListComponent;
let fixture: ComponentFixture<ConnectionListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ConnectionListComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ConnectionListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,62 @@
import { Component, inject, Input } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { Connection } from '../../shared/models/connection';
import { ConnectionItemComponent } from "../connection-item/connection-item.component";
import { MatInputModule } from '@angular/material/input';
import { MatDialog } from '@angular/material/dialog';
import { ConnectionItemEditComponent } from '../connection-item-edit/connection-item-edit.component';
import { MatSelectModule } from '@angular/material/select';
import { containsLettersInOrder } from '../../shared/utils/string-compare';
@Component({
selector: 'connection-list',
imports: [
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule,
ConnectionItemComponent,
],
templateUrl: './connection-list.component.html',
styleUrl: './connection-list.component.scss'
})
export class ConnectionListComponent {
private readonly _dialog = inject(MatDialog);
private _connections: Connection[] = [];
readonly searchControl = new FormControl<string>('');
readonly typeControl = new FormControl<string>('');
opened = false;
get connections() {
return this._connections.filter(c => containsLettersInOrder(c.name, this.searchControl.value) && (!this.typeControl.value || c.type == this.typeControl.value));
}
@Input({ required: true })
set connections(value: Connection[]) {
this._connections = value;
}
add() {
if (this.opened)
return;
this.opened = true;
const dialogRef = this._dialog.open(ConnectionItemEditComponent, {
data: { name: this.searchControl.value },
});
dialogRef.afterClosed().subscribe((_: any) => this.opened = false);
}
}

View File

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

View File

@ -0,0 +1,5 @@
<content>
<h3>Connections</h3>
<connection-list [connections]="connections" />
</content>

View File

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

View File

@ -0,0 +1,41 @@
import { Component, inject, OnDestroy } from '@angular/core';
import { Connection } from '../../shared/models/connection';
import { ActivatedRoute } from '@angular/router';
import { ConnectionListComponent } from "../connection-list/connection-list.component";
import { Subscription } from 'rxjs';
import { ConnectionService } from '../../shared/services/connection.service';
@Component({
selector: 'connections',
imports: [ConnectionListComponent],
templateUrl: './connections.component.html',
styleUrl: './connections.component.scss'
})
export class ConnectionsComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly connectionService = inject(ConnectionService);
subscriptions: (Subscription | undefined)[] = [];
connections: Connection[] = [];
constructor() {
this.route.data.subscribe(payload => {
this.connections = payload['connections'] ?? [];
});
this.subscriptions.push(this.connectionService.delete$?.subscribe(d => {
if (d.error) {
return;
}
this.connectionService.fetch().subscribe(connections => this.connections = connections);
}));
}
ngOnDestroy(): void {
for (let subscription of this.subscriptions) {
if (subscription)
subscription.unsubscribe();
}
}
}

View File

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

View File

@ -38,13 +38,15 @@
}
</mat-form-field>
</mat-card-content>
<mat-card-actions align="end">
<mat-card-actions>
<button mat-button
class="neutral"
[disabled]="waitForResponse"
(click)="cancel()">
<mat-icon>cancel</mat-icon>Cancel
</button>
<button mat-button
class="confirm"
[disabled]="waitForResponse || formGroup.invalid"
(click)="add()">
<mat-icon>add</mat-icon>Add

View File

@ -5,17 +5,4 @@
.mat-mdc-card-actions {
align-self: center;
}
.error {
display: block;
color: #ba1a1a;
}
.below {
display: block;
justify-self: center;
align-items: center;
align-self: center;
text-align: center;
}

View File

@ -50,7 +50,7 @@ export class GroupItemEditComponent implements OnInit {
}
add() {
if (this.formGroup.invalid || this.waitForResponse)
if (!this.formGroup.dirty || this.formGroup.invalid || this.waitForResponse)
return;
this.waitForResponse = true;

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

@ -1,16 +1,20 @@
article {
background-color: #f0f0f0;
display: flex;
flex-direction: row;
justify-content: space-between;
border: grey solid 1px;
border-radius: 15px;
padding: 1em;
padding: 0.5em 1em;
& :first-child {
min-width: 180px;
& > :first-child {
min-width: 200px;
}
& :not(:first-child) {
& > :last-child {
min-width: 100px;
}
& > :not(:first-child) {
text-align: center;
align-self: center;
}

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>
@ -25,7 +37,8 @@
[groups]="groups"
[policies]="policies"
[group]="group?.id" />
<policy-table [policies]="policies" />
<policy-table [policies]="policies"
[groups]="groups"/>
</mat-expansion-panel>
<mat-expansion-panel>
@ -43,7 +56,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.op == 4 && 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[] }[] = [];
this._groups = payload['groups'];
this._chatters = payload['chatters'];
this._permissions = payload['permissions'];
this._policies = payload['policies'];
});
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.subscriptions.push(this._permissionService.delete$?.subscribe(d => {
if (d.error) {
return;
}
this.items = elements;
});
this._permissionService.fetch().subscribe(permissions => this._permissions = permissions);
}));
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._groupService.deleteGroup$?.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._groupService.fetch().subscribe(data => this._groups = data.groups);
}));
this.subscriptions.push(this._groupService.deleteChatter$?.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._chatters = data.chatters);
}));
this.subscriptions.push(this._policyService.delete$?.subscribe(d => {
if (d.error) {
return;
}
this.items = this.items.filter(r => r.group.id != d.request.data.id);
});
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 };
if (this.opened) {
return;
}
this.opened = true;
const dialogRef = this._dialog.open(GroupItemEditComponent, {
data: { group, isSpecial: groupName.length > 0 },
data: { group: { id: '', user_id: '', name: groupName, priority: 0 }, 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;
}
});
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

@ -34,7 +34,7 @@ export class HermesClientService {
return this.listen();
}
public disconnect() {
public disconnect(impersonated: boolean = false) {
if (!this.connected)
return;
@ -43,7 +43,7 @@ export class HermesClientService {
this.session_id = undefined;
this.api_key = undefined;
this.socket.close();
this.events.emit('tts_logoff', null);
this.events.emit('tts_logoff', impersonated);
}
public filter(predicate: (data: any) => boolean): Observable<any> | undefined {
@ -90,6 +90,28 @@ export class HermesClientService {
});
}
public createConnection(name: string, type: string, client_id: string, access_token: string, grant_type: string, scope: string, expiration: Date) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "create_connection",
data: { name, type, client_id, access_token, grant_type, scope, expiration },
});
}
public createConnectionState(name: string, type: string, client_id: string, grant_type: string) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "create_connection_state",
data: { name, type, client_id, grant_type },
});
}
public createGroup(name: string, priority: number) {
if (!this.logged_in)
return;
@ -112,6 +134,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,20 +152,18 @@ 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 },
});
}
public createRedeemableAction(name: string, type: string, d: { [key: string]: any }) {
public createRedeemableAction(name: string, type: string, has_message: boolean, d: { [key: string]: any }) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "create_redeemable_action",
data: { name, type, data: d },
data: { name, type, has_message, data: d },
nounce: this.session_id,
});
}
@ -161,6 +192,28 @@ export class HermesClientService {
});
}
public deleteConnection(name: string) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "delete_connection",
data: { name },
});
}
public deleteConnectionState(name: string) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "delete_connection_state",
data: { name },
});
}
public deleteGroup(id: string) {
if (!this.logged_in)
return;
@ -183,6 +236,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;
@ -230,6 +294,28 @@ export class HermesClientService {
});
}
public fetchConnections() {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "get_connections",
data: null,
});
}
public fetchConnectionStates() {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "get_connection_states",
data: null,
});
}
public fetchFilters() {
if (!this.logged_in)
return;
@ -252,13 +338,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 +417,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,20 +435,18 @@ 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 },
});
}
public updateRedeemableAction(name: string, type: string, d: { [key: string]: any }) {
public updateRedeemableAction(name: string, type: string, has_message: boolean, d: { [key: string]: any }) {
if (!this.logged_in)
return;
this.send(3, {
request_id: null,
type: "update_redeemable_action",
data: { name, type, data: d },
data: { name, type, has_message, data: d },
nounce: this.session_id,
});
}

View File

@ -1,4 +1,4 @@
import { OnInit, Injectable } from '@angular/core';
import { Injectable } from '@angular/core';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { catchError, first, timeout } from 'rxjs/operators';
import { environment } from '../environments/environment';
@ -8,13 +8,9 @@ import { EMPTY, Observable, Observer, throwError } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class HermesSocketService implements OnInit {
export class HermesSocketService {
private socket: WebSocketSubject<any> | undefined = undefined
constructor() { }
ngOnInit(): void {
}
public connect(): void {
if (!this.socket || this.socket.closed) {
@ -22,9 +18,10 @@ export class HermesSocketService implements OnInit {
}
}
public first(predicate: (data: any) => boolean): Observable<any> {
if (!this.socket || this.socket.closed)
return new Observable().pipe(timeout(3000), catchError((e) => throwError(() => 'No response after 3 seconds.')));
public first<T>(predicate: (data: T) => boolean): Observable<T> {
if (!this.socket || this.socket.closed) {
throw new Error('Socket is ' + (this.socket ? 'closed' : 'null') + '.');
}
return this.socket.pipe(timeout(3000), catchError((e) => throwError(() => 'No response after 3 seconds.')), first(predicate));
}
@ -43,7 +40,11 @@ export class HermesSocketService implements OnInit {
}
public get$(): Observable<any> | undefined {
return this.socket?.asObservable().pipe(catchError(_ => EMPTY));
if (!this.socket || this.socket.closed) {
throw new Error('Socket is ' + (this.socket ? 'closed' : 'null') + '.');
}
return this.socket.asObservable().pipe(catchError(_ => EMPTY));
}
public subscribe(subscriptions: Partial<Observer<any>> | ((value: any) => void)) {

View File

@ -0,0 +1,38 @@
<mat-card>
<mat-card-header>
<mat-card-title-group>
<mat-card-title>Add API Key</mat-card-title>
<mat-card-subtitle></mat-card-subtitle>
</mat-card-title-group>
</mat-card-header>
<mat-card-content>
<mat-form-field>
<mat-label>Key Label</mat-label>
<input matInput
[formControl]="labelControl" />
@if (labelControl.invalid && (labelControl.dirty || labelControl.touched)) {
@if (labelControl.hasError('required')) {
<small class="error">This field is required.</small>
}
}
</mat-form-field>
</mat-card-content>
<mat-card-actions class="actions">
<button mat-button
class="neutral"
disabled="{{waitForResponse}}"
(click)="dialogRef.close()">Cancel</button>
<button mat-button
class="confirm"
disabled="{{!form.dirty || form.invalid || waitForResponse}}"
(click)="submit()">Add</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 { KeyItemEditComponent } from './key-item-edit.component';
describe('KeyItemEditComponent', () => {
let component: KeyItemEditComponent;
let fixture: ComponentFixture<KeyItemEditComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [KeyItemEditComponent]
})
.compileComponents();
fixture = TestBed.createComponent(KeyItemEditComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,68 @@
import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import EventService from '../../shared/services/EventService';
import { environment } from '../../../environments/environment';
@Component({
selector: 'key-item-edit',
imports: [
MatButtonModule,
MatCardModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule,
],
templateUrl: './key-item-edit.component.html',
styleUrl: './key-item-edit.component.scss'
})
export class KeyItemEditComponent {
private readonly http = inject(HttpClient);
private readonly events = inject(EventService);
readonly data = inject<{ name: string }>(MAT_DIALOG_DATA);
readonly labelControl = new FormControl<string>('', [Validators.required]);
readonly form = new FormGroup({
name: this.labelControl,
});
readonly dialogRef = inject(MatDialogRef<KeyItemEditComponent>);
responseError: string | undefined;
waitForResponse = false;
ngOnInit(): void {
this.labelControl.setValue(this.data.name);
}
submit(): void {
if (!this.form.dirty || this.form.invalid || this.waitForResponse) {
return;
}
const label = this.labelControl.value;
this.http.post(environment.API_HOST + '/keys', { label },
{
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe(async (d: any) => {
this.events.emit('add_api_key', {
id: d.key,
label: d.label,
});
this.dialogRef.close();
});
}
}

View File

@ -0,0 +1,20 @@
<section>
{{(isVisible ? key().id : key().label)}}
<article class="right">
<button mat-button
class="neutral"
[disabled]="waitForResponse"
(click)="isVisible = !isVisible">
<mat-icon>{{(isVisible ? "visibility_off" : "visibility")}}</mat-icon>
{{(isVisible ? "Hide" : "View")}} Key
</button>
<button mat-button
class="danger"
[disabled]="waitForResponse"
(click)="delete()">
<mat-icon>delete</mat-icon>
Delete
</button>
</article>
</section>

View File

@ -0,0 +1,11 @@
section {
padding: 1em;
}
.right {
float: right;
}
button {
margin: 0 0.5em;
}

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserListComponent } from './user-list.component';
import { KeyItemComponent } from './key-item.component';
describe('UserListComponent', () => {
let component: UserListComponent;
let fixture: ComponentFixture<UserListComponent>;
describe('KeyItemComponent', () => {
let component: KeyItemComponent;
let fixture: ComponentFixture<KeyItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserListComponent]
imports: [KeyItemComponent]
})
.compileComponents();
fixture = TestBed.createComponent(UserListComponent);
fixture = TestBed.createComponent(KeyItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,49 @@
import { Component, inject, input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import ApiKey from '../../shared/models/api-key';
import EventService from '../../shared/services/EventService';
import { environment } from '../../../environments/environment';
@Component({
selector: 'key-item',
imports: [
MatButtonModule,
MatIconModule,
MatFormFieldModule,
ReactiveFormsModule,
],
templateUrl: './key-item.component.html',
styleUrl: './key-item.component.scss'
})
export class KeyItemComponent {
private readonly http = inject(HttpClient);
private readonly events = inject(EventService);
key = input.required<ApiKey>();
isVisible: boolean = false;
waitForResponse = false;
delete() {
if (this.waitForResponse) {
return;
}
const key_id = this.key().id;
this.http.delete(environment.API_HOST + '/keys',
{
body: {
key: key_id,
},
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('jwt')
}
}).subscribe(async (d: any) => {
this.events.emit('delete_api_key', key_id);
this.waitForResponse = false;
});
}
}

View File

@ -0,0 +1,28 @@
<ul>
<li class="header">
<mat-form-field appearance="outline"
subscriptSizing="dynamic">
<mat-label>Label Filter</mat-label>
<input matInput
placeholder="Filter keys by label"
[formControl]="searchControl" />
</mat-form-field>
<button mat-icon-button
(click)="add()">
<mat-icon>add</mat-icon>
</button>
</li>
@for (key of keys; track key.id) {
<li>
<key-item [key]="key" />
</li>
}
@if (!keys.length) {
@if (searchControl.value) {
<p class="notice">No API keys match the filter.</p>
} @else {
<p class="notice">No API keys available.</p>
}
}
</ul>

View File

@ -0,0 +1,38 @@
@use '@angular/material' as mat;
ul {
background-color: rgb(202, 68, 255);
border-radius: 15px;
margin: 0 0;
padding: 0;
max-width: 600px;
overflow: hidden;
@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),
));
}
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 { KeyListComponent } from './key-list.component';
describe('KeyListComponent', () => {
let component: KeyListComponent;
let fixture: ComponentFixture<KeyListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [KeyListComponent]
})
.compileComponents();
fixture = TestBed.createComponent(KeyListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,61 @@
import { Component, inject, Input } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatDialog } from '@angular/material/dialog';
import { MatSelectModule } from '@angular/material/select';
import { containsLettersInOrder } from '../../shared/utils/string-compare';
import { KeyItemComponent } from '../key-item/key-item.component';
import { KeyItemEditComponent } from '../key-item-edit/key-item-edit.component';
import ApiKey from '../../shared/models/api-key';
@Component({
selector: 'key-list',
imports: [
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule,
KeyItemComponent,
],
templateUrl: './key-list.component.html',
styleUrl: './key-list.component.scss'
})
export class KeyListComponent {
private readonly _dialog = inject(MatDialog);
private _keys: ApiKey[] = [];
readonly searchControl = new FormControl<string>('');
opened = false;
get keys() {
return this._keys.filter(c => containsLettersInOrder(c.label, this.searchControl.value));
}
@Input({ required: true })
set keys(value: ApiKey[]) {
this._keys = value;
}
add() {
if (this.opened)
return;
this.opened = true;
const dialogRef = this._dialog.open(KeyItemEditComponent, {
data: { name: this.searchControl.value },
});
dialogRef.afterClosed().subscribe((_: any) => this.opened = false);
}
}

View File

@ -9,4 +9,4 @@ import { CommonModule } from '@angular/common';
CommonModule
]
})
export class UsersModule { }
export class KeysModule { }

View File

@ -0,0 +1,2 @@
<h3>API Keys</h3>
<key-list [keys]="keys" />

View File

View File

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

View File

@ -0,0 +1,39 @@
import { Component, inject, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { KeyListComponent } from '../key-list/key-list.component';
import ApiKey from '../../shared/models/api-key';
import EventService from '../../shared/services/EventService';
import { ApiKeyService } from '../../shared/services/api/api-key.service';
@Component({
selector: 'keys',
imports: [KeyListComponent],
templateUrl: './keys.component.html',
styleUrl: './keys.component.scss'
})
export class KeysComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly events = inject(EventService);
private readonly keyService = inject(ApiKeyService);
subscriptions: (Subscription | undefined)[] = [];
keys: ApiKey[] = [];
constructor() {
this.route.data.subscribe(payload => {
this.keys = payload['keys'] ?? [];
});
this.subscriptions.push(this.events.listen('add_api_key', _ => this.keyService.fetch().subscribe(keys => this.keys = keys)));
this.subscriptions.push(this.events.listen('delete_api_key', _ => this.keyService.fetch().subscribe(keys => this.keys = keys)));
}
ngOnDestroy(): void {
for (let subscription of this.subscriptions) {
if (subscription)
subscription.unsubscribe();
}
}
}

View File

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

View File

@ -1,37 +0,0 @@
$primary_background_color: #EEEEEE;
$primary_font_color: #111111;
$secondary_background_color: #DDDDDD;
$secondary_font_color: #333333;
ul {
padding: 0;
}
li {
list-style: none;
width: 100%;
display: flex;
flex-grow: 1;
justify-content: center;
flex-direction: column;
}
a {
background-color: transparent;
padding: 1em;
border: 0;
margin: 0;
font-size: large;
text-decoration: none;
color: $primary_font_color;
border-radius: 10px;
}
a:hover {
background-color: #FAFAFA;
}
a.active {
background-color: #F5F5F5;
}

View File

@ -1,30 +0,0 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HermesClientService } from '../hermes-client.service';
import { ApiAuthenticationService } from '../shared/services/api/api-authentication.service';
import { MatCardModule } from '@angular/material/card';
import { AuthModule } from '../auth/auth.module';
import { UserCardComponent } from "../auth/user-card/user-card.component";
@Component({
selector: 'navigation',
standalone: true,
imports: [RouterModule, MatCardModule, AuthModule, UserCardComponent],
templateUrl: './navigation.component.html',
styleUrl: './navigation.component.scss'
})
export class NavigationComponent {
constructor(private auth: ApiAuthenticationService, private hermes: HermesClientService) { }
isLoggedIn() {
return this.auth.isAuthenticated();
}
isAdmin() {
return this.isLoggedIn() && this.auth.isAdmin()
}
isTTSLoggedIn() {
return this.hermes?.logged_in ?? false;
}
}

View File

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

View File

@ -0,0 +1,72 @@
<nav>
<ul>
@if (isLoggedIn()) {
@if (isTTSLoggedIn()) {
<li>
<a mat-raised-button
routerLink="/policies"
routerLinkActive="active">
Policies
</a>
</li>
<li>
<a mat-raised-button
routerLink="/filters"
routerLinkActive="active">
Filters
</a>
</li>
<li>
<a mat-raised-button
routerLink="/actions"
routerLinkActive="active">
Actions
</a>
</li>
<li>
<a mat-raised-button
routerLink="/redemptions"
routerLinkActive="active">
Redemptions
</a>
</li>
<li>
<a mat-raised-button
routerLink="/groups"
routerLinkActive="active">
Groups
</a>
</li>
<li>
<a mat-raised-button
routerLink="/connections"
routerLinkActive="active">
Connections
</a>
</li>
} @else {
<li>
<a mat-raised-button
routerLink="/tts-login"
routerLinkActive="active">
TTS Login
</a>
</li>
}
<li>
<a mat-raised-button
routerLink="/keys"
routerLinkActive="active">
API Keys
</a>
</li>
} @else {
<li>
<a routerLink="/login"
routerLinkActive="active">
Login
</a>
</li>
}
</ul>
</nav>

View File

@ -0,0 +1,31 @@
@use "@angular/material" as mat;
ul {
padding: 0;
}
li {
list-style: none;
width: 100%;
display: flex;
justify-content: center;
flex-direction: column;
margin: 0.5em 0;
}
a {
padding: 1em;
border: 0;
margin: 0;
font-size: large;
text-decoration: none;
border-radius: 10px;
@include mat.button-overrides(
(
protected-container-height: 1
)
);
}

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NavigationComponent } from './navigation.component';
import { SidebarComponent } from './sidebar.component';
describe('NavigationComponent', () => {
let component: NavigationComponent;
let fixture: ComponentFixture<NavigationComponent>;
let component: SidebarComponent;
let fixture: ComponentFixture<SidebarComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NavigationComponent]
imports: [SidebarComponent]
})
.compileComponents();
fixture = TestBed.createComponent(NavigationComponent);
fixture = TestBed.createComponent(SidebarComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,41 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HermesClientService } from '../../hermes-client.service';
import { ApiAuthenticationService } from '../../shared/services/api/api-authentication.service';
import { MatCardModule } from '@angular/material/card';
import { AuthModule } from '../../auth/auth.module';
import { MatButtonModule } from '@angular/material/button';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'sidebar',
standalone: true,
imports: [
AuthModule,
MatButtonModule,
MatCardModule,
MatIconModule,
MatSidenavModule,
MatToolbarModule,
RouterModule,
],
templateUrl: './sidebar.component.html',
styleUrl: './sidebar.component.scss'
})
export class SidebarComponent {
constructor(private auth: ApiAuthenticationService, private hermes: HermesClientService) { }
isLoggedIn() {
return this.auth.isAuthenticated();
}
isAdmin() {
return this.isLoggedIn() && this.auth.isAdmin()
}
isTTSLoggedIn() {
return this.hermes?.logged_in ?? false;
}
}

Some files were not shown because too many files have changed in this diff Show More