Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface SystemSettingsPatchRequest {
domainWhitelistOnly?: boolean;
verificationRequiredPaths?: string[];
rateLimitingEnabled?: boolean;
rateLimitingCooldownMinutes?: number;
logoutFromAllDevices?: boolean;
passwordHistoryCount?: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface SystemSettingsResponse {
domainWhitelistOnly: boolean;
verificationRequiredPaths: string[];
rateLimitingEnabled: boolean;
rateLimitingCooldownMinutes: number;
logoutFromAllDevices: boolean;
passwordHistoryCount: number;
}
20 changes: 20 additions & 0 deletions frontend/Exence/src/app/private/admin/admin-settings.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { inject, Injectable } from '@angular/core';
import { HttpService } from '../../shared/http/http.service';
import { SystemSettingsResponse } from '../../data-model/modules/admin/SystemSettingsResponse';
import { SystemSettingsPatchRequest } from '../../data-model/modules/admin/SystemSettingsPatchRequest';
import { lastValueFrom } from 'rxjs';

@Injectable()
export class AdminSettingsService {
private readonly http = inject(HttpService);

private baseUrl = '/api/admin/settings';

public getSettings(): Promise<SystemSettingsResponse> {
return lastValueFrom(this.http.get<SystemSettingsResponse>(this.baseUrl));
}

public updateSettings(request: SystemSettingsPatchRequest): Promise<SystemSettingsResponse> {
return lastValueFrom(this.http.patch<SystemSettingsResponse>(this.baseUrl, request));
}
}
4 changes: 4 additions & 0 deletions frontend/Exence/src/app/private/admin/admin.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ <h2 class="m-0 text-center text-md-start {{ display.isSm() ? 'fs-1' : 'fs-2' }}"

<ng-template #configurationsTab>
<section class="d-flex flex-column gap-3 overflow-y-auto px-2">
<ex-system-settings />

<mat-divider />

<ex-admin-registration />

<mat-divider />
Expand Down
2 changes: 2 additions & 0 deletions frontend/Exence/src/app/private/admin/admin.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { TranslatePipe } from '../../shared/pipes/translate.pipe';
import { AdminRegistrationComponent } from './admin-registration/admin-registration.component';
import { AdminStatisticsListComponent } from './admin-statistics-list/admin-statistics-list.component';
import { EmailBroadcastComponent } from './email-broadcast/email-broadcast.component';
import { SystemSettingsComponent } from './system-settings/system-settings.component';

@Component({
selector: 'ex-admin',
Expand All @@ -21,6 +22,7 @@ import { EmailBroadcastComponent } from './email-broadcast/email-broadcast.compo
AdminStatisticsListComponent,
AdminRegistrationComponent,
EmailBroadcastComponent,
SystemSettingsComponent,
TranslatePipe,
],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<ex-dialog-card>
<div ex-dialog-card-title>{{ 'admin.configurations.systemSettings.verificationRequiredPaths' | translate }}</div>

<form ex-dialog-card-content class="d-flex flex-column gap-3 py-2" [formGroup]="form" (submit)="add()">
<mat-form-field>
<mat-label>{{ 'admin.configurations.systemSettings.addPath' | translate }}</mat-label>
<input matInput [formControl]="form.controls.path" (keydown.enter)="add(); $event.preventDefault()" />
</mat-form-field>
</form>

<div
ex-dialog-card-actions
class="d-flex flex-column-reverse flex-sm-row justify-content-end align-items-center gap-2 w-100"
>
<ex-button outline color="accent" matIcon="close" type="button" (click)="dialogRef.close(null)">
{{ 'literals.cancel' | translate }}
</ex-button>
<ex-button filled color="primary" matIcon="add" [disabled]="form.invalid" (click)="add()">
{{ 'literals.add' | translate }}
</ex-button>
</div>
</ex-dialog-card>
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { ButtonComponent } from '../../../../shared/button/button.component';
import { DialogCardComponent } from '../../../../shared/dialog-card/dialog-card.component';
import { DialogComponent, DialogRef } from '../../../../shared/dialog/dialog.service';
import { TranslatePipe } from '../../../../shared/pipes/translate.pipe';

@Component({
selector: 'ex-add-path-dialog',
templateUrl: './add-path-dialog.component.html',
imports: [ReactiveFormsModule, MatFormFieldModule, MatInput, ButtonComponent, DialogCardComponent, TranslatePipe],
})
export class AddPathDialogComponent extends DialogComponent<undefined, string | null> {
private readonly fb = inject(NonNullableFormBuilder);

readonly form = this.fb.group({
path: this.fb.control<string>('', [Validators.required]),
});

constructor() {
super(inject(DialogRef));
}

add(): void {
const trimmed = this.form.controls.path.value.trim();
if (!trimmed) return;
this.dialogRef.submit(trimmed);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<h2 class="mb-4 fs-4">{{ 'admin.configurations.systemSettings.title' | translate }}</h2>

@if (settingsResource.isLoading()) {
<div class="d-flex flex-column gap-4 w-100">
<div class="d-flex flex-column flex-md-row gap-4 flex-wrap">
@for (_ of [1, 2, 3]; track $index) {
<div class="skeleton-toggle-card flex-grow-1 d-flex flex-column gap-3 p-3">
<div class="d-flex align-items-center gap-2">
<ex-animated-skeleton-loader width="24px" height="24px" shape="circle" />
<ex-animated-skeleton-loader width="130px" height="20px" />
<ex-animated-skeleton-loader class="ms-auto" width="38px" height="22px" />
</div>
<div class="d-flex align-items-center justify-content-between gap-1">
<ex-animated-skeleton-loader width="85%" height="16px" />
<ex-animated-skeleton-loader width="22px" height="22px" shape="circle" />
</div>
</div>
}
</div>

<div class="d-flex flex-column flex-md-row gap-4">
@for (_ of [1, 2]; track $index) {
<ex-animated-skeleton-loader width="220px" height="56px" />
}
</div>

<div class="d-flex flex-column gap-2">
<ex-animated-skeleton-loader width="200px" height="14px" />
<div class="d-flex gap-2">
@for (_ of [1, 2, 3]; track $index) {
<ex-animated-skeleton-loader width="90px" height="32px" />
}
</div>
</div>

<div class="d-flex justify-content-end">
<ex-animated-skeleton-loader width="110px" height="36px" />
</div>
</div>
} @else {
<form [formGroup]="form" (submit)="save()" class="d-flex flex-column gap-4 w-100">
<div class="d-flex flex-column flex-md-row flex-wrap flex-xl-nowrap gap-4">
<ex-toggle-card
class="flex-grow-1"
matIcon="shield"
label="admin.configurations.systemSettings.domainWhitelistOnly"
description="admin.configurations.systemSettings.domainWhitelistOnlyDescription"
tooltip="admin.configurations.systemSettings.domainWhitelistOnlyTooltip"
[value]="formValue().domainWhitelistOnly"
(valueChange)="form.controls.domainWhitelistOnly.setValue($event)"
/>

<ex-toggle-card
class="flex-grow-1"
matIcon="speed"
label="admin.configurations.systemSettings.rateLimitingEnabled"
description="admin.configurations.systemSettings.rateLimitingEnabledDescription"
tooltip="admin.configurations.systemSettings.rateLimitingEnabledTooltip"
[value]="formValue().rateLimitingEnabled"
(valueChange)="form.controls.rateLimitingEnabled.setValue($event)"
/>

<ex-toggle-card
class="flex-grow-1"
matIcon="devices"
label="admin.configurations.systemSettings.logoutFromAllDevices"
description="admin.configurations.systemSettings.logoutFromAllDevicesDescription"
tooltip="admin.configurations.systemSettings.logoutFromAllDevicesTooltip"
[value]="formValue().logoutFromAllDevices"
(valueChange)="form.controls.logoutFromAllDevices.setValue($event)"
/>
</div>

<div class="d-flex flex-column flex-md-row gap-4">
<ex-amount-stepper
[control]="form.controls.rateLimitingCooldownMinutes"
label="admin.configurations.systemSettings.rateLimitingCooldownMinutes"
[min]="1"
[step]="1"
[disabled]="!formValue().rateLimitingEnabled"
[matTooltip]="'admin.configurations.systemSettings.rateLimitingCooldownDisabled' | translate"
[matTooltipDisabled]="formValue().rateLimitingEnabled"
/>

<ex-amount-stepper
[control]="form.controls.passwordHistoryCount"
label="admin.configurations.systemSettings.passwordHistoryCount"
[min]="0"
[step]="1"
/>
</div>

<div class="d-flex flex-column gap-2">
<span class="text-muted small">{{
'admin.configurations.systemSettings.verificationRequiredPaths' | translate
}}</span>
<mat-chip-set>
<mat-chip (click)="openAddPathDialog()">
<mat-icon matChipAvatar>add</mat-icon>
{{ 'literals.add' | translate }}
</mat-chip>
@for (path of verificationPaths(); track path) {
<mat-chip (removed)="removePath(path)">
{{ path }}
<button matChipRemove>
<mat-icon>cancel</mat-icon>
</button>
</mat-chip>
}
</mat-chip-set>
</div>

<div class="d-flex justify-content-end gap-2">
<ex-button matIcon="cancel" outlined [disabled]="saving()" (click)="cancel()">
{{ 'literals.cancel' | translate }}
</ex-button>
<ex-button matIcon="save" iconPositionEnd filled type="submit" [disabled]="form.invalid || saving()">
{{ 'literals.save' | translate }}
</ex-button>
</div>
</form>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.skeleton-toggle-card {
border-radius: 10px;
border: 1px solid var(--primary-color);
background-color: var(--app-card-color);
box-shadow: 0 0 7px var(--shadow-color);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Component, inject, resource, signal } from '@angular/core';
import { toRawValueSignal } from '../../../shared/util/utils';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslocoService } from '@jsverse/transloco';
import { SystemSettingsPatchRequest } from '../../../data-model/modules/admin/SystemSettingsPatchRequest';
import { AmountStepperComponent } from '../../../shared/amount-stepper/amount-stepper.component';
import { AnimatedSkeletonLoaderComponent } from '../../../shared/animated-skeleton-loader/animated-skeleton-loader.component';
import { ToggleCardComponent } from '../../../shared/toggle-card/toggle-card.component';
import { ButtonComponent } from '../../../shared/button/button.component';
import { DialogService } from '../../../shared/dialog/dialog.service';
import { TranslatePipe } from '../../../shared/pipes/translate.pipe';
import { SnackbarService } from '../../../shared/snackbar/snackbar.service';
import { AdminSettingsService } from '../admin-settings.service';
import { AddPathDialogComponent } from './add-path-dialog/add-path-dialog.component';

@Component({
selector: 'ex-system-settings',
templateUrl: './system-settings.component.html',
styleUrl: './system-settings.component.scss',
imports: [
ReactiveFormsModule,
MatFormFieldModule,
MatChipsModule,
MatIconModule,
MatTooltipModule,
AmountStepperComponent,
AnimatedSkeletonLoaderComponent,
ToggleCardComponent,
ButtonComponent,
TranslatePipe,
],
providers: [AdminSettingsService],
})
export class SystemSettingsComponent {
private readonly fb = inject(NonNullableFormBuilder);
private readonly adminSettingsService = inject(AdminSettingsService);
private readonly snackbarService = inject(SnackbarService);
private readonly translocoService = inject(TranslocoService);
private readonly dialogService = inject(DialogService);

readonly saving = signal(false);
readonly verificationPaths = signal<string[]>([]);

readonly form = this.fb.group({
domainWhitelistOnly: this.fb.control<boolean>(false),
rateLimitingEnabled: this.fb.control<boolean>(false),
rateLimitingCooldownMinutes: this.fb.control<number>(1, [Validators.required, Validators.min(1)]),
logoutFromAllDevices: this.fb.control<boolean>(false),
passwordHistoryCount: this.fb.control<number>(0, [Validators.required, Validators.min(0)]),
});
readonly formValue = toRawValueSignal(this.form);

readonly settingsResource = resource({
loader: async () => {
const settings = await this.adminSettingsService.getSettings();
this.verificationPaths.set(settings.verificationRequiredPaths);
this.form.patchValue({
domainWhitelistOnly: settings.domainWhitelistOnly,
rateLimitingEnabled: settings.rateLimitingEnabled,
rateLimitingCooldownMinutes: settings.rateLimitingCooldownMinutes,
logoutFromAllDevices: settings.logoutFromAllDevices,
passwordHistoryCount: settings.passwordHistoryCount,
});
return settings;
},
});

async openAddPathDialog(): Promise<void> {
const path = await this.dialogService.openNonModal<undefined, string | null>(AddPathDialogComponent, undefined);
if (path && !this.verificationPaths().includes(path)) {
this.verificationPaths.update(paths => [...paths, path]);
}
}

removePath(path: string): void {
this.verificationPaths.update(paths => paths.filter(p => p !== path));
}

cancel(): void {
this.settingsResource.reload();
}

async save(): Promise<void> {
if (this.saving() || this.form.invalid) return;
this.saving.set(true);

try {
const formValue = this.form.getRawValue();
const request: SystemSettingsPatchRequest = {
domainWhitelistOnly: formValue.domainWhitelistOnly,
verificationRequiredPaths: this.verificationPaths(),
rateLimitingEnabled: formValue.rateLimitingEnabled,
rateLimitingCooldownMinutes: formValue.rateLimitingCooldownMinutes,
logoutFromAllDevices: formValue.logoutFromAllDevices,
passwordHistoryCount: formValue.passwordHistoryCount,
};
const updated = await this.adminSettingsService.updateSettings(request);
this.verificationPaths.set(updated.verificationRequiredPaths);
this.snackbarService.showSuccess(
this.translocoService.translate('admin.configurations.systemSettings.success'),
);
} finally {
this.saving.set(false);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<div class="d-flex align-items-center gap-2">
@if (matIcon()) {
<mat-icon size="lg" class="text-primary flex-shrink-0">{{ matIcon() }}</mat-icon>
}
@if (svgIcon()) {
<mat-icon size="lg" class="text-primary flex-shrink-0" [svgIcon]="svgIcon()!" />
}
<span class="fw-semibold fs-5">{{ label() | translate }}</span>
<mat-slide-toggle class="ms-auto" [formControl]="control" />
</div>
<div class="d-flex align-items-center justify-content-between gap-1">
<span class="small flex-grow-1 fs-6">{{ description() | translate }}</span>
<ex-info-button block [tooltip]="tooltip() | translate" />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
:host {
display: block;
padding: 1rem;
border-radius: 10px;
border: 1px solid var(--primary-color);
background-color: var(--app-card-color);
box-shadow: 0 0 7px var(--shadow-color);
transition: box-shadow 0.3s ease;

display: flex;
flex-direction: column;
justify-content: space-between;
gap: 1rem;

&.active {
box-shadow: 0 0 14px 2px var(--primary-color);
}
}
Loading
Loading