Compare commits

...

76 Commits

Author SHA1 Message Date
52b2af097f feat: add options for campuses
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m27s
2025-02-06 17:26:18 +03:00
ea5e731bd2 feat: add notify for success upload
All checks were successful
Build and Deploy Angular App / build (push) Successful in 48s
2025-02-06 16:42:04 +03:00
74a7fe7eb6 fix: allow save if data is empty 2025-02-06 16:41:43 +03:00
2f9d552e43 refactor: remove unused code 2025-02-06 16:41:24 +03:00
004671c006 feat: add support for the new api
All checks were successful
Build and Deploy Angular App / build (push) Successful in 43s
2025-02-03 03:37:30 +03:00
0f6a1e7a45 fix: use is array instead typeof 2025-02-03 03:36:41 +03:00
437a3fcc58 refactor: clean code
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m17s
2025-02-02 22:38:52 +03:00
0002371265 feat: add new components to schedule configuration 2025-02-02 22:38:39 +03:00
0f25d5404c feat: add schedule files upload 2025-02-02 22:38:05 +03:00
e98a0db7ca feat: add term start configuration 2025-02-02 22:37:27 +03:00
324c7630ea feat: add dialog about force remove data 2025-02-02 22:36:08 +03:00
f1f1ed16e1 feat: add routes for admin panel 2025-02-02 20:58:24 +03:00
6fcd68b627 feat: add schedule configuration 2025-02-02 20:58:10 +03:00
d50da4db3e feat: add page cap 2025-02-02 20:57:56 +03:00
066b1444af feat: add admin panel 2025-02-02 20:57:36 +03:00
df4ea723b3 feat: add date adapter provider 2025-02-02 20:54:22 +03:00
434dec492d feat: add component for change cron expression 2025-02-02 20:34:57 +03:00
24d6b91553 feat: add component for skip update 2025-02-02 20:34:30 +03:00
2b988db70d feat: add new api 2025-02-02 20:33:35 +03:00
a3a19be5a4 refactor: clean code 2025-02-02 20:32:45 +03:00
9f742cab78 feat: add base component for configuration card 2025-02-02 18:52:37 +03:00
c8bcda8da2 refactor: move locale settings to app 2025-02-02 18:52:02 +03:00
1bf2868d00 refactor: rename AuthApiService 2025-02-02 03:45:25 +03:00
5b9b67d50c feat: add filter by lesson type 2025-02-01 17:11:28 +03:00
061307447e feat: show campus name with lecture hall 2025-02-01 17:09:24 +03:00
cf09738447 fix: check variable existing 2025-02-01 16:26:56 +03:00
79a992dc69 fix: mapping part of url if null or empty 2025-02-01 16:26:04 +03:00
612da04cbb refactor: use object RequestData 2025-02-01 16:25:05 +03:00
3d38b49839 build: upgrade to angular 19.1 2025-02-01 16:06:02 +03:00
fcd179166e build: change version
All checks were successful
Build and Deploy Angular App / build (push) Successful in 2m0s
2024-12-28 08:37:36 +03:00
224d7a3443 fix: add stretch for input 2024-12-28 08:36:26 +03:00
2370a2051b fix: add OAuthAction 2024-12-28 08:36:00 +03:00
1d691ccc09 refactor: rewrite oauth login and 2fa for the new api 2024-12-28 08:35:48 +03:00
a7542eaf32 refactor: rewrite oauth setup for the new api 2024-12-28 07:44:41 +03:00
a8b1485b0e fix: do not resend the request if it is not necessary
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m20s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-12-26 10:40:27 +03:00
90fca336f5 fix: remove css
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m15s
2024-12-23 08:03:30 +03:00
a7b8c15e3a build: update ref
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m46s
2024-12-23 06:45:34 +03:00
135570d384 fix: redesign the service for a new api 2024-12-23 06:45:19 +03:00
7830c5f21d refactor: put the input password in a separate component
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m17s
2024-12-23 06:41:28 +03:00
6e914caabc refactor: translate error to russia 2024-12-23 05:15:54 +03:00
f26d74aae5 refactor: change name arg 2024-12-23 05:15:35 +03:00
3aefee124a fix: calculating weeks in a year 2024-12-23 05:15:19 +03:00
eda6ca4b1a refactor: use RFC 7807 standard for error handling
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m30s
2024-12-22 07:17:21 +03:00
10bf53adec feat: add integration with seq 2024-12-22 07:16:54 +03:00
e10075dfed fix: message error text 2024-12-18 08:47:36 +03:00
2b482d2b2d fix: set max height 2024-12-18 08:47:22 +03:00
9017e87175 fix: bypassing cors 2024-12-18 08:41:29 +03:00
16e25905dc feat: add spinner 2024-12-18 08:40:54 +03:00
8138a63324 refactor: rename shared
All checks were successful
Build and Deploy Angular App / build (push) Successful in 57s
2024-12-18 07:53:25 +03:00
1ffbfad37a build: update ref
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m36s
2024-12-18 07:11:15 +03:00
c04c457211 fix: add alt 2024-12-18 07:10:51 +03:00
fba28b6bbe feat: rewrite setup wizard 2024-12-18 07:09:29 +03:00
86e6f59567 feat: add providers OAuth 2024-12-18 07:02:08 +03:00
a2d4151cc3 refactor: clean code 2024-12-18 06:57:27 +03:00
3af8c43cd9 refactor: adapt models to the new api 2024-12-18 06:50:41 +03:00
21f89132ff refactor: adapt models to the new api 2024-12-18 06:48:51 +03:00
99958a2383 Merge remote-tracking branch 'origin/master'
All checks were successful
Build and Deploy Angular App / build (push) Successful in 58s
2024-10-27 08:30:16 +03:00
38b877608f feat: add import to excel
Made at the request of the customer
2024-10-27 08:29:30 +03:00
7c66f31bac refactor: make compliance with the new api 2024-10-27 07:38:42 +03:00
72a5f37404 revert e92927addb9291f02d0d959f9d344b3f5354242d
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m40s
revert build: try set group owner

Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-10-25 03:06:07 +03:00
e92927addb build: try set group owner
Some checks failed
Build and Deploy Angular App / build (push) Failing after 46s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-10-25 02:55:53 +03:00
924c75ea79 build: remove sudo
Some checks failed
Build and Deploy Angular App / build (push) Failing after 57s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-10-25 02:40:41 +03:00
5d265e4b48 fix: combine typeOfOccupation
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m0s
2024-10-25 01:53:25 +03:00
a8159b4f27 build: update ref 2024-10-25 01:52:29 +03:00
9231bd0d4a fix: set the text based on the context
All checks were successful
Build and Deploy Angular App / build (push) Successful in 2m32s
2024-10-09 03:11:09 +03:00
0bbed93df2 refactor: remove unused methods 2024-10-09 03:10:32 +03:00
2b09086902 refactor: adapting token storage to the API 2024-10-09 03:10:11 +03:00
9209b31db2 feat: add more info to error 2024-10-07 03:25:13 +03:00
3ca6f56fec feat: replace custom notify to ngx-toastr
#https://github.com/scttcper/ngx-toastr
2024-10-07 01:17:49 +03:00
eded639cc3 build: update ref 2024-10-07 01:16:11 +03:00
844d91de7d fix: add correct font 2024-10-07 01:15:13 +03:00
380b2efa0d fix: delete data if top-level data is selected
All checks were successful
Build and Deploy Angular App / build (push) Successful in 2m3s
2024-09-30 04:55:33 +03:00
6211dd8889 fix: show spinner after click on retry 2024-09-30 01:30:23 +03:00
a86e88e087 ci: delete other files
All checks were successful
Build and Deploy Angular App / build (push) Successful in 3m46s
2024-09-30 01:21:48 +03:00
eb4b5d31df build: upgrade ref 2024-09-30 01:21:07 +03:00
1f901f0612 build: revert zone.js
All checks were successful
Build and Deploy Angular App / build (push) Successful in 56s
2024-09-16 00:12:38 +03:00
147 changed files with 7895 additions and 6005 deletions

View File

@ -41,5 +41,5 @@ jobs:
ssh-keyscan $SSH_HOST >> ~/.ssh/known_hosts
sudo apt update
sudo apt install rsync -y
rsync -avr -p --chmod=770 --no-times ./dist/frontend/browser/ $SSH_USER@$SSH_HOST:$TARGET_DIR
ssh $SSH_USER@$SSH_HOST "sudo chown -R :www-data $TARGET_DIR"
rsync -avr -p --chmod=770 --no-times --delete ./dist/frontend/browser/ $SSH_USER@$SSH_HOST:$TARGET_DIR
ssh $SSH_USER@$SSH_HOST "chown -R :www-data $TARGET_DIR"

View File

@ -1,6 +1,6 @@
# MIREA schedule by Winsomnia
[![Angular Release](https://img.shields.io/badge/v18.2-8?style=flat-square&label=Angular&labelColor=512BD4&color=606060)](https://github.com/angular/angular-cli)
[![Angular Release](https://img.shields.io/badge/v19.1-8?style=flat-square&label=Angular&labelColor=512BD4&color=606060)](https://github.com/angular/angular-cli)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT)
This project provides a Web interface for working with the MIREA schedule.

8705
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "1.0.0-b6",
"version": "1.0.0-rc0",
"scripts": {
"ng": "ng",
"start": "ng serve",
@ -10,34 +10,34 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^18.2.4",
"@angular/cdk": "~18.2.4",
"@angular/cdk-experimental": "^18.2.4",
"@angular/common": "^18.2.4",
"@angular/compiler": "^18.2.4",
"@angular/core": "^18.2.4",
"@angular/forms": "^18.2.4",
"@angular/material": "~18.2.4",
"@angular/platform-browser": "^18.2.4",
"@angular/platform-browser-dynamic": "^18.2.4",
"@angular/router": "^18.2.4",
"@dhutaryan/ngx-mat-timepicker": "^18.0.1",
"@progress/kendo-date-math": "^1.5.13",
"@angular/animations": "^19.1.4",
"@angular/cdk": "~19.1.2",
"@angular/cdk-experimental": "^19.1.2",
"@angular/common": "^19.1.4",
"@angular/compiler": "^19.1.4",
"@angular/core": "^19.1.4",
"@angular/forms": "^19.1.4",
"@angular/material": "~19.1.2",
"@angular/platform-browser": "^19.1.4",
"@angular/platform-browser-dynamic": "^19.1.4",
"@angular/router": "^19.1.4",
"@progress/kendo-date-math": "^1.5.14",
"ngx-toastr": "^19.0.0",
"rxjs": "~7.8.1",
"tslib": "^2.7.0",
"tslib": "^2.8.1",
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.2.4",
"@angular/cli": "^18.2.4",
"@angular/compiler-cli": "^18.2.4",
"@types/jasmine": "~5.1.4",
"jasmine-core": "~5.3.0",
"@angular-devkit/build-angular": "^19.1.5",
"@angular/cli": "^19.1.5",
"@angular/compiler-cli": "^19.1.4",
"@types/jasmine": "~5.1.5",
"jasmine-core": "~5.5.0",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "^5.5.4"
"typescript": "^5.7.3"
}
}

View File

@ -11,89 +11,44 @@ export interface RequestData {
}
export class RequestBuilder {
private endpoint: string = '';
private queryParams: Record<string, string | number | boolean | Array<any> | null> | null = null;
private httpHeaders: HttpHeaders = new HttpHeaders();
private data: any = null;
private silenceMode: boolean = false;
private withCredentials: boolean = false;
private result: RequestData = Object.create({});
constructor() {
}
public setEndpoint(endpoint: string): this {
this.endpoint = endpoint;
this.result.endpoint = endpoint;
return this;
}
public setQueryParams(queryParams: Record<string, string | number | boolean | Array<any> | null>): RequestBuilder {
this.queryParams = queryParams;
this.result.queryParams = queryParams;
return this;
}
public addHeaders(headers: Record<string, string>): RequestBuilder {
Object.keys(headers).forEach(key => {
this.httpHeaders = this.httpHeaders.set(key, headers[key]);
this.result.httpHeaders = this.result.httpHeaders.set(key, headers[key]);
});
return this;
}
public setData(data: any): RequestBuilder {
this.data = data;
this.result.data = data;
return this;
}
public setSilenceMode(silence: boolean = true): RequestBuilder {
this.silenceMode = silence;
this.result.silenceMode = silence;
return this;
}
public setWithCredentials(credentials: boolean = true): RequestBuilder {
this.withCredentials = credentials;
this.result.withCredentials = credentials;
return this;
}
public get build(): RequestData {
return {
endpoint: this.endpoint,
queryParams: this.queryParams,
httpHeaders: this.httpHeaders,
data: this.data,
silenceMode: this.silenceMode,
withCredentials: this.withCredentials,
needAuth: false
};
}
public getEndpoint(): string {
return this.endpoint;
}
public getQueryParams(): Record<string, string | number | boolean | Array<any> | null> | null {
return this.queryParams;
}
public getHttpHeaders(): HttpHeaders {
return this.httpHeaders;
}
public getData(): any {
return this.data;
}
public getSilenceMode(): boolean {
return this.silenceMode;
}
public static getStandardRequestData(): RequestData {
return {
endpoint: '',
queryParams: null,
httpHeaders: new HttpHeaders(),
data: null,
silenceMode: false,
withCredentials: false,
needAuth: false
};
return this.result;
}
}

View File

@ -1,35 +1,21 @@
import {catchError, distinctUntilChanged, filter, first, mergeMap, Observable, retryWhen, switchMap, timer} from "rxjs";
import {
BehaviorSubject,
catchError,
distinctUntilChanged,
filter,
first,
Observable,
of,
ReplaySubject,
switchMap
} from "rxjs";
import {HttpClient, HttpErrorResponse} from "@angular/common/http";
import {NotifyColor, OpenNotifyService} from "@service/open-notify.service";
import {environment} from "@environment";
import {Router} from "@angular/router";
import {Injectable} from "@angular/core";
import {RequestBuilder, RequestData} from "@api/RequestBuilder";
import {TokenRefreshService} from "@service/token-refresh.service";
import {AuthToken} from "@service/auth.service";
export function retryWithInterval<T>(): (source: Observable<T>) => Observable<T> {
return (source: Observable<T>) =>
source.pipe(
retryWhen((errors: Observable<any>) =>
errors.pipe(
mergeMap((error, index) => {
if (index < (environment.maxRetry < 0 ? Infinity : environment.maxRetry - 1) && !error.status.toString().startsWith('4') && !error.status.toString().startsWith('5')) {
console.log(`Retrying after ${environment.retryDelay}ms...`);
return timer(environment.retryDelay);
} else {
if (error.status.toString().startsWith('4'))
console.error(`Server returned a client code error`);
else
console.error(`Exceeded maximum retries (${environment.maxRetry})`);
throw error;
}
})
)
)
);
}
import {ToastrService} from "ngx-toastr";
import {AuthRoles} from "@model/authRoles";
export enum AvailableVersion {
v1
@ -37,16 +23,16 @@ export enum AvailableVersion {
@Injectable()
export default abstract class ApiService {
constructor(private http: HttpClient, private notify: OpenNotifyService, private router: Router, protected tokenRefreshService: TokenRefreshService) {
constructor(protected http: HttpClient, protected notify: ToastrService, private router: Router) {
}
private apiUrl = environment.apiUrl;
private static isRefreshingToken: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
private static refreshTokenSubject: ReplaySubject<any> = new ReplaySubject(1);
protected abstract basePath: string;
protected abstract version: AvailableVersion;
public static readonly tokenKey = 'auth_token';
private static addQuery(endpoint: string, queryParams?: Record<string, string | number | boolean | Array<any> | null> | null): string {
const url = new URL(endpoint);
@ -54,7 +40,7 @@ export default abstract class ApiService {
Object.keys(queryParams).forEach(key => {
const value = queryParams[key];
if (value !== null && value !== undefined) {
if (typeof (value) === typeof (Array)) {
if (Array.isArray(value)) {
(value as Array<any>).forEach(x => url.searchParams.append(key, x.toString()));
} else
url.searchParams.append(key, value.toString());
@ -66,14 +52,14 @@ export default abstract class ApiService {
}
private static combineUrls(...parts: string[]): string {
return parts.map(part => part.replace(/(^\/+|\/+$)/g, '')).join('/');
return parts.map(part => (!part || part == '' ? '/' : part).replace(/(^\/+|\/+$)/g, '')).join('/');
}
protected combinedUrl(request: RequestData) {
return ApiService.addQuery(ApiService.combineUrls(this.apiUrl, AvailableVersion[this.version], this.basePath, request.endpoint), request.queryParams);
}
private sendHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put', request: RequestData): Observable<Type> {
private sendHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put', request: RequestData, secondTry: boolean = false): Observable<Type> {
const doneEndpoint = this.combinedUrl(request);
return this.http.request<Type>(method, doneEndpoint, {
@ -81,35 +67,58 @@ export default abstract class ApiService {
headers: request.httpHeaders,
body: request.data
}).pipe(
retryWithInterval<Type>(),
catchError(error => {
if (!request.silenceMode)
this.handleError(error);
if (request.needAuth && !secondTry && error.status === 401)
return this.handle401Error(error).pipe(
switchMap(() => this.sendHttpRequest<Type>(method, request, true))
);
else {
if (!request.silenceMode)
this.handleError(error);
throw error;
}
})
);
}
private refreshToken(): Observable<AuthRoles> {
return this.http.get<AuthRoles>(ApiService.combineUrls(this.apiUrl, AvailableVersion[AvailableVersion.v1], 'Auth', 'ReLogin'), {
withCredentials: true
});
}
private handle401Error(error: any): Observable<any> {
if (ApiService.isRefreshingToken.value)
return ApiService.refreshTokenSubject.asObservable();
ApiService.isRefreshingToken.next(true);
return this.refreshToken().pipe(
switchMap(_ => {
ApiService.isRefreshingToken.next(false);
ApiService.refreshTokenSubject.next(null);
return of(null);
}),
catchError(err => {
ApiService.isRefreshingToken.next(false);
ApiService.refreshTokenSubject.error(err);
ApiService.refreshTokenSubject = new ReplaySubject(1);
throw error;
})
);
}
private makeHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put', request: RequestData): Observable<Type> {
if (request.needAuth)
return this.tokenRefreshService.getTokenRefreshing$().pipe(
if (request.needAuth) {
return ApiService.isRefreshingToken.pipe(
distinctUntilChanged(),
filter(isRefreshing => !isRefreshing),
first(),
switchMap(() => {
const token = localStorage.getItem(ApiService.tokenKey);
if (token) {
const authToken = AuthToken.httpHeader((JSON.parse(token) as AuthToken));
authToken.keys().forEach(key => request.httpHeaders = request.httpHeaders.append(key, authToken.get(key) ?? ''));
}
return this.sendHttpRequest<Type>(method, request);
})
switchMap(() => this.sendHttpRequest<Type>(method, request))
);
return this.sendHttpRequest<Type>(method, request);
} else {
return this.sendHttpRequest<Type>(method, request);
}
}
private getRequest(request: RequestData | string | null): RequestData {
@ -143,51 +152,58 @@ export default abstract class ApiService {
public addAuth(request: RequestData) {
request.needAuth = true;
request.withCredentials = true;
return this;
}
private handleError(error: HttpErrorResponse): void {
// todo: change to Retry-After condition
if (error.error && error.error.toString().includes("setup")) {
if (error.error && error.error.detail && error.error.detail.includes("setup")) {
this.router.navigate(['/setup/']).then();
return;
}
let message: string;
let title: string;
let message: string | undefined = undefined;
if (error.error instanceof ErrorEvent) {
message = `Произошла ошибка: ${error.error.message}`;
title = `Произошла ошибка: ${error.error.message}`;
} else {
switch (error.status) {
case 0:
message = 'Неизвестная ошибка. Пожалуйста, попробуйте позже.';
break;
case 400:
message = 'Ошибка запроса. Пожалуйста, проверьте отправленные данные.';
break;
case 401:
this.router.navigate(['/login/']).then();
message = 'Ошибка авторизации. Пожалуйста, выполните вход с правильными учетными данными.';
break;
case 403:
message = 'Отказано в доступе. У вас нет разрешения на выполнение этого действия.';
break;
case 404:
message = 'Запрашиваемый ресурс не найден.';
break;
case 500:
message = 'Внутренняя ошибка сервера. Пожалуйста, попробуйте позже.';
break;
case 503:
message = 'Сервер на обслуживании. Пожалуйста, попробуйте позже.';
break;
default:
message = `Сервер вернул код ошибки: ${error.status}`;
break;
}
if (error.error?.Error) {
message += ` ${error.error.Error}`;
if (error.error && error.error.type && error.error.title) {
title = error.error.title || `Ошибка с кодом ${error.status}`;
message = error.error.detail || 'Неизвестная ошибка';
} else {
switch (error.status) {
case 0:
title = 'Неизвестная ошибка. Пожалуйста, попробуйте позже.';
break;
case 400:
title = 'Ошибка запроса. Пожалуйста, проверьте отправленные данные.';
break;
case 401:
this.router.navigate(['/login/']).then();
title = 'Ошибка авторизации. Пожалуйста, выполните вход с правильными учетными данными.';
break;
case 403:
title = 'Отказано в доступе. У вас нет разрешения на выполнение этого действия.';
break;
case 404:
title = 'Запрашиваемый ресурс не найден.';
break;
case 500:
title = 'Внутренняя ошибка сервера. Пожалуйста, попробуйте позже.';
break;
case 503:
title = 'Сервер на обслуживании. Пожалуйста, попробуйте позже.';
break;
default:
title = `Сервер вернул код ошибки: ${error.status}`;
break;
}
}
if (!message)
message = error.error.statusMessage;
}
this.notify.open(message, NotifyColor.Danger);
this.notify.error(message == '' ? undefined : message, title);
}
}

View File

@ -0,0 +1,119 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {LoginRequest} from "@api/v1/loginRequest";
import {catchError, map, Observable, of} from "rxjs";
import {AuthRoles} from "@model/authRoles";
import {AvailableOAuthProvidersResponse} from "@api/v1/availableProvidersResponse";
import {OAuthProvider} from "@model/oAuthProvider";
import {TwoFactorAuthentication} from "@model/twoFactorAuthentication";
import {TwoFactorAuthRequest} from "@api/v1/twoFactorAuthRequest";
import {OAuthAction} from "@model/oAuthAction";
export interface OAuthProviderData extends AvailableOAuthProvidersResponse {
icon: string;
}
@Injectable()
export default class AuthApiService extends ApiService {
public readonly basePath = 'Auth/';
public readonly version = AvailableVersion.v1;
public login(login: LoginRequest) {
let request = this.createRequestBuilder()
.setEndpoint('Login')
.setData(login)
.setWithCredentials()
.build;
return this.post<TwoFactorAuthentication>(request);
}
public twoFactorAuth(data: TwoFactorAuthRequest) {
let request = this.createRequestBuilder()
.setEndpoint('2FA')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public reLogin() {
let request = this.createRequestBuilder()
.setEndpoint('ReLogin')
.setWithCredentials()
.build;
return this.get<AuthRoles>(request);
}
public logout() {
let request = this.createRequestBuilder()
.setWithCredentials()
.setEndpoint('Logout')
.build;
return this.addAuth(request).get(request);
}
public getRole(isSilence: boolean = true) {
let request = this.createRequestBuilder()
.setSilenceMode(isSilence)
.setEndpoint('GetRole')
.build;
return this.addAuth(request)
.get<AuthRoles>(request)
.pipe(
catchError(_ => {
return of(null);
})
);
}
private getProviderIcon(provider: OAuthProvider): string {
switch (provider) {
case OAuthProvider.Google:
return 'assets/icons/google.svg';
case OAuthProvider.Yandex:
return 'assets/icons/yandex.svg';
case OAuthProvider.MailRu:
return 'assets/icons/mailru.svg';
default:
return '';
}
}
public availableProviders(callback: string): Observable<OAuthProviderData[]> {
let request = this.createRequestBuilder()
.setEndpoint('AvailableProviders')
.setQueryParams({callback: callback})
.setWithCredentials()
.build;
return this.get<Array<AvailableOAuthProvidersResponse>>(request).pipe(
map(data => {
return data.map((provider) => ({
...provider,
icon: this.getProviderIcon(provider.provider),
}) as OAuthProviderData);
}));
}
private handleTokenRequest(token: string, action: OAuthAction) {
return this.createRequestBuilder()
.setEndpoint('HandleToken')
.setQueryParams({token: token, action: action})
.setWithCredentials()
.build;
}
public loginOAuth(token: string) {
return this.get<TwoFactorAuthentication>(this.handleTokenRequest(token, OAuthAction.Login));
}
public linkAccount(token: string): Observable<null> {
const request = this.handleTokenRequest(token, OAuthAction.Bind);
return this.addAuth(request).get(request);
}
}

View File

@ -1,59 +0,0 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {LoginRequest} from "@api/v1/loginRequest";
import {TokenResponse} from "@api/v1/tokenResponse";
import {catchError, of, tap} from "rxjs";
import {AuthRoles} from "@model/AuthRoles";
import {AuthService, AvailableAuthenticationProvider} from "@service/auth.service";
@Injectable()
export default class AuthApiService extends ApiService {
public readonly basePath = 'Auth/';
public readonly version = AvailableVersion.v1;
public login(login: LoginRequest) {
let request = this.createRequestBuilder()
.setEndpoint('Login')
.setData(login)
.setWithCredentials()
.build;
return this.post<TokenResponse>(request)
.pipe(
tap(response => {
AuthService.setToken(response, AvailableAuthenticationProvider.Bearer, this.combinedUrl(this.createRequestBuilder().setEndpoint('ReLogin').build));
this.tokenRefreshService.setRefreshTokenExpireMs(response.expiresIn);
})
);
}
public logout() {
let request = this.createRequestBuilder()
.setWithCredentials()
.setEndpoint('Logout')
.build;
return this.addAuth(request)
.get(request)
.pipe(
tap(_ => {
localStorage.removeItem(ApiService.tokenKey);
})
);
}
public getRole(isSilence: boolean = true) {
let request = this.createRequestBuilder()
.setSilenceMode(isSilence)
.setEndpoint('GetRole')
.build;
return this.addAuth(request)
.get<AuthRoles>(request)
.pipe(
catchError(_ => {
return of(null);
})
);
}
}

View File

@ -0,0 +1,89 @@
import {Injectable} from '@angular/core';
import ApiService, {AvailableVersion} from "@api/api.service";
import {CronUpdateScheduleResponse} from "@api/v1/configuration/cronUpdateScheduleResponse";
import {DateOnly} from "@model/dateOnly";
import {map} from "rxjs";
import CronUpdateSkip from "@model/cronUpdateSkip";
@Injectable()
export class ScheduleService extends ApiService {
public readonly basePath = 'Configuration/Schedule';
public readonly version = AvailableVersion.v1;
public getCronUpdateSchedule() {
const request = this.createRequestBuilder()
.setEndpoint('CronUpdateSchedule')
.build;
return this.addAuth(request).get<CronUpdateScheduleResponse>(request);
}
public postCronUpdateSchedule(cron: string) {
const request = this.createRequestBuilder()
.setEndpoint('CronUpdateSchedule')
.setQueryParams({cron: cron})
.build;
return this.addAuth(request).post<CronUpdateScheduleResponse>(request);
}
public getStartTerm() {
const request = this.createRequestBuilder()
.setEndpoint('StartTerm')
.build;
return this.addAuth(request).get<string>(request).pipe(map(date => new DateOnly(date)));
}
public postStartTerm(startTerm: DateOnly, force: boolean) {
const request = this.createRequestBuilder()
.setEndpoint('StartTerm')
.setQueryParams({force: force, startTerm: startTerm.toString()})
.build;
return this.addAuth(request).post(request);
}
public getCronUpdateSkip() {
const request = this.createRequestBuilder()
.setEndpoint('CronUpdateSkip')
.build;
return this.addAuth(request).get<{ date?: string, start?: string, end?: string }[]>(request)
.pipe(
map(data => {
return data.map(x => <CronUpdateSkip>{
date: x.date ? new DateOnly(x.date) : null,
start: x.start ? new DateOnly(x.start) : null,
end: x.end ? new DateOnly(x.end) : null
});
}
));
}
public postCronUpdateSkip(data: CronUpdateSkip[]) {
const request = this.createRequestBuilder()
.setEndpoint('CronUpdateSkip')
.setData(data.map(x => <any>{
start: x.start?.toString(),
end: x.end?.toString(),
date: x.date?.toString()
}))
.build;
return this.addAuth(request).post<any>(request);
}
public uploadScheduleFile(files: File[], campus: string[], force: boolean) {
const formData = new FormData();
files.forEach(file => formData.append('files', file, file.name));
const request = this.createRequestBuilder()
.setEndpoint('Upload')
.setData(formData)
.setQueryParams({force: force, defaultCampus: campus})
.build;
return this.addAuth(request).post(request);
}
}

View File

@ -1,7 +1,6 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {FacultyResponse} from "@api/v1/facultyResponse";
import {FacultyDetailsResponse} from "@api/v1/facultyDetailsResponse";
@Injectable()
export class FacultyService extends ApiService {
@ -15,8 +14,4 @@ export class FacultyService extends ApiService {
return this.get<FacultyResponse[]>(request);
}
public getById(id: number) {
return this.get<FacultyDetailsResponse>(id.toString());
}
}

View File

@ -0,0 +1,22 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {ScheduleRequest} from "@api/v1/scheduleRequest";
@Injectable()
export class ImportService extends ApiService {
public readonly basePath = 'Import/';
public readonly version = AvailableVersion.v1;
public importToExcel(data: ScheduleRequest) {
let request = this.createRequestBuilder()
.setData(data)
.setEndpoint('ImportToExcel')
.build;
console.log(this.combinedUrl(request));
console.log(data);
return this.http.post(this.combinedUrl(request), data, {
responseType: 'blob'
});
}
}

View File

@ -0,0 +1,17 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {LessonTypeResponse} from "@api/v1/lessonTypeResponse";
@Injectable()
export class LessonTypeService extends ApiService {
public readonly basePath = 'LessonType/';
public readonly version = AvailableVersion.v1;
public getLessonTypes(page: number | null = null, pageSize: number | null = null) {
let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize})
.build;
return this.get<LessonTypeResponse[]>(request);
}
}

View File

@ -1,7 +1,7 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {DateOnly} from "@model/DateOnly";
import {PeriodTimes} from "@model/pairPeriodTime";
import {DateOnly} from "@model/dateOnly";
import {PairPeriodTime} from "@model/pairPeriodTime";
import {ScheduleRequest} from "@api/v1/scheduleRequest";
import {ScheduleResponse} from "@api/v1/scheduleResponse";
import {map} from "rxjs";
@ -16,7 +16,7 @@ export class ScheduleService extends ApiService {
}
public pairPeriod() {
return this.get<PeriodTimes>('PairPeriod');
return this.get<PairPeriodTime>('PairPeriod');
}
public postSchedule(data: ScheduleRequest) {

View File

@ -0,0 +1,26 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {PasswordPolicy} from "@model/passwordPolicy";
@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);
}
public passwordPolicy() {
let request = this.createRequestBuilder()
.setEndpoint('PasswordPolicy')
.build;
return this.get<PasswordPolicy>(request);
}
}

View File

@ -1,12 +1,17 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {DatabaseRequest} from "@api/v1/databaseRequest";
import {CacheRequest} from "@api/v1/cacheRequest";
import {catchError, of} from "rxjs";
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 {LoggingRequest} from "@api/v1/loggingRequest";
import {EmailRequest} from "@api/v1/emailRequest";
import {ScheduleConfigurationRequest} from "@api/v1/scheduleConfigurationRequest";
import {DateOnly} from "@model/DateOnly";
import {LoggingRequest} from "@api/v1/configuration/loggingRequest";
import {ScheduleConfigurationRequest} from "@api/v1/configuration/scheduleConfigurationRequest";
import {EmailRequest} from "@api/v1/configuration/emailRequest";
import {DateOnly} from "@model/dateOnly";
import {CacheResponse} from "@api/v1/configuration/cacheResponse";
import {PasswordPolicy} from "@model/passwordPolicy";
import {UserResponse} from "@api/v1/userResponse";
@Injectable()
export default class SetupService extends ApiService {
@ -23,6 +28,17 @@ export default class SetupService extends ApiService {
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) {
let request = this.createRequestBuilder()
.setEndpoint('SetPsql')
@ -53,6 +69,15 @@ export default class SetupService extends ApiService {
return this.post<boolean>(request);
}
public databaseConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('DatabaseConfiguration')
.setWithCredentials()
.build;
return this.get<DatabaseResponse>(request);
}
public setRedis(data: CacheRequest) {
let request = this.createRequestBuilder()
.setEndpoint('SetRedis')
@ -72,6 +97,34 @@ export default class SetupService extends ApiService {
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) {
let request = this.createRequestBuilder()
.setEndpoint('CreateAdmin')
@ -82,6 +135,25 @@ export default class SetupService extends ApiService {
return this.post<boolean>(request);
}
public adminConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('AdminConfiguration')
.setWithCredentials()
.build;
return this.get<UserResponse>(request);
}
public registerOAuth(token: string) {
let request = this.createRequestBuilder()
.setEndpoint('HandleToken')
.setQueryParams({token: token})
.setWithCredentials()
.build;
return this.get<null>(request);
}
public setLogging(data: LoggingRequest | null = null) {
let request = this.createRequestBuilder()
.setEndpoint('SetLogging')
@ -92,6 +164,15 @@ export default class SetupService extends ApiService {
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) {
let request = this.createRequestBuilder()
.setEndpoint('SetEmail')
@ -114,6 +195,34 @@ export default class SetupService extends ApiService {
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() {
let request = this.createRequestBuilder()
.setEndpoint('Submit')

View File

@ -1,10 +1,29 @@
import {ApplicationConfig} from '@angular/core';
import {ApplicationConfig, LOCALE_ID} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import {provideHttpClient} from "@angular/common/http";
import {provideToastr} from "ngx-toastr";
import {MAT_DATE_LOCALE, provideNativeDateAdapter} from "@angular/material/core";
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), provideAnimationsAsync(), provideHttpClient()]
providers: [
provideRouter(routes),
provideAnimationsAsync(),
provideHttpClient(),
provideToastr({
timeOut: 5000,
extendedTimeOut: 2000,
positionClass: "toast-top-right",
progressBar: true,
progressAnimation: "decreasing",
newestOnTop: true,
tapToDismiss: true,
disableTimeOut: false,
autoDismiss: true,
maxOpened: 5
}),
provideNativeDateAdapter(),
{ provide: LOCALE_ID, useValue: 'ru' },
{ provide: MAT_DATE_LOCALE, useValue: 'ru' }]
};

View File

@ -9,6 +9,11 @@ import {SetupComponent} from "@page/setup/setup.component";
import {CreateAdminComponent} from "@page/setup/create-admin/create-admin.component";
import {SummaryComponent} from "@page/setup/summary/summary.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";
import {AdminComponent} from "@page/admin/admin.component";
import {UnderConstructionComponent} from "@page/admin/under-construction/under-construction.component";
import {ScheduleConfigurationComponent} from "@page/admin/schedule-configuration/schedule-configuration.component";
export const routes: Routes = [
{path: '', title: 'Расписание', pathMatch: 'full', component: ScheduleComponent},
@ -21,10 +26,20 @@ export const routes: Routes = [
{path: 'schedule', component: SetupScheduleComponent},
{path: 'logging', component: LoggingComponent},
{path: 'summary', component: SummaryComponent},
{path: 'password-policy', component: PasswordPolicyComponent},
{path: 'two-factor', component: TwoFactorComponent},
{path: '', redirectTo: 'welcome', pathMatch: 'full'}
]
},
{path: 'login', title: 'Вход', component: LoginComponent},
/*{path: 'not-found', title: '404 страница не найдена'},
{path: '**', redirectTo: '/not-found'}*/
{
path: 'admin', title: 'Админ панель', component: AdminComponent, children: [
{path: 'schedule', component: ScheduleConfigurationComponent},
{path: 'institute', component: UnderConstructionComponent},
{path: 'account', component: UnderConstructionComponent},
{path: 'server', component: UnderConstructionComponent},
{path: '', redirectTo: 'schedule', pathMatch: 'full'},
{path: '**', redirectTo: 'schedule', 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

@ -0,0 +1,85 @@
.provider-container {
display: flex;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
margin: 0;
padding: 0;
}
.provider-container a {
display: inline-block;
text-align: center;
transition: transform 0.3s ease-in-out, filter 0.3s ease;
border-radius: 50%;
padding: 8px;
}
.provider-icon {
object-fit: contain;
user-select: none;
cursor: pointer;
transition: transform 0.3s ease-in-out, box-shadow 0.3s ease, filter 0.3s ease;
border-radius: 50%;
}
.provider-container a:hover .provider-icon {
transform: scale(1.1); /* Slight zoom-in effect on hover */
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); /* Adding shadow for effect */
}
.provider-container .provider-item.disabled {
pointer-events: none; /* Disables click */
opacity: 0.5; /* Dims the icon to indicate it is disabled */
}
.provider-container .provider-item.disabled .provider-icon {
filter: grayscale(100%) contrast(100%); /* Desaturates image if disabled */
}
.provider-item {
width: 48px;
height: 48px;
position: relative;
}
.provider-item.provider-unlink {
filter: grayscale(50%) contrast(60%);
transition: filter 0.3s ease;
}
.provider-item.provider-unlink:hover {
filter: grayscale(0%) contrast(100%);
}
.provider-item.provider-unlink::after {
content: '×';
position: absolute;
top: 10%;
left: 90%;
transform: translate(-100%, 0%) scale(0.0);
font-size: 24px;
color: white;
background-color: red;
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
cursor: pointer;
transition: transform 0.15s ease;
}
.provider-item.provider-unlink:hover::after {
transform: translate(-50%, -50%) scale(1.0);
}
.provider-item .provider-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(1.0);
border-radius: 50%;
}

View File

@ -0,0 +1,23 @@
@if (!loading && providers.length !== 0) {
<hr/>
<div>
<p class="mat-body-2 secondary">{{ message }}</p>
<div class="provider-container">
@for (provider of providers; track $index) {
<a class="provider-item" (click)="provider.disabled ? confirmDelete(provider) : openOAuth(provider)"
[class.disabled]="!canUnlink && provider.disabled || provider.active"
[class.provider-unlink]="canUnlink && provider.disabled">
<img [alt]="provider.providerName" [src]="provider.icon"
class="provider-icon" draggable="false"/>
@if (provider.active) {
<app-data-spinner class="provider-spinner"/>
}
</a>
}
</div>
</div>
} @else if (loading) {
<hr/>
<app-data-spinner style="display: flex; justify-content: center;"/>
}

View File

@ -0,0 +1,194 @@
import {Component, EventEmitter, Inject, Input, OnInit, Output} from '@angular/core';
import AuthApiService, {OAuthProviderData} from "@api/v1/authApi.service";
import {OAuthProvider} from "@model/oAuthProvider";
import {ToastrService} from "ngx-toastr";
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from "@angular/material/dialog";
import {MatButton} from "@angular/material/button";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {ActivatedRoute} from "@angular/router";
import {catchError, finalize, Observable, switchMap, tap} from "rxjs";
import {TwoFactorAuthentication} from "@model/twoFactorAuthentication";
import {OAuthAction} from "@model/oAuthAction";
import SetupService from "@api/v1/setup.service";
interface AvailableOAuthProviders extends OAuthProviderData {
disabled: boolean;
active: boolean;
}
@Component({
selector: 'app-delete-confirm-dialog',
template: `
<h1 mat-dialog-title>Удалить провайдера?</h1>
<mat-dialog-content>
<p>Вы уверены, что хотите удалить провайдера {{ data.provider.name }}?</p>
</mat-dialog-content>
<mat-dialog-actions style="display: flex; justify-content: flex-end;">
<button mat-button (click)="onCancel()">Отмена</button>
<button mat-raised-button color="warn" (click)="onConfirm()">Удалить</button>
</mat-dialog-actions>
`,
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatButton
]
})
export class DeleteConfirmDialog {
constructor(
public dialogRef: MatDialogRef<DeleteConfirmDialog>,
@Inject(MAT_DIALOG_DATA) public data: any
) {
}
onConfirm(): void {
this.dialogRef.close(true);
}
onCancel(): void {
this.dialogRef.close(false);
}
}
@Component({
selector: 'OAuthProviders',
imports: [
DataSpinnerComponent
],
templateUrl: './OAuthProviders.html',
styleUrl: './OAuthProviders.css',
providers: [SetupService, AuthApiService]
})
export class OAuthProviders implements OnInit {
protected providers: AvailableOAuthProviders[] = [];
protected _activeProvidersId: OAuthProvider[] = [];
protected _activeProviders: string[] = [];
protected loading = true;
@Input() message: string = 'Вы можете войти в аккаунт через';
@Input() set activeProviders(data: string[]) {
this._activeProviders = data;
this.updateDisabledProviders();
}
@Input() set activeProvidersId(data: OAuthProvider[]) {
this._activeProvidersId = data;
this.updateDisabledProviders();
}
@Input() canUnlink: boolean = false;
@Input() action: OAuthAction = OAuthAction.Login;
@Input() isSetup: boolean = false;
@Output() public oAuthUpdateProviders = new EventEmitter();
@Output() public oAuthLoginResult: EventEmitter<TwoFactorAuthentication> = new EventEmitter();
constructor(private setupApi: SetupService,
private authApi: AuthApiService,
private notify: ToastrService,
private dialog: MatDialog,
private route: ActivatedRoute) {
}
ngOnInit(): void {
const fullUrl = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
this.authApi.availableProviders(fullUrl).subscribe(providers => {
this.updateDisabledProviders(providers);
});
this.route.queryParamMap
.pipe(
switchMap(params => {
const result = params.get('result');
if (!result) {
this.loading = false; // Нет результата, завершение загрузки
return [];
}
return this.handleOAuthResult(result); // Обрабатываем результат
}),
catchError(_ => {
this.loading = false;
return [];
})
)
.subscribe();
}
private handleOAuthResult(result: string): Observable<any> {
switch (this.action) {
case OAuthAction.Login:
return this.authApi.loginOAuth(result).pipe(
tap(auth => {
this.oAuthLoginResult.emit(auth);
}),
finalize(() => {
this.loading = false;
})
);
case OAuthAction.Bind:
if (this.isSetup) {
return this.setupApi.registerOAuth(result).pipe(
tap(() => {
this.oAuthUpdateProviders.emit();
}),
finalize(() => {
this.loading = false;
})
);
} else
throw new Error('Action "Bind" requires setup mode to be enabled.');
break;
default:
throw new Error('Unknown action type for action ' + this.action);
}
}
private updateDisabledProviders(data: OAuthProviderData[] | null = null) {
this.providers = (data ?? this.providers).map(provider => {
return {
...provider,
disabled: this._activeProvidersId.includes(provider.provider) || this._activeProviders.includes(provider.providerName),
active: false
};
});
}
protected openOAuth(provider: AvailableOAuthProviders) {
const oauthWindow = window.open(
provider.redirect,
'_self'
);
if (!oauthWindow) {
this.notify.error('Не удалось открыть OAuth окно');
return;
}
}
protected confirmDelete(provider: AvailableOAuthProviders) {
const dialogRef = this.dialog.open(DeleteConfirmDialog, {data: {provider}});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.deleteProvider(provider);
}
});
}
protected deleteProvider(provider: AvailableOAuthProviders) {
// todo: remove provider
}
}

View File

@ -0,0 +1,17 @@
<mat-card style="margin: 16px; padding: 16px;">
<mat-card-header style="margin-bottom: 15px;">
<mat-card-title>{{ title }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-content></ng-content>
</mat-card-content>
<mat-card-actions style="display: flex; justify-content: end; margin-top: 15px;">
@if (isLoading) {
<app-data-spinner/>
} @else {
<button mat-raised-button color="accent" [disabled]="!isSaveEnabled" (click)="onSave()">
Сохранить
</button>
}
</mat-card-actions>
</mat-card>

View File

@ -0,0 +1,37 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {MatCardModule} from "@angular/material/card";
import {MatButton} from "@angular/material/button";
import {catchError, Observable, tap} from "rxjs";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
@Component({
selector: 'app-configuration-card',
imports: [
MatCardModule,
MatButton,
DataSpinnerComponent
],
templateUrl: './configuration-card.component.html'
})
export class ConfigurationCardComponent {
@Input() title: string = '';
@Input() isSaveEnabled: boolean = false;
@Input() saveFunction!: () => Observable<any>;
@Output() onSaveFunction = new EventEmitter<any>();
protected isLoading: boolean = false;
onSave(): void {
this.isLoading = true;
const result = this.saveFunction().pipe(catchError(err => {
this.isLoading = false;
throw err;
}),
tap(_ => {
this.isLoading = false;
}));
this.onSaveFunction.emit(result);
}
}

View File

@ -0,0 +1,8 @@
<h2 mat-dialog-title>Удаление расписания при изменении значения</h2>
<mat-dialog-content>
<p>Вы хотите удалить старое расписание?</p>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button [mat-dialog-close]="false">Нет</button>
<button mat-button [mat-dialog-close]="true" color="warn">Да, удалить</button>
</mat-dialog-actions>

View File

@ -0,0 +1,22 @@
import {Component} from '@angular/core';
import {
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle
} from "@angular/material/dialog";
import {MatButton} from "@angular/material/button";
@Component({
selector: 'app-confirm-delete-schedule-dialog',
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButton
],
templateUrl: './confirm-delete-schedule-dialog.component.html'
})
export class ConfirmDeleteScheduleDialogComponent {
}

View File

@ -0,0 +1,16 @@
<app-configuration-card [title]="'Cron для обновление расписания'"
[isSaveEnabled]="cronExpression != cronExpressionBefore"
[saveFunction]="saveFunction()"
(onSaveFunction)="onSave($event)">
<mat-form-field color="accent">
<mat-label>cron</mat-label>
<input matInput type="text" [(ngModel)]="cronExpression"/>
</mat-form-field>
<p>Следующие запуски:</p>
<ul>
@for (date of nextRunDates; track $index) {
<li>{{ date }}</li>
}
</ul>
</app-configuration-card>

View File

@ -0,0 +1,48 @@
import {Component} from '@angular/core';
import {ConfigurationCardComponent} from "@component/admin/configuration-card/configuration-card.component";
import {ScheduleService} from "@api/v1/configuration/schedule.service";
import {MatInputModule} from "@angular/material/input";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {Observable} from "rxjs";
import {CronUpdateScheduleResponse} from "@api/v1/configuration/cronUpdateScheduleResponse";
@Component({
selector: 'app-cron-update-schedule',
imports: [
ConfigurationCardComponent,
MatInputModule,
ReactiveFormsModule,
FormsModule
],
templateUrl: './cron-update-schedule.component.html',
providers: [ScheduleService]
})
export class CronUpdateScheduleComponent {
protected nextRunDates: string[] = [];
protected cronExpression: string = '';
protected cronExpressionBefore: string = '';
constructor(private api: ScheduleService) {
api.getCronUpdateSchedule().subscribe(data => {
this.nextRunDates = data.nextStart?.map(x => this.convertDateToString(x)) ?? [];
this.cronExpression = data.cron;
this.cronExpressionBefore = data.cron;
});
}
private convertDateToString(data: Date): string {
data = new Date(data);
return data.toLocaleDateString() + ' ' + data.toLocaleTimeString();
}
protected saveFunction() {
return () => this.api.postCronUpdateSchedule(this.cronExpression);
}
protected onSave(data: Observable<CronUpdateScheduleResponse>): void {
data.subscribe(apiData => {
this.nextRunDates = apiData.nextStart?.map(x => this.convertDateToString(x)) ?? [];
this.cronExpressionBefore = apiData.cron;
});
}
}

View File

@ -0,0 +1,40 @@
<app-configuration-card
[title]="'Загрузка расписания Excel'"
[isSaveEnabled]="selectedFiles.length > 0"
[saveFunction]="saveFunction()"
(onSaveFunction)="onUpload($event)">
<input type="file" #fileInput (change)="onFileSelected($event)" multiple accept=".xlsx, .xls" style="display: none;">
@if (fileLoading) {
<app-data-spinner/>
} @else {
<button mat-raised-button color="primary" (click)="onFileChooseClick()">
Выберите файлы
<mat-icon>attach_file</mat-icon>
</button>
}
@if (selectedFiles.length > 0) {
<div style="margin-top: 15px;">
<p>Выбранные файлы:</p>
@for (item of selectedFiles; track $index) {
<p>
{{ item.file.name }}
</p>
<mat-form-field color="accent" style="margin-bottom: 18px;">
<mat-label>Кампус по умолчанию</mat-label>
<input matInput type="text" [(ngModel)]="item.campus"
[matAutocomplete]="auto">
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="onSelectCampus($event.option.value, item)">
@for (option of onFilter(item.campus); track $index) {
<mat-option [value]="option">
{{ option }}
</mat-option>
}
</mat-autocomplete>
</mat-form-field>
}
</div>
}
</app-configuration-card>

View File

@ -0,0 +1,91 @@
import {Component, ElementRef, ViewChild} from '@angular/core';
import {ConfigurationCardComponent} from "@component/admin/configuration-card/configuration-card.component";
import {MatDialog} from "@angular/material/dialog";
import {
ConfirmDeleteScheduleDialogComponent
} from "@component/admin/confirm-delete-schedule-dialog/confirm-delete-schedule-dialog.component";
import {Observable, switchMap} from "rxjs";
import {MatButtonModule} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import {ScheduleService} from "@api/v1/configuration/schedule.service";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {MatFormFieldModule} from "@angular/material/form-field";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatInput} from "@angular/material/input";
import {ToastrService} from "ngx-toastr";
import {MatAutocomplete, MatAutocompleteTrigger, MatOption} from "@angular/material/autocomplete";
import {CampusService} from "@api/v1/campus.service";
@Component({
selector: 'app-schedule-file-upload',
imports: [
ConfigurationCardComponent,
MatButtonModule,
MatIcon,
DataSpinnerComponent,
MatFormFieldModule,
FormsModule,
MatInput,
MatAutocomplete,
MatAutocompleteTrigger,
MatOption,
ReactiveFormsModule
],
templateUrl: './schedule-file-upload.component.html',
providers: [ScheduleService, CampusService]
})
export class ScheduleFileUploadComponent {
protected selectedFiles: { file: File, campus: string }[] = [];
protected fileLoading: boolean = false;
protected campuses: string[] = [];
@ViewChild('fileInput') input!: ElementRef;
constructor(
private dialog: MatDialog,
private api: ScheduleService,
private notify: ToastrService,
campus: CampusService) {
campus.getCampus().subscribe(data => {
this.campuses = data.map(x => x.codeName);
});
}
protected onSelectCampus(value: string, item: { file: File, campus: string }) {
item.campus = value;
}
protected saveFunction() {
return () => {
const dialogRef = this.dialog.open(ConfirmDeleteScheduleDialogComponent);
return dialogRef.afterClosed().pipe(switchMap(result => {
return this.api.uploadScheduleFile(
this.selectedFiles.map(x => x.file),
this.selectedFiles.map(x => x.campus),
result);
}));
};
}
protected onFilter(value: string): string[] {
const filterValue = value?.toLowerCase() || '';
return this.campuses.filter(campus => campus.toLowerCase().includes(filterValue));
}
protected onFileChooseClick() {
this.fileLoading = true;
this.input.nativeElement.click();
}
protected onFileSelected(event: any): void {
this.fileLoading = false;
this.selectedFiles = Array.from(event.target.files).map(file => ({file: <File>file, campus: ''}));
}
protected onUpload(data: Observable<any>): void {
data.subscribe(_ => {
this.notify.info(`Файлы в размере ${this.selectedFiles.length} успешно загружены. Задача поставлена в очередь`);
this.selectedFiles = [];
});
}
}

View File

@ -0,0 +1,19 @@
.date-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.date-item {
display: flex;
align-items: center;
gap: 16px;
}
mat-form-field {
flex: 1;
}
button[mat-icon-button] {
margin-left: auto;
}

View File

@ -0,0 +1,51 @@
<app-configuration-card [title]="'Список пропуска обновления расписания'"
[isSaveEnabled]="validateSaveButton() && !isDisableAddNewItem()"
[saveFunction]="saveFunction()"
(onSaveFunction)="onSave($event)">
<div class="date-list">
@for (dateItem of dateItems; track $index) {
<div class="date-item">
<mat-form-field color="accent">
<mat-label>Диапазон дат</mat-label>
<mat-date-range-input [rangePicker]="rangePicker"
[disabled]="dateItems[$index].date">
<input matStartDate [(ngModel)]="dateItem.start"
placeholder="Начало"
(dateChange)="validateDate($index)"
[min]="CurrentDate">
<input matEndDate [(ngModel)]="dateItem.end"
placeholder="Конец"
(dateChange)="validateDate($index)"
[min]="CurrentDate">
</mat-date-range-input>
<mat-datepicker-toggle matSuffix [for]="rangePicker"></mat-datepicker-toggle>
<mat-date-range-picker #rangePicker></mat-date-range-picker>
</mat-form-field>
<mat-form-field color="accent">
<mat-label>Конкретная дата</mat-label>
<input matInput [matDatepicker]="specificDatePicker"
[(ngModel)]="dateItem.date"
(dateChange)="validateDate($index)"
[min]="CurrentDate"
[disabled]="dateItems[$index].start != null || dateItems[$index].end != null">
<mat-datepicker-toggle matSuffix [for]="specificDatePicker"></mat-datepicker-toggle>
<mat-datepicker #specificDatePicker></mat-datepicker>
</mat-form-field>
<button mat-icon-button color="warn" (click)="removeDate($index)" style="height: 100%;">
<mat-icon>delete</mat-icon>
</button>
</div>
}
</div>
<button mat-raised-button color="accent"
[disabled]="isDisableAddNewItem()"
(click)="addDate()">
<mat-icon>add</mat-icon>
Добавить строку
</button>
</app-configuration-card>

View File

@ -0,0 +1,93 @@
import {Component} from '@angular/core';
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatDatepickerModule} from "@angular/material/datepicker";
import {MatInput} from "@angular/material/input";
import {FormsModule} from "@angular/forms";
import {ConfigurationCardComponent} from "@component/admin/configuration-card/configuration-card.component";
import {MatButtonModule} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import {ScheduleService} from "@api/v1/configuration/schedule.service";
import CronUpdateSkip from "@model/cronUpdateSkip";
import {DateOnly} from "@model/dateOnly";
import {addDays} from "@progress/kendo-date-math";
import {Observable} from "rxjs";
@Component({
selector: 'app-skip-update-schedule',
imports: [
MatFormFieldModule,
MatDatepickerModule,
MatInput,
FormsModule,
ConfigurationCardComponent,
MatButtonModule,
MatIcon
],
templateUrl: './skip-update-schedule.component.html',
styleUrl: './skip-update-schedule.component.css',
providers: [ScheduleService]
})
export class SkipUpdateScheduleComponent {
dateItems: { start?: Date, end?: Date, date?: Date }[] = [];
dateItemsBefore: { start?: Date, end?: Date, date?: Date }[] = [];
constructor(private api: ScheduleService) {
api.getCronUpdateSkip().subscribe(data => {
this.dateItems = data.map(x => <{ start?: Date, end?: Date, date?: Date }>{
start: x.start?.date,
end: x.end?.date,
date: x.date?.date
});
if (this.dateItems.length == 0)
this.addDate();
this.dateItemsBefore = JSON.parse(JSON.stringify(this.dateItems));
});
}
addDate(): void {
this.dateItems.push({start: undefined, end: undefined, date: undefined});
}
removeDate(index: number): void {
this.dateItems.splice(index, 1);
}
validateDate(index: number): void {
const item = this.dateItems[index];
if (item.start && item.start < this.CurrentDate)
item.start = undefined;
if (item.end && item.end < this.CurrentDate)
item.end = undefined;
if (item.date && item.date < this.CurrentDate)
item.date = undefined;
}
isDisableAddNewItem() {
return this.dateItems.some(item => (!item.start || !item.end) && !item.date);
}
validateSaveButton(): boolean {
return (this.dateItems.length == 0 || this.dateItems.some(item =>
(item.start && item.end) || item.date
)) && JSON.stringify(this.dateItems) != JSON.stringify(this.dateItemsBefore);
}
saveFunction() {
return () => this.api.postCronUpdateSkip(this.dateItems.map(x =>
<CronUpdateSkip>{
start: x.start ? new DateOnly(x.start) : undefined,
end: x.end ? new DateOnly(x.end) : undefined,
date: x.date ? new DateOnly(x.date) : undefined
}));
}
onSave(event: Observable<any>): void {
event.subscribe(_ => {
this.dateItemsBefore = JSON.parse(JSON.stringify(this.dateItems));
});
}
protected CurrentDate: Date = addDays(new Date(), -1);
}

View File

@ -0,0 +1,12 @@
<app-configuration-card
[title]="'Дата начала семестра'"
[isSaveEnabled]="startDate != startDateBefore"
[saveFunction]="saveFunction()"
(onSaveFunction)="onSave($event)">
<mat-form-field color="accent">
<mat-label>Дата начала семестра</mat-label>
<input matInput [matDatepicker]="datePicker" [(ngModel)]="startDate" [min]="ValidMinDate">
<mat-datepicker-toggle matSuffix [for]="datePicker"></mat-datepicker-toggle>
<mat-datepicker #datePicker></mat-datepicker>
</mat-form-field>
</app-configuration-card>

View File

@ -0,0 +1,61 @@
import {Component} from '@angular/core';
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatDatepicker, MatDatepickerInput, MatDatepickerToggle} from "@angular/material/datepicker";
import {FormsModule} from "@angular/forms";
import {ConfigurationCardComponent} from "@component/admin/configuration-card/configuration-card.component";
import {MatInput} from "@angular/material/input";
import {addDays} from "@progress/kendo-date-math";
import {ScheduleService} from "@api/v1/configuration/schedule.service";
import {DateOnly} from "@model/dateOnly";
import {MatDialog} from "@angular/material/dialog";
import {
ConfirmDeleteScheduleDialogComponent
} from "@component/admin/confirm-delete-schedule-dialog/confirm-delete-schedule-dialog.component";
import {Observable, switchMap} from "rxjs";
@Component({
selector: 'app-term-start-date',
imports: [
MatFormFieldModule,
MatDatepickerToggle,
FormsModule,
MatDatepickerInput,
MatDatepicker,
ConfigurationCardComponent,
MatInput
],
templateUrl: './term-start-date.component.html',
providers: [ScheduleService]
})
export class TermStartDateComponent {
protected startDate: Date = new Date();
protected startDateBefore: Date = new Date();
constructor(private api: ScheduleService, private dialog: MatDialog) {
this.api.getStartTerm().subscribe(data => {
this.startDate = data.date;
this.startDateBefore = this.startDate;
console.log(this.startDate == this.startDateBefore);
});
}
protected saveFunction() {
return () => {
const dialogRef = this.dialog.open(ConfirmDeleteScheduleDialogComponent, {
data: {startDate: this.startDate}
});
return dialogRef.afterClosed().pipe(switchMap(result => {
return this.api.postStartTerm(new DateOnly(this.startDate), result);
}));
};
}
protected onSave(data: Observable<any>): void {
data.subscribe(_ => {
this.startDateBefore = this.startDate;
});
}
protected ValidMinDate = addDays(new Date(), -180);
}

View File

@ -1 +1 @@
<mat-progress-spinner [color]="color" mode="indeterminate" [diameter]="scale" />
<mat-progress-spinner [color]="color" mode="indeterminate" [diameter]="scale"/>

View File

@ -1,13 +1,11 @@
import {Component, Input} from '@angular/core';
import {MatProgressSpinner} from "@angular/material/progress-spinner";
import {NgStyle} from "@angular/common";
@Component({
selector: 'app-data-spinner',
standalone: true,
imports: [
MatProgressSpinner,
NgStyle
],
templateUrl: './data-spinner.component.html'
})

View File

@ -22,8 +22,8 @@
<hr/>
<div class="app-footer-copyright">
<span>Powered by <a href="https://winsomnia.net">Winsomnia</a> &copy;{{ currentYear }}.</span>
<a href="https://opensource.org/license/mit/">Code licensed under an MIT-style License.</a>
<span>Current Version: {{ version }}</span>
<a href="https://opensource.org/license/mit/">Code licensed under an MIT-style License.</a>
<span>Current Version: {{ version }}</span>
</div>
</div>
</footer>

View File

@ -1,16 +1,13 @@
import { Component } from '@angular/core';
import {Component} from '@angular/core';
import {MatToolbar} from "@angular/material/toolbar";
import {MatAnchor, MatButton} from "@angular/material/button";
import {HasRoleDirective} from "@/directives/has-role.directive";
import {AuthRoles} from "@model/AuthRoles";
import {AuthRoles} from "@model/authRoles";
@Component({
selector: 'app-header',
standalone: true,
imports: [
MatToolbar,
MatButton,
MatAnchor,
HasRoleDirective
],
templateUrl: './header.component.html',

View File

@ -1,7 +1,7 @@
@if (loading) {
<app-data-spinner/>
} @else {
<button mat-fab color="primary" (click)="retryFunction.emit()">
<button mat-fab color="primary" (click)="retryLoad()">
<mat-icon>refresh</mat-icon>
</button>
}

View File

@ -1,14 +1,13 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {MatIcon} from "@angular/material/icon";
import {MatButton, MatFabButton} from "@angular/material/button";
import {MatFabButton} from "@angular/material/button";
@Component({
selector: 'app-loading-indicator',
standalone: true,
imports: [
DataSpinnerComponent,
MatButton,
MatIcon,
MatFabButton
],
@ -18,4 +17,9 @@ import {MatButton, MatFabButton} from "@angular/material/button";
export class LoadingIndicatorComponent {
@Input() loading: boolean = true;
@Output() retryFunction: EventEmitter<void> = new EventEmitter<void>();
protected retryLoad() {
this.loading = true;
this.retryFunction.emit();
}
}

View File

@ -1,10 +0,0 @@
.notification-content {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.close-button {
margin-left: 8px;
}

View File

@ -1,11 +0,0 @@
<div class="notification-content" [class]="data.className">
<span>
{{ data.message }}
</span>
<button mat-icon-button class="close-button" (click)="dismiss()">
<mat-icon>close</mat-icon>
</button>
</div>
@if (showProgressBar) {
<mat-progress-bar mode="determinate" [value]="progress" [color]="color"/>
}

View File

@ -1,50 +0,0 @@
import {Component, Inject} from '@angular/core';
import {MatIcon} from "@angular/material/icon";
import {MatProgressBar} from "@angular/material/progress-bar";
import {MAT_SNACK_BAR_DATA, MatSnackBarRef} from "@angular/material/snack-bar";
import {MatIconButton} from "@angular/material/button";
@Component({
selector: 'app-notification',
standalone: true,
imports: [
MatIconButton,
MatIcon,
MatProgressBar
],
templateUrl: './notification.component.html',
styleUrl: './notification.component.css'
})
export class NotificationComponent {
showProgressBar: boolean = false;
progress: number = 100;
color: string = "primary";
constructor(@Inject(MAT_SNACK_BAR_DATA) public data: any, private snackBarRef: MatSnackBarRef<NotificationComponent>) {
if (data.duration) {
this.startProgress(data.duration);
this.showProgressBar = true;
}
if (data.color) {
this.color = data.color;
}
}
dismiss(): void {
this.snackBarRef.dismiss();
}
private startProgress(duration: number): void {
const interval: number = duration / 100;
const progressInterval = setInterval(async () => {
this.progress--;
if (this.progress === 0) {
clearInterval(progressInterval);
setTimeout(() => {
this.dismiss();
}, 1000);
}
}, interval);
}
}

View File

@ -0,0 +1,45 @@
<div [formGroup]="formGroup" style="display: flex; flex-direction: column; align-items: stretch;">
<mat-form-field color="accent">
<mat-label>Пароль</mat-label>
<input matInput
matTooltip="Укажите пароль"
formControlName="password"
required
[type]="hidePass ? 'password' : 'text'"
id="passwordNextFocus"
focusNext="focusNext">
<button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'"
[attr.aria-pressed]="hidePass">
<mat-icon>{{ hidePass ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
@if (formGroup.get('password')?.hasError('required')) {
<mat-error>
Пароль является <i>обязательным</i>
</mat-error>
}
@if (formGroup.get('password')?.hasError('minlength')) {
<mat-error>
Пароль должен быть не менее {{ policy.minimumLength }} символов
</mat-error>
}
@if (formGroup.get('password')?.hasError('pattern')) {
<mat-error>
Пароль должен содержать:
@if (policy.requireLettersDifferentCase) {
Латинские символы разных регистров
} @else if (policy.requireLetter) {
Один латинский символ
} @else if (policy.requireDigit) {
Одну цифру
}
@if (policy.requireSpecialCharacter) {
Специальный символ
}
</mat-error>
}
</mat-form-field>
</div>

View File

@ -0,0 +1,76 @@
import {Component, Input} from '@angular/core';
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import {MatIconButton} from "@angular/material/button";
import {FormGroup, ReactiveFormsModule, ValidatorFn, Validators} from "@angular/forms";
import {MatSelectModule} from "@angular/material/select";
import {MatTooltip} from "@angular/material/tooltip";
import {MatIcon} from "@angular/material/icon";
import {PasswordPolicy} from "@model/passwordPolicy";
import SecurityService from "@api/v1/securityService";
import {FocusNextDirective} from "@/directives/focus-next.directive";
import SetupService from "@api/v1/setup.service";
@Component({
selector: 'password-input',
imports: [
ReactiveFormsModule,
MatFormFieldModule,
MatSelectModule,
MatInput,
MatTooltip,
MatIconButton,
MatIcon,
FocusNextDirective
],
templateUrl: './password-input.component.html',
providers: [SecurityService, SetupService]
})
export class PasswordInputComponent {
protected hidePass = true;
protected policy!: PasswordPolicy;
@Input() formGroup!: FormGroup;
@Input() focusNext: string | undefined;
@Input() isSetupMode: boolean = false;
constructor(securityApi: SecurityService, setupApi: SetupService) {
if (this.isSetupMode)
setupApi.passwordPolicyConfiguration().subscribe(policy => {
this.policy = policy;
this.updateValueAndValidity(policy);
});
else
securityApi.passwordPolicy().subscribe(policy => {
this.policy = policy;
this.updateValueAndValidity(policy);
});
}
private updateValueAndValidity(policy: PasswordPolicy): void {
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(/[!@#$%^&*(),.?":{}|<>]/));
}
this.formGroup.get('password')?.setValidators(validators);
this.formGroup.get('password')?.updateValueAndValidity();
}
protected togglePassword(event: MouseEvent) {
this.hidePass = !this.hidePass;
event.stopPropagation();
}
}

View File

@ -51,10 +51,12 @@
<div class="mat-body-1">{{ elementData["discipline"] }}</div>
<!-- Type of Occupation -->
@for (typeOfOccupation of elementData["typeOfOccupations"]; track $index) {
@if ($index !== 0) {
<br/>
@if ($index === 0 && elementData["typeOfOccupations"][$index - 1] !== typeOfOccupation) {
@if ($index !== 0) {
<br/>
}
<div class="mat-body">({{ typeOfOccupation }})</div>
}
<div class="mat-body">({{ typeOfOccupation }})</div>
}
<!-- Professors -->

View File

@ -3,7 +3,6 @@ import {MatTableDataSource, MatTableModule} from "@angular/material/table";
import {MatIcon} from "@angular/material/icon";
import {DatePipe} from "@angular/common";
import {addDays} from "@progress/kendo-date-math";
import {MatDivider} from "@angular/material/divider";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {ScheduleResponse} from "@api/v1/scheduleResponse";
@ -23,7 +22,6 @@ interface Dictionary {
MatTableModule,
MatIcon,
DatePipe,
MatDivider,
DataSpinnerComponent
],
templateUrl: './table.component.html',
@ -89,7 +87,7 @@ export class TableComponent implements OnChanges {
);
const groupedData = filteredData.reduce((acc, item) => {
const key = `${item.lectureHalls}-${item.campus}-${item.discipline}-${item.professors.join(', ')}-${item.isExcludedWeeks}-${item.weeks?.join(', ') || ''}`;
const key = `${item.typeOfOccupations.join(', ')}-${item.lectureHalls}-${item.campus}-${item.discipline}-${item.professors.join(', ')}-${item.isExcludedWeeks}-${item.weeks?.join(', ') || ''}`;
if (!acc[key])
acc[key] = {...item, groups: [item.group]};
else

View File

@ -90,7 +90,7 @@
}
@if (((filteredGroupsSpecialist && filteredGroupsSpecialist.length > 0 && filteredGroupsBehaviour && filteredGroupsBehaviour.length > 0) ||
((!filteredGroupsSpecialist || filteredGroupsSpecialist.length === 0) && filteredGroupsBehaviour && filteredGroupsBehaviour.length > 0)) &&
((!filteredGroupsSpecialist || filteredGroupsSpecialist.length === 0) && filteredGroupsBehaviour && filteredGroupsBehaviour.length > 0)) &&
filteredGroupsMagistracy && filteredGroupsMagistracy.length > 0) {
<div class="div-wrapper">
<hr/>

View File

@ -1,4 +1,4 @@
import {Component, EventEmitter, Output, ViewChild} from '@angular/core';
import {Component, EventEmitter, ViewChild} from '@angular/core';
import {MatExpansionModule, MatExpansionPanel} from "@angular/material/expansion";
import {MatChipListbox, MatChipsModule} from '@angular/material/chips';
import {FormControl, FormsModule, ReactiveFormsModule} from "@angular/forms";
@ -9,7 +9,11 @@ import {FacultyResponse} from "@api/v1/facultyResponse";
import {FacultyService} from "@api/v1/faculty.service";
import {GroupService} from "@api/v1/group.service";
import {IScheduleTab} from "@component/schedule/tabs/ischedule-tab";
import {TabSelect, TabSelectData, TabSelectType, TabStorageService} from "@service/tab-storage.service";
import {TabSelect, TabStorageService} from "@service/tab-storage.service";
enum Enclosure {
faculty, course, group
}
@Component({
selector: 'app-group',
@ -25,7 +29,6 @@ import {TabSelect, TabSelectData, TabSelectType, TabStorageService} from "@servi
styleUrl: './group.component.css',
providers: [FacultyService, GroupService]
})
export class GroupComponent implements IScheduleTab {
protected faculties: FacultyResponse[] | null = null;
protected courseNumbers: number[] | null = null;
@ -51,13 +54,50 @@ export class GroupComponent implements IScheduleTab {
@ViewChild('courseIndicator') courseIndicator!: LoadingIndicatorComponent;
@ViewChild('groupIndicator') groupIndicator!: LoadingIndicatorComponent;
@Output() eventResult = new EventEmitter<number>();
constructor(private facultyApi: FacultyService, private groupApi: GroupService, private tabStorage: TabStorageService) {
private resetCourse() {
this.courseNumber = null;
this.groups = [];
this.formChipCourse.reset();
this.courseChip.value = undefined;
}
existParams(data: TabSelectData): boolean {
return data.selected['group'] !== undefined || data.selected['course'] !== undefined || data.selected['faculty'] !== undefined;
private resetGroup() {
this.filteredGroupsBehaviour = [];
this.filteredGroupsMagistracy = [];
this.filteredGroupsSpecialist = [];
this.formChipGroup.reset();
this.groupChip.value = undefined;
}
public eventResult = new EventEmitter<number>();
public selectChangeEvent = new EventEmitter<TabSelect[]>();
constructor(private facultyApi: FacultyService, private groupApi: GroupService) {
}
private getSelectedTabs(): TabSelect[] {
const faculty = this.facultyChip.value;
const course = this.courseChip.value;
const group = this.groupChip.value;
const result: TabSelect[] = [];
if (faculty)
result.push(new TabSelect(faculty, this.faculties!.find(x => x.id === faculty)?.name ?? ''));
if (course)
result.push(new TabSelect(course, course.toString()));
if (group)
result.push(new TabSelect(group, this.groups!.find(x => x.id == group)?.name ?? ''));
return result;
}
public getEnclosureList(): string[] {
return Object.keys(Enclosure).filter((item) => {
return isNaN(Number(item));
});
}
protected loadFaculties() {
@ -69,7 +109,8 @@ export class GroupComponent implements IScheduleTab {
.subscribe(data => {
this.faculties = data;
let selected = TabStorageService.selected?.selected['faculty'];
let selected = TabStorageService.selected?.selected[this.getEnclosureList()[Enclosure.faculty]];
if (selected) {
let selectedFaculty = data.find(x => x.id === selected.index);
@ -78,7 +119,7 @@ export class GroupComponent implements IScheduleTab {
if (selectedFaculty !== undefined) {
TabStorageService.trySelectChip(selectedFaculty.id, this.facultyChip);
this.onFacultySelected(selectedFaculty.id);
this.onFacultySelected(selectedFaculty.id, true);
}
}
});
@ -104,7 +145,7 @@ export class GroupComponent implements IScheduleTab {
.sort((a, b) => a - b))
);
let selected = TabStorageService.selected?.selected['course'];
let selected = TabStorageService.selected?.selected[this.getEnclosureList()[Enclosure.course]];
if (selected) {
let selectedCourse = this.courseNumbers.find(x => x === selected.index);
@ -113,11 +154,11 @@ export class GroupComponent implements IScheduleTab {
if (selectedCourse !== undefined) {
TabStorageService.trySelectChip(selectedCourse, this.courseChip);
this.onCourseSelected(selectedCourse);
this.onCourseSelected(selectedCourse, true);
}
}
let selectedGroupStorage = TabStorageService.selected?.selected['group'];
let selectedGroupStorage = TabStorageService.selected?.selected[this.getEnclosureList()[Enclosure.group]];
if (selectedGroupStorage) {
let selectedGroup = data.find(x => x.id === selectedGroupStorage.index);
@ -126,7 +167,7 @@ export class GroupComponent implements IScheduleTab {
if (selectedGroup !== undefined) {
TabStorageService.trySelectChip(selectedGroup.id, this.groupChip);
this.onGroupSelected(selectedGroup.id);
this.onGroupSelected(selectedGroup.id, true);
}
}
});
@ -147,18 +188,19 @@ export class GroupComponent implements IScheduleTab {
}
}
protected onFacultySelected(index: number) {
this.courseNumber = null;
this.groups = [];
this.formChipGroup.reset();
this.formChipCourse.reset();
protected onFacultySelected(index: number, loadMode: boolean = false) {
this.resetCourse();
this.resetGroup();
if (index === undefined) {
this.facultyId = null;
return;
}
this.tabStorage.select(new TabSelect(index, this.faculties!.find(x => x.id === index)?.name ?? ''), TabSelectType.group, 'faculty');
if (loadMode)
this.facultyChip.value = index;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
this.facultyId = index;
this.courseNumberPanel.open();
@ -166,29 +208,32 @@ export class GroupComponent implements IScheduleTab {
this.loadCourseGroup();
}
protected onCourseSelected(course: number) {
this.filteredGroupsBehaviour = [];
this.filteredGroupsMagistracy = [];
this.filteredGroupsSpecialist = [];
this.formChipGroup.reset();
protected onCourseSelected(course: number, loadMode: boolean = false) {
this.resetGroup();
if (course === undefined) {
this.courseNumber = null;
return;
}
this.tabStorage.select(new TabSelect(course, course.toString()), TabSelectType.group, 'course');
if (loadMode)
this.courseChip.value = course;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
this.courseNumber = course;
this.groupPanel.open();
this.loadCourseGroup();
}
protected onGroupSelected(index: number) {
protected onGroupSelected(index: number, loadMode: boolean = false) {
if (index === undefined)
return;
this.tabStorage.select(new TabSelect(index, this.groups!.find(x => x.id == index)?.name ?? ''), TabSelectType.group, 'group');
if (loadMode)
this.groupChip.value = index;
this.selectChangeEvent.emit(this.getSelectedTabs());
this.groupPanel.close();
this.eventResult.emit(index);

View File

@ -1,8 +1,12 @@
import {EventEmitter} from "@angular/core";
import {TabSelectData} from "@service/tab-storage.service";
import {TabSelect} from "@service/tab-storage.service";
export interface IScheduleTab {
load(): void;
getEnclosureList(): string[];
eventResult: EventEmitter<number>;
existParams(data: TabSelectData): boolean;
selectChangeEvent: EventEmitter<TabSelect[]>;
}

View File

@ -23,7 +23,8 @@
Кабинет
</mat-panel-title>
</mat-expansion-panel-header>
<mat-chip-listbox hideSingleSelectionIndicator (change)="onLectureHallSelected($event.value)" [formControl]="formLectureHalls" #lectureChip>
<mat-chip-listbox hideSingleSelectionIndicator (change)="onLectureHallSelected($event.value)"
[formControl]="formLectureHalls" #lectureChip>
@for (lectureHall of lectureHallsFiltered; track $index) {
<mat-chip-option [value]="lectureHall.id" color="accent">
{{ lectureHall.name }}

View File

@ -1,5 +1,4 @@
import {Component, EventEmitter, Output, ViewChild} from '@angular/core';
import {AsyncPipe} from "@angular/common";
import {Component, EventEmitter, ViewChild} from '@angular/core';
import {MatAccordion, MatExpansionModule, MatExpansionPanel} from "@angular/material/expansion";
import {MatChipListbox, MatChipsModule} from "@angular/material/chips";
import {catchError} from "rxjs";
@ -10,7 +9,11 @@ import {LectureHallResponse} from "@api/v1/lectureHallResponse";
import {CampusService} from "@api/v1/campus.service";
import {LectureHallService} from "@api/v1/lectureHall.service";
import {IScheduleTab} from "@component/schedule/tabs/ischedule-tab";
import {TabSelect, TabSelectData, TabSelectType, TabStorageService} from "@service/tab-storage.service";
import {TabSelect, TabStorageService} from "@service/tab-storage.service";
enum Enclosure {
campus, lecture
}
@Component({
selector: 'app-lecture-hall',
@ -18,7 +21,6 @@ import {TabSelect, TabSelectData, TabSelectType, TabStorageService} from "@servi
imports: [
MatChipsModule,
MatExpansionModule,
AsyncPipe,
ReactiveFormsModule,
MatAccordion,
LoadingIndicatorComponent
@ -27,7 +29,6 @@ import {TabSelect, TabSelectData, TabSelectType, TabStorageService} from "@servi
styleUrl: './lecture-hall.component.css',
providers: [CampusService, LectureHallService]
})
export class LectureHallComponent implements IScheduleTab {
protected campusId: number | null = null;
protected formLectureHalls: FormControl = new FormControl();
@ -35,8 +36,6 @@ export class LectureHallComponent implements IScheduleTab {
protected campuses: CampusBasicInfoResponse[] | null = null;
protected lectureHallsFiltered: LectureHallResponse[] | null = null;
@Output() eventResult = new EventEmitter<number>();
@ViewChild('lecturePanel') lecturePanel!: MatExpansionPanel;
@ViewChild('lectureIndicator') lectureIndicator!: LoadingIndicatorComponent;
@ViewChild('campusIndicator') campusIndicator!: LoadingIndicatorComponent;
@ -46,11 +45,31 @@ export class LectureHallComponent implements IScheduleTab {
private lectureHalls: LectureHallResponse[] | null = null;
constructor(private campusApi: CampusService, private lectureHallApi: LectureHallService, private tabStorage: TabStorageService) {
public eventResult = new EventEmitter<number>();
public selectChangeEvent = new EventEmitter<TabSelect[]>();
constructor(private campusApi: CampusService, private lectureHallApi: LectureHallService) {
}
existParams(data: TabSelectData): boolean {
return data.selected['campus'] !== undefined || data.selected['lecture'] !== undefined;
private getSelectedTabs(): TabSelect[] {
const campus = this.campusChip.value;
const lecture = this.lectureChip.value;
const result: TabSelect[] = [];
if (campus)
result.push(new TabSelect(campus, this.campuses!.find(x => x.id === campus)?.codeName ?? ''));
if (lecture)
result.push(new TabSelect(lecture, this.lectureHallsFiltered!.find(x => x.id === lecture)?.name ?? ''));
return result;
}
getEnclosureList(): string[] {
return Object.keys(Enclosure).filter((item) => {
return isNaN(Number(item));
});
}
protected loadCampuses() {
@ -62,7 +81,7 @@ export class LectureHallComponent implements IScheduleTab {
.subscribe(data => {
this.campuses = data;
let selected = TabStorageService.selected?.selected['campus'];
let selected = TabStorageService.selected?.selected[this.getEnclosureList()[Enclosure.campus]];
if (selected) {
let selectedCampus = data.find(x => x.id === selected.index);
@ -71,7 +90,7 @@ export class LectureHallComponent implements IScheduleTab {
if (selectedCampus !== undefined) {
TabStorageService.trySelectChip(selectedCampus.id, this.campusChip);
this.onCampusSelected(selectedCampus.id);
this.onCampusSelected(selectedCampus.id, true);
}
}
});
@ -81,10 +100,14 @@ export class LectureHallComponent implements IScheduleTab {
this.lectureHallsFiltered = this.lectureHalls?.filter(x => x.campusId === this.campusId) ?? null;
}
protected onCampusSelected(index: number) {
protected onCampusSelected(index: number, loadMode: boolean = false) {
this.formLectureHalls.reset();
this.lectureChip.value = undefined;
this.tabStorage.select(new TabSelect(index, this.campuses!.find(x => x.id === index)?.codeName ?? ''), TabSelectType.lecture, 'campus');
if (loadMode)
this.campusChip.value = index;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
if (index === undefined) {
this.campusId = null;
@ -111,7 +134,7 @@ export class LectureHallComponent implements IScheduleTab {
this.lectureHalls = data;
this.filteringLectureHalls();
let selected = TabStorageService.selected?.selected['lecture'];
let selected = TabStorageService.selected?.selected[this.getEnclosureList()[Enclosure.lecture]];
if (selected) {
let selectedLecture = data.find(x => x.id === selected.index);
@ -120,17 +143,20 @@ export class LectureHallComponent implements IScheduleTab {
if (selectedLecture !== undefined) {
TabStorageService.trySelectChip(selectedLecture.id, this.lectureChip);
this.onLectureHallSelected(selectedLecture.id);
this.onLectureHallSelected(selectedLecture.id, true);
}
}
});
}
protected onLectureHallSelected(index: number) {
protected onLectureHallSelected(index: number, loadMode: boolean = false) {
if (index === undefined)
return;
this.tabStorage.select(new TabSelect(index, this.lectureHallsFiltered!.find(x => x.id === index)?.name ?? ''), TabSelectType.lecture, 'lecture');
if (loadMode)
this.lectureChip.value = index;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
this.lecturePanel.close();
this.eventResult.emit(index);

View File

@ -1,11 +1,14 @@
<!--suppress CssInvalidPropertyValue -->
<button mat-button [matMenuTriggerFor]="menu" #menuTrigger="matMenuTrigger" [id]="idButton" style="margin-bottom: 10px;">{{ textButton }}</button>
<button mat-button [matMenuTriggerFor]="menu" #menuTrigger="matMenuTrigger" [id]="idButton"
style="margin-bottom: 10px;">{{ textButton }}
</button>
<mat-menu #menu="matMenu" [hasBackdrop]="false" class="menu-options">
<div (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()" style="padding: 0 15px 15px">
<div class="header-menu">
<mat-form-field appearance="outline" color="accent" style="display:flex;">
<input matInput placeholder="Поиск..." [(ngModel)]="searchQuery" [disabled]="data === null || data.length === 0">
<input matInput placeholder="Поиск..." [(ngModel)]="searchQuery"
[disabled]="data === null || data.length === 0">
<button mat-icon-button matSuffix (click)="clearSearchQuery()" [disabled]="data === null || data.length === 0">
<mat-icon style="color: var(--mdc-filled-button-label-text-color);">close</mat-icon>
</button>

View File

@ -1,4 +1,4 @@
import {Component, EventEmitter, OnInit, Output, ViewChild} from "@angular/core";
import {Component, EventEmitter, OnInit, ViewChild} from "@angular/core";
import {MatFormField, MatInput} from "@angular/material/input";
import {FormControl, ReactiveFormsModule} from "@angular/forms";
import {MatAutocompleteModule} from "@angular/material/autocomplete";
@ -8,7 +8,7 @@ import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loa
import {ProfessorResponse} from "@api/v1/professorResponse";
import {ProfessorService} from "@api/v1/professor.service";
import {IScheduleTab} from "@component/schedule/tabs/ischedule-tab";
import {TabSelect, TabSelectData, TabSelectType, TabStorageService} from "@service/tab-storage.service";
import {TabSelect, TabStorageService} from "@service/tab-storage.service";
@Component({
selector: 'app-professor',
@ -33,13 +33,14 @@ export class ProfessorComponent implements OnInit, IScheduleTab {
@ViewChild('professorIndicator') professorIndicator!: LoadingIndicatorComponent;
@Output() eventResult = new EventEmitter<number>();
public eventResult = new EventEmitter<number>();
public selectChangeEvent = new EventEmitter<TabSelect[]>();
constructor(private api: ProfessorService, private tabStorage: TabStorageService) {
constructor(private api: ProfessorService) {
}
existParams(data: TabSelectData): boolean {
return data.selected['professor'] !== undefined;
getEnclosureList(): string[] {
return ['professor'];
}
protected loadProfessors() {
@ -95,7 +96,7 @@ export class ProfessorComponent implements OnInit, IScheduleTab {
this.professorControl.setValue(selectedOption.name);
this.eventResult.emit(selectedOption.id);
this.tabStorage.select(new TabSelect(selectedOption.id, selectedOption.name), TabSelectType.professor, 'professor');
this.selectChangeEvent.emit([new TabSelect(selectedOption.id, selectedOption.name)]);
}
}

View File

@ -3,17 +3,17 @@
(selectedTabChange)="chooseTabs($event.index)">
<mat-tab label="Группа">
<div>
<app-group #groupTab (eventResult)="groupSelected($event)"/>
<app-group #groupTab/>
</div>
</mat-tab>
<mat-tab label="Преподаватель">
<div>
<app-professor #professorTab (eventResult)="professorSelected($event)"/>
<app-professor #professorTab/>
</div>
</mat-tab>
<mat-tab label="Кабинет">
<div>
<app-lecture-hall #lectureHallTab (eventResult)="lectureHallSelected($event)"/>
<app-lecture-hall #lectureHallTab/>
</div>
</mat-tab>
@ -23,6 +23,7 @@
<app-other idButton="lecture-button" textButton="Кабинеты" #lecture (retryLoadData)="loadLectureHalls()"/>
<app-other idButton="group-button" textButton="Группы" #group (retryLoadData)="loadGroups()"/>
<app-other idButton="professor-button" textButton="Профессоры" #professor (retryLoadData)="loadProfessors()"/>
<app-other idButton="lesson-type-button" textButton="Тип занятия" #lesson_type (retryLoadData)="loadLessonType()"/>
<section>
<button mat-flat-button (click)="otherFilter()">Отфильтровать</button>
</section>

View File

@ -4,7 +4,6 @@ import {MatTab, MatTabGroup} from "@angular/material/tabs";
import {Observable} from "rxjs";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatButton} from "@angular/material/button";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {GroupComponent} from "@component/schedule/tabs/group/group.component";
import {ProfessorComponent} from "@component/schedule/tabs/professor/professor.component";
import {LectureHallComponent} from "@component/schedule/tabs/lecture-hall/lecture-hall.component";
@ -15,9 +14,12 @@ import {DisciplineService} from "@api/v1/discipline.service";
import {LectureHallService} from "@api/v1/lectureHall.service";
import {GroupService} from "@api/v1/group.service";
import {ProfessorService} from "@api/v1/professor.service";
import {AuthRoles} from "@model/AuthRoles";
import {AuthRoles} from "@model/authRoles";
import {HasRoleDirective} from "@/directives/has-role.directive";
import {TabStorageService} from "@service/tab-storage.service";
import {TabSelectType, TabStorageService} from "@service/tab-storage.service";
import {ScheduleRequest} from "@api/v1/scheduleRequest";
import {CampusService} from "@api/v1/campus.service";
import {LessonTypeService} from "@api/v1/lessonType.service";
export enum TabsSelect {
Group,
@ -35,7 +37,6 @@ export enum TabsSelect {
MatTab,
ReactiveFormsModule,
MatButton,
DataSpinnerComponent,
GroupComponent,
ProfessorComponent,
LectureHallComponent,
@ -44,88 +45,116 @@ export enum TabsSelect {
],
templateUrl: './tabs.component.html',
styleUrl: './tabs.component.css',
providers: [ScheduleService, DisciplineService, LectureHallService, GroupService, ProfessorService, TabStorageService]
providers: [
ScheduleService,
DisciplineService,
LectureHallService,
GroupService,
ProfessorService,
TabStorageService,
CampusService,
LessonTypeService]
})
export class TabsComponent implements AfterViewInit {
@Output() eventResult = new EventEmitter<[TabsSelect, number, Observable<ScheduleResponse[]>]>();
@Output() eventResult = new EventEmitter<[TabsSelect, number, Observable<ScheduleResponse[]>, ScheduleRequest]>();
private currentTab: number = -1;
constructor(private scheduleApi: ScheduleService,
private disciplineApi: DisciplineService,
private lectureApi: LectureHallService,
private groupApi: GroupService,
private professorApi: ProfessorService) {
private professorApi: ProfessorService,
private tabStorage: TabStorageService,
private campusApi: CampusService,
private lessonTypeApi: LessonTypeService) {
}
ngAfterViewInit(): void {
this.groupTab.selectChangeEvent.subscribe(event => this.tabStorage.select(TabSelectType.group, event));
this.professorTab.selectChangeEvent.subscribe(event => this.tabStorage.select(TabSelectType.professor, event));
this.lectureHallTab.selectChangeEvent.subscribe(event => this.tabStorage.select(TabSelectType.lecture, event));
this.groupTab.eventResult.subscribe(event => this.eventResult.emit(
[
TabsSelect.Group,
event,
this.scheduleApi.getByGroup(event),
{groups: [event]}
]
));
this.professorTab.eventResult.subscribe(event => this.eventResult.emit(
[
TabsSelect.Professor,
event,
this.scheduleApi.getByProfessor(event),
{professors: [event]}
]
));
this.lectureHallTab.eventResult.subscribe(event => this.eventResult.emit(
[
TabsSelect.LectureHall,
event,
this.scheduleApi.getByLectureHall(event),
{lectureHalls: [event]}
]
));
let selected = TabStorageService.selected;
let index = 0;
if (selected !== null) {
const selectedKeys = Object.keys(selected?.selected);
if (selected.type === null) {
if (this.groupTab.existParams(selected))
if (this.groupTab.getEnclosureList().every((value, index) => value === selectedKeys[index]))
index = 0;
else if (this.professorTab.existParams(selected))
else if (this.professorTab.getEnclosureList().every((value, index) => value === selectedKeys[index]))
index = 1;
else if (this.lectureHallTab.existParams(selected))
else if (this.lectureHallTab.getEnclosureList().every((value, index) => value === selectedKeys[index]))
index = 2;
} else
index = selected.type;
}
if (index === null || index === 0)
if (index === 0)
this.chooseTabs(0).then();
else
this.tabs.selectedIndex = index;
}
protected groupSelected(id: number) {
this.eventResult.emit(
[
TabsSelect.Group,
id,
this.scheduleApi.getByGroup(id)
]
);
}
protected professorSelected(id: number) {
this.eventResult.emit(
[
TabsSelect.Professor,
id,
this.scheduleApi.getByProfessor(id)
]
);
}
protected lectureHallSelected(id: number) {
this.eventResult.emit(
[
TabsSelect.LectureHall,
id,
this.scheduleApi.getByLectureHall(id)
]
);
}
protected async chooseTabs(index: number) {
let needGetEnclosure = false;
if (this.currentTab !== index) {
this.currentTab = index;
needGetEnclosure = true;
}
switch (index) {
case 0:
this.groupTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.groupTab.getEnclosureList();
break;
case 1:
this.professorTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.professorTab.getEnclosureList();
break;
case 2:
this.lectureHallTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.lectureHallTab.getEnclosureList();
break;
case 3:
await this.loadDisciplines();
await this.loadLectureHalls();
await this.loadGroups();
await this.loadProfessors();
await this.loadLessonType();
break;
default:
await this.chooseTabs(0);
@ -143,11 +172,13 @@ export class TabsComponent implements AfterViewInit {
}
protected async loadLectureHalls() {
this.lectureApi.getLectureHalls().subscribe(data => {
this.lectureHallEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
this.campusApi.getCampus().subscribe(campus => {
this.lectureApi.getLectureHalls().subscribe(data => {
this.lectureHallEx.Data = data.map(x => ({
id: x.id,
name: x.name + ` (${campus.find(c => c.id == x.campusId)?.codeName})`
}) as SelectData);
});
});
}
@ -169,6 +200,15 @@ export class TabsComponent implements AfterViewInit {
});
}
protected async loadLessonType() {
this.lessonTypeApi.getLessonTypes().subscribe(data => {
this.lessonTypeEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
});
}
@ViewChild('groupTab') groupTab!: IScheduleTab;
@ViewChild('professorTab') professorTab!: IScheduleTab;
@ViewChild('lectureHallTab') lectureHallTab!: IScheduleTab;
@ -177,21 +217,26 @@ export class TabsComponent implements AfterViewInit {
@ViewChild('lecture') lectureHallEx!: OtherComponent;
@ViewChild('group') groupEx!: OtherComponent;
@ViewChild('professor') professorEx!: OtherComponent;
@ViewChild('lesson_type') lessonTypeEx!: OtherComponent;
@ViewChild('tabGroup') tabs!: MatTabGroup;
protected readonly AuthRoles = AuthRoles;
protected otherFilter() {
const data: ScheduleRequest = ({
groups: this.groupEx.selectedIds,
disciplines: this.disciplineEx.selectedIds,
professors: this.professorEx.selectedIds,
lectureHalls: this.lectureHallEx.selectedIds,
lessonType: this.lessonTypeEx.selectedIds
});
this.eventResult.emit(
[
TabsSelect.Other,
0,
this.scheduleApi.postSchedule(({
groups: this.groupEx.selectedIds,
disciplines: this.disciplineEx.selectedIds,
professors: this.professorEx.selectedIds,
lectureHalls: this.lectureHallEx.selectedIds
}))
this.scheduleApi.postSchedule(data),
data
]
);
}

View File

@ -1,6 +1,6 @@
import {Directive, Input, TemplateRef, ViewContainerRef} from '@angular/core';
import AuthApiService from "@api/v1/authApiService";
import {AuthRoles} from "@model/AuthRoles";
import AuthApiService from "@api/v1/authApi.service";
import {AuthRoles} from "@model/authRoles";
import {catchError, of} from "rxjs";
@Directive({
@ -13,7 +13,8 @@ export class HasRoleDirective {
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private authService: AuthApiService
) {}
) {
}
@Input() set appHasRole(role: AuthRoles) {
this.viewContainer.clear();
@ -29,6 +30,6 @@ export class HasRoleDirective {
this.viewContainer.createEmbeddedView(this.templateRef);
else
this.viewContainer.clear();
})
});
}
}

View File

@ -6,10 +6,14 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
<app-root></app-root>
</body>
</html>

View File

@ -1,6 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
import {bootstrapApplication} from '@angular/platform-browser';
import {appConfig} from './app/app.config';
import {AppComponent} from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

@ -0,0 +1,28 @@
mat-sidenav-container {
min-height: 100vh;
}
mat-sidenav {
width: auto;
min-width: 200px;
max-width: 20vw;
}
mat-nav-list a {
display: flex;
align-items: center;
text-decoration: none;
padding: 10px 16px;
border-radius: 4px;
mat-icon {
margin-right: 16px;
font-size: 24px;
vertical-align: middle;
line-height: 1;
}
}
.active-link {
backdrop-filter: contrast(75%);
}

View File

@ -0,0 +1,21 @@
<mat-card *appHasRole="AuthRoles.Admin">
<mat-sidenav-container>
<mat-sidenav mode="side" opened>
<mat-nav-list>
@for (link of navLinks; track $index) {
<a
mat-list-item
[class.active-link]="isActive(link.route)"
(click)="navigate(link.route)"
[disabled]="isActive(link.route)">
<mat-icon>{{ link.icon }}</mat-icon>
<span>{{ link.label }}</span>
</a>
}
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content>
<router-outlet></router-outlet>
</mat-sidenav-content>
</mat-sidenav-container>
</mat-card>

View File

@ -0,0 +1,59 @@
import {Component} from '@angular/core';
import {MatCard} from "@angular/material/card";
import {MatSidenavModule} from "@angular/material/sidenav";
import {HasRoleDirective} from "@/directives/has-role.directive";
import {Router, RouterOutlet} from "@angular/router";
import AuthApiService from "@api/v1/authApi.service";
import {MatListItem, MatNavList} from "@angular/material/list";
import {AuthRoles} from "@model/authRoles";
import {MatIcon} from "@angular/material/icon";
@Component({
selector: 'app-admin',
standalone: true,
imports: [
MatCard,
HasRoleDirective,
MatNavList,
MatSidenavModule,
RouterOutlet,
MatListItem,
MatIcon,
],
templateUrl: './admin.component.html',
styleUrl: './admin.component.css',
providers: [AuthApiService]
})
export class AdminComponent {
navLinks = [
{label: 'Расписание', route: 'schedule', icon: 'calendar_month'},
{label: 'Институт', route: 'institute', icon: 'school'},
{label: 'Аккаунт', route: 'account', icon: 'person'},
{label: 'Сервер', route: 'server', icon: 'settings'},
];
constructor(private auth: AuthApiService, private router: Router) {
this.auth.getRole()
.subscribe(data => {
if (data === null)
router.navigate(['login']).then();
});
}
isActive(route: string): boolean {
return this.router.isActive(`/admin/${route}`, {
paths: 'exact',
queryParams: 'ignored',
fragment: 'ignored',
matrixParams: 'ignored',
});
}
navigate(route: string): void {
if (!this.isActive(route)) {
this.router.navigate([`/admin/${route}`]).then();
}
}
protected readonly AuthRoles = AuthRoles;
}

View File

@ -0,0 +1,22 @@
/* Основной контейнер */
.container {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.container > * {
flex: 1 1 calc(50% - 16px);
min-width: 0;
}
.container > :first-child:nth-last-child(1),
.container > :first-child:nth-last-child(1) ~ * {
flex: 1 1 100%;
}
@media (max-width: 1500px) {
.container > * {
flex: 1 1 100%;
}
}

View File

@ -0,0 +1,10 @@
<div>
<h2 style="margin: 15px;">Конфигурация расписания</h2>
<div class="container">
<app-term-start-date></app-term-start-date>
<app-schedule-file-upload></app-schedule-file-upload>
<app-cron-update-schedule></app-cron-update-schedule>
<app-skip-update-schedule></app-skip-update-schedule>
</div>
</div>

View File

@ -0,0 +1,20 @@
import { Component } from '@angular/core';
import {CronUpdateScheduleComponent} from "@component/admin/cron-update-schedule/cron-update-schedule.component";
import {SkipUpdateScheduleComponent} from "@component/admin/skip-update-schedule/skip-update-schedule.component";
import {TermStartDateComponent} from "@component/admin/term-start-date/term-start-date.component";
import {ScheduleFileUploadComponent} from "@component/admin/schedule-file-upload/schedule-file-upload.component";
@Component({
selector: 'app-schedule-configuration',
imports: [
CronUpdateScheduleComponent,
SkipUpdateScheduleComponent,
TermStartDateComponent,
ScheduleFileUploadComponent
],
templateUrl: './schedule-configuration.component.html',
styleUrl: './schedule-configuration.component.css'
})
export class ScheduleConfigurationComponent {
}

View File

@ -0,0 +1,14 @@
.under-construction {
text-align: center;
margin-top: 50px;
}
.under-construction h1 {
font-size: 2rem;
margin-bottom: 16px;
}
.under-construction p {
font-size: 1.2rem;
color: rgba(255, 255, 255, 0.7);
}

View File

@ -0,0 +1,4 @@
<div class="under-construction">
<h1>Страница находится в разработке</h1>
<p>Пожалуйста, зайдите позже.</p>
</div>

View File

@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-under-construction',
imports: [],
templateUrl: './under-construction.component.html',
styleUrl: './under-construction.component.css'
})
export class UnderConstructionComponent {
}

View File

@ -17,7 +17,7 @@
.formLogin form {
display: flex;
flex-direction:column;
flex-direction: column;
}
.formLoginButton {

View File

@ -1,63 +1,56 @@
<mat-sidenav-container class="formLogin">
<mat-card>
<p class="mat-h3">
Вход в систему
</p>
<form [formGroup]="loginForm">
<mat-form-field color="accent">
<mat-label>Имя пользователя/email</mat-label>
<input matInput
formControlName="user"
matTooltip='Укажите имя пользователя используя латинские буквы и цифры без пробелов или email'
required
focusNext="passwordNextFocus">
@if (!requiresTwoFactorAuth) {
<mat-form-field color="accent">
<mat-label>Имя пользователя/email</mat-label>
<input matInput
formControlName="user"
matTooltip='Укажите имя пользователя используя латинские буквы и цифры без пробелов или email'
required
focusNext="passwordNextFocus">
@if (loginForm.get('user')?.hasError('required')) {
<mat-error>
Имя пользователя или email является <i>обязательным</i>
</mat-error>
}
@if (loginForm.get('user')?.hasError('required')) {
<mat-error>
Имя пользователя или email является <i>обязательным</i>
</mat-error>
}
@if (loginForm.get('user')?.hasError('minlength')) {
<mat-error>
Количество символов должно быть не менее 4
</mat-error>
}
</mat-form-field>
@if (loginForm.get('user')?.hasError('minlength')) {
<mat-error>
Количество символов должно быть не менее 4
</mat-error>
}
</mat-form-field>
<mat-form-field color="accent" style="margin-bottom: 20px">
<mat-label>Пароль</mat-label>
<input matInput
matTooltip="Укажите пароль"
formControlName="password"
required
[type]="hidePass ? 'password' : 'text'"
id="passwordNextFocus"
focusNext="loginNextFocus">
<button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'"
[attr.aria-pressed]="hidePass">
<mat-icon>{{ hidePass ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
@if (loginForm.get('password')?.hasError('required')) {
<mat-error>
Пароль является <i>обязательным</i>
</mat-error>
}
@if (loginForm.get('password')?.hasError('minlength')) {
<mat-error>
Пароль должен быть не менее 8 символов
</mat-error>
}
</mat-form-field>
<password-input [focusNext]="'loginNextFocus'" [formGroup]="loginForm"/>
} @else {
<mat-form-field color="accent">
<mat-label>Код 2FA</mat-label>
<input matInput
formControlName="twoFactorCode"
matTooltip="Введите код из приложения"
required
focusNext="loginNextFocus">
@if (loginForm.get('twoFactorCode')?.hasError('required')) {
<mat-error>
Код 2FA обязателен.
</mat-error>
}
</mat-form-field>
}
</form>
@if (!requiresTwoFactorAuth) {
<OAuthProviders (oAuthLoginResult)="loginOAuth($event)"/>
}
<mat-error>
{{errorText}}
{{ errorText }}
</mat-error>
<div class="formLoginButton">
@ -66,7 +59,7 @@
} @else {
<button mat-flat-button color="accent"
[disabled]="loginButtonIsDisable"
(click)="login()"
(click)="requiresTwoFactorAuth ? login2Fa() : login()"
id="loginNextFocus">
Войти
</button>

View File

@ -3,15 +3,17 @@ import {MatSidenavContainer} from "@angular/material/sidenav";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {MatIcon} from "@angular/material/icon";
import {MatButton, MatIconButton} from "@angular/material/button";
import {MatButton} from "@angular/material/button";
import {MatCard} from "@angular/material/card";
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {FocusNextDirective} from "@/directives/focus-next.directive";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import AuthApiService from "@api/v1/authApiService";
import AuthApiService from "@api/v1/authApi.service";
import {Router} from "@angular/router";
import {catchError} from "rxjs";
import {TwoFactorAuthentication} from "@model/twoFactorAuthentication";
import {PasswordInputComponent} from "@component/common/password-input/password-input.component";
import {OAuthProviders} from "@component/OAuthProviders/OAuthProviders";
@Component({
selector: 'app-login',
@ -21,13 +23,13 @@ import {catchError} from "rxjs";
MatFormFieldModule,
MatInput,
MatTooltip,
MatIcon,
MatIconButton,
MatButton,
MatCard,
ReactiveFormsModule,
FocusNextDirective,
DataSpinnerComponent
DataSpinnerComponent,
PasswordInputComponent,
OAuthProviders
],
templateUrl: './login.component.html',
styleUrl: './login.component.css',
@ -35,10 +37,10 @@ import {catchError} from "rxjs";
})
export class LoginComponent {
protected loginForm!: FormGroup;
protected hidePass: boolean = true;
protected loaderActive: boolean = false;
protected loginButtonIsDisable: boolean = true;
protected errorText: string = '';
protected requiresTwoFactorAuth: boolean = false;
constructor(private formBuilder: FormBuilder, private auth: AuthApiService, private router: Router) {
this.auth.getRole()
@ -68,9 +70,20 @@ export class LoginComponent {
});
}
protected togglePassword(event: MouseEvent) {
this.hidePass = !this.hidePass;
event.stopPropagation();
private updateTwoFactorValidation(data: TwoFactorAuthentication) {
if (data === TwoFactorAuthentication.None) {
this.router.navigate(['admin']).then();
return;
}
this.requiresTwoFactorAuth = true;
this.loginForm.addControl(
'twoFactorCode',
new FormControl('', Validators.required)
);
this.loginForm.removeControl('user');
this.loginForm.removeControl('password');
this.loginButtonIsDisable = !this.loginForm.valid;
}
protected login() {
@ -82,7 +95,27 @@ export class LoginComponent {
})
.pipe(catchError(error => {
this.loaderActive = false;
this.errorText = error.error;
this.errorText = error.error.detail;
this.loginButtonIsDisable = true;
throw error;
}))
.subscribe(x => {
this.loaderActive = false;
this.errorText = '';
this.updateTwoFactorValidation(x);
});
}
protected login2Fa() {
this.loaderActive = true;
this.auth.twoFactorAuth({
code: this.loginForm.get('twoFactorCode')?.value,
method: TwoFactorAuthentication.TotpRequired
})
.pipe(catchError(error => {
this.loaderActive = false;
this.errorText = error.error.detail;
this.loginButtonIsDisable = true;
throw error;
}))
@ -92,4 +125,8 @@ export class LoginComponent {
this.router.navigate(['admin']).then();
});
}
protected loginOAuth(result: TwoFactorAuthentication) {
this.updateTwoFactorValidation(result);
}
}

View File

@ -0,0 +1,8 @@
<h1 mat-dialog-title>Подтверждение</h1>
<div mat-dialog-content>
<p>Вы уверены, что хотите запросить Excel с выбранными данными?</p>
</div>
<div mat-dialog-actions>
<button mat-button color="accent" (click)="onConfirm()">Запросить</button>
<button mat-button (click)="onCancel()">Отмена</button>
</div>

View File

@ -0,0 +1,28 @@
import {Component} from '@angular/core';
import {MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle} from '@angular/material/dialog';
import {MatButton} from "@angular/material/button";
@Component({
selector: 'app-confirm-dialog',
templateUrl: './confirm-dialog.component.html',
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatButton
],
standalone: true
})
export class ConfirmDialogComponent {
constructor(public dialogRef: MatDialogRef<ConfirmDialogComponent>) {
}
protected onConfirm(): void {
this.dialogRef.close(true);
}
protected onCancel(): void {
this.dialogRef.close(false);
}
}

View File

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

View File

@ -1,55 +1,65 @@
import {Component, LOCALE_ID, OnInit, ViewChild} from '@angular/core';
import {TableComponent} from "@component/schedule/table/table.component";
import {MatFormField, MatInput} from "@angular/material/input";
import {MatButton} from "@angular/material/button";
import {FormsModule} from "@angular/forms";
import {Component, ViewChild} from '@angular/core';
import {AdditionalText, TableHeaderComponent} from "@component/schedule/table-header/table-header.component";
import {addDays, weekInYear} from "@progress/kendo-date-math";
import {MatCard} from "@angular/material/card";
import {MatSidenavModule} from "@angular/material/sidenav";
import {TabsComponent, TabsSelect} from "@component/schedule/tabs/tabs.component";
import {catchError, Observable} from "rxjs";
import {ScheduleService} from "@api/v1/schedule.service";
import {ScheduleResponse} from "@api/v1/scheduleResponse";
import {PeriodTimes} from "@model/pairPeriodTime";
import {MatCheckbox} from "@angular/material/checkbox";
import {PairPeriodTime} from "@model/pairPeriodTime";
import {ActivatedRoute} from "@angular/router";
import {TabStorageService} from "@service/tab-storage.service";
import {MatDialog} from "@angular/material/dialog";
import {ConfirmDialogComponent} from "@page/schedule/confirm-dialog.component";
import {AuthRoles} from "@model/authRoles";
import {ImportService} from "@api/v1/import.service";
import {ScheduleRequest} from "@api/v1/scheduleRequest";
import {ToastrService} from "ngx-toastr";
import {MatSidenavModule} from "@angular/material/sidenav";
import {TableComponent} from "@component/schedule/table/table.component";
import {MatCheckbox} from "@angular/material/checkbox";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {MatButton} from "@angular/material/button";
import {HasRoleDirective} from "@/directives/has-role.directive";
@Component({
selector: 'app-schedule',
standalone: true,
imports: [
TableComponent,
MatInput,
MatFormField,
MatButton,
FormsModule,
TableHeaderComponent,
MatCard,
MatSidenavModule,
TabsComponent,
MatCheckbox
TableHeaderComponent,
TableComponent,
MatCheckbox,
DataSpinnerComponent,
MatButton,
HasRoleDirective
],
templateUrl: './schedule.component.html',
styleUrl: './schedule.component.css',
providers: [
ScheduleService,
{provide: LOCALE_ID, useValue: 'ru-RU'}
ImportService
]
})
export class ScheduleComponent implements OnInit {
export class ScheduleComponent {
private lastRequest: ScheduleRequest | null = null;
protected startWeek: Date;
protected data: ScheduleResponse[] = [];
protected startTerm: Date;
protected isLoadTable: boolean = false;
protected pairPeriods: PeriodTimes = {};
protected pairPeriods: PairPeriodTime | null = null;
protected disciplineWithWeeks: boolean = false;
protected excelImportLoader: boolean = false;
@ViewChild('tableHeader') childComponent!: TableHeaderComponent;
constructor(api: ScheduleService, route: ActivatedRoute) {
constructor(api: ScheduleService,
route: ActivatedRoute,
private importApi: ImportService,
private notify: ToastrService,
public dialog: MatDialog) {
route.queryParams.subscribe(params => {
TabStorageService.selectDataFromQuery(params);
});
@ -71,11 +81,10 @@ export class ScheduleComponent implements OnInit {
});
}
ngOnInit(): void {
}
protected result(data: [TabsSelect, number, Observable<ScheduleResponse[]>]) {
protected result(data: [TabsSelect, number, Observable<ScheduleResponse[]>, ScheduleRequest]) {
this.isLoadTable = true;
this.lastRequest = data[3];
data[2]
.pipe(catchError(error => {
this.data = [];
@ -124,10 +133,10 @@ export class ScheduleComponent implements OnInit {
this.startWeek = this.startTerm;
}
protected handleWeekEvent(eventData: boolean | null) {
if (eventData === null) {
protected handleWeekEvent(forward: boolean | null) {
if (forward === null) {
this.calculateCurrentWeek();
} else if (eventData) {
} else if (forward) {
this.startWeek = addDays(this.startWeek, 7);
} else {
this.startWeek = addDays(this.startWeek, -7);
@ -135,7 +144,16 @@ export class ScheduleComponent implements OnInit {
}
get currentWeek(): number {
let result = (weekInYear(this.startWeek) - weekInYear(this.startTerm)) + 1;
const startTermWeek = weekInYear(this.startTerm);
let startWeekNumber;
const startWeek = addDays(this.startWeek, 6);
if (startWeek.getFullYear() > this.startTerm.getFullYear())
startWeekNumber = weekInYear(new Date(this.startTerm.getFullYear(), 11, 29)) + weekInYear(startWeek);
else
startWeekNumber = weekInYear(startWeek);
let result = startWeekNumber - startTermWeek + 1;
if (result <= 0)
result = 1;
@ -147,4 +165,35 @@ export class ScheduleComponent implements OnInit {
localStorage.setItem('disciplineWithWeeks', checked.toString());
this.disciplineWithWeeks = checked;
}
protected openDialog() {
if (this.lastRequest == null) {
this.notify.error("Запрос на импорт невозможен, поскольку данные таблицы не были выбраны", "Ошибка импорта");
return;
}
const dialogRef = this.dialog.open(ConfirmDialogComponent);
dialogRef.afterClosed().subscribe(result => {
if (result && this.lastRequest != null) {
this.excelImportLoader = true;
this.importApi.importToExcel(this.lastRequest).subscribe({
next: (blob: Blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'schedule.xlsx';
a.click();
window.URL.revokeObjectURL(url);
this.excelImportLoader = false;
},
error: _ => {
this.excelImportLoader = false;
this.notify.error("Не удалось импортировать файл Excel");
}
});
}
});
}
protected readonly AuthRoles = AuthRoles;
}

View File

@ -15,7 +15,7 @@
<mat-form-field color="accent">
<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="memcached">Memcached</mat-option>
</mat-select>
@ -29,7 +29,8 @@
<input matInput
matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6'
required
formControlName="server">
formControlName="server"
focusNext="serverNextFocus">
@if (databaseForm.get('server')?.hasError('required')) {
<mat-error>
@ -49,7 +50,9 @@
<input matInput
matTooltip="Укажите порт сервера"
required
formControlName="port">
formControlName="port"
id="serverNextFocus"
focusNext="passwordNextFocus">
@if (databaseForm.get('port')?.hasError('required')) {
<mat-error>
@ -69,7 +72,9 @@
<input matInput
matTooltip="Укажите пароль"
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'"
[attr.aria-pressed]="hidePass">

View File

@ -8,6 +8,9 @@ import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import {of} from "rxjs";
import {CacheType} from "@model/cacheType";
import {FocusNextDirective} from "@/directives/focus-next.directive";
@Component({
selector: 'app-cache',
@ -19,7 +22,8 @@ import {MatIcon} from "@angular/material/icon";
MatInput,
MatTooltip,
MatIconButton,
MatIcon
MatIcon,
FocusNextDirective
],
templateUrl: './cache.component.html'
})
@ -40,6 +44,35 @@ export class CacheComponent {
this.databaseForm.valueChanges.subscribe(() => {
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) {

View File

@ -28,7 +28,7 @@
@if (createAdminForm.get('user')?.hasError('pattern')) {
<mat-error>
Имя пользователя должен содержать латинские сиволы и цифры и быть не менее 4 символов
Имя пользователя должен содержать латинские символы и цифры и быть не менее 4 символов
</mat-error>
}
</mat-form-field>
@ -53,37 +53,7 @@
}
</mat-form-field>
<mat-form-field color="accent" style="margin-bottom: 20px">
<mat-label>Пароль</mat-label>
<input matInput
matTooltip="Укажите пароль"
formControlName="password"
required
[type]="hidePass ? 'password' : 'text'">
<button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'"
[attr.aria-pressed]="hidePass">
<mat-icon>{{ hidePass ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
@if (createAdminForm.get('password')?.hasError('required')) {
<mat-error>
Пароль является <i>обязательным</i>
</mat-error>
}
@if (createAdminForm.get('password')?.hasError('minlength')) {
<mat-error>
Пароль должен быть не менее 8 символов
</mat-error>
}
@if (createAdminForm.get('password')?.hasError('pattern')) {
<mat-error>
Пароль должен содержать хотя бы один латинский символ верхнего регистра и специальный символ (!&#x40;#$%^&*)
</mat-error>
}
</mat-form-field>
<password-input [formGroup]="createAdminForm" [isSetupMode]="true"/>
<mat-form-field color="accent">
<mat-label>Повторите пароль</mat-label>
@ -105,5 +75,10 @@
</mat-error>
}
</mat-form-field>
<OAuthProviders [canUnlink]="true" [activeProvidersId]="activatedProviders"
(oAuthUpdateProviders)="updateProviders()"
[message]="'Или можете получить часть данных от сторонних сервисов'"
[action]="OAuthAction.Bind" [isSetup]="true"/>
</div>
</form>

View File

@ -1,4 +1,5 @@
import {Component} from '@angular/core';
import {Location} from '@angular/common';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {NavigationService} from "@service/navigation.service";
import {passwordMatchValidator} from '@service/password-match.validator';
@ -9,6 +10,12 @@ import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import AuthApiService from "@api/v1/authApi.service";
import {OAuthProviders} from "@component/OAuthProviders/OAuthProviders";
import {OAuthProvider} from "@model/oAuthProvider";
import {PasswordInputComponent} from "@component/common/password-input/password-input.component";
import {OAuthAction} from "@model/oAuthAction";
import {Router} from "@angular/router";
@Component({
selector: 'app-create-admin',
@ -20,18 +27,22 @@ import {MatIcon} from "@angular/material/icon";
MatInput,
MatTooltip,
MatIconButton,
MatIcon
MatIcon,
OAuthProviders,
PasswordInputComponent
],
templateUrl: './create-admin.component.html'
templateUrl: './create-admin.component.html',
providers: [AuthApiService, Location]
})
export class CreateAdminComponent {
protected createAdminForm!: FormGroup;
protected hidePass = true;
protected hideRetypePass = true;
protected activatedProviders: OAuthProvider[] = [];
constructor(
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
constructor(private router: Router,
private location: Location,
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
this.createAdminForm = this.formBuilder.group({
user: ['', Validators.pattern(/^([A-Za-z0-9]){4,}$/)],
email: ['', Validators.email],
@ -41,12 +52,6 @@ export class CreateAdminComponent {
{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.createAdminForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.createAdminForm.valid);
@ -60,15 +65,35 @@ export class CreateAdminComponent {
}
);
};
this.updateAdminData();
}
protected togglePassword(event: MouseEvent) {
this.hidePass = !this.hidePass;
event.stopPropagation();
private updateAdminData() {
this.api.adminConfiguration().subscribe(configuration => {
if (configuration) {
if (this.createAdminForm.get('email')?.value == 0)
this.createAdminForm.get('email')?.setValue(configuration.email);
if (this.createAdminForm.get('user')?.value == 0)
this.createAdminForm.get('user')?.setValue(configuration.username);
this.activatedProviders = configuration.usedOAuthProviders;
}
const currentPath = this.router.url.split('?')[0];
this.location.replaceState(currentPath);
});
}
protected toggleRetypePassword(event: MouseEvent) {
this.hideRetypePass = !this.hideRetypePass;
event.stopPropagation();
}
protected updateProviders() {
this.updateAdminData();
}
protected readonly OAuthAction = OAuthAction;
}

View File

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

View File

@ -2,14 +2,17 @@ import {Component} from '@angular/core';
import {NavigationService} from "@service/navigation.service";
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import SetupService from "@api/v1/setup.service";
import {DatabaseRequest} from "@api/v1/databaseRequest";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select";
import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
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 {DatabaseRequest} from "@api/v1/configuration/databaseRequest";
import {of} from "rxjs";
import {DatabaseType} from "@model/databaseType";
import {FocusNextDirective} from "@/directives/focus-next.directive";
@Component({
selector: 'app-database',
@ -22,7 +25,8 @@ import {MatCheckbox} from "@angular/material/checkbox";
MatTooltip,
MatIconButton,
MatIcon,
MatCheckbox
MatCheckbox,
FocusNextDirective
],
templateUrl: './database.component.html'
})
@ -49,6 +53,42 @@ export class DatabaseComponent {
this.databaseForm.valueChanges.subscribe(() => {
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) {

View File

@ -4,6 +4,11 @@
Настройте систему логирования как будет удобно для отображения.
Можно настроить путь к файлу, имена файлов или вовсе отключить логирование в файл.
</p>
<p class="mat-body-2 secondary">
Также вы можете настроить интеграцию с Seq.
Введите необходимые данные и мы отправим тестовый лог на сервер Seq. Его уровень будет Warning.
Если тестовый лог не появился вернитесь на данный шаг и перепроверьте данные.
</p>
<form [formGroup]="loggingSettings">
<p>
@ -31,9 +36,18 @@
matTooltip="Укажите название файла, в который будут записаны логи"
formControlName="logName">
</mat-form-field>
<mat-form-field color="accent">
<mat-label>Сервер Seq</mat-label>
<input matInput
matTooltip="Укажите сервер Seq вначале указав схему (http/https)"
formControlName="seqServer">
</mat-form-field>
<mat-form-field color="accent">
<mat-label>Api ключ Seq</mat-label>
<input matInput
matTooltip="Укажите ключ API, который вы создали в Seq"
formControlName="seqKey">
</mat-form-field>
</div>
</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 {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {MatButton, MatIconButton} from "@angular/material/button";
import {MatCheckbox} from "@angular/material/checkbox";
import {of} from "rxjs";
@Component({
selector: 'app-logging',
@ -18,9 +18,7 @@ import {MatCheckbox} from "@angular/material/checkbox";
MatSelectModule,
MatInput,
MatTooltip,
MatIconButton,
MatCheckbox,
MatButton
MatCheckbox
],
templateUrl: './logging.component.html'
@ -39,16 +37,17 @@ export class LoggingComponent {
}
}
protected skipButton() {
this.navigationService.skipNavigation.emit(() => this.api.setLogging(null));
}
constructor(
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({
enabled: [true, Validators.required],
logPath: [''],
logName: ['']
logName: [''],
seqServer: [''],
seqKey: ['']
}
);
@ -59,11 +58,27 @@ export class LoggingComponent {
this.navigationService.nextButtonAction = () => {
return this.api.setLogging({
"enableLogToFile": this.loggingSettings.get('cron')?.value,
"logFileName": this.loggingSettings.get('logName')?.value,
"logFilePath": this.loggingSettings.get('logPath')?.value
enableLogToFile: this.loggingSettings.get('enabled')?.value,
logFileName: this.loggingSettings.get('logName')?.value,
logFilePath: this.loggingSettings.get('logPath')?.value,
apiServerSeq: this.loggingSettings.get('seqServer')?.value,
apiKeySeq: this.loggingSettings.get('seqKey')?.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);
this.loggingSettings.get('seqServer')?.setValue(x.apiServerSeq);
this.loggingSettings.get('seqKey')?.setValue(x.apiKeySeq);
});
}
}

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 {MatInput} from "@angular/material/input";
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 {DateOnly} from "@model/dateOnly";
import {of} from "rxjs";
@Component({
selector: 'app-schedule-conf',
@ -20,8 +20,6 @@ import {MatDatepickerModule} from "@angular/material/datepicker";
MatSelectModule,
MatInput,
MatTooltip,
MatIconButton,
MatIcon,
MatDatepickerModule,
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>
<button mat-flat-button color="accent"
[disabled]="previousButtonDisabled"
[hidden]="previousButtonRoute === ''"
(click)="onPreviousClick()"
[routerLink]="previousButtonRoute">
[hidden]="getIndex <= 2"
(click)="onPreviousClick()">
Назад
</button>
</div>
@if (!skipButtonDisabled) {
<button mat-flat-button color="accent" (click)="onSkipClick()">Пропустить</button>
}
@if (loaderActive) {
<app-data-spinner [scale]="40"/>
} @else {
<button mat-flat-button color="accent"
[disabled]="nextButtonDisabled"
(click)="onNextClick()">
(click)="onNextClick()"
id="nextButtonFocus">
@if (getIndex === routes.length - 1) {
Завершить
} @else {

View File

@ -1,12 +1,13 @@
import {Component, ViewEncapsulation} from '@angular/core';
import {MatSidenavModule} from "@angular/material/sidenav";
import {Router, RouterLink, RouterOutlet} from "@angular/router";
import {ActivatedRoute, Router, RouterOutlet} from "@angular/router";
import {MatCard} from "@angular/material/card";
import {MatButton} from "@angular/material/button";
import {NavigationService} from "@service/navigation.service";
import {catchError, Observable} from "rxjs";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import SetupService from "@api/v1/setup.service";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-setup',
@ -16,7 +17,6 @@ import SetupService from "@api/v1/setup.service";
RouterOutlet,
MatCard,
MatButton,
RouterLink,
DataSpinnerComponent
],
templateUrl: './setup.component.html',
@ -26,76 +26,124 @@ import SetupService from "@api/v1/setup.service";
})
export class SetupComponent {
protected previousButtonDisabled: boolean = false;
protected previousButtonRoute: string = '';
protected nextButtonDisabled: boolean = false;
protected nextButtonRoute!: string;
protected skipButtonDisabled: boolean = false;
protected loaderActive: boolean = false;
protected routes: Array<string> = ['', 'welcome', 'database', 'cache', 'create-admin', 'schedule', 'logging', 'summary'];
protected routes: Array<string> = ['', 'welcome', 'create-admin', 'database', 'cache', 'password-policy', 'schedule', 'logging', 'two-factor', 'summary'];
private index: number = 1;
protected get getIndex() {
return this.index;
}
constructor(private router: Router, private navigationService: NavigationService, api: SetupService) {
constructor(private route: ActivatedRoute,
private router: Router,
private navigationService: NavigationService,
api: SetupService,
private notify: ToastrService) {
api.isConfigured().subscribe(x => {
if (x)
this.router.navigate(['/']).then();
if (x) this.router.navigate(['/']).then();
});
if (!this.router.url.includes(this.routes[this.index]))
this.router.navigate(['setup/', this.routes[this.index]]).then();
if (!this.router.url.includes(this.routes[this.index])) {
const currentQueryParams = this.route.snapshot.queryParams;
this.router.navigate(
['setup/', this.routes[this.index]],
{queryParams: currentQueryParams}
).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.nextButtonDisabled = !state;
});
this.navigationService.skipNavigation.subscribe(action => {
this.executeAction(action);
this.navigationService.skipButtonState$.subscribe(state => {
this.skipButtonDisabled = !state;
});
}
private setRoutes() {
this.previousButtonRoute = this.routes[this.index - 1];
this.nextButtonRoute = this.routes[this.index + 1];
}
private executeAction(action: () => Observable<boolean>) {
this.loaderActive = true;
action().pipe(
catchError(error => {
this.nextButtonDisabled = true;
this.loaderActive = false;
throw error;
})
)
.subscribe(x => {
this.nextButtonDisabled = x;
this.loaderActive = !x;
if (x) {
if (this.index < this.routes.length - 1) {
this.router.navigate(['setup/', this.nextButtonRoute]).then();
this.index++;
this.setRoutes();
} else
this.router.navigate(['/']).then();
action()
.pipe(
catchError(error => {
this.nextButtonDisabled = true;
this.loaderActive = false;
throw error;
})
)
.subscribe(success => {
if (success) {
this.moveToNextPage();
} else {
this.notify.error('Некорректно введены данные');
this.nextButtonDisabled = true;
}
this.loaderActive = false;
});
}
protected onSkipClick() {
this.navigationService.skipButtonAction().subscribe(success => {
if (success) {
this.moveToNextPage();
}
});
}
protected onNextClick() {
this.executeAction(this.navigationService.nextButtonAction);
}
protected onPreviousClick() {
if (this.index - 1 > 0) {
this.index--;
this.setRoutes();
this.navigationService.setUserInitiatedNavigation(true);
this.moveToPreviousPage();
}
private moveToNextPage(): void {
if (this.index < this.routes.length - 1) {
this.index++;
const currentQueryParams = this.route.snapshot.queryParams;
this.router.navigate(
['setup/', this.routes[this.index]],
{queryParams: currentQueryParams}
).then(() => {
this.initializePage();
});
} else {
const currentQueryParams = this.route.snapshot.queryParams;
this.router.navigate(
['/'],
{queryParams: currentQueryParams}
).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>
<p class="mat-body-2 secondary">
Теперь, когда основные настройки завершены, вы можете начать использовать систему с уверенностью, что все работает правильно.
Теперь, когда основные настройки завершены, вы можете начать использовать систему с уверенностью, что все работает
правильно.
</p>
<h4 style="margin-bottom: -5px;">Изменение настроек</h4>
@ -17,6 +18,183 @@
Для изменения настроек перейдите в раздел настроек программы, который доступен в меню пользователя.
</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>
}
@if (loggingConfig.apiServerSeq) {
<div>
Сервер Seq: {{ loggingConfig.apiServerSeq }}
</div>
}
@if (loggingConfig.apiKeySeq) {
<div>
Ключ Seq: {{ loggingConfig.apiKeySeq }}
</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>

View File

@ -1,26 +1,60 @@
import {Component} from '@angular/core';
import {MatButton} from "@angular/material/button";
import {Component, OnInit} from '@angular/core';
import {NavigationService} from "@service/navigation.service";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
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({
selector: 'app-summary',
standalone: true,
imports: [
MatButton,
MatFormFieldModule,
MatInput
],
imports: [MatFormFieldModule, MatExpansionModule],
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) {
this.navigationService.nextButtonAction = () => {
return this.api.submit();
};
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" alt="totp-qr-code" style="max-height: 60vh;"/>
</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 приложение и выполните:
</p>
<code>
curl -X 'GET' '{{apiToGetToken}}/Setup/GenerateToken' -H 'accept: application/json'
curl -X 'GET' '{{ apiToGetToken }}/Setup/GenerateToken' -H 'accept: application/json'
</code>
<p>
Или
</p>
<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>
<div style="display: flex; flex-direction: column; margin: 25px 0;">

View File

@ -1,22 +1,19 @@
import {Component} from '@angular/core';
import {MatButton} from "@angular/material/button";
import {NavigationService} from "@service/navigation.service";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import {AsyncPipe} from "@angular/common";
import {FormControl, ReactiveFormsModule, Validators} from "@angular/forms";
import SetupService from "@api/v1/setup.service";
import {environment} from "@environment";
import {AvailableVersion} from "@api/api.service";
import {of} from "rxjs";
@Component({
selector: 'app-welcome',
standalone: true,
imports: [
MatButton,
MatFormFieldModule,
MatInput,
AsyncPipe,
ReactiveFormsModule
],
templateUrl: './welcome.component.html'
@ -33,13 +30,21 @@ export class WelcomeComponent {
constructor(private navigationService: NavigationService, private api: SetupService) {
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.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,83 +0,0 @@
import {Injectable} from '@angular/core';
import {HttpClient, HttpHeaders} from "@angular/common/http";
import {map, Observable, throwError} from "rxjs";
import {TokenResponse} from "@api/v1/tokenResponse";
import ApiService from "@api/api.service";
export enum AvailableAuthenticationProvider {
Bearer
}
export class AuthToken {
accessToken: string;
expiresIn: Date;
authProvider: AvailableAuthenticationProvider;
endpoint: string;
constructor(accessToken: string, expiresIn: Date, authProvider: AvailableAuthenticationProvider, refreshEndpoint: string) {
this.accessToken = accessToken;
this.expiresIn = expiresIn;
this.authProvider = authProvider;
this.endpoint = refreshEndpoint;
}
public static httpHeader(token: AuthToken): HttpHeaders {
let header = new HttpHeaders();
if (token.authProvider === AvailableAuthenticationProvider.Bearer)
header = header.set('Authorization', `Bearer ${token.accessToken}`);
return header;
}
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
constructor(private http: HttpClient) {
}
public static setToken(token: TokenResponse, provider: AvailableAuthenticationProvider, refreshEndpoint: string) {
localStorage.setItem(ApiService.tokenKey, JSON.stringify(
new AuthToken(token.accessToken, token.expiresIn, provider, refreshEndpoint)
));
}
public static get tokenExpiresIn(): Date {
const token = localStorage.getItem(ApiService.tokenKey);
if (!token)
return new Date();
const result = new Date((JSON.parse(token) as AuthToken).expiresIn);
return result <= new Date() ? new Date() : result;
}
public refreshToken(): Observable<Date> {
const token = localStorage.getItem(ApiService.tokenKey);
if (!token)
return throwError(() => new Error("Token is not found"));
const authToken = JSON.parse(token) as AuthToken;
switch (authToken.authProvider) {
case AvailableAuthenticationProvider.Bearer:
return this.http.get<TokenResponse>(authToken.endpoint, {withCredentials: true})
.pipe(
map(response => {
const newExpireDate = new Date(response.expiresIn);
const oldExpireDate = new Date(authToken.expiresIn);
if (newExpireDate.getTime() !== oldExpireDate.getTime()) {
AuthService.setToken(response, AvailableAuthenticationProvider.Bearer, authToken.endpoint);
return newExpireDate;
}
return newExpireDate;
})
);
}
}
}

View File

@ -1,18 +1,44 @@
import {EventEmitter, Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from "rxjs";
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable, Subject} from "rxjs";
@Injectable({
providedIn: 'root'
})
export class NavigationService {
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();
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) {
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);
}
}

View File

@ -1,59 +0,0 @@
import {Injectable} from '@angular/core';
import {MatSnackBar} from '@angular/material/snack-bar';
import {NotificationComponent} from "@component/common/notification/notification.component";
export enum NotifyColor {
Basic,
Warn,
Danger,
Success
}
@Injectable({
providedIn: 'root'
})
export class OpenNotifyService {
constructor(private _snackBar: MatSnackBar) {
}
colorClass: string = '';
set setColorClass(color: NotifyColor) {
switch (color) {
case NotifyColor.Warn: {
this.colorClass = 'yellow-snackbar';
}
break;
case NotifyColor.Danger: {
this.colorClass = 'red-snackbar';
}
break;
case NotifyColor.Success: {
this.colorClass = 'green-snackbar';
}
break;
default: {
this.colorClass = 'snackbar';
}
break;
}
}
open(message: string, color: NotifyColor = NotifyColor.Basic, duration: number = 5000) {
this.setColorClass = color;
this._snackBar.openFromComponent(NotificationComponent, {
data: {
message: message,
duration: duration,
className: this.colorClass,
color: color === NotifyColor.Danger ? "accent" : "primary"
},
duration: duration,
verticalPosition: 'top',
horizontalPosition: 'center',
panelClass: [this.colorClass]
})
;
}
}

View File

@ -30,6 +30,7 @@ export interface TabSelectData {
})
export class TabStorageService {
private static dataName = 'tabSelectedData';
private _enclosure: string[] = [];
constructor(private router: Router, private location: Location) {
}
@ -52,27 +53,18 @@ export class TabStorageService {
}, 100);
}
private resetIfNeed(type: TabSelectType) {
let selectedData = TabStorageService.selected;
if (selectedData === null || selectedData?.type === null)
return;
if (selectedData.type !== type) {
localStorage.removeItem(TabStorageService.dataName);
const currentUrl = this.router.url.split('?')[0];
this.location.replaceState(currentUrl);
}
public set enclosure(data: string[]) {
this._enclosure = data;
}
public select(selected: TabSelect, type: TabSelectType, navigateName: string) {
this.resetIfNeed(type);
let selectedData = TabStorageService.selected;
public select(type: TabSelectType, tabSelected: TabSelect[]) {
let selectedData = {selected: {}} as TabSelectData;
if (selectedData === null || !selectedData.selected)
selectedData = {selected: {}} as TabSelectData;
for (let index = 0; index < this._enclosure.length; index++) {
if (tabSelected[index])
selectedData.selected[this._enclosure[index]] = tabSelected[index];
}
selectedData.selected[navigateName] = selected;
selectedData.type = type;
localStorage.setItem(TabStorageService.dataName, JSON.stringify(selectedData));

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