Upgraded all libraries, switched to npm, upgraded angular and jdk21
Locusworks Team/portal-webapp/pipeline/head There was a failure building this commit
Locusworks Team/portal-webapp/pipeline/head There was a failure building this commit
This commit is contained in:
1 parent
c2f8e20fad
commit
a6df549919
335 files changed
+10923
-12700
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter, withHashLocation } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './core/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes, withHashLocation()),
|
||||
provideHttpClient(withInterceptors([authInterceptor]))
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<router-outlet />
|
||||
@@ -0,0 +1,36 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { RouterTestingHarness } from '@angular/router/testing';
|
||||
import { throwError } from 'rxjs';
|
||||
import { appConfig } from './app.config';
|
||||
import { PortalApiService } from './core/portal-api.service';
|
||||
import { RealtimeService } from './core/realtime.service';
|
||||
|
||||
describe('Lazy portal routes', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
...appConfig.providers,
|
||||
{ provide: PortalApiService, useValue: {
|
||||
request: () => throwError(() => new Error('Not authenticated'))
|
||||
} },
|
||||
{ provide: RealtimeService, useValue: {
|
||||
status: signal('disconnected'), connect: () => undefined
|
||||
} }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the lazy login page with the production Material providers', async () => {
|
||||
const harness = await RouterTestingHarness.create('/login');
|
||||
expect(harness.routeNativeElement?.querySelector('input[formControlName="username"]')).toBeTruthy();
|
||||
expect(harness.routeNativeElement?.querySelector('input[type="password"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('redirects an unauthenticated dashboard request to the lazy login page', async () => {
|
||||
const harness = await RouterTestingHarness.create('/dashboard/repos');
|
||||
expect(TestBed.inject(Router).url).toBe('/login');
|
||||
expect(harness.routeNativeElement?.querySelector('form')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './core/auth.guard';
|
||||
import { FEATURES } from './core/models';
|
||||
import { permissionGuard } from './core/permission.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'login', loadComponent: () => import('./login/login').then(m => m.LoginComponent), title: 'Portal Login' },
|
||||
{ path: 'dashboard', loadComponent: () => import('./dashboard/dashboard').then(m => m.DashboardComponent), canActivate: [authGuard], canActivateChild: [permissionGuard], children: [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'repos' },
|
||||
...FEATURES.map(feature => ({ path: feature.path, loadComponent: () => import('./features/feature-page').then(m => m.FeaturePageComponent), data: { feature: feature.path, permission: feature.permission }, title: feature.title }))
|
||||
] },
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'dashboard' },
|
||||
{ path: '**', redirectTo: 'dashboard' }
|
||||
];
|
||||
Whitespace-only changes.
@@ -0,0 +1,24 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the application router outlet', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('router-outlet')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
imports: [RouterOutlet],
|
||||
selector: 'app-root',
|
||||
styleUrl: './app.scss',
|
||||
templateUrl: './app.html',
|
||||
})
|
||||
export class App {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { catchError, map, of } from 'rxjs';
|
||||
import { Account } from './models';
|
||||
import { PortalApiService } from './portal-api.service';
|
||||
import { SessionService } from './session.service';
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const session = inject(SessionService), api = inject(PortalApiService), router = inject(Router);
|
||||
if (session.authenticated()) return true;
|
||||
return api.request<Account>('/account/isLoggedIn.do').pipe(map(account => { session.setAccount(account); return true; }), catchError(() => of(router.createUrlTree(['/login']))));
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { RealtimeService } from './realtime.service';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (request, next) => {
|
||||
const session = inject(SessionService);
|
||||
const realtime = inject(RealtimeService);
|
||||
const router = inject(Router);
|
||||
return next(request).pipe(catchError(error => {
|
||||
if ((error.status === 401 || error.status === 403) && session.authenticated()) {
|
||||
session.clear();
|
||||
realtime.disconnect();
|
||||
void router.navigate(['/login']);
|
||||
}
|
||||
return throwError(() => error);
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface PortalResponse<T> { success?: boolean; message?: string; body?: T; }
|
||||
export interface Account { id?: number; username?: string; firstName?: string; lastName?: string; page?: string; authorities?: string[]; permissions?: Record<string, unknown>; stompTopics?: string[]; [key: string]: unknown; }
|
||||
export type FeatureEditor = 'git' | 'credentials' | 'email' | 'user' | 'environment' | 'permission' | 'script';
|
||||
export interface FeatureDefinition { path: string; title: string; icon: string; permission: string; loadEndpoint: string; deleteEndpoint?: string; deletePermission?: string; executeEndpoint?: string; executePermission?: string; editor?: FeatureEditor; saveEndpoint?: string; savePermission?: string; }
|
||||
export const FEATURES: FeatureDefinition[] = [
|
||||
{ path: 'scripts', title: 'Scripts', icon: 'description', permission: 'VIEW_SCRIPT_TAB', loadEndpoint: '/scripts/getAll.do', editor: 'script', saveEndpoint: '/scripts/addScript.do', savePermission: 'ADD_SCRIPT', deleteEndpoint: '/scripts/deleteScript.do', deletePermission: 'DELETE_SCRIPT', executeEndpoint: '/scripts/runScript.do', executePermission: 'RUN_SCRIPT' },
|
||||
{ path: 'repos', title: 'Git Repositories', icon: 'computer', permission: 'VIEW_GIT_TAB', loadEndpoint: '/git/getAll.do', editor: 'git', saveEndpoint: '/git/add.do', savePermission: 'ADD_GIT_REPO', deleteEndpoint: '/git/delete.do', deletePermission: 'DELETE_GIT_REPO', executeEndpoint: '/git/pull.do', executePermission: 'PULL_GIT_REPO' },
|
||||
{ path: 'credentials', title: 'Credentials', icon: 'vpn_key', permission: 'VIEW_CREDS_TAB', loadEndpoint: '/creds/get.do', editor: 'credentials', saveEndpoint: '/creds/add.do', savePermission: 'ADD_CREDENTIALS', deleteEndpoint: '/creds/delete.do', deletePermission: 'DELETE_CREDENTIALS' },
|
||||
{ path: 'emails', title: 'Email Templates', icon: 'email', permission: 'VIEW_EMAIL_TAB', loadEndpoint: '/system/getEmailTemplates.do', editor: 'email', saveEndpoint: '/system/addEmailTemplate.do', savePermission: 'ADD_EMAIL', deleteEndpoint: '/system/deleteEmailTemplate.do', deletePermission: 'DELETE_EMAIL' },
|
||||
{ path: 'users', title: 'Users', icon: 'people', permission: 'VIEW_USER_TAB', loadEndpoint: '/account/getAll.do', editor: 'user', saveEndpoint: '/account/addUser.do', savePermission: 'ADD_USERS', deleteEndpoint: '/account/deleteUser.do', deletePermission: 'DELETE_USERS' },
|
||||
{ path: 'permissions', title: 'Permissions', icon: 'lock_open', permission: 'VIEW_PERMISSION_TAB', loadEndpoint: '/permissions/getPermissionGroups.do', editor: 'permission', saveEndpoint: '/permissions/addPermssionCategory.do', savePermission: 'ADD_PERMS', deleteEndpoint: '/permissions/deletePermissionGroup.do', deletePermission: 'DELETE_PERMS' },
|
||||
{ path: 'envars', title: 'Environment Variables', icon: 'storage', permission: 'VIEW_ENV_VARS_TAB', loadEndpoint: '/system/getAll.do', editor: 'environment', saveEndpoint: '/system/addEnvVars.do', savePermission: 'ADD_ENV_VARS', deleteEndpoint: '/system/deleteEnvVars.do', deletePermission: 'DELETE_ENV_VARS' },
|
||||
{ path: 'syslogs', title: 'System Logs', icon: 'receipt_long', permission: 'VIEW_LOG_TAB', loadEndpoint: '/logs/getAll.do' }
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateChildFn, Router } from '@angular/router';
|
||||
import { FEATURES } from './models';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
export const permissionGuard: CanActivateChildFn = route => {
|
||||
const session = inject(SessionService);
|
||||
const router = inject(Router);
|
||||
const permission = route.data['permission'] as string | undefined;
|
||||
if (!permission || session.hasPermission(permission)) return true;
|
||||
const fallback = FEATURES.find(feature => session.hasPermission(feature.permission));
|
||||
return fallback ? router.createUrlTree(['/dashboard', fallback.path]) : router.createUrlTree(['/login']);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PortalApiService } from './portal-api.service';
|
||||
|
||||
describe('PortalApiService', () => {
|
||||
let service: PortalApiService;
|
||||
let http: HttpTestingController;
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({providers:[provideHttpClient(),provideHttpClientTesting()]});
|
||||
service=TestBed.inject(PortalApiService); http=TestBed.inject(HttpTestingController);
|
||||
});
|
||||
afterEach(()=>http.verify());
|
||||
|
||||
it('posts to the portal context and unwraps Success bodies', () => {
|
||||
let result: unknown;
|
||||
service.request('/scripts/getAll.do').subscribe(value=>result=value);
|
||||
const request=http.expectOne('/portal/scripts/getAll.do');
|
||||
expect(request.request.method).toBe('POST'); expect(request.request.withCredentials).toBe(true);
|
||||
request.flush({success:true,body:[{id:1,name:'test'}]});
|
||||
expect(result).toEqual([{id:1,name:'test'}]);
|
||||
});
|
||||
|
||||
it('turns unsuccessful envelopes into errors', () => {
|
||||
let message=''; service.request('/test.do').subscribe({error:error=>message=error.message});
|
||||
http.expectOne('/portal/test.do').flush({success:false,message:'Denied'});
|
||||
expect(message).toBe('Denied');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, catchError, map, throwError } from 'rxjs';
|
||||
import { PortalResponse } from './models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PortalApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/portal';
|
||||
request<T>(endpoint: string, data?: unknown, params?: Record<string, string>): Observable<T> {
|
||||
return this.http.post<PortalResponse<T> | T>(`${this.baseUrl}${endpoint}`, data ?? null, { params: new HttpParams({ fromObject: params ?? {} }), withCredentials: true }).pipe(
|
||||
map(response => this.unwrap(response)),
|
||||
catchError((error: HttpErrorResponse) => throwError(() => new Error(error.error?.message ?? error.message ?? 'Portal request failed')))
|
||||
);
|
||||
}
|
||||
upload<T>(endpoint: string, file: File, fields: Record<string, string> = {}): Observable<T> {
|
||||
const body = new FormData(); body.append('file', file);
|
||||
Object.entries(fields).forEach(([key, value]) => body.append(key, value));
|
||||
return this.http.post<PortalResponse<T> | T>(`${this.baseUrl}${endpoint}`, body, { withCredentials: true }).pipe(map(response => this.unwrap(response)));
|
||||
}
|
||||
private unwrap<T>(response: PortalResponse<T> | T): T {
|
||||
if (response && typeof response === 'object' && ('body' in response || 'success' in response)) {
|
||||
const envelope = response as PortalResponse<T>;
|
||||
if (envelope.success === false) throw new Error(envelope.message ?? 'Portal request failed');
|
||||
return ('body' in envelope ? envelope.body : response) as T;
|
||||
}
|
||||
return response as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable, OnDestroy, signal } from '@angular/core';
|
||||
import { Client, IMessage, StompSubscription } from '@stomp/stompjs';
|
||||
import SockJS from 'sockjs-client';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export interface PortalTrigger { type: string; [key: string]: unknown; }
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'failed';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RealtimeService implements OnDestroy {
|
||||
readonly status = signal<ConnectionStatus>('disconnected');
|
||||
readonly triggers = new Subject<PortalTrigger>();
|
||||
private subscriptions: StompSubscription[] = [];
|
||||
private readonly client = new Client({
|
||||
webSocketFactory: () => new SockJS('/portal/ws/stomp') as WebSocket,
|
||||
reconnectDelay: 5000,
|
||||
heartbeatIncoming: 10000,
|
||||
heartbeatOutgoing: 10000,
|
||||
debug: () => undefined
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.client.onConnect = () => this.status.set('connected');
|
||||
this.client.onWebSocketClose = () => this.status.set('disconnected');
|
||||
this.client.onStompError = () => this.status.set('failed');
|
||||
}
|
||||
connect(topics: string[] = []): void {
|
||||
this.client.onConnect = () => { this.status.set('connected'); this.subscribe(topics); };
|
||||
if (!this.client.active) { this.status.set('connecting'); this.client.activate(); }
|
||||
else if (this.client.connected) this.subscribe(topics);
|
||||
}
|
||||
subscribe(topics: string[]): void {
|
||||
this.subscriptions.forEach(subscription => subscription.unsubscribe()); this.subscriptions = [];
|
||||
topics.forEach(topic => this.subscriptions.push(this.client.subscribe(topic, message => this.receive(message))));
|
||||
}
|
||||
disconnect(): void { this.subscriptions.forEach(subscription => subscription.unsubscribe()); this.subscriptions = []; void this.client.deactivate(); }
|
||||
ngOnDestroy(): void { this.disconnect(); this.triggers.complete(); }
|
||||
private receive(message: IMessage): void { try { this.triggers.next(JSON.parse(message.body) as PortalTrigger); } catch { /* ignore malformed server events */ } }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
describe('SessionService', () => {
|
||||
let service: SessionService;
|
||||
beforeEach(() => { TestBed.configureTestingModule({}); service = TestBed.inject(SessionService); });
|
||||
|
||||
it('starts unauthenticated', () => { expect(service.authenticated()).toBe(false); });
|
||||
it('exposes authorities as permissions', () => {
|
||||
service.setAccount({username:'admin',authorities:['VIEW_SCRIPT_TAB','ADD_SCRIPT']});
|
||||
expect(service.authenticated()).toBe(true);
|
||||
expect(service.hasPermission('ADD_SCRIPT')).toBe(true);
|
||||
expect(service.hasPermission('DELETE_SCRIPT')).toBe(false);
|
||||
});
|
||||
it('clears authentication state', () => { service.setAccount({username:'admin'}); service.clear(); expect(service.account()).toBeNull(); });
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
import { Account } from './models';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionService {
|
||||
private readonly accountState = signal<Account | null>(null);
|
||||
readonly account = this.accountState.asReadonly();
|
||||
readonly authenticated = computed(() => this.accountState() !== null);
|
||||
setAccount(account: Account): void { this.accountState.set(account); }
|
||||
clear(): void { this.accountState.set(null); }
|
||||
hasPermission(permission: string): boolean { return this.accountState()?.authorities?.includes(permission) ?? false; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Component, Inject, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { PortalApiService } from '../core/portal-api.service';
|
||||
import { SessionService } from '../core/session.service';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export type AdminDialogKind = 'configuration' | 'loggers' | 'seed' | 'smtp';
|
||||
interface AdminDialogData { kind: AdminDialogKind; }
|
||||
interface LoggerSetting { logger: string; level: string; }
|
||||
interface SmtpSetting { name: string; displayValue: string; value: string | number | boolean; type: 'text' | 'number' | 'boolean' | 'password'; }
|
||||
const LEVELS = ['OFF', 'FATAL', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE', 'ALL'];
|
||||
const SMTP_DEFAULTS: SmtpSetting[] = [
|
||||
{name:'smtp.enabled',displayValue:'SMTP Enabled',value:false,type:'boolean'}, {name:'mail.transport.protocol',displayValue:'Transport Protocol',value:'smtp',type:'text'},
|
||||
{name:'mail.smtp.host',displayValue:'SMTP Server URL',value:'',type:'text'}, {name:'mail.smtp.port',displayValue:'SMTP Server Port',value:0,type:'number'},
|
||||
{name:'mail.debug',displayValue:'Enable Mail Debug Logging',value:false,type:'boolean'}, {name:'mail.smtp.starttls.enable',displayValue:'Enable Start TLS',value:false,type:'boolean'},
|
||||
{name:'mail.smtp.starttls.required',displayValue:'Require Start TLS',value:false,type:'boolean'}, {name:'mail.smtp.ssl.trust',displayValue:'SSL Trust Certificate',value:'*',type:'text'},
|
||||
{name:'mail.smtp.auth',displayValue:'Require Authentication',value:false,type:'boolean'}, {name:'smtp.from.user',displayValue:'From User',value:'',type:'text'},
|
||||
{name:'smtp.username',displayValue:'SMTP Username',value:'',type:'text'}, {name:'smtp.password',displayValue:'SMTP Password',value:'',type:'password'}
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-dialog',
|
||||
imports: [FormsModule, ReactiveFormsModule, MatButtonModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatProgressSpinnerModule, MatSelectModule, MatSlideToggleModule, MatTabsModule],
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ title }}</h2>
|
||||
<mat-dialog-content>
|
||||
@if (loading()) { <mat-spinner diameter="36" /> } @else {
|
||||
@switch (data.kind) {
|
||||
@case ('configuration') {
|
||||
<form class="fields" [formGroup]="configForm">
|
||||
<mat-form-field><mat-label>File path</mat-label><input matInput formControlName="filepath"></mat-form-field>
|
||||
<mat-form-field><mat-label>Default log level</mat-label><mat-select formControlName="logLevel">@for(level of levels;track level){<mat-option [value]="level">{{level}}</mat-option>}</mat-select></mat-form-field>
|
||||
<mat-form-field><mat-label>User expiration days</mat-label><input matInput type="number" formControlName="userExpirationDays"></mat-form-field>
|
||||
<mat-form-field><mat-label>Database type</mat-label><mat-select formControlName="dbType"><mat-option value="h2">H2 (local)</mat-option><mat-option value="mysql">MySQL</mat-option></mat-select></mat-form-field>
|
||||
@if (configForm.controls.dbType.value === 'h2') {
|
||||
<mat-form-field><mat-label>H2 JDBC URL</mat-label><input matInput formControlName="h2Url"></mat-form-field>
|
||||
<mat-form-field><mat-label>H2 username</mat-label><input matInput formControlName="h2Username"></mat-form-field>
|
||||
<mat-form-field><mat-label>H2 password</mat-label><input matInput type="password" formControlName="h2Password"></mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field><mat-label>MySQL host</mat-label><input matInput formControlName="dbHost"></mat-form-field>
|
||||
<mat-form-field><mat-label>MySQL port</mat-label><input matInput type="number" formControlName="dbPort"></mat-form-field>
|
||||
<mat-form-field><mat-label>MySQL username</mat-label><input matInput formControlName="dbUsername"></mat-form-field>
|
||||
<mat-form-field><mat-label>MySQL password</mat-label><input matInput type="password" formControlName="dbPassword"></mat-form-field>
|
||||
<mat-form-field><mat-label>MySQL root username</mat-label><input matInput formControlName="dbRootUser"></mat-form-field>
|
||||
<mat-form-field><mat-label>MySQL root password</mat-label><input matInput type="password" formControlName="dbRootPassword"></mat-form-field>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
@case ('seed') { <form [formGroup]="seedForm"><mat-form-field><mat-label>New AES seed</mat-label><input matInput type="password" formControlName="seed"></mat-form-field><p class="warning">Changing the seed re-encrypts all stored encrypted values and cannot be undone.</p></form> }
|
||||
@case ('loggers') {
|
||||
@if (canEdit()) { <mat-form-field><mat-label>Set all loggers</mat-label><mat-select (selectionChange)="setAll($event.value)">@for(level of levels;track level){<mat-option [value]="level">{{level}}</mat-option>}</mat-select></mat-form-field> }
|
||||
<div class="settings">@for(item of loggers();track item.logger){<div><span>{{item.logger}}</span><mat-form-field><mat-select [(ngModel)]="item.level" [disabled]="!canEdit()">@for(level of levels;track level){<mat-option [value]="level">{{level}}</mat-option>}</mat-select></mat-form-field></div>}</div>
|
||||
}
|
||||
@case ('smtp') {
|
||||
<mat-tab-group>@for(group of smtpGroups;track group.label){<mat-tab [label]="group.label"><div class="settings">@for(item of smtp().slice(group.start,group.end);track item.name){<div><span>{{item.displayValue}}</span>@if(item.type==='boolean'){<mat-slide-toggle [(ngModel)]="item.value" [disabled]="!canEdit()"/>}@else{<mat-form-field><input matInput [(ngModel)]="item.value" [type]="item.type" [disabled]="!canEdit()"></mat-form-field>}</div>}</div></mat-tab>}</mat-tab-group>
|
||||
}
|
||||
}
|
||||
@if (error()) { <p class="error">{{error()}}</p> }
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end"><button mat-button mat-dialog-close>Cancel</button>@if(canEdit()){<button mat-flat-button (click)="save()" [disabled]="loading()">Save</button>}</mat-dialog-actions>`,
|
||||
styles: [`mat-dialog-content{min-width:min(50rem,82vw);max-height:70vh}.fields{display:grid;grid-template-columns:1fr 1fr;gap:0 1rem}.settings>div{display:grid;grid-template-columns:minmax(15rem,1fr) minmax(10rem,14rem);align-items:center;gap:1rem}.settings mat-form-field,form mat-form-field{width:100%}.warning{color:#a34b00}.error{color:#b00020}`]
|
||||
})
|
||||
export class AdminDialogComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly api = inject(PortalApiService);
|
||||
private readonly session = inject(SessionService);
|
||||
readonly levels = LEVELS;
|
||||
readonly loading = signal(true); readonly error = signal('');
|
||||
readonly loggers = signal<LoggerSetting[]>([]); readonly smtp = signal<SmtpSetting[]>(SMTP_DEFAULTS.map(item => ({...item})));
|
||||
readonly smtpGroups = [{label:'General',start:0,end:5},{label:'Start TLS',start:5,end:8},{label:'Authentication',start:8,end:12}];
|
||||
readonly configForm = this.fb.group({filepath:[''],logLevel:[''],userExpirationDays:[0],dbType:['h2'],h2Url:[''],h2Username:['sa'],h2Password:[''],dbHost:[''],dbPort:[0],dbUsername:[''],dbPassword:[''],dbRootUser:[''],dbRootPassword:['']});
|
||||
readonly seedForm = this.fb.group({seed:['',Validators.required]});
|
||||
readonly title: string;
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) readonly data: AdminDialogData, private readonly ref: MatDialogRef<AdminDialogComponent>) { this.title = {configuration:'Configuration',loggers:'Log Levels',seed:'AES Seed',smtp:'SMTP Configuration'}[data.kind]; }
|
||||
ngOnInit(): void {
|
||||
const request: Observable<unknown> | null = this.data.kind === 'configuration' ? this.api.request<Record<string, unknown>>('/config/getConfiguration.do')
|
||||
: this.data.kind === 'loggers' ? this.api.request<LoggerSetting[]>('/logger/getLoggers.do')
|
||||
: this.data.kind === 'smtp' ? this.api.request<Array<{name:string;value:unknown}>>('/system/getSmtpConfig.do') : null;
|
||||
if (!request) { this.loading.set(false); return; }
|
||||
request.subscribe({next: value => { this.applyLoaded(value); this.loading.set(false); },error: error => {this.error.set(error.message);this.loading.set(false);}});
|
||||
}
|
||||
canEdit(): boolean { return this.session.hasPermission({configuration:'CHANGE_CONFIG',loggers:'CHANGE_LOG_LEVELS',seed:'UPDATE_SEED_VALUE',smtp:'EDIT_SMTP_CONFIG'}[this.data.kind]); }
|
||||
setAll(level: string): void { this.loggers.update(items => items.map(item => ({...item,level}))); }
|
||||
save(): void {
|
||||
if (!this.canEdit()) return;
|
||||
const endpoints = {configuration:'/config/saveConfiguration.do',loggers:'/logger/setLoggers.do',seed:'/system/updateSeed.do',smtp:'/system/updateSmtpConfig.do'};
|
||||
const payload = this.data.kind === 'configuration' ? this.configForm.getRawValue() : this.data.kind === 'loggers' ? this.loggers() : this.data.kind === 'seed' ? this.seedForm.value.seed : this.smtp();
|
||||
if (this.data.kind === 'seed' && this.seedForm.invalid) return;
|
||||
if (!confirm(`Save ${this.title.toLowerCase()}?`)) return;
|
||||
this.loading.set(true); this.api.request(endpoints[this.data.kind], payload).subscribe({next:()=>this.ref.close(true),error:error=>{this.error.set(error.message);this.loading.set(false);}});
|
||||
}
|
||||
private applyLoaded(value: unknown): void {
|
||||
if (this.data.kind === 'configuration') this.configForm.patchValue(value as never);
|
||||
else if (this.data.kind === 'loggers') this.loggers.set(value as LoggerSetting[]);
|
||||
else if (this.data.kind === 'smtp') { const loaded = new Map((value as Array<{name:string;value:unknown}>).map(item=>[item.name,item.value])); this.smtp.update(items=>items.map(item=>({...item,value:this.convertSmtp(item,loaded.get(item.name))}))); }
|
||||
}
|
||||
private convertSmtp(item:SmtpSetting,value:unknown):string|number|boolean { if(value==null)return item.value;if(item.type==='boolean')return value===true||value==='true';if(item.type==='number')return Number(value);return String(value); }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { FEATURES } from '../core/models';
|
||||
import { PortalApiService } from '../core/portal-api.service';
|
||||
import { SessionService } from '../core/session.service';
|
||||
import { RealtimeService } from '../core/realtime.service';
|
||||
import type { AdminDialogKind } from './admin-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, MatButtonModule, MatIconModule, MatMenuModule, MatSidenavModule, MatToolbarModule],
|
||||
template: `
|
||||
<mat-toolbar><img src="images/portal.png" alt="Portal"><span>Portal</span><span class="spacer"></span>
|
||||
<span class="welcome">Welcome {{session.account()?.firstName}} {{session.account()?.lastName}}</span>
|
||||
<button mat-icon-button [matMenuTriggerFor]="accountMenu" aria-label="Account menu"><mat-icon>menu</mat-icon></button>
|
||||
<mat-menu #accountMenu="matMenu">
|
||||
@if(session.hasPermission('VIEW_CONFIG')){<button mat-menu-item (click)="openAdmin('configuration')"><mat-icon>settings</mat-icon>Configuration</button>}
|
||||
@if(session.hasPermission('VIEW_LOG_LEVELS')){<button mat-menu-item (click)="openAdmin('loggers')"><mat-icon>feedback</mat-icon>Log Levels</button>}
|
||||
@if(session.hasPermission('UPDATE_SEED_VALUE')){<button mat-menu-item (click)="openAdmin('seed')"><mat-icon>fiber_pin</mat-icon>AES Seed</button>}
|
||||
@if(session.hasPermission('VIEW_SMTP_CONFIG')){<button mat-menu-item (click)="openAdmin('smtp')"><mat-icon>mail</mat-icon>SMTP Configuration</button>}
|
||||
<button mat-menu-item (click)="logout()"><mat-icon>logout</mat-icon>Logout</button>
|
||||
</mat-menu>
|
||||
</mat-toolbar>
|
||||
<mat-sidenav-container><mat-sidenav mode="side" opened>@for(feature of visibleFeatures();track feature.path){<a mat-button [routerLink]="feature.path" routerLinkActive="active"><mat-icon>{{feature.icon}}</mat-icon>{{feature.title}}</a>}</mat-sidenav><mat-sidenav-content><router-outlet/></mat-sidenav-content></mat-sidenav-container>`,
|
||||
styles: [`mat-toolbar{background:#455a64;color:#fff}mat-toolbar img{height:42px;margin-right:1rem}.spacer{flex:1}.welcome{font-size:14px;margin-right:1rem}mat-sidenav-container{height:calc(100vh - 64px)}mat-sidenav{width:230px;padding:.75rem}mat-sidenav a{display:flex;justify-content:flex-start;width:100%;margin:.2rem 0}.active{background:#dde7eb}mat-sidenav-content{padding:1.25rem}`]
|
||||
})
|
||||
export class DashboardComponent {
|
||||
readonly session = inject(SessionService);
|
||||
private readonly api = inject(PortalApiService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly realtime = inject(RealtimeService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
readonly visibleFeatures = computed(() => FEATURES.filter(feature => this.session.hasPermission(feature.permission)));
|
||||
|
||||
constructor() { this.realtime.connect(this.session.account()?.stompTopics ?? []); }
|
||||
async openAdmin(kind: AdminDialogKind): Promise<void> { const { AdminDialogComponent } = await import('./admin-dialog'); this.dialog.open(AdminDialogComponent, {data:{kind},disableClose:true,width:'min(56rem,95vw)'}); }
|
||||
logout(): void { this.api.request('/account/logout.do').subscribe({next:()=>this.finishLogout(),error:()=>this.finishLogout()}); }
|
||||
private finishLogout(): void { this.realtime.disconnect(); this.session.clear(); void this.router.navigate(['/login']); }
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Component, Inject, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatOptionModule } from '@angular/material/core';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { FeatureDefinition } from '../core/models';
|
||||
import { PortalApiService } from '../core/portal-api.service';
|
||||
|
||||
interface DialogData { feature: FeatureDefinition; row?: Record<string, unknown>; }
|
||||
interface Choice { name?: string; value?: string; uid?: string; credentialType?: string; }
|
||||
|
||||
@Component({
|
||||
selector: 'app-entity-dialog',
|
||||
imports: [ReactiveFormsModule, MatButtonModule, MatDatepickerModule, MatDialogModule, MatFormFieldModule,
|
||||
MatInputModule, MatNativeDateModule, MatOptionModule, MatProgressSpinnerModule, MatSelectModule],
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ isNew ? 'Add' : 'Edit' }} {{ singularTitle }}</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="save()">
|
||||
<mat-dialog-content>
|
||||
@if (loading()) { <mat-spinner diameter="36" /> }
|
||||
@else {
|
||||
@switch (data.feature.editor) {
|
||||
@case ('git') {
|
||||
<mat-form-field><mat-label>Name</mat-label><input matInput formControlName="name"></mat-form-field>
|
||||
<mat-form-field><mat-label>Git SCM URL</mat-label><input matInput formControlName="scm"></mat-form-field>
|
||||
<mat-form-field><mat-label>Branch</mat-label><input matInput formControlName="branch"></mat-form-field>
|
||||
<mat-form-field><mat-label>Local checkout directory</mat-label><input matInput formControlName="directory"></mat-form-field>
|
||||
<mat-form-field><mat-label>Credentials</mat-label><mat-select formControlName="credentials">
|
||||
@for (choice of choices(); track choice.uid) { <mat-option [value]="choice">{{ credentialLabel(choice) }}</mat-option> }
|
||||
</mat-select></mat-form-field>
|
||||
}
|
||||
@case ('credentials') {
|
||||
<mat-form-field><mat-label>Credential name</mat-label><input matInput formControlName="name"></mat-form-field>
|
||||
<mat-form-field><mat-label>Credential type</mat-label><mat-select formControlName="credentialType">
|
||||
@for (choice of choices(); track choice.name) { <mat-option [value]="choice.name">{{ choice.value }}</mat-option> }
|
||||
</mat-select></mat-form-field>
|
||||
<mat-form-field><mat-label>Username / identity</mat-label><input matInput formControlName="username"></mat-form-field>
|
||||
@if (form.controls.credentialType.value === 'SSH') {
|
||||
<mat-form-field><mat-label>SSH private key</mat-label><textarea matInput rows="6" formControlName="key"></textarea></mat-form-field>
|
||||
<mat-form-field><mat-label>Passphrase</mat-label><input matInput type="password" formControlName="passphrase"></mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field><mat-label>Password</mat-label><input matInput type="password" formControlName="password"></mat-form-field>
|
||||
}
|
||||
}
|
||||
@case ('email') {
|
||||
<mat-form-field><mat-label>Name</mat-label><input matInput formControlName="emailName"></mat-form-field>
|
||||
<mat-form-field><mat-label>Subject</mat-label><input matInput formControlName="emailSubject"></mat-form-field>
|
||||
<mat-form-field><mat-label>Message</mat-label><textarea matInput rows="12" formControlName="emailMessage"></textarea></mat-form-field>
|
||||
}
|
||||
@case ('user') {
|
||||
<mat-form-field><mat-label>Email address</mat-label><input matInput type="email" formControlName="email"></mat-form-field>
|
||||
<div class="pair"><mat-form-field><mat-label>First name</mat-label><input matInput formControlName="firstName"></mat-form-field>
|
||||
<mat-form-field><mat-label>Last name</mat-label><input matInput formControlName="lastName"></mat-form-field></div>
|
||||
<div class="pair"><mat-form-field><mat-label>Password</mat-label><input matInput type="password" formControlName="password"></mat-form-field>
|
||||
<mat-form-field><mat-label>Confirm password</mat-label><input matInput type="password" formControlName="confPassword"></mat-form-field></div>
|
||||
<mat-form-field><mat-label>Role</mat-label><mat-select formControlName="role"><mat-option value="USER">User</mat-option><mat-option value="ADMIN">Administrator</mat-option></mat-select></mat-form-field>
|
||||
<div class="pair"><mat-form-field><mat-label>Effective date</mat-label><input matInput [matDatepicker]="effective" formControlName="effectiveDate"><mat-datepicker-toggle matIconSuffix [for]="effective"/><mat-datepicker #effective/></mat-form-field>
|
||||
<mat-form-field><mat-label>Expiration date</mat-label><input matInput [matDatepicker]="expiration" formControlName="expirationDate"><mat-datepicker-toggle matIconSuffix [for]="expiration"/><mat-datepicker #expiration/></mat-form-field></div>
|
||||
}
|
||||
}
|
||||
@if (error()) { <p class="error">{{ error() }}</p> }
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end"><button mat-button type="button" mat-dialog-close>Cancel</button><button mat-flat-button type="submit" [disabled]="loading() || form.invalid">Save</button></mat-dialog-actions>
|
||||
</form>`,
|
||||
styles: [`mat-dialog-content{display:flex;flex-direction:column;min-width:min(34rem,75vw);padding-top:.5rem}mat-form-field{width:100%}.pair{display:flex;gap:1rem}.pair>*{flex:1}.error{color:#b00020}`]
|
||||
})
|
||||
export class EntityDialogComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly api = inject(PortalApiService);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal('');
|
||||
readonly choices = signal<Choice[]>([]);
|
||||
readonly isNew: boolean;
|
||||
readonly singularTitle: string;
|
||||
readonly form = this.fb.group({
|
||||
id: [null as unknown], uid: [''], name: [''], scm: [''], branch: [''], directory: [''], credentials: [null as Choice | null],
|
||||
credentialType: [''], username: [''], password: [''], key: [''], passphrase: [''],
|
||||
emailName: [''], emailSubject: [''], emailMessage: [''],
|
||||
email: ['', Validators.email], firstName: [''], lastName: [''], confPassword: [''], role: [''], effectiveDate: [null as Date | null], expirationDate: [null as Date | null]
|
||||
});
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) readonly data: DialogData, private readonly ref: MatDialogRef<EntityDialogComponent>) {
|
||||
this.isNew = !data.row;
|
||||
this.singularTitle = data.feature.title.replace(/ies$/, 'y').replace(/s$/, '');
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const requiredByEditor: Record<string, string[]> = {
|
||||
git: ['name', 'scm', 'branch', 'directory', 'credentials'],
|
||||
credentials: ['name', 'credentialType', 'username'],
|
||||
email: ['emailName', 'emailSubject', 'emailMessage'],
|
||||
user: ['email', 'role']
|
||||
};
|
||||
for (const name of requiredByEditor[this.data.feature.editor!] ?? []) this.form.get(name)?.addValidators(Validators.required);
|
||||
this.form.patchValue(this.normalizedRow());
|
||||
if (this.data.feature.editor === 'user') {
|
||||
const password = this.form.controls.password;
|
||||
const confirmation = this.form.controls.confPassword;
|
||||
if (this.isNew) { password.addValidators(Validators.required); confirmation.addValidators(Validators.required); }
|
||||
this.form.controls.effectiveDate.setValue(this.dateValue(this.data.row?.['effectiveDate']) ?? new Date());
|
||||
this.form.controls.expirationDate.setValue(this.dateValue(this.data.row?.['expirationDate']) ?? this.defaultExpiration());
|
||||
}
|
||||
const auxiliary = this.data.feature.editor === 'git' ? this.api.request<Choice[]>('/creds/get.do')
|
||||
: this.data.feature.editor === 'credentials' ? this.api.request<Choice[]>('/creds/getCredentialTypes.do') : of([]);
|
||||
const expiration = this.data.feature.editor === 'user' && this.isNew
|
||||
? this.api.request<{userExpirationDays?: number}>('/config/getConfigurationValue.do', 'userExpirationDays') : of(null);
|
||||
forkJoin([auxiliary, expiration]).subscribe({next: ([choices, configuration]) => {
|
||||
this.choices.set(choices ?? []);
|
||||
if (configuration?.userExpirationDays) this.form.controls.expirationDate.setValue(this.defaultExpiration(configuration.userExpirationDays));
|
||||
this.loading.set(false);
|
||||
}, error: error => { this.error.set(error.message); this.loading.set(false); }});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (this.form.invalid || !this.data.feature.saveEndpoint) return;
|
||||
if (this.data.feature.editor === 'user' && this.form.value.password !== this.form.value.confPassword) { this.error.set('Passwords do not match.'); return; }
|
||||
this.loading.set(true);
|
||||
const payload = { ...this.data.row, ...this.form.getRawValue() };
|
||||
this.api.request(this.data.feature.saveEndpoint, payload).subscribe({next: () => this.ref.close(true), error: error => { this.error.set(error.message); this.loading.set(false); }});
|
||||
}
|
||||
|
||||
credentialLabel(choice: Choice): string { return `${choice.name ?? ''}${choice.credentialType === 'SSH' ? ' (SSH Key)' : ' (Username/Password)'}`; }
|
||||
private normalizedRow(): Record<string, unknown> { const row = { ...(this.data.row ?? {}) }; delete row['password']; delete row['key']; delete row['passphrase']; return row; }
|
||||
private dateValue(value: unknown): Date | null { const date = value ? new Date(String(value)) : null; return date && !Number.isNaN(date.getTime()) ? date : null; }
|
||||
private defaultExpiration(days = 3650): Date { const date = new Date(); date.setDate(date.getDate() + days); return date; }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component, Inject, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { FeatureDefinition } from '../core/models';
|
||||
import { PortalApiService } from '../core/portal-api.service';
|
||||
|
||||
interface DialogData { feature: FeatureDefinition; row?: Record<string, unknown>; }
|
||||
|
||||
@Component({
|
||||
selector: 'app-environment-dialog',
|
||||
imports: [ReactiveFormsModule, MatButtonModule, MatDialogModule, MatFormFieldModule, MatIconModule, MatInputModule],
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ data.row ? 'Edit' : 'Add' }} Environment Variables</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="save()">
|
||||
<mat-dialog-content formArrayName="variables">
|
||||
@for (group of variables.controls; track group; let index = $index) {
|
||||
<div class="variable" [formGroupName]="index">
|
||||
<mat-form-field><mat-label>Variable name</mat-label><input matInput formControlName="name"></mat-form-field>
|
||||
<mat-form-field><mat-label>Variable value</mat-label><input matInput formControlName="value"></mat-form-field>
|
||||
<button mat-icon-button type="button" aria-label="Remove variable" (click)="remove(index)" [disabled]="variables.length === 1"><mat-icon>delete</mat-icon></button>
|
||||
</div>
|
||||
}
|
||||
<button mat-button type="button" (click)="add()"><mat-icon>add</mat-icon>Add variable</button>
|
||||
@if (error()) { <p class="error">{{ error() }}</p> }
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end"><button mat-button type="button" mat-dialog-close>Cancel</button><button mat-flat-button type="submit" [disabled]="saving() || form.invalid">Save</button></mat-dialog-actions>
|
||||
</form>`,
|
||||
styles: [`mat-dialog-content{min-width:min(40rem,80vw)}.variable{display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:start}.error{color:#b00020}`]
|
||||
})
|
||||
export class EnvironmentDialogComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly api = inject(PortalApiService);
|
||||
readonly error = signal('');
|
||||
readonly saving = signal(false);
|
||||
readonly variables = this.fb.array([this.variableGroup()]);
|
||||
readonly form = this.fb.group({ variables: this.variables });
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) readonly data: DialogData, private readonly ref: MatDialogRef<EnvironmentDialogComponent>) {
|
||||
if (data.row) this.variables.at(0).patchValue(data.row);
|
||||
}
|
||||
|
||||
add(value?: Record<string, unknown>): void { this.variables.push(this.variableGroup(value)); }
|
||||
remove(index: number): void { this.variables.removeAt(index); }
|
||||
save(): void {
|
||||
if (this.form.invalid || !this.data.feature.saveEndpoint) return;
|
||||
this.saving.set(true);
|
||||
this.api.request(this.data.feature.saveEndpoint, this.variables.getRawValue()).subscribe({next: () => this.ref.close(true), error: error => { this.error.set(error.message); this.saving.set(false); }});
|
||||
}
|
||||
private variableGroup(value?: Record<string, unknown>) { return this.fb.group({ id: [value?.['id'] ?? null], name: [String(value?.['name'] ?? ''), Validators.required], value: [String(value?.['value'] ?? ''), Validators.required] }); }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { FEATURES } from '../core/models';
|
||||
import { PortalApiService } from '../core/portal-api.service';
|
||||
import { RealtimeService } from '../core/realtime.service';
|
||||
import { SessionService } from '../core/session.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-feature-page',
|
||||
imports: [MatButtonModule, MatCardModule, MatDialogModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTableModule],
|
||||
template: `
|
||||
<header>
|
||||
<div><h1>{{ feature().title }}</h1><p>Manage {{ feature().title.toLowerCase() }}</p></div>
|
||||
<div class="toolbar">
|
||||
<mat-form-field subscriptSizing="dynamic"><mat-label>Filter</mat-label><input matInput (input)="filter.set($any($event.target).value)"><mat-icon matSuffix>search</mat-icon></mat-form-field>
|
||||
@if (feature().path === 'syslogs' && session.hasPermission('PURGE_LOGS')) { <button mat-flat-button (click)="purgeLogs()"><mat-icon>delete_sweep</mat-icon>Purge</button> }
|
||||
@if (canSave()) { <button mat-flat-button (click)="edit()"><mat-icon>add</mat-icon>Add</button> }
|
||||
<button mat-button (click)="load()"><mat-icon>refresh</mat-icon>Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
@if (loading()) { <mat-spinner diameter="42" /> }
|
||||
@else if (error()) { <mat-card class="error">{{ error() }}</mat-card> }
|
||||
@else if (rows().length === 0) { <mat-card class="empty">No records found.</mat-card> }
|
||||
@else {
|
||||
<mat-card><div class="table-container"><table mat-table [dataSource]="displayedRows()">
|
||||
@for (column of dataColumns(); track column) {
|
||||
<ng-container [matColumnDef]="column"><th mat-header-cell *matHeaderCellDef>{{ label(column) }}</th><td mat-cell *matCellDef="let row">{{ display(row[column]) }}</td></ng-container>
|
||||
}
|
||||
@if (hasActions()) {
|
||||
<ng-container matColumnDef="actions"><th mat-header-cell *matHeaderCellDef>Actions</th><td mat-cell *matCellDef="let row">
|
||||
@if (canSave()) { <button mat-icon-button aria-label="Edit" (click)="edit(row)"><mat-icon>edit</mat-icon></button> }
|
||||
@if (feature().path === 'scripts' && (session.hasPermission('VIEW_SCRIPT_SCHED_STATUS') || session.hasPermission('DELETE_SCRIPT_SCHED_STATUS'))) { <button mat-icon-button aria-label="Schedules and run status" (click)="openScriptStatus(row)"><mat-icon>schedule</mat-icon></button> }
|
||||
@if (canExecute()) { <button mat-icon-button [attr.aria-label]="feature().path === 'scripts' ? 'Run' : 'Pull'" (click)="execute(row)"><mat-icon>{{ feature().path === 'scripts' ? 'play_arrow' : 'sync' }}</mat-icon></button> }
|
||||
@if (canDelete()) { <button mat-icon-button aria-label="Delete" (click)="remove(row)"><mat-icon>delete</mat-icon></button> }
|
||||
</td></ng-container>
|
||||
}
|
||||
<tr mat-header-row *matHeaderRowDef="columns()"></tr><tr mat-row *matRowDef="let row; columns: columns()"></tr>
|
||||
</table></div></mat-card>
|
||||
}`,
|
||||
styles: [`header{display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem}h1{margin:0}p{color:#607d8b}.toolbar{display:flex;gap:.5rem}.table-container{overflow:auto}table{width:100%}.error{color:#b00020;padding:1rem}.empty{padding:2rem;text-align:center}`]
|
||||
})
|
||||
export class FeaturePageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly api = inject(PortalApiService);
|
||||
private readonly realtime = inject(RealtimeService);
|
||||
readonly session = inject(SessionService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal('');
|
||||
readonly rows = signal<Record<string, unknown>[]>([]);
|
||||
readonly filter = signal('');
|
||||
readonly displayedRows = computed(() => { const term=this.filter().trim().toLowerCase(); return term ? this.rows().filter(row=>Object.values(row).some(value=>String(value??'').toLowerCase().includes(term))) : this.rows(); });
|
||||
readonly feature = computed(() => FEATURES.find(feature => feature.path === this.route.snapshot.data['feature'])!);
|
||||
readonly dataColumns = computed(() => Object.keys(this.rows()[0] ?? {}).filter(key => typeof this.rows()[0]?.[key] !== 'object').slice(0, 8));
|
||||
readonly columns = computed(() => this.hasActions() ? [...this.dataColumns(), 'actions'] : this.dataColumns());
|
||||
|
||||
constructor() { this.load(); this.realtime.triggers.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.load()); }
|
||||
hasActions(): boolean { return this.canSave() || this.canDelete() || this.canExecute() || (this.feature().path === 'scripts' && (this.session.hasPermission('VIEW_SCRIPT_SCHED_STATUS') || this.session.hasPermission('DELETE_SCRIPT_SCHED_STATUS'))); }
|
||||
canSave(): boolean { return !!this.feature().editor && !!this.feature().saveEndpoint && this.session.hasPermission(this.feature().savePermission!); }
|
||||
canDelete(): boolean { return !!this.feature().deleteEndpoint && this.session.hasPermission(this.feature().deletePermission!); }
|
||||
canExecute(): boolean { return !!this.feature().executeEndpoint && this.session.hasPermission(this.feature().executePermission!); }
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true); this.error.set('');
|
||||
this.api.request<unknown>(this.feature().loadEndpoint).subscribe({
|
||||
next: data => { this.rows.set(Array.isArray(data) ? data : (data ? [data as Record<string, unknown>] : [])); this.loading.set(false); },
|
||||
error: error => { this.error.set(error.message); this.loading.set(false); }
|
||||
});
|
||||
}
|
||||
|
||||
async edit(row?: Record<string, unknown>): Promise<void> {
|
||||
const config = { data: { feature: this.feature(), row }, disableClose: true, width: 'min(52rem, 95vw)' };
|
||||
if (this.feature().editor === 'environment') {
|
||||
const { EnvironmentDialogComponent } = await import('./environment-dialog');
|
||||
this.dialog.open(EnvironmentDialogComponent, config).afterClosed().subscribe(saved => { if (saved) this.load(); });
|
||||
} else if (this.feature().editor === 'permission') {
|
||||
const { PermissionDialogComponent } = await import('./permission-dialog');
|
||||
this.dialog.open(PermissionDialogComponent, config).afterClosed().subscribe(saved => { if (saved) this.load(); });
|
||||
} else if (this.feature().editor === 'script') {
|
||||
const { ScriptDialogComponent } = await import('./script-dialog');
|
||||
this.dialog.open(ScriptDialogComponent, config).afterClosed().subscribe(saved => { if (saved) this.load(); });
|
||||
} else {
|
||||
const { EntityDialogComponent } = await import('./entity-dialog');
|
||||
this.dialog.open(EntityDialogComponent, config).afterClosed().subscribe(saved => { if (saved) this.load(); });
|
||||
}
|
||||
}
|
||||
remove(row: Record<string, unknown>): void { if (confirm(`Delete this ${this.feature().title} record?`)) this.api.request(this.feature().deleteEndpoint!, this.feature().editor === 'environment' ? [row] : row).subscribe({ next: () => this.load(), error: error => this.error.set(error.message) }); }
|
||||
execute(row: Record<string, unknown>): void { const verb=this.feature().path==='scripts'?'run':'pull';if(!confirm(`Are you sure you want to ${verb} ${String(row['name']??'this item')}?`))return;this.api.request(this.feature().executeEndpoint!, row).subscribe({ next: () => this.load(), error: error => this.error.set(error.message) }); }
|
||||
async purgeLogs(): Promise<void> { const {PurgeLogsDialogComponent}=await import('./purge-logs-dialog');this.dialog.open(PurgeLogsDialogComponent,{disableClose:true}).afterClosed().subscribe(count=>{if(count!==undefined)this.load();}); }
|
||||
async openScriptStatus(row:Record<string,unknown>):Promise<void>{const {ScriptStatusDialogComponent}=await import('./script-status-dialog');this.dialog.open(ScriptStatusDialogComponent,{data:{script:row},width:'min(70rem,96vw)'}).afterClosed().subscribe(()=>this.load());}
|
||||
label(value: string): string { return value.replace(/([A-Z])/g, ' $1').replace(/^./, character => character.toUpperCase()); }
|
||||
display(value: unknown): string { return value == null ? '' : String(value); }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Component, Inject, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { FeatureDefinition } from '../core/models';
|
||||
import { PortalApiService } from '../core/portal-api.service';
|
||||
|
||||
interface Permission { id: number; name: string; displayName: string; permissionCategory?: string; allowed?: boolean; }
|
||||
interface Category { name: string; displayName: string; permissions: Permission[]; }
|
||||
interface DialogData { feature: FeatureDefinition; row?: Record<string, unknown>; }
|
||||
|
||||
@Component({
|
||||
selector: 'app-permission-dialog',
|
||||
imports: [FormsModule, ReactiveFormsModule, MatButtonModule, MatCheckboxModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatProgressSpinnerModule, MatSlideToggleModule, MatTabsModule],
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ data.row ? 'Edit' : 'Add' }} Permission Group</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="save()">
|
||||
<mat-dialog-content>
|
||||
@if (loading()) { <mat-spinner diameter="36" /> } @else {
|
||||
<div class="heading">
|
||||
<mat-form-field><mat-label>Unique name</mat-label><input matInput formControlName="name" [readonly]="!!data.row"></mat-form-field>
|
||||
<mat-form-field><mat-label>Display name</mat-label><input matInput formControlName="displayName"></mat-form-field>
|
||||
<mat-slide-toggle formControlName="enabled">Enabled</mat-slide-toggle>
|
||||
</div>
|
||||
<mat-tab-group>
|
||||
@for (category of categories(); track category.name) {
|
||||
<mat-tab [label]="category.displayName"><div class="permissions">
|
||||
@for (permission of category.permissions; track permission.id) { <mat-checkbox [(ngModel)]="permission.allowed" [ngModelOptions]="{standalone:true}">{{ permission.displayName }}</mat-checkbox> }
|
||||
</div></mat-tab>
|
||||
}
|
||||
</mat-tab-group>
|
||||
@if (error()) { <p class="error">{{ error() }}</p> }
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end"><button mat-button type="button" mat-dialog-close>Cancel</button><button mat-flat-button type="submit" [disabled]="loading() || form.invalid">Save</button></mat-dialog-actions>
|
||||
</form>`,
|
||||
styles: [`mat-dialog-content{min-width:min(48rem,82vw)}.heading{display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:center}.permissions{display:grid;grid-template-columns:repeat(auto-fit,minmax(15rem,1fr));gap:.75rem;padding:1rem}.error{color:#b00020}`]
|
||||
})
|
||||
export class PermissionDialogComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly api = inject(PortalApiService);
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal('');
|
||||
readonly categories = signal<Category[]>([]);
|
||||
readonly form = this.fb.group({ id: [null as unknown], name: ['', Validators.required], displayName: ['', Validators.required], enabled: [false] });
|
||||
|
||||
constructor(@Inject(MAT_DIALOG_DATA) readonly data: DialogData, private readonly ref: MatDialogRef<PermissionDialogComponent>) { this.form.patchValue(data.row ?? {}); }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.api.request<Category[]>('/permissions/getPermissionCategories.do').subscribe({
|
||||
next: categories => { this.categories.set(this.mergeSelections(categories ?? [])); this.loading.set(false); },
|
||||
error: error => { this.error.set(error.message); this.loading.set(false); }
|
||||
});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.loading.set(true);
|
||||
const permissions = this.categories().flatMap(category => category.permissions.map(permission => ({
|
||||
type: 'PermissionGroupPermission', permissionId: permission.id, permissionGroupId: this.form.value.id,
|
||||
name: permission.name, displayName: permission.displayName, allowed: !!permission.allowed
|
||||
})));
|
||||
const endpoint = this.data.row ? '/permissions/updatePermissionCategory.do' : '/permissions/addPermssionCategory.do';
|
||||
this.api.request(endpoint, { ...this.form.getRawValue(), permissions }).subscribe({next: () => this.ref.close(true), error: error => { this.error.set(error.message); this.loading.set(false); }});
|
||||
}
|
||||
|
||||
private mergeSelections(categories: Category[]): Category[] {
|
||||
const selected = new Map<number, boolean>((this.data.row?.['permissions'] as Array<{permissionId: number; allowed: boolean}> | undefined)?.map(item => [item.permissionId, item.allowed]) ?? []);
|
||||
return categories.map(category => ({ ...category, permissions: category.permissions.map(permission => ({ ...permission, allowed: selected.get(permission.id) ?? false })) }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { PortalApiService } from '../core/portal-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-purge-logs-dialog',
|
||||
imports: [ReactiveFormsModule, MatButtonModule, MatDatepickerModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatNativeDateModule],
|
||||
template: `<h2 mat-dialog-title>Purge Logs</h2><form [formGroup]="form" (ngSubmit)="purge()"><mat-dialog-content><p>Select the inclusive date range to purge.</p>
|
||||
<mat-form-field><mat-label>Start date</mat-label><input matInput [matDatepicker]="start" formControlName="purgeStartDate"><mat-datepicker-toggle matIconSuffix [for]="start"/><mat-datepicker #start/></mat-form-field>
|
||||
<mat-form-field><mat-label>End date</mat-label><input matInput [matDatepicker]="end" formControlName="purgeEndDate"><mat-datepicker-toggle matIconSuffix [for]="end"/><mat-datepicker #end/></mat-form-field>
|
||||
@if(error()){<p class="error">{{error()}}</p>}</mat-dialog-content><mat-dialog-actions align="end"><button mat-button type="button" mat-dialog-close>Cancel</button><button mat-flat-button type="submit" [disabled]="form.invalid||saving()">Purge</button></mat-dialog-actions></form>`,
|
||||
styles: [`mat-dialog-content{display:grid;min-width:min(28rem,75vw)}.error{color:#b00020}`]
|
||||
})
|
||||
export class PurgeLogsDialogComponent {
|
||||
private readonly fb=inject(FormBuilder); private readonly api=inject(PortalApiService); private readonly ref=inject(MatDialogRef<PurgeLogsDialogComponent>);
|
||||
readonly saving=signal(false); readonly error=signal(''); readonly form=this.fb.group({purgeStartDate:[null as Date|null,Validators.required],purgeEndDate:[null as Date|null,Validators.required]});
|
||||
purge():void{if(this.form.invalid)return;const {purgeStartDate,purgeEndDate}=this.form.getRawValue();if(purgeStartDate!>purgeEndDate!){this.error.set('Start date must be on or before end date.');return;}if(!confirm('Permanently purge logs in this date range?'))return;this.saving.set(true);this.api.request<number>('/logs/purge.do',this.form.getRawValue()).subscribe({next:count=>this.ref.close(count),error:error=>{this.error.set(error.message);this.saving.set(false);}});}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Component, Inject, OnInit, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { FeatureDefinition } from '../core/models';
|
||||
import { PortalApiService } from '../core/portal-api.service';
|
||||
|
||||
interface DialogData { feature: FeatureDefinition; row?: Record<string, unknown>; }
|
||||
interface Choice { id?:number; uid?:string; name?:string; value?:string; emailName?:string; email?:string; username?:string; firstName?:string; lastName?:string; }
|
||||
type Item = any;
|
||||
|
||||
@Component({
|
||||
selector:'app-script-dialog',
|
||||
imports:[FormsModule,MatButtonModule,MatCheckboxModule,MatDialogModule,MatFormFieldModule,MatIconModule,MatInputModule,MatProgressSpinnerModule,MatSelectModule,MatTabsModule],
|
||||
template:`<h2 mat-dialog-title>{{data.row?'Edit':'Add'}} Script</h2><mat-dialog-content>
|
||||
@if(loading()){<mat-spinner diameter="36"/>}@else{
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Script"><div class="grid">
|
||||
<mat-form-field><mat-label>Name</mat-label><input matInput [(ngModel)]="script.name" required></mat-form-field>
|
||||
<mat-form-field><mat-label>Type</mat-label><mat-select [(ngModel)]="script.scriptType" required>@for(type of scriptTypes();track type.name){<mat-option [value]="type.name">{{type.value}}</mat-option>}</mat-select></mat-form-field>
|
||||
<mat-form-field><mat-label>Base directory</mat-label><input matInput [(ngModel)]="script.baseDirectory"></mat-form-field>
|
||||
<mat-form-field><mat-label>Output directory</mat-label><input matInput [(ngModel)]="script.outputDirectory"></mat-form-field>
|
||||
<mat-form-field class="wide"><mat-label>Command</mat-label><textarea matInput rows="5" [(ngModel)]="script.command" required></textarea></mat-form-field>
|
||||
</div></mat-tab>
|
||||
@if(script.scriptType==='REMOTE'){<mat-tab label="Remote"><div class="grid">
|
||||
<mat-form-field><mat-label>Host</mat-label><input matInput [(ngModel)]="script.remote.url"></mat-form-field><mat-form-field><mat-label>Port</mat-label><input matInput type="number" [(ngModel)]="script.remote.port"></mat-form-field>
|
||||
<mat-form-field class="wide"><mat-label>Credentials</mat-label><mat-select [(ngModel)]="script.remote.scriptCredentials">@for(cred of credentials();track cred.uid){<mat-option [value]="cred">{{cred.name}}</mat-option>}</mat-select></mat-form-field>
|
||||
</div></mat-tab>}
|
||||
@if(script.scriptType==='SCP'){<mat-tab label="SCP"><div class="grid">
|
||||
<mat-form-field><mat-label>From host (LOCAL supported)</mat-label><input matInput [(ngModel)]="script.scp.fromHost"></mat-form-field><mat-form-field><mat-label>From port</mat-label><input matInput type="number" [(ngModel)]="script.scp.fromPort"></mat-form-field>
|
||||
<mat-form-field><mat-label>From credentials</mat-label><mat-select [(ngModel)]="script.scp.fromCredentials">@for(cred of credentials();track cred.uid){<mat-option [value]="cred">{{cred.name}}</mat-option>}</mat-select></mat-form-field>
|
||||
<mat-form-field><mat-label>To host (LOCAL supported)</mat-label><input matInput [(ngModel)]="script.scp.toHost"></mat-form-field><mat-form-field><mat-label>To port</mat-label><input matInput type="number" [(ngModel)]="script.scp.toPort"></mat-form-field>
|
||||
<mat-form-field><mat-label>To credentials</mat-label><mat-select [(ngModel)]="script.scp.toCredentials">@for(cred of credentials();track cred.uid){<mat-option [value]="cred">{{cred.name}}</mat-option>}</mat-select></mat-form-field>
|
||||
<mat-form-field class="wide"><mat-label>Data regex</mat-label><input matInput [(ngModel)]="script.scp.dataRegex"></mat-form-field>
|
||||
</div></mat-tab>}
|
||||
<mat-tab label="Parameters"><div class="collection">@for(item of script.parameters;track item;let i=$index){<div><mat-form-field><mat-label>Parameter</mat-label><input matInput [(ngModel)]="item.parameter"></mat-form-field><button mat-icon-button (click)="remove(script.parameters,i)"><mat-icon>delete</mat-icon></button></div>}<button mat-button (click)="add('parameters',{id:null,parameter:'',script:script.id})"><mat-icon>add</mat-icon>Add parameter</button></div></mat-tab>
|
||||
<mat-tab label="Variables"><div class="collection">@for(item of script.scriptVariables;track item;let i=$index){<div><mat-form-field><mat-label>Name</mat-label><input matInput [(ngModel)]="item.name"></mat-form-field><mat-form-field><mat-label>Value</mat-label><input matInput [(ngModel)]="item.value"></mat-form-field><button mat-icon-button (click)="remove(script.scriptVariables,i)"><mat-icon>delete</mat-icon></button></div>}<button mat-button (click)="add('scriptVariables',{id:null,name:'',value:'',script:script.id})"><mat-icon>add</mat-icon>Add variable</button></div></mat-tab>
|
||||
<mat-tab label="Schedules"><div class="collection">@for(item of script.schedule;track item;let i=$index){<div class="schedule"><mat-form-field><mat-label>Frequency</mat-label><mat-select [(ngModel)]="item.frequency">@for(freq of frequencies();track freq.name){<mat-option [value]="freq.name">{{freq.value}}</mat-option>}</mat-select></mat-form-field><mat-form-field><mat-label>Start</mat-label><input matInput type="datetime-local" [(ngModel)]="item.startDatetime"></mat-form-field><mat-form-field><mat-label>End</mat-label><input matInput type="datetime-local" [(ngModel)]="item.endDatetime"></mat-form-field><mat-form-field><mat-label>Max executions (0 = unlimited)</mat-label><input matInput type="number" [(ngModel)]="item.maxExecutions"></mat-form-field><button mat-icon-button (click)="remove(script.schedule,i)"><mat-icon>delete</mat-icon></button></div>}<button mat-button (click)="addSchedule()"><mat-icon>add</mat-icon>Add schedule</button></div></mat-tab>
|
||||
<mat-tab label="Chains"><div class="collection">@for(item of script.scriptChain;track item;let i=$index){<div><mat-form-field><mat-label>Child script</mat-label><mat-select [(ngModel)]="item.childScript">@for(child of availableScripts();track child.id){<mat-option [value]="child.id">{{child.name}}</mat-option>}</mat-select></mat-form-field><mat-form-field><mat-label>Required exit status</mat-label><input matInput type="number" [(ngModel)]="item.exitStatus"></mat-form-field><mat-form-field><mat-label>Delay minutes</mat-label><input matInput type="number" [(ngModel)]="item.executeAfterMin"></mat-form-field><button mat-icon-button (click)="remove(script.scriptChain,i)"><mat-icon>delete</mat-icon></button></div>}<button mat-button (click)="add('scriptChain',{id:null,parentScript:script.id,childScript:null,exitStatus:0,executeAfterMin:0})"><mat-icon>add</mat-icon>Add chain</button></div></mat-tab>
|
||||
<mat-tab label="Email"><div class="collection">@for(item of script.scriptEmails;track item;let i=$index){<div><mat-form-field><mat-label>Template</mat-label><mat-select [(ngModel)]="item.id" (selectionChange)="selectEmail(item,$event.value)">@for(email of emails();track email.id){<mat-option [value]="email.id">{{email.emailName}}</mat-option>}</mat-select></mat-form-field><mat-form-field><mat-label>Recipients</mat-label><mat-select multiple [(ngModel)]="item.recipients">@for(user of users();track user.id){<mat-option [value]="user">{{user.email||user.username}}</mat-option>}</mat-select></mat-form-field><button mat-icon-button (click)="remove(script.scriptEmails,i)"><mat-icon>delete</mat-icon></button></div>}<button mat-button (click)="add('scriptEmails',{id:null,emailName:'',emailSubject:'',emailMessage:'',scriptId:script.id,recipients:[]})"><mat-icon>add</mat-icon>Add notification</button></div></mat-tab>
|
||||
</mat-tab-group>@if(error()){<p class="error">{{error()}}</p>}}
|
||||
</mat-dialog-content><mat-dialog-actions align="end"><button mat-button mat-dialog-close>Cancel</button><button mat-flat-button (click)="save()" [disabled]="loading()">Save</button></mat-dialog-actions>`,
|
||||
styles:[`mat-dialog-content{min-width:min(70rem,88vw);height:min(45rem,72vh)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:0 1rem;padding:1rem}.wide{grid-column:1/-1}.collection{padding:1rem}.collection>div{display:flex;gap:1rem;align-items:start}.collection mat-form-field{flex:1}.schedule{display:grid!important;grid-template-columns:repeat(4,1fr) auto}.error{color:#b00020}`]
|
||||
})
|
||||
export class ScriptDialogComponent implements OnInit{
|
||||
private readonly api=inject(PortalApiService);private readonly ref=inject(MatDialogRef<ScriptDialogComponent>);readonly loading=signal(true);readonly error=signal('');
|
||||
readonly scriptTypes=signal<Choice[]>([]);readonly frequencies=signal<Choice[]>([]);readonly credentials=signal<Choice[]>([]);readonly availableScripts=signal<Choice[]>([]);readonly emails=signal<Choice[]>([]);readonly users=signal<Choice[]>([]);
|
||||
readonly script:Item;
|
||||
constructor(@Inject(MAT_DIALOG_DATA)readonly data:DialogData){this.script=this.normalize(data.row);}
|
||||
ngOnInit():void{forkJoin({types:this.api.request<Choice[]>('/scripts/getScriptType.do'),freq:this.api.request<Choice[]>('/scripts/getScheduleFrequencies.do'),creds:this.api.request<Choice[]>('/creds/get.do'),scripts:this.api.request<Choice[]>('/scripts/getAll.do'),emails:this.api.request<Choice[]>('/system/getEmailTemplates.do'),users:this.api.request<Choice[]>('/account/getAll.do')}).subscribe({next:value=>{this.scriptTypes.set(value.types);this.frequencies.set(value.freq);this.credentials.set(value.creds);this.availableScripts.set(value.scripts.filter(item=>item.id!==this.script.id));this.emails.set(value.emails);this.users.set(value.users);this.loading.set(false);},error:error=>{this.error.set(error.message);this.loading.set(false);}});}
|
||||
add(key:string,item:Item):void{this.script[key].push(item);} remove(array:Item[],index:number):void{array.splice(index,1);}
|
||||
addSchedule():void{this.script.schedule.unshift({id:null,jobId:'',startDatetime:this.localDate(new Date()),endDatetime:null,frequency:null,maxExecutions:0,currentExecutionCount:0,lastRun:null,script:this.script.id,executionStatus:null});}
|
||||
selectEmail(target:Item,id:number):void{const source=this.emails().find(item=>item.id===id);if(source)Object.assign(target,structuredClone(source),{scriptId:this.script.id,recipients:target.recipients??[]});}
|
||||
save():void{if(!this.script.name||!this.script.command||!this.script.scriptType){this.error.set('Name, type, and command are required.');return;}const payload=structuredClone(this.script);payload.schedule=payload.schedule.map((item:Item)=>({...item,startDatetime:this.timestamp(item.startDatetime),endDatetime:this.timestamp(item.endDatetime),lastRun:this.timestamp(item.lastRun)}));this.loading.set(true);this.api.request(payload.id==null?'/scripts/addScript.do':'/scripts/updateScript.do',payload).subscribe({next:()=>this.ref.close(true),error:error=>{this.error.set(error.message);this.loading.set(false);}});}
|
||||
private normalize(row?:Record<string,unknown>):Item{const value=structuredClone(row??{}) as Item;const result:Item={id:null,name:'',command:'',scriptType:'',baseDirectory:'',outputDirectory:'',...value};result.remote={scriptId:value.id??null,scriptCredentials:null,url:'',port:22,...value.remote};result.scp={id:null,scriptId:value.id??null,fromHost:'',fromPort:22,fromCredentials:null,toHost:'',toPort:22,toCredentials:null,dataRegex:'',...value.scp};result.parameters=value.parameters??[];result.schedule=(value.schedule??[]).map((item:Item)=>({...item,startDatetime:this.localDate(item.startDatetime),endDatetime:this.localDate(item.endDatetime)}));result.scriptVariables=value.scriptVariables??[];result.scriptChain=value.scriptChain??[];result.scriptEmails=value.scriptEmails??[];return result;}
|
||||
private timestamp(value:unknown):number|undefined{if(value==null||value==='')return undefined;if(typeof value==='number')return value;const result=new Date(String(value)).getTime();return Number.isNaN(result)?undefined:result;}
|
||||
private localDate(value:unknown):string{if(!value)return'';const date=new Date(typeof value==='number'&&value<1e12?value*1000:value as string|number|Date);if(Number.isNaN(date.getTime()))return'';const offset=date.getTimezoneOffset()*60000;return new Date(date.getTime()-offset).toISOString().slice(0,16);}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Component, Inject, inject, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { PortalApiService } from '../core/portal-api.service';
|
||||
import { SessionService } from '../core/session.service';
|
||||
|
||||
interface DialogData { script: Record<string, any>; }
|
||||
|
||||
@Component({
|
||||
selector:'app-script-status-dialog',
|
||||
imports:[DatePipe,MatButtonModule,MatCardModule,MatDialogModule,MatExpansionModule,MatIconModule,MatProgressSpinnerModule],
|
||||
template:`<h2 mat-dialog-title>Schedules — {{data.script['name']}}</h2><mat-dialog-content>
|
||||
<section class="schedules">@for(schedule of schedules();track schedule['id']){<mat-card><div><strong>{{schedule['jobId']||'Unscheduled'}}</strong><span>{{schedule['frequency']}}</span><span>{{schedule['startDatetime']|date:'medium'}}</span><span>{{statusLabel(schedule)}}</span>
|
||||
@if(canViewStatus()&&schedule['jobId']){<button mat-icon-button aria-label="View run status" (click)="loadStatus(schedule)"><mat-icon>history</mat-icon></button>}
|
||||
@if(canDeleteSchedule()){<button mat-icon-button aria-label="Delete schedule" (click)="deleteSchedule(schedule)"><mat-icon>delete</mat-icon></button>}</div></mat-card>}@empty{<p>No schedules configured.</p>}</section>
|
||||
@if(loading()){<mat-spinner diameter="36"/>}@else if(selectedJob()){
|
||||
<h3>Runs for {{selectedJob()}}</h3>@for(run of runs();track run['id'];let index=$index){<mat-expansion-panel><mat-expansion-panel-header><mat-panel-title>Run {{runs().length-index}}</mat-panel-title><mat-panel-description [class.failed]="run['exitStatus']!==0">{{run['exitStatus']===0?'Success':'Failed'}} · {{run['startTime']|date:'medium'}}</mat-panel-description></mat-expansion-panel-header><pre>{{run['results']}}</pre>
|
||||
@if(run['resourceFiles']?.length){<h4>Generated files</h4>@for(file of run['resourceFiles'];track file['id']){<div class="file"><span>{{file['fileName']}}</span><a mat-icon-button aria-label="Download" [href]="downloadUrl(file)" target="_blank"><mat-icon>download</mat-icon></a>@if(canDeleteFile()){<button mat-icon-button aria-label="Delete file" (click)="deleteFile(file)"><mat-icon>delete</mat-icon></button>}</div>}}</mat-expansion-panel>}@empty{<p>No run status found.</p>}
|
||||
}@if(error()){<p class="error">{{error()}}</p>}
|
||||
</mat-dialog-content><mat-dialog-actions align="end"><button mat-button mat-dialog-close>Close</button></mat-dialog-actions>`,
|
||||
styles:[`mat-dialog-content{min-width:min(65rem,86vw);max-height:75vh}.schedules mat-card{padding:.65rem;margin:.4rem 0}.schedules mat-card>div{display:grid;grid-template-columns:2fr 1fr 2fr 1fr auto auto;align-items:center;gap:.75rem}.failed,.error{color:#b00020}pre{white-space:pre-wrap;max-height:20rem;overflow:auto}.file{display:flex;align-items:center}.file span{flex:1}`]
|
||||
})
|
||||
export class ScriptStatusDialogComponent{
|
||||
private readonly api=inject(PortalApiService);readonly session=inject(SessionService);
|
||||
readonly schedules=signal<Record<string,any>[]>([]);readonly runs=signal<Record<string,any>[]>([]);readonly selectedJob=signal('');readonly loading=signal(false);readonly error=signal('');
|
||||
constructor(@Inject(MAT_DIALOG_DATA)readonly data:DialogData){this.schedules.set(data.script['schedule']??[]);}
|
||||
canViewStatus():boolean{return this.session.hasPermission('VIEW_SCRIPT_SCHED_STATUS');}canDeleteSchedule():boolean{return this.session.hasPermission('DELETE_SCRIPT_SCHED_STATUS');}canDeleteFile():boolean{return this.session.hasPermission('DELETE_FILE');}
|
||||
statusLabel(schedule:Record<string,any>):string{return schedule['currentExecutionCount']==null?'Not run':schedule['executionStatus']===0?'Success':'Failed';}
|
||||
loadStatus(schedule:Record<string,any>):void{this.loading.set(true);this.error.set('');this.selectedJob.set(String(schedule['jobId']));this.api.request<Record<string,any>[]>('/scripts/getScriptScheduleStatus.do',schedule['jobId']).subscribe({next:runs=>{this.runs.set(runs??[]);this.loading.set(false);},error:error=>{this.error.set(error.message);this.loading.set(false);}});}
|
||||
deleteSchedule(schedule:Record<string,any>):void{if(!confirm(`Delete schedule ${schedule['jobId']}?`))return;this.api.request('/scripts/deleteScriptSchedule.do',schedule).subscribe({next:()=>this.schedules.update(items=>items.filter(item=>item!==schedule)),error:error=>this.error.set(error.message)});}
|
||||
deleteFile(file:Record<string,any>):void{if(!confirm(`Delete ${file['fileName']}?`))return;this.api.request('/file/delete.do',file).subscribe({next:()=>this.runs.update(runs=>runs.map(run=>({...run,resourceFiles:(run['resourceFiles']??[]).filter((item:Record<string,any>)=>item['id']!==file['id'])}))),error:error=>this.error.set(error.message)});}
|
||||
downloadUrl(file:Record<string,any>):string{return `/portal/file/download.do?id=${encodeURIComponent(file['id'])}`;}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
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 { Account } from '../core/models';
|
||||
import { PortalApiService } from '../core/portal-api.service';
|
||||
import { SessionService } from '../core/session.service';
|
||||
import { RealtimeService } from '../core/realtime.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
imports: [ReactiveFormsModule, MatButtonModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule],
|
||||
template: `<main class="login-page"><mat-card><img src="images/portal.png" alt="Portal"><h1>Portal</h1>
|
||||
<form [formGroup]="form" (ngSubmit)="login()">
|
||||
<mat-form-field><mat-label>Username</mat-label><mat-icon matPrefix>account_circle</mat-icon><input matInput formControlName="username" autocomplete="username"></mat-form-field>
|
||||
<mat-form-field><mat-label>Password</mat-label><mat-icon matPrefix>lock</mat-icon><input matInput formControlName="password" type="password" autocomplete="current-password"></mat-form-field>
|
||||
<p class="connection">Server connection: {{ realtime.status() }}</p>@if (error()) { <p class="error">{{ error() }}</p> }
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid || processing()">{{ processing() ? 'Signing in…' : 'Login' }}</button>
|
||||
</form></mat-card></main>`,
|
||||
styles: [`.login-page{min-height:100vh;display:grid;place-items:center;background:#eceff1}mat-card{width:min(420px,90vw);padding:2rem;text-align:center}img{max-width:180px;margin:auto}form{display:grid;gap:.75rem}.error{color:#b00020}`]
|
||||
})
|
||||
export class LoginComponent {
|
||||
private readonly api = inject(PortalApiService); private readonly session = inject(SessionService); private readonly router = inject(Router); readonly realtime = inject(RealtimeService);
|
||||
readonly processing = signal(false); readonly error = signal('');
|
||||
readonly form = new FormGroup({ username: new FormControl('', { nonNullable: true, validators: Validators.required }), password: new FormControl('', { nonNullable: true, validators: Validators.required }) });
|
||||
constructor() { this.realtime.connect(); }
|
||||
login(): void {
|
||||
if (this.form.invalid) return; this.processing.set(true); this.error.set('');
|
||||
this.api.request<Account>('/account/login.do', this.form.getRawValue()).subscribe({
|
||||
next: account => { if (account['message']) { this.error.set(String(account['message'])); this.processing.set(false); return; } this.session.setAccount(account); this.realtime.connect(account.stompTopics ?? []); void this.router.navigate(['/dashboard']); },
|
||||
error: error => { this.error.set(error.message); this.processing.set(false); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Portal</title>
|
||||
<base href="/portal/client/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
@@ -0,0 +1,3 @@
|
||||
// sockjs-client still expects the Node-style `global` alias in a browser.
|
||||
// Define it before Angular loads the application bundle.
|
||||
(globalThis as typeof globalThis & { global: typeof globalThis }).global = globalThis;
|
||||
@@ -0,0 +1,5 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
html { @include mat.theme((color: (primary: mat.$azure-palette, tertiary: mat.$blue-palette), typography: Arial, density: 0)); }
|
||||
html, body { height: 100%; margin: 0; font-family: Arial, sans-serif; background: #f4f6f7; }
|
||||
* { box-sizing: border-box; }
|
||||
Reference in new issue
Block a user