feat: rewrite setup wizard

This commit is contained in:
Polianin Nikita 2024-12-18 07:09:29 +03:00
parent 86e6f59567
commit fba28b6bbe
28 changed files with 993 additions and 127 deletions

View File

@ -0,0 +1,17 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
@Injectable()
export default class SecurityService extends ApiService {
public readonly basePath = 'Security/';
public readonly version = AvailableVersion.v1;
public generateTotpQrCode(totpKey: string, username: string) {
let request = this.createRequestBuilder()
.setEndpoint('GenerateTotpQrCode')
.setQueryParams({totpKey: totpKey, label: username})
.build;
return this.combinedUrl(request);
}
}

View File

@ -1,12 +1,17 @@
import {Injectable} from "@angular/core"; import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service"; import ApiService, {AvailableVersion} from "@api/api.service";
import {DatabaseRequest} from "@api/v1/databaseRequest"; import {catchError, of, switchMap} from "rxjs";
import {CacheRequest} from "@api/v1/cacheRequest"; import {DatabaseResponse} from "@api/v1/configuration/databaseResponse";
import {DatabaseRequest} from "@api/v1/configuration/databaseRequest";
import {CacheRequest} from "@api/v1/configuration/cacheRequest";
import {CreateUserRequest} from "@api/v1/createUserRequest"; import {CreateUserRequest} from "@api/v1/createUserRequest";
import {LoggingRequest} from "@api/v1/loggingRequest"; import {LoggingRequest} from "@api/v1/configuration/loggingRequest";
import {EmailRequest} from "@api/v1/emailRequest"; import {ScheduleConfigurationRequest} from "@api/v1/configuration/scheduleConfigurationRequest";
import {ScheduleConfigurationRequest} from "@api/v1/scheduleConfigurationRequest"; import {EmailRequest} from "@api/v1/configuration/emailRequest";
import {DateOnly} from "@model/DateOnly"; import {DateOnly} from "@model/dateOnly";
import {CacheResponse} from "@api/v1/configuration/cacheResponse";
import {PasswordPolicy} from "@model/passwordPolicy";
import {UserResponse} from "@api/v1/userResponse";
@Injectable() @Injectable()
export default class SetupService extends ApiService { export default class SetupService extends ApiService {
@ -23,6 +28,17 @@ export default class SetupService extends ApiService {
return this.get<boolean>(request); return this.get<boolean>(request);
} }
public isConfiguredToken() {
let request = this.createRequestBuilder()
.setEndpoint('IsConfiguredToken')
.setWithCredentials()
.build;
return this.get<boolean>(request).pipe(catchError(_ => {
return of(false);
}));
}
public setPsql(data: DatabaseRequest) { public setPsql(data: DatabaseRequest) {
let request = this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetPsql') .setEndpoint('SetPsql')
@ -53,6 +69,15 @@ export default class SetupService extends ApiService {
return this.post<boolean>(request); return this.post<boolean>(request);
} }
public databaseConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('DatabaseConfiguration')
.setWithCredentials()
.build;
return this.get<DatabaseResponse>(request);
}
public setRedis(data: CacheRequest) { public setRedis(data: CacheRequest) {
let request = this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetRedis') .setEndpoint('SetRedis')
@ -72,6 +97,34 @@ export default class SetupService extends ApiService {
return this.post<boolean>(request); return this.post<boolean>(request);
} }
public cacheConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('CacheConfiguration')
.setWithCredentials()
.build;
return this.get<CacheResponse>(request);
}
public setPasswordPolicy(data: PasswordPolicy | null) {
let request = this.createRequestBuilder()
.setEndpoint('SetPasswordPolicy')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public passwordPolicyConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('PasswordPolicyConfiguration')
.setWithCredentials()
.build;
return this.get<PasswordPolicy>(request);
}
public createAdmin(data: CreateUserRequest) { public createAdmin(data: CreateUserRequest) {
let request = this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('CreateAdmin') .setEndpoint('CreateAdmin')
@ -82,6 +135,22 @@ export default class SetupService extends ApiService {
return this.post<boolean>(request); return this.post<boolean>(request);
} }
public adminConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('UpdateAdminConfiguration')
.setWithCredentials()
.build;
return this.get(request).pipe(switchMap(_ => {
request = this.createRequestBuilder()
.setEndpoint('AdminConfiguration')
.setWithCredentials()
.build;
return this.get<UserResponse>(request);
}));
}
public setLogging(data: LoggingRequest | null = null) { public setLogging(data: LoggingRequest | null = null) {
let request = this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetLogging') .setEndpoint('SetLogging')
@ -92,6 +161,15 @@ export default class SetupService extends ApiService {
return this.post<boolean>(request); return this.post<boolean>(request);
} }
public loggingConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('LoggingConfiguration')
.setWithCredentials()
.build;
return this.get<LoggingRequest>(request);
}
public setEmail(data: EmailRequest | null = null) { public setEmail(data: EmailRequest | null = null) {
let request = this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetEmail') .setEndpoint('SetEmail')
@ -114,6 +192,34 @@ export default class SetupService extends ApiService {
return this.post<boolean>(request); return this.post<boolean>(request);
} }
public generateTotpKey() {
let request = this.createRequestBuilder()
.setEndpoint('GenerateTotpKey')
.setWithCredentials()
.build;
return this.get<string>(request);
}
public verifyTotp(code: string) {
let request = this.createRequestBuilder()
.setEndpoint('VerifyTotp')
.setWithCredentials()
.setQueryParams({code: code})
.build;
return this.get<boolean>(request);
}
public scheduleConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('ScheduleConfiguration')
.setWithCredentials()
.build;
return this.get<ScheduleConfigurationRequest>(request);
}
public submit() { public submit() {
let request = this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('Submit') .setEndpoint('Submit')

View File

@ -9,6 +9,8 @@ import {SetupComponent} from "@page/setup/setup.component";
import {CreateAdminComponent} from "@page/setup/create-admin/create-admin.component"; import {CreateAdminComponent} from "@page/setup/create-admin/create-admin.component";
import {SummaryComponent} from "@page/setup/summary/summary.component"; import {SummaryComponent} from "@page/setup/summary/summary.component";
import {LoginComponent} from "@page/login/login.component"; import {LoginComponent} from "@page/login/login.component";
import {PasswordPolicyComponent} from "@page/setup/password-policy/password-policy.component";
import {TwoFactorComponent} from "@page/setup/two-factor/two-factor.component";
export const routes: Routes = [ export const routes: Routes = [
{path: '', title: 'Расписание', pathMatch: 'full', component: ScheduleComponent}, {path: '', title: 'Расписание', pathMatch: 'full', component: ScheduleComponent},
@ -21,6 +23,8 @@ export const routes: Routes = [
{path: 'schedule', component: SetupScheduleComponent}, {path: 'schedule', component: SetupScheduleComponent},
{path: 'logging', component: LoggingComponent}, {path: 'logging', component: LoggingComponent},
{path: 'summary', component: SummaryComponent}, {path: 'summary', component: SummaryComponent},
{path: 'password-policy', component: PasswordPolicyComponent},
{path: 'two-factor', component: TwoFactorComponent},
{path: '', redirectTo: 'welcome', pathMatch: 'full'} {path: '', redirectTo: 'welcome', pathMatch: 'full'}
] ]
}, },

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Social_Icons" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<style>
.cls-1 {
fill: #ea4335;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
fill-rule: evenodd;
}
.cls-5 {
fill: none;
}
.cls-2 {
fill: #4285f4;
}
.cls-3 {
fill: #fbbc05;
}
.cls-4 {
fill: #34a853;
}
</style>
</defs>
<g id="_x31__stroke">
<g id="Google">
<rect width="128" height="128" rx="96" ry="96" fill="#fff"/>
<rect class="cls-5" width="128" height="128"/>
<g transform="scale(0.67, 0.67)" transform-origin="center">
<path class="cls-3"
d="M27.58,64c0-4.16.69-8.14,1.92-11.88L7.94,35.65c-4.2,8.53-6.57,18.15-6.57,28.35s2.37,19.8,6.56,28.33l21.56-16.5c-1.22-3.72-1.9-7.69-1.9-11.83"/>
<path class="cls-1"
d="M65.46,26.18c9.03,0,17.19,3.2,23.6,8.44l18.64-18.62C96.34,6.11,81.77,0,65.46,0,40.13,0,18.36,14.48,7.94,35.65l21.57,16.47c4.97-15.09,19.14-25.94,35.95-25.94"/>
<path class="cls-4"
d="M65.46,101.82c-16.81,0-30.98-10.85-35.95-25.94l-21.57,16.47c10.42,21.17,32.19,35.65,57.52,35.65,15.63,0,30.56-5.55,41.76-15.95l-20.47-15.83c-5.78,3.64-13.05,5.6-21.28,5.6"/>
<path class="cls-2"
d="M126.63,64c0-3.78-.58-7.85-1.46-11.64h-59.72v24.73h34.38c-1.72,8.43-6.4,14.91-13.09,19.13l20.47,15.83c11.77-10.92,19.42-27.19,19.42-48.05"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="mailru" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 492.91 492.91">
<defs>
<style>
.cls-1 {
fill: #fff;
fill-rule: evenodd;
}
.cls-2 {
fill: #0874fc;
}
</style>
</defs>
<circle class="cls-2" cx="246.45" cy="246.46" r="246.46"/>
<g id="Logo">
<g id="yellow">
<path class="cls-1"
d="M241.66,168.96c21.11,0,40.96,9.33,55.53,23.94v.05c0-7.01,4.72-12.3,11.28-12.3l1.66-.02c10.25,0,12.35,9.7,12.35,12.78l.05,109.06c-.73,7.14,7.36,10.82,11.85,6.25,17.51-17.99,38.46-92.52-10.89-135.69-45.99-40.25-107.7-33.62-140.52-11-34.89,24.06-57.21,77.31-35.53,127.32,23.64,54.57,91.28,70.83,131.49,54.61,20.36-8.22,29.77,19.3,8.62,28.3-31.95,13.62-120.86,12.25-162.4-59.71-28.06-48.59-26.57-134.08,47.86-178.37,56.94-33.88,132.01-24.49,177.28,22.78,47.32,49.42,44.56,141.96-1.59,177.96-20.91,16.34-51.97.43-51.77-23.39l-.21-7.79c-14.56,14.45-33.94,22.88-55.05,22.88-41.71,0-78.41-36.71-78.41-78.4,0-42.13,36.7-79.25,78.41-79.25h0ZM294.16,245.19c-1.57-30.54-24.24-48.91-51.62-48.91h-1.03c-31.59,0-49.11,24.84-49.11,53.06,0,31.6,21.2,51.56,48.99,51.56,30.99,0,51.37-22.7,52.84-49.55l-.07-6.17Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="yandex" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<style>
.cls-1 {
fill: white;
}
</style>
</defs>
<g id="_x33_91-yandex">
<rect width="512" height="512" rx="256" ry="256" fill="red"/>
<g transform="scale(0.67, 0.67) translate(100, 128)">
<path class="cls-1"
d="M278.55,309.73l-78.52,176.27h-57.23l86.25-188.49c-40.52-20.58-67.56-57.86-67.56-126.77-.09-96.49,61.1-144.74,133.78-144.74h73.94v460h-49.5v-176.27h-41.15ZM319.7,67.78h-26.42c-39.89,0-78.52,26.41-78.52,102.96s35.4,97.75,78.52,97.75h26.42V67.78h0Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@ -9,12 +9,15 @@
</mat-sidenav-content> </mat-sidenav-content>
<mat-sidenav-content> <mat-sidenav-content>
<app-table [currentWeek]="currentWeek" [startWeek]="startWeek" [data]="data" [isLoad]="isLoadTable" [disciplineWithWeeks]="disciplineWithWeeks"/> <app-table [currentWeek]="currentWeek" [startWeek]="startWeek" [data]="data" [isLoad]="isLoadTable"
[disciplineWithWeeks]="disciplineWithWeeks"/>
</mat-sidenav-content> </mat-sidenav-content>
<mat-sidenav-content style="display: flex; justify-content: space-between; align-items: center;"> <mat-sidenav-content style="display: flex; justify-content: space-between; align-items: center;">
<mat-checkbox (change)="changeDisciplineWeeksView($event.checked)" [checked]="disciplineWithWeeks">Показать недели в дисциплине</mat-checkbox> <mat-checkbox (change)="changeDisciplineWeeksView($event.checked)" [checked]="disciplineWithWeeks">Показать недели в
@if(excelImportLoader) { дисциплине
</mat-checkbox>
@if (excelImportLoader) {
<app-data-spinner/> <app-data-spinner/>
} @else { } @else {
<button mat-button (click)="openDialog()" *appHasRole="AuthRoles.Admin">Импортировать расписание (.xlsx)</button> <button mat-button (click)="openDialog()" *appHasRole="AuthRoles.Admin">Импортировать расписание (.xlsx)</button>

View File

@ -5,12 +5,12 @@ import {TabsComponent, TabsSelect} from "@component/schedule/tabs/tabs.component
import {catchError, Observable} from "rxjs"; import {catchError, Observable} from "rxjs";
import {ScheduleService} from "@api/v1/schedule.service"; import {ScheduleService} from "@api/v1/schedule.service";
import {ScheduleResponse} from "@api/v1/scheduleResponse"; import {ScheduleResponse} from "@api/v1/scheduleResponse";
import {PeriodTimes} from "@model/pairPeriodTime"; import {PairPeriodTime} from "@model/pairPeriodTime";
import {ActivatedRoute} from "@angular/router"; import {ActivatedRoute} from "@angular/router";
import {TabStorageService} from "@service/tab-storage.service"; import {TabStorageService} from "@service/tab-storage.service";
import {MatDialog} from "@angular/material/dialog"; import {MatDialog} from "@angular/material/dialog";
import {ConfirmDialogComponent} from "@page/schedule/confirm-dialog.component"; import {ConfirmDialogComponent} from "@page/schedule/confirm-dialog.component";
import {AuthRoles} from "@model/AuthRoles"; import {AuthRoles} from "@model/authRoles";
import {ImportService} from "@api/v1/import.service"; import {ImportService} from "@api/v1/import.service";
import {ScheduleRequest} from "@api/v1/scheduleRequest"; import {ScheduleRequest} from "@api/v1/scheduleRequest";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
@ -50,7 +50,7 @@ export class ScheduleComponent {
protected data: ScheduleResponse[] = []; protected data: ScheduleResponse[] = [];
protected startTerm: Date; protected startTerm: Date;
protected isLoadTable: boolean = false; protected isLoadTable: boolean = false;
protected pairPeriods: PeriodTimes = {}; protected pairPeriods: PairPeriodTime | null = null;
protected disciplineWithWeeks: boolean = false; protected disciplineWithWeeks: boolean = false;
protected excelImportLoader: boolean = false; protected excelImportLoader: boolean = false;

View File

@ -15,7 +15,7 @@
<mat-form-field color="accent"> <mat-form-field color="accent">
<mat-label>База данных</mat-label> <mat-label>База данных</mat-label>
<mat-select (valueChange)="onDatabaseChange($event)"> <mat-select (valueChange)="onDatabaseChange($event)" [value]="database">
<mat-option value="redis">Redis</mat-option> <mat-option value="redis">Redis</mat-option>
<mat-option value="memcached">Memcached</mat-option> <mat-option value="memcached">Memcached</mat-option>
</mat-select> </mat-select>
@ -29,7 +29,8 @@
<input matInput <input matInput
matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6' matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6'
required required
formControlName="server"> formControlName="server"
focusNext="serverNextFocus">
@if (databaseForm.get('server')?.hasError('required')) { @if (databaseForm.get('server')?.hasError('required')) {
<mat-error> <mat-error>
@ -49,7 +50,9 @@
<input matInput <input matInput
matTooltip="Укажите порт сервера" matTooltip="Укажите порт сервера"
required required
formControlName="port"> formControlName="port"
id="serverNextFocus"
focusNext="passwordNextFocus">
@if (databaseForm.get('port')?.hasError('required')) { @if (databaseForm.get('port')?.hasError('required')) {
<mat-error> <mat-error>
@ -69,7 +72,9 @@
<input matInput <input matInput
matTooltip="Укажите пароль" matTooltip="Укажите пароль"
formControlName="password" formControlName="password"
[type]="hidePass ? 'password' : 'text'"> [type]="hidePass ? 'password' : 'text'"
id="passwordNextFocus"
focusNext="nextButtonFocus">
<button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'" <button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'"
[attr.aria-pressed]="hidePass"> [attr.aria-pressed]="hidePass">

View File

@ -8,6 +8,9 @@ import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip"; import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button"; import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon"; import {MatIcon} from "@angular/material/icon";
import {of} from "rxjs";
import {CacheType} from "@model/cacheType";
import {FocusNextDirective} from "@/directives/focus-next.directive";
@Component({ @Component({
selector: 'app-cache', selector: 'app-cache',
@ -19,7 +22,8 @@ import {MatIcon} from "@angular/material/icon";
MatInput, MatInput,
MatTooltip, MatTooltip,
MatIconButton, MatIconButton,
MatIcon MatIcon,
FocusNextDirective
], ],
templateUrl: './cache.component.html' templateUrl: './cache.component.html'
}) })
@ -40,6 +44,35 @@ export class CacheComponent {
this.databaseForm.valueChanges.subscribe(() => { this.databaseForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.databaseForm.valid); this.navigationService.setNextButtonState(this.databaseForm.valid);
}); });
this.api.cacheConfiguration().subscribe(response => {
if (!response)
return;
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
this.databaseForm.patchValue({
server: response.server,
port: response.port,
password: response.password,
});
let type: string;
switch (response.type) {
case CacheType.Redis:
type = "redis";
break;
case CacheType.Memcached:
type = "memcached";
break;
}
this.database = type;
this.onDatabaseChange(type);
});
} }
onDatabaseChange(selectedDatabase: string) { onDatabaseChange(selectedDatabase: string) {

View File

@ -28,7 +28,7 @@
@if (createAdminForm.get('user')?.hasError('pattern')) { @if (createAdminForm.get('user')?.hasError('pattern')) {
<mat-error> <mat-error>
Имя пользователя должен содержать латинские сиволы и цифры и быть не менее 4 символов Имя пользователя должен содержать латинские символы и цифры и быть не менее 4 символов
</mat-error> </mat-error>
} }
</mat-form-field> </mat-form-field>
@ -74,13 +74,23 @@
@if (createAdminForm.get('password')?.hasError('minlength')) { @if (createAdminForm.get('password')?.hasError('minlength')) {
<mat-error> <mat-error>
Пароль должен быть не менее 8 символов Пароль должен быть не менее {{ policy.minimumLength }} символов
</mat-error> </mat-error>
} }
@if (createAdminForm.get('password')?.hasError('pattern')) { @if (createAdminForm.get('password')?.hasError('pattern')) {
<mat-error> <mat-error>
Пароль должен содержать хотя бы один латинский символ верхнего регистра и специальный символ (!&#x40;#$%^&*) Пароль должен содержать:
@if (policy.requireLettersDifferentCase) {
* Латинские символы разных регистров
} @else if (policy.requireLetter) {
* Один латинский символ
} @else if (policy.requireDigit) {
* Одну цифру
}
@if (policy.requireSpecialCharacter) {
* специальный символ
}
</mat-error> </mat-error>
} }
</mat-form-field> </mat-form-field>
@ -105,5 +115,8 @@
</mat-error> </mat-error>
} }
</mat-form-field> </mat-form-field>
<OAuthProviders [canUnlink]="true" [activeProvidersId]="activatedProviders"
[message]="'Или можете получить часть данных от сторонних сервисов'"/>
</div> </div>
</form> </form>

View File

@ -1,5 +1,5 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; import {FormBuilder, FormGroup, ReactiveFormsModule, ValidatorFn, Validators} from "@angular/forms";
import {NavigationService} from "@service/navigation.service"; import {NavigationService} from "@service/navigation.service";
import {passwordMatchValidator} from '@service/password-match.validator'; import {passwordMatchValidator} from '@service/password-match.validator';
import SetupService from "@api/v1/setup.service"; import SetupService from "@api/v1/setup.service";
@ -9,6 +9,10 @@ import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip"; import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button"; import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon"; import {MatIcon} from "@angular/material/icon";
import AuthApiService from "@api/v1/authApiService";
import {PasswordPolicy} from "@model/passwordPolicy";
import {OAuthProviders} from "@component/OAuthProviders/OAuthProviders";
import {OAuthProvider} from "@model/oAuthProvider";
@Component({ @Component({
selector: 'app-create-admin', selector: 'app-create-admin',
@ -20,15 +24,19 @@ import {MatIcon} from "@angular/material/icon";
MatInput, MatInput,
MatTooltip, MatTooltip,
MatIconButton, MatIconButton,
MatIcon MatIcon,
OAuthProviders
], ],
templateUrl: './create-admin.component.html' templateUrl: './create-admin.component.html',
providers: [AuthApiService]
}) })
export class CreateAdminComponent { export class CreateAdminComponent {
protected createAdminForm!: FormGroup; protected createAdminForm!: FormGroup;
protected hidePass = true; protected hidePass = true;
protected hideRetypePass = true; protected hideRetypePass = true;
protected policy!: PasswordPolicy;
protected activatedProviders: OAuthProvider[] = [];
constructor( constructor(
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) { private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
@ -41,12 +49,6 @@ export class CreateAdminComponent {
{validators: passwordMatchValidator('password', 'retype')} {validators: passwordMatchValidator('password', 'retype')}
); );
this.createAdminForm.get('password')?.setValidators([Validators.required,
Validators.pattern(/[A-Z]/),
Validators.pattern(/[!@#$%^&*]/),
Validators.minLength(8)
]);
this.navigationService.setNextButtonState(false); this.navigationService.setNextButtonState(false);
this.createAdminForm.valueChanges.subscribe(() => { this.createAdminForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.createAdminForm.valid); this.navigationService.setNextButtonState(this.createAdminForm.valid);
@ -60,6 +62,44 @@ export class CreateAdminComponent {
} }
); );
}; };
this.api.passwordPolicyConfiguration().subscribe(policy => {
this.policy = policy;
const passwordValidators = this.createPasswordValidators(policy);
this.createAdminForm.get('password')?.setValidators(passwordValidators);
this.createAdminForm.get('password')?.updateValueAndValidity();
});
this.api.adminConfiguration().subscribe(configuration => {
if (configuration) {
this.createAdminForm.get('email')?.setValue(configuration.email);
this.createAdminForm.get('user')?.setValue(configuration.username);
this.activatedProviders = configuration.usedOAuthProviders;
}
});
}
private createPasswordValidators(policy: PasswordPolicy): ValidatorFn[] {
const validators: ValidatorFn[] = [Validators.required];
if (policy.minimumLength) {
validators.push(Validators.minLength(policy.minimumLength));
}
if (policy.requireLettersDifferentCase) {
validators.push(Validators.pattern(/(?=.*[a-z])(?=.*[A-Z])/));
} else if (policy.requireLetter) {
validators.push(Validators.pattern(/[A-Za-z]/));
} else if (policy.requireDigit) {
validators.push(Validators.pattern(/\d/));
}
if (policy.requireSpecialCharacter) {
validators.push(Validators.pattern(/[!@#$%^&*(),.?":{}|<>]/));
}
return validators;
} }
protected togglePassword(event: MouseEvent) { protected togglePassword(event: MouseEvent) {

View File

@ -17,9 +17,9 @@
</p> </p>
<mat-form-field color="accent"> <mat-form-field color="accent">
<mat-label>База данных</mat-label> <mat-label>База данных</mat-label>
<mat-select (valueChange)="onDatabaseChange($event)"> <mat-select (valueChange)="onDatabaseChange($event)" [value]="database">
<mat-option value="SetMysql">MySQL</mat-option> <mat-option value="mysql">MySQL</mat-option>
<mat-option value="SetPsql">PostgreSQL</mat-option> <mat-option value="psql">PostgreSQL</mat-option>
<mat-option value="sqlite">Sqlite</mat-option> <mat-option value="sqlite">Sqlite</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
@ -57,7 +57,8 @@
<input matInput <input matInput
matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6' matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6'
required required
formControlName="server"> formControlName="server"
focusNext="portNextFocus">
@if (databaseForm.get('server')?.hasError('required')) { @if (databaseForm.get('server')?.hasError('required')) {
<mat-error> <mat-error>
@ -77,7 +78,9 @@
<input matInput <input matInput
matTooltip="Укажите порт сервера" matTooltip="Укажите порт сервера"
required required
formControlName="port"> formControlName="port"
id="portNextFocus"
focusNext="databaseNextFocus">
@if (databaseForm.get('port')?.hasError('required')) { @if (databaseForm.get('port')?.hasError('required')) {
<mat-error> <mat-error>
@ -97,7 +100,9 @@
<input matInput <input matInput
matTooltip="Укажите название базы данных" matTooltip="Укажите название базы данных"
required required
formControlName="database_name"> formControlName="database_name"
id="databaseNextFocus"
focusNext="userNextFocus">
@if (databaseForm.get('database_name')?.hasError('required')) { @if (databaseForm.get('database_name')?.hasError('required')) {
<mat-error> <mat-error>
@ -117,7 +122,9 @@
<input matInput <input matInput
matTooltip="Укажите пользователя, который имеет доступ к базе данных" matTooltip="Укажите пользователя, который имеет доступ к базе данных"
required required
formControlName="user"> formControlName="user"
id="userNextFocus"
focusNext="passwordNextFocus">
@if (databaseForm.get('user')?.hasError('required')) { @if (databaseForm.get('user')?.hasError('required')) {
<mat-error> <mat-error>
@ -137,7 +144,9 @@
<input matInput <input matInput
matTooltip="Укажите пароль" matTooltip="Укажите пароль"
formControlName="password" formControlName="password"
[type]="hidePass ? 'password' : 'text'"> [type]="hidePass ? 'password' : 'text'"
id="passwordNextFocus"
focusNext="nextButtonFocus">
<button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'" <button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'"
[attr.aria-pressed]="hidePass"> [attr.aria-pressed]="hidePass">

View File

@ -2,14 +2,17 @@ import {Component} from '@angular/core';
import {NavigationService} from "@service/navigation.service"; import {NavigationService} from "@service/navigation.service";
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import SetupService from "@api/v1/setup.service"; import SetupService from "@api/v1/setup.service";
import {DatabaseRequest} from "@api/v1/databaseRequest";
import {MatFormFieldModule} from "@angular/material/form-field"; import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select"; import {MatSelectModule} from "@angular/material/select";
import {MatInput} from "@angular/material/input"; import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip"; import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button"; import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon" import {MatIcon} from "@angular/material/icon";
import {MatCheckbox} from "@angular/material/checkbox"; import {MatCheckbox} from "@angular/material/checkbox";
import {DatabaseRequest} from "@api/v1/configuration/databaseRequest";
import {of} from "rxjs";
import {DatabaseType} from "@model/databaseType";
import {FocusNextDirective} from "@/directives/focus-next.directive";
@Component({ @Component({
selector: 'app-database', selector: 'app-database',
@ -22,7 +25,8 @@ import {MatCheckbox} from "@angular/material/checkbox";
MatTooltip, MatTooltip,
MatIconButton, MatIconButton,
MatIcon, MatIcon,
MatCheckbox MatCheckbox,
FocusNextDirective
], ],
templateUrl: './database.component.html' templateUrl: './database.component.html'
}) })
@ -49,6 +53,42 @@ export class DatabaseComponent {
this.databaseForm.valueChanges.subscribe(() => { this.databaseForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.databaseForm.valid); this.navigationService.setNextButtonState(this.databaseForm.valid);
}); });
this.api.databaseConfiguration().subscribe(response => {
if (!response)
return;
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
this.databaseForm.patchValue({
server: response.server,
port: response.port,
database_name: response.database,
user: response.user,
ssl: response.ssl,
password: response.password,
folder: response.pathToDatabase
});
let type: string;
switch (response.type) {
case DatabaseType.Mysql:
type = "mysql";
break;
case DatabaseType.PostgresSql:
type = "psql";
break;
case DatabaseType.Sqlite:
type = "sqlite";
break;
}
this.database = type;
this.onDatabaseChange(type);
});
} }
private createForm(database: string) { private createForm(database: string) {

View File

@ -33,7 +33,3 @@
</mat-form-field> </mat-form-field>
</div> </div>
</form> </form>
<div style="display: flex; justify-content: center;">
<button mat-flat-button color="accent" (click)="skipButton()">Пропустить</button>
</div>

View File

@ -6,8 +6,8 @@ import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select"; import {MatSelectModule} from "@angular/material/select";
import {MatInput} from "@angular/material/input"; import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip"; import {MatTooltip} from "@angular/material/tooltip";
import {MatButton, MatIconButton} from "@angular/material/button";
import {MatCheckbox} from "@angular/material/checkbox"; import {MatCheckbox} from "@angular/material/checkbox";
import {of} from "rxjs";
@Component({ @Component({
selector: 'app-logging', selector: 'app-logging',
@ -18,9 +18,7 @@ import {MatCheckbox} from "@angular/material/checkbox";
MatSelectModule, MatSelectModule,
MatInput, MatInput,
MatTooltip, MatTooltip,
MatIconButton, MatCheckbox
MatCheckbox,
MatButton
], ],
templateUrl: './logging.component.html' templateUrl: './logging.component.html'
@ -39,12 +37,11 @@ export class LoggingComponent {
} }
} }
protected skipButton() {
this.navigationService.skipNavigation.emit(() => this.api.setLogging(null));
}
constructor( constructor(
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) { private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => this.api.setLogging(null);
this.loggingSettings = this.formBuilder.group({ this.loggingSettings = this.formBuilder.group({
enabled: [true, Validators.required], enabled: [true, Validators.required],
logPath: [''], logPath: [''],
@ -59,11 +56,23 @@ export class LoggingComponent {
this.navigationService.nextButtonAction = () => { this.navigationService.nextButtonAction = () => {
return this.api.setLogging({ return this.api.setLogging({
"enableLogToFile": this.loggingSettings.get('cron')?.value, "enableLogToFile": this.loggingSettings.get('enabled')?.value,
"logFileName": this.loggingSettings.get('logName')?.value, "logFileName": this.loggingSettings.get('logName')?.value,
"logFilePath": this.loggingSettings.get('logPath')?.value "logFilePath": this.loggingSettings.get('logPath')?.value
} }
); );
}; };
api.loggingConfiguration().subscribe(x => {
if (!x)
return;
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
this.loggingSettings.get('enabled')?.setValue(x.enableLogToFile);
this.loggingSettings.get('logName')?.setValue(x.logFileName);
this.loggingSettings.get('logPath')?.setValue(x.logFilePath);
});
} }
} }

View File

@ -0,0 +1,48 @@
<h1>Настройка политики паролей</h1>
<hr/>
<p class="mat-body-2 secondary">
Задайте параметры для обеспечения безопасности паролей.
<br/>
Можно установить минимальную длину пароля и другие требования, чтобы усилить защиту учетных записей.
</p>
<form [formGroup]="policyForm">
<p>
Введите данные для настройки политики паролей:
</p>
<div style="display:flex; flex-direction: column;">
<mat-form-field color="accent">
<mat-label>Минимальная длина пароля</mat-label>
<input matInput
type="number"
matTooltip="Укажите минимальное количество длины пароля"
formControlName="minimumLength">
@if (policyForm.get('minimumLength')?.hasError('min')) {
<mat-error>
Пароль не может быть меньше 6 символов
</mat-error>
}
@if (policyForm.get('minimumLength')?.hasError('max')) {
<mat-error>
Пароль не может быть больше 12 символов
</mat-error>
}
</mat-form-field>
<mat-checkbox formControlName="requireLetter">
Требовать наличие букв в пароле
</mat-checkbox>
<mat-checkbox formControlName="requireLettersDifferentCase">
Требовать буквы разного регистра (заглавные и строчные)
</mat-checkbox>
<mat-checkbox formControlName="requireDigit">
Требовать наличие цифр в пароле
</mat-checkbox>
<mat-checkbox formControlName="requireSpecialCharacter">
Требовать наличие специальных символов (например, !, $, #)
</mat-checkbox>
</div>
</form>

View File

@ -0,0 +1,77 @@
import {Component} from '@angular/core';
import {MatCheckbox} from "@angular/material/checkbox";
import {MatError, MatFormField, MatLabel} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {NavigationService} from "@service/navigation.service";
import SetupService from "@api/v1/setup.service";
import {of} from "rxjs";
@Component({
selector: 'app-password-policy',
standalone: true,
imports: [
MatCheckbox,
MatFormField,
MatInput,
MatLabel,
MatTooltip,
ReactiveFormsModule,
MatError
],
templateUrl: './password-policy.component.html'
})
export class PasswordPolicyComponent {
protected policyForm!: FormGroup;
constructor(
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
this.policyForm = this.formBuilder.group({
minimumLength: ['', [
Validators.required,
Validators.min(6),
Validators.max(12)
]],
requireLetter: [false],
requireLettersDifferentCase: [false],
requireDigit: [false],
requireSpecialCharacter: [false]
});
this.api.passwordPolicyConfiguration().subscribe(response => {
if (!response)
return;
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
this.policyForm.patchValue({
minimumLength: response.minimumLength,
requireLetter: response.requireLetter,
requireLettersDifferentCase: response.requireLettersDifferentCase,
requireDigit: response.requireDigit,
requireSpecialCharacter: response.requireSpecialCharacter
});
});
this.navigationService.setNextButtonState(false);
this.policyForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.policyForm.valid);
});
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => this.api.setPasswordPolicy(null);
this.navigationService.nextButtonAction = () => {
return this.api.setPasswordPolicy(({
minimumLength: this.policyForm.get('minimumLength')?.value,
requireLetter: this.policyForm.get('requireLetter')?.value,
requireLettersDifferentCase: this.policyForm.get('requireLettersDifferentCase')?.value,
requireDigit: this.policyForm.get('requireDigit')?.value,
requireSpecialCharacter: this.policyForm.get('requireSpecialCharacter')?.value
}));
};
};
}

View File

@ -7,9 +7,9 @@ import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select"; import {MatSelectModule} from "@angular/material/select";
import {MatInput} from "@angular/material/input"; import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip"; import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import {MatDatepickerModule} from "@angular/material/datepicker"; import {MatDatepickerModule} from "@angular/material/datepicker";
import {DateOnly} from "@model/dateOnly";
import {of} from "rxjs";
@Component({ @Component({
selector: 'app-schedule-conf', selector: 'app-schedule-conf',
@ -20,8 +20,6 @@ import {MatDatepickerModule} from "@angular/material/datepicker";
MatSelectModule, MatSelectModule,
MatInput, MatInput,
MatTooltip, MatTooltip,
MatIconButton,
MatIcon,
MatDatepickerModule, MatDatepickerModule,
MatNativeDateModule MatNativeDateModule
], ],
@ -55,5 +53,19 @@ export class ScheduleComponent {
} }
); );
}; };
api.scheduleConfiguration().subscribe(x => {
if (!x)
return;
this.scheduleSettings.get('startTerm')?.setValue(new DateOnly(x.startTerm).date);
this.scheduleSettings.get('cron')?.setValue(x.cronUpdateSchedule);
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
});
} }
} }

View File

@ -5,20 +5,23 @@
<div class="setup-navigation"> <div class="setup-navigation">
<div> <div>
<button mat-flat-button color="accent" <button mat-flat-button color="accent"
[disabled]="previousButtonDisabled" [hidden]="getIndex <= 2"
[hidden]="previousButtonRoute === ''" (click)="onPreviousClick()">
(click)="onPreviousClick()"
[routerLink]="previousButtonRoute">
Назад Назад
</button> </button>
</div> </div>
@if (!skipButtonDisabled) {
<button mat-flat-button color="accent" (click)="onSkipClick()">Пропустить</button>
}
@if (loaderActive) { @if (loaderActive) {
<app-data-spinner [scale]="40"/> <app-data-spinner [scale]="40"/>
} @else { } @else {
<button mat-flat-button color="accent" <button mat-flat-button color="accent"
[disabled]="nextButtonDisabled" [disabled]="nextButtonDisabled"
(click)="onNextClick()"> (click)="onNextClick()"
id="nextButtonFocus">
@if (getIndex === routes.length - 1) { @if (getIndex === routes.length - 1) {
Завершить Завершить
} @else { } @else {

View File

@ -1,12 +1,13 @@
import {Component, ViewEncapsulation} from '@angular/core'; import {Component, ViewEncapsulation} from '@angular/core';
import {MatSidenavModule} from "@angular/material/sidenav"; import {MatSidenavModule} from "@angular/material/sidenav";
import {Router, RouterLink, RouterOutlet} from "@angular/router"; import {Router, RouterOutlet} from "@angular/router";
import {MatCard} from "@angular/material/card"; import {MatCard} from "@angular/material/card";
import {MatButton} from "@angular/material/button"; import {MatButton} from "@angular/material/button";
import {NavigationService} from "@service/navigation.service"; import {NavigationService} from "@service/navigation.service";
import {catchError, Observable} from "rxjs"; import {catchError, Observable} from "rxjs";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component"; import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import SetupService from "@api/v1/setup.service"; import SetupService from "@api/v1/setup.service";
import {ToastrService} from "ngx-toastr";
@Component({ @Component({
selector: 'app-setup', selector: 'app-setup',
@ -16,7 +17,6 @@ import SetupService from "@api/v1/setup.service";
RouterOutlet, RouterOutlet,
MatCard, MatCard,
MatButton, MatButton,
RouterLink,
DataSpinnerComponent DataSpinnerComponent
], ],
templateUrl: './setup.component.html', templateUrl: './setup.component.html',
@ -26,64 +26,71 @@ import SetupService from "@api/v1/setup.service";
}) })
export class SetupComponent { export class SetupComponent {
protected previousButtonDisabled: boolean = false;
protected previousButtonRoute: string = '';
protected nextButtonDisabled: boolean = false; protected nextButtonDisabled: boolean = false;
protected nextButtonRoute!: string; protected skipButtonDisabled: boolean = false;
protected loaderActive: boolean = false; protected loaderActive: boolean = false;
protected routes: Array<string> = ['', 'welcome', 'database', 'cache', 'create-admin', 'schedule', 'logging', 'summary']; protected routes: Array<string> = ['', 'welcome', 'database', 'cache', 'password-policy', 'schedule', 'logging', 'create-admin', 'two-factor', 'summary'];
private index: number = 1; private index: number = 1;
protected get getIndex() { protected get getIndex() {
return this.index; return this.index;
} }
constructor(private router: Router, private navigationService: NavigationService, api: SetupService) { constructor(private router: Router, private navigationService: NavigationService, api: SetupService, private notify: ToastrService) {
api.isConfigured().subscribe(x => { api.isConfigured().subscribe(x => {
if (x) if (x) this.router.navigate(['/']).then();
this.router.navigate(['/']).then();
}); });
if (!this.router.url.includes(this.routes[this.index])) if (!this.router.url.includes(this.routes[this.index])) {
this.router.navigate(['setup/', this.routes[this.index]]).then(); this.router.navigate(['setup/', this.routes[this.index]]).then();
}
this.setRoutes(); this.initializeButtonSubscriptions();
this.navigationService.autoSkipNavigation$.subscribe(action => {
if (!this.navigationService.isNavigationUserInitiated) {
this.executeAction(action);
}
});
}
private initializeButtonSubscriptions() {
this.navigationService.nextButtonState$.subscribe(state => { this.navigationService.nextButtonState$.subscribe(state => {
this.nextButtonDisabled = !state; this.nextButtonDisabled = !state;
}); });
this.navigationService.skipNavigation.subscribe(action => { this.navigationService.skipButtonState$.subscribe(state => {
this.executeAction(action); this.skipButtonDisabled = !state;
}); });
} }
private setRoutes() {
this.previousButtonRoute = this.routes[this.index - 1];
this.nextButtonRoute = this.routes[this.index + 1];
}
private executeAction(action: () => Observable<boolean>) { private executeAction(action: () => Observable<boolean>) {
this.loaderActive = true; this.loaderActive = true;
action().pipe( action()
.pipe(
catchError(error => { catchError(error => {
this.nextButtonDisabled = true; this.nextButtonDisabled = true;
this.loaderActive = false; this.loaderActive = false;
throw error; throw error;
}) })
) )
.subscribe(x => { .subscribe(success => {
this.nextButtonDisabled = x; if (success) {
this.loaderActive = !x; this.moveToNextPage();
if (x) { } else {
if (this.index < this.routes.length - 1) { this.notify.error('Некорректно введены данные');
this.router.navigate(['setup/', this.nextButtonRoute]).then(); this.nextButtonDisabled = true;
this.index++; }
this.setRoutes();
} else this.loaderActive = false;
this.router.navigate(['/']).then(); });
}
protected onSkipClick() {
this.navigationService.skipButtonAction().subscribe(success => {
if (success) {
this.moveToNextPage();
} }
}); });
} }
@ -93,9 +100,29 @@ export class SetupComponent {
} }
protected onPreviousClick() { protected onPreviousClick() {
if (this.index - 1 > 0) { this.navigationService.setUserInitiatedNavigation(true);
this.index--; this.moveToPreviousPage();
this.setRoutes();
} }
private moveToNextPage() {
if (this.index < this.routes.length - 1) {
this.index++;
this.router.navigate(['setup/', this.routes[this.index]]).then();
this.initializePage();
} else {
this.router.navigate(['/']).then();
}
}
private moveToPreviousPage() {
if (this.index > 0) {
this.index--;
this.router.navigate(['setup/', this.routes[this.index]]).then();
this.initializePage();
}
}
private initializePage() {
this.navigationService.resetButtonStates();
} }
} }

View File

@ -8,7 +8,8 @@
<h4 style="margin-bottom: -5px;">Что дальше?</h4> <h4 style="margin-bottom: -5px;">Что дальше?</h4>
<p class="mat-body-2 secondary"> <p class="mat-body-2 secondary">
Теперь, когда основные настройки завершены, вы можете начать использовать систему с уверенностью, что все работает правильно. Теперь, когда основные настройки завершены, вы можете начать использовать систему с уверенностью, что все работает
правильно.
</p> </p>
<h4 style="margin-bottom: -5px;">Изменение настроек</h4> <h4 style="margin-bottom: -5px;">Изменение настроек</h4>
@ -17,6 +18,173 @@
Для изменения настроек перейдите в раздел настроек программы, который доступен в меню пользователя. Для изменения настроек перейдите в раздел настроек программы, который доступен в меню пользователя.
</p> </p>
<style>
.marginBottom {
margin-bottom: 25px;
}
.marginBottom h2 {
margin-bottom: 5px;
}
</style>
<h4 style="margin-bottom: -5px;">Ваши настройки:</h4>
<br/>
<mat-accordion>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title> Настройки</mat-panel-title>
</mat-expansion-panel-header>
@if (databaseConfig) {
<div class="marginBottom">
<h2>Конфигурация базы данных</h2>
<div class="config-item">
Тип: {{ databaseConfig.type }}
</div>
@if (databaseConfig.server) {
<div>
Сервер: {{ databaseConfig.server }}
</div>
}
@if (databaseConfig.port) {
<div>
Порт: {{ databaseConfig.port }}
</div>
}
@if (databaseConfig.database) {
<div>
База данных: {{ databaseConfig.database }}
</div>
}
@if (databaseConfig.user) {
<div>
Пользователь: {{ databaseConfig.user }}
</div>
}
@if (databaseConfig.ssl) {
<div>
SSL: {{ databaseConfig.ssl ? 'Yes' : 'No' }}
</div>
}
@if (databaseConfig.password) {
<div>
Пароль: ***
</div>
}
@if (databaseConfig.pathToDatabase) {
<div>
Путь к базе данных: {{ databaseConfig.pathToDatabase }}
</div>
}
</div>
}
@if (cacheConfig) {
<div class="marginBottom">
<h2>Конфигурация кэша</h2>
<div class="config-item">
Тип: {{ cacheConfig.type }}
</div>
@if (cacheConfig.server) {
<div>
Сервер: {{ cacheConfig.server }}
</div>
}
@if (cacheConfig.port) {
<div>
Порт: {{ cacheConfig.port }}
</div>
}
@if (cacheConfig.password) {
<div>
Пароль: ***
</div>
}
</div>
}
@if (passwordPolicyConfig) {
<div class="marginBottom">
<h2>Политика паролей</h2>
<div>
Минимальная длина: {{ passwordPolicyConfig.minimumLength }}
</div>
<div>
Требуется буква: {{ passwordPolicyConfig.requireLetter ? 'Да' : 'Нет' }}
</div>
<div>
Требуются буквы разных регистров: {{ passwordPolicyConfig.requireLettersDifferentCase ? 'Да' : 'Нет' }}
</div>
<div>
Требуется число: {{ passwordPolicyConfig.requireDigit ? 'Да' : 'Нет' }}
</div>
<div>
Требуется специальный символ: {{ passwordPolicyConfig.requireSpecialCharacter ? 'Да' : 'Нет' }}
</div>
</div>
}
@if (adminConfig) {
<div class="marginBottom">
<h2>Конфигурация администратора</h2>
<div>
Email: {{ adminConfig.email }}
</div>
<div>
Username: {{ adminConfig.username }}
</div>
<div>
Двухфакторный аутентификатор
Включен: {{ adminConfig.twoFactorAuthenticatorEnabled ? 'Да' : 'Нет' }}
</div>
</div>
}
@if (loggingConfig) {
<div class="marginBottom">
<h2>Конфигурация ведения журнала</h2>
<div>
Включить запись журнала в файл: {{ loggingConfig.enableLogToFile ? 'Да' : 'Нет' }}
</div>
@if (loggingConfig.logFileName) {
<div>
Имя файла журнала: {{ loggingConfig.logFileName }}
</div>
}
@if (loggingConfig.logFilePath) {
<div>
Путь к файлу журнала: {{ loggingConfig.logFilePath }}
</div>
}
</div>
}
@if (scheduleConfig) {
<div class="marginBottom">
<h2>Настройка расписания</h2>
<div>
Расписание обновлений Cron: {{ scheduleConfig.cronUpdateSchedule }}
</div>
<div>
Дата начала семестра: {{ scheduleConfig.startTerm }}
</div>
</div>
}
</mat-expansion-panel>
</mat-accordion>
<p class="mat-body-2 secondary">
Помните, что вы всегда можете изменить некоторые настройки позже через интерфейс программы.
Для изменения настроек перейдите в раздел настроек программы, который доступен в меню пользователя.
</p>
<p class="mat-h3" style="color: red;font-weight: lighter;"> <p class="mat-h3" style="color: red;font-weight: lighter;">
Для того, чтобы настройки были применены нажмите кнопку "Завершить" и перезагрузите приложение Для того, чтобы настройки были применены нажмите кнопку "Завершить" и перезагрузите приложение
</p> </p>

View File

@ -1,26 +1,60 @@
import {Component} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {MatButton} from "@angular/material/button";
import {NavigationService} from "@service/navigation.service"; import {NavigationService} from "@service/navigation.service";
import {MatFormFieldModule} from "@angular/material/form-field"; import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import SetupService from "@api/v1/setup.service"; import SetupService from "@api/v1/setup.service";
import {DatabaseResponse} from "@api/v1/configuration/databaseResponse";
import {CacheResponse} from "@api/v1/configuration/cacheResponse";
import {PasswordPolicy} from "@model/passwordPolicy";
import {UserResponse} from "@api/v1/userResponse";
import {LoggingRequest} from "@api/v1/configuration/loggingRequest";
import {ScheduleConfigurationRequest} from "@api/v1/configuration/scheduleConfigurationRequest";
import {MatExpansionModule} from "@angular/material/expansion";
@Component({ @Component({
selector: 'app-summary', selector: 'app-summary',
standalone: true, standalone: true,
imports: [ imports: [MatFormFieldModule, MatExpansionModule],
MatButton,
MatFormFieldModule,
MatInput
],
templateUrl: './summary.component.html' templateUrl: './summary.component.html'
}) })
export class SummaryComponent { export class SummaryComponent implements OnInit {
databaseConfig: DatabaseResponse | undefined;
cacheConfig: CacheResponse | undefined;
passwordPolicyConfig: PasswordPolicy | undefined;
adminConfig: UserResponse | undefined;
loggingConfig: LoggingRequest | undefined;
scheduleConfig: ScheduleConfigurationRequest | undefined;
constructor(private navigationService: NavigationService, private api: SetupService) { constructor(private navigationService: NavigationService, private api: SetupService) {
this.navigationService.nextButtonAction = () => { this.navigationService.nextButtonAction = () => {
return this.api.submit(); return this.api.submit();
}; };
this.navigationService.setNextButtonState(true); this.navigationService.setNextButtonState(true);
} }
ngOnInit(): void {
this.api.databaseConfiguration().subscribe(config => {
this.databaseConfig = config;
});
this.api.cacheConfiguration().subscribe(config => {
this.cacheConfig = config;
});
this.api.passwordPolicyConfiguration().subscribe(config => {
this.passwordPolicyConfig = config;
});
this.api.adminConfiguration().subscribe(config => {
this.adminConfig = config;
});
this.api.loggingConfiguration().subscribe(config => {
this.loggingConfig = config;
});
this.api.scheduleConfiguration().subscribe(config => {
this.scheduleConfig = config;
});
}
} }

View File

@ -0,0 +1,50 @@
<h1>Настройка двухфакторной аутентификации (2FA)</h1>
<hr/>
<p class="mat-body-2 secondary">
На этой странице вы можете настроить двухфакторную аутентификацию (2FA) для повышения безопасности вашего аккаунта.
</p>
<p class="mat-body-2 secondary">
Чтобы настроить 2FA, отсканируйте QR-код или введите секретный код в приложение Google Authenticator, Authy или другие
подобные приложения для двухфакторной аутентификации.
</p>
<p class="mat-body-2 secondary">
Если вы не хотите настраивать 2FA сейчас, вы можете пропустить этот шаг.
</p>
<h3>Ваш код: <i><strong>{{ secret }}</strong></i></h3>
<div>
<img [src]="totpImage"/>
</div>
<form [formGroup]="twoFactorForm">
<p>
Введите ключ из приложения:
</p>
<mat-form-field color="accent">
<mat-label>Код из приложения</mat-label>
<input matInput
matTooltip='Укажите код в цифровом формате'
required
formControlName="code">
@if (twoFactorForm.get('code')?.hasError('required')) {
<mat-error>
Код является <i>обязательным</i>
</mat-error>
}
@if (twoFactorForm.get('code')?.hasError('minlength')) {
<mat-error>
Код должен быть не меньше 6 символов
</mat-error>
}
@if (twoFactorForm.get('code')?.hasError('pattern')) {
<mat-error>
Код должен содержать только цифры
</mat-error>
}
</mat-form-field>
</form>

View File

@ -0,0 +1,55 @@
import {Component} from '@angular/core';
import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, ValidatorFn, Validators} from "@angular/forms";
import {NavigationService} from "@service/navigation.service";
import SetupService from "@api/v1/setup.service";
import {of} from "rxjs";
import {MatError, MatFormField, MatLabel} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import SecurityService from "@api/v1/securityService";
@Component({
selector: 'app-two-factor',
imports: [
FormsModule,
ReactiveFormsModule,
MatError,
MatFormField,
MatInput,
MatLabel,
MatTooltip
],
templateUrl: './two-factor.component.html',
providers: [SecurityService]
})
export class TwoFactorComponent {
protected twoFactorForm!: FormGroup;
protected secret!: string;
protected totpImage!: string;
constructor(
private navigationService: NavigationService, private formBuilder: FormBuilder, api: SetupService, apiSecurity: SecurityService) {
api.generateTotpKey().subscribe(x => {
this.secret = x;
});
api.adminConfiguration().subscribe(x => {
this.totpImage = apiSecurity.generateTotpQrCode(this.secret, x.username);
if (x.twoFactorAuthenticatorEnabled)
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
});
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => of(true);
const validators: ValidatorFn[] = [Validators.required, Validators.minLength(6), Validators.pattern('^[0-9]*$')];
this.twoFactorForm = this.formBuilder.group({
code: ['', validators],
});
this.navigationService.nextButtonAction = () => api.verifyTotp(this.twoFactorForm.get('code')?.value);
this.navigationService.setNextButtonState(this.twoFactorForm.valid);
this.twoFactorForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.twoFactorForm.valid);
});
}
}

View File

@ -15,13 +15,13 @@
Для получения ключа используете тот же хост, что и Backend приложение и выполните: Для получения ключа используете тот же хост, что и Backend приложение и выполните:
</p> </p>
<code> <code>
curl -X 'GET' '{{apiToGetToken}}/Setup/GenerateToken' -H 'accept: application/json' curl -X 'GET' '{{ apiToGetToken }}/Setup/GenerateToken' -H 'accept: application/json'
</code> </code>
<p> <p>
Или Или
</p> </p>
<code> <code>
Invoke-RestMethod -Uri "{{apiToGetToken}}/Setup/GenerateToken" -Method Get -Headers &#x40;&#123;accept="application/json"&#125; Invoke-RestMethod -Uri "{{ apiToGetToken }}/Setup/GenerateToken" -Method Get -Headers &#x40;&#123;accept="application/json"&#125;
</code> </code>
<div style="display: flex; flex-direction: column; margin: 25px 0;"> <div style="display: flex; flex-direction: column; margin: 25px 0;">

View File

@ -1,22 +1,19 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {MatButton} from "@angular/material/button";
import {NavigationService} from "@service/navigation.service"; import {NavigationService} from "@service/navigation.service";
import {MatFormFieldModule} from "@angular/material/form-field"; import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input"; import {MatInput} from "@angular/material/input";
import {AsyncPipe} from "@angular/common";
import {FormControl, ReactiveFormsModule, Validators} from "@angular/forms"; import {FormControl, ReactiveFormsModule, Validators} from "@angular/forms";
import SetupService from "@api/v1/setup.service"; import SetupService from "@api/v1/setup.service";
import {environment} from "@environment"; import {environment} from "@environment";
import {AvailableVersion} from "@api/api.service"; import {AvailableVersion} from "@api/api.service";
import {of} from "rxjs";
@Component({ @Component({
selector: 'app-welcome', selector: 'app-welcome',
standalone: true, standalone: true,
imports: [ imports: [
MatButton,
MatFormFieldModule, MatFormFieldModule,
MatInput, MatInput,
AsyncPipe,
ReactiveFormsModule ReactiveFormsModule
], ],
templateUrl: './welcome.component.html' templateUrl: './welcome.component.html'
@ -33,13 +30,21 @@ export class WelcomeComponent {
constructor(private navigationService: NavigationService, private api: SetupService) { constructor(private navigationService: NavigationService, private api: SetupService) {
this.apiToGetToken += AvailableVersion[this.api.version]; this.apiToGetToken += AvailableVersion[this.api.version];
this.navigationService.nextButtonAction = () => {
return this.api.checkToken(this.tokenControl.value ?? '');
};
this.navigationService.setNextButtonState(false); this.navigationService.nextButtonAction = () => this.api.checkToken(this.tokenControl.value ?? '');
this.tokenControl.valueChanges.subscribe(() => { this.tokenControl.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.tokenControl.valid); this.navigationService.setNextButtonState(this.tokenControl.valid);
}); });
this.api.isConfiguredToken().subscribe(data => {
console.log(data);
if (!data)
return;
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
});
} }
} }

View File

@ -1,18 +1,44 @@
import {EventEmitter, Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from "rxjs"; import {BehaviorSubject, Observable, Subject} from "rxjs";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class NavigationService { export class NavigationService {
private nextButtonState = new BehaviorSubject<boolean>(false); private nextButtonState = new BehaviorSubject<boolean>(false);
private skipButtonState = new BehaviorSubject<boolean>(false);
private autoSkipNavigationSubject = new Subject<() => Observable<boolean>>();
private isUserInitiatedNavigation = false;
nextButtonState$ = this.nextButtonState.asObservable(); nextButtonState$ = this.nextButtonState.asObservable();
nextButtonAction!: () => Observable<boolean>; skipButtonState$ = this.skipButtonState.asObservable();
autoSkipNavigation$ = this.autoSkipNavigationSubject.asObservable();
skipNavigation: EventEmitter<() => Observable<boolean>> = new EventEmitter(); skipButtonAction!: () => Observable<boolean>;
nextButtonAction!: () => Observable<boolean>;
setNextButtonState(state: boolean) { setNextButtonState(state: boolean) {
this.nextButtonState.next(state); this.nextButtonState.next(state);
} }
setSkipButtonState(state: boolean) {
this.skipButtonState.next(state);
}
resetButtonStates() {
this.setNextButtonState(false);
this.setSkipButtonState(false);
}
setUserInitiatedNavigation(isUserInitiated: boolean) {
this.isUserInitiatedNavigation = isUserInitiated;
}
get isNavigationUserInitiated() {
return this.isUserInitiatedNavigation;
}
triggerAutoSkip(action: () => Observable<boolean>) {
this.autoSkipNavigationSubject.next(action);
}
} }